diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e0b02364a830..9dab4e616b0b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,8 +45,8 @@ jobs: - name: Ensure Electron runtime is installed run: vp run --filter @t3tools/desktop ensure:electron - # Export cleanup is still a manual audit; files and dependencies have no baseline. - - name: Check unused files and dependencies + # Files/dependencies are repo-wide; export checks cover clean workspaces only. + - name: Check unused code run: vp run knip:check - name: Check diff --git a/apps/desktop/src/backend/DesktopServerExposure.test.ts b/apps/desktop/src/backend/DesktopServerExposure.test.ts index dcfee93778d1..4a8b516cb936 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.test.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.test.ts @@ -272,8 +272,6 @@ describe("DesktopServerExposure", () => { modeError, DesktopServerExposure.DesktopServerExposureModePersistenceError, ); - assert.isTrue(DesktopServerExposure.isDesktopServerExposureSetModeError(modeError)); - assert.isTrue(DesktopServerExposure.isDesktopServerExposureError(modeError)); assert.equal(modeError.mode, "network-accessible"); assert.strictEqual(modeError.cause, settingsFailure); assert.strictEqual(modeError.cause.cause, diskFailure); @@ -290,7 +288,6 @@ describe("DesktopServerExposure", () => { tailscaleError, DesktopServerExposure.DesktopTailscaleServePersistenceError, ); - assert.isTrue(DesktopServerExposure.isDesktopServerExposureError(tailscaleError)); assert.equal(tailscaleError.enabled, true); assert.equal(tailscaleError.port, 8443); assert.strictEqual(tailscaleError.cause, settingsFailure); diff --git a/apps/desktop/src/backend/DesktopServerExposure.ts b/apps/desktop/src/backend/DesktopServerExposure.ts index f04d2af7b1f6..6c3cd55527eb 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.ts @@ -244,7 +244,6 @@ export const DesktopServerExposureSetModeError = Schema.Union([ DesktopServerExposureModePersistenceError, ]); export type DesktopServerExposureSetModeError = typeof DesktopServerExposureSetModeError.Type; -export const isDesktopServerExposureSetModeError = Schema.is(DesktopServerExposureSetModeError); export const DesktopServerExposureError = Schema.Union([ DesktopServerExposureNoNetworkAddressError, @@ -252,7 +251,6 @@ export const DesktopServerExposureError = Schema.Union([ DesktopTailscaleServePersistenceError, ]); export type DesktopServerExposureError = typeof DesktopServerExposureError.Type; -export const isDesktopServerExposureError = Schema.is(DesktopServerExposureError); export interface DesktopServerExposureBackendConfig { readonly port: number; diff --git a/apps/desktop/src/electron/ElectronDialog.test.ts b/apps/desktop/src/electron/ElectronDialog.test.ts index ac42e0914d0c..8d27553dc9a8 100644 --- a/apps/desktop/src/electron/ElectronDialog.test.ts +++ b/apps/desktop/src/electron/ElectronDialog.test.ts @@ -114,7 +114,6 @@ describe("ElectronDialog", () => { ); assert.instanceOf(error, ElectronDialog.ElectronDialogPickFolderError); - assert.isTrue(ElectronDialog.isElectronDialogError(error)); assert.strictEqual(error.ownerWindowId, 7); assert.strictEqual(error.defaultPath, "/workspace"); assert.strictEqual(error.cause, cause); diff --git a/apps/desktop/src/electron/ElectronDialog.ts b/apps/desktop/src/electron/ElectronDialog.ts index a772f1b1c8c4..cde67b099cea 100644 --- a/apps/desktop/src/electron/ElectronDialog.ts +++ b/apps/desktop/src/electron/ElectronDialog.ts @@ -92,7 +92,6 @@ export const ElectronDialogError = Schema.Union([ ElectronDialogShowErrorBoxError, ]); export type ElectronDialogError = typeof ElectronDialogError.Type; -export const isElectronDialogError = Schema.is(ElectronDialogError); export interface ElectronDialogPickFolderInput { readonly owner: Option.Option; diff --git a/apps/desktop/src/electron/ElectronTheme.test.ts b/apps/desktop/src/electron/ElectronTheme.test.ts index 4b81943eff2b..b4028930af66 100644 --- a/apps/desktop/src/electron/ElectronTheme.test.ts +++ b/apps/desktop/src/electron/ElectronTheme.test.ts @@ -64,7 +64,6 @@ describe("ElectronTheme", () => { const error = yield* Effect.flip(electronTheme.setSource("dark")); assert.instanceOf(error, ElectronTheme.ElectronThemeSetSourceError); - assert.isTrue(ElectronTheme.isElectronThemeSetSourceError(error)); assert.strictEqual(error.source, "dark"); assert.strictEqual(error.cause, cause); assert.include(error.message, "dark"); diff --git a/apps/desktop/src/electron/ElectronTheme.ts b/apps/desktop/src/electron/ElectronTheme.ts index ef47e3d0954f..24b2d856b9d2 100644 --- a/apps/desktop/src/electron/ElectronTheme.ts +++ b/apps/desktop/src/electron/ElectronTheme.ts @@ -19,8 +19,6 @@ export class ElectronThemeSetSourceError extends Schema.TaggedErrorClass { const error = yield* updater.checkForUpdates.pipe(Effect.flip); assert.instanceOf(error, ElectronUpdater.ElectronUpdaterCheckForUpdatesError); - assert.isTrue(ElectronUpdater.isElectronUpdaterError(error)); assert.equal(error.channel, "beta"); assert.strictEqual(error.cause, cause); assert.equal(error.message, "Electron updater failed to check for updates on channel beta."); @@ -89,7 +88,6 @@ describe("ElectronUpdater", () => { const error = yield* updater.downloadUpdate.pipe(Effect.flip); assert.instanceOf(error, ElectronUpdater.ElectronUpdaterDownloadUpdateError); - assert.isTrue(ElectronUpdater.isElectronUpdaterError(error)); assert.equal(error.channel, "nightly"); assert.strictEqual(error.cause, cause); assert.equal( @@ -126,7 +124,6 @@ describe("ElectronUpdater", () => { .pipe(Effect.flip); assert.instanceOf(error, ElectronUpdater.ElectronUpdaterQuitAndInstallError); - assert.isTrue(ElectronUpdater.isElectronUpdaterError(error)); assert.equal(error.channel, "alpha"); assert.equal(error.isSilent, true); assert.equal(error.isForceRunAfter, false); diff --git a/apps/desktop/src/electron/ElectronUpdater.ts b/apps/desktop/src/electron/ElectronUpdater.ts index 4157d29a9df8..8e044de65ad6 100644 --- a/apps/desktop/src/electron/ElectronUpdater.ts +++ b/apps/desktop/src/electron/ElectronUpdater.ts @@ -54,7 +54,6 @@ export const ElectronUpdaterError = Schema.Union([ ElectronUpdaterQuitAndInstallError, ]); export type ElectronUpdaterError = typeof ElectronUpdaterError.Type; -export const isElectronUpdaterError = Schema.is(ElectronUpdaterError); export class ElectronUpdater extends Context.Service< ElectronUpdater, diff --git a/apps/desktop/src/electron/ElectronWindow.test.ts b/apps/desktop/src/electron/ElectronWindow.test.ts index bebb0e5c4178..c802e595633a 100644 --- a/apps/desktop/src/electron/ElectronWindow.test.ts +++ b/apps/desktop/src/electron/ElectronWindow.test.ts @@ -79,7 +79,6 @@ describe("ElectronWindow", () => { const error = yield* electronWindow.create(options).pipe(Effect.flip); assert.instanceOf(error, ElectronWindow.ElectronWindowCreateError); - assert.isTrue(ElectronWindow.isElectronWindowCreateError(error)); assert.deepEqual(error.options, { title: "T3 Code", width: 1100, diff --git a/apps/desktop/src/electron/ElectronWindow.ts b/apps/desktop/src/electron/ElectronWindow.ts index 5f6a9d34280b..9234399191cf 100644 --- a/apps/desktop/src/electron/ElectronWindow.ts +++ b/apps/desktop/src/electron/ElectronWindow.ts @@ -58,8 +58,6 @@ export class ElectronWindowCreateError extends Schema.TaggedErrorClass()( "ElectronWindowOperationError", { diff --git a/apps/desktop/src/ipc/DesktopIpc.test.ts b/apps/desktop/src/ipc/DesktopIpc.test.ts index fc311877f829..5533831f9b55 100644 --- a/apps/desktop/src/ipc/DesktopIpc.test.ts +++ b/apps/desktop/src/ipc/DesktopIpc.test.ts @@ -41,7 +41,6 @@ describe("DesktopIpc", () => { const error = yield* Effect.flip(Effect.scoped(ipc.handle(invokeMethod))); assert.instanceOf(error, DesktopIpc.DesktopIpcRegistrationError); - assert.isTrue(DesktopIpc.isDesktopIpcError(error)); assert.strictEqual(error.handlerKind, "invoke"); assert.strictEqual(error.channel, invokeMethod.channel); assert.strictEqual(error.cause, cause); @@ -69,7 +68,6 @@ describe("DesktopIpc", () => { if (exit._tag === "Success") return; const error = Cause.squash(exit.cause); assert.instanceOf(error, DesktopIpc.DesktopIpcUnregistrationError); - assert.isTrue(DesktopIpc.isDesktopIpcError(error)); assert.strictEqual(error.handlerKind, "sync"); assert.strictEqual(error.channel, syncMethod.channel); assert.strictEqual(error.cause, cause); diff --git a/apps/desktop/src/ipc/DesktopIpc.ts b/apps/desktop/src/ipc/DesktopIpc.ts index e948571cc628..643543d4ec33 100644 --- a/apps/desktop/src/ipc/DesktopIpc.ts +++ b/apps/desktop/src/ipc/DesktopIpc.ts @@ -55,7 +55,6 @@ export const DesktopIpcError = Schema.Union([ DesktopIpcUnregistrationError, ]); export type DesktopIpcError = typeof DesktopIpcError.Type; -export const isDesktopIpcError = Schema.is(DesktopIpcError); export interface DesktopIpcMethod { readonly channel: string; diff --git a/apps/desktop/src/linuxSecretStorage.test.ts b/apps/desktop/src/linuxSecretStorage.test.ts index a91790200771..5827e38e406f 100644 --- a/apps/desktop/src/linuxSecretStorage.test.ts +++ b/apps/desktop/src/linuxSecretStorage.test.ts @@ -3,7 +3,6 @@ import { describe, expect, it } from "vite-plus/test"; import { normalizeLinuxPasswordStorePreference, resolveLinuxPasswordStoreSwitch, - resolveLinuxSecretStorageUnavailableMessage, } from "./linuxSecretStorage.ts"; const autoSwitch = (env: NodeJS.ProcessEnv) => @@ -124,80 +123,4 @@ describe("linuxSecretStorage", () => { }), ).toBe("gnome-libsecret"); }); - - it("uses GNOME Keyring remediation for libsecret and unknown backends", () => { - expect( - resolveLinuxSecretStorageUnavailableMessage({ - configuredPreference: "auto", - selectedBackend: "gnome_libsecret", - env: { XDG_CURRENT_DESKTOP: "niri" }, - }), - ).toContain("GNOME Keyring"); - }); - - it("prefers explicit libsecret selection over KDE desktop heuristics", () => { - expect( - resolveLinuxSecretStorageUnavailableMessage({ - configuredPreference: "gnome-libsecret", - selectedBackend: "unknown", - env: { XDG_CURRENT_DESKTOP: "KDE" }, - }), - ).toContain("GNOME Keyring"); - expect( - resolveLinuxSecretStorageUnavailableMessage({ - configuredPreference: "auto", - selectedBackend: "gnome_libsecret", - env: { XDG_CURRENT_DESKTOP: "KDE" }, - }), - ).toContain("GNOME Keyring"); - }); - - it("prefers explicit KWallet preference over selected gnome-libsecret backend", () => { - expect( - resolveLinuxSecretStorageUnavailableMessage({ - configuredPreference: "kwallet6", - selectedBackend: "gnome_libsecret", - env: { XDG_CURRENT_DESKTOP: "niri" }, - }), - ).toContain("KWallet"); - expect( - resolveLinuxSecretStorageUnavailableMessage({ - configuredPreference: "kwallet", - selectedBackend: "gnome-libsecret", - env: {}, - }), - ).toContain("KWallet"); - }); - - it("uses KWallet remediation wording for KDE-looking sessions", () => { - expect( - resolveLinuxSecretStorageUnavailableMessage({ - configuredPreference: "auto", - selectedBackend: "kwallet6", - env: {}, - }), - ).toContain("KWallet"); - expect( - resolveLinuxSecretStorageUnavailableMessage({ - configuredPreference: "auto", - selectedBackend: "unknown", - env: { XDG_CURRENT_DESKTOP: "KDE" }, - }), - ).toContain("KWallet"); - expect( - resolveLinuxSecretStorageUnavailableMessage({ - configuredPreference: "auto", - selectedBackend: "unknown", - env: { DESKTOP_SESSION: "plasmawayland" }, - }), - ).toContain("KWallet"); - // A desktop name outranks a bare KDE marker when choosing the wording. - expect( - resolveLinuxSecretStorageUnavailableMessage({ - configuredPreference: "auto", - selectedBackend: "unknown", - env: { GDMSESSION: "gnome", KDE_FULL_SESSION: "true" }, - }), - ).toContain("GNOME Keyring"); - }); }); diff --git a/apps/desktop/src/linuxSecretStorage.ts b/apps/desktop/src/linuxSecretStorage.ts index fe3e21eadb92..3aa7a440d1e8 100644 --- a/apps/desktop/src/linuxSecretStorage.ts +++ b/apps/desktop/src/linuxSecretStorage.ts @@ -25,9 +25,6 @@ const ELECTRON_KDE_DESKTOP = "KDE"; // Chromium recognizes LXQt and still selects basic text for it, so it does need a forced backend. const ELECTRON_UNPROTECTED_DESKTOPS = new Set(["LXQt"]); -const KDE_NAME_PREFIXES = ["kde", "plasma"]; -const NEGATIVE_FLAG_VALUES = new Set(["0", "false", "no", "off"]); - export function normalizeLinuxPasswordStorePreference( value: unknown, ): LinuxPasswordStorePreference { @@ -77,102 +74,6 @@ function electronSelectsProtectedBackend(env: NodeJS.ProcessEnv): boolean { return false; } -export function resolveLinuxSecretStorageUnavailableMessage(input: { - readonly configuredPreference: LinuxPasswordStorePreference; - readonly selectedBackend: string | null; - readonly env: NodeJS.ProcessEnv; -}): string { - if (input.configuredPreference === "gnome-libsecret") { - return getGnomeKeyringRemediationMessage(); - } - - if ( - input.configuredPreference === "kwallet" || - input.configuredPreference === "kwallet5" || - input.configuredPreference === "kwallet6" - ) { - return getKWalletRemediationMessage(); - } - - const backend = normalizeSelectedStorageBackend(input.selectedBackend); - if (backend === "gnome-libsecret") { - return getGnomeKeyringRemediationMessage(); - } - - if ( - backend === "kwallet" || - backend === "kwallet5" || - backend === "kwallet6" || - looksLikeKdeSession(input.env) - ) { - return getKWalletRemediationMessage(); - } - - return getGnomeKeyringRemediationMessage(); -} - -function getGnomeKeyringRemediationMessage(): string { - return "T3 Code could not access GNOME Keyring to save this environment credential. Install and start GNOME Keyring, then restart T3 Code."; -} - -function getKWalletRemediationMessage(): string { - return "T3 Code could not access KWallet to save this environment credential. Enable the KDE wallet subsystem in System Settings, then restart T3 Code."; -} - -// Advisory only: this picks between the GNOME Keyring and KWallet wording in the failure notice. It -// never decides which backend to select, so a loose match costs a user slightly wrong instructions -// rather than an unprotected credential store. -function looksLikeKdeSession(env: NodeJS.ProcessEnv): boolean { - const currentDesktopNames = nonEmptyDesktopNames(env.XDG_CURRENT_DESKTOP); - if (currentDesktopNames.length > 0) { - return currentDesktopNames.some(isKdeDesktopName); - } - - const legacyNames = legacyDesktopNames(env); - if (legacyNames.length > 0) { - return legacyNames.some(isKdeDesktopName); - } - - return isSet(env.KDE_SESSION_VERSION) || isAffirmativeFlag(env.KDE_FULL_SESSION); -} - -function isKdeDesktopName(name: string): boolean { - return KDE_NAME_PREFIXES.some((prefix) => name.startsWith(prefix)); -} - -function legacyDesktopNames(env: NodeJS.ProcessEnv): string[] { - return [env.XDG_SESSION_DESKTOP, env.DESKTOP_SESSION, env.GDMSESSION].flatMap((entry) => { - const normalized = normalizeDesktopName(entry); - return normalized ? [normalized] : []; - }); -} - -function nonEmptyDesktopNames(value: string | undefined): string[] { - return splitDesktopNameList(value).flatMap((entry) => { - const normalized = normalizeDesktopName(entry); - return normalized ? [normalized] : []; - }); -} - -function isSet(value: string | undefined): boolean { - return Boolean(value?.trim()); -} - -function isAffirmativeFlag(value: string | undefined): boolean { - const normalized = value?.trim().toLowerCase(); - return normalized ? !NEGATIVE_FLAG_VALUES.has(normalized) : false; -} - function splitDesktopNameList(value: string | undefined): string[] { return value?.split(":") ?? []; } - -function normalizeDesktopName(value: string | undefined): string | null { - const normalized = value?.trim().toLowerCase(); - return normalized && normalized.length > 0 ? normalized : null; -} - -function normalizeSelectedStorageBackend(value: string | null): string | null { - const normalized = value?.trim().toLowerCase().replace(/_/gu, "-"); - return normalized && normalized.length > 0 ? normalized : null; -} diff --git a/apps/desktop/src/preview/BrowserSession.test.ts b/apps/desktop/src/preview/BrowserSession.test.ts index ff22f3dd2272..aaf34c3578f9 100644 --- a/apps/desktop/src/preview/BrowserSession.test.ts +++ b/apps/desktop/src/preview/BrowserSession.test.ts @@ -172,8 +172,6 @@ describe("BrowserSession", () => { const error = yield* browserSessions.getPartition("environment-a").pipe(Effect.flip); assert.instanceOf(error, BrowserSession.BrowserSessionPartitionDerivationError); - assert.isTrue(BrowserSession.isBrowserSessionGetSessionError(error)); - assert.isTrue(BrowserSession.isBrowserSessionError(error)); assert.equal(error.scope, "environment-a"); assert.strictEqual(error.cause, platformCause); assert.strictEqual(error.cause.reason.cause, nativeCause); @@ -196,8 +194,6 @@ describe("BrowserSession", () => { const error = yield* browserSessions.getSession("environment-b").pipe(Effect.flip); assert.instanceOf(error, BrowserSession.BrowserSessionCreationError); - assert.isTrue(BrowserSession.isBrowserSessionGetSessionError(error)); - assert.isTrue(BrowserSession.isBrowserSessionError(error)); assert.equal(error.scope, "environment-b"); assert.equal(error.partition, partition); assert.strictEqual(error.cause, cause); @@ -270,7 +266,6 @@ describe("BrowserSession", () => { const storageError = yield* browserSessions.clearCookies().pipe(Effect.flip); assert.instanceOf(storageError, BrowserSession.BrowserSessionStorageClearError); - assert.isTrue(BrowserSession.isBrowserSessionError(storageError)); assert.equal(storageError.partition, secondPartition); assert.strictEqual(storageError.cause, storageCause); assert.equal( @@ -287,7 +282,6 @@ describe("BrowserSession", () => { const cacheError = yield* browserSessions.clearCache().pipe(Effect.flip); assert.instanceOf(cacheError, BrowserSession.BrowserSessionCacheClearError); - assert.isTrue(BrowserSession.isBrowserSessionError(cacheError)); assert.equal(cacheError.partition, firstPartition); assert.strictEqual(cacheError.cause, cacheCause); assert.equal( diff --git a/apps/desktop/src/preview/BrowserSession.ts b/apps/desktop/src/preview/BrowserSession.ts index 7f3c9ec5d7ac..7ff879852283 100644 --- a/apps/desktop/src/preview/BrowserSession.ts +++ b/apps/desktop/src/preview/BrowserSession.ts @@ -93,7 +93,6 @@ export const BrowserSessionGetSessionError = Schema.Union([ BrowserSessionCreationError, ]); export type BrowserSessionGetSessionError = typeof BrowserSessionGetSessionError.Type; -export const isBrowserSessionGetSessionError = Schema.is(BrowserSessionGetSessionError); export const BrowserSessionError = Schema.Union([ BrowserSessionPartitionDerivationError, @@ -102,7 +101,6 @@ export const BrowserSessionError = Schema.Union([ BrowserSessionCacheClearError, ]); export type BrowserSessionError = typeof BrowserSessionError.Type; -export const isBrowserSessionError = Schema.is(BrowserSessionError); export class BrowserSession extends Context.Service< BrowserSession, diff --git a/apps/desktop/src/settings/DesktopClientSettings.diagnostics.test.ts b/apps/desktop/src/settings/DesktopClientSettings.diagnostics.test.ts index 5034df44cf70..d2fd166e878c 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.diagnostics.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.diagnostics.test.ts @@ -54,7 +54,7 @@ const readWithLogs = (fileSystemLayer: Layer.Layer) => { const environment = yield* DesktopEnvironment.DesktopEnvironment; const settings = yield* DesktopClientSettings.DesktopClientSettings; return { - result: yield* settings.get, + result: yield* Effect.result(settings.get), settingsPath: environment.clientSettingsPath, records, }; @@ -73,12 +73,13 @@ describe("DesktopClientSettings diagnostics", () => { Effect.gen(function* () { const result = yield* readWithLogs(FileSystem.layerNoop({})); - assert.isTrue(Option.isNone(result.result)); + if (result.result._tag !== "Success") return assert.fail("expected a successful read"); + assert.isTrue(Option.isNone(result.result.success)); assert.deepEqual(result.records, []); }), ); - it.effect("logs non-missing filesystem failures with the settings path", () => { + it.effect("reports non-missing filesystem failures and logs the settings path", () => { const permissionError = PlatformError.systemError({ _tag: "PermissionDenied", module: "FileSystem", @@ -93,7 +94,12 @@ describe("DesktopClientSettings diagnostics", () => { }), ); - assert.isTrue(Option.isNone(result.result)); + if (result.result._tag !== "Failure") return assert.fail("expected a read failure"); + assert.instanceOf( + result.result.failure, + DesktopClientSettings.DesktopClientSettingsReadError, + ); + assert.strictEqual(result.result.failure.cause, permissionError); assert.equal(result.records.length, 1); assert.deepEqual(result.records[0]?.message, [ "Could not read desktop client settings.", @@ -103,7 +109,7 @@ describe("DesktopClientSettings diagnostics", () => { }); }); - it.effect("logs malformed settings documents with the settings path", () => + it.effect("reports malformed settings documents and logs the settings path", () => Effect.gen(function* () { const result = yield* readWithLogs( FileSystem.layerNoop({ @@ -111,7 +117,12 @@ describe("DesktopClientSettings diagnostics", () => { }), ); - assert.isTrue(Option.isNone(result.result)); + if (result.result._tag !== "Failure") return assert.fail("expected a decode failure"); + assert.instanceOf( + result.result.failure, + DesktopClientSettings.DesktopClientSettingsReadError, + ); + assert.equal(result.result.failure.operation, "decode-document"); assert.equal(result.records.length, 1); const message = result.records[0]?.message; if (!Array.isArray(message)) { diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 78ee11c789aa..c40144be7c90 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -45,6 +45,7 @@ const clientSettings: ClientSettings = { fontSizeTerminal: 12, fontSmoothing: true, glassOpacity: 80, + onboardingCompletedAt: null, panelAnimationDurationMs: 0, providerFavorites: [], openWithEntries: [ @@ -151,6 +152,59 @@ describe("DesktopClientSettings", () => { ), ); + for (const failure of [ + { label: "permission", reason: "PermissionDenied" }, + { label: "I/O", reason: "Unknown" }, + ] as const) { + it.effect(`preserves saved preferences across ${failure.label} read failures and retries`, () => + withClientSettings( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const settings = yield* DesktopClientSettings.DesktopClientSettings; + const savedSettings = { + ...clientSettings, + onboardingCompletedAt: "2026-09-05T12:00:00.000Z", + }; + yield* settings.set(savedSettings); + const savedContents = yield* fileSystem.readFileString(environment.clientSettingsPath); + const cause = PlatformError.systemError({ + _tag: failure.reason, + module: "FileSystem", + method: "readFileString", + pathOrDescriptor: environment.clientSettingsPath, + }); + let failRead = true; + const retryableSettings = yield* DesktopClientSettings.make.pipe( + Effect.provideService( + FileSystem.FileSystem, + FileSystem.FileSystem.of({ + ...fileSystem, + readFileString: (path) => + Effect.suspend(() => + failRead ? Effect.fail(cause) : fileSystem.readFileString(path), + ), + }), + ), + ); + + const error = yield* retryableSettings.get.pipe(Effect.flip); + assert.instanceOf(error, DesktopClientSettings.DesktopClientSettingsReadError); + assert.equal(error.operation, "read-file"); + assert.equal(error.path, environment.clientSettingsPath); + assert.strictEqual(error.cause, cause); + assert.equal( + yield* fileSystem.readFileString(environment.clientSettingsPath), + savedContents, + ); + + failRead = false; + assert.deepEqual(yield* retryableSettings.get, Option.some(savedSettings)); + }), + ), + ); + } + it.effect("reports the failed client settings write operation and path", () => withClientSettings( Effect.gen(function* () { @@ -237,17 +291,31 @@ describe("DesktopClientSettings", () => { ), ); - it.effect("treats malformed client settings documents as absent", () => - withClientSettings( - Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - const fileSystem = yield* FileSystem.FileSystem; - const settings = yield* DesktopClientSettings.DesktopClientSettings; - yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); - yield* fileSystem.writeFileString(environment.clientSettingsPath, "{not-json"); + for (const document of [ + { label: "malformed JSON", contents: "{not-json" }, + { label: "invalid direct settings", contents: '{"fontSizeCode":"large"}' }, + { label: "invalid legacy settings", contents: '{"settings":{"fontSizeCode":"large"}}' }, + ]) { + it.effect(`reports ${document.label} without treating the settings file as absent`, () => + withClientSettings( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const settings = yield* DesktopClientSettings.DesktopClientSettings; + yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); + yield* fileSystem.writeFileString(environment.clientSettingsPath, document.contents); - assert.isTrue(Option.isNone(yield* settings.get)); - }), - ), - ); + const error = yield* settings.get.pipe(Effect.flip); + assert.instanceOf(error, DesktopClientSettings.DesktopClientSettingsReadError); + assert.equal(error.operation, "decode-document"); + assert.equal(error.path, environment.clientSettingsPath); + assert.instanceOf(error.cause, Schema.SchemaError); + assert.equal( + yield* fileSystem.readFileString(environment.clientSettingsPath), + document.contents, + ); + }), + ), + ); + } }); diff --git a/apps/desktop/src/settings/DesktopClientSettings.ts b/apps/desktop/src/settings/DesktopClientSettings.ts index 4ff091e27a27..5eadd27d5454 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.ts @@ -12,25 +12,33 @@ import * as Ref from "effect/Ref"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; -const ClientSettingsDocumentSchema = Schema.Struct({ - settings: ClientSettingsSchema, -}); - const ClientSettingsJson = fromLenientJson(ClientSettingsSchema); -const LegacyClientSettingsDocumentJson = fromLenientJson(ClientSettingsDocumentSchema); -const decodeLegacyClientSettingsDocumentJson = Schema.decodeEffect( - LegacyClientSettingsDocumentJson, +const decodeClientSettingsDocument = Schema.decodeEffect( + fromLenientJson(Schema.Record(Schema.String, Schema.Unknown)), ); -const decodeClientSettingsJsonValue = Schema.decodeEffect(ClientSettingsJson); -const decodeClientSettingsJson = (raw: string): Effect.Effect => - decodeLegacyClientSettingsDocumentJson(raw).pipe( - Effect.map((document) => document.settings), - Effect.catchTags({ - SchemaError: () => decodeClientSettingsJsonValue(raw), - }), +const decodeClientSettingsValue = Schema.decodeUnknownEffect(ClientSettingsSchema); +const decodeClientSettingsJson = Effect.fnUntraced(function* (raw: string) { + const document = yield* decodeClientSettingsDocument(raw); + // Select the shape before validation so invalid legacy settings cannot become defaults. + return yield* decodeClientSettingsValue( + Object.hasOwn(document, "settings") ? document.settings : document, ); +}); const encodeClientSettingsJson = Schema.encodeEffect(ClientSettingsJson); +export class DesktopClientSettingsReadError extends Schema.TaggedErrorClass()( + "DesktopClientSettingsReadError", + { + operation: Schema.Literals(["read-file", "decode-document"]), + path: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Desktop client settings read failed during ${this.operation} at ${this.path}.`; + } +} + const DesktopClientSettingsWriteOperation = Schema.Literals([ "create-temporary-file-name", "encode-document", @@ -55,7 +63,7 @@ export class DesktopClientSettingsWriteError extends Schema.TaggedErrorClass>; + readonly get: Effect.Effect, DesktopClientSettingsReadError>; readonly set: ( settings: ClientSettings, ) => Effect.Effect; @@ -65,7 +73,7 @@ export class DesktopClientSettings extends Context.Service< const readClientSettings = ( fileSystem: FileSystem.FileSystem, settingsPath: string, -): Effect.Effect> => +): Effect.Effect, DesktopClientSettingsReadError> => fileSystem.readFileString(settingsPath).pipe( Effect.map(Option.some), Effect.catchTags({ @@ -74,7 +82,15 @@ const readClientSettings = ( ? Effect.succeed(Option.none()) : Effect.logWarning("Could not read desktop client settings.", cause).pipe( Effect.annotateLogs({ settingsPath }), - Effect.as(Option.none()), + Effect.andThen( + Effect.fail( + new DesktopClientSettingsReadError({ + operation: "read-file", + path: settingsPath, + cause, + }), + ), + ), ), }), Effect.flatMap( @@ -87,7 +103,15 @@ const readClientSettings = ( SchemaError: (cause) => Effect.logWarning("Could not decode desktop client settings.", cause).pipe( Effect.annotateLogs({ settingsPath }), - Effect.as(Option.none()), + Effect.andThen( + Effect.fail( + new DesktopClientSettingsReadError({ + operation: "decode-document", + path: settingsPath, + cause, + }), + ), + ), ), }), ), diff --git a/apps/desktop/src/shell/DesktopOpenWith.ts b/apps/desktop/src/shell/DesktopOpenWith.ts index 6fc855971b7c..95da0123394e 100644 --- a/apps/desktop/src/shell/DesktopOpenWith.ts +++ b/apps/desktop/src/shell/DesktopOpenWith.ts @@ -198,8 +198,12 @@ export const make = Effect.gen(function* () { Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ); + const readClientSettings = clientSettings.get.pipe( + Effect.catchTag("DesktopClientSettingsReadError", () => Effect.succeed(Option.none())), + ); + const resolvePresentations = Effect.gen(function* () { - const settings = yield* clientSettings.get; + const settings = yield* readClientSettings; if (Option.isNone(settings)) return []; return yield* Effect.forEach(settings.value.openWithEntries, (entry) => Effect.gen(function* () { @@ -245,7 +249,7 @@ export const make = Effect.gen(function* () { reason: "not-directory", }); } - const settings = yield* clientSettings.get; + const settings = yield* readClientSettings; const entry = Option.isSome(settings) ? settings.value.openWithEntries.find((candidate) => candidate.id === input.entryId) : undefined; diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index 1978337df3e7..509778521511 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -794,7 +794,6 @@ describe("DesktopUpdates", () => { const error = yield* updates.setChannel("nightly").pipe(Effect.flip); assert.instanceOf(error, DesktopUpdates.DesktopUpdateChannelPersistenceError); - assert.isTrue(DesktopUpdates.isDesktopUpdateSetChannelError(error)); assert.equal(error.channel, "nightly"); assert.strictEqual(error.cause, settingsFailure); assert.strictEqual(error.cause.cause, diskFailure); diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index 3f9156d1d065..e52d91f8ed83 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -156,7 +156,6 @@ export const DesktopUpdateSetChannelError = Schema.Union([ DesktopUpdateChannelPersistenceError, ]); export type DesktopUpdateSetChannelError = typeof DesktopUpdateSetChannelError.Type; -export const isDesktopUpdateSetChannelError = Schema.is(DesktopUpdateSetChannelError); export class DesktopUpdates extends Context.Service< DesktopUpdates, diff --git a/apps/marketing/src/pages/index.astro b/apps/marketing/src/pages/index.astro index a4fdc966b9d8..723ad4d4324b 100644 --- a/apps/marketing/src/pages/index.astro +++ b/apps/marketing/src/pages/index.astro @@ -826,8 +826,11 @@ const screenshot = await getImage({ gap: 12px; overflow-x: auto; overscroll-behavior-x: contain; - padding: 4px max(32px, calc((100vw - 1240px) / 2 + 32px)); - scroll-padding-inline: max(32px, calc((100vw - 1240px) / 2 + 32px)); + /* Percentages resolve against this element's own box rather than the + viewport, so the first card lines up with the heading's .container edge + even when a classic scrollbar makes 100vw wider than the layout. */ + padding: 4px max(32px, calc((100% - 1240px) / 2 + 32px)); + scroll-padding-inline: max(32px, calc((100% - 1240px) / 2 + 32px)); } .endorsement-card { diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index 582c58fb27e6..152948274ca3 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -33,7 +33,6 @@ import { mergeAgentAwarenessRegistrationPreferences, refreshActiveLiveActivityRemoteRegistration, refreshAgentAwarenessRegistration, - normalizeAgentAwarenessRelayBaseUrl, registerAgentAwarenessConnection, registerLiveActivityPushToken, releaseAgentAwarenessRelayTokenProvider, @@ -363,13 +362,6 @@ describe("makeRelayDeviceRegistrationRequest", () => { }); }); - it("normalizes relay base URLs for APNs registration requests", () => { - expect(normalizeAgentAwarenessRelayBaseUrl(" https://relay.example.test/// ")).toBe( - "https://relay.example.test", - ); - expect(normalizeAgentAwarenessRelayBaseUrl(" ")).toBeNull(); - }); - it("overrides persisted preferences for an in-flight registration", () => { expect( mergeAgentAwarenessRegistrationPreferences( diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts index a2d4261de603..9f4539c44d64 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -139,16 +139,6 @@ export function mergeAgentAwarenessRegistrationPreferences( return { ...stored, ...override }; } -export function normalizeAgentAwarenessRelayBaseUrl( - value: string | null | undefined, -): string | null { - const trimmed = value?.trim(); - if (!trimmed) { - return null; - } - return trimmed.replace(/\/+$/g, ""); -} - function readRelayConfig(): { readonly url: string } | null { const relayUrl = resolveCloudPublicConfig().relay.url; if (!relayUrl) { diff --git a/apps/mobile/src/features/cloud/linkEnvironment.test.ts b/apps/mobile/src/features/cloud/linkEnvironment.test.ts index 42aa8ffebb61..feadf6c81893 100644 --- a/apps/mobile/src/features/cloud/linkEnvironment.test.ts +++ b/apps/mobile/src/features/cloud/linkEnvironment.test.ts @@ -11,13 +11,11 @@ import { MobilePreferencesStore } from "../../persistence/mobile-preferences"; import { MobileStorage } from "../../persistence/mobile-storage"; import { - cloudEnvironmentsPendingStatus, linkEnvironmentToCloud, linkEnvironmentToCloudWithPreference, connectCloudEnvironment, listCloudEnvironments, listCloudEnvironmentsWithStatus, - normalizeRelayBaseUrl, refreshCloudEnvironmentConnection, } from "./linkEnvironment"; @@ -195,23 +193,6 @@ describe("mobile cloud link environment client", () => { loadPreferences.mockClear(); }); - it("normalizes configured relay base URLs before building DPoP-bound requests", () => { - expect(normalizeRelayBaseUrl(" https://relay.example.test/// ")).toBe( - "https://relay.example.test", - ); - expect(normalizeRelayBaseUrl(" ")).toBeNull(); - }); - - it("makes linked environments visible while their status is still loading", () => { - expect(cloudEnvironmentsPendingStatus([listedEnvironment("env-1")])).toMatchObject([ - { - environment: { environmentId: "env-1", label: "Desktop" }, - status: null, - statusError: "Checking status...", - }, - ]); - }); - it.effect("decodes relay environment list responses before returning records", () => Effect.gen(function* () { vi.stubGlobal( diff --git a/apps/mobile/src/features/cloud/linkEnvironment.ts b/apps/mobile/src/features/cloud/linkEnvironment.ts index c2033117f69d..b8dc8f878e0c 100644 --- a/apps/mobile/src/features/cloud/linkEnvironment.ts +++ b/apps/mobile/src/features/cloud/linkEnvironment.ts @@ -43,14 +43,6 @@ const RELAY_STATUS_AND_CONNECT_SCOPES = [ RelayEnvironmentConnectScope, ] satisfies ReadonlyArray; -export function normalizeRelayBaseUrl(value: string | null | undefined): string | null { - const trimmed = value?.trim(); - if (!trimmed) { - return null; - } - return trimmed.replace(/\/+$/g, ""); -} - function readRelayUrl(): string | null { return resolveCloudPublicConfig().relay.url; } @@ -405,16 +397,6 @@ export function getCloudEnvironmentStatus(input: { }); } -export function cloudEnvironmentsPendingStatus( - environments: ReadonlyArray, -): ReadonlyArray { - return environments.map((environment) => ({ - environment, - status: null, - statusError: "Checking status...", - })); -} - export function loadCloudEnvironmentStatuses(input: { readonly clerkToken: string; readonly environments: ReadonlyArray; diff --git a/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.test.ts b/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.test.ts index 00679afa4a63..24b18edd5db3 100644 --- a/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.test.ts +++ b/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.test.ts @@ -2,11 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import type { NativeReviewDiffRow } from "./nativeReviewDiffSurface"; import type { NativeReviewDiffFile } from "./nativeReviewDiffTypes"; -import { - highlightNativeReviewDiffVisibleRows, - streamNativeReviewDiffTokens, - type NativeReviewDiffTokenChunk, -} from "./nativeReviewDiffHighlighter"; +import { highlightNativeReviewDiffVisibleRows } from "./nativeReviewDiffHighlighter"; const tokenization = vi.hoisted(() => ({ calls: [] as string[], @@ -257,13 +253,13 @@ describe.each(["native", "javascript"] as const)("%s highlighting budgets", (eng expect(result.tokensByRowId["line-1"]?.some((token) => token.color !== null)).toBe(true); }); - it("keeps long lines and unknown following syntax plain until the next hunk", async () => { + it("keeps only the long line plain and resumes highlighting after it", async () => { const longLine = `${"x".repeat(1_001)} /*`; const rows = [ line(1, "export const before = 1;"), line(2, longLine), { kind: "comment", id: "note", commentText: "Check this", fileId: TYPESCRIPT_FILE.id }, - line(3, "inside the comment */"), + line(3, "export const inside = 'x';"), makeHunk("next-hunk"), line(100, "export const after = 2;"), ] satisfies ReadonlyArray; @@ -274,14 +270,36 @@ describe.each(["native", "javascript"] as const)("%s highlighting budgets", (eng expect(result.tokensByRowId["line-2"]).toEqual([ { content: longLine, color: null, fontStyle: null }, ]); - expect(result.tokensByRowId["line-3"]).toEqual([ - { content: "inside the comment */", color: null, fontStyle: null }, - ]); - expect(result.tokensByRowId["line-1"]?.some((token) => token.color !== null)).toBe(true); - expect(result.tokensByRowId["line-100"]?.some((token) => token.color !== null)).toBe(true); + for (const id of ["line-1", "line-3", "line-100"]) { + expect(result.tokensByRowId[id]?.some((token) => token.color !== null)).toBe(true); + } expect(tokenization.calls.some((code) => code.includes(longLine))).toBe(false); }); + it("highlights the rows after a long line the same regardless of the first window", async () => { + const rows = [ + line(1, "export const before = 1;"), + line(2, `const data = "${"x".repeat(1_050)}";`), + line(3, "export const inside = 'x';"), + line(4, "export const after = 2;"), + ]; + const spanning = await highlightRows(rows); + const afterLongLine = await highlightNativeReviewDiffVisibleRows({ + rows, + files: [TYPESCRIPT_FILE], + scheme: "dark", + engine, + firstRowIndex: 2, + lastRowIndex: 3, + overscanRows: 0, + }); + + for (const id of ["line-3", "line-4"]) { + expect(spanning.tokensByRowId[id]?.some((token) => token.color !== null)).toBe(true); + expect(afterLongLine.tokensByRowId[id]).toEqual(spanning.tokensByRowId[id]); + } + }); + it("preserves multiline grammar and row mapping across character-limited batches", async () => { const opening = line(1, "const message = `open"); const body = Array.from({ length: 40 }, (_, index) => line(index + 2, "inside ".repeat(45))); @@ -318,21 +336,4 @@ describe.each(["native", "javascript"] as const)("%s highlighting budgets", (eng expect(result.rowCount).toBe(0); expect(result.tokensByRowId).toEqual({}); }); - - it("applies the same long-line guard to streamed token chunks", async () => { - const content = "x".repeat(10_000); - const chunks: NativeReviewDiffTokenChunk[] = []; - - await streamNativeReviewDiffTokens({ - rows: [line(1, content)], - files: [TYPESCRIPT_FILE], - scheme: "dark", - engine, - onChunk: (chunk) => chunks.push(chunk), - }); - - expect(chunks).toHaveLength(1); - expect(chunks[0]?.tokensByRowId["line-1"]).toEqual([{ content, color: null, fontStyle: null }]); - expect(tokenization.calls).toHaveLength(0); - }); }); diff --git a/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.ts b/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.ts index 383e1e73a85f..e924ff1aa645 100644 --- a/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.ts +++ b/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.ts @@ -65,26 +65,6 @@ interface IndexedNativeReviewDiffLineRow { readonly rowIndex: number; } -export interface NativeReviewDiffTokenChunk { - readonly chunkIndex: number; - readonly fileId: string; - readonly filePath: string; - readonly language: NativeReviewDiffLanguage; - readonly lineCount: number; - readonly durationMs: number; - readonly tokensByRowId: Record>; -} - -export interface StreamNativeReviewDiffTokenInput { - readonly rows: ReadonlyArray; - readonly files: ReadonlyArray; - readonly scheme: NativeReviewDiffHighlightScheme; - readonly engine?: NativeReviewDiffHighlightEngine; - readonly chunkSize?: number; - readonly signal?: AbortSignal; - readonly onChunk: (chunk: NativeReviewDiffTokenChunk) => void; -} - export interface HighlightNativeReviewDiffVisibleRowsInput { readonly rows: ReadonlyArray; readonly files: ReadonlyArray; @@ -98,7 +78,6 @@ export interface HighlightNativeReviewDiffVisibleRowsInput { readonly signal?: AbortSignal; } -const NATIVE_REVIEW_DIFF_HIGHLIGHT_CHUNK_SIZE = 500; const NATIVE_REVIEW_DIFF_VISIBLE_OVERSCAN_ROWS = 160; const NATIVE_REVIEW_DIFF_VISIBLE_MAX_ROWS = 360; const NATIVE_REVIEW_DIFF_TOKENIZE_MAX_LINE_LENGTH = 1_000; @@ -247,15 +226,16 @@ function createHighlighterHandle( while (start < lines.length) { if (signal?.aborted) return []; - // Skipping this line leaves its ending grammar state unknown. Keep the - // rest of this contiguous segment plain instead of guessing its syntax. + // Skipping this line leaves its ending grammar state unknown. Resume + // from a fresh state rather than leaving the rest of the segment plain: + // highlighted rows are cached for the sheet's lifetime, so a plain tail + // would stick, and which rows it covered would depend on where the + // first visible window happened to start. if (lines[start]!.length > NATIVE_REVIEW_DIFF_TOKENIZE_MAX_LINE_LENGTH) { - highlighted.push( - ...lines - .slice(start) - .map((content) => [{ content: content || " ", color: null, fontStyle: null }]), - ); - break; + highlighted.push([{ content: lines[start] || " ", color: null, fontStyle: null }]); + grammarState = undefined; + start += 1; + continue; } let end = start; @@ -422,20 +402,6 @@ function canShareGrammarContext( ); } -function groupLineRowsByFileId(rows: ReadonlyArray) { - const rowsByFileId = new Map(); - for (const row of rows) { - if (!isHighlightableLineRow(row)) { - continue; - } - - const fileRows = rowsByFileId.get(row.fileId) ?? []; - fileRows.push(row); - rowsByFileId.set(row.fileId, fileRows); - } - return rowsByFileId; -} - function createFileMap(files: ReadonlyArray) { return new Map(files.map((file) => [file.id, file])); } @@ -560,52 +526,3 @@ export async function highlightNativeReviewDiffVisibleRows( durationMs: Math.round(performance.now() - startedAt), }; } - -export async function streamNativeReviewDiffTokens( - input: StreamNativeReviewDiffTokenInput, -): Promise { - const highlighter = await getNativeReviewDiffHighlighter(input.engine ?? "native"); - const rowsByFileId = groupLineRowsByFileId(input.rows); - const theme = NATIVE_REVIEW_DIFF_THEME_NAME_BY_SCHEME[input.scheme]; - const chunkSize = input.chunkSize ?? NATIVE_REVIEW_DIFF_HIGHLIGHT_CHUNK_SIZE; - let chunkIndex = 0; - - for (const file of input.files) { - const fileRows = rowsByFileId.get(file.id) ?? []; - for (let startIndex = 0; startIndex < fileRows.length; startIndex += chunkSize) { - if (input.signal?.aborted) { - return highlighter.engine; - } - - const startedAt = performance.now(); - const chunkRows = fileRows.slice(startIndex, startIndex + chunkSize); - const code = chunkRows.map((row) => row.content).join("\n"); - const tokenLines = await highlighter.tokenize(code, { - lang: file.language, - theme, - signal: input.signal, - }); - if (input.signal?.aborted) return highlighter.engine; - const tokensByRowId: Record> = {}; - - chunkRows.forEach((row, rowIndex) => { - tokensByRowId[row.id] = tokenLines[rowIndex] ?? makePlainTokenFallback(row); - }); - - input.onChunk({ - chunkIndex, - fileId: file.id, - filePath: file.path, - language: file.language, - lineCount: chunkRows.length, - durationMs: Math.round(performance.now() - startedAt), - tokensByRowId, - }); - - chunkIndex += 1; - await waitForNextFrame(); - } - } - - return highlighter.engine; -} diff --git a/apps/mobile/src/features/files/fileTree.test.ts b/apps/mobile/src/features/files/fileTree.test.ts index 7345a7f366c5..edab2ba687b8 100644 --- a/apps/mobile/src/features/files/fileTree.test.ts +++ b/apps/mobile/src/features/files/fileTree.test.ts @@ -1,13 +1,7 @@ import { describe, expect, it } from "vite-plus/test"; import type { ProjectEntry } from "@t3tools/contracts"; -import { - buildFileTree, - countFileNodes, - defaultExpandedTreePaths, - firstFilePath, - flattenFileTree, -} from "./fileTree"; +import { buildFileTree, defaultExpandedTreePaths, flattenFileTree } from "./fileTree"; const entries = [ { kind: "file", path: "README.md" }, @@ -30,8 +24,6 @@ describe("mobile file tree helpers", () => { "directory:src/components", "file:src/index.ts", ]); - expect(countFileNodes(tree)).toBe(4); - expect(firstFilePath(tree)).toBe("src/components/App.tsx"); }); it("flattens expanded directories and hides collapsed descendants", () => { diff --git a/apps/mobile/src/features/files/fileTree.ts b/apps/mobile/src/features/files/fileTree.ts index 28b5822aaa0f..2e0b8140329c 100644 --- a/apps/mobile/src/features/files/fileTree.ts +++ b/apps/mobile/src/features/files/fileTree.ts @@ -117,18 +117,6 @@ export function buildFileTree(entries: ReadonlyArray): ReadonlyArr return [...root.children.values()].sort(compareNodes).map(freezeNode); } -export function countFileNodes(nodes: ReadonlyArray): number { - let count = 0; - for (const node of nodes) { - if (node.kind === "file") { - count += 1; - } else { - count += countFileNodes(node.children); - } - } - return count; -} - export function defaultExpandedTreePaths(nodes: ReadonlyArray): ReadonlySet { const expanded = new Set(); for (const node of nodes) { @@ -205,16 +193,3 @@ export function flattenFileTree(input: { } return output; } - -export function firstFilePath(nodes: ReadonlyArray): string | null { - for (const node of nodes) { - if (node.kind === "file") { - return node.path; - } - const child = firstFilePath(node.children); - if (child !== null) { - return child; - } - } - return null; -} diff --git a/apps/mobile/src/features/files/nativeSourceFileAdapter.test.ts b/apps/mobile/src/features/files/nativeSourceFileAdapter.test.ts index 0e7d478c6bdb..937d3a1d3c8f 100644 --- a/apps/mobile/src/features/files/nativeSourceFileAdapter.test.ts +++ b/apps/mobile/src/features/files/nativeSourceFileAdapter.test.ts @@ -3,30 +3,10 @@ import { describe, expect, it } from "vite-plus/test"; import { buildNativeSourceRows, buildNativeSourceTokens, - NATIVE_SOURCE_ROW_HEIGHT, - NATIVE_SOURCE_STYLE, nativeSourceRowId, } from "./nativeSourceFileAdapter"; -import { - NATIVE_REVIEW_DIFF_ROW_HEIGHT, - NATIVE_REVIEW_DIFF_STYLE, -} from "../review/nativeReviewDiffAdapter"; describe("nativeSourceFileAdapter", () => { - it("uses the same compact code typography as the diff viewer", () => { - expect(NATIVE_SOURCE_ROW_HEIGHT).toBe(NATIVE_REVIEW_DIFF_ROW_HEIGHT); - expect(NATIVE_SOURCE_STYLE).toMatchObject({ - rowHeight: NATIVE_REVIEW_DIFF_STYLE.rowHeight, - gutterWidth: NATIVE_REVIEW_DIFF_STYLE.gutterWidth, - codePadding: NATIVE_REVIEW_DIFF_STYLE.codePadding, - textVerticalInset: NATIVE_REVIEW_DIFF_STYLE.textVerticalInset, - codeFontSize: NATIVE_REVIEW_DIFF_STYLE.codeFontSize, - codeFontWeight: NATIVE_REVIEW_DIFF_STYLE.codeFontWeight, - lineNumberFontSize: NATIVE_REVIEW_DIFF_STYLE.lineNumberFontSize, - lineNumberFontWeight: NATIVE_REVIEW_DIFF_STYLE.lineNumberFontWeight, - }); - }); - it("maps plain source lines onto context rows with stable line numbers", () => { expect(buildNativeSourceRows(["const value = 1;", "\treturn value;"])).toEqual([ { diff --git a/apps/mobile/src/features/files/nativeSourceFileAdapter.ts b/apps/mobile/src/features/files/nativeSourceFileAdapter.ts index 0c83134ea703..f1dbefdc383b 100644 --- a/apps/mobile/src/features/files/nativeSourceFileAdapter.ts +++ b/apps/mobile/src/features/files/nativeSourceFileAdapter.ts @@ -4,17 +4,11 @@ import type { NativeReviewDiffToken, } from "../diffs/nativeReviewDiffSurface"; import type { ResolvedMobileCodeSurface } from "../../lib/appearancePreferences"; -import { resolveMobileCodeSurface } from "../../lib/appearancePreferences"; import { MOBILE_CODE_SURFACE, MOBILE_TYPOGRAPHY } from "../../lib/typography"; import type { SourceHighlightTokens } from "./sourceHighlightingState"; -export const NATIVE_SOURCE_ROW_HEIGHT = MOBILE_CODE_SURFACE.rowHeight; export const NATIVE_SOURCE_CONTENT_WIDTH = 32_000; -export const NATIVE_SOURCE_STYLE: NativeReviewDiffStyle = createNativeSourceStyle( - resolveMobileCodeSurface(MOBILE_CODE_SURFACE.fontSize), -); - export function createNativeSourceStyle( codeSurface: ResolvedMobileCodeSurface, ): NativeReviewDiffStyle { diff --git a/apps/mobile/src/features/home/workspace-connection-status.test.ts b/apps/mobile/src/features/home/workspace-connection-status.test.ts index 15a990bb1cbe..f1af93316a6b 100644 --- a/apps/mobile/src/features/home/workspace-connection-status.test.ts +++ b/apps/mobile/src/features/home/workspace-connection-status.test.ts @@ -1,11 +1,7 @@ import { describe, expect, it } from "vite-plus/test"; import type { WorkspaceState } from "../../state/workspaceModel"; -import { - shouldShowWorkspaceConnectionStatus, - workspaceConnectionStatusLabel, - workspaceConnectionStatusPresentation, -} from "./workspace-connection-status"; +import { workspaceConnectionStatusPresentation } from "./workspace-connection-status"; function workspaceState(overrides: Partial = {}): WorkspaceState { return { @@ -27,14 +23,16 @@ function workspaceState(overrides: Partial = {}): WorkspaceState describe("workspace connection status", () => { it("stays hidden while a ready environment is connected", () => { - expect(shouldShowWorkspaceConnectionStatus(workspaceState())).toBe(false); + expect(workspaceConnectionStatusPresentation(workspaceState())).toBeNull(); }); it("surfaces offline snapshots", () => { const state = workspaceState({ networkStatus: "offline", hasReadyEnvironment: false }); - expect(shouldShowWorkspaceConnectionStatus(state)).toBe(true); - expect(workspaceConnectionStatusLabel(state)).toBe("You are offline"); + expect(workspaceConnectionStatusPresentation(state)).toEqual({ + label: "You are offline", + showsProgress: false, + }); }); it("names the environment while reconnecting", () => { @@ -54,8 +52,10 @@ describe("workspace connection status", () => { ], }); - expect(shouldShowWorkspaceConnectionStatus(state)).toBe(true); - expect(workspaceConnectionStatusLabel(state)).toBe("Reconnecting to Julius’s Mac mini"); + expect(workspaceConnectionStatusPresentation(state)).toEqual({ + label: "Reconnecting to Julius’s Mac mini", + showsProgress: true, + }); }); it("surfaces connection errors before the generic disconnected fallback", () => { @@ -65,15 +65,19 @@ describe("workspace connection status", () => { hasReadyEnvironment: false, }); - expect(shouldShowWorkspaceConnectionStatus(state)).toBe(true); - expect(workspaceConnectionStatusLabel(state)).toBe("Could not reach Julius’s Mac mini"); + expect(workspaceConnectionStatusPresentation(state)).toEqual({ + label: "Could not reach Julius’s Mac mini", + showsProgress: false, + }); }); it("shows shell catch-up while cached threads remain visible", () => { const state = workspaceState({ hasPendingShellSnapshot: true }); - expect(shouldShowWorkspaceConnectionStatus(state)).toBe(true); - expect(workspaceConnectionStatusLabel(state)).toBe("Syncing threads..."); + expect(workspaceConnectionStatusPresentation(state)).toEqual({ + label: "Syncing threads...", + showsProgress: true, + }); }); it("distinguishes initial shell loading from cached catch-up", () => { @@ -82,39 +86,9 @@ describe("workspace connection status", () => { hasPendingShellSnapshot: true, }); - expect(shouldShowWorkspaceConnectionStatus(state)).toBe(true); - expect(workspaceConnectionStatusLabel(state)).toBe("Loading threads..."); - }); - - it("presents nothing while connected", () => { - expect(workspaceConnectionStatusPresentation(workspaceState())).toBeNull(); - }); - - it("presents progress while reconnecting but not while offline", () => { - const reconnecting = workspaceState({ - hasConnectingEnvironment: true, - hasReadyEnvironment: false, - connectingEnvironments: [ - { - environmentId: "environment-1" as never, - environmentLabel: "Julius’s Mac mini", - displayUrl: "", - isRelayManaged: false, - connectionState: "reconnecting", - connectionError: null, - connectionErrorTraceId: null, - }, - ], - }); - expect(workspaceConnectionStatusPresentation(reconnecting)).toEqual({ - label: "Reconnecting to Julius’s Mac mini", + expect(workspaceConnectionStatusPresentation(state)).toEqual({ + label: "Loading threads...", showsProgress: true, }); - - const offline = workspaceState({ networkStatus: "offline", hasReadyEnvironment: false }); - expect(workspaceConnectionStatusPresentation(offline)).toEqual({ - label: "You are offline", - showsProgress: false, - }); }); }); diff --git a/apps/mobile/src/features/home/workspace-connection-status.ts b/apps/mobile/src/features/home/workspace-connection-status.ts index 6f9898b1bb01..d45a46adf933 100644 --- a/apps/mobile/src/features/home/workspace-connection-status.ts +++ b/apps/mobile/src/features/home/workspace-connection-status.ts @@ -6,7 +6,7 @@ export interface WorkspaceConnectionStatusPresentation { readonly showsProgress: boolean; } -export function shouldShowWorkspaceConnectionStatus(state: WorkspaceState): boolean { +function shouldShowWorkspaceConnectionStatus(state: WorkspaceState): boolean { return ( state.networkStatus === "offline" || state.connectionError !== null || @@ -16,7 +16,7 @@ export function shouldShowWorkspaceConnectionStatus(state: WorkspaceState): bool ); } -export function workspaceConnectionStatusLabel(state: WorkspaceState): string { +function workspaceConnectionStatusLabel(state: WorkspaceState): string { if (state.networkStatus === "offline") return "You are offline"; if (state.connectingEnvironments.length === 1) { return `Reconnecting to ${state.connectingEnvironments[0]!.environmentLabel}`; diff --git a/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts b/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts index 3c2eb9016feb..39b9c0cef26e 100644 --- a/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts +++ b/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts @@ -6,8 +6,6 @@ import type { import * as Arr from "effect/Array"; import { pipe } from "effect/Function"; import type { ResolvedMobileCodeSurface } from "../../lib/appearancePreferences"; -import { resolveMobileCodeSurface } from "../../lib/appearancePreferences"; -import { MOBILE_CODE_SURFACE } from "../../lib/typography"; import { type MobileThemeId, type MobileThemeVariables } from "../../lib/mobileTheme"; import { getMobileTerminalTheme, type TerminalAppearanceScheme } from "../terminal/terminalTheme"; import { computeWordAltDiffRanges } from "./reviewWordDiffs"; @@ -25,13 +23,8 @@ const NATIVE_HEX_COLOR = /^#([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i; const NATIVE_RGBA_COLOR = /^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)$/; -export const NATIVE_REVIEW_DIFF_ROW_HEIGHT = MOBILE_CODE_SURFACE.rowHeight; export const NATIVE_REVIEW_DIFF_CONTENT_WIDTH = 2_800; -export const NATIVE_REVIEW_DIFF_STYLE = createNativeReviewDiffStyle( - resolveMobileCodeSurface(MOBILE_CODE_SURFACE.fontSize), -); - function opaqueNativeHexColor(color: string, background: string): string { const hex = NATIVE_HEX_COLOR.exec(color); if (hex) return color; diff --git a/apps/mobile/src/features/review/reviewDiffBridgeKeys.test.ts b/apps/mobile/src/features/review/reviewDiffBridgeKeys.test.ts index 9b39a51dc90f..406665def071 100644 --- a/apps/mobile/src/features/review/reviewDiffBridgeKeys.test.ts +++ b/apps/mobile/src/features/review/reviewDiffBridgeKeys.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from "vite-plus/test"; -import { buildNativeReviewTokensResetKey, hashReviewDiffKey } from "./reviewDiffBridgeKeys"; +import { buildNativeReviewTokensResetKey } from "./reviewDiffBridgeKeys"; describe("native review diff bridge", () => { - it("builds stable reset keys from the rendered diff identity", () => { + it("changes reset keys when the rendered diff identity changes", () => { const input = { threadKey: "env:thread", sectionId: "turn:2", @@ -13,15 +13,12 @@ describe("native review diff bridge", () => { rowCount: 4, }; - expect(buildNativeReviewTokensResetKey(input)).toBe(buildNativeReviewTokensResetKey(input)); - expect(buildNativeReviewTokensResetKey({ ...input, rowCount: 5 })).not.toBe( - buildNativeReviewTokensResetKey(input), - ); - expect(buildNativeReviewTokensResetKey({ ...input, diff: null })).toContain(":empty:"); - }); + const resetKey = buildNativeReviewTokensResetKey(input); - it("includes diff length in the hash key to reduce accidental collisions", () => { - expect(hashReviewDiffKey("abc")).toMatch(/^3:/); - expect(hashReviewDiffKey("abcd")).toMatch(/^4:/); + expect( + buildNativeReviewTokensResetKey({ ...input, diff: "diff --git a/b.ts b/b.ts" }), + ).not.toBe(resetKey); + expect(buildNativeReviewTokensResetKey({ ...input, rowCount: 5 })).not.toBe(resetKey); + expect(buildNativeReviewTokensResetKey({ ...input, diff: null })).not.toBe(resetKey); }); }); diff --git a/apps/mobile/src/features/review/reviewDiffBridgeKeys.ts b/apps/mobile/src/features/review/reviewDiffBridgeKeys.ts index d04534003b37..6c7c1e545785 100644 --- a/apps/mobile/src/features/review/reviewDiffBridgeKeys.ts +++ b/apps/mobile/src/features/review/reviewDiffBridgeKeys.ts @@ -3,7 +3,7 @@ import type { NativeReviewDiffHighlightScheme } from "../diffs/nativeReviewDiffH // Pure key-derivation helpers for the native review diff bridge. Kept free of // react-native / hook imports so they stay unit-testable in node. -export function hashReviewDiffKey(diff: string | null | undefined): string { +function hashReviewDiffKey(diff: string | null | undefined): string { if (!diff) { return "empty"; } diff --git a/apps/mobile/src/features/review/reviewFileVisibility.test.ts b/apps/mobile/src/features/review/reviewFileVisibility.test.ts index 4a7a2f98af62..8fec1cbf8bd5 100644 --- a/apps/mobile/src/features/review/reviewFileVisibility.test.ts +++ b/apps/mobile/src/features/review/reviewFileVisibility.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; import { - getDefaultReviewExpandedFileIds, getValidExplicitReviewFileIds, getValidReviewFileIds, removeReviewFileId, @@ -29,7 +28,6 @@ describe("review file visibility", () => { const files = [makeFile("a.ts"), makeFile("b.ts")]; it("defaults expanded files to every renderable file", () => { - expect(getDefaultReviewExpandedFileIds(files)).toEqual(["a.ts", "b.ts"]); expect(getValidReviewFileIds(files, undefined)).toEqual(["a.ts", "b.ts"]); }); diff --git a/apps/mobile/src/features/review/reviewFileVisibility.ts b/apps/mobile/src/features/review/reviewFileVisibility.ts index 53f2d7f5f956..fbdfcf230225 100644 --- a/apps/mobile/src/features/review/reviewFileVisibility.ts +++ b/apps/mobile/src/features/review/reviewFileVisibility.ts @@ -3,7 +3,7 @@ import { useCallback, useMemo } from "react"; import { updateReviewExpandedFileIds, updateReviewViewedFileIds } from "./reviewState"; import type { ReviewRenderableFile } from "./reviewModel"; -export function getDefaultReviewExpandedFileIds( +function getDefaultReviewExpandedFileIds( files: ReadonlyArray, ): ReadonlyArray { return files.map((file) => file.id); diff --git a/apps/mobile/src/features/review/reviewModel.test.ts b/apps/mobile/src/features/review/reviewModel.test.ts index 3390afd9ff27..ee568085f7a2 100644 --- a/apps/mobile/src/features/review/reviewModel.test.ts +++ b/apps/mobile/src/features/review/reviewModel.test.ts @@ -8,7 +8,6 @@ import { } from "@t3tools/contracts"; import { - buildReviewListItems, buildReviewParsedDiff, buildReviewSectionItems, getDefaultReviewSectionId, @@ -271,84 +270,4 @@ describe("buildReviewParsedDiff", () => { actionLabel: "Load diff", }); }); - - it("flattens expanded file rows into virtualized review items", () => { - const file = makeRenderableFile({ - path: "apps/mobile/src/a.ts", - rows: [ - { - kind: "hunk", - id: "hunk-1", - header: "@@ -1,1 +1,2 @@", - context: null, - }, - { - kind: "line", - id: "line-1", - change: "add", - oldLineNumber: null, - newLineNumber: 1, - content: "const after = 2;", - additionTokenIndex: 0, - deletionTokenIndex: null, - comparison: null, - }, - ], - }); - - const items = buildReviewListItems({ - files: [file], - expandedFileIds: [file.id], - revealedLargeFileIds: [], - }); - - expect(items).toEqual([ - expect.objectContaining({ kind: "file-header", fileId: file.id, expanded: true }), - expect.objectContaining({ - kind: "hunk", - fileId: file.id, - file, - row: file.rows[0], - }), - expect.objectContaining({ - kind: "line", - fileId: file.id, - file, - row: file.rows[1], - lineIndex: 0, - }), - ]); - }); - - it("keeps large diffs collapsed into a placeholder item until revealed", () => { - const file = makeRenderableFile({ - path: "apps/mobile/src/big.ts", - rows: Array.from({ length: 401 }, (_, index) => ({ - kind: "line" as const, - id: `line-${index}`, - change: "add" as const, - oldLineNumber: null, - newLineNumber: index + 1, - content: `const line${index} = ${index};`, - additionTokenIndex: index, - deletionTokenIndex: null, - comparison: null, - })), - }); - - const items = buildReviewListItems({ - files: [file], - expandedFileIds: [file.id], - revealedLargeFileIds: [], - }); - - expect(items).toEqual([ - expect.objectContaining({ kind: "file-header", fileId: file.id, expanded: true }), - expect.objectContaining({ - kind: "file-suppressed", - fileId: file.id, - actionLabel: "Load diff", - }), - ]); - }); }); diff --git a/apps/mobile/src/features/review/reviewModel.ts b/apps/mobile/src/features/review/reviewModel.ts index 9459d41872d1..202157b837cc 100644 --- a/apps/mobile/src/features/review/reviewModel.ts +++ b/apps/mobile/src/features/review/reviewModel.ts @@ -58,45 +58,6 @@ export interface ReviewRenderableFile { readonly rows: ReadonlyArray; } -export interface ReviewFileHeaderListItem { - readonly kind: "file-header"; - readonly id: string; - readonly fileId: string; - readonly file: ReviewRenderableFile; - readonly expanded: boolean; -} - -export interface ReviewFileSuppressedListItem { - readonly kind: "file-suppressed"; - readonly id: string; - readonly fileId: string; - readonly message: string; - readonly actionLabel: string | null; -} - -export interface ReviewHunkListItem { - readonly kind: "hunk"; - readonly id: string; - readonly fileId: string; - readonly file: ReviewRenderableFile; - readonly row: ReviewRenderableHunkRow; -} - -export interface ReviewLineListItem { - readonly kind: "line"; - readonly id: string; - readonly fileId: string; - readonly file: ReviewRenderableFile; - readonly row: ReviewRenderableLineRow; - readonly lineIndex: number; -} - -export type ReviewListItem = - | ReviewFileHeaderListItem - | ReviewFileSuppressedListItem - | ReviewHunkListItem - | ReviewLineListItem; - export type ReviewFilePreviewState = | { readonly kind: "render"; @@ -316,77 +277,6 @@ export function getReviewFilePreviewState(file: ReviewRenderableFile): ReviewFil return { kind: "render" }; } -// The flattened review list item model is inspired by pierre/diffs' iterator-first -// virtualization architecture, adapted here for React Native virtualization. -// Original project: https://github.com/pingdotgg/pierre/tree/main/packages/diffs -// Reference files: -// - src/utils/iterateOverDiff.ts -// - src/components/VirtualizedFileDiff.ts -export function buildReviewListItems(input: { - readonly files: ReadonlyArray; - readonly expandedFileIds: ReadonlyArray; - readonly revealedLargeFileIds: ReadonlyArray; -}): ReadonlyArray { - const expandedFileIds = new Set(input.expandedFileIds); - const revealedLargeFileIds = new Set(input.revealedLargeFileIds); - const items: ReviewListItem[] = []; - - input.files.forEach((file) => { - const expanded = expandedFileIds.has(file.id); - items.push({ - kind: "file-header", - id: `${file.id}:header`, - fileId: file.id, - file, - expanded, - }); - - if (!expanded) { - return; - } - - const previewState = getReviewFilePreviewState(file); - if (previewState.kind === "suppressed") { - if (previewState.reason !== "large" || !revealedLargeFileIds.has(file.id)) { - items.push({ - kind: "file-suppressed", - id: `${file.id}:suppressed`, - fileId: file.id, - message: previewState.message, - actionLabel: previewState.actionLabel, - }); - return; - } - } - - let lineIndex = 0; - file.rows.forEach((row, rowIndex) => { - if (row.kind === "hunk") { - items.push({ - kind: "hunk", - id: `${file.id}:row:${rowIndex}:${row.id}`, - fileId: file.id, - file, - row, - }); - return; - } - - items.push({ - kind: "line", - id: `${file.id}:row:${rowIndex}:${row.id}`, - fileId: file.id, - file, - row, - lineIndex, - }); - lineIndex += 1; - }); - }); - - return items; -} - function fallbackHunkHeader(hunk: FileDiffMetadata["hunks"][number]): string { return `@@ -${hunk.deletionStart},${hunk.deletionCount} +${hunk.additionStart},${hunk.additionCount} @@`; } diff --git a/apps/mobile/src/features/review/shikiReviewHighlighter.test.ts b/apps/mobile/src/features/review/shikiReviewHighlighter.test.ts index f136fc236f73..519764f3b02d 100644 --- a/apps/mobile/src/features/review/shikiReviewHighlighter.test.ts +++ b/apps/mobile/src/features/review/shikiReviewHighlighter.test.ts @@ -1,145 +1,52 @@ import { describe, expect, it, vi } from "vite-plus/test"; -import type { ReviewRenderableFile } from "./reviewModel"; -import { highlightCodeSnippet, highlightReviewFile } from "./shikiReviewHighlighter"; +import type { ReviewRenderableLineRow } from "./reviewModel"; +import { + highlightCodeSnippet, + highlightReviewSelectedLines, + highlightSourceFile, +} from "./shikiReviewHighlighter"; -function makeRenderableFile( - input: Partial & Pick, -): ReviewRenderableFile { - return { - id: input.path, - cacheKey: input.path, - previousPath: null, - changeType: "new", - additions: 0, - deletions: 0, - languageHint: null, - additionLines: [], - deletionLines: [], - rows: [], - ...input, - }; -} - -describe("highlightReviewFile", () => { - it("preserves one highlighted token row per diff line even without trailing newlines", async () => { - const file = makeRenderableFile({ - path: "apps/mobile/src/example.txt", - additionLines: [ - 'const items = ["a"];', - 'expect(items).toEqual(["a"]);', - "const next = items.map((item) => item.toUpperCase());", - 'expect(next).toContain("A");', - ], +describe("highlightSourceFile", () => { + it("preserves one highlighted token row per source line without trailing newlines", async () => { + const lines = [ + 'const items = ["a"];', + 'expect(items).toEqual(["a"]);', + "const next = items.map((item) => item.toUpperCase());", + 'expect(next).toContain("A");', + ]; + + const highlighted = await highlightSourceFile({ + path: "apps/mobile/src/example.ts", + contents: lines.join("\n"), + theme: "light", }); - const highlighted = await highlightReviewFile(file, "light"); - - expect(highlighted.additionLines).toHaveLength(file.additionLines.length); - expect(highlighted.additionLines[0]?.map((token) => token.content).join("")).toBe( - file.additionLines[0], - ); - expect(highlighted.additionLines[1]?.map((token) => token.content).join("")).toBe( - file.additionLines[1], + expect(highlighted.map((tokens) => tokens.map((token) => token.content).join(""))).toEqual( + lines, ); - expect(highlighted.additionLines[2]?.map((token) => token.content).join("")).toBe( - file.additionLines[2], - ); - expect(highlighted.additionLines[3]?.map((token) => token.content).join("")).toBe( - file.additionLines[3], - ); - }); - - it("adds word-alt diff emphasis for paired deletion and addition lines", async () => { - const file = makeRenderableFile({ - path: "apps/mobile/src/example-inline-diff.txt", - additionLines: ["const after = 2;"], - deletionLines: ["const before = 1;"], - rows: [ - { - kind: "line", - id: "delete-1", - change: "delete", - oldLineNumber: 1, - newLineNumber: null, - content: "const before = 1;", - additionTokenIndex: null, - deletionTokenIndex: 0, - comparison: { change: "add", tokenIndex: 0 }, - }, - { - kind: "line", - id: "add-1", - change: "add", - oldLineNumber: null, - newLineNumber: 1, - content: "const after = 2;", - additionTokenIndex: 0, - deletionTokenIndex: null, - comparison: { change: "delete", tokenIndex: 0 }, - }, - ], - }); - - const highlighted = await highlightReviewFile(file, "light"); - - expect(highlighted.deletionLines[0]?.some((token) => token.diffHighlight === true)).toBe(true); - expect(highlighted.additionLines[0]?.some((token) => token.diffHighlight === true)).toBe(true); }); it("falls back to plain tokens for very long lines", async () => { const longLine = `const value = "${"a".repeat(1_100)}";`; - const file = makeRenderableFile({ - path: "apps/mobile/src/example-long-line.txt", - additionLines: [longLine], - rows: [ + + const highlighted = await highlightSourceFile({ + path: "apps/mobile/src/example-long-line.ts", + contents: longLine, + theme: "light", + }); + + expect(highlighted).toEqual([ + [ { - kind: "line", - id: "add-1", - change: "add", - oldLineNumber: null, - newLineNumber: 1, content: longLine, - additionTokenIndex: 0, - deletionTokenIndex: null, - comparison: null, + color: null, + fontStyle: null, }, ], - }); - - const highlighted = await highlightReviewFile(file, "light"); - - expect(highlighted.additionLines).toHaveLength(1); - expect(highlighted.additionLines[0]).toEqual([ - { - content: longLine, - color: null, - fontStyle: null, - }, ]); }); -}); -describe("highlightCodeSnippet", () => { - it("resolves language aliases and returns syntax-colored tokens", async () => { - const source = "const answer: number = 42;"; - const highlighted = await highlightCodeSnippet({ - code: source, - language: "ts", - theme: "dark", - }); - - expect( - highlighted - .flat() - .map((token) => token.content) - .join(""), - ).toBe(source); - expect(highlighted.flat().some((token) => token.color !== null)).toBe(true); - }); -}); - -describe("highlightSourceFile", () => { it("initializes source and snippet highlighting without a warmup", async () => { vi.resetModules(); const highlighter = await import("./shikiReviewHighlighter"); @@ -172,3 +79,60 @@ describe("highlightSourceFile", () => { expect(snippet.flat().some((token) => token.color !== null)).toBe(true); }); }); + +describe("highlightReviewSelectedLines", () => { + it("adds word-alt diff emphasis for paired deletion and addition lines", async () => { + const lines: ReviewRenderableLineRow[] = [ + { + kind: "line", + id: "delete-1", + change: "delete", + oldLineNumber: 1, + newLineNumber: null, + content: "const before = 1;", + additionTokenIndex: null, + deletionTokenIndex: 0, + comparison: { change: "add", tokenIndex: 0 }, + }, + { + kind: "line", + id: "add-1", + change: "add", + oldLineNumber: null, + newLineNumber: 1, + content: "const after = 2;", + additionTokenIndex: 0, + deletionTokenIndex: null, + comparison: { change: "delete", tokenIndex: 0 }, + }, + ]; + + const highlighted = await highlightReviewSelectedLines({ + filePath: "apps/mobile/src/example-inline-diff.txt", + lines, + theme: "light", + }); + + expect(highlighted["delete-1"]?.some((token) => token.diffHighlight === true)).toBe(true); + expect(highlighted["add-1"]?.some((token) => token.diffHighlight === true)).toBe(true); + }); +}); + +describe("highlightCodeSnippet", () => { + it("resolves language aliases and returns syntax-colored tokens", async () => { + const source = "const answer: number = 42;"; + const highlighted = await highlightCodeSnippet({ + code: source, + language: "ts", + theme: "dark", + }); + + expect( + highlighted + .flat() + .map((token) => token.content) + .join(""), + ).toBe(source); + expect(highlighted.flat().some((token) => token.color !== null)).toBe(true); + }); +}); diff --git a/apps/mobile/src/features/review/shikiReviewHighlighter.ts b/apps/mobile/src/features/review/shikiReviewHighlighter.ts index c6734765af23..f7e131bb9b1e 100644 --- a/apps/mobile/src/features/review/shikiReviewHighlighter.ts +++ b/apps/mobile/src/features/review/shikiReviewHighlighter.ts @@ -17,7 +17,7 @@ import { resolveReviewHighlighterEnginePreference, type ReviewHighlighterEngine, } from "./reviewHighlighterEngine"; -import type { ReviewRenderableFile, ReviewRenderableLineRow } from "./reviewModel"; +import type { ReviewRenderableLineRow } from "./reviewModel"; import { applyDiffRangesToTokens, computeWordAltDiffRanges } from "./reviewWordDiffs"; export type ReviewDiffTheme = "light" | "dark"; @@ -43,17 +43,6 @@ export interface ReviewHighlightedToken { readonly diffHighlight?: boolean; } -export interface ReviewHighlightedFile { - readonly additionLines: ReadonlyArray>; - readonly deletionLines: ReadonlyArray>; -} - -export interface ReviewHighlightFileProgress { - readonly highlightedFile: ReviewHighlightedFile; - readonly complete: boolean; - readonly highlightedLineCount: number; -} - const SHIKI_THEME_NAME_BY_SCHEME = { light: "github-light-default", dark: "github-dark-default", @@ -64,16 +53,9 @@ const REVIEW_HIGHLIGHTER_ENGINE_ENV_VALUE = const REVIEW_HIGHLIGHTER_ENGINE_PREFERENCE = resolveReviewHighlighterEnginePreference( REVIEW_HIGHLIGHTER_ENGINE_ENV_VALUE, ); -const REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE = resolveReviewHighlighterBooleanFlag( - process.env.EXPO_PUBLIC_REVIEW_HIGHLIGHTER_DISABLE_CACHE, - false, -); -const REVIEW_HIGHLIGHT_RESULT_CACHE_LIMIT = 8; const REVIEW_HIGHLIGHT_CHUNK_LINE_THRESHOLD = 8; const REVIEW_HIGHLIGHT_CHUNK_SIZE = 200; const REVIEW_TOKENIZE_MAX_LINE_LENGTH = 1_000; -const highlightCache = new Map>(); -const resolvedHighlightCache = new Map(); const REVIEW_INITIAL_LANGUAGE_MODULES = [ bashLanguage, javascriptLanguage, @@ -204,22 +186,6 @@ type LoadedLanguageModule = { default: Parameters[0]; }; -function resolveReviewHighlighterBooleanFlag( - value: string | undefined, - defaultValue: boolean, -): boolean { - switch (value) { - case "1": - case "true": - return true; - case "0": - case "false": - return false; - default: - return defaultValue; - } -} - function isReviewHighlighterDebugLoggingEnabled(): boolean { return typeof __DEV__ !== "undefined" ? __DEV__ : false; } @@ -270,7 +236,6 @@ async function getHighlighter(): Promise { logReviewHighlighterDiagnostic("initializing", { configuredPreference: REVIEW_HIGHLIGHTER_ENGINE_ENV_VALUE, preference: REVIEW_HIGHLIGHTER_ENGINE_PREFERENCE, - resultCacheDisabled: REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE, }); const themes = [githubLightDefault, githubDarkDefault]; @@ -491,13 +456,6 @@ async function resolveLanguageFromPath( return candidate; } -async function resolveLanguage(file: ReviewRenderableFile): Promise { - return ( - resolveLoadedLanguageFromPath(file.path, file.languageHint) ?? - (await resolveLanguageFromPath(file.path, file.languageHint)) - ); -} - function normalizeHighlightedLines( tokenLines: ReadonlyArray>, ): ReadonlyArray> { @@ -510,84 +468,6 @@ function normalizeHighlightedLines( ); } -function makePlainHighlightedLines( - lines: ReadonlyArray, -): ReadonlyArray> { - return lines.map((line) => [ - { - content: stripTrailingNewline(line), - color: null, - fontStyle: null, - }, - ]); -} - -function applyWordAltDiffHighlightsToFile( - file: ReviewRenderableFile, - highlighted: ReviewHighlightedFile, -): ReviewHighlightedFile { - const nextAdditionLines = [...highlighted.additionLines]; - const nextDeletionLines = [...highlighted.deletionLines]; - const processedPairs = new Set(); - let changed = false; - - file.rows.forEach((row) => { - if (row.kind !== "line" || row.change === "context" || !row.comparison) { - return; - } - - const deletionTokenIndex = - row.change === "delete" - ? row.deletionTokenIndex - : row.comparison.change === "delete" - ? row.comparison.tokenIndex - : null; - const additionTokenIndex = - row.change === "add" - ? row.additionTokenIndex - : row.comparison.change === "add" - ? row.comparison.tokenIndex - : null; - - if (deletionTokenIndex === null || additionTokenIndex === null) { - return; - } - - const pairKey = `${deletionTokenIndex}:${additionTokenIndex}`; - if (processedPairs.has(pairKey)) { - return; - } - processedPairs.add(pairKey); - - const deletionLine = stripTrailingNewline(file.deletionLines[deletionTokenIndex] ?? ""); - const additionLine = stripTrailingNewline(file.additionLines[additionTokenIndex] ?? ""); - const ranges = computeWordAltDiffRanges({ deletionLine, additionLine }); - - if (ranges.deletion.length > 0) { - nextDeletionLines[deletionTokenIndex] = applyDiffRangesToTokens( - nextDeletionLines[deletionTokenIndex] ?? [], - ranges.deletion, - ); - changed = true; - } - - if (ranges.addition.length > 0) { - nextAdditionLines[additionTokenIndex] = applyDiffRangesToTokens( - nextAdditionLines[additionTokenIndex] ?? [], - ranges.addition, - ); - changed = true; - } - }); - - return changed - ? { - additionLines: nextAdditionLines, - deletionLines: nextDeletionLines, - } - : highlighted; -} - function applyWordAltDiffHighlightsToSelectedLines(input: { readonly lines: ReadonlyArray; readonly tokenMap: Record>; @@ -736,292 +616,6 @@ export async function highlightSourceFile(input: { return highlightLines(input.contents, language, SHIKI_THEME_NAME_BY_SCHEME[input.theme]); } -async function highlightPatchLinesInChunks(input: { - readonly lines: ReadonlyArray; - readonly language: string; - readonly theme: string; - readonly onChunk: ( - startIndex: number, - tokens: ReadonlyArray>, - ) => void; -}): Promise>> { - if (input.lines.length === 0) { - return []; - } - - const highlighter = await getHighlighter(); - const highlightedLines: Array> = []; - - for ( - let startIndex = 0; - startIndex < input.lines.length; - startIndex += REVIEW_HIGHLIGHT_CHUNK_SIZE - ) { - const lineChunk = input.lines.slice(startIndex, startIndex + REVIEW_HIGHLIGHT_CHUNK_SIZE); - const chunkTokens: Array> = []; - const tokenizableLines: string[] = []; - const tokenizableIndexes: number[] = []; - - lineChunk.forEach((line, index) => { - const strippedLine = stripTrailingNewline(line); - if (strippedLine.length > REVIEW_TOKENIZE_MAX_LINE_LENGTH) { - chunkTokens[index] = [{ content: strippedLine, color: null, fontStyle: null }]; - return; - } - - tokenizableIndexes.push(index); - tokenizableLines.push(strippedLine); - }); - - if (tokenizableLines.length > 0) { - const tokenLines = highlighter.codeToTokensBase(tokenizableLines.join("\n"), { - lang: input.language, - theme: input.theme, - }); - const normalizedTokenLines = normalizeHighlightedLines(tokenLines); - - tokenizableIndexes.forEach((chunkIndex, tokenIndex) => { - chunkTokens[chunkIndex] = normalizedTokenLines[tokenIndex] ?? []; - }); - } - - const completedChunk = lineChunk.map((_, index) => chunkTokens[index] ?? []); - highlightedLines.push(...completedChunk); - input.onChunk(startIndex, completedChunk); - - if (startIndex + REVIEW_HIGHLIGHT_CHUNK_SIZE < input.lines.length) { - await waitForNextFrame(); - } - } - - return highlightedLines; -} - -function getHighlightCacheKey(file: ReviewRenderableFile, theme: ReviewDiffTheme): string { - return `${SHIKI_THEME_NAME_BY_SCHEME[theme]}:${file.cacheKey}`; -} - -function storeResolvedHighlightedFile(cacheKey: string, highlighted: ReviewHighlightedFile): void { - if (resolvedHighlightCache.has(cacheKey)) { - resolvedHighlightCache.delete(cacheKey); - } - - resolvedHighlightCache.set(cacheKey, highlighted); - - while (resolvedHighlightCache.size > REVIEW_HIGHLIGHT_RESULT_CACHE_LIMIT) { - const oldestKey = resolvedHighlightCache.keys().next().value; - if (oldestKey === undefined) { - break; - } - resolvedHighlightCache.delete(oldestKey); - } -} - -export async function highlightReviewFile( - file: ReviewRenderableFile, - theme: ReviewDiffTheme, -): Promise { - const shikiTheme = SHIKI_THEME_NAME_BY_SCHEME[theme]; - const cacheKey = getHighlightCacheKey(file, theme); - if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) { - const resolved = resolvedHighlightCache.get(cacheKey); - if (resolved) { - logReviewHighlighterDiagnostic("file highlight cache hit (resolved)", { - fileId: file.id, - filePath: file.path, - theme, - }); - return resolved; - } - const cached = highlightCache.get(cacheKey); - if (cached) { - logReviewHighlighterDiagnostic("file highlight cache hit (pending)", { - fileId: file.id, - filePath: file.path, - theme, - }); - return cached; - } - } - - const promise = (async () => { - const startedAt = Date.now(); - logReviewHighlighterDiagnostic("file highlight start", { - fileId: file.id, - filePath: file.path, - theme, - additionLineCount: file.additionLines.length, - deletionLineCount: file.deletionLines.length, - rowCount: file.rows.length, - }); - const loadedLanguage = resolveLoadedLanguageFromPath(file.path, file.languageHint); - const language = loadedLanguage ?? (await resolveLanguage(file)); - if (language === "text") { - const highlighted = applyWordAltDiffHighlightsToFile(file, { - additionLines: makePlainHighlightedLines(file.additionLines), - deletionLines: makePlainHighlightedLines(file.deletionLines), - }); - if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) { - storeResolvedHighlightedFile(cacheKey, highlighted); - } - logReviewHighlighterDiagnostic("file highlight complete", { - fileId: file.id, - filePath: file.path, - theme, - language, - highlightedAdditionLineCount: highlighted.additionLines.length, - highlightedDeletionLineCount: highlighted.deletionLines.length, - durationMs: Date.now() - startedAt, - }); - return highlighted; - } - - const additionLines = await highlightLines( - joinPatchLines(file.additionLines), - language, - shikiTheme, - ); - await waitForNextFrame(); - const deletionLines = await highlightLines( - joinPatchLines(file.deletionLines), - language, - shikiTheme, - ); - await waitForNextFrame(); - - const highlighted = applyWordAltDiffHighlightsToFile(file, { additionLines, deletionLines }); - if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) { - storeResolvedHighlightedFile(cacheKey, highlighted); - } - logReviewHighlighterDiagnostic("file highlight complete", { - fileId: file.id, - filePath: file.path, - theme, - language, - highlightedAdditionLineCount: highlighted.additionLines.length, - highlightedDeletionLineCount: highlighted.deletionLines.length, - durationMs: Date.now() - startedAt, - }); - return highlighted; - })(); - - if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) { - highlightCache.set(cacheKey, promise); - } - return promise.finally(() => { - if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) { - highlightCache.delete(cacheKey); - } - }); -} - -export async function streamHighlightReviewFile( - file: ReviewRenderableFile, - theme: ReviewDiffTheme, - onProgress: (progress: ReviewHighlightFileProgress) => void, -): Promise { - const shikiTheme = SHIKI_THEME_NAME_BY_SCHEME[theme]; - const cacheKey = getHighlightCacheKey(file, theme); - if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) { - const resolved = resolvedHighlightCache.get(cacheKey); - if (resolved) { - onProgress({ - highlightedFile: resolved, - complete: true, - highlightedLineCount: resolved.additionLines.length + resolved.deletionLines.length, - }); - return resolved; - } - } - - const startedAt = Date.now(); - logReviewHighlighterDiagnostic("file stream highlight start", { - fileId: file.id, - filePath: file.path, - theme, - additionLineCount: file.additionLines.length, - deletionLineCount: file.deletionLines.length, - rowCount: file.rows.length, - }); - - const loadedLanguage = resolveLoadedLanguageFromPath(file.path, file.languageHint); - const language = loadedLanguage ?? (await resolveLanguage(file)); - if (language === "text") { - const highlighted = applyWordAltDiffHighlightsToFile(file, { - additionLines: makePlainHighlightedLines(file.additionLines), - deletionLines: makePlainHighlightedLines(file.deletionLines), - }); - if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) { - storeResolvedHighlightedFile(cacheKey, highlighted); - } - onProgress({ - highlightedFile: highlighted, - complete: true, - highlightedLineCount: highlighted.additionLines.length + highlighted.deletionLines.length, - }); - logReviewHighlighterDiagnostic("file stream highlight complete", { - fileId: file.id, - filePath: file.path, - theme, - language, - highlightedAdditionLineCount: highlighted.additionLines.length, - highlightedDeletionLineCount: highlighted.deletionLines.length, - highlightedLineCount: highlighted.additionLines.length + highlighted.deletionLines.length, - durationMs: Date.now() - startedAt, - }); - return highlighted; - } - - const additionLines: Array> = []; - const deletionLines: Array> = []; - let highlightedLineCount = 0; - - await highlightPatchLinesInChunks({ - lines: file.additionLines, - language, - theme: shikiTheme, - onChunk: (startIndex, tokens) => { - tokens.forEach((lineTokens, index) => { - additionLines[startIndex + index] = lineTokens; - }); - highlightedLineCount += tokens.length; - }, - }); - await waitForNextFrame(); - await highlightPatchLinesInChunks({ - lines: file.deletionLines, - language, - theme: shikiTheme, - onChunk: (startIndex, tokens) => { - tokens.forEach((lineTokens, index) => { - deletionLines[startIndex + index] = lineTokens; - }); - highlightedLineCount += tokens.length; - }, - }); - - const highlighted = applyWordAltDiffHighlightsToFile(file, { additionLines, deletionLines }); - if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) { - storeResolvedHighlightedFile(cacheKey, highlighted); - } - onProgress({ - highlightedFile: highlighted, - complete: true, - highlightedLineCount, - }); - logReviewHighlighterDiagnostic("file stream highlight complete", { - fileId: file.id, - filePath: file.path, - theme, - language, - highlightedAdditionLineCount: highlighted.additionLines.length, - highlightedDeletionLineCount: highlighted.deletionLines.length, - highlightedLineCount, - durationMs: Date.now() - startedAt, - }); - return highlighted; -} - export async function highlightReviewSelectedLines(input: { readonly filePath: string; readonly lines: ReadonlyArray; diff --git a/apps/mobile/src/features/review/useNativeReviewDiffBridge.ts b/apps/mobile/src/features/review/useNativeReviewDiffBridge.ts index c6a656e012f7..1728da662686 100644 --- a/apps/mobile/src/features/review/useNativeReviewDiffBridge.ts +++ b/apps/mobile/src/features/review/useNativeReviewDiffBridge.ts @@ -8,7 +8,7 @@ import { useNativeReviewDiffHighlighting } from "./useNativeReviewDiffHighlighti import { buildNativeReviewTokensResetKey } from "./reviewDiffBridgeKeys"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; -export { buildNativeReviewTokensResetKey, hashReviewDiffKey } from "./reviewDiffBridgeKeys"; +export { buildNativeReviewTokensResetKey } from "./reviewDiffBridgeKeys"; export function useNativeReviewDiffBridge(input: { readonly threadKey: string | null; diff --git a/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx b/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx index 79d67ebaa7c2..f161e654788b 100644 --- a/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx +++ b/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx @@ -37,7 +37,6 @@ import { getMobileUniwindThemeName, type MobileThemeRuntimeState, } from "../../../lib/mobileThemeRuntime"; -import { cacheTerminalFontSize } from "../../terminal/terminalUiState"; interface AppearancePreferencesContextValue { /** Effective values with base-size derivation applied. Use this for rendering. */ @@ -143,8 +142,7 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN useLayoutEffect(() => { selectedThemeIdsRef.current = themeIds; syncThemeRuntime(runtimeState); - cacheTerminalFontSize(appearance.terminalFontSize); - }, [appearance.terminalFontSize, runtimeState, syncThemeRuntime, themeIds]); + }, [runtimeState, syncThemeRuntime, themeIds]); const setThemeIdForAppearance = useCallback( (appearance: MobileThemeAppearance, value: MobileThemeId) => { diff --git a/apps/mobile/src/features/terminal/terminalTheme.test.ts b/apps/mobile/src/features/terminal/terminalTheme.test.ts index 24edb384bebb..478ddc96d960 100644 --- a/apps/mobile/src/features/terminal/terminalTheme.test.ts +++ b/apps/mobile/src/features/terminal/terminalTheme.test.ts @@ -3,15 +3,11 @@ import { BUILT_IN_THEMES, getThemeColorsForAppearance } from "@t3tools/shared/th import { themeColorToNativeColor } from "../../lib/mobileTheme"; -import { - buildGhosttyThemeConfig, - getMobileTerminalTheme, - getPierreTerminalTheme, -} from "./terminalTheme"; +import { buildGhosttyThemeConfig, getMobileTerminalTheme } from "./terminalTheme"; -describe("getPierreTerminalTheme", () => { - it("returns the Pierre light terminal palette", () => { - expect(getPierreTerminalTheme("light")).toMatchObject({ +describe("getMobileTerminalTheme", () => { + it("preserves the default light terminal palette", () => { + expect(getMobileTerminalTheme("t3-code", "light")).toMatchObject({ background: "#f2f2f7", foreground: "#6C6C71", cursorForeground: "#009fff", @@ -19,23 +15,14 @@ describe("getPierreTerminalTheme", () => { }); }); - it("returns the Pierre dark terminal palette", () => { - expect(getPierreTerminalTheme("dark")).toMatchObject({ + it("preserves the default dark terminal palette", () => { + expect(getMobileTerminalTheme("t3-code", "dark")).toMatchObject({ background: "#0a0a0a", foreground: "#adadb1", cursorForeground: "#009fff", cursorBackground: "#0a0a0a", }); }); -}); - -describe("getMobileTerminalTheme", () => { - it("preserves the Pierre terminal for the default theme", () => { - for (const scheme of ["light", "dark"] as const) { - expect(getMobileTerminalTheme("t3-code", scheme)).toEqual(getPierreTerminalTheme(scheme)); - } - }); - it("applies the selected palette without replacing ANSI status colors", () => { const standard = getMobileTerminalTheme("t3-code", "dark"); const ocean = getMobileTerminalTheme("ocean", "dark"); @@ -58,7 +45,7 @@ describe("getMobileTerminalTheme", () => { describe("buildGhosttyThemeConfig", () => { it("serializes theme colors into a ghostty config file", () => { - const config = buildGhosttyThemeConfig(getPierreTerminalTheme("dark")); + const config = buildGhosttyThemeConfig(getMobileTerminalTheme("t3-code", "dark")); expect(config).toContain("background = #0a0a0a"); expect(config).toContain("foreground = #adadb1"); diff --git a/apps/mobile/src/features/terminal/terminalTheme.ts b/apps/mobile/src/features/terminal/terminalTheme.ts index 9a913022571d..569b10f7bd55 100644 --- a/apps/mobile/src/features/terminal/terminalTheme.ts +++ b/apps/mobile/src/features/terminal/terminalTheme.ts @@ -74,7 +74,7 @@ const PIERRE_DARK_THEME: TerminalTheme = { ], }; -export function getPierreTerminalTheme(scheme: TerminalAppearanceScheme): TerminalTheme { +function getPierreTerminalTheme(scheme: TerminalAppearanceScheme): TerminalTheme { return scheme === "light" ? PIERRE_LIGHT_THEME : PIERRE_DARK_THEME; } diff --git a/apps/mobile/src/features/terminal/terminalUiState.test.ts b/apps/mobile/src/features/terminal/terminalUiState.test.ts index 0bb3c1395915..6879fdfdbb20 100644 --- a/apps/mobile/src/features/terminal/terminalUiState.test.ts +++ b/apps/mobile/src/features/terminal/terminalUiState.test.ts @@ -2,9 +2,7 @@ import { beforeEach, describe, expect, it } from "vite-plus/test"; import { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { - cacheTerminalFontSize, cacheTerminalGridSize, - getCachedTerminalFontSize, getCachedTerminalGridSize, resetTerminalUiStateCaches, } from "./terminalUiState"; @@ -14,14 +12,6 @@ describe("terminalUiState", () => { resetTerminalUiStateCaches(); }); - it("caches terminal font size using the shared normalization rules", () => { - expect(getCachedTerminalFontSize()).toBeNull(); - expect(cacheTerminalFontSize(8.5)).toBe(8.5); - expect(getCachedTerminalFontSize()).toBe(8.5); - expect(cacheTerminalFontSize(100)).toBe(14); - expect(getCachedTerminalFontSize()).toBe(14); - }); - it("stores terminal grid sizes per terminal target", () => { const primaryTarget = { environmentId: EnvironmentId.make("env-1"), diff --git a/apps/mobile/src/features/terminal/terminalUiState.ts b/apps/mobile/src/features/terminal/terminalUiState.ts index 2cac0bf52b9e..84274430e8a1 100644 --- a/apps/mobile/src/features/terminal/terminalUiState.ts +++ b/apps/mobile/src/features/terminal/terminalUiState.ts @@ -1,7 +1,5 @@ import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; -import { DEFAULT_TERMINAL_FONT_SIZE, normalizeTerminalFontSize } from "./terminalPreferences"; - export interface TerminalGridSize { readonly cols: number; readonly rows: number; @@ -14,22 +12,11 @@ export interface TerminalUiStateTarget { } const terminalGridSizeCache = new Map(); -let cachedTerminalFontSize: number | null = null; function terminalUiStateKey(target: TerminalUiStateTarget): string { return `${target.environmentId}:${target.threadId}:${target.terminalId}`; } -export function getCachedTerminalFontSize(): number | null { - return cachedTerminalFontSize; -} - -export function cacheTerminalFontSize(value: number | null | undefined): number { - const normalized = normalizeTerminalFontSize(value ?? DEFAULT_TERMINAL_FONT_SIZE); - cachedTerminalFontSize = normalized; - return normalized; -} - export function getCachedTerminalGridSize(target: TerminalUiStateTarget): TerminalGridSize | null { return terminalGridSizeCache.get(terminalUiStateKey(target)) ?? null; } @@ -47,6 +34,5 @@ export function cacheTerminalGridSize( } export function resetTerminalUiStateCaches() { - cachedTerminalFontSize = null; terminalGridSizeCache.clear(); } diff --git a/apps/mobile/src/features/threads/new-task-project-selection.test.ts b/apps/mobile/src/features/threads/new-task-project-selection.test.ts index 7068a95d558a..2d52ed716d50 100644 --- a/apps/mobile/src/features/threads/new-task-project-selection.test.ts +++ b/apps/mobile/src/features/threads/new-task-project-selection.test.ts @@ -4,7 +4,6 @@ import { describe, expect, it } from "vite-plus/test"; import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import type { HomeProjectScope } from "../home/homeThreadList"; import { - getOnlySelectableProject, getProjectScopeSelectionTarget, resolveDraftProjectSelection, } from "./new-task-project-selection"; @@ -36,18 +35,6 @@ function makeScope(projects: ReadonlyArray): HomeProjectScop }; } -describe("getOnlySelectableProject", () => { - it("auto-selects when there is exactly one physical project", () => { - const project = makeProject("t3code"); - expect(getOnlySelectableProject([makeScope([project])])).toBe(project); - }); - - it("selects the representative when one logical project has multiple workspaces", () => { - const projects = [makeProject("t3code"), makeProject("t3code-2"), makeProject("t3code-3")]; - expect(getOnlySelectableProject([makeScope(projects)])).toBe(projects[0]); - }); -}); - describe("getProjectScopeSelectionTarget", () => { it("keeps the current environment when it hosts the selected logical project", () => { const projects = [makeProject("t3code-mac", "mac"), makeProject("t3code-server", "server")]; diff --git a/apps/mobile/src/features/threads/new-task-project-selection.ts b/apps/mobile/src/features/threads/new-task-project-selection.ts index 7be899d62a1a..0528dc66687a 100644 --- a/apps/mobile/src/features/threads/new-task-project-selection.ts +++ b/apps/mobile/src/features/threads/new-task-project-selection.ts @@ -19,7 +19,7 @@ export function getProjectScopeSelectionTarget( ); } -export function getOnlySelectableProject( +function getOnlySelectableProject( projectScopes: ReadonlyArray, ): EnvironmentProject | null { const onlyScope = projectScopes.length === 1 ? projectScopes[0] : null; diff --git a/apps/mobile/src/lib/appearancePreferences.test.ts b/apps/mobile/src/lib/appearancePreferences.test.ts index 3458f0120f99..417a66138d95 100644 --- a/apps/mobile/src/lib/appearancePreferences.test.ts +++ b/apps/mobile/src/lib/appearancePreferences.test.ts @@ -2,19 +2,13 @@ import { describe, expect, it } from "vite-plus/test"; import { DEFAULT_BASE_FONT_SIZE, - deriveCodeFontSize, - deriveTerminalFontSize, normalizeBaseFontSize, - normalizeCodeFontSize, - normalizeCodeWordBreak, resolveAppearance, resolveAppearancePreferences, resolveMarkdownFontSizes, resolveMobileCodeSurface, resolveNativeMarkdownTypography, resolveTextScaleVariables, - stepBaseFontSize, - stepCodeFontSize, stepTerminalFontSize, } from "./appearancePreferences"; @@ -50,10 +44,8 @@ describe("appearancePreferences", () => { expect(appearance.isCodeFontSizeCustom).toBe(false); const scaled = resolveAppearance(resolveAppearancePreferences({ baseFontSize: 22 })); - expect(scaled.terminalFontSize).toBe(deriveTerminalFontSize(22)); - expect(scaled.codeFontSize).toBe(deriveCodeFontSize(22)); - expect(scaled.terminalFontSize).toBeGreaterThan(10); - expect(scaled.codeFontSize).toBeGreaterThan(11); + expect(scaled.terminalFontSize).toBe(14); + expect(scaled.codeFontSize).toBe(17); }); it("applies explicit overrides over derived values", () => { @@ -69,15 +61,12 @@ describe("appearancePreferences", () => { it("clamps base and code font sizes", () => { expect(normalizeBaseFontSize(4)).toBe(11); expect(normalizeBaseFontSize(30)).toBe(22); - expect(normalizeCodeFontSize(4)).toBe(8); - expect(normalizeCodeFontSize(30)).toBe(18); + expect(resolveAppearancePreferences({ codeFontSize: 4 }).codeFontSize).toBe(8); + expect(resolveAppearancePreferences({ codeFontSize: 30 }).codeFontSize).toBe(18); }); - it("steps font sizes within bounds", () => { + it("steps terminal font size within bounds", () => { expect(stepTerminalFontSize(6, -1)).toBe(6); - expect(stepBaseFontSize(11, -1)).toBe(11); - expect(stepCodeFontSize(8, -1)).toBe(8); - expect(stepBaseFontSize(15, 1)).toBe(16); }); it("scales markdown typography from the base size", () => { @@ -97,9 +86,8 @@ describe("appearancePreferences", () => { }); }); - it("defaults code word break to false", () => { - expect(normalizeCodeWordBreak(undefined)).toBe(false); - expect(normalizeCodeWordBreak(true)).toBe(true); + it("keeps explicit code word break enabled", () => { + expect(resolveAppearancePreferences({ codeWordBreak: true }).codeWordBreak).toBe(true); }); it("returns the authored text scale at the 16pt default", () => { diff --git a/apps/mobile/src/lib/appearancePreferences.ts b/apps/mobile/src/lib/appearancePreferences.ts index d2504a629dda..2ce6a8b5a367 100644 --- a/apps/mobile/src/lib/appearancePreferences.ts +++ b/apps/mobile/src/lib/appearancePreferences.ts @@ -75,7 +75,7 @@ export function normalizeBaseFontSize(value: number | null | undefined): number return Math.min(MAX_BASE_FONT_SIZE, Math.max(MIN_BASE_FONT_SIZE, Math.round(value))); } -export function normalizeCodeFontSize(value: number | null | undefined): number { +function normalizeCodeFontSize(value: number | null | undefined): number { if (typeof value !== "number" || !Number.isFinite(value)) { return DEFAULT_CODE_FONT_SIZE; } @@ -83,18 +83,18 @@ export function normalizeCodeFontSize(value: number | null | undefined): number return Math.min(MAX_CODE_FONT_SIZE, Math.max(MIN_CODE_FONT_SIZE, Math.round(value))); } -export function normalizeCodeWordBreak(value: boolean | null | undefined): boolean { +function normalizeCodeWordBreak(value: boolean | null | undefined): boolean { return value === true; } /** Terminal size derived from base: 10.5pt at base 16, snapped to 0.5pt steps. */ -export function deriveTerminalFontSize(baseFontSize: number): number { +function deriveTerminalFontSize(baseFontSize: number): number { const scale = normalizeBaseFontSize(baseFontSize) / DEFAULT_BASE_FONT_SIZE; return normalizeTerminalFontSize(Math.round(DEFAULT_TERMINAL_FONT_SIZE * scale * 2) / 2); } /** Code/diff size derived from base: 12pt at base 16. */ -export function deriveCodeFontSize(baseFontSize: number): number { +function deriveCodeFontSize(baseFontSize: number): number { const scale = normalizeBaseFontSize(baseFontSize) / DEFAULT_BASE_FONT_SIZE; return normalizeCodeFontSize(Math.round(DEFAULT_CODE_FONT_SIZE * scale)); } @@ -235,22 +235,12 @@ export function resolveNativeMarkdownTypography(baseFontSize: number): NativeMar }; } -export function stepBaseFontSize(current: number, direction: -1 | 1): number { - const next = direction === -1 ? current - BASE_FONT_SIZE_STEP : current + BASE_FONT_SIZE_STEP; - return normalizeBaseFontSize(next); -} - export function stepTerminalFontSize(current: number, direction: -1 | 1): number { const next = direction === -1 ? current - TERMINAL_FONT_SIZE_STEP : current + TERMINAL_FONT_SIZE_STEP; return normalizeTerminalFontSize(next); } -export function stepCodeFontSize(current: number, direction: -1 | 1): number { - const next = direction === -1 ? current - CODE_FONT_SIZE_STEP : current + CODE_FONT_SIZE_STEP; - return normalizeCodeFontSize(next); -} - export { DEFAULT_TERMINAL_FONT_SIZE, MAX_TERMINAL_FONT_SIZE, diff --git a/apps/mobile/src/lib/commandMetadata.test.ts b/apps/mobile/src/lib/commandMetadata.test.ts deleted file mode 100644 index d1ba1d86eba9..000000000000 --- a/apps/mobile/src/lib/commandMetadata.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { describe, expect, it, vi } from "vite-plus/test"; - -import { makeQueuedMessageMetadata, makeTurnCommandMetadata } from "./commandMetadata"; - -vi.mock("expo-crypto", () => ({ - randomUUID: () => crypto.randomUUID(), -})); - -describe("mobile command metadata", () => { - it("creates ids and timestamps for thread starts", () => { - const metadata = makeTurnCommandMetadata(); - - expect(metadata.commandId).toMatch( - /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, - ); - expect(metadata.messageId).toMatch( - /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, - ); - expect(metadata.threadId).toMatch( - /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, - ); - expect(metadata.createdAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); - }); - - it("creates ids and timestamps for queued messages", () => { - const metadata = makeQueuedMessageMetadata(); - - expect(metadata.commandId).toMatch( - /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, - ); - expect(metadata.messageId).toMatch( - /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, - ); - expect(metadata.createdAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); - }); -}); diff --git a/apps/mobile/src/lib/layout.test.ts b/apps/mobile/src/lib/layout.test.ts index 8342fd1aeebc..7d288b1352a5 100644 --- a/apps/mobile/src/lib/layout.test.ts +++ b/apps/mobile/src/lib/layout.test.ts @@ -2,11 +2,9 @@ import { describe, expect, it } from "vite-plus/test"; import { constrainAuxiliaryPaneWidth, - constrainPrimarySidebarWidth, deriveCenteredContentHorizontalPadding, deriveFileInspectorPaneLayout, deriveLayout, - deriveStableFormSheetDetent, deriveThreadFeedInitialContentInset, deriveThreadWorkLogSizing, deriveWorkspacePaneLayout, @@ -75,12 +73,6 @@ describe("deriveThreadFeedInitialContentInset", () => { }); describe("resizable pane constraints", () => { - it("keeps a preferred sidebar width across large windows and clamps it in a narrow split view", () => { - expect(constrainPrimarySidebarWidth(430, 1_366)).toBe(430); - expect(constrainPrimarySidebarWidth(430, 744)).toBe(384); - expect(constrainPrimarySidebarWidth(100, 1_366)).toBe(280); - }); - it("preserves a useful main pane while constraining a trailing pane", () => { expect(constrainAuxiliaryPaneWidth({ preferredWidth: 440, availableWidth: 1_100 })).toBe(440); expect(constrainAuxiliaryPaneWidth({ preferredWidth: 440, availableWidth: 900 })).toBe(340); @@ -392,14 +384,3 @@ describe("deriveWorkspacePaneLayout", () => { }); }); }); - -describe("deriveStableFormSheetDetent", () => { - it.each([ - { height: 1_194, expected: 0.62 }, - { height: 834, expected: 0.863 }, - { height: 600, expected: 0.893 }, - { height: 0, expected: 0.92 }, - ])("derives a stable sheet detent for height $height", ({ height, expected }) => { - expect(deriveStableFormSheetDetent(height)).toBe(expected); - }); -}); diff --git a/apps/mobile/src/lib/layout.ts b/apps/mobile/src/lib/layout.ts index 4199dc8dc8ed..ee38ac020e74 100644 --- a/apps/mobile/src/lib/layout.ts +++ b/apps/mobile/src/lib/layout.ts @@ -16,7 +16,6 @@ export const SPLIT_LAYOUT_MIN_WIDTH = 720; export const SPLIT_LAYOUT_MIN_HEIGHT = 600; export const SPLIT_SIDEBAR_MIN_WIDTH = 280; -export const SPLIT_SIDEBAR_MAX_WIDTH = 460; const SPLIT_SIDEBAR_DEFAULT_MAX_WIDTH = 380; export const AUXILIARY_PANE_MIN_CONTENT_WIDTH = 960; @@ -50,10 +49,6 @@ export const AUXILIARY_PANE_MAX_WIDTH = 480; const AUXILIARY_PANE_DEFAULT_MAX_WIDTH = 320; const FILE_INSPECTOR_MIN_VIEWPORT_WIDTH = 820; const FILE_INSPECTOR_MIN_MAIN_WIDTH = 560; -const STABLE_FORM_SHEET_MAX_HEIGHT = 720; -const STABLE_FORM_SHEET_VERTICAL_MARGIN = 64; -const STABLE_FORM_SHEET_MIN_DETENT = 0.62; -const STABLE_FORM_SHEET_MAX_DETENT = 0.92; export type LayoutVariant = "compact" | "split"; @@ -218,22 +213,6 @@ export function deriveFileInspectorPaneLayout(input: { }; } -/** Keep a user-selected sidebar width useful as a window is resized. */ -export function constrainPrimarySidebarWidth( - preferredWidth: number, - viewportWidth = Number.POSITIVE_INFINITY, -): number { - const safeWidth = Number.isFinite(preferredWidth) ? preferredWidth : SPLIT_SIDEBAR_MIN_WIDTH; - const viewportMax = Number.isFinite(viewportWidth) - ? Math.max(SPLIT_SIDEBAR_MIN_WIDTH, viewportWidth - 360) - : SPLIT_SIDEBAR_MAX_WIDTH; - return clamp( - Math.round(safeWidth), - SPLIT_SIDEBAR_MIN_WIDTH, - Math.min(SPLIT_SIDEBAR_MAX_WIDTH, viewportMax), - ); -} - /** * Keep an auxiliary pane within native-feeling bounds without squeezing its * neighboring content below a usable reading/editor width. @@ -275,20 +254,3 @@ export function deriveCenteredContentHorizontalPadding(input: { return minimumPadding + Math.max(0, (viewportWidth - input.maxContentWidth) / 2); } - -export function deriveStableFormSheetDetent(containerHeight: number): number { - if (!Number.isFinite(containerHeight) || containerHeight <= 0) { - return STABLE_FORM_SHEET_MAX_DETENT; - } - - const targetHeight = Math.min( - STABLE_FORM_SHEET_MAX_HEIGHT, - Math.max(0, containerHeight - STABLE_FORM_SHEET_VERTICAL_MARGIN), - ); - const detent = clamp( - targetHeight / containerHeight, - STABLE_FORM_SHEET_MIN_DETENT, - STABLE_FORM_SHEET_MAX_DETENT, - ); - return Math.round(detent * 1_000) / 1_000; -} diff --git a/apps/mobile/src/lib/providerOptions.test.ts b/apps/mobile/src/lib/providerOptions.test.ts index d87df6baaf1d..9b94cecb3db9 100644 --- a/apps/mobile/src/lib/providerOptions.test.ts +++ b/apps/mobile/src/lib/providerOptions.test.ts @@ -2,11 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; import type { ModelCapabilities } from "@t3tools/contracts"; -import { - applyProviderOptionSelection, - providerOptionValueLabels, - resolveProviderOptionDescriptors, -} from "./providerOptions"; +import { applyProviderOptionSelection, resolveProviderOptionDescriptors } from "./providerOptions"; const CODEX_CAPABILITIES: ModelCapabilities = { optionDescriptors: [ @@ -34,15 +30,6 @@ const CODEX_CAPABILITIES: ModelCapabilities = { }; describe("mobile provider options", () => { - it("summarizes the option values currently in effect", () => { - const descriptors = resolveProviderOptionDescriptors({ - capabilities: CODEX_CAPABILITIES, - selections: undefined, - }); - - expect(providerOptionValueLabels(descriptors)).toEqual(["Medium", "Standard"]); - }); - it("updates generic select options without knowing provider-specific ids", () => { const descriptors = resolveProviderOptionDescriptors({ capabilities: CODEX_CAPABILITIES, @@ -62,7 +49,7 @@ describe("mobile provider options", () => { expect(applyProviderOptionSelection(descriptors, { id: "unknown", value: "high" })).toBeNull(); }); - it("treats an unspecified boolean capability as off", () => { + it("updates generic boolean options", () => { const descriptors = resolveProviderOptionDescriptors({ capabilities: { optionDescriptors: [{ id: "fastMode", label: "Fast Mode", type: "boolean" }], @@ -70,7 +57,6 @@ describe("mobile provider options", () => { selections: undefined, }); - expect(providerOptionValueLabels(descriptors)).toEqual([]); expect(applyProviderOptionSelection(descriptors, { id: "fastMode", value: true })).toEqual([ { id: "fastMode", value: true }, ]); diff --git a/apps/mobile/src/lib/providerOptions.ts b/apps/mobile/src/lib/providerOptions.ts index 593f5a37442c..dec0d327030d 100644 --- a/apps/mobile/src/lib/providerOptions.ts +++ b/apps/mobile/src/lib/providerOptions.ts @@ -5,7 +5,6 @@ import type { } from "@t3tools/contracts"; import { buildProviderOptionSelectionsFromDescriptors, - getProviderOptionCurrentLabel, getProviderOptionDescriptors, } from "@t3tools/shared/model"; @@ -22,23 +21,6 @@ export function resolveProviderOptionDescriptors(input: { }); } -/** - * Labels for the option values currently in effect (select values plus - * enabled booleans), used to summarize the thread configuration in the - * composer trigger pill. - */ -export function providerOptionValueLabels( - descriptors: ReadonlyArray, -): ReadonlyArray { - return descriptors.flatMap((descriptor) => { - if (descriptor.type === "boolean") { - return descriptor.currentValue ? [descriptor.label] : []; - } - const label = getProviderOptionCurrentLabel(descriptor); - return label ? [label] : []; - }); -} - /** * Applies one option change (by descriptor id) and returns the full selection * list to store on the model selection, or null when the change doesn't match diff --git a/apps/mobile/src/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts index e355e0e6dd7f..a5e85227e271 100644 --- a/apps/mobile/src/state/use-composer-drafts.test.ts +++ b/apps/mobile/src/state/use-composer-drafts.test.ts @@ -159,7 +159,6 @@ import { copyComposerDraftContentIfEmpty, copyComposerDraftContentState, decodePersistedComposerState, - decodePersistedComposerDrafts, ensureComposerDraftsLoaded, type ComposerDraft, flushComposerDrafts, @@ -252,12 +251,12 @@ describe("mobile composer drafts", () => { }; expect( - decodePersistedComposerDrafts({ + decodePersistedComposerState({ schemaVersion: 1, drafts: { "environment-1:thread-1": { text: "Review this file", attachments: [file] }, }, - }), + }).drafts, ).toEqual({ "environment-1:thread-1": { text: "Review this file", attachments: [file] }, }); @@ -987,7 +986,7 @@ describe("mobile composer drafts", () => { it("rejects persisted images without image bytes or a file URI", () => { expect(() => - decodePersistedComposerDrafts({ + decodePersistedComposerState({ schemaVersion: 1, drafts: { "environment-1:thread-1": { @@ -1010,7 +1009,7 @@ describe("mobile composer drafts", () => { it("hydrates selector state even when the message content is empty", () => { expect( - decodePersistedComposerDrafts({ + decodePersistedComposerState({ schemaVersion: 1, drafts: { "new-task:environment-1:project-1": { @@ -1030,7 +1029,7 @@ describe("mobile composer drafts", () => { }, }, }, - }), + }).drafts, ).toEqual({ "new-task:environment-1:project-1": { text: "", @@ -1053,18 +1052,18 @@ describe("mobile composer drafts", () => { it("keeps legacy content-only drafts and rejects invalid selector state", () => { expect( - decodePersistedComposerDrafts({ + decodePersistedComposerState({ schemaVersion: 1, drafts: { "environment-1:thread-1": DRAFT, }, - }), + }).drafts, ).toEqual({ "environment-1:thread-1": DRAFT, }); expect(() => - decodePersistedComposerDrafts({ + decodePersistedComposerState({ schemaVersion: 1, drafts: { "environment-1:thread-1": { diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts index 6777df47dfd0..2a71d53b7cb9 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -247,10 +247,6 @@ export function decodePersistedComposerState(value: unknown): { }; } -export function decodePersistedComposerDrafts(value: unknown): Record { - return decodePersistedComposerState(value).drafts; -} - async function getComposerDraftsFile() { const { Directory, File, Paths } = await import("expo-file-system"); const directory = new Directory(Paths.document, COMPOSER_DRAFTS_DIRECTORY); diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts index dbc33ea86ea7..093866540ff7 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -53,6 +53,15 @@ describe("RPC authorization scopes", () => { ); }); + it("requires write access to import agent session history", () => { + expect(requiredScopeForRpcMethod(WS_METHODS.agentSessionsScan)).toBe( + AuthOrchestrationReadScope, + ); + expect(requiredScopeForRpcMethod(WS_METHODS.agentSessionsImport)).toBe( + AuthOrchestrationOperateScope, + ); + }); + it("reads the reviewer menu under the same scope as the pull request it belongs to", () => { // The candidate list is a read like the detail beside it, and asking somebody for a review is // a write like every other pull request operation. diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index de83177868ef..78bfb7783a6c 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -106,6 +106,8 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.projectsWriteFile]: AuthOrchestrationOperateScope, [WS_METHODS.shellOpenInEditor]: AuthOrchestrationOperateScope, [WS_METHODS.filesystemBrowse]: AuthOrchestrationReadScope, + [WS_METHODS.agentSessionsScan]: AuthOrchestrationReadScope, + [WS_METHODS.agentSessionsImport]: AuthOrchestrationOperateScope, [WS_METHODS.assetsCreateUrl]: AuthOrchestrationReadScope, [WS_METHODS.attachmentsCreateUploadUrl]: AuthOrchestrationOperateScope, [WS_METHODS.attachmentsDelete]: AuthOrchestrationOperateScope, diff --git a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts index 3a2e3687e13e..f3d449687753 100644 --- a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts +++ b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts @@ -92,6 +92,7 @@ describe("CheckpointDiffQuery.layer", () => { getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), getProjectShellById: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getImportedAgentSessionSources: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.sync(() => { getThreadCheckpointContextCalls += 1; @@ -208,6 +209,7 @@ describe("CheckpointDiffQuery.layer", () => { getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), getProjectShellById: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getImportedAgentSessionSources: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)), getFullThreadDiffContext: () => Effect.die("unused"), getThreadRuntimeContext: () => Effect.die("unused"), @@ -299,6 +301,7 @@ describe("CheckpointDiffQuery.layer", () => { getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), getProjectShellById: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getImportedAgentSessionSources: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)), getFullThreadDiffContext: () => Effect.die("unused"), getThreadRuntimeContext: () => Effect.die("unused"), @@ -375,6 +378,7 @@ describe("CheckpointDiffQuery.layer", () => { getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), getProjectShellById: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getImportedAgentSessionSources: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)), getFullThreadDiffContext: () => Effect.die("unused"), getThreadRuntimeContext: () => Effect.die("unused"), @@ -436,6 +440,7 @@ describe("CheckpointDiffQuery.layer", () => { getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), getProjectShellById: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getImportedAgentSessionSources: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.succeed(Option.none()), getFullThreadDiffContext: () => Effect.succeed(Option.none()), getThreadRuntimeContext: () => Effect.die("unused"), diff --git a/apps/server/src/checkpointing/CheckpointStore.ts b/apps/server/src/checkpointing/CheckpointStore.ts index b1ea7ee1e841..0767dc79022d 100644 --- a/apps/server/src/checkpointing/CheckpointStore.ts +++ b/apps/server/src/checkpointing/CheckpointStore.ts @@ -121,7 +121,7 @@ export const make = Effect.gen(function* () { const isGitRepository: CheckpointStore["Service"]["isGitRepository"] = (cwd) => vcsRegistry - .detect({ cwd, requestedKind: "git" }) + .detect({ cwd, requestedKind: "git", fresh: true }) .pipe(Effect.map((repository) => repository !== null)); const captureCheckpoint: CheckpointStore["Service"]["captureCheckpoint"] = Effect.fn( diff --git a/apps/server/src/cli/connect.test.ts b/apps/server/src/cli/connect.test.ts index 1e0c88c24e84..f3cc88d1b58a 100644 --- a/apps/server/src/cli/connect.test.ts +++ b/apps/server/src/cli/connect.test.ts @@ -13,31 +13,11 @@ import * as Terminal from "effect/Terminal"; import * as BootService from "../cloud/bootService.ts"; import { acquireRelayClientForLink, - formatHeadlessAuthorizationPrompt, - formatRelayClientReady, headlessSessionConfig, - isPublishAgentActivityEnabledValue, reportCloudDisconnectResults, } from "./connect.ts"; import { recoverServiceOnboardingOffer } from "./service.ts"; -it("explains how to complete headless authorization", () => { - assert.equal( - formatHeadlessAuthorizationPrompt("https://example.test/connect"), - [ - "Headless authorization", - "Open this URL on a device with a browser:", - " https://example.test/connect", - "", - "After signing in, return here and enter the code shown in your browser.", - ].join("\n"), - ); -}); - -it("formats relay readiness without printing its installation path", () => { - assert.equal(formatRelayClientReady("2026.5.2"), "✓ Relay client ready · cloudflared 2026.5.2"); -}); - const readHeadlessSessionConfig = (env: Record) => headlessSessionConfig.pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env })))); @@ -209,10 +189,3 @@ it.effect("keeps disconnect causes in structured logs and out of console warning ), ); }); - -it("treats only the literal 'true' as publish-enabled", () => { - assert.equal(isPublishAgentActivityEnabledValue("true"), true); - assert.equal(isPublishAgentActivityEnabledValue("false"), false); - assert.equal(isPublishAgentActivityEnabledValue(null), false); - assert.equal(isPublishAgentActivityEnabledValue("TRUE"), false); -}); diff --git a/apps/server/src/cli/connect.ts b/apps/server/src/cli/connect.ts index 25cfb18f3402..b7c78e5ea68b 100644 --- a/apps/server/src/cli/connect.ts +++ b/apps/server/src/cli/connect.ts @@ -86,7 +86,7 @@ const promptForOutOfBandOAuthCode = Effect.fn("cloud.cli.prompt_for_out_of_band_ }, ); -export function formatHeadlessAuthorizationPrompt(authorizeUrl: string): string { +function formatHeadlessAuthorizationPrompt(authorizeUrl: string): string { return [ "Headless authorization", "Open this URL on a device with a browser:", @@ -144,10 +144,6 @@ function stringToBytes(value: string): Uint8Array { return new TextEncoder().encode(value); } -export function isPublishAgentActivityEnabledValue(value: string | null): boolean { - return isAgentActivityPublishingEnabledValue(value); -} - interface CloudCliStatus { readonly desired: boolean; readonly authenticated: boolean; @@ -464,7 +460,7 @@ const runCloudCommand = Effect.fn("cloud.cli.run_cloud_command")(function* (identity ? ` as ${identity}` : ""); -export function formatRelayClientReady(version: string): string { +function formatRelayClientReady(version: string): string { return `✓ Relay client ready · cloudflared ${version}`; } @@ -573,7 +569,7 @@ const connectStatusCommand = Command.make("status", { linked: Option.isSome(cloudUserId), cloudUserId: Option.isSome(cloudUserId) ? bytesToString(cloudUserId.value) : null, relayUrl: Option.isSome(relayUrl) ? bytesToString(relayUrl.value) : null, - publishAgentActivity: isPublishAgentActivityEnabledValue( + publishAgentActivity: isAgentActivityPublishingEnabledValue( Option.isSome(publishAgentActivity) ? bytesToString(publishAgentActivity.value) : null, ), relayClient: executable, diff --git a/apps/server/src/cli/invocation.test.ts b/apps/server/src/cli/invocation.test.ts index c01a2caa49b5..370a8977fc4c 100644 --- a/apps/server/src/cli/invocation.test.ts +++ b/apps/server/src/cli/invocation.test.ts @@ -1,49 +1,62 @@ import { assert, it } from "@effect/vitest"; -import { detectCliRunner, formatCliCommand, suggestedPackageSpec } from "./invocation.ts"; +import { formatCliCommand } from "./invocation.ts"; -it("detects package runners from their cache entry paths", () => { - assert.equal(detectCliRunner("/home/theo/.npm/_npx/abc123/node_modules/t3/dist/bin.mjs"), "npx"); - assert.equal( - detectCliRunner( +it("formats package runner commands from their cache entry paths", () => { + for (const [entryPath, expected] of [ + ["/home/theo/.npm/_npx/abc123/node_modules/t3/dist/bin.mjs", "npx t3 serve"], + [ "C:\\Users\\theo\\AppData\\Local\\npm-cache\\_npx\\abc\\node_modules\\t3\\dist\\bin.mjs", - ), - "npx", - ); - assert.equal( - detectCliRunner("/home/theo/.cache/pnpm/dlx/abc/node_modules/t3/dist/bin.mjs"), - "pnpm dlx", - ); - assert.equal( - detectCliRunner("/home/theo/.local/share/pnpm/.pnpm/dlx/abc/node_modules/t3/dist/bin.mjs"), - "pnpm dlx", - ); - assert.equal( - detectCliRunner( + "npx t3 serve", + ], + ["/home/theo/.cache/pnpm/dlx/abc/node_modules/t3/dist/bin.mjs", "pnpm dlx t3 serve"], + [ + "/home/theo/.local/share/pnpm/.pnpm/dlx/abc/node_modules/t3/dist/bin.mjs", + "pnpm dlx t3 serve", + ], + [ "C:\\Users\\theo\\AppData\\Local\\pnpm-cache\\dlx\\abc\\node_modules\\t3\\dist\\bin.mjs", - ), - "pnpm dlx", - ); - assert.equal(detectCliRunner("/home/theo/.bun/install/cache/t3@0.0.31/dist/bin.mjs"), "bunx"); - assert.equal(detectCliRunner("/tmp/bunx-1000-t3@latest/node_modules/t3/dist/bin.mjs"), "bunx"); - assert.equal( - detectCliRunner( + "pnpm dlx t3 serve", + ], + ["/home/theo/.bun/install/cache/t3@0.0.31/dist/bin.mjs", "bunx t3 serve"], + ["/tmp/bunx-1000-t3@latest/node_modules/t3/dist/bin.mjs", "bunx t3 serve"], + [ "C:\\Users\\theo\\AppData\\Local\\Temp\\bunx-0-t3@latest\\node_modules\\t3\\dist\\bin.mjs", - ), - "bunx", - ); + "bunx t3 serve", + ], + ] as const) { + assert.equal(formatCliCommand({ subcommand: "serve", entryPath, version: "0.0.31" }), expected); + } }); it("treats stable installs as direct invocations", () => { - assert.isNull(detectCliRunner("/usr/local/lib/node_modules/t3/dist/bin.mjs")); - assert.isNull(detectCliRunner("/home/theo/Code/work/t3code/apps/server/dist/bin.mjs")); - assert.isNull(detectCliRunner("/home/theo/.t3/runtime/0.0.31/node_modules/t3/dist/bin.mjs")); - assert.isNull(detectCliRunner("")); + for (const entryPath of [ + "/usr/local/lib/node_modules/t3/dist/bin.mjs", + "/home/theo/Code/work/t3code/apps/server/dist/bin.mjs", + "/home/theo/.t3/runtime/0.0.31/node_modules/t3/dist/bin.mjs", + "", + ]) { + assert.equal( + formatCliCommand({ subcommand: "serve", entryPath, version: "0.0.31" }), + "t3 serve", + ); + } }); it("re-suggests the nightly channel only for nightly builds", () => { - assert.equal(suggestedPackageSpec("0.0.31-nightly.20260729"), "t3@nightly"); - assert.equal(suggestedPackageSpec("0.0.31"), "t3"); + for (const [version, expected] of [ + ["0.0.31-nightly.20260729", "npx t3@nightly serve"], + ["0.0.31", "npx t3 serve"], + ] as const) { + assert.equal( + formatCliCommand({ + subcommand: "serve", + entryPath: "/home/theo/.npm/_npx/abc123/node_modules/t3/dist/bin.mjs", + version, + }), + expected, + ); + } }); it("formats serve suggestions to match the launching command", () => { diff --git a/apps/server/src/cli/invocation.ts b/apps/server/src/cli/invocation.ts index e1b03552948d..55f5b66ad9dd 100644 --- a/apps/server/src/cli/invocation.ts +++ b/apps/server/src/cli/invocation.ts @@ -18,7 +18,7 @@ export type CliRunner = "npx" | "pnpm dlx" | "bunx"; * Global installs and repo checkouts match none of these and return null. * Detection is best-effort; callers must fail closed to a plain `t3` command. */ -export function detectCliRunner(entryPath: string): CliRunner | null { +function detectCliRunner(entryPath: string): CliRunner | null { const path = entryPath.replaceAll("\\", "/"); if (path.includes("/_npx/")) { return "npx"; @@ -42,7 +42,7 @@ export function detectCliRunner(entryPath: string): CliRunner | null { * from the running version: nightly builds re-suggest the nightly channel, * anything else suggests the bare package. */ -export function suggestedPackageSpec(version: string): string { +function suggestedPackageSpec(version: string): string { return version.includes("-nightly.") ? "t3@nightly" : "t3"; } diff --git a/apps/server/src/cloud/CliTokenManager.test.ts b/apps/server/src/cloud/CliTokenManager.test.ts index e6eb6b7cd6fd..33a0f1224961 100644 --- a/apps/server/src/cloud/CliTokenManager.test.ts +++ b/apps/server/src/cloud/CliTokenManager.test.ts @@ -93,19 +93,6 @@ class PromptRejectedError extends Schema.TaggedErrorClass() { message: Schema.String }, ) {} -it("formats loopback authorization with a headless-host fallback", () => { - assert.equal( - CliTokenManager.formatLoopbackAuthorizationPrompt("https://clerk.example.test/authorize"), - [ - "Open this URL to authorize T3 Connect:", - " https://clerk.example.test/authorize", - "", - "Press \u001b[1mEnter\u001b[22m to open it in your browser.", - "No browser on this device? Press \u001b[1mH\u001b[22m to switch to headless mode.", - ].join("\n"), - ); -}); - const makeTestTerminal = (queue: Queue.Queue) => Terminal.make({ columns: Effect.succeed(80), diff --git a/apps/server/src/cloud/CliTokenManager.ts b/apps/server/src/cloud/CliTokenManager.ts index c4443a7301cb..8c3869accc76 100644 --- a/apps/server/src/cloud/CliTokenManager.ts +++ b/apps/server/src/cloud/CliTokenManager.ts @@ -44,7 +44,7 @@ const CLOUD_CLI_OAUTH_CALLBACK_TIMEOUT = Duration.minutes(10); const CLOUD_CLI_OAUTH_REFRESH_EARLY_MS = Duration.toMillis(Duration.minutes(5)); const boldTerminalText = (value: string): string => `\u001b[1m${value}\u001b[22m`; -export function formatLoopbackAuthorizationPrompt(authorizationUrl: string): string { +function formatLoopbackAuthorizationPrompt(authorizationUrl: string): string { return [ "Open this URL to authorize T3 Connect:", ` ${authorizationUrl}`, diff --git a/apps/server/src/cloud/cliAuthHtml.test.ts b/apps/server/src/cloud/cliAuthHtml.test.ts deleted file mode 100644 index 1104927b9800..000000000000 --- a/apps/server/src/cloud/cliAuthHtml.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { expect, it } from "@effect/vitest"; - -import { - renderLoopbackAuthorizationCompleteHtml, - resolveLoopbackAuthorizationStage, -} from "./cliAuthHtml.ts"; - -it("renders the branded loopback authorization completion page", () => { - const html = renderLoopbackAuthorizationCompleteHtml(); - - expect(resolveLoopbackAuthorizationStage()).toBe("dev"); - expect(html).toContain("T3 Code (Dev)"); - expect(html).toContain('class="stage stage-dev"'); - expect(html).not.toContain("Secure terminal handoff"); - expect(html).toContain("You're connected"); - expect(html).toContain("Return to your terminal"); - expect(html).not.toContain('class="next"'); - expect(html).toContain('name="viewport"'); - expect(html).not.toContain('class="status"'); -}); - -it("renders the matching header treatment for each release channel", () => { - const nightly = renderLoopbackAuthorizationCompleteHtml("nightly"); - const latest = renderLoopbackAuthorizationCompleteHtml("latest"); - - expect(nightly).toContain("T3 Code (Nightly)"); - expect(nightly).toContain('class="stage stage-nightly"'); - expect(latest).toContain('

T3 Code

'); - expect(latest).not.toContain("(Latest)"); - expect(latest).toContain('class="stage stage-latest"'); -}); diff --git a/apps/server/src/cloud/cliAuthHtml.ts b/apps/server/src/cloud/cliAuthHtml.ts index 5a22a25993a9..69d3b471ae32 100644 --- a/apps/server/src/cloud/cliAuthHtml.ts +++ b/apps/server/src/cloud/cliAuthHtml.ts @@ -2,7 +2,7 @@ export type LoopbackAuthorizationStage = "dev" | "nightly" | "latest"; declare const __T3CODE_BUILD_CHANNEL__: "nightly" | "latest" | undefined; -export function resolveLoopbackAuthorizationStage(): LoopbackAuthorizationStage { +function resolveLoopbackAuthorizationStage(): LoopbackAuthorizationStage { return typeof __T3CODE_BUILD_CHANNEL__ === "undefined" ? "dev" : __T3CODE_BUILD_CHANNEL__; } diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 94901b675f35..025b914c8dec 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -293,6 +293,7 @@ describe("CheckpointReactor", () => { async function createHarness(options?: { readonly hasSession?: boolean; readonly seedFilesystemCheckpoints?: boolean; + readonly initializeGit?: boolean; readonly projectWorkspaceRoot?: string; readonly threadWorktreePath?: string | null; readonly threadBranch?: string | null; @@ -304,6 +305,9 @@ describe("CheckpointReactor", () => { readonly pullRequestRefreshCalls?: Array; }) { const cwd = createGitRepository(); + if (options?.initializeGit === false) { + NodeFS.rmSync(NodePath.join(cwd, ".git"), { recursive: true }); + } tempDirs.push(cwd); const provider = createProviderServiceHarness( cwd, @@ -1159,52 +1163,148 @@ describe("CheckpointReactor", () => { ).toBe(true); }); - it("appends capture failure activity when turn diff summary cannot be derived", async () => { - const harness = await createHarness({ seedFilesystemCheckpoints: false }); - const createdAt = "2026-01-01T00:00:00.000Z"; - - await Effect.runPromise( - harness.engine.dispatch({ - type: "thread.session.set", - commandId: CommandId.make("cmd-session-set-missing-baseline-diff"), + effectIt.effect("captures a checkpoint without a summary when the baseline is missing", () => + Effect.gen(function* () { + const harness = yield* Effect.promise(() => + createHarness({ seedFilesystemCheckpoints: false }), + ); + harness.provider.emit({ + type: "turn.completed", + eventId: EventId.make("evt-turn-completed-missing-baseline"), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", threadId: ThreadId.make("thread-1"), - session: { - threadId: ThreadId.make("thread-1"), - status: "ready", - providerName: "codex", - runtimeMode: "approval-required", - activeTurnId: null, - lastError: null, - updatedAt: createdAt, - }, - createdAt, - }), - ); - - harness.provider.emit({ - type: "turn.completed", - eventId: EventId.make("evt-turn-completed-missing-baseline"), - provider: ProviderDriverKind.make("codex"), + turnId: asTurnId("turn-missing-baseline"), + payload: { state: "completed" }, + }); + expect(yield* harness.nextReceipt).toMatchObject({ + type: "checkpoint.diff.finalized", + checkpointTurnCount: 1, + }); + yield* Effect.promise(harness.drain); + const thread = (yield* Effect.promise(harness.readModel)).threads[0]; + expect(thread?.checkpoints[0]).toMatchObject({ + status: "ready", + checkpointTurnCount: 1, + files: [], + }); + expect( + gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.make("thread-1"), 1)), + ).toBe(true); + expect( + thread?.activities.some((activity) => activity.kind === "checkpoint.capture.failed"), + ).toBe(false); + }), + ); - createdAt: "2026-01-01T00:00:00.000Z", - threadId: ThreadId.make("thread-1"), - turnId: asTurnId("turn-missing-baseline"), - payload: { state: "completed" }, - }); + effectIt.effect.each([ + { timing: "between turns", commit: false }, + { timing: "between turns", commit: true }, + { timing: "during a turn", commit: false }, + { timing: "during a turn", commit: true }, + ])("resumes checkpointing after git init $timing (commit: $commit)", ({ timing, commit }) => + Effect.gen(function* () { + const harness = yield* Effect.promise(() => + createHarness({ initializeGit: false, seedFilesystemCheckpoints: false }), + ); + const threadId = ThreadId.make("thread-1"); + const createdAt = "2026-01-01T00:00:00.000Z"; + const emit = (type: "turn.started" | "turn.completed", turn: number) => + harness.provider.emit({ + type, + eventId: EventId.make(`${type}-${turn}`), + provider: ProviderDriverKind.make("codex"), + createdAt, + threadId, + turnId: asTurnId(`turn-${turn}`), + ...(type === "turn.completed" ? { payload: { state: "completed" } } : {}), + }); + emit("turn.started", 1); + yield* Effect.promise(harness.drain); + NodeFS.writeFileSync(NodePath.join(harness.cwd, "README.md"), "before git\n"); + emit("turn.completed", 1); + yield* Effect.promise(harness.drain); + expect((yield* Effect.promise(harness.readModel)).threads[0]?.checkpoints).toEqual([]); - await waitForEvent(harness.engine, (event) => event.type === "thread.turn-diff-completed"); - const thread = await waitForThread( - harness.readModel, - (entry) => - entry.checkpoints.length === 1 && - entry.activities.some((activity) => activity.kind === "checkpoint.capture.failed"), - ); + if (timing === "during a turn") { + emit("turn.started", 2); + yield* Effect.promise(harness.drain); + } + runGit(harness.cwd, ["init", "--initial-branch=main"]); + if (commit) { + runGit(harness.cwd, ["add", "."]); + runGit(harness.cwd, [ + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "commit", + "-m", + "Initial", + ]); + } + if (timing === "between turns") { + // Exercise the domain entry point as well as the provider turn-start event. + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-after-git-init"), + threadId, + message: { + messageId: MessageId.make("message-after-git-init"), + role: "user", + text: "continue", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt, + }); + expect(yield* harness.nextReceipt).toMatchObject({ + type: "checkpoint.baseline.captured", + checkpointTurnCount: 0, + }); + emit("turn.started", 2); + yield* Effect.promise(harness.drain); + } + NodeFS.writeFileSync(NodePath.join(harness.cwd, "README.md"), "after git\n"); + emit("turn.completed", 2); + expect(yield* harness.nextReceipt).toMatchObject({ + type: "checkpoint.diff.finalized", + checkpointTurnCount: 1, + }); + expect(yield* harness.nextReceipt).toMatchObject({ type: "turn.processing.quiesced" }); + yield* Effect.promise(harness.drain); + const firstCheckpoint = (yield* Effect.promise(harness.readModel)).threads[0]?.checkpoints[0]; + expect(firstCheckpoint?.files).toEqual( + timing === "between turns" + ? [{ path: "README.md", kind: "modified", additions: 1, deletions: 1 }] + : [], + ); + expect( + gitShowFileAtRef(harness.cwd, checkpointRefForThreadTurn(threadId, 1), "README.md"), + ).toBe("after git\n"); + expect(gitRefExists(harness.cwd, checkpointRefForThreadTurn(threadId, 0))).toBe( + timing === "between turns", + ); - expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1); - expect( - thread.activities.some((activity) => activity.kind === "checkpoint.capture.failed"), - ).toBe(true); - }); + emit("turn.started", 3); + yield* Effect.promise(harness.drain); + NodeFS.writeFileSync(NodePath.join(harness.cwd, "README.md"), "next turn\n"); + emit("turn.completed", 3); + expect(yield* harness.nextReceipt).toMatchObject({ + type: "checkpoint.diff.finalized", + checkpointTurnCount: 2, + }); + yield* Effect.promise(harness.drain); + const thread = (yield* Effect.promise(harness.readModel)).threads[0]; + expect(thread?.checkpoints[1]?.files).toEqual([ + { path: "README.md", kind: "modified", additions: 1, deletions: 1 }, + ]); + expect( + thread?.activities.some((activity) => activity.kind === "checkpoint.capture.failed"), + ).toBe(false); + }), + ); it("captures pre-turn baseline from project workspace root when thread worktree is unset", async () => { const harness = await createHarness({ @@ -1243,6 +1343,36 @@ describe("CheckpointReactor", () => { ).toBe("v1\n"); }); + it("does not create checkpoints while importing historical user messages", async () => { + const harness = await createHarness({ + hasSession: false, + seedFilesystemCheckpoints: false, + threadWorktreePath: null, + }); + if (runtime === null) throw new Error("Checkpoint test runtime was not initialized."); + + await runtime.runPromise( + harness.engine.dispatch({ + type: "thread.history.import", + commandId: CommandId.make("cmd-import-history-without-checkpoint"), + threadId: ThreadId.make("thread-1"), + messages: [ + { + messageId: MessageId.make("imported-user-message"), + role: "user", + text: "A message from an existing agent session", + createdAt: "2026-01-01T00:00:00.000Z", + }, + ], + }), + ); + await harness.drain(); + + expect( + gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.make("thread-1"), 0)), + ).toBe(false); + }); + it("captures turn completion checkpoint from project workspace root when provider session cwd is unavailable", async () => { const harness = await createHarness({ hasSession: false, diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.ts index f155a6ae365c..0331b0141fb3 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.ts @@ -258,41 +258,46 @@ const make = Effect.gen(function* () { // reflects files created or deleted during this turn. yield* workspaceEntries.refresh(input.cwd); - const files = yield* checkpointStore - .diffCheckpoints({ - cwd: input.cwd, - fromCheckpointRef, - toCheckpointRef: targetCheckpointRef, - fallbackFromToHead: false, - ignoreWhitespace: false, - format: "numstat", - }) - .pipe( - Effect.map((diff) => - parseTurnDiffFilesFromNumstat(diff).map((file) => ({ - path: file.path, - kind: "modified" as const, - additions: file.additions, - deletions: file.deletions, - })), - ), - Effect.tapError((error) => - appendCaptureFailureActivity({ - threadId: input.threadId, - turnId: input.turnId, - detail: `Checkpoint captured, but turn diff summary is unavailable: ${error.message}`, - createdAt: input.createdAt, - }), - ), - Effect.catch((error) => - Effect.logWarning("failed to derive checkpoint file summary", { - threadId: input.threadId, - turnId: input.turnId, - turnCount: input.turnCount, - detail: error.message, - }).pipe(Effect.as([])), - ), - ); + // Git may have been initialized during this turn, leaving no pre-turn + // snapshot. Keep the completion checkpoint for future turns, but do not + // invent a baseline or attempt a diff against a ref that does not exist. + const files = yield* ( + fromCheckpointExists + ? checkpointStore.diffCheckpoints({ + cwd: input.cwd, + fromCheckpointRef, + toCheckpointRef: targetCheckpointRef, + fallbackFromToHead: false, + ignoreWhitespace: false, + format: "numstat", + }) + : Effect.succeed("") + ).pipe( + Effect.map((diff) => + parseTurnDiffFilesFromNumstat(diff).map((file) => ({ + path: file.path, + kind: "modified" as const, + additions: file.additions, + deletions: file.deletions, + })), + ), + Effect.tapError((error) => + appendCaptureFailureActivity({ + threadId: input.threadId, + turnId: input.turnId, + detail: `Checkpoint captured, but turn diff summary is unavailable: ${error.message}`, + createdAt: input.createdAt, + }), + ), + Effect.catch((error) => + Effect.logWarning("failed to derive checkpoint file summary", { + threadId: input.threadId, + turnId: input.turnId, + turnCount: input.turnCount, + detail: error.message, + }).pipe(Effect.as([])), + ), + ); const assistantMessageId = input.assistantMessageId ?? @@ -610,6 +615,7 @@ const make = Effect.gen(function* () { ) { if (event.type === "thread.message-sent") { if ( + event.metadata.historyImport === true || event.payload.role !== "user" || event.payload.streaming || event.payload.turnId !== null diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 362ae07cc1b1..1664458a98cb 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -492,6 +492,7 @@ describe("OrchestrationEngine", () => { getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), getProjectShellById: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getImportedAgentSessionSources: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.succeed(Option.none()), getFullThreadDiffContext: () => Effect.succeed(Option.none()), getThreadRuntimeContext: () => Effect.die("unused"), diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 4762e1fad224..91da6c7700bc 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -108,6 +108,102 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-curs }, ); +it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-import-shell-")))( + "imported thread shell projection", + (it) => { + it.effect("does not mark imported user messages as queued work in thread shells", () => + Effect.gen(function* () { + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + const sql = yield* SqlClient.SqlClient; + const createdAt = "2026-08-24T10:00:00.000Z"; + const threadId = ThreadId.make("import:codex:shell-session"); + + yield* eventStore.append({ + type: "thread.created", + eventId: EventId.make("evt-import-shell-thread"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: createdAt, + commandId: CommandId.make("cmd-import-shell-thread"), + causationEventId: null, + correlationId: CommandId.make("cmd-import-shell-thread"), + metadata: {}, + payload: { + threadId, + projectId: ProjectId.make("project-import-shell"), + title: "Imported thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt, + updatedAt: createdAt, + }, + }); + yield* eventStore.append({ + type: "thread.message-sent", + eventId: EventId.make("evt-import-shell-message"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: createdAt, + commandId: CommandId.make("cmd-import-shell-message"), + causationEventId: null, + correlationId: CommandId.make("cmd-import-shell-message"), + metadata: { historyImport: true }, + payload: { + threadId, + messageId: MessageId.make("import:codex:shell-session:0"), + role: "user", + text: "Imported user prompt", + turnId: null, + streaming: false, + createdAt, + updatedAt: createdAt, + }, + }); + + yield* projectionPipeline.bootstrap; + + const readLatestUserMessageAt = sql<{ readonly latestUserMessageAt: string | null }>` + SELECT latest_user_message_at AS "latestUserMessageAt" + FROM projection_threads + WHERE thread_id = ${threadId} + `; + assert.deepEqual(yield* readLatestUserMessageAt, [{ latestUserMessageAt: null }]); + + const sessionEvent = yield* eventStore.append({ + type: "thread.session-set", + eventId: EventId.make("evt-import-shell-session"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: createdAt, + commandId: CommandId.make("cmd-import-shell-session"), + causationEventId: null, + correlationId: CommandId.make("cmd-import-shell-session"), + metadata: {}, + payload: { + threadId, + session: { + threadId, + status: "ready", + providerName: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: createdAt, + }, + }, + }); + yield* projectionPipeline.projectEvent(sessionEvent); + assert.deepEqual(yield* readLatestUserMessageAt, [{ latestUserMessageAt: null }]); + }), + ); + }, +); + it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { it.effect("bootstraps all projection states and writes projection rows", () => Effect.gen(function* () { diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index a3b19ebfd82a..9049050f73e0 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -1,10 +1,12 @@ import { ApprovalRequestId, + isImportedAgentSessionMessageId, type ChatAttachment, type OrchestrationEvent, type OrchestrationSessionStatus, ThreadId, } from "@t3tools/contracts"; +import { compareDateTimeStrings } from "@t3tools/shared/dateTime"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -252,7 +254,7 @@ function retainProjectionMessagesAfterRevert( } for (const message of messages) { - if (message.role === "system") { + if (message.role === "system" || isImportedAgentSessionMessageId(message.messageId)) { retainedMessageIds.add(message.messageId); continue; } @@ -262,7 +264,10 @@ function retainProjectionMessagesAfterRevert( } const retainedUserCount = messages.filter( - (message) => message.role === "user" && retainedMessageIds.has(message.messageId), + (message) => + message.role === "user" && + !isImportedAgentSessionMessageId(message.messageId) && + retainedMessageIds.has(message.messageId), ).length; const missingUserCount = Math.max(0, turnCount - retainedUserCount); if (missingUserCount > 0) { @@ -275,7 +280,7 @@ function retainProjectionMessagesAfterRevert( ) .toSorted( (left, right) => - left.createdAt.localeCompare(right.createdAt) || + compareDateTimeStrings(left.createdAt, right.createdAt) || left.messageId.localeCompare(right.messageId), ) .slice(0, missingUserCount); @@ -285,7 +290,10 @@ function retainProjectionMessagesAfterRevert( } const retainedAssistantCount = messages.filter( - (message) => message.role === "assistant" && retainedMessageIds.has(message.messageId), + (message) => + message.role === "assistant" && + !isImportedAgentSessionMessageId(message.messageId) && + retainedMessageIds.has(message.messageId), ).length; const missingAssistantCount = Math.max(0, turnCount - retainedAssistantCount); if (missingAssistantCount > 0) { @@ -298,7 +306,7 @@ function retainProjectionMessagesAfterRevert( ) .toSorted( (left, right) => - left.createdAt.localeCompare(right.createdAt) || + compareDateTimeStrings(left.createdAt, right.createdAt) || left.messageId.localeCompare(right.messageId), ) .slice(0, missingAssistantCount); @@ -983,6 +991,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti updatedAt: event.occurredAt, latestUserMessageAt: event.payload.role === "user" && + !isImportedAgentSessionMessageId(event.payload.messageId) && (previousLatest === null || event.payload.createdAt > previousLatest) ? event.payload.createdAt : previousLatest, diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index cbc68adb9aa7..94953289bc12 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -1,4 +1,5 @@ import { + type AgentSessionImportSource, CheckpointRef, EventId, MessageId, @@ -12,6 +13,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; @@ -2667,7 +2669,9 @@ projectionSnapshotLayer("ProjectionSnapshotQuery windowed thread detail", (it) = // // Straggler user message at T03.5 (turn_id NULL, not any pending_message_id) // and a turnless activity at T03.6 — both belong to the page containing T03+. - const seedFanOutThread = Effect.fnUntraced(function* () { + const seedFanOutThread = Effect.fnUntraced(function* (options?: { + readonly importedMessageCount?: number; + }) { const sql = yield* SqlClient.SqlClient; // Tests in this block share one in-memory database; reset before seeding. @@ -2696,6 +2700,20 @@ projectionSnapshotLayer("ProjectionSnapshotQuery windowed thread detail", (it) = 'turn-5', 0, 0, 0, '2026-03-01T00:00:00.000Z', '2026-03-01T00:00:10.000Z', NULL) `; + if (options?.importedMessageCount) { + for (let index = 0; index < options.importedMessageCount; index += 1) { + const messageId = `import:codex:session-w:${String(index).padStart(6, "0")}`; + const role = index % 2 === 0 ? "user" : "assistant"; + yield* sql` + INSERT INTO projection_thread_messages ( + message_id, thread_id, turn_id, role, text, is_streaming, created_at, updated_at + ) + VALUES (${messageId}, 'thread-w', NULL, ${role}, ${"imported message " + index}, 0, + '2026-02-28T00:00:00.000Z', '2026-02-28T00:00:00.000Z') + `; + } + } + const turns: ReadonlyArray<{ turn: string; pendingMessage: string | null; @@ -2929,6 +2947,51 @@ projectionSnapshotLayer("ProjectionSnapshotQuery windowed thread detail", (it) = }), ); + it.effect("keeps imported history on the oldest page after resumed turns", () => + Effect.gen(function* () { + yield* seedFanOutThread({ importedMessageCount: 12 }); + const snapshotQuery = yield* ProjectionSnapshotQuery; + + const completePage = yield* snapshotQuery.getThreadDetailSnapshot(threadW, { turnLimit: 50 }); + assert.equal(completePage._tag, "Some"); + if (completePage._tag !== "Some") return; + assert.equal( + completePage.value.thread.messages.filter((message) => message.id.startsWith("import:")) + .length, + 12, + ); + assert.equal(completePage.value.page?.hasMore, false); + assert.equal(completePage.value.page?.beforeCursor, null); + + const recentPage = yield* snapshotQuery.getThreadDetailSnapshot(threadW, { turnLimit: 2 }); + assert.equal(recentPage._tag, "Some"); + if (recentPage._tag !== "Some") return; + assert.equal( + recentPage.value.thread.messages.some((message) => message.id.startsWith("import:")), + false, + ); + const cursor = recentPage.value.page?.beforeCursor; + assert.notEqual(cursor, null); + assert.notEqual(cursor, undefined); + if (cursor === null || cursor === undefined) return; + + const oldestPage = yield* snapshotQuery.getThreadDetailSnapshot(threadW, { + turnLimit: 1, + beforeCursor: cursor, + }); + assert.equal(oldestPage._tag, "Some"); + if (oldestPage._tag !== "Some") return; + + const importedIds = oldestPage.value.thread.messages + .map((message) => message.id) + .filter((messageId) => messageId.startsWith("import:")); + assert.equal(importedIds.length, 12); + assert.equal(new Set(importedIds).size, 12); + assert.equal(oldestPage.value.page?.hasMore, false); + assert.equal(oldestPage.value.page?.beforeCursor, null); + }), + ); + it.effect("a cursor for a different thread degrades to the first page", () => Effect.gen(function* () { yield* seedFanOutThread(); @@ -3312,3 +3375,237 @@ projectionSnapshotLayer("ProjectionSnapshotQuery windowed thread detail", (it) = }), ); }); + +projectionSnapshotLayer("ProjectionSnapshotQuery imported sources", (it) => { + const encodeJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); + const source: AgentSessionImportSource = { + provider: "codex", + providerInstanceId: ProviderInstanceId.make("codex-home"), + providerSessionId: "native-session", + filePath: "/tmp/transcript.jsonl", + size: 128, + mtimeMs: 1_700_000_000_000, + device: 1, + inode: 2, + birthtimeMs: 1_699_000_000_000, + }; + + const seedImportedSession = Effect.fn("seedImportedSession")(function* ( + projectId: ProjectId, + source: AgentSessionImportSource, + ) { + const sql = yield* SqlClient.SqlClient; + const threadId = ThreadId.make( + `import:${source.providerInstanceId}:${source.providerSessionId}`, + ); + const timestamp = "2026-03-02T00:00:00.000Z"; + yield* sql` + INSERT OR IGNORE INTO projection_projects ( + project_id, title, workspace_root, scripts_json, created_at, updated_at + ) VALUES (${projectId}, 'Imported project', '/tmp/imported-project', '[]', + ${timestamp}, ${timestamp}) + `; + yield* sql` + INSERT INTO projection_threads ( + thread_id, project_id, title, model_selection_json, runtime_mode, interaction_mode, + created_at, updated_at + ) VALUES (${threadId}, ${projectId}, 'Imported thread', + ${encodeJson({ instanceId: source.providerInstanceId, model: "gpt-5-codex" })}, + 'full-access', 'default', + ${timestamp}, ${timestamp}) + `; + yield* sql` + INSERT INTO provider_session_runtime ( + thread_id, provider_name, provider_instance_id, adapter_key, runtime_mode, status, + last_seen_at, resume_cursor_json, runtime_payload_json + ) VALUES (${threadId}, ${source.provider}, ${source.providerInstanceId}, + ${source.provider}, 'full-access', 'stopped', ${timestamp}, + ${encodeJson({ threadId: source.providerSessionId })}, + ${encodeJson({ importedTranscripts: [source] })}) + `; + yield* sql` + INSERT INTO projection_thread_messages ( + message_id, thread_id, role, text, is_streaming, created_at, updated_at + ) VALUES (${`${threadId}:000000`}, ${threadId}, 'user', 'Imported history', 0, + ${timestamp}, ${timestamp}) + `; + return { threadId, source }; + }); + + it.effect("reads completed source copies without decoding message bodies", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const query = yield* ProjectionSnapshotQuery; + const projectId = ProjectId.make("project-import-metadata"); + const imported = yield* seedImportedSession(projectId, source); + const copiedSource = { + ...source, + filePath: "/tmp/transcript-copy.jsonl", + mtimeMs: null, + inode: null, + birthtimeMs: null, + }; + yield* sql` + UPDATE provider_session_runtime + SET runtime_payload_json = ${encodeJson({ + cwd: "/tmp/imported-project", + importedTranscripts: [source, copiedSource], + })} + WHERE thread_id = ${imported.threadId} + `; + yield* sql` + UPDATE projection_thread_messages SET attachments_json = 'not-json' + WHERE thread_id = ${imported.threadId} + `; + + const counter = makeSqlStatementCounter(); + const sources = yield* query + .getImportedAgentSessionSources(projectId) + .pipe(Effect.withTracer(counter.tracer)); + assert.deepEqual(sources, [imported, { threadId: imported.threadId, source: copiedSource }]); + assert.equal(counter.count(), 1); + }), + ); + + it.effect("requires active project threads, a binding, and an imported message", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const query = yield* ProjectionSnapshotQuery; + const projectId = ProjectId.make("project-import-completion"); + const completed = yield* seedImportedSession(projectId, { + ...source, + providerSessionId: "completed", + }); + yield* sql` + UPDATE projection_thread_messages SET message_id = ${`${completed.threadId}:legacy`} + WHERE thread_id = ${completed.threadId} + `; + const partials = yield* Effect.forEach( + [ + "no-binding", + "no-history", + "no-imported-message", + "wrong-message-thread", + "archived", + "deleted", + ], + (providerSessionId) => seedImportedSession(projectId, { ...source, providerSessionId }), + ); + const [noBinding, noHistory, noImportedMessage, wrongMessageThread, archived, deleted] = + partials; + assert.isDefined(noBinding); + assert.isDefined(noHistory); + assert.isDefined(noImportedMessage); + assert.isDefined(wrongMessageThread); + assert.isDefined(archived); + assert.isDefined(deleted); + yield* sql`DELETE FROM provider_session_runtime WHERE thread_id = ${noBinding.threadId}`; + yield* sql`DELETE FROM projection_thread_messages WHERE thread_id = ${noHistory.threadId}`; + yield* sql` + UPDATE projection_thread_messages SET message_id = ${`normal:${noImportedMessage.threadId}`} + WHERE thread_id = ${noImportedMessage.threadId} + `; + yield* sql` + UPDATE projection_thread_messages SET thread_id = 'unrelated-thread' + WHERE thread_id = ${wrongMessageThread.threadId} + `; + yield* sql` + UPDATE projection_threads SET archived_at = '2026-03-03T00:00:00.000Z' + WHERE thread_id = ${archived.threadId} + `; + yield* sql` + UPDATE projection_threads SET deleted_at = '2026-03-03T00:00:00.000Z' + WHERE thread_id = ${deleted.threadId} + `; + const otherProjectId = ProjectId.make("project-import-other"); + const otherProject = yield* seedImportedSession(otherProjectId, { + ...source, + providerSessionId: "other-project", + }); + const deletedProjectId = ProjectId.make("project-import-deleted"); + yield* seedImportedSession(deletedProjectId, { + ...source, + providerSessionId: "deleted-project", + }); + yield* sql` + UPDATE projection_projects SET deleted_at = '2026-03-03T00:00:00.000Z' + WHERE project_id = ${deletedProjectId} + `; + + assert.deepEqual(yield* query.getImportedAgentSessionSources(projectId), [completed]); + assert.deepEqual(yield* query.getImportedAgentSessionSources(otherProjectId), [otherProject]); + assert.deepEqual(yield* query.getImportedAgentSessionSources(deletedProjectId), []); + assert.deepEqual( + yield* query.getImportedAgentSessionSources(ProjectId.make("project-import-missing")), + [], + ); + }), + ); + + it.effect("keeps original sources when the current runtime provider and cursor change", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const query = yield* ProjectionSnapshotQuery; + const projectId = ProjectId.make("project-import-switched"); + const imported = yield* seedImportedSession(projectId, { + ...source, + provider: "claudeAgent", + providerInstanceId: ProviderInstanceId.make("claude-original"), + providerSessionId: "original-session", + }); + yield* sql` + UPDATE provider_session_runtime + SET provider_name = 'codex', provider_instance_id = 'codex-new', adapter_key = 'codex', + resume_cursor_json = '{"threadId":"new-session"}' + WHERE thread_id = ${imported.threadId} + `; + + assert.deepEqual(yield* query.getImportedAgentSessionSources(projectId), [imported]); + }), + ); + + it.effect("skips invalid source payloads and entries without dropping valid sources", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const query = yield* ProjectionSnapshotQuery; + const projectId = ProjectId.make("project-import-invalid"); + const imported = yield* seedImportedSession(projectId, { + ...source, + providerSessionId: "a-invalid", + }); + const valid = yield* seedImportedSession(projectId, { + ...source, + providerSessionId: "z-valid", + }); + for (const payload of [null, "not-json", "null", "[]", "{}", '{"importedTranscripts":{}}']) { + yield* sql` + UPDATE provider_session_runtime SET runtime_payload_json = ${payload} + WHERE thread_id = ${imported.threadId} + `; + assert.deepEqual(yield* query.getImportedAgentSessionSources(projectId), [valid]); + } + yield* sql` + UPDATE provider_session_runtime SET runtime_payload_json = X'FF' + WHERE thread_id = ${imported.threadId} + `; + assert.deepEqual(yield* query.getImportedAgentSessionSources(projectId), [valid]); + + yield* sql` + UPDATE provider_session_runtime + SET runtime_payload_json = ${encodeJson({ + importedTranscripts: [ + null, + {}, + { ...imported.source, size: -1 }, + { ...imported.source, provider: "cursor" }, + { ...imported.source, providerInstanceId: "wrong-instance" }, + { ...imported.source, providerSessionId: "wrong-session" }, + imported.source, + ], + })} + WHERE thread_id = ${imported.threadId} + `; + assert.deepEqual(yield* query.getImportedAgentSessionSources(projectId), [imported, valid]); + }), + ); +}); diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index d9be568a4699..6307045c9cc5 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -1,4 +1,5 @@ import { + AgentSessionImportSource, ApprovalRequestId, ChatAttachment, CheckpointRef, @@ -81,6 +82,14 @@ import { const decodeReadModel = Schema.decodeUnknownEffect(OrchestrationReadModel); const decodeShellSnapshot = Schema.decodeUnknownEffect(OrchestrationShellSnapshot); const decodeThread = Schema.decodeUnknownEffect(OrchestrationThread); +const decodeImportedTranscriptsPayload = Schema.decodeUnknownOption( + Schema.fromJsonString( + Schema.Struct({ + importedTranscripts: Schema.Array(Schema.Unknown), + }), + ), +); +const decodeAgentSessionImportSource = Schema.decodeUnknownOption(AgentSessionImportSource); // Keep detail reads consistent with the in-memory projector's retained // activity window. Applying the limit in SQL avoids decoding an unbounded // payload_json set before the projector can enforce that invariant. @@ -183,6 +192,10 @@ const WorkspaceRootLookupInput = Schema.Struct({ const ProjectIdLookupInput = Schema.Struct({ projectId: ProjectId, }); +const ProjectionImportedAgentSessionSourcesRowSchema = Schema.Struct({ + threadId: ThreadId, + runtimePayload: Schema.Unknown, +}); const ThreadIdLookupInput = Schema.Struct({ threadId: ThreadId, }); @@ -1137,6 +1150,33 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); + const listImportedAgentSessionSourceRows = SqlSchema.findAll({ + Request: ProjectIdLookupInput, + Result: ProjectionImportedAgentSessionSourcesRowSchema, + execute: ({ projectId }) => + sql` + SELECT + threads.thread_id AS "threadId", + runtime.runtime_payload_json AS "runtimePayload" + FROM projection_threads AS threads + INNER JOIN projection_projects AS projects + ON projects.project_id = threads.project_id + INNER JOIN provider_session_runtime AS runtime + ON runtime.thread_id = threads.thread_id + WHERE threads.project_id = ${projectId} + AND threads.deleted_at IS NULL + AND threads.archived_at IS NULL + AND projects.deleted_at IS NULL + AND EXISTS ( + SELECT 1 + FROM projection_thread_messages AS messages + WHERE messages.thread_id = threads.thread_id + AND messages.message_id GLOB 'import:*' + ) + ORDER BY threads.thread_id ASC + `, + }); + const getThreadCheckpointContextThreadRow = SqlSchema.findOneOption({ Request: ThreadIdLookupInput, Result: ProjectionThreadCheckpointContextThreadRowSchema, @@ -3043,6 +3083,33 @@ pending_approval_requests AS ( Effect.map(Option.map((row) => row.threadId)), ); + const getImportedAgentSessionSources: ProjectionSnapshotQueryShape["getImportedAgentSessionSources"] = + Effect.fn("ProjectionSnapshotQuery.getImportedAgentSessionSources")(function* (projectId) { + const rows = yield* listImportedAgentSessionSourceRows({ projectId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getImportedAgentSessionSources:query", + "ProjectionSnapshotQuery.getImportedAgentSessionSources:decodeRows", + ), + ), + ); + return rows.flatMap((row) => { + const payload = decodeImportedTranscriptsPayload(row.runtimePayload); + if (Option.isNone(payload)) return []; + return payload.value.importedTranscripts.flatMap((entry) => { + const source = decodeAgentSessionImportSource(entry); + if ( + Option.isNone(source) || + row.threadId !== + `import:${source.value.providerInstanceId}:${source.value.providerSessionId}` + ) { + return []; + } + return [{ threadId: row.threadId, source: source.value }]; + }); + }); + }); + const getThreadCheckpointContext: ProjectionSnapshotQueryShape["getThreadCheckpointContext"] = ( threadId, ) => @@ -3639,17 +3706,35 @@ pending_approval_requests AS ( ); const oldest = windowRows[0]; + const hasMore = + oldest !== undefined && + (yield* listTurnWindowRows({ + threadId, + beforeAnchorAt: oldest.anchorAt, + beforeTurnKey: oldest.turnKey, + userTurnLimit: 1, + maxRawTurns: 1, + }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadDetailSnapshot:probeOlder:query", + "ProjectionSnapshotQuery.getThreadDetailSnapshot:probeOlder:decodeRows", + ), + ), + )).length > 0; // An empty window (no turns before the cursor, or a thread with no // turns at all) still returns thread metadata with empty collections // for turn-linked rows; turnless rows are bounded to the same empty // range. The first page of a turnless thread stays unwindowed so - // pre-turn content (e.g. a just-created thread) is not hidden. + // pre-turn content (e.g. a just-created thread) is not hidden. Once + // paging reaches the oldest turn, include turnless messages before + // the first turn, such as history imported from a provider session. const bounds: ThreadDetailBounds | undefined = oldest === undefined && cursor === null ? undefined : { - minAnchorAt: oldest?.anchorAt ?? "", - minTurnKey: oldest?.turnKey ?? "", + minAnchorAt: hasMore ? (oldest?.anchorAt ?? "") : "", + minTurnKey: hasMore ? (oldest?.turnKey ?? "") : "", beforeAnchorAt: cursor?.beforeAnchorAt ?? ANCHOR_UNBOUNDED, beforeTurnKey: cursor?.beforeTurnId ?? "", }; @@ -3666,23 +3751,6 @@ pending_approval_requests AS ( return Option.none(); } - const hasMore = - oldest !== undefined && - (yield* listTurnWindowRows({ - threadId, - beforeAnchorAt: oldest.anchorAt, - beforeTurnKey: oldest.turnKey, - userTurnLimit: 1, - maxRawTurns: 1, - }).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionSnapshotQuery.getThreadDetailSnapshot:probeOlder:query", - "ProjectionSnapshotQuery.getThreadDetailSnapshot:probeOlder:decodeRows", - ), - ), - )).length > 0; - const { snapshotSequence } = yield* getSnapshotSequence(); const watermarkRow = yield* getThreadEventWatermarkRow({ threadId, @@ -3788,6 +3856,7 @@ pending_approval_requests AS ( getActiveProjectByWorkspaceRoot, getProjectShellById, getFirstActiveThreadIdByProjectId, + getImportedAgentSessionSources, getThreadCheckpointContext, getFullThreadDiffContext, getSessionStopContextById, diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 36eb1aa72f83..72c82edcf455 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -61,7 +61,6 @@ import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQu import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts"; import * as ThreadPlanProgress from "../ThreadPlanProgress.ts"; import { - providerErrorLabel, providerErrorLabelFromInstanceHint, ProviderCommandReactorLive, } from "./ProviderCommandReactor.ts"; @@ -166,10 +165,6 @@ describe("ProviderCommandReactor", () => { }), ).toBe("claude_openrouter"); }); - - it("uses the unknown driver kind when the resolved driver is not registered locally", () => { - expect(providerErrorLabel("third_party_driver")).toBe("third_party_driver"); - }); }); async function createHarness(input?: { diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 7dd5f61a3a0f..f41667cda857 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -242,7 +242,7 @@ function formatThreadTitleContext(messages: ReadonlyArray): } const STARTUP_RECOVERY_CONCURRENCY = 4; -export function providerErrorLabel(value: string | undefined): string { +function providerErrorLabel(value: string | undefined): string { const normalized = value?.trim(); return normalized && normalized.length > 0 ? normalized : "unknown"; } diff --git a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts index 2a580966ee0b..c99a56b620ae 100644 --- a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts @@ -7,6 +7,7 @@ * @module ProjectionSnapshotQuery */ import type { + AgentSessionImportSource, ApprovalRequestId, CheckpointRef, OrchestrationCheckpointSummary, @@ -178,6 +179,15 @@ export interface ProjectionSnapshotQueryShape { projectId: ProjectId, ) => Effect.Effect, ProjectionRepositoryError>; + /** Read completed import sources without loading thread history. */ + readonly getImportedAgentSessionSources: (projectId: ProjectId) => Effect.Effect< + ReadonlyArray<{ + readonly threadId: ThreadId; + readonly source: AgentSessionImportSource; + }>, + ProjectionRepositoryError + >; + /** * Read the checkpoint context needed to resolve a single thread diff. */ diff --git a/apps/server/src/orchestration/commandInvariants.test.ts b/apps/server/src/orchestration/commandInvariants.test.ts index 05f4cfdb875a..62e7a21bac3d 100644 --- a/apps/server/src/orchestration/commandInvariants.test.ts +++ b/apps/server/src/orchestration/commandInvariants.test.ts @@ -129,9 +129,7 @@ const messageSendCommand: OrchestrationCommand = { }; describe("commandInvariants", () => { - it("finds threads by id and project", () => { - expect(findThreadById(readModel, ThreadId.make("thread-1"))?.projectId).toBe("project-a"); - expect(findThreadById(readModel, ThreadId.make("missing"))).toBeUndefined(); + it("lists threads by project", () => { expect( listThreadsByProjectId(readModel, ProjectId.make("project-b")).map((thread) => thread.id), ).toEqual([ThreadId.make("thread-2")]); diff --git a/apps/server/src/orchestration/decider.import.test.ts b/apps/server/src/orchestration/decider.import.test.ts new file mode 100644 index 000000000000..36887fce8415 --- /dev/null +++ b/apps/server/src/orchestration/decider.import.test.ts @@ -0,0 +1,515 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import { + CommandId, + EventId, + MessageId, + ProjectId, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as HashMap from "effect/HashMap"; +import * as TestClock from "effect/testing/TestClock"; + +import { decideOrchestrationCommand } from "./decider.ts"; +import { createEmptyReadModel, projectEvent } from "./projector.ts"; +import type { CommandReadModel } from "./commandReadModel.ts"; + +function firstThread(model: CommandReadModel) { + return Array.from(HashMap.values(model.threads))[0]; +} + +it.layer(NodeServices.layer)("thread history import", (it) => { + it.effect("marks imported thread creation without changing live creation", () => + Effect.gen(function* () { + const createdAt = "2026-08-24T10:00:00.000Z"; + const projectId = ProjectId.make("project-1"); + const readModel = yield* projectEvent(createEmptyReadModel(createdAt), { + sequence: 1, + eventId: EventId.make("event-project-created"), + aggregateKind: "project", + aggregateId: projectId, + type: "project.created", + occurredAt: createdAt, + commandId: CommandId.make("command-project-created"), + causationEventId: null, + correlationId: CommandId.make("command-project-created"), + metadata: {}, + payload: { + projectId, + title: "Project", + workspaceRoot: "/tmp/project", + defaultModelSelection: null, + scripts: [], + createdAt, + updatedAt: createdAt, + }, + }); + const makeCreateCommand = (threadId: ThreadId) => ({ + type: "thread.create" as const, + commandId: CommandId.make(`command-create-${threadId}`), + threadId, + projectId, + title: "Imported thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, + runtimeMode: "full-access" as const, + interactionMode: "default" as const, + branch: null, + worktreePath: null, + createdAt, + }); + + const imported = yield* decideOrchestrationCommand({ + command: { + ...makeCreateCommand(ThreadId.make("import:codex:session-1")), + historyImport: true, + }, + readModel, + }); + const live = yield* decideOrchestrationCommand({ + command: makeCreateCommand(ThreadId.make("live-thread")), + readModel, + }); + + expect(imported).toMatchObject({ + type: "thread.created", + metadata: { historyImport: true }, + }); + expect(live).toMatchObject({ type: "thread.created" }); + expect(live).not.toMatchObject({ metadata: { historyImport: true } }); + }), + ); + + it.effect("settles imported messages at the latest absolute timestamp", () => + Effect.gen(function* () { + const createdAt = "2026-08-24T10:30:00.000+02:00"; + const threadId = ThreadId.make("import:codex:session-1"); + const readModel = yield* projectEvent(createEmptyReadModel(createdAt), { + sequence: 1, + eventId: EventId.make("event-thread-created"), + aggregateKind: "thread", + aggregateId: threadId, + type: "thread.created", + occurredAt: createdAt, + commandId: CommandId.make("command-thread-created"), + causationEventId: null, + correlationId: CommandId.make("command-thread-created"), + metadata: {}, + payload: { + threadId, + projectId: ProjectId.make("project-1"), + title: "Imported thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt, + updatedAt: createdAt, + }, + }); + + const events = yield* decideOrchestrationCommand({ + command: { + type: "thread.history.import", + commandId: CommandId.make("command-import-history"), + threadId, + messages: [ + { + messageId: MessageId.make(`${threadId}:000000`), + role: "user", + text: "Fix the bug", + createdAt, + }, + { + messageId: MessageId.make(`${threadId}:000001`), + role: "assistant", + text: "Fixed", + createdAt: "2026-08-24T09:00:00.000Z", + }, + ], + }, + readModel, + }); + + expect(events).toMatchObject([ + { + type: "thread.message-sent", + metadata: { historyImport: true }, + payload: { role: "user", text: "Fix the bug", turnId: null, streaming: false }, + }, + { + type: "thread.message-sent", + metadata: { historyImport: true }, + payload: { role: "assistant", text: "Fixed", turnId: null, streaming: false }, + }, + { + type: "thread.settled", + metadata: { historyImport: true }, + occurredAt: "2026-08-24T09:00:00.000Z", + payload: { + settledAt: "2026-08-24T09:00:00.000Z", + updatedAt: "2026-08-24T09:00:00.000Z", + }, + }, + ]); + + let projected = readModel; + const plannedEvents = Array.isArray(events) ? events : [events]; + for (const [index, event] of plannedEvents.entries()) { + projected = yield* projectEvent(projected, { ...event, sequence: index + 2 }); + } + projected = yield* projectEvent(projected, { + sequence: 5, + eventId: EventId.make("event-import-reverted"), + aggregateKind: "thread", + aggregateId: threadId, + type: "thread.reverted", + occurredAt: "2026-08-24T10:02:00.000Z", + commandId: CommandId.make("command-import-reverted"), + causationEventId: null, + correlationId: CommandId.make("command-import-reverted"), + metadata: {}, + payload: { threadId, turnCount: 0 }, + }); + expect(firstThread(projected)?.messages.map((message) => message.text)).toEqual([ + "Fix the bug", + "Fixed", + ]); + }), + ); + + it.effect("allows a thread with a newly imported user message to be settled", () => + Effect.gen(function* () { + const createdAt = "2026-08-24T10:00:00.000Z"; + yield* TestClock.setTime(Date.parse("2026-08-24T10:00:30.000Z")); + const threadId = ThreadId.make("import:codex:session-1"); + const withThread = yield* projectEvent(createEmptyReadModel(createdAt), { + sequence: 1, + eventId: EventId.make("event-import-thread-created"), + aggregateKind: "thread", + aggregateId: threadId, + type: "thread.created", + occurredAt: createdAt, + commandId: CommandId.make("command-import-thread-created"), + causationEventId: null, + correlationId: CommandId.make("command-import-thread-created"), + metadata: {}, + payload: { + threadId, + projectId: ProjectId.make("project-1"), + title: "Imported thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt, + updatedAt: createdAt, + }, + }); + const readModel = yield* projectEvent(withThread, { + sequence: 2, + eventId: EventId.make("event-import-user-message"), + aggregateKind: "thread", + aggregateId: threadId, + type: "thread.message-sent", + occurredAt: createdAt, + commandId: CommandId.make("command-import-user-message"), + causationEventId: null, + correlationId: CommandId.make("command-import-user-message"), + metadata: { historyImport: true }, + payload: { + threadId, + messageId: MessageId.make("import:codex:session-1:0"), + role: "user", + text: "Existing prompt", + turnId: null, + streaming: false, + createdAt, + updatedAt: createdAt, + }, + }); + + const result = yield* decideOrchestrationCommand({ + command: { + type: "thread.settle", + commandId: CommandId.make("command-settle-imported-thread"), + threadId, + }, + readModel, + }); + + expect(result).toMatchObject({ type: "thread.settled" }); + }), + ); + + it.effect("rejects history import after a client message reaches the thread", () => + Effect.gen(function* () { + const createdAt = "2026-08-24T10:00:00.000Z"; + const liveMessageAt = "2026-08-24T10:02:00.000Z"; + const threadId = ThreadId.make("import:codex:client-race"); + const withThread = yield* projectEvent(createEmptyReadModel(createdAt), { + sequence: 1, + eventId: EventId.make("event-client-race-thread-created"), + aggregateKind: "thread", + aggregateId: threadId, + type: "thread.created", + occurredAt: createdAt, + commandId: CommandId.make("command-client-race-thread-created"), + causationEventId: null, + correlationId: CommandId.make("command-client-race-thread-created"), + metadata: {}, + payload: { + threadId, + projectId: ProjectId.make("project-1"), + title: "Imported thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt, + updatedAt: createdAt, + }, + }); + const readModel = yield* projectEvent(withThread, { + sequence: 2, + eventId: EventId.make("event-client-race-message"), + aggregateKind: "thread", + aggregateId: threadId, + type: "thread.message-sent", + occurredAt: liveMessageAt, + commandId: CommandId.make("command-client-race-message"), + causationEventId: null, + correlationId: CommandId.make("command-client-race-message"), + metadata: {}, + payload: { + threadId, + messageId: MessageId.make("client-race-message"), + role: "user", + text: "Start live work", + turnId: null, + streaming: false, + createdAt: liveMessageAt, + updatedAt: liveMessageAt, + }, + }); + + const error = yield* Effect.flip( + decideOrchestrationCommand({ + command: { + type: "thread.history.import", + commandId: CommandId.make("command-client-race-import"), + threadId, + messages: [ + { + messageId: MessageId.make(`${threadId}:000000`), + role: "user", + text: "Old work", + createdAt, + }, + ], + }, + readModel, + }), + ); + + expect(error._tag).toBe("OrchestrationCommandInvariantError"); + expect(error.message).toContain("must be active and empty"); + expect(firstThread(readModel)?.updatedAt).toBe(liveMessageAt); + }), + ); + + for (const requestKind of ["approval.requested", "user-input.requested"] as const) { + it.effect(`rejects history import with an open ${requestKind} activity`, () => + Effect.gen(function* () { + const createdAt = "2026-08-24T10:00:00.000Z"; + const threadId = ThreadId.make(`import:codex:${requestKind}`); + const withThread = yield* projectEvent(createEmptyReadModel(createdAt), { + sequence: 1, + eventId: EventId.make(`event-${requestKind}-thread-created`), + aggregateKind: "thread", + aggregateId: threadId, + type: "thread.created", + occurredAt: createdAt, + commandId: CommandId.make(`command-${requestKind}-thread-created`), + causationEventId: null, + correlationId: CommandId.make(`command-${requestKind}-thread-created`), + metadata: {}, + payload: { + threadId, + projectId: ProjectId.make("project-1"), + title: "Imported thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt, + updatedAt: createdAt, + }, + }); + const readModel = yield* projectEvent(withThread, { + sequence: 2, + eventId: EventId.make(`event-${requestKind}`), + aggregateKind: "thread", + aggregateId: threadId, + type: "thread.activity-appended", + occurredAt: createdAt, + commandId: CommandId.make(`command-${requestKind}`), + causationEventId: null, + correlationId: CommandId.make(`command-${requestKind}`), + metadata: {}, + payload: { + threadId, + activity: { + id: EventId.make(`activity-${requestKind}`), + tone: "approval", + kind: requestKind, + summary: "Pending request", + payload: { requestId: "request-1" }, + turnId: null, + createdAt, + }, + }, + }); + + const error = yield* Effect.flip( + decideOrchestrationCommand({ + command: { + type: "thread.history.import", + commandId: CommandId.make(`command-import-${requestKind}`), + threadId, + messages: [ + { + messageId: MessageId.make(`${threadId}:000000`), + role: "user", + text: "Old work", + createdAt, + }, + ], + }, + readModel, + }), + ); + + expect(error._tag).toBe("OrchestrationCommandInvariantError"); + expect(error.message).toContain("must be active and empty"); + }), + ); + } + + it.effect("rejects a live user message in the imported-session namespace", () => + Effect.gen(function* () { + const createdAt = "2026-08-24T10:00:00.000Z"; + const threadId = ThreadId.make("thread-live-message"); + const readModel = yield* projectEvent(createEmptyReadModel(createdAt), { + sequence: 1, + eventId: EventId.make("event-live-thread-created"), + aggregateKind: "thread", + aggregateId: threadId, + type: "thread.created", + occurredAt: createdAt, + commandId: CommandId.make("command-live-thread-created"), + causationEventId: null, + correlationId: CommandId.make("command-live-thread-created"), + metadata: {}, + payload: { + threadId, + projectId: ProjectId.make("project-1"), + title: "Live thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt, + updatedAt: createdAt, + }, + }); + + const error = yield* Effect.flip( + decideOrchestrationCommand({ + command: { + type: "thread.turn.start", + commandId: CommandId.make("command-live-import-id"), + threadId, + message: { + messageId: MessageId.make("import:forged-live-message"), + role: "user", + text: "Live work", + attachments: [], + }, + runtimeMode: "full-access", + interactionMode: "default", + createdAt, + }, + readModel, + }), + ); + + expect(error._tag).toBe("OrchestrationCommandInvariantError"); + expect(error.message).toContain("reserved imported-session namespace"); + }), + ); + + it.effect("rejects live assistant messages in the imported-session namespace", () => + Effect.gen(function* () { + const createdAt = "2026-08-24T10:00:00.000Z"; + const threadId = ThreadId.make("thread-live-assistant-message"); + const readModel = yield* projectEvent(createEmptyReadModel(createdAt), { + sequence: 1, + eventId: EventId.make("event-live-assistant-thread-created"), + aggregateKind: "thread", + aggregateId: threadId, + type: "thread.created", + occurredAt: createdAt, + commandId: CommandId.make("command-live-assistant-thread-created"), + causationEventId: null, + correlationId: CommandId.make("command-live-assistant-thread-created"), + metadata: {}, + payload: { + threadId, + projectId: ProjectId.make("project-1"), + title: "Live thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt, + updatedAt: createdAt, + }, + }); + + for (const commandType of [ + "thread.message.assistant.delta", + "thread.message.assistant.complete", + ] as const) { + const command = + commandType === "thread.message.assistant.delta" + ? { + type: commandType, + commandId: CommandId.make("command-live-assistant-delta-import-id"), + threadId, + messageId: MessageId.make("import:forged-live-assistant-message"), + delta: "Live work", + createdAt, + } + : { + type: commandType, + commandId: CommandId.make("command-live-assistant-complete-import-id"), + threadId, + messageId: MessageId.make("import:forged-live-assistant-message"), + createdAt, + }; + const error = yield* Effect.flip(decideOrchestrationCommand({ command, readModel })); + + expect(error._tag).toBe("OrchestrationCommandInvariantError"); + expect(error.message).toContain("reserved imported-session namespace"); + } + }), + ); +}); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 25cb592727db..825baabd360f 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -2,6 +2,7 @@ import { EventId, MessageId, UserInputRequestedPayload, + isImportedAgentSessionMessageId, type OrchestrationCommand, type OrchestrationEvent, type OrchestrationQueuedMessage, @@ -9,6 +10,7 @@ import { type OrchestrationThreadActivity, type SourceRef, } from "@t3tools/contracts"; +import { compareDateTimeStrings } from "@t3tools/shared/dateTime"; import * as DateTime from "effect/DateTime"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; @@ -100,7 +102,7 @@ function hasQueuedTurnStartForThread( let latestUserMessageAt: string | null = null; let latestUserMessageAtMs = Number.NEGATIVE_INFINITY; for (const message of thread.messages) { - if (message.role !== "user") continue; + if (message.role !== "user" || isImportedAgentSessionMessageId(message.id)) continue; const messageAtMs = Date.parse(message.createdAt); latestUserMessageAtMs = Math.max(latestUserMessageAtMs, messageAtMs); if (messageAtMs === latestUserMessageAtMs) { @@ -546,6 +548,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" aggregateId: command.threadId, occurredAt: command.createdAt, commandId: command.commandId, + ...(command.historyImport === true ? { metadata: { historyImport: true } } : {}), })), type: "thread.created", payload: { @@ -1102,6 +1105,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" } case "thread.turn.start": { + if (isImportedAgentSessionMessageId(command.message.messageId)) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Message id '${command.message.messageId}' uses the reserved imported-session namespace.`, + }); + } const targetThread = yield* requireThread({ readModel, command, @@ -1611,6 +1620,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" } case "thread.message.assistant.delta": { + if (isImportedAgentSessionMessageId(command.messageId)) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Message id '${command.messageId}' uses the reserved imported-session namespace.`, + }); + } yield* requireThread({ readModel, command, @@ -1638,6 +1653,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" } case "thread.message.assistant.complete": { + if (isImportedAgentSessionMessageId(command.messageId)) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Message id '${command.messageId}' uses the reserved imported-session namespace.`, + }); + } yield* requireThread({ readModel, command, @@ -1664,6 +1685,79 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + case "thread.history.import": { + const thread = yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); + if ( + thread.deletedAt !== null || + thread.archivedAt !== null || + thread.messages.length > 0 || + thread.latestTurn !== null || + thread.session !== null || + hasOpenBlockingRequest(thread) + ) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Thread '${command.threadId}' must be active and empty before history can be imported.`, + }); + } + const firstMessage = command.messages[0]; + if (firstMessage === undefined) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: "Thread history imports require at least one message.", + }); + } + + const events: Array = []; + for (const message of command.messages) { + events.push({ + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: message.createdAt, + commandId: command.commandId, + metadata: { historyImport: true }, + })), + type: "thread.message-sent", + payload: { + threadId: command.threadId, + messageId: message.messageId, + role: message.role, + text: message.text, + turnId: null, + streaming: false, + createdAt: message.createdAt, + updatedAt: message.createdAt, + }, + }); + } + const settledAt = command.messages.reduce( + (latest, message) => + compareDateTimeStrings(message.createdAt, latest) > 0 ? message.createdAt : latest, + firstMessage.createdAt, + ); + events.push({ + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: settledAt, + commandId: command.commandId, + metadata: { historyImport: true }, + })), + type: "thread.settled", + payload: { + threadId: command.threadId, + settledAt, + updatedAt: settledAt, + }, + }); + return events; + } + case "thread.proposed-plan.upsert": { yield* requireThread({ readModel, diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 9f1f32dd4c8c..ddc0eed7f5ca 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -1,5 +1,6 @@ import type { OrchestrationEvent, OrchestrationProject, ThreadId } from "@t3tools/contracts"; import { + isImportedAgentSessionMessageId, OrchestrationCheckpointSummary, OrchestrationMessage, OrchestrationSession, @@ -8,6 +9,7 @@ import { type ThreadParticipantSummary, } from "@t3tools/contracts"; import { mergeParticipantSummaries, nextOriginSource } from "@t3tools/shared/sourceAttribution"; +import { compareDateTimeStrings } from "@t3tools/shared/dateTime"; import * as Effect from "effect/Effect"; import * as HashMap from "effect/HashMap"; import * as HashSet from "effect/HashSet"; @@ -159,7 +161,7 @@ function retainThreadMessagesAfterRevert( ): ReadonlyArray { const retainedMessageIds = new Set(); for (const message of messages) { - if (message.role === "system") { + if (message.role === "system" || isImportedAgentSessionMessageId(message.id)) { retainedMessageIds.add(message.id); continue; } @@ -169,7 +171,10 @@ function retainThreadMessagesAfterRevert( } const retainedUserCount = messages.filter( - (message) => message.role === "user" && retainedMessageIds.has(message.id), + (message) => + message.role === "user" && + !isImportedAgentSessionMessageId(message.id) && + retainedMessageIds.has(message.id), ).length; const missingUserCount = Math.max(0, turnCount - retainedUserCount); if (missingUserCount > 0) { @@ -182,7 +187,8 @@ function retainThreadMessagesAfterRevert( ) .toSorted( (left, right) => - left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id), + compareDateTimeStrings(left.createdAt, right.createdAt) || + left.id.localeCompare(right.id), ) .slice(0, missingUserCount); for (const message of fallbackUserMessages) { @@ -191,7 +197,10 @@ function retainThreadMessagesAfterRevert( } const retainedAssistantCount = messages.filter( - (message) => message.role === "assistant" && retainedMessageIds.has(message.id), + (message) => + message.role === "assistant" && + !isImportedAgentSessionMessageId(message.id) && + retainedMessageIds.has(message.id), ).length; const missingAssistantCount = Math.max(0, turnCount - retainedAssistantCount); if (missingAssistantCount > 0) { @@ -204,7 +213,8 @@ function retainThreadMessagesAfterRevert( ) .toSorted( (left, right) => - left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id), + compareDateTimeStrings(left.createdAt, right.createdAt) || + left.id.localeCompare(right.id), ) .slice(0, missingAssistantCount); for (const message of fallbackAssistantMessages) { diff --git a/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts b/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts index c8fa16158bae..d4a70af59be5 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts @@ -12,12 +12,24 @@ const layer = it.layer( ); layer("ProjectionThreadMessageRepository", (it) => { - it.effect("finds the latest user-message time within one thread", () => + it.effect("finds the latest live user-message time within one thread", () => Effect.gen(function* () { const repository = yield* ProjectionThreadMessageRepository; const threadId = ThreadId.make("thread-latest-user-message"); assert.isNull(yield* repository.getLatestUserMessageAt({ threadId })); + yield* repository.upsert({ + messageId: MessageId.make("import:codex:latest-user-message:000000"), + threadId, + turnId: null, + role: "user", + text: "Imported prompt", + isStreaming: false, + createdAt: "2026-02-28T19:05:06.000Z", + updatedAt: "2026-02-28T19:05:06.000Z", + }); + assert.isNull(yield* repository.getLatestUserMessageAt({ threadId })); + const messages = [ { role: "user", createdAt: "2026-02-28T19:05:02.000Z" }, { role: "user", createdAt: "2026-02-28T19:05:01.000Z" }, diff --git a/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts b/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts index e591e665b58d..6f5aabf6a0bb 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts @@ -211,6 +211,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { SELECT MAX(created_at) AS "latestUserMessageAt" FROM projection_thread_messages WHERE thread_id = ${threadId} AND role = 'user' + AND message_id NOT GLOB 'import:*' `, }); diff --git a/apps/server/src/persistence/ProviderSessionRuntime.ts b/apps/server/src/persistence/ProviderSessionRuntime.ts index 2ccdd862522f..d73f56aab9e0 100644 --- a/apps/server/src/persistence/ProviderSessionRuntime.ts +++ b/apps/server/src/persistence/ProviderSessionRuntime.ts @@ -10,6 +10,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as SqlSchema from "effect/unstable/sql/SqlSchema"; import { + AgentSessionImportSource, IsoDateTime, ProviderInstanceId, ProviderSessionRuntimeStatus, @@ -58,6 +59,16 @@ export type GetProviderSessionRuntimeInput = typeof GetProviderSessionRuntimeInp export const DeleteProviderSessionRuntimeInput = Schema.Struct({ threadId: ThreadId }); export type DeleteProviderSessionRuntimeInput = typeof DeleteProviderSessionRuntimeInput.Type; +export const RecordImportedTranscriptInput = Schema.Struct({ + threadId: ThreadId, + source: AgentSessionImportSource, +}); +export type RecordImportedTranscriptInput = typeof RecordImportedTranscriptInput.Type; + +export interface ProviderSessionRuntimeUpsertOptions { + readonly onConflict?: "update" | "ignore"; +} + /** * ProviderSessionRuntimeRepository - Service tag for provider runtime persistence. */ @@ -67,10 +78,17 @@ export class ProviderSessionRuntimeRepository extends Context.Service< /** * Insert or replace a provider runtime row. * - * Upserts by canonical `threadId`, including JSON payload/cursor fields. + * Upserts by canonical `threadId`, retaining imported transcript records + * from the current database row. */ readonly upsert: ( runtime: ProviderSessionRuntime, + options?: ProviderSessionRuntimeUpsertOptions, + ) => Effect.Effect; + + /** Record one source file without replacing the current session state. */ + readonly recordImportedTranscript: ( + input: RecordImportedTranscriptInput, ) => Effect.Effect; /** @@ -129,6 +147,10 @@ const GetRuntimeRequestSchema = Schema.Struct({ const DeleteRuntimeRequestSchema = GetRuntimeRequestSchema; +const RecordImportedTranscriptRequestSchema = RecordImportedTranscriptInput.mapFields( + Struct.assign({ source: Schema.fromJsonString(AgentSessionImportSource) }), +); + function toPersistenceSqlOrDecodeError( sqlOperation: string, decodeOperation: string, @@ -147,6 +169,8 @@ function toPersistenceSqlOrDecodeError( export const make = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; + // Runtime writes can carry stale payloads. Only recordImportedTranscript may + // change source records, so restore that field from the row being updated. const upsertRuntimeRow = SqlSchema.void({ Request: ProviderSessionRuntimeDbRowSchema, execute: (runtime) => @@ -171,7 +195,11 @@ export const make = Effect.gen(function* () { ${runtime.status}, ${runtime.lastSeenAt}, ${runtime.resumeCursor}, - ${runtime.runtimePayload} + CASE + WHEN json_type(${runtime.runtimePayload}) = 'object' + THEN json_remove(${runtime.runtimePayload}, '$.importedTranscripts') + ELSE ${runtime.runtimePayload} + END ) ON CONFLICT (thread_id) DO UPDATE SET @@ -182,7 +210,107 @@ export const make = Effect.gen(function* () { status = excluded.status, last_seen_at = excluded.last_seen_at, resume_cursor_json = excluded.resume_cursor_json, - runtime_payload_json = excluded.runtime_payload_json + runtime_payload_json = CASE + WHEN json_type( + CASE + WHEN json_valid(provider_session_runtime.runtime_payload_json) + THEN provider_session_runtime.runtime_payload_json + ELSE '{}' + END, + '$.importedTranscripts' + ) IS NOT NULL + THEN json_set( + CASE + WHEN json_type(excluded.runtime_payload_json) = 'object' + THEN excluded.runtime_payload_json + ELSE '{}' + END, + '$.importedTranscripts', + json_extract(provider_session_runtime.runtime_payload_json, '$.importedTranscripts') + ) + ELSE excluded.runtime_payload_json + END + `, + }); + + const insertRuntimeRow = SqlSchema.void({ + Request: ProviderSessionRuntimeDbRowSchema, + execute: (runtime) => + sql` + INSERT INTO provider_session_runtime ( + thread_id, + provider_name, + provider_instance_id, + adapter_key, + runtime_mode, + status, + last_seen_at, + resume_cursor_json, + runtime_payload_json + ) + VALUES ( + ${runtime.threadId}, + ${runtime.providerName}, + ${runtime.providerInstanceId}, + ${runtime.adapterKey}, + ${runtime.runtimeMode}, + ${runtime.status}, + ${runtime.lastSeenAt}, + ${runtime.resumeCursor}, + CASE + WHEN json_type(${runtime.runtimePayload}) = 'object' + THEN json_remove(${runtime.runtimePayload}, '$.importedTranscripts') + ELSE ${runtime.runtimePayload} + END + ) + ON CONFLICT (thread_id) DO NOTHING + `, + }); + + const recordImportedTranscriptRow = SqlSchema.void({ + Request: RecordImportedTranscriptRequestSchema, + execute: ({ threadId, source }) => + sql` + WITH current_runtime AS ( + SELECT CASE + WHEN json_valid(runtime_payload_json) THEN CASE + WHEN json_type(runtime_payload_json) = 'object' THEN runtime_payload_json + ELSE '{}' + END + ELSE '{}' + END AS payload + FROM provider_session_runtime + WHERE thread_id = ${threadId} + ) + UPDATE provider_session_runtime + SET runtime_payload_json = ( + SELECT json_set( + payload, + '$.importedTranscripts', + json(( + SELECT json_group_array(json(value)) + FROM ( + SELECT value + FROM json_each(CASE + WHEN json_type(payload, '$.importedTranscripts') = 'array' + THEN json_extract(payload, '$.importedTranscripts') + ELSE '[]' + END) + WHERE CASE + WHEN type = 'object' THEN + json_extract(value, '$.providerInstanceId') + IS NOT json_extract(${source}, '$.providerInstanceId') + OR json_extract(value, '$.filePath') IS NOT json_extract(${source}, '$.filePath') + ELSE 0 + END + UNION ALL + SELECT ${source} AS value + ) + )) + ) + FROM current_runtime + ) + WHERE thread_id = ${threadId} `, }); @@ -235,8 +363,8 @@ export const make = Effect.gen(function* () { `, }); - const upsert: ProviderSessionRuntimeRepository["Service"]["upsert"] = (runtime) => - upsertRuntimeRow(runtime).pipe( + const upsert: ProviderSessionRuntimeRepository["Service"]["upsert"] = (runtime, options) => + (options?.onConflict === "ignore" ? insertRuntimeRow(runtime) : upsertRuntimeRow(runtime)).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( "ProviderSessionRuntimeRepository.upsert:query", @@ -246,6 +374,18 @@ export const make = Effect.gen(function* () { ), ); + const recordImportedTranscript: ProviderSessionRuntimeRepository["Service"]["recordImportedTranscript"] = + (input) => + recordImportedTranscriptRow(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProviderSessionRuntimeRepository.recordImportedTranscript:query", + "ProviderSessionRuntimeRepository.recordImportedTranscript:encodeRequest", + { threadId: input.threadId }, + ), + ), + ); + const getByThreadId: ProviderSessionRuntimeRepository["Service"]["getByThreadId"] = (input) => getRuntimeRowByThreadId(input).pipe( Effect.mapError( @@ -324,6 +464,7 @@ export const make = Effect.gen(function* () { return { upsert, + recordImportedTranscript, getByThreadId, list, deleteByThreadId, diff --git a/apps/server/src/project/AgentSessionImporter.test.ts b/apps/server/src/project/AgentSessionImporter.test.ts new file mode 100644 index 000000000000..81413f238f32 --- /dev/null +++ b/apps/server/src/project/AgentSessionImporter.test.ts @@ -0,0 +1,1217 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it, vi } from "@effect/vitest"; +import { + AgentSessionImportProjectChangedError, + CommandId, + MessageId, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + ThreadId, + type OrchestrationCommand, + type OrchestrationProjectShell, + type OrchestrationThread, + type ProviderSendTurnInput, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Deferred from "effect/Deferred"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; + +import { makeTestProviderAdapterHarness } from "../../integration/TestProviderAdapter.integration.ts"; +import { ServerConfig } from "../config.ts"; +import { GitWorkflowService } from "../git/GitWorkflowService.ts"; +import { OrchestrationCommandReceiptRepositoryLive } from "../persistence/Layers/OrchestrationCommandReceipts.ts"; +import { OrchestrationEventStoreLive } from "../persistence/Layers/OrchestrationEventStore.ts"; +import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; +import * as ProviderSessionRuntime from "../persistence/ProviderSessionRuntime.ts"; +import { OrchestrationEngineLive } from "../orchestration/Layers/OrchestrationEngine.ts"; +import { OrchestrationProjectionPipelineLive } from "../orchestration/Layers/ProjectionPipeline.ts"; +import { OrchestrationProjectionSnapshotQueryLive } from "../orchestration/Layers/ProjectionSnapshotQuery.ts"; +import { ProviderCommandReactorLive } from "../orchestration/Layers/ProviderCommandReactor.ts"; +import { OrchestrationCommandInvariantError } from "../orchestration/Errors.ts"; +import * as ThreadBackgroundLiveness from "../orchestration/ThreadBackgroundLiveness.ts"; +import * as ThreadPlanProgress from "../orchestration/ThreadPlanProgress.ts"; +import * as OrchestrationEngine from "../orchestration/Services/OrchestrationEngine.ts"; +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { ProviderCommandReactor } from "../orchestration/Services/ProviderCommandReactor.ts"; +import { ProviderSessionDirectoryLive } from "../provider/Layers/ProviderSessionDirectory.ts"; +import { makeProviderServiceLive } from "../provider/Layers/ProviderService.ts"; +import { + NoOpProviderEventLoggers, + ProviderEventLoggers, +} from "../provider/Layers/ProviderEventLoggers.ts"; +import { ProviderSessionDirectoryPersistenceError } from "../provider/Errors.ts"; +import { ProviderAdapterRegistry } from "../provider/Services/ProviderAdapterRegistry.ts"; +import { ProviderAuthService } from "../provider/Services/ProviderAuthService.ts"; +import * as ProviderSessionDirectory from "../provider/Services/ProviderSessionDirectory.ts"; +import { makeAdapterRegistryMock } from "../provider/testUtils/providerAdapterRegistryMock.ts"; +import { makeProviderRegistryLayer } from "../provider/testUtils/providerRegistryMock.ts"; +import { ServerSettingsService } from "../serverSettings.ts"; +import * as IdentityService from "../identity/IdentityService.ts"; +import * as AnalyticsService from "../telemetry/AnalyticsService.ts"; +import { TextGeneration } from "../textGeneration/TextGeneration.ts"; +import { VcsStatusBroadcaster } from "../vcs/VcsStatusBroadcaster.ts"; +import * as RepositoryIdentityResolver from "./RepositoryIdentityResolver.ts"; +import { importRecentAgentThreads } from "./AgentSessionImporter.ts"; +import * as AgentSessionScanner from "./AgentSessionScanner.ts"; + +const PROJECT_ID = ProjectId.make("project-1"); +const WORKSPACE_ROOT = "/tmp/project-from-server"; +const CLAUDE_SESSION_ID = "123e4567-e89b-42d3-a456-426614174000"; +const encodeTranscriptRecord = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); + +const makeThread = (source: "codex" | "claudeAgent"): AgentSessionScanner.AgentSessionThread => ({ + source, + providerInstanceId: ProviderInstanceId.make(source), + providerSessionId: source === "codex" ? "codex-session" : CLAUDE_SESSION_ID, + title: `Imported ${source} thread`, + model: null, + createdAt: "2026-08-24T10:00:00.000Z", + updatedAt: "2026-08-24T10:01:00.000Z", + messages: [ + { role: "user", text: "Fix the bug", createdAt: "2026-08-24T10:00:00.000Z" }, + { role: "assistant", text: "Fixed", createdAt: "2026-08-24T10:01:00.000Z" }, + ], +}); + +const makeThreadOutcome = (thread: AgentSessionScanner.AgentSessionThread) => + ({ + _tag: "Importable", + thread, + source: { + provider: thread.source, + providerInstanceId: thread.providerInstanceId, + providerSessionId: thread.providerSessionId, + filePath: `/tmp/transcripts/${thread.providerInstanceId}/${thread.providerSessionId}.jsonl`, + size: 0, + mtimeMs: 0, + device: 0, + inode: 0, + birthtimeMs: 0, + }, + }) satisfies AgentSessionScanner.AgentSessionRecentThread; + +const makeProject = (): OrchestrationProjectShell => ({ + id: PROJECT_ID, + title: "Project", + workspaceRoot: WORKSPACE_ROOT, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-08-24T09:00:00.000Z", + updatedAt: "2026-08-24T09:00:00.000Z", +}); + +const makeProjectedThread = (input: { + readonly source: "codex" | "claudeAgent"; + readonly projectId?: ProjectId; + readonly imported?: boolean; + readonly includeFollowup?: boolean; +}): OrchestrationThread => { + const sourceThread = makeThread(input.source); + const threadId = ThreadId.make( + `import:${sourceThread.providerInstanceId}:${sourceThread.providerSessionId}`, + ); + return { + id: threadId, + projectId: input.projectId ?? PROJECT_ID, + title: sourceThread.title, + modelSelection: { instanceId: sourceThread.providerInstanceId, model: "default" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: sourceThread.createdAt, + updatedAt: sourceThread.updatedAt, + archivedAt: null, + settledOverride: null, + settledAt: null, + deletedAt: null, + messages: input.imported + ? [ + { + id: MessageId.make(`${threadId}:000000`), + role: "user", + text: "Fix the bug", + turnId: null, + streaming: false, + createdAt: "2026-08-24T10:00:00.000Z", + updatedAt: "2026-08-24T10:00:00.000Z", + }, + ...(input.includeFollowup + ? [ + { + id: MessageId.make("user-followup"), + role: "user" as const, + text: "Keep going", + turnId: null, + streaming: false, + createdAt: "2026-08-24T10:02:00.000Z", + updatedAt: "2026-08-24T10:02:00.000Z", + }, + ] + : []), + ] + : [], + queuedMessages: [], + pendingTurnStart: null, + proposedPlans: [], + activities: [], + checkpoints: [], + session: null, + }; +}; + +const makeSnapshotsLayer = (input: { + readonly project?: OrchestrationProjectShell; + readonly getThread?: (threadId: ThreadId) => Option.Option; +}) => + Layer.mock(ProjectionSnapshotQuery.ProjectionSnapshotQuery)({ + getProjectShellById: () => + Effect.succeed(input.project === undefined ? Option.none() : Option.some(input.project)), + getImportedAgentSessionSources: () => Effect.succeed([]), + getThreadDetailById: (threadId) => Effect.succeed(input.getThread?.(threadId) ?? Option.none()), + }); + +const runImport = (input: { + readonly scanner: AgentSessionScanner.AgentSessionScanner["Service"]; + readonly engine: OrchestrationEngine.OrchestrationEngineService["Service"]; + readonly directory: ProviderSessionDirectory.ProviderSessionDirectory["Service"]; + readonly snapshots: ReturnType; + readonly expectedWorkspaceRoot?: string; +}) => + importRecentAgentThreads({ + projectId: PROJECT_ID, + ...(input.expectedWorkspaceRoot === undefined + ? {} + : { expectedWorkspaceRoot: input.expectedWorkspaceRoot }), + }).pipe( + Effect.provideService(AgentSessionScanner.AgentSessionScanner, input.scanner), + Effect.provideService(OrchestrationEngine.OrchestrationEngineService, input.engine), + Effect.provideService(ProviderSessionDirectory.ProviderSessionDirectory, input.directory), + Effect.provide(input.snapshots), + ); + +it.layer(NodeServices.layer)("AgentSessionImporter", (it) => { + describe("importRecentAgentThreads", () => { + it.effect("uses the project root and stores provider-specific resume cursors", () => + Effect.gen(function* () { + const commands: Array = []; + const bindings: Array = []; + let scannedRoot: string | undefined; + const scanner = AgentSessionScanner.AgentSessionScanner.of({ + scan: Effect.die("unused"), + recentThreads: (workspaceRoot) => { + scannedRoot = workspaceRoot; + return Stream.concat( + Stream.succeed(makeThreadOutcome(makeThread("codex"))), + Stream.fromEffect( + Effect.sync(() => { + expect(bindings).toHaveLength(1); + return makeThreadOutcome(makeThread("claudeAgent")); + }), + ), + ); + }, + }); + const engine = OrchestrationEngine.OrchestrationEngineService.of({ + dispatch: (command) => Effect.sync(() => ({ sequence: commands.push(command) })), + readEvents: () => Stream.empty, + readThreadEvents: () => Stream.empty, + getThreadReplayStats: () => Effect.die("unused"), + streamDomainEvents: Stream.empty, + subscribeDomainEvents: Effect.succeed(Stream.empty), + latestSequence: Effect.succeed(0), + }); + const directory = ProviderSessionDirectory.ProviderSessionDirectory.of({ + upsert: (binding) => Effect.sync(() => void bindings.push(binding)), + getProvider: () => Effect.die("unused"), + recordImportedTranscript: () => Effect.void, + getBinding: () => Effect.succeed(Option.none()), + listThreadIds: () => Effect.die("unused"), + listBindings: () => Effect.die("unused"), + }); + + const result = yield* runImport({ + scanner, + engine, + directory, + snapshots: makeSnapshotsLayer({ project: makeProject() }), + expectedWorkspaceRoot: `${WORKSPACE_ROOT}/`, + }); + + expect(result).toEqual({ importedCount: 2, skippedCount: 0 }); + expect(scannedRoot).toBe(WORKSPACE_ROOT); + expect(commands.map((command) => command.type)).toEqual([ + "thread.create", + "thread.history.import", + "thread.create", + "thread.history.import", + ]); + expect(commands.filter((command) => command.type === "thread.create")).toMatchObject([ + { historyImport: true }, + { historyImport: true }, + ]); + expect( + commands + .filter((command) => command.type === "thread.history.import") + .flatMap((command) => command.messages.map((message) => message.messageId)), + ).toEqual([ + "import:codex:codex-session:000000", + "import:codex:codex-session:000001", + `import:claudeAgent:${CLAUDE_SESSION_ID}:000000`, + `import:claudeAgent:${CLAUDE_SESSION_ID}:000001`, + ]); + expect(bindings).toMatchObject([ + { + provider: "codex", + providerInstanceId: "codex", + resumeCursor: { threadId: "codex-session" }, + runtimePayload: { cwd: WORKSPACE_ROOT }, + }, + { + provider: "claudeAgent", + providerInstanceId: "claudeAgent", + resumeCursor: { + threadId: `import:claudeAgent:${CLAUDE_SESSION_ID}`, + resume: CLAUDE_SESSION_ID, + }, + runtimePayload: { cwd: WORKSPACE_ROOT }, + }, + ]); + }), + ); + + it.effect("rejects a changed project root before scanning or writing", () => + Effect.gen(function* () { + const recentThreads = vi.fn(() => Stream.empty); + const error = yield* importRecentAgentThreads({ + projectId: PROJECT_ID, + expectedWorkspaceRoot: WORKSPACE_ROOT, + }).pipe( + Effect.provideService( + AgentSessionScanner.AgentSessionScanner, + AgentSessionScanner.AgentSessionScanner.of({ + scan: Effect.die("must not scan a changed project"), + recentThreads, + }), + ), + Effect.provide( + Layer.mergeAll( + Layer.mock(OrchestrationEngine.OrchestrationEngineService)({}), + Layer.mock(ProviderSessionDirectory.ProviderSessionDirectory)({}), + makeSnapshotsLayer({ + project: { ...makeProject(), workspaceRoot: "/tmp/project-moved" }, + }), + ), + ), + Effect.flip, + ); + + expect(error).toEqual(new AgentSessionImportProjectChangedError({ projectId: PROJECT_ID })); + expect(recentThreads).not.toHaveBeenCalled(); + }), + ); + + it.effect("counts scanner skips without writing a thread or binding", () => + Effect.gen(function* () { + const scanner = AgentSessionScanner.AgentSessionScanner.of({ + scan: Effect.die("unused"), + recentThreads: () => Stream.succeed({ _tag: "Skipped" }), + }); + const engine = OrchestrationEngine.OrchestrationEngineService.of({ + dispatch: () => Effect.die("must not dispatch for a scanner skip"), + readEvents: () => Stream.empty, + readThreadEvents: () => Stream.empty, + getThreadReplayStats: () => Effect.die("unused"), + streamDomainEvents: Stream.empty, + subscribeDomainEvents: Effect.succeed(Stream.empty), + latestSequence: Effect.succeed(0), + }); + const directory = ProviderSessionDirectory.ProviderSessionDirectory.of({ + upsert: () => Effect.die("must not bind a scanner skip"), + getProvider: () => Effect.die("unused"), + recordImportedTranscript: () => Effect.die("unused"), + getBinding: () => Effect.die("must not read a scanner skip binding"), + listThreadIds: () => Effect.die("unused"), + listBindings: () => Effect.die("unused"), + }); + + const result = yield* runImport({ + scanner, + engine, + directory, + snapshots: makeSnapshotsLayer({ project: makeProject() }), + }); + + expect(result).toEqual({ importedCount: 0, skippedCount: 1 }); + }), + ); + + it.effect("recovers after a rejected history receipt and a failed binding write", () => + Effect.gen(function* () { + let threadCreated = false; + let historyImported = false; + let historyAttemptCount = 0; + let bindingAttemptCount = 0; + const rejectedCommandIds = new Set(); + const bindings: Array = []; + const scanner = AgentSessionScanner.AgentSessionScanner.of({ + scan: Effect.die("unused"), + recentThreads: () => Stream.fromIterable([makeThreadOutcome(makeThread("codex"))]), + }); + const engine = OrchestrationEngine.OrchestrationEngineService.of({ + dispatch: (command) => { + if (rejectedCommandIds.has(command.commandId)) { + return Effect.fail( + new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: "Previously rejected.", + }), + ); + } + if (command.type === "thread.create") threadCreated = true; + if (command.type === "thread.history.import") { + historyAttemptCount += 1; + if (historyAttemptCount === 1) { + rejectedCommandIds.add(command.commandId); + return Effect.fail( + new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: "Temporary history import failure.", + }), + ); + } + historyImported = true; + } + return Effect.succeed({ sequence: 1 }); + }, + readEvents: () => Stream.empty, + readThreadEvents: () => Stream.empty, + getThreadReplayStats: () => Effect.die("unused"), + streamDomainEvents: Stream.empty, + subscribeDomainEvents: Effect.succeed(Stream.empty), + latestSequence: Effect.succeed(0), + }); + const directory = ProviderSessionDirectory.ProviderSessionDirectory.of({ + upsert: (binding) => { + bindingAttemptCount += 1; + if (bindingAttemptCount === 1) { + return Effect.fail( + new ProviderSessionDirectoryPersistenceError({ + operation: "upsert", + detail: "Temporary session storage failure.", + }), + ); + } + bindings.push(binding); + return Effect.void; + }, + getProvider: () => Effect.die("unused"), + recordImportedTranscript: () => Effect.void, + getBinding: () => + Effect.succeed(bindings[0] === undefined ? Option.none() : Option.some(bindings[0])), + listThreadIds: () => Effect.die("unused"), + listBindings: () => Effect.die("unused"), + }); + const snapshots = makeSnapshotsLayer({ + project: makeProject(), + getThread: () => + threadCreated + ? Option.some(makeProjectedThread({ source: "codex", imported: historyImported })) + : Option.none(), + }); + const importOnce = () => runImport({ scanner, engine, directory, snapshots }); + + expect(yield* importOnce()).toEqual({ importedCount: 0, skippedCount: 1 }); + expect(yield* importOnce()).toEqual({ importedCount: 0, skippedCount: 1 }); + expect(yield* importOnce()).toEqual({ importedCount: 1, skippedCount: 0 }); + const historyAttemptsAfterCompletion = historyAttemptCount; + expect(yield* importOnce()).toEqual({ importedCount: 1, skippedCount: 0 }); + expect(historyAttemptCount).toBe(historyAttemptsAfterCompletion); + expect(historyAttemptCount).toBe(2); + expect(bindings).toHaveLength(1); + }), + ); + + it.effect("does not replace completed history or an active binding on retry", () => + Effect.gen(function* () { + const scanner = AgentSessionScanner.AgentSessionScanner.of({ + scan: Effect.die("unused"), + recentThreads: () => Stream.fromIterable([makeThreadOutcome(makeThread("codex"))]), + }); + const runningBinding: ProviderSessionDirectory.ProviderRuntimeBinding = { + threadId: ThreadId.make("import:codex:codex-session"), + provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + status: "running", + resumeCursor: { threadId: "newer-codex-session" }, + }; + const directory = ProviderSessionDirectory.ProviderSessionDirectory.of({ + upsert: () => Effect.die("must not replace an active binding"), + getProvider: () => Effect.die("unused"), + recordImportedTranscript: () => Effect.void, + getBinding: () => Effect.succeed(Option.some(runningBinding)), + listThreadIds: () => Effect.die("unused"), + listBindings: () => Effect.die("unused"), + }); + const engine = OrchestrationEngine.OrchestrationEngineService.of({ + dispatch: () => Effect.die("must not replay history or settle active work"), + readEvents: () => Stream.empty, + readThreadEvents: () => Stream.empty, + getThreadReplayStats: () => Effect.die("unused"), + streamDomainEvents: Stream.empty, + subscribeDomainEvents: Effect.succeed(Stream.empty), + latestSequence: Effect.succeed(0), + }); + + const result = yield* runImport({ + scanner, + engine, + directory, + snapshots: makeSnapshotsLayer({ + project: makeProject(), + getThread: () => + Option.some( + makeProjectedThread({ source: "codex", imported: true, includeFollowup: true }), + ), + }), + }); + + expect(result).toEqual({ importedCount: 1, skippedCount: 0 }); + }), + ); + + it.effect("skips malformed Claude ids and wrong-project thread collisions", () => + Effect.gen(function* () { + const scanner = AgentSessionScanner.AgentSessionScanner.of({ + scan: Effect.die("unused"), + recentThreads: () => + Stream.fromIterable([ + makeThreadOutcome({ ...makeThread("claudeAgent"), providerSessionId: "not-a-uuid" }), + makeThreadOutcome(makeThread("codex")), + ]), + }); + const commands: Array = []; + const engine = OrchestrationEngine.OrchestrationEngineService.of({ + dispatch: (command) => Effect.sync(() => ({ sequence: commands.push(command) })), + readEvents: () => Stream.empty, + readThreadEvents: () => Stream.empty, + getThreadReplayStats: () => Effect.die("unused"), + streamDomainEvents: Stream.empty, + subscribeDomainEvents: Effect.succeed(Stream.empty), + latestSequence: Effect.succeed(0), + }); + const directory = ProviderSessionDirectory.ProviderSessionDirectory.of({ + upsert: () => Effect.die("must not bind malformed or wrong-project sessions"), + getProvider: () => Effect.die("unused"), + recordImportedTranscript: () => Effect.die("unused"), + getBinding: () => Effect.succeed(Option.none()), + listThreadIds: () => Effect.die("unused"), + listBindings: () => Effect.die("unused"), + }); + + const result = yield* runImport({ + scanner, + engine, + directory, + snapshots: makeSnapshotsLayer({ + project: makeProject(), + getThread: (threadId) => + threadId === "import:codex:codex-session" + ? Option.some( + makeProjectedThread({ + source: "codex", + projectId: ProjectId.make("project-other"), + }), + ) + : Option.none(), + }), + }); + + expect(result).toEqual({ importedCount: 0, skippedCount: 2 }); + expect(commands).toHaveLength(0); + }), + ); + }); +}); + +const integrationThread = { + ...makeThread("codex"), + updatedAt: "2026-08-24T10:00:00.000Z", + messages: Array.from({ length: 12 }, (_, index) => ({ + role: index % 2 === 0 ? ("user" as const) : ("assistant" as const), + text: `Message ${index}`, + createdAt: "2026-08-24T10:00:00.000Z", + })), +}; +const integrationScanner = AgentSessionScanner.AgentSessionScanner.of({ + scan: Effect.die("unused"), + recentThreads: () => Stream.fromIterable([makeThreadOutcome(integrationThread)]), +}); +const integrationServerConfig = ServerConfig.layerTest(process.cwd(), { + prefix: "t3-agent-session-importer-test-", +}); +const integrationRuntimeRepository = ProviderSessionRuntime.layer.pipe( + Layer.provide(SqlitePersistenceMemory), +); +const integrationLayer = Layer.mergeAll( + OrchestrationEngineLive.pipe( + Layer.provide(OrchestrationProjectionSnapshotQueryLive), + Layer.provide(OrchestrationProjectionPipelineLive), + ), + OrchestrationProjectionSnapshotQueryLive, + integrationRuntimeRepository, + ProviderSessionDirectoryLive.pipe(Layer.provide(integrationRuntimeRepository)), + Layer.succeed(AgentSessionScanner.AgentSessionScanner, integrationScanner), +).pipe( + Layer.provide(ThreadBackgroundLiveness.layer), + Layer.provide(ThreadPlanProgress.layer), + Layer.provide(OrchestrationEventStoreLive), + Layer.provide(OrchestrationCommandReceiptRepositoryLive), + Layer.provide(RepositoryIdentityResolver.layer), + Layer.provideMerge(SqlitePersistenceMemory), + Layer.provideMerge(integrationServerConfig), + Layer.provideMerge(NodeServices.layer), +); + +it.layer(integrationLayer)("AgentSessionImporter integration", (it) => { + it.effect("imports once after the real engine persists an old rejected receipt", () => + Effect.gen(function* () { + const engine = yield* OrchestrationEngine.OrchestrationEngineService; + const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; + const threadId = ThreadId.make("import:codex:codex-session"); + + yield* engine.dispatch({ + type: "project.create", + commandId: CommandId.make("create-import-integration-project"), + projectId: PROJECT_ID, + title: "Project", + workspaceRoot: WORKSPACE_ROOT, + defaultModelSelection: null, + createdAt: "2026-08-24T09:00:00.000Z", + }); + const rejected = yield* Effect.result( + engine.dispatch({ + type: "thread.history.import", + commandId: CommandId.make(`agent-session:history:${threadId}`), + threadId, + messages: [ + { + messageId: MessageId.make(`${threadId}:000000`), + role: "user", + text: "Fix the bug", + createdAt: "2026-08-24T10:00:00.000Z", + }, + ], + }), + ); + expect(rejected._tag).toBe("Failure"); + + const result = yield* importRecentAgentThreads({ projectId: PROJECT_ID }); + const importedThread = yield* snapshots.getThreadDetailById(threadId); + const binding = yield* directory.getBinding(threadId); + + expect(result).toEqual({ importedCount: 1, skippedCount: 0 }); + expect(Option.getOrThrow(importedThread).messages.map((message) => message.text)).toEqual( + integrationThread.messages.map((message) => message.text), + ); + expect(Option.getOrThrow(importedThread).settledOverride).toBe("settled"); + expect(Option.getOrThrow(importedThread).updatedAt).toBe("2026-08-24T10:00:00.000Z"); + expect(Option.getOrThrow(binding)).toMatchObject({ + provider: "codex", + providerInstanceId: "codex", + resumeCursor: { threadId: "codex-session" }, + runtimePayload: { cwd: WORKSPACE_ROOT }, + }); + + yield* engine.dispatch({ + type: "thread.revert.complete", + commandId: CommandId.make("revert-imported-thread-to-baseline"), + threadId, + turnCount: 0, + createdAt: "2026-08-24T10:05:00.000Z", + }); + const afterRevert = yield* snapshots.getThreadDetailById(threadId); + expect(Option.getOrThrow(afterRevert).messages.map((message) => message.text)).toEqual( + integrationThread.messages.map((message) => message.text), + ); + }), + ); + + it.effect( + "retries a bounded import after scanner restart without rereading completed transcripts", + () => + Effect.gen(function* () { + const engine = yield* OrchestrationEngine.OrchestrationEngineService; + const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const fixtureDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-import-retry-", + }); + const workspaceRoot = path.join(fixtureDir, "workspace"); + const claudeHomePath = path.join(fixtureDir, "claude"); + const codexHomePath = path.join(fixtureDir, "codex"); + const sessionsDir = path.join(codexHomePath, "sessions", "2026", "08", "24"); + yield* fileSystem.makeDirectory(workspaceRoot); + yield* fileSystem.makeDirectory(claudeHomePath); + yield* fileSystem.makeDirectory(sessionsDir, { recursive: true }); + + const projectId = ProjectId.make("project-bounded-import-retry"); + const transcripts = Array.from({ length: 101 }, (_, index) => { + const providerSessionId = `bounded-session-${String(index).padStart(3, "0")}`; + return { + providerSessionId, + threadId: ThreadId.make(`import:codex:${providerSessionId}`), + filePath: path.join(sessionsDir, `rollout-${providerSessionId}.jsonl`), + }; + }); + for (const [index, transcript] of transcripts.entries()) { + yield* fileSystem.writeFileString( + transcript.filePath, + [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: transcript.providerSessionId, cwd: workspaceRoot }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { + type: "user_message", + message: `Prompt ${transcript.providerSessionId}`, + }, + }), + ].join("\n"), + ); + const seconds = nowMs / 1_000 - index; + yield* fileSystem.utimes(transcript.filePath, seconds, seconds); + } + const legacy = transcripts[0]!; + const failed = transcripts[1]!; + const remaining = transcripts[100]!; + yield* engine.dispatch({ + type: "project.create", + commandId: CommandId.make("create-bounded-import-project"), + projectId, + title: "Bounded import", + workspaceRoot, + defaultModelSelection: null, + createdAt: "2026-08-24T09:00:00.000Z", + }); + + // This completed import predates persisted transcript source metadata. + yield* directory.upsert({ + threadId: legacy.threadId, + provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + status: "stopped", + resumeCursor: { threadId: "legacy-current-session" }, + runtimePayload: { cwd: workspaceRoot }, + }); + yield* engine.dispatch({ + type: "thread.create", + commandId: CommandId.make("create-legacy-bounded-import"), + threadId: legacy.threadId, + projectId, + title: "Legacy import", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "default" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: "2026-08-24T10:00:00.000Z", + historyImport: true, + }); + yield* engine.dispatch({ + type: "thread.history.import", + commandId: CommandId.make("import-legacy-bounded-history"), + threadId: legacy.threadId, + messages: [ + { + messageId: MessageId.make(`${legacy.threadId}:000000`), + role: "user", + text: "Legacy imported history", + createdAt: "2026-08-24T10:00:00.000Z", + }, + ], + }); + expect(yield* snapshots.getImportedAgentSessionSources(projectId)).toEqual([]); + + let failHistory = true; + const importerEngine = OrchestrationEngine.OrchestrationEngineService.of({ + ...engine, + dispatch: (command) => { + if ( + failHistory && + command.type === "thread.history.import" && + command.threadId === failed.threadId + ) { + failHistory = false; + return Effect.fail( + new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: "Injected history import failure.", + }), + ); + } + return engine.dispatch(command); + }, + }); + const settingsLayer = ServerSettingsService.layerTest({ + providers: { + claudeAgent: { homePath: claudeHomePath }, + codex: { homePath: codexHomePath }, + }, + }); + const transcriptPaths = new Set(transcripts.map((transcript) => transcript.filePath)); + const runAttempt = Effect.fn("runBoundedImportAttempt")(function* ( + completedPaths: ReadonlySet, + ) { + const openCounts = new Map(); + const fullReads: string[] = []; + const observedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + open: (filePath, options) => + Effect.suspend(() => { + if (transcriptPaths.has(filePath)) { + const count = (openCounts.get(filePath) ?? 0) + 1; + openCounts.set(filePath, count); + // A fresh scanner first opens each file for project discovery. + if (count > 1) { + fullReads.push(filePath); + if (completedPaths.has(filePath)) { + return Effect.die(new Error(`Completed transcript reopened: ${filePath}`)); + } + } + } + return fileSystem.open(filePath, options); + }), + }); + const result = yield* importRecentAgentThreads({ projectId }).pipe( + Effect.provide( + Layer.fresh(AgentSessionScanner.layer).pipe( + Layer.provide(settingsLayer), + Layer.provide(Layer.succeed(FileSystem.FileSystem, observedFileSystem)), + ), + ), + Effect.provideService(OrchestrationEngine.OrchestrationEngineService, importerEngine), + ); + return { result, fullReads, openCounts }; + }); + + const first = yield* runAttempt(new Set()); + expect(first.result).toEqual({ importedCount: 99, skippedCount: 2 }); + expect(failHistory).toBe(false); + expect(first.fullReads).toEqual(transcripts.slice(0, 100).map((entry) => entry.filePath)); + expect(first.openCounts.get(remaining.filePath)).toBe(1); + const completedSources = yield* snapshots.getImportedAgentSessionSources(projectId); + expect(completedSources).toHaveLength(99); + expect(completedSources).toContainEqual({ + threadId: legacy.threadId, + source: expect.objectContaining({ filePath: legacy.filePath }), + }); + expect( + Option.getOrThrow(yield* snapshots.getThreadDetailById(failed.threadId)).messages, + ).toEqual([]); + expect(Option.getOrThrow(yield* directory.getBinding(failed.threadId))).toMatchObject({ + status: "stopped", + resumeCursor: { threadId: failed.providerSessionId }, + }); + expect(Option.isNone(yield* snapshots.getThreadDetailById(remaining.threadId))).toBe(true); + + const completedPaths = new Set(completedSources.map((entry) => entry.source.filePath)); + const second = yield* runAttempt(completedPaths); + expect(second.result).toEqual({ importedCount: 101, skippedCount: 0 }); + expect(second.fullReads).toEqual([failed.filePath, remaining.filePath]); + for (const transcript of transcripts) { + expect(second.openCounts.get(transcript.filePath)).toBe( + completedPaths.has(transcript.filePath) ? 1 : 2, + ); + } + expect(yield* snapshots.getImportedAgentSessionSources(projectId)).toHaveLength(101); + expect( + Option.getOrThrow(yield* snapshots.getThreadDetailById(legacy.threadId)).messages.map( + (message) => message.text, + ), + ).toEqual(["Legacy imported history"]); + expect( + Option.getOrThrow(yield* directory.getBinding(legacy.threadId)).resumeCursor, + ).toEqual({ + threadId: "legacy-current-session", + }); + for (const transcript of [failed, remaining]) { + expect( + Option.getOrThrow( + yield* snapshots.getThreadDetailById(transcript.threadId), + ).messages.map((message) => message.text), + ).toEqual([`Prompt ${transcript.providerSessionId}`]); + } + }), + ); + + for (const source of ["codex", "claudeAgent"] as const) { + it.effect(`resumes imported ${source} history only after the first prompt`, () => + Effect.gen(function* () { + const engine = yield* OrchestrationEngine.OrchestrationEngineService; + const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; + const fileSystem = yield* FileSystem.FileSystem; + const workspaceRoot = yield* fileSystem.makeTempDirectoryScoped(); + const projectId = ProjectId.make(`project-import-resume-${source}`); + const sourceThread = { + ...makeThread(source), + providerSessionId: source === "codex" ? "codex-first-resume" : CLAUDE_SESSION_ID, + }; + const threadId = ThreadId.make( + `import:${sourceThread.providerInstanceId}:${sourceThread.providerSessionId}`, + ); + const resumeCursor = + source === "codex" + ? { threadId: sourceThread.providerSessionId } + : { threadId, resume: sourceThread.providerSessionId }; + const provider = ProviderDriverKind.make(source); + const harness = yield* makeTestProviderAdapterHarness({ provider }); + const importSettled = yield* Deferred.make(); + const turnSent = yield* Deferred.make(); + const startSession = vi.fn(harness.adapter.startSession); + const sendTurn = vi.fn((input: ProviderSendTurnInput) => + harness.adapter + .sendTurn(input) + .pipe(Effect.tap(() => Deferred.succeed(turnSent, undefined))), + ); + const providerLayer = makeProviderServiceLive().pipe( + Layer.provide( + Layer.succeed( + ProviderAdapterRegistry, + makeAdapterRegistryMock({ + [provider]: { ...harness.adapter, startSession, sendTurn }, + }), + ), + ), + Layer.provide( + Layer.succeed(ProviderSessionDirectory.ProviderSessionDirectory, directory), + ), + Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), + Layer.provide(AnalyticsService.layerTest), + ); + const reactorLayer = ProviderCommandReactorLive.pipe( + Layer.provideMerge(providerLayer), + Layer.provide( + Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { + ...snapshots, + // Acknowledge the imported settlement before draining the reactor. + getThreadShellById: (requestedThreadId) => + snapshots + .getThreadShellById(requestedThreadId) + .pipe( + Effect.tap(() => + requestedThreadId === threadId + ? Deferred.succeed(importSettled, undefined) + : Effect.void, + ), + ), + }), + ), + Layer.provide( + Layer.mock(ProviderAuthService)({ + tryHandlePromptCommand: () => Effect.succeed(false), + }), + ), + Layer.provide(makeProviderRegistryLayer()), + Layer.provide(Layer.mock(GitWorkflowService)({})), + Layer.provide(Layer.mock(VcsStatusBroadcaster)({})), + Layer.provide(Layer.mock(TextGeneration)({})), + Layer.provide(ServerSettingsService.layerTest()), + Layer.provide(IdentityService.layerWithPeople([])), + ); + + yield* engine.dispatch({ + type: "project.create", + commandId: CommandId.make(`create-import-resume-project-${source}`), + projectId, + title: "Import resume", + workspaceRoot, + defaultModelSelection: null, + createdAt: "2026-08-24T09:00:00.000Z", + }); + yield* harness.queueTurnResponseForNextSession({ events: [] }); + + yield* Effect.gen(function* () { + const reactor = yield* ProviderCommandReactor; + yield* reactor.start(); + expect(yield* importRecentAgentThreads({ projectId })).toEqual({ + importedCount: 1, + skippedCount: 0, + }); + yield* Deferred.await(importSettled); + yield* reactor.drain; + expect(startSession).not.toHaveBeenCalled(); + expect(sendTurn).not.toHaveBeenCalled(); + const importedThread = Option.getOrThrow(yield* snapshots.getThreadDetailById(threadId)); + expect(importedThread.session).toBeNull(); + expect(importedThread.latestTurn).toBeNull(); + + yield* engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make(`resume-imported-${source}`), + threadId, + message: { + messageId: MessageId.make(`resume-imported-message-${source}`), + role: "user", + text: "Continue this session", + attachments: [], + }, + modelSelection: importedThread.modelSelection, + runtimeMode: importedThread.runtimeMode, + interactionMode: importedThread.interactionMode, + createdAt: "2026-08-24T10:02:00.000Z", + }); + yield* Deferred.await(turnSent); + yield* reactor.drain; + expect(startSession).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + threadId, + provider, + providerInstanceId: sourceThread.providerInstanceId, + resumeCursor, + cwd: workspaceRoot, + }), + ); + expect(sendTurn).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ threadId, input: "Continue this session" }), + ); + expect(Option.getOrThrow(yield* directory.getBinding(threadId))).toMatchObject({ + provider, + providerInstanceId: sourceThread.providerInstanceId, + resumeCursor, + }); + }).pipe( + Effect.provide(reactorLayer), + Effect.provideService( + AgentSessionScanner.AgentSessionScanner, + AgentSessionScanner.AgentSessionScanner.of({ + scan: Effect.die("unused"), + recentThreads: () => Stream.succeed(makeThreadOutcome(sourceThread)), + }), + ), + ); + }), + ); + } + + it.effect("persists the resume cursor before publishing a new imported thread", () => + Effect.gen(function* () { + const engine = yield* OrchestrationEngine.OrchestrationEngineService; + const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; + const repository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; + const projectId = ProjectId.make("project-import-binding-race"); + const workspaceRoot = "/tmp/project-import-binding-race"; + const providerSessionId = "codex-binding-race"; + const threadId = ThreadId.make(`import:codex:${providerSessionId}`); + const scanner = AgentSessionScanner.AgentSessionScanner.of({ + scan: Effect.die("unused"), + recentThreads: () => + Stream.succeed( + makeThreadOutcome({ ...integrationThread, providerSessionId, title: "Binding race" }), + ), + }); + const importerAtBindingWrite = yield* Deferred.make(); + const releaseImporter = yield* Deferred.make(); + const importerRepository = ProviderSessionRuntime.ProviderSessionRuntimeRepository.of({ + ...repository, + upsert: (runtime, options) => + options?.onConflict === "ignore" + ? Deferred.succeed(importerAtBindingWrite, undefined).pipe( + Effect.andThen(Deferred.await(releaseImporter)), + Effect.andThen(repository.upsert(runtime, options)), + ) + : repository.upsert(runtime, options), + }); + const importerDirectory = yield* ProviderSessionDirectory.ProviderSessionDirectory.pipe( + Effect.provide( + Layer.fresh(ProviderSessionDirectoryLive).pipe( + Layer.provide( + Layer.succeed( + ProviderSessionRuntime.ProviderSessionRuntimeRepository, + importerRepository, + ), + ), + ), + ), + ); + + yield* engine.dispatch({ + type: "project.create", + commandId: CommandId.make("create-import-binding-race-project"), + projectId, + title: "Binding race", + workspaceRoot, + defaultModelSelection: null, + createdAt: "2026-08-24T09:00:00.000Z", + }); + + const importFiber = yield* importRecentAgentThreads({ projectId }).pipe( + Effect.provideService(AgentSessionScanner.AgentSessionScanner, scanner), + Effect.provideService(ProviderSessionDirectory.ProviderSessionDirectory, importerDirectory), + Effect.forkChild, + ); + + yield* Effect.raceFirst( + Deferred.await(importerAtBindingWrite), + Fiber.join(importFiber).pipe( + Effect.flatMap((result) => + Effect.die( + new Error(`Import completed before the binding write: ${JSON.stringify(result)}`), + ), + ), + ), + ); + expect(Option.isNone(yield* snapshots.getThreadDetailById(threadId))).toBe(true); + + yield* directory.upsert({ + threadId, + provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + status: "running", + resumeCursor: { threadId: "active-client-session" }, + runtimePayload: { cwd: workspaceRoot, activeTurnId: "turn-active" }, + }); + yield* Deferred.succeed(releaseImporter, undefined); + + expect(yield* Fiber.join(importFiber)).toEqual({ importedCount: 1, skippedCount: 0 }); + expect( + Option.getOrThrow(yield* snapshots.getThreadDetailById(threadId)).messages.map( + (message) => message.text, + ), + ).toEqual(integrationThread.messages.map((message) => message.text)); + expect(Option.getOrThrow(yield* directory.getBinding(threadId))).toMatchObject({ + status: "running", + resumeCursor: { threadId: "active-client-session" }, + runtimePayload: { cwd: workspaceRoot, activeTurnId: "turn-active" }, + }); + }), + ); + + it.effect("does not import history over a turn started on a partial thread", () => + Effect.gen(function* () { + const engine = yield* OrchestrationEngine.OrchestrationEngineService; + const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; + const repository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; + const projectId = ProjectId.make("project-import-turn-race"); + const workspaceRoot = "/tmp/project-import-turn-race"; + const providerSessionId = "codex-turn-race"; + const threadId = ThreadId.make(`import:codex:${providerSessionId}`); + const scanner = AgentSessionScanner.AgentSessionScanner.of({ + scan: Effect.die("unused"), + recentThreads: () => + Stream.succeed( + makeThreadOutcome({ ...integrationThread, providerSessionId, title: "Turn race" }), + ), + }); + const importerAtBindingWrite = yield* Deferred.make(); + const releaseImporter = yield* Deferred.make(); + const importerRepository = ProviderSessionRuntime.ProviderSessionRuntimeRepository.of({ + ...repository, + upsert: (runtime, options) => + options?.onConflict === "ignore" + ? Deferred.succeed(importerAtBindingWrite, undefined).pipe( + Effect.andThen(Deferred.await(releaseImporter)), + Effect.andThen(repository.upsert(runtime, options)), + ) + : repository.upsert(runtime, options), + }); + const importerDirectory = yield* ProviderSessionDirectory.ProviderSessionDirectory.pipe( + Effect.provide( + Layer.fresh(ProviderSessionDirectoryLive).pipe( + Layer.provide( + Layer.succeed( + ProviderSessionRuntime.ProviderSessionRuntimeRepository, + importerRepository, + ), + ), + ), + ), + ); + + yield* engine.dispatch({ + type: "project.create", + commandId: CommandId.make("create-import-turn-race-project"), + projectId, + title: "Turn race", + workspaceRoot, + defaultModelSelection: null, + createdAt: "2026-08-24T09:00:00.000Z", + }); + yield* engine.dispatch({ + type: "thread.create", + commandId: CommandId.make("create-import-turn-race-thread"), + threadId, + projectId, + title: "Turn race", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "default" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: "2026-08-24T10:00:00.000Z", + }); + + const importFiber = yield* importRecentAgentThreads({ projectId }).pipe( + Effect.provideService(AgentSessionScanner.AgentSessionScanner, scanner), + Effect.provideService(ProviderSessionDirectory.ProviderSessionDirectory, importerDirectory), + Effect.forkChild, + ); + yield* Deferred.await(importerAtBindingWrite); + + yield* directory.upsert({ + threadId, + provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + status: "running", + resumeCursor: { threadId: "active-client-session" }, + runtimePayload: { cwd: workspaceRoot, activeTurnId: "turn-active" }, + }); + yield* engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("start-turn-during-import"), + threadId, + message: { + messageId: MessageId.make("message-during-import"), + role: "user", + text: "Continue while import waits", + attachments: [], + }, + runtimeMode: "full-access", + interactionMode: "default", + createdAt: "2026-08-24T10:02:00.000Z", + }); + yield* Deferred.succeed(releaseImporter, undefined); + + expect(yield* Fiber.join(importFiber)).toEqual({ importedCount: 0, skippedCount: 1 }); + expect(Option.getOrThrow(yield* directory.getBinding(threadId))).toMatchObject({ + status: "running", + resumeCursor: { threadId: "active-client-session" }, + runtimePayload: { cwd: workspaceRoot, activeTurnId: "turn-active" }, + }); + expect( + Option.getOrThrow(yield* snapshots.getThreadDetailById(threadId)).messages.map( + (message) => message.text, + ), + ).toEqual(["Continue while import waits"]); + }), + ); +}); diff --git a/apps/server/src/project/AgentSessionImporter.ts b/apps/server/src/project/AgentSessionImporter.ts new file mode 100644 index 000000000000..3820c2cf411a --- /dev/null +++ b/apps/server/src/project/AgentSessionImporter.ts @@ -0,0 +1,297 @@ +import { + CommandId, + DEFAULT_MODEL, + DEFAULT_MODEL_BY_PROVIDER, + DEFAULT_PROVIDER_INTERACTION_MODE, + DEFAULT_RUNTIME_MODE, + AgentSessionImportProjectChangedError, + AgentSessionImportProjectNotFoundError, + AgentSessionSource, + AgentSessionScanError, + isImportedAgentSessionMessageId, + MessageId, + ProjectId, + ProviderDriverKind, + ThreadId, + type AgentSessionImportInput, + type AgentSessionImportResult, + type OrchestrationThread, +} from "@t3tools/contracts"; +import { normalizeProjectPathForComparison } from "@t3tools/shared/path"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; + +import * as OrchestrationEngine from "../orchestration/Services/OrchestrationEngine.ts"; +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as ProviderSessionDirectory from "../provider/Services/ProviderSessionDirectory.ts"; +import * as AgentSessionScanner from "./AgentSessionScanner.ts"; + +const CLAUDE_SESSION_ID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +class AgentSessionUnresumableSessionError extends Schema.TaggedErrorClass()( + "AgentSessionUnresumableSessionError", + { + source: AgentSessionSource, + providerSessionId: Schema.String, + }, +) { + override get message(): string { + return `Session '${this.providerSessionId}' from '${this.source}' cannot be resumed.`; + } +} + +class AgentSessionThreadProjectConflictError extends Schema.TaggedErrorClass()( + "AgentSessionThreadProjectConflictError", + { + threadId: ThreadId, + expectedProjectId: ProjectId, + actualProjectId: ProjectId, + }, +) { + override get message(): string { + return `Imported thread '${this.threadId}' belongs to project '${this.actualProjectId}', not '${this.expectedProjectId}'.`; + } +} + +class AgentSessionThreadModifiedError extends Schema.TaggedErrorClass()( + "AgentSessionThreadModifiedError", + { threadId: ThreadId }, +) { + override get message(): string { + return `Imported thread '${this.threadId}' changed before its history import completed.`; + } +} + +function hasImportedHistory(thread: OrchestrationThread): boolean { + return thread.messages.some((message) => isImportedAgentSessionMessageId(message.id)); +} + +function hasImportBlockingActivity( + thread: OrchestrationThread, + importedHistoryPresent: boolean, +): boolean { + return ( + thread.archivedAt !== null || + thread.deletedAt !== null || + thread.latestTurn !== null || + thread.session !== null || + thread.messages.some((message) => !isImportedAgentSessionMessageId(message.id)) || + thread.proposedPlans.length > 0 || + thread.activities.length > 0 || + thread.checkpoints.length > 0 || + thread.snoozedUntil != null || + thread.snoozedAt != null || + thread.pinnedAt != null || + thread.pinOrderKey != null || + thread.titleRegeneration != null || + thread.linkedPullRequest != null || + thread.unsettledAt != null || + (importedHistoryPresent + ? thread.settledOverride !== "settled" + : thread.settledOverride !== null || thread.settledAt !== null) + ); +} + +/** Import recent transcript text and persist the cursor needed to resume its provider session. */ +export const importRecentAgentThreads = Effect.fn("importRecentAgentThreads")(function* ( + input: AgentSessionImportInput, +) { + const scanner = yield* AgentSessionScanner.AgentSessionScanner; + const engine = yield* OrchestrationEngine.OrchestrationEngineService; + const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; + const crypto = yield* Crypto.Crypto; + const project = yield* snapshots.getProjectShellById(input.projectId).pipe( + Effect.mapError((cause) => new AgentSessionScanError({ operation: "read-projects", cause })), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail(new AgentSessionImportProjectNotFoundError({ projectId: input.projectId })), + onSome: Effect.succeed, + }), + ), + ); + const workspaceRoot = project.workspaceRoot; + if ( + input.expectedWorkspaceRoot !== undefined && + normalizeProjectPathForComparison(workspaceRoot) !== + normalizeProjectPathForComparison(input.expectedWorkspaceRoot) + ) { + return yield* new AgentSessionImportProjectChangedError({ projectId: input.projectId }); + } + const completedSources = yield* snapshots + .getImportedAgentSessionSources(input.projectId) + .pipe( + Effect.mapError((cause) => new AgentSessionScanError({ operation: "read-projects", cause })), + ); + const threads = scanner.recentThreads( + workspaceRoot, + completedSources.map((entry) => entry.source), + ); + const importedThreadIds = new Set(); + let importedCount = 0; + let skippedCount = 0; + + yield* Stream.runForEach(threads, (outcome) => + Effect.gen(function* () { + if (outcome._tag === "Skipped") { + skippedCount += 1; + return; + } + if (outcome._tag === "AlreadyImported" || outcome._tag === "Duplicate") { + const threadId = ThreadId.make( + `import:${outcome.source.providerInstanceId}:${outcome.source.providerSessionId}`, + ); + if (outcome._tag === "AlreadyImported") { + importedThreadIds.add(threadId); + importedCount += 1; + } else if (importedThreadIds.has(threadId)) { + const recorded = yield* directory + .recordImportedTranscript({ threadId, source: outcome.source }) + .pipe(Effect.result); + if (recorded._tag === "Failure") { + skippedCount += 1; + yield* Effect.logWarning("Could not record an imported transcript copy", { + threadId, + cause: recorded.failure, + }); + } + } + return; + } + const thread = outcome.thread; + const threadId = ThreadId.make( + `import:${thread.providerInstanceId}:${thread.providerSessionId}`, + ); + const imported = yield* Effect.gen(function* () { + const provider = ProviderDriverKind.make(thread.source); + const model = thread.model ?? DEFAULT_MODEL_BY_PROVIDER[provider] ?? DEFAULT_MODEL; + const existingThread = yield* snapshots.getThreadDetailById(threadId); + const existingBinding = yield* directory.getBinding(threadId); + + if ( + thread.source === "claudeAgent" && + !CLAUDE_SESSION_ID_PATTERN.test(thread.providerSessionId) + ) { + return yield* new AgentSessionUnresumableSessionError({ + source: thread.source, + providerSessionId: thread.providerSessionId, + }); + } + + if (Option.isSome(existingThread) && existingThread.value.projectId !== input.projectId) { + return yield* new AgentSessionThreadProjectConflictError({ + threadId, + expectedProjectId: input.projectId, + actualProjectId: existingThread.value.projectId, + }); + } + + const importedHistoryPresent = Option.isSome(existingThread) + ? hasImportedHistory(existingThread.value) + : false; + if ( + Option.isSome(existingThread) && + importedHistoryPresent && + Option.isSome(existingBinding) + ) { + yield* directory.recordImportedTranscript({ threadId, source: outcome.source }); + return true; + } + + if ( + Option.isSome(existingThread) && + hasImportBlockingActivity(existingThread.value, importedHistoryPresent) + ) { + return yield* new AgentSessionThreadModifiedError({ threadId }); + } + + if ( + Option.isSome(existingBinding) && + (existingBinding.value.provider !== provider || + existingBinding.value.providerInstanceId !== thread.providerInstanceId || + existingBinding.value.status !== "stopped") + ) { + return yield* new AgentSessionThreadModifiedError({ threadId }); + } + + // Install the cursor before the thread becomes visible. A concurrent + // real session can replace it, while insert-ignore keeps this import + // from replacing that newer binding. + if (Option.isNone(existingBinding)) { + yield* directory.upsert( + { + threadId, + provider, + providerInstanceId: thread.providerInstanceId, + status: "stopped", + runtimeMode: DEFAULT_RUNTIME_MODE, + resumeCursor: + thread.source === "codex" + ? { threadId: thread.providerSessionId } + : { threadId, resume: thread.providerSessionId }, + runtimePayload: { cwd: workspaceRoot }, + }, + { onConflict: "ignore" }, + ); + } + + if (Option.isNone(existingThread)) { + yield* engine.dispatch({ + type: "thread.create", + commandId: CommandId.make(yield* crypto.randomUUIDv4), + threadId, + projectId: input.projectId, + title: thread.title, + modelSelection: { instanceId: thread.providerInstanceId, model }, + runtimeMode: DEFAULT_RUNTIME_MODE, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + branch: null, + worktreePath: null, + createdAt: thread.createdAt, + historyImport: true, + }); + } + + if (!importedHistoryPresent) { + yield* engine.dispatch({ + type: "thread.history.import", + commandId: CommandId.make(yield* crypto.randomUUIDv4), + threadId, + messages: thread.messages.map((message, index) => ({ + messageId: MessageId.make(`${threadId}:${String(index).padStart(6, "0")}`), + role: message.role, + text: message.text, + createdAt: message.createdAt, + })), + }); + } + + yield* directory.recordImportedTranscript({ threadId, source: outcome.source }); + + return true; + }).pipe( + Effect.catch((cause) => + Effect.logWarning("Could not import an agent session", { + provider: thread.source, + sessionId: thread.providerSessionId, + cause, + }).pipe(Effect.as(false)), + ), + ); + + if (imported) { + importedThreadIds.add(threadId); + importedCount += 1; + } else { + skippedCount += 1; + } + }), + ); + + return { importedCount, skippedCount } satisfies AgentSessionImportResult; +}); diff --git a/apps/server/src/project/AgentSessionScanner.test.ts b/apps/server/src/project/AgentSessionScanner.test.ts new file mode 100644 index 000000000000..578f73222329 --- /dev/null +++ b/apps/server/src/project/AgentSessionScanner.test.ts @@ -0,0 +1,3091 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as NodeOS from "node:os"; +import { describe, expect, it } from "@effect/vitest"; +import { + type OrchestrationProjectShell, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + type ServerSettings as ContractServerSettings, +} from "@t3tools/contracts"; +import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; + +import * as ServerConfig from "../config.ts"; +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import * as AgentSessionScanner from "./AgentSessionScanner.ts"; + +const makeProjectShell = (workspaceRoot: string): OrchestrationProjectShell => ({ + id: ProjectId.make("project-1"), + title: "Imported", + workspaceRoot, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", +}); + +/** Only `getShellSnapshot` is exercised; the rest must not be called. */ +const makeProjectionSnapshotQueryLayer = (importedWorkspaceRoots: ReadonlyArray) => + Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { + getCommandReadModel: () => Effect.die("unused"), + getUserInputActivity: () => Effect.die("unused"), + getSnapshot: () => Effect.die("unused"), + getShellSnapshot: () => + Effect.succeed({ + snapshotSequence: 0, + projects: importedWorkspaceRoots.map((workspaceRoot) => makeProjectShell(workspaceRoot)), + threads: [], + updatedAt: "2026-01-01T00:00:00.000Z", + }), + getArchivedShellSnapshot: () => Effect.die("unused"), + getSnapshotSequence: () => Effect.die("unused"), + getCounts: () => Effect.die("unused"), + getEventReplayStats: () => Effect.die("unused"), + getActiveProjectByWorkspaceRoot: () => Effect.die("unused"), + getProjectShellById: () => Effect.die("unused"), + getImportedAgentSessionSources: () => Effect.succeed([]), + getFirstActiveThreadIdByProjectId: () => Effect.die("unused"), + getThreadCheckpointContext: () => Effect.die("unused"), + getFullThreadDiffContext: () => Effect.die("unused"), + getThreadShellById: () => Effect.die("unused"), + getThreadRuntimeContext: () => Effect.die("unused"), + getThreadDetailById: () => Effect.die("unused"), + getThreadDetailSnapshot: () => Effect.die("unused"), + searchThreads: () => Effect.die("unused"), + getSessionStopContextById: () => Effect.die("unused"), + getThreadActivitiesPage: () => Effect.die("unused"), + getThreadLifecycleById: () => Effect.die("unused"), + }); + +/** + * Run a scan against the given homes. Homes are temp dirs created inside the + * test, so the layer is built per run rather than shared. + */ +interface ScannerTestInput { + readonly claudeHomePath: string; + readonly codexHomePath: string; + readonly importedWorkspaceRoots?: ReadonlyArray; + /** Base dir for the test ServerConfig; worktreesDir derives from it. */ + readonly configBaseDir?: string; + readonly providerInstances?: ContractServerSettings["providerInstances"]; +} + +const makeScannerTestLayer = (input: ScannerTestInput) => + AgentSessionScanner.layer.pipe( + Layer.provide( + Layer.mergeAll( + ServerSettings.layerTest({ + providers: { + claudeAgent: { homePath: input.claudeHomePath }, + codex: { homePath: input.codexHomePath }, + }, + ...(input.providerInstances === undefined + ? {} + : { providerInstances: input.providerInstances }), + }), + ServerConfig.layerTest( + input.claudeHomePath, + input.configBaseDir ?? { prefix: "t3code-scanner-config-" }, + ), + makeProjectionSnapshotQueryLayer(input.importedWorkspaceRoots ?? []), + ), + ), + ); + +const runScan = (input: ScannerTestInput) => + Effect.gen(function* () { + const scanner = yield* AgentSessionScanner.AgentSessionScanner; + return yield* scanner.scan; + }).pipe(Effect.provide(makeScannerTestLayer(input))); + +const runRecentThreadOutcomes = (input: ScannerTestInput & { readonly workspaceRoot: string }) => + Effect.gen(function* () { + const scanner = yield* AgentSessionScanner.AgentSessionScanner; + return yield* scanner.recentThreads(input.workspaceRoot).pipe( + Stream.runCollect, + Effect.map((outcomes) => Array.from(outcomes)), + ); + }).pipe(Effect.provide(makeScannerTestLayer(input))); + +const runRecentThreads = (input: ScannerTestInput & { readonly workspaceRoot: string }) => + runRecentThreadOutcomes(input).pipe( + Effect.map((outcomes) => + outcomes.flatMap((outcome) => (outcome._tag === "Importable" ? [outcome.thread] : [])), + ), + ); + +const makeTempDir = Effect.fn("AgentSessionScanner.test.makeTempDir")(function* (prefix: string) { + const fileSystem = yield* FileSystem.FileSystem; + return yield* fileSystem.makeTempDirectoryScoped({ prefix }); +}); + +const writeTranscript = Effect.fn("AgentSessionScanner.test.writeTranscript")(function* (input: { + readonly filePath: string; + readonly contents: string; + /** Epoch millis, so ordering assertions never depend on write timing. */ + readonly mtimeMs: number; +}) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fileSystem.makeDirectory(path.dirname(input.filePath), { recursive: true }); + yield* fileSystem.writeFileString(input.filePath, input.contents); + // Numeric utimes arguments are seconds, not milliseconds. + const seconds = input.mtimeMs / 1000; + yield* fileSystem.utimes(input.filePath, seconds, seconds); +}); + +/** Claude session line: the first record carries the real `cwd`. */ +const claudeSessionLine = (cwd: string) => + `${JSON.stringify({ type: "user", cwd, sessionId: "s1" })}\n${JSON.stringify({ type: "assistant" })}\n`; + +/** Codex rollout line: session metadata is nested under `payload`. */ +const codexRolloutLine = (cwd: string) => + `${JSON.stringify({ timestamp: "2026-01-01T00:00:00.000Z", type: "session_meta", payload: { id: "r1", cwd } })}\n`; + +const encodeTranscriptRecord = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); + +function makeRecordLimitTranscript(cwd: string, overflow: boolean): string { + const records = + [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "record-limit-session", cwd }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "First prompt" }, + }), + ].join("\n") + + "\n" + + "{}\n".repeat(99_998); + return overflow + ? records + + "\n" + + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Overflow prompt" }, + }) + + "\n" + : records; +} + +it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { + describe("scan", () => { + it.effect("reads Claude project cwds from transcripts, newest first", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const olderWorkspace = yield* makeTempDir("t3code-workspace-older-"); + const newerWorkspace = yield* makeTempDir("t3code-workspace-newer-"); + + // Slugs are intentionally lossy; the scanner must not decode them. + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-slug-older", "a.jsonl"), + contents: claudeSessionLine(olderWorkspace), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-slug-older", "b.jsonl"), + contents: claudeSessionLine(olderWorkspace), + mtimeMs: Date.parse("2026-01-02T00:00:00.000Z"), + }); + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-slug-newer", "c.jsonl"), + contents: claudeSessionLine(newerWorkspace), + mtimeMs: Date.parse("2026-03-01T00:00:00.000Z"), + }); + + const result = yield* runScan({ claudeHomePath, codexHomePath }); + + expect(result.candidates).toEqual([ + { + path: newerWorkspace, + title: path.basename(newerWorkspace), + sources: ["claudeAgent"], + threadCount: 1, + lastActiveAt: "2026-03-01T00:00:00.000Z", + alreadyImported: false, + }, + { + path: olderWorkspace, + title: path.basename(olderWorkspace), + sources: ["claudeAgent"], + threadCount: 2, + lastActiveAt: "2026-01-02T00:00:00.000Z", + alreadyImported: false, + }, + ]); + }), + ); + + it.effect("groups Codex rollouts by cwd across date directories", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const otherWorkspace = yield* makeTempDir("t3code-workspace-other-"); + + const rollout = (year: string, month: string, day: string, name: string) => + path.join(codexHomePath, "sessions", year, month, day, name); + + yield* writeTranscript({ + filePath: rollout("2026", "01", "05", "rollout-2026-01-05T10-00-00-aaa.jsonl"), + contents: codexRolloutLine(workspace), + mtimeMs: Date.parse("2026-01-05T10:00:00.000Z"), + }); + yield* writeTranscript({ + filePath: rollout("2026", "02", "09", "rollout-2026-02-09T10-00-00-bbb.jsonl"), + contents: codexRolloutLine(workspace), + mtimeMs: Date.parse("2026-02-09T10:00:00.000Z"), + }); + yield* writeTranscript({ + filePath: rollout("2026", "02", "09", "rollout-2026-02-09T11-00-00-ccc.jsonl"), + contents: codexRolloutLine(otherWorkspace), + mtimeMs: Date.parse("2026-02-09T11:00:00.000Z"), + }); + + const result = yield* runScan({ claudeHomePath, codexHomePath }); + + expect(result.candidates).toEqual([ + { + path: otherWorkspace, + title: path.basename(otherWorkspace), + sources: ["codex"], + threadCount: 1, + lastActiveAt: "2026-02-09T11:00:00.000Z", + alreadyImported: false, + }, + { + path: workspace, + title: path.basename(workspace), + sources: ["codex"], + threadCount: 2, + lastActiveAt: "2026-02-09T10:00:00.000Z", + alreadyImported: false, + }, + ]); + }), + ); + + it.effect.each(["claudeAgent", "codex"] as const)( + "does not open a non-file %s transcript", + (source) => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const transcriptPath = + source === "claudeAgent" + ? path.join(claudeHomePath, "projects", "-slug", "session.jsonl") + : path.join(codexHomePath, "sessions", "2026", "08", "24", "rollout-session.jsonl"); + yield* fileSystem.makeDirectory(transcriptPath, { recursive: true }); + + let transcriptOpenCount = 0; + const simulatedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + open: (filePath, options) => { + if (filePath === transcriptPath) transcriptOpenCount += 1; + return fileSystem.open(filePath, options); + }, + }); + + const result = yield* runScan({ claudeHomePath, codexHomePath }).pipe( + Effect.provideService(FileSystem.FileSystem, simulatedFileSystem), + ); + + expect(result.candidates).toEqual([]); + expect(transcriptOpenCount).toBe(0); + }), + ); + + it.effect.each(["claudeAgent", "codex"] as const)( + "stops %s directory reads at the discovery operation budget", + (source) => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const discoveryRoot = + source === "claudeAgent" + ? path.join(claudeHomePath, "projects") + : path.join(codexHomePath, "sessions"); + const emptyDirectories = Array.from( + { length: 20_001 }, + (_, index) => `empty-${index.toString().padStart(5, "0")}`, + ); + let directoryReadCount = 0; + const simulatedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + readDirectory: (directory, options) => { + if (directory === discoveryRoot) { + directoryReadCount += 1; + return Effect.succeed(emptyDirectories); + } + if (path.dirname(directory) === discoveryRoot) { + directoryReadCount += 1; + return Effect.succeed([]); + } + return fileSystem.readDirectory(directory, options); + }, + }); + + const result = yield* runScan({ claudeHomePath, codexHomePath }).pipe( + Effect.provideService(FileSystem.FileSystem, simulatedFileSystem), + ); + + expect(result.candidates).toEqual([]); + expect(directoryReadCount).toBe(20_000); + }), + ); + + it.effect("merges the same cwd seen by both agents and flags imported projects", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"), + contents: claudeSessionLine(workspace), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + yield* writeTranscript({ + filePath: path.join( + codexHomePath, + "sessions", + "2026", + "04", + "01", + "rollout-2026-04-01T09-00-00-aaa.jsonl", + ), + contents: codexRolloutLine(workspace), + mtimeMs: Date.parse("2026-04-01T09:00:00.000Z"), + }); + + const result = yield* runScan({ + claudeHomePath, + codexHomePath, + importedWorkspaceRoots: [workspace], + }); + + expect(result.candidates).toEqual([ + { + path: workspace, + title: path.basename(workspace), + projectId: ProjectId.make("project-1"), + sources: ["claudeAgent", "codex"], + threadCount: 2, + lastActiveAt: "2026-04-01T09:00:00.000Z", + alreadyImported: true, + }, + ]); + }), + ); + + it.effect("returns the imported project ID through a realpath alias", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const linkParent = yield* makeTempDir("t3code-scanner-links-"); + const workspaceAlias = path.join(linkParent, "workspace-alias"); + yield* fileSystem.symlink(workspace, workspaceAlias); + + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"), + contents: claudeSessionLine(workspaceAlias), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + + const result = yield* runScan({ + claudeHomePath, + codexHomePath, + importedWorkspaceRoots: [workspace], + }); + + expect(result.candidates[0]).toMatchObject({ + path: workspace, + projectId: ProjectId.make("project-1"), + alreadyImported: true, + }); + }), + ); + + it.effect("matches a persisted project alias to a transcript realpath", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const linkParent = yield* makeTempDir("t3code-scanner-links-"); + const workspaceAlias = path.join(linkParent, "workspace-alias"); + yield* fileSystem.symlink(workspace, workspaceAlias); + + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"), + contents: claudeSessionLine(workspace), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + + const result = yield* runScan({ + claudeHomePath, + codexHomePath, + importedWorkspaceRoots: [workspaceAlias], + }); + + expect(result.candidates[0]).toMatchObject({ + path: workspaceAlias, + projectId: ProjectId.make("project-1"), + alreadyImported: true, + }); + }), + ); + + it.effect("merges case aliases and preserves the persisted project path", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const workspaceAlias = path.join( + path.dirname(workspace), + path.basename(workspace).toUpperCase(), + ); + + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"), + contents: claudeSessionLine(workspaceAlias), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + yield* writeTranscript({ + filePath: path.join(codexHomePath, "sessions", "2026", "01", "02", "rollout-b.jsonl"), + contents: codexRolloutLine(workspace), + mtimeMs: Date.parse("2026-01-02T00:00:00.000Z"), + }); + + const simulatedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + stat: (filePath) => fileSystem.stat(filePath === workspaceAlias ? workspace : filePath), + }); + const result = yield* runScan({ + claudeHomePath, + codexHomePath, + importedWorkspaceRoots: [workspace], + }).pipe(Effect.provideService(FileSystem.FileSystem, simulatedFileSystem)); + + expect(result.candidates).toEqual([ + { + path: workspace, + title: path.basename(workspace), + projectId: ProjectId.make("project-1"), + sources: ["claudeAgent", "codex"], + threadCount: 2, + lastActiveAt: "2026-01-02T00:00:00.000Z", + alreadyImported: true, + }, + ]); + }), + ); + + it.effect("keeps case variants distinct when the filesystem identities differ", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const backingUpper = yield* makeTempDir("t3code-backing-upper-"); + const backingLower = yield* makeTempDir("t3code-backing-lower-"); + const aliasParent = yield* makeTempDir("t3code-case-aliases-"); + const upperWorkspace = path.join(aliasParent, "Repo"); + const lowerWorkspace = path.join(aliasParent, "repo"); + + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-upper", "a.jsonl"), + contents: claudeSessionLine(upperWorkspace), + mtimeMs: Date.parse("2026-01-02T00:00:00.000Z"), + }); + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-lower", "b.jsonl"), + contents: claudeSessionLine(lowerWorkspace), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + + const simulatedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + stat: (filePath) => + fileSystem.stat( + filePath === upperWorkspace + ? backingUpper + : filePath === lowerWorkspace + ? backingLower + : filePath, + ), + }); + const result = yield* runScan({ claudeHomePath, codexHomePath }).pipe( + Effect.provideService(FileSystem.FileSystem, simulatedFileSystem), + ); + + expect(result.candidates.map((candidate) => candidate.path)).toEqual([ + upperWorkspace, + lowerWorkspace, + ]); + }), + ); + + it.effect("uses explicit provider instance homes instead of overridden legacy homes", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-legacy-"); + const codexHomePath = yield* makeTempDir("t3code-codex-legacy-"); + const claudeInstanceHome = yield* makeTempDir("t3code-claude-instance-"); + const codexInstanceHome = yield* makeTempDir("t3code-codex-instance-"); + const legacyWorkspace = yield* makeTempDir("t3code-workspace-legacy-"); + const claudeWorkspace = yield* makeTempDir("t3code-workspace-claude-"); + const codexWorkspace = yield* makeTempDir("t3code-workspace-codex-"); + + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-legacy", "session.jsonl"), + contents: claudeSessionLine(legacyWorkspace), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + yield* writeTranscript({ + filePath: path.join(claudeInstanceHome, "projects", "-actual", "session.jsonl"), + contents: claudeSessionLine(claudeWorkspace), + mtimeMs: Date.parse("2026-02-01T00:00:00.000Z"), + }); + yield* writeTranscript({ + filePath: path.join( + codexInstanceHome, + "sessions", + "2026", + "03", + "01", + "rollout-instance.jsonl", + ), + contents: codexRolloutLine(codexWorkspace), + mtimeMs: Date.parse("2026-03-01T00:00:00.000Z"), + }); + + const result = yield* runScan({ + claudeHomePath, + codexHomePath, + providerInstances: { + [ProviderInstanceId.make("claudeAgent")]: { + driver: ProviderDriverKind.make("claudeAgent"), + config: { homePath: claudeInstanceHome }, + }, + [ProviderInstanceId.make("codex")]: { + driver: ProviderDriverKind.make("codex"), + config: { homePath: codexInstanceHome }, + }, + }, + }); + + expect(result.candidates.map((candidate) => candidate.path)).toEqual([ + codexWorkspace, + claudeWorkspace, + ]); + }), + ); + + it.effect("scans each distinct home across multiple instances once", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const otherCodexHome = yield* makeTempDir("t3code-codex-other-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const otherWorkspace = yield* makeTempDir("t3code-workspace-other-"); + + for (const [home, cwd] of [ + [codexHomePath, workspace], + [otherCodexHome, otherWorkspace], + ] as const) { + yield* writeTranscript({ + filePath: path.join(home, "sessions", "2026", "01", "01", "rollout-session.jsonl"), + contents: codexRolloutLine(cwd), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + } + + const result = yield* runScan({ + claudeHomePath, + codexHomePath, + providerInstances: { + [ProviderInstanceId.make("codex-personal")]: { + driver: ProviderDriverKind.make("codex"), + config: { homePath: codexHomePath }, + }, + [ProviderInstanceId.make("codex-work")]: { + driver: ProviderDriverKind.make("codex"), + config: { homePath: otherCodexHome }, + }, + }, + }); + + expect(result.candidates).toHaveLength(2); + expect(result.candidates.map((candidate) => candidate.threadCount)).toEqual([1, 1]); + expect(result.candidates.map((candidate) => candidate.path).sort()).toEqual( + [workspace, otherWorkspace].sort(), + ); + }), + ); + + it.effect("honors provider instance home directory environment variables", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-legacy-"); + const codexHomePath = yield* makeTempDir("t3code-codex-legacy-"); + const claudeEnvironmentHome = yield* makeTempDir("t3code-claude-env-"); + const codexEnvironmentHome = yield* makeTempDir("t3code-codex-env-"); + const claudeWorkspace = yield* makeTempDir("t3code-workspace-claude-"); + const codexWorkspace = yield* makeTempDir("t3code-workspace-codex-"); + + yield* writeTranscript({ + filePath: path.join(claudeEnvironmentHome, "projects", "-actual", "session.jsonl"), + contents: claudeSessionLine(claudeWorkspace), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + yield* writeTranscript({ + filePath: path.join( + codexEnvironmentHome, + "sessions", + "2026", + "01", + "01", + "rollout-session.jsonl", + ), + contents: codexRolloutLine(codexWorkspace), + mtimeMs: Date.parse("2026-01-02T00:00:00.000Z"), + }); + + const result = yield* runScan({ + claudeHomePath, + codexHomePath, + providerInstances: { + [ProviderInstanceId.make("claudeAgent")]: { + driver: ProviderDriverKind.make("claudeAgent"), + environment: [ + { name: "CLAUDE_CONFIG_DIR", value: claudeEnvironmentHome, sensitive: false }, + ], + config: {}, + }, + [ProviderInstanceId.make("codex")]: { + driver: ProviderDriverKind.make("codex"), + environment: [{ name: "CODEX_HOME", value: codexEnvironmentHome, sensitive: false }], + config: {}, + }, + }, + }); + + expect(result.candidates.map((candidate) => candidate.path)).toEqual([ + codexWorkspace, + claudeWorkspace, + ]); + }), + ); + + it.effect("ignores invalid provider instances while scanning the remaining providers", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-actual", "session.jsonl"), + contents: claudeSessionLine(workspace), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + + const result = yield* runScan({ + claudeHomePath, + codexHomePath, + providerInstances: { + [ProviderInstanceId.make("codex")]: { + driver: ProviderDriverKind.make("codex"), + config: { homePath: 123 }, + }, + }, + }); + + expect(result.candidates.map((candidate) => candidate.path)).toEqual([workspace]); + }), + ); + + it.effect("does not scan provider instances disabled by the envelope or config", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const envelopeDisabledHome = yield* makeTempDir("t3code-codex-disabled-envelope-"); + const configDisabledHome = yield* makeTempDir("t3code-codex-disabled-config-"); + const envelopeWorkspace = yield* makeTempDir("t3code-workspace-disabled-envelope-"); + const configWorkspace = yield* makeTempDir("t3code-workspace-disabled-config-"); + + for (const [home, workspace, session] of [ + [envelopeDisabledHome, envelopeWorkspace, "envelope-disabled"], + [configDisabledHome, configWorkspace, "config-disabled"], + ] as const) { + yield* writeTranscript({ + filePath: path.join(home, "sessions", "2026", "08", "24", `rollout-${session}.jsonl`), + contents: codexRolloutLine(workspace), + mtimeMs: Date.parse("2026-08-24T12:00:00.000Z"), + }); + } + + const result = yield* runScan({ + claudeHomePath, + codexHomePath, + providerInstances: { + [ProviderInstanceId.make("codex-envelope-disabled")]: { + driver: ProviderDriverKind.make("codex"), + enabled: false, + config: { homePath: envelopeDisabledHome }, + }, + [ProviderInstanceId.make("codex-config-disabled")]: { + driver: ProviderDriverKind.make("codex"), + config: { enabled: false, homePath: configDisabledHome }, + }, + }, + }); + + expect(result.candidates).toEqual([]); + }), + ); + + it.effect("ignores relative working directories from malformed transcripts", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-relative", "session.jsonl"), + contents: claudeSessionLine(path.relative(path.resolve(), workspace)), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + + const result = yield* runScan({ claudeHomePath, codexHomePath }); + + expect(result.candidates).toEqual([]); + }), + ); + + it.effect("drops candidates whose directory no longer exists", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"), + contents: claudeSessionLine(path.join(claudeHomePath, "does-not-exist")), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + + const result = yield* runScan({ claudeHomePath, codexHomePath }); + + expect(result.candidates).toEqual([]); + }), + ); + + it.effect("excludes the home directory, temporary root, and T3 data directory", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const configBaseDir = yield* makeTempDir("t3code-scanner-base-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + + for (const [index, cwd] of [ + NodeOS.homedir(), + NodeOS.tmpdir(), + configBaseDir, + workspace, + ].entries()) { + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", `-slug-${index}`, "session.jsonl"), + contents: claudeSessionLine(cwd), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z") + index, + }); + } + + const result = yield* runScan({ claudeHomePath, codexHomePath, configBaseDir }); + + expect(result.candidates.map((candidate) => candidate.path)).toEqual([workspace]); + }), + ); + + it.effect("excludes T3-managed worktree sandboxes", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const fileSystem = yield* FileSystem.FileSystem; + + const worktreeCwd = path.join(claudeHomePath, ".t3", "worktrees", "t3code", "wt-1"); + yield* fileSystem.makeDirectory(worktreeCwd, { recursive: true }); + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"), + contents: claudeSessionLine(worktreeCwd), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + + const result = yield* runScan({ claudeHomePath, codexHomePath }); + + expect(result.candidates).toEqual([]); + }), + ); + + it.effect("excludes sandboxes under the configured worktrees dir without .t3 in the path", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const configBaseDir = yield* makeTempDir("t3code-scanner-base-"); + const fileSystem = yield* FileSystem.FileSystem; + + // worktreesDir derives as `/worktrees`, and the temp base + // dir contains no `.t3` segment — only the config-based prefix match + // can exclude this one. + const worktreeCwd = path.join(configBaseDir, "worktrees", "t3code", "wt-2"); + yield* fileSystem.makeDirectory(worktreeCwd, { recursive: true }); + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"), + contents: claudeSessionLine(worktreeCwd), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + + const result = yield* runScan({ claudeHomePath, codexHomePath, configBaseDir }); + + expect(result.candidates).toEqual([]); + }), + ); + + it.effect("excludes sandboxes reached through a symlink into the worktrees dir", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const configBaseDir = yield* makeTempDir("t3code-scanner-base-"); + const linkParent = yield* makeTempDir("t3code-scanner-links-"); + const fileSystem = yield* FileSystem.FileSystem; + + // The recorded cwd is a symlink whose own spelling looks harmless; + // only its realpath reveals the managed sandbox. + const worktreeCwd = path.join(configBaseDir, "worktrees", "t3code", "wt-3"); + yield* fileSystem.makeDirectory(worktreeCwd, { recursive: true }); + const symlinkCwd = path.join(linkParent, "innocent-project"); + yield* fileSystem.symlink(worktreeCwd, symlinkCwd); + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"), + contents: claudeSessionLine(symlinkCwd), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + + const result = yield* runScan({ claudeHomePath, codexHomePath, configBaseDir }); + + expect(result.candidates).toEqual([]); + }), + ); + + it.effect("finds the cwd on a later line when the first records carry none", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + + // Claude transcripts often open with records that have no cwd. + const contents = `{"type":"file-history-snapshot","messageId":"m1"}\n{"type":"queue-operation","operation":"enqueue"}\n${claudeSessionLine(workspace)}`; + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"), + contents, + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + + const result = yield* runScan({ claudeHomePath, codexHomePath }); + + expect(result.candidates.map((candidate) => candidate.path)).toEqual([workspace]); + }), + ); + + it.effect("reads a complete transcript record at the exact chunk boundary", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const record = claudeSessionLine(workspace).split("\n")[0]!; + const prefix = '{"padding":"'; + const suffix = `",${record.slice(1)}`; + const contents = `${prefix}${"x".repeat(32 * 1024 - prefix.length - suffix.length)}${suffix}`; + + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-exact", "session.jsonl"), + contents, + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + + const result = yield* runScan({ claudeHomePath, codexHomePath }); + + expect(contents).toHaveLength(32 * 1024); + expect(result.candidates.map((candidate) => candidate.path)).toEqual([workspace]); + }), + ); + + it.effect("finds session metadata after a first record larger than one chunk", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const history = `{"type":"file-history-snapshot","data":"${"x".repeat(32 * 1024)}"}\n`; + + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-large", "session.jsonl"), + contents: `${history}${claudeSessionLine(workspace)}`, + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + + const result = yield* runScan({ claudeHomePath, codexHomePath }); + + expect(result.candidates.map((candidate) => candidate.path)).toEqual([workspace]); + }), + ); + + it.effect.each([64, 65])("shares metadata bytes across homes for %s one-MiB files", (count) => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const claudeHomePath = yield* makeTempDir("t3code-metadata-home-"); + const secondHome = yield* makeTempDir("t3code-metadata-second-"); + const codexHomePath = yield* makeTempDir("t3code-metadata-codex-"); + const firstWorkspace = yield* makeTempDir("t3code-metadata-first-project-"); + const secondWorkspace = yield* makeTempDir("t3code-metadata-second-project-"); + const directories = [ + path.join(claudeHomePath, "projects", "p"), + path.join(secondHome, "projects", "p"), + ]; + const templates = directories.map((directory) => path.join(directory, "template.jsonl")); + for (const [index, workspace] of [firstWorkspace, secondWorkspace].entries()) { + const record = encodeTranscriptRecord({ cwd: workspace }); + yield* writeTranscript({ + filePath: templates[index]!, + contents: + " ".repeat(1024 * 1024 - new TextEncoder().encode(record).byteLength) + record, + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z") - index * 1_000, + }); + } + const resolveFile = (filePath: string) => { + const index = directories.indexOf(path.dirname(filePath)); + return index === -1 ? filePath : templates[index]!; + }; + let reservedBytes = 0; + let opens = 0; + const requests: number[] = []; + const observedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + readDirectory: (directory, options) => { + const index = directories.indexOf(directory); + return index === -1 + ? fileSystem.readDirectory(directory, options) + : Effect.succeed( + Array.from( + { length: index === 0 ? 32 : count - 32 }, + (_, item) => `session-${item}.jsonl`, + ), + ); + }, + stat: (filePath) => fileSystem.stat(resolveFile(filePath)), + open: (filePath, options) => { + if (!directories.includes(path.dirname(filePath))) + return fileSystem.open(filePath, options); + opens += 1; + return fileSystem.open(resolveFile(filePath), options).pipe( + Effect.map((file) => ({ + ...file, + stat: file.stat, + readAlloc: (size: FileSystem.SizeInput) => { + reservedBytes += Number(size); + requests.push(Number(size)); + return file.readAlloc(size); + }, + })), + ); + }, + }); + const result = yield* runScan({ + claudeHomePath, + codexHomePath, + providerInstances: { + [ProviderInstanceId.make("claude-work")]: { + driver: ProviderDriverKind.make("claudeAgent"), + config: { homePath: secondHome }, + }, + }, + }).pipe(Effect.provideService(FileSystem.FileSystem, observedFileSystem)); + expect(result.candidates.map((candidate) => candidate.path)).toEqual([ + firstWorkspace, + secondWorkspace, + ]); + expect(result.candidates.map((candidate) => candidate.threadCount)).toEqual([32, 32]); + expect(result.truncated).toBe(count === 65 ? true : undefined); + expect(opens).toBe(64); + expect(reservedBytes).toBe(64 * 1024 * 1024); + expect(requests[0]).toBe(8 * 1024); + expect(Math.max(...requests)).toBe(8 * 1024); + }), + ); + + it.effect.each([50, 51])("bounds metadata open/read calls for %s short-read files", (count) => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const claudeHomePath = yield* makeTempDir("t3code-short-metadata-home-"); + const codexHomePath = yield* makeTempDir("t3code-short-metadata-codex-"); + const workspace = yield* makeTempDir("t3code-short-metadata-project-"); + const directory = path.join(claudeHomePath, "projects", "p"); + const template = path.join(directory, "template.jsonl"); + const record = encodeTranscriptRecord({ cwd: workspace }); + const contents = " ".repeat(399 - new TextEncoder().encode(record).byteLength) + record; + const bytes = new TextEncoder().encode(contents); + yield* writeTranscript({ + filePath: template, + contents, + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + let operations = 0; + const observedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + readDirectory: (target, options) => + target === directory + ? Effect.succeed( + Array.from({ length: count }, (_, index) => `session-${index}.jsonl`), + ) + : fileSystem.readDirectory(target, options), + stat: (filePath) => + fileSystem.stat(path.dirname(filePath) === directory ? template : filePath), + open: (filePath, options) => { + if (path.dirname(filePath) !== directory) return fileSystem.open(filePath, options); + operations += 1; + let offset = 0; + return fileSystem.open(template, options).pipe( + Effect.map((file) => ({ + ...file, + stat: file.stat, + readAlloc: () => + Effect.sync(() => { + operations += 1; + if (offset === bytes.length) return Option.none(); + return Option.some(bytes.subarray(offset, ++offset)); + }), + })), + ); + }, + }); + const result = yield* runScan({ claudeHomePath, codexHomePath }).pipe( + Effect.provideService(FileSystem.FileSystem, observedFileSystem), + ); + expect(operations).toBe(20_000); + expect(result.candidates[0]?.threadCount).toBe(50); + expect(result.truncated).toBe(count === 51 ? true : undefined); + }), + ); + + it.effect("bounds malformed metadata records without excluding another account", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const claudeHomePath = yield* makeTempDir("t3code-record-metadata-home-"); + const secondHome = yield* makeTempDir("t3code-record-metadata-second-"); + const codexHomePath = yield* makeTempDir("t3code-record-metadata-codex-"); + const workspace = yield* makeTempDir("t3code-record-metadata-project-"); + const directory = path.join(claudeHomePath, "projects", "p"); + const template = path.join(directory, "template.jsonl"); + yield* writeTranscript({ + filePath: template, + contents: "x\n".repeat(1_001), + mtimeMs: Date.parse("2026-01-02T00:00:00.000Z"), + }); + yield* writeTranscript({ + filePath: path.join(secondHome, "projects", "p", "session.jsonl"), + contents: encodeTranscriptRecord({ cwd: workspace }), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + let malformedOpens = 0; + const observedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + readDirectory: (target, options) => + target === directory + ? Effect.succeed(Array.from({ length: 102 }, (_, index) => `session-${index}.jsonl`)) + : fileSystem.readDirectory(target, options), + stat: (filePath) => + fileSystem.stat(path.dirname(filePath) === directory ? template : filePath), + open: (filePath, options) => { + if (path.dirname(filePath) !== directory) return fileSystem.open(filePath, options); + malformedOpens += 1; + return fileSystem.open(template, options); + }, + }); + const result = yield* runScan({ + claudeHomePath, + codexHomePath, + providerInstances: { + [ProviderInstanceId.make("claude-work")]: { + driver: ProviderDriverKind.make("claudeAgent"), + config: { homePath: secondHome }, + }, + }, + }).pipe(Effect.provideService(FileSystem.FileSystem, observedFileSystem)); + expect(result.candidates.map((candidate) => candidate.path)).toEqual([workspace]); + expect(malformedOpens).toBe(100); + expect(result.truncated).toBe(true); + }), + ); + + it.effect.each([19_999, 20_000])( + "reports unfinished directory work for %s project directories", + (count) => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const claudeHomePath = yield* makeTempDir("t3code-directory-budget-home-"); + const codexHomePath = yield* makeTempDir("t3code-directory-budget-codex-"); + const projectsDir = path.join(claudeHomePath, "projects"); + let reads = 0; + const observedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + readDirectory: (directory, options) => { + if (directory === projectsDir) { + reads += 1; + return Effect.succeed( + Array.from({ length: count }, (_, index) => `project-${index}`), + ); + } + if (path.dirname(directory) === projectsDir) { + reads += 1; + return Effect.succeed([]); + } + return fileSystem.readDirectory(directory, options); + }, + }); + const result = yield* runScan({ claudeHomePath, codexHomePath }).pipe( + Effect.provideService(FileSystem.FileSystem, observedFileSystem), + ); + expect(reads).toBe(20_000); + expect(result.candidates).toEqual([]); + expect(result.truncated).toBe(count === 20_000 ? true : undefined); + }), + ); + + it.effect("skips malformed transcripts without failing the scan", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-broken", "a.jsonl"), + contents: "not json at all\n", + mtimeMs: Date.parse("2026-05-01T00:00:00.000Z"), + }); + // Valid JSON, but no cwd anywhere in the record. + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-no-cwd", "a.jsonl"), + contents: `{"type":"summary"}\n`, + mtimeMs: Date.parse("2026-05-02T00:00:00.000Z"), + }); + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-good", "a.jsonl"), + contents: claudeSessionLine(workspace), + mtimeMs: Date.parse("2026-05-03T00:00:00.000Z"), + }); + + const result = yield* runScan({ claudeHomePath, codexHomePath }); + + expect(result.candidates).toEqual([ + { + path: workspace, + title: path.basename(workspace), + sources: ["claudeAgent"], + threadCount: 1, + lastActiveAt: "2026-05-03T00:00:00.000Z", + alreadyImported: false, + }, + ]); + }), + ); + + it.effect("returns an empty result when neither home directory exists", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const root = yield* makeTempDir("t3code-missing-homes-"); + + const result = yield* runScan({ + claudeHomePath: path.join(root, "no-claude"), + codexHomePath: path.join(root, "no-codex"), + }); + + expect(result.candidates).toEqual([]); + expect(result.scannedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); + }), + ); + }); + + describe("recentThreads", () => { + it.effect.each([false, true])( + "counts terminal newlines correctly with record overflow=%s", + (overflow) => + Effect.gen(function* () { + const path = yield* Path.Path; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-record-limit-claude-"); + const codexHomePath = yield* makeTempDir("t3code-record-limit-codex-"); + const workspace = yield* makeTempDir("t3code-record-limit-project-"); + const directory = path.join(codexHomePath, "sessions", "2026", "08", "24"); + yield* writeTranscript({ + filePath: path.join(directory, "rollout-records.jsonl"), + contents: makeRecordLimitTranscript(workspace, overflow), + mtimeMs: nowMs, + }); + yield* writeTranscript({ + filePath: path.join(directory, "rollout-older.jsonl"), + contents: [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "older-session", cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Older prompt" }, + }), + ].join("\n"), + mtimeMs: nowMs - 1_000, + }); + const outcomes = yield* runRecentThreadOutcomes({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + }); + expect(outcomes.map((outcome) => outcome._tag)).toEqual( + overflow ? ["Skipped", "Importable"] : ["Importable", "Skipped"], + ); + expect( + outcomes.flatMap((outcome) => + outcome._tag === "Importable" + ? outcome.thread.messages.map((message) => message.text) + : [], + ), + ).toEqual([overflow ? "Older prompt" : "First prompt"]); + }), + ); + + it.effect("imports recent Claude and Codex sessions for the selected project only", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const otherWorkspace = yield* makeTempDir("t3code-workspace-other-"); + + const claudeTranscript = (cwd: string, sessionId: string) => + `${JSON.stringify({ + type: "user", + cwd, + sessionId, + timestamp: "2026-08-23T12:00:00.000Z", + message: { role: "user", content: "Fix the project" }, + })}\n${JSON.stringify({ + type: "assistant", + sessionId, + timestamp: "2026-08-23T12:01:00.000Z", + message: { role: "assistant", content: [{ type: "text", text: "Done" }] }, + })}\n`; + + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-selected", "claude-recent.jsonl"), + contents: claudeTranscript(workspace, "claude-recent"), + mtimeMs: nowMs - 24 * 60 * 60 * 1000, + }); + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-selected", "claude-old.jsonl"), + contents: claudeTranscript(workspace, "claude-old"), + mtimeMs: nowMs - 31 * 24 * 60 * 60 * 1000, + }); + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-other", "claude-other.jsonl"), + contents: claudeTranscript(otherWorkspace, "claude-other"), + mtimeMs: nowMs - 24 * 60 * 60 * 1000, + }); + yield* writeTranscript({ + filePath: path.join( + codexHomePath, + "sessions", + "2026", + "08", + "24", + "rollout-codex-recent.jsonl", + ), + contents: [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "codex-recent", cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + timestamp: "2026-08-24T10:00:00.000Z", + payload: { type: "user_message", message: "Review this code" }, + }), + encodeTranscriptRecord({ + type: "response_item", + timestamp: "2026-08-24T10:01:00.000Z", + payload: { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "Looks good" }], + }, + }), + ].join("\n"), + mtimeMs: nowMs - 60 * 60 * 1000, + }); + + const threads = yield* runRecentThreads({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + }); + + expect(threads.map((thread) => thread.providerSessionId)).toEqual([ + "codex-recent", + "claude-recent", + ]); + expect(threads.map((thread) => thread.messages.map((message) => message.text))).toEqual([ + ["Review this code", "Looks good"], + ["Fix the project", "Done"], + ]); + }), + ); + + it.effect("imports history recorded with a case alias", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const workspaceAlias = path.join( + path.dirname(workspace), + path.basename(workspace).toUpperCase(), + ); + + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-alias", "case-session.jsonl"), + contents: [ + encodeTranscriptRecord({ + type: "user", + cwd: workspaceAlias, + sessionId: "case-session", + timestamp: "2026-08-24T10:00:00.000Z", + message: { role: "user", content: "Import case alias history" }, + }), + encodeTranscriptRecord({ + type: "assistant", + sessionId: "case-session", + timestamp: "2026-08-24T10:01:00.000Z", + message: { role: "assistant", content: "Imported" }, + }), + ].join("\n"), + mtimeMs: nowMs, + }); + + const simulatedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + stat: (filePath) => fileSystem.stat(filePath === workspaceAlias ? workspace : filePath), + }); + const threads = yield* runRecentThreads({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + }).pipe(Effect.provideService(FileSystem.FileSystem, simulatedFileSystem)); + + expect(threads.map((thread) => thread.providerSessionId)).toEqual(["case-session"]); + }), + ); + + it.effect("keeps the provider instance that owns a custom session home", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const customHome = yield* makeTempDir("t3code-codex-custom-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + + yield* writeTranscript({ + filePath: path.join(customHome, "sessions", "2026", "08", "24", "rollout-custom.jsonl"), + contents: [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "custom-session", cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Use my work account" }, + }), + ].join("\n"), + mtimeMs: nowMs, + }); + + const threads = yield* runRecentThreads({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + providerInstances: { + [ProviderInstanceId.make("codex-work")]: { + driver: ProviderDriverKind.make("codex"), + config: { homePath: customHome }, + }, + }, + }); + + expect(threads[0]?.providerInstanceId).toBe("codex-work"); + }), + ); + + it.effect("suppresses duplicate session copies without reporting a skipped import", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const contents = [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "copied-session", cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Import this session once" }, + }), + ].join("\n"); + + for (const [name, mtimeMs] of [ + ["rollout-copy-a.jsonl", nowMs], + ["rollout-copy-b.jsonl", nowMs - 1], + ] as const) { + yield* writeTranscript({ + filePath: path.join(codexHomePath, "sessions", "2026", "08", "24", name), + contents, + mtimeMs, + }); + } + + const outcomes = yield* runRecentThreadOutcomes({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + }); + + expect(outcomes.map((outcome) => outcome._tag)).toEqual(["Importable", "Duplicate"]); + expect(outcomes[0]).toMatchObject({ + _tag: "Importable", + thread: { providerSessionId: "copied-session" }, + }); + }), + ); + + it.effect("shares a 64 MiB full-read budget across providers without hiding projects", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-budget-claude-"); + const codexHomePath = yield* makeTempDir("t3code-budget-codex-"); + const workspace = yield* makeTempDir("t3code-budget-workspace-"); + const transcriptPaths = new Set(); + for (const [index, source] of [ + "codex", + "claudeAgent", + "codex", + "claudeAgent", + "codex", + ].entries()) { + const sessionId = `budget-session-${index}`; + const filePath = + source === "codex" + ? path.join( + codexHomePath, + "sessions", + "2026", + "08", + "24", + `rollout-${sessionId}.jsonl`, + ) + : path.join(claudeHomePath, "projects", "selected", `${sessionId}.jsonl`); + const contents = + source === "codex" + ? [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: sessionId, cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Imported prompt" }, + }), + ].join("\n") + : encodeTranscriptRecord({ + type: "user", + cwd: workspace, + sessionId, + message: { content: "Imported prompt" }, + }); + transcriptPaths.add(filePath); + yield* writeTranscript({ + filePath, + contents: `${contents}\n`.padEnd(16 * 1024 * 1024, " "), + mtimeMs: nowMs - index * 1_000, + }); + } + + const opens = new Map(); + let fullReadBytes = 0; + const trackedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + open: (filePath, options) => { + const count = (opens.get(filePath) ?? 0) + 1; + opens.set(filePath, count); + return fileSystem.open(filePath, options).pipe( + Effect.map((file) => + !transcriptPaths.has(filePath) || count === 1 + ? file + : { + ...file, + stat: file.stat, + readAlloc: (size: FileSystem.SizeInput) => + file.readAlloc(size).pipe( + Effect.tap((chunk) => + Effect.sync(() => { + if (chunk._tag === "Some") fullReadBytes += chunk.value.byteLength; + }), + ), + ), + }, + ), + ); + }, + }); + const outcomes = yield* Effect.gen(function* () { + const scanner = yield* AgentSessionScanner.AgentSessionScanner; + const scan = yield* scanner.scan; + expect(scan.candidates[0]?.threadCount).toBe(5); + return yield* scanner.recentThreads(workspace).pipe(Stream.runCollect); + }).pipe( + Effect.provide(makeScannerTestLayer({ claudeHomePath, codexHomePath })), + Effect.provideService(FileSystem.FileSystem, trackedFileSystem), + ); + + expect(outcomes.map((outcome) => outcome._tag)).toEqual([ + "Importable", + "Importable", + "Importable", + "Importable", + "Skipped", + ]); + expect(fullReadBytes).toBe(64 * 1024 * 1024); + }), + ); + + it.effect("skips excessive records without blocking an older valid transcript", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-record-budget-claude-"); + const codexHomePath = yield* makeTempDir("t3code-record-budget-codex-"); + const workspace = yield* makeTempDir("t3code-record-budget-workspace-"); + for (const [sessionId, padding, mtimeMs] of [ + ["excessive", "\n".repeat(100_001), nowMs], + ["older", "", nowMs - 1_000], + ] as const) { + yield* writeTranscript({ + filePath: path.join( + codexHomePath, + "sessions", + "2026", + "08", + "24", + `rollout-${sessionId}.jsonl`, + ), + contents: + [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: sessionId, cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Imported prompt" }, + }), + ].join("\n") + padding, + mtimeMs, + }); + } + const outcomes = yield* runRecentThreadOutcomes({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + }); + expect(outcomes.map((outcome) => outcome._tag)).toEqual(["Skipped", "Importable"]); + expect(outcomes[1]).toMatchObject({ thread: { providerSessionId: "older" } }); + }), + ); + + for (const source of ["claudeAgent", "codex"] as const) { + for (const replacement of [ + "same root", + "other root", + "symlink alias", + "other then same", + ] as const) { + it.effect.skipIf(replacement === "symlink alias" && !symlinksSupported)( + `rechecks ${source} snapshot cwd after replacement with ${replacement}`, + () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const fixture = yield* makeTempDir("t3code-replaced-cwd-"); + const workspace = path.join(fixture, "original"); + const otherWorkspace = path.join(fixture, "other"); + const alias = path.join(fixture, "alias"); + const claudeHomePath = path.join(fixture, "claude"); + const codexHomePath = path.join(fixture, "codex"); + yield* fileSystem.makeDirectory(workspace); + yield* fileSystem.makeDirectory(otherWorkspace); + if (replacement === "symlink alias") yield* fileSystem.symlink(workspace, alias); + const filePath = + source === "codex" + ? path.join( + codexHomePath, + "sessions", + "2026", + "08", + "24", + "rollout-replaced.jsonl", + ) + : path.join(claudeHomePath, "projects", "p", "replaced.jsonl"); + const makeContents = (cwd: string, text: string, laterCwd?: string) => + [ + ...(source === "codex" + ? [ + { type: "session_meta", payload: { id: "replacement-session", cwd } }, + { type: "event_msg", payload: { type: "user_message", message: text } }, + ] + : [ + { + type: "user", + cwd, + sessionId: "replacement-session", + message: { content: text }, + }, + ]), + ...(laterCwd === undefined ? [] : [{ cwd: laterCwd }]), + ] + .map((record) => encodeTranscriptRecord(record)) + .join("\n"); + yield* writeTranscript({ + filePath, + contents: makeContents(workspace, "Original prompt"), + mtimeMs: nowMs, + }); + + yield* Effect.gen(function* () { + const scanner = yield* AgentSessionScanner.AgentSessionScanner; + const scan = yield* scanner.scan; + expect(scan.candidates.map((candidate) => candidate.path)).toEqual([workspace]); + const replacementCwd = + replacement === "symlink alias" + ? alias + : replacement === "same root" + ? workspace + : otherWorkspace; + yield* fileSystem.remove(filePath); + yield* writeTranscript({ + filePath, + contents: makeContents( + replacementCwd, + "Replacement prompt", + replacement === "other then same" ? workspace : undefined, + ), + mtimeMs: nowMs, + }); + const outcomes = yield* scanner.recentThreads(workspace).pipe(Stream.runCollect); + if (replacement === "same root" || replacement === "symlink alias") { + expect(outcomes).toHaveLength(1); + expect(outcomes[0]).toMatchObject({ + _tag: "Importable", + thread: { messages: [{ text: "Replacement prompt" }] }, + }); + } else { + expect(outcomes).toEqual([{ _tag: "Skipped" }]); + } + }).pipe(Effect.provide(makeScannerTestLayer({ claudeHomePath, codexHomePath }))); + }), + ); + } + } + + it.effect("checks file identity and provider before skipping completed history", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-completed-claude-"); + const codexHomePath = yield* makeTempDir("t3code-completed-codex-"); + const workspace = yield* makeTempDir("t3code-completed-workspace-"); + const filePath = path.join( + codexHomePath, + "sessions", + "2026", + "08", + "24", + "rollout-replaced.jsonl", + ); + const contents = (sessionId: string) => + [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: sessionId, cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Imported prompt" }, + }), + ].join("\n"); + yield* writeTranscript({ + filePath, + contents: contents("original-session"), + mtimeMs: nowMs, + }); + + yield* Effect.gen(function* () { + const scanner = yield* AgentSessionScanner.AgentSessionScanner; + const initial = yield* scanner.recentThreads(workspace).pipe(Stream.runCollect); + const imported = initial[0]; + expect(imported?._tag).toBe("Importable"); + if (imported?._tag !== "Importable") return; + const completed = yield* scanner + .recentThreads(workspace, [imported.source]) + .pipe(Stream.runCollect); + expect(completed[0]?._tag).toBe("AlreadyImported"); + const wrongProvider = yield* scanner + .recentThreads(workspace, [{ ...imported.source, provider: "claudeAgent" }]) + .pipe(Stream.runCollect); + expect(wrongProvider[0]?._tag).toBe("Importable"); + + // Keep the old inode allocated while replacing the path with an equal-size file. + yield* fileSystem.open(filePath); + yield* fileSystem.remove(filePath); + yield* writeTranscript({ + filePath, + contents: contents("replaced-session"), + mtimeMs: nowMs, + }); + const replaced = yield* scanner + .recentThreads(workspace, [imported.source]) + .pipe(Stream.runCollect); + expect(replaced[0]).toMatchObject({ + _tag: "Importable", + thread: { providerSessionId: "replaced-session" }, + source: { size: imported.source.size, mtimeMs: imported.source.mtimeMs }, + }); + }).pipe(Effect.provide(makeScannerTestLayer({ claudeHomePath, codexHomePath }))); + }), + ); + + it.effect("reports an eligible transcript over 16 MiB as skipped", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const transcript = [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "large-session", cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Import this large session" }, + }), + ] + .join("\n") + .padEnd(16 * 1024 * 1024 + 1, " "); + yield* writeTranscript({ + filePath: path.join(codexHomePath, "sessions", "2026", "08", "24", "rollout-large.jsonl"), + contents: transcript, + mtimeMs: nowMs, + }); + + const outcomes = yield* runRecentThreadOutcomes({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + }); + + expect(outcomes).toEqual([{ _tag: "Skipped" }]); + }), + ); + + it.effect("reports stat, read, and parse failures as skipped", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const missingPath = path.join(codexHomePath, "missing.jsonl"); + const transcriptPaths = { + stat: path.join(codexHomePath, "sessions", "2026", "08", "24", "rollout-stat.jsonl"), + read: path.join(codexHomePath, "sessions", "2026", "08", "24", "rollout-read.jsonl"), + parse: path.join(codexHomePath, "sessions", "2026", "08", "24", "rollout-parse.jsonl"), + }; + const transcriptContents = (sessionId: string) => + [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: sessionId, cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Import this session" }, + }), + ].join("\n"); + + yield* writeTranscript({ + filePath: transcriptPaths.stat, + contents: transcriptContents("stat-session"), + mtimeMs: nowMs, + }); + yield* writeTranscript({ + filePath: transcriptPaths.read, + contents: transcriptContents("read-session"), + mtimeMs: nowMs, + }); + yield* writeTranscript({ + filePath: transcriptPaths.parse, + contents: encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "parse-session", cwd: workspace }, + }), + mtimeMs: nowMs, + }); + + let statCount = 0; + let readOpenCount = 0; + const simulatedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + stat: (filePath) => { + if (filePath !== transcriptPaths.stat) return fileSystem.stat(filePath); + statCount += 1; + return fileSystem.stat(statCount === 1 ? filePath : missingPath); + }, + open: (filePath, options) => { + if (filePath !== transcriptPaths.read) return fileSystem.open(filePath, options); + readOpenCount += 1; + return fileSystem.open(readOpenCount === 1 ? filePath : missingPath, options); + }, + }); + + const outcomes = yield* runRecentThreadOutcomes({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + }).pipe(Effect.provideService(FileSystem.FileSystem, simulatedFileSystem)); + + expect(outcomes).toEqual([{ _tag: "Skipped" }, { _tag: "Skipped" }, { _tag: "Skipped" }]); + }), + ); + + it.effect("does not reopen a transcript that becomes a non-file after discovery", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const nonFilePath = yield* makeTempDir("t3code-non-file-"); + const transcriptPath = path.join( + codexHomePath, + "sessions", + "2026", + "08", + "24", + "rollout-changed.jsonl", + ); + yield* writeTranscript({ + filePath: transcriptPath, + contents: [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "changed-session", cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Do not import this session" }, + }), + ].join("\n"), + mtimeMs: nowMs, + }); + + let transcriptStatCount = 0; + let transcriptOpenCount = 0; + const simulatedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + stat: (filePath) => { + if (filePath !== transcriptPath) return fileSystem.stat(filePath); + transcriptStatCount += 1; + return fileSystem.stat(transcriptStatCount === 1 ? transcriptPath : nonFilePath); + }, + open: (filePath, options) => { + if (filePath === transcriptPath) transcriptOpenCount += 1; + return fileSystem.open(filePath, options); + }, + }); + + const outcomes = yield* runRecentThreadOutcomes({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + }).pipe(Effect.provideService(FileSystem.FileSystem, simulatedFileSystem)); + + expect(transcriptStatCount).toBe(2); + expect(transcriptOpenCount).toBe(1); + expect(outcomes).toEqual([{ _tag: "Skipped" }]); + }), + ); + + it.effect("does not import a transcript dated after the current time", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + yield* writeTranscript({ + filePath: path.join( + codexHomePath, + "sessions", + "2026", + "08", + "24", + "rollout-future.jsonl", + ), + contents: [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "future-session", cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Future work" }, + }), + ].join("\n"), + mtimeMs: nowMs + 1, + }); + + const outcomes = yield* runRecentThreadOutcomes({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + }); + + expect(outcomes).toEqual([]); + }), + ); + + it.effect("skips growth during reading without exceeding the reserved bytes", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const transcriptPath = path.join( + codexHomePath, + "sessions", + "2026", + "08", + "24", + "rollout-growing.jsonl", + ); + const contents = [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "growing-session", cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Do not import a changing file" }, + }), + ].join("\n"); + yield* writeTranscript({ filePath: transcriptPath, contents, mtimeMs: nowMs }); + let transcriptOpenCount = 0; + let fullReadBytes = 0; + let grew = false; + const simulatedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + open: (filePath, options) => { + if (filePath !== transcriptPath) return fileSystem.open(filePath, options); + transcriptOpenCount += 1; + if (transcriptOpenCount === 1) return fileSystem.open(filePath, options); + return fileSystem.open(filePath, options).pipe( + Effect.map((file) => ({ + ...file, + stat: file.stat, + readAlloc: (size: FileSystem.SizeInput) => + file.readAlloc(size).pipe( + Effect.tap((chunk) => + Effect.gen(function* () { + if (chunk._tag === "None") return; + fullReadBytes += chunk.value.byteLength; + if (!grew) { + grew = true; + yield* fileSystem.writeFileString(filePath, `${contents}\nchanged`); + } + }), + ), + ), + })), + ); + }, + }); + + const outcomes = yield* runRecentThreadOutcomes({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + }).pipe(Effect.provideService(FileSystem.FileSystem, simulatedFileSystem)); + + expect(transcriptOpenCount).toBe(2); + expect(fullReadBytes).toBe(new TextEncoder().encode(contents).byteLength); + expect(outcomes).toEqual([{ _tag: "Skipped" }]); + }), + ); + + it.effect("skips a transcript that shrinks after its size check", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const transcriptPath = path.join( + codexHomePath, + "sessions", + "2026", + "08", + "24", + "rollout-shrinking.jsonl", + ); + const shrunkPath = path.join(codexHomePath, "shrunk.jsonl"); + const contents = [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "shrinking-session", cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Do not import a changing file" }, + }), + ].join("\n"); + yield* writeTranscript({ + filePath: transcriptPath, + contents: `${contents}\n${"padding".repeat(100)}`, + mtimeMs: nowMs, + }); + yield* writeTranscript({ filePath: shrunkPath, contents, mtimeMs: nowMs }); + + let transcriptOpenCount = 0; + const simulatedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + open: (filePath, options) => { + if (filePath !== transcriptPath) return fileSystem.open(filePath, options); + transcriptOpenCount += 1; + return fileSystem.open( + transcriptOpenCount === 1 ? transcriptPath : shrunkPath, + options, + ); + }, + }); + + const outcomes = yield* runRecentThreadOutcomes({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + }).pipe(Effect.provideService(FileSystem.FileSystem, simulatedFileSystem)); + + expect(transcriptOpenCount).toBe(2); + expect(outcomes).toEqual([{ _tag: "Skipped" }]); + }), + ); + + it.effect("does not read the second transcript when the consumer takes one thread", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const makeCodexTranscript = (sessionId: string, text: string) => + [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: sessionId, cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: text }, + }), + ].join("\n"); + const olderPath = path.join( + codexHomePath, + "sessions", + "2026", + "08", + "23", + "rollout-older.jsonl", + ); + const newerPath = path.join( + codexHomePath, + "sessions", + "2026", + "08", + "24", + "rollout-newer.jsonl", + ); + yield* writeTranscript({ + filePath: olderPath, + contents: makeCodexTranscript("older-session", "Older prompt"), + mtimeMs: nowMs - 1_000, + }); + yield* writeTranscript({ + filePath: newerPath, + contents: makeCodexTranscript("newer-session", "Newer prompt"), + mtimeMs: nowMs, + }); + + const openCounts = new Map(); + const contentReads: Array = []; + const trackedPaths = new Set([olderPath, newerPath]); + const simulatedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + open: (filePath, options) => { + if (trackedPaths.has(filePath)) { + const count = (openCounts.get(filePath) ?? 0) + 1; + openCounts.set(filePath, count); + if (count === 2) contentReads.push(filePath); + } + return fileSystem.open(filePath, options); + }, + }); + + const threads = yield* Effect.gen(function* () { + const scanner = yield* AgentSessionScanner.AgentSessionScanner; + return yield* scanner.recentThreads(workspace).pipe( + Stream.take(1), + Stream.runCollect, + Effect.map((items) => Array.from(items)), + ); + }).pipe( + Effect.provide(makeScannerTestLayer({ claudeHomePath, codexHomePath })), + Effect.provideService(FileSystem.FileSystem, simulatedFileSystem), + ); + + expect( + threads.flatMap((outcome) => + outcome._tag === "Importable" ? [outcome.thread.providerSessionId] : [], + ), + ).toEqual(["newer-session"]); + expect(contentReads).toEqual([newerPath]); + expect(openCounts.get(olderPath)).toBe(1); + }), + ); + + it.effect("does not import sessions from a T3-managed worktree", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const configBaseDir = yield* makeTempDir("t3code-scanner-base-"); + const workspace = path.join(configBaseDir, "worktrees", "t3code", "managed-worktree"); + yield* fileSystem.makeDirectory(workspace, { recursive: true }); + + yield* writeTranscript({ + filePath: path.join( + codexHomePath, + "sessions", + "2026", + "08", + "24", + "rollout-managed.jsonl", + ), + contents: [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "managed-session", cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Do not import this session" }, + }), + ].join("\n"), + mtimeMs: nowMs, + }); + + const threads = yield* runRecentThreads({ + claudeHomePath, + codexHomePath, + configBaseDir, + workspaceRoot: workspace, + }); + + expect(threads).toEqual([]); + }), + ); + + it.effect("uses one deterministic provider instance for a shared session home", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const sharedHome = yield* makeTempDir("t3code-codex-shared-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + + yield* writeTranscript({ + filePath: path.join(sharedHome, "sessions", "2026", "08", "24", "rollout-shared.jsonl"), + contents: [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "shared-session", cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Use the shared session" }, + }), + ].join("\n"), + mtimeMs: nowMs, + }); + + const threads = yield* runRecentThreads({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + providerInstances: { + [ProviderInstanceId.make("codex")]: { + driver: ProviderDriverKind.make("codex"), + config: { homePath: sharedHome }, + }, + [ProviderInstanceId.make("codex-personal")]: { + driver: ProviderDriverKind.make("codex"), + config: { homePath: sharedHome }, + }, + [ProviderInstanceId.make("codex-work")]: { + driver: ProviderDriverKind.make("codex"), + config: { homePath: sharedHome }, + }, + }, + }); + + expect(threads.map((thread) => thread.providerInstanceId)).toEqual(["codex"]); + }), + ); + + it.effect("uses configured order when custom instances share a session home", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const sharedHome = yield* makeTempDir("t3code-codex-shared-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + + yield* writeTranscript({ + filePath: path.join(sharedHome, "sessions", "2026", "08", "24", "rollout-shared.jsonl"), + contents: [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "shared-session", cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Use the first account" }, + }), + ].join("\n"), + mtimeMs: nowMs, + }); + + const threads = yield* runRecentThreads({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + providerInstances: { + [ProviderInstanceId.make("codex-work")]: { + driver: ProviderDriverKind.make("codex"), + config: { homePath: sharedHome }, + }, + [ProviderInstanceId.make("codex-personal")]: { + driver: ProviderDriverKind.make("codex"), + config: { homePath: sharedHome }, + }, + }, + }); + + expect(threads.map((thread) => thread.providerInstanceId)).toEqual(["codex-work"]); + }), + ); + + it.effect("keeps a second account when the first has 5000 newer files", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const oldWorkspace = yield* makeTempDir("t3code-workspace-old-"); + const recentWorkspace = yield* makeTempDir("t3code-workspace-recent-"); + const recentHome = yield* makeTempDir("t3code-claude-recent-home-"); + const oldDirectory = path.join(claudeHomePath, "projects", "-aaa-old"); + const oldTranscript = path.join(oldDirectory, "old.jsonl"); + const recentDirectory = path.join(recentHome, "projects", "-zzz-recent"); + + yield* writeTranscript({ + filePath: oldTranscript, + contents: encodeTranscriptRecord({ + type: "user", + cwd: oldWorkspace, + sessionId: "old-session", + message: { role: "user", content: "Old work" }, + }), + mtimeMs: nowMs, + }); + yield* writeTranscript({ + filePath: path.join(recentDirectory, "recent.jsonl"), + contents: encodeTranscriptRecord({ + type: "user", + cwd: recentWorkspace, + sessionId: "recent-session", + message: { role: "user", content: "Recent work" }, + }), + mtimeMs: nowMs - 1_000, + }); + + const simulatedOldTranscripts = Array.from( + { length: 5_000 }, + (_, index) => `old-${index}.jsonl`, + ); + const resolveTranscript = (filePath: string) => + path.dirname(filePath) === oldDirectory && path.basename(filePath).startsWith("old-") + ? oldTranscript + : filePath; + const simulatedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + readDirectory: (directory, options) => + directory === oldDirectory + ? Effect.succeed(simulatedOldTranscripts) + : fileSystem.readDirectory(directory, options), + stat: (filePath) => fileSystem.stat(resolveTranscript(filePath)), + open: (filePath, options) => fileSystem.open(resolveTranscript(filePath), options), + }); + + const input = { + claudeHomePath, + codexHomePath, + providerInstances: { + [ProviderInstanceId.make("claude-work")]: { + driver: ProviderDriverKind.make("claudeAgent"), + config: { homePath: recentHome }, + }, + }, + }; + const threads = yield* Effect.gen(function* () { + const scanner = yield* AgentSessionScanner.AgentSessionScanner; + const scan = yield* scanner.scan; + expect(scan.truncated).toBe(true); + return yield* scanner.recentThreads(recentWorkspace).pipe(Stream.runCollect); + }).pipe( + Effect.provide(makeScannerTestLayer(input)), + Effect.provideService(FileSystem.FileSystem, simulatedFileSystem), + ); + + expect( + threads.flatMap((outcome) => + outcome._tag === "Importable" ? [outcome.thread.providerSessionId] : [], + ), + ).toEqual(["recent-session"]); + }), + ); + }); +}); + +describe("parseAgentSessionTranscript", () => { + it.each([false, true])( + "handles the exact record limit and an interior blank overflow=%s", + (overflow) => { + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: makeRecordLimitTranscript("/project", overflow), + source: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + fallbackSessionId: "unused", + lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), + }); + if (overflow) expect(thread).toBeNull(); + else expect(thread?.messages.map((message) => message.text)).toEqual(["First prompt"]); + }, + ); + + it("keeps Claude text and titles while dropping malformed and tool records", () => { + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: [ + "not valid json", + JSON.stringify({ type: "ai-title", aiTitle: "Fix authentication" }), + JSON.stringify({ + type: "user", + sessionId: "claude-session", + isMeta: true, + message: { role: "user", content: "Injected skill instructions" }, + }), + JSON.stringify({ + type: "user", + sessionId: "claude-session", + isCompactSummary: true, + message: { role: "user", content: "Injected compaction summary" }, + }), + JSON.stringify({ + type: "user", + sessionId: "claude-session", + timestamp: "2026-08-24T10:00:00.000Z", + message: { role: "user", content: [{ type: "text", text: "Fix authentication" }] }, + }), + JSON.stringify({ + type: "user", + sessionId: "claude-session", + message: { role: "user", content: [{ type: "tool_result", text: "hidden" }] }, + }), + JSON.stringify({ + type: "assistant", + sessionId: "claude-session", + message: { + role: "assistant", + model: "claude-sonnet-5", + content: [{ type: "text", text: "Updated the login flow" }], + }, + }), + JSON.stringify({ + type: "assistant", + sessionId: "claude-session", + message: { + role: "assistant", + model: "", + content: [{ type: "text", text: "The provider request failed" }], + }, + }), + ].join("\n"), + source: "claudeAgent", + providerInstanceId: ProviderInstanceId.make("claudeAgent"), + fallbackSessionId: "fallback", + lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), + }); + + expect(thread).toMatchObject({ + providerSessionId: "claude-session", + title: "Fix authentication", + model: "claude-sonnet-5", + messages: [ + { role: "user", text: "Fix authentication" }, + { role: "assistant", text: "Updated the login flow" }, + { role: "assistant", text: "The provider request failed" }, + ], + }); + }); + + it("drops injected Codex instructions while keeping the visible user event", () => { + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: [ + JSON.stringify({ type: "session_meta", payload: { id: "codex-session" } }), + JSON.stringify({ + type: "response_item", + payload: { + type: "message", + role: "user", + internal_chat_message_metadata_passthrough: { turn_id: "turn-1" }, + content: [ + { + type: "input_text", + text: "\nInternal setup instructions\n", + }, + ], + }, + }), + JSON.stringify({ + type: "event_msg", + payload: { type: "user_message", message: "Fix the actual bug" }, + }), + JSON.stringify({ + type: "response_item", + payload: { + type: "message", + role: "user", + internal_chat_message_metadata_passthrough: { turn_id: "turn-1" }, + content: [{ type: "input_text", text: "Fix the actual bug" }], + }, + }), + JSON.stringify({ + type: "response_item", + payload: { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "Fixed" }], + }, + }), + ].join("\n"), + source: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + fallbackSessionId: "fallback", + lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), + }); + + expect(thread?.messages.map((message) => message.text)).toEqual([ + "Fix the actual bug", + "Fixed", + ]); + }); + + it("keeps the canonical first prompt after long Codex transcripts are capped", () => { + const canonicalPrompt = "\n Keep the canonical prompt \n"; + const canonicalTimestamp = "2026-08-24T10:01:00.000Z"; + const laterAssistantMessages = Array.from({ length: 200 }, (_, index) => + encodeTranscriptRecord({ + type: "response_item", + timestamp: `2026-08-24T11:${String(index % 60).padStart(2, "0")}:00.000Z`, + payload: { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: `Assistant message ${index}` }], + }, + }), + ); + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: [ + encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }), + encodeTranscriptRecord({ + type: "response_item", + timestamp: "2026-08-24T10:00:00.000Z", + payload: { + type: "message", + role: "user", + content: [{ type: "input_text", text: "Keep the canonical prompt" }], + }, + }), + encodeTranscriptRecord({ + type: "event_msg", + timestamp: canonicalTimestamp, + payload: { type: "user_message", message: canonicalPrompt }, + }), + ...laterAssistantMessages, + ].join("\n"), + source: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + fallbackSessionId: "fallback", + lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), + }); + + expect(thread?.messages).toHaveLength(200); + expect(thread?.messages[0]).toMatchObject({ + role: "user", + text: canonicalPrompt, + createdAt: canonicalTimestamp, + }); + }); + + it("restores the canonical first prompt when a later user message remains", () => { + const canonicalPrompt = "\n Keep the canonical prompt \n"; + const canonicalTimestamp = "2026-08-24T10:01:00.000Z"; + const assistantMessages = Array.from({ length: 198 }, (_, index) => + encodeTranscriptRecord({ + type: "response_item", + timestamp: `2026-08-24T11:${String(index % 60).padStart(2, "0")}:00.000Z`, + payload: { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: `Assistant message ${index}` }], + }, + }), + ); + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: [ + encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }), + encodeTranscriptRecord({ + type: "response_item", + timestamp: "2026-08-24T10:00:00.000Z", + payload: { + type: "message", + role: "user", + internal_chat_message_metadata_passthrough: { turn_id: "turn-1" }, + content: [{ type: "input_text", text: "Keep the canonical prompt" }], + }, + }), + encodeTranscriptRecord({ + type: "event_msg", + timestamp: canonicalTimestamp, + payload: { type: "user_message", message: canonicalPrompt }, + }), + ...assistantMessages, + encodeTranscriptRecord({ + type: "event_msg", + timestamp: "2026-08-24T11:58:30.000Z", + payload: { type: "user_message", message: "Keep this later prompt" }, + }), + encodeTranscriptRecord({ + type: "response_item", + timestamp: "2026-08-24T11:59:00.000Z", + payload: { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "Keep this latest response" }], + }, + }), + ].join("\n"), + source: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + fallbackSessionId: "fallback", + lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), + }); + + expect(thread?.messages).toHaveLength(200); + expect(thread?.messages[0]).toMatchObject({ + role: "user", + text: canonicalPrompt, + createdAt: canonicalTimestamp, + }); + expect( + thread?.messages.filter((message) => message.text.trim() === canonicalPrompt.trim()), + ).toHaveLength(1); + expect(thread?.messages.some((message) => message.text === "Keep this later prompt")).toBe( + true, + ); + expect(thread?.messages.at(-1)?.text).toBe("Keep this latest response"); + }); + + it("keeps mixed-format response users when turn IDs repeat after an assistant", () => { + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: [ + encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }), + encodeTranscriptRecord({ + type: "response_item", + payload: { + type: "message", + role: "user", + internal_chat_message_metadata_passthrough: { turn_id: "turn-older" }, + content: [{ type: "input_text", text: "Keep this older prompt" }], + }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Keep this newer prompt" }, + }), + encodeTranscriptRecord({ + type: "response_item", + payload: { + type: "message", + role: "user", + internal_chat_message_metadata_passthrough: { turn_id: "turn-newer" }, + content: [{ type: "input_text", text: "Keep this newer prompt" }], + }, + }), + encodeTranscriptRecord({ + type: "response_item", + payload: { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "Ask again when needed" }], + }, + }), + encodeTranscriptRecord({ + type: "response_item", + payload: { + type: "message", + role: "user", + internal_chat_message_metadata_passthrough: { turn_id: "turn-newer" }, + content: [{ type: "input_text", text: "Keep this newer prompt" }], + }, + }), + ].join("\n"), + source: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + fallbackSessionId: "fallback", + lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), + }); + + expect(thread?.messages.map((message) => message.text)).toEqual([ + "Keep this older prompt", + "Keep this newer prompt", + "Ask again when needed", + "Keep this newer prompt", + ]); + }); + + it("preserves response user text when Codex turn metadata is ambiguous", () => { + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: [ + encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }), + encodeTranscriptRecord({ + type: "response_item", + payload: { + type: "message", + role: "user", + internal_chat_message_metadata_passthrough: ["unexpected"], + content: [{ type: "input_text", text: "Keep this legacy prompt" }], + }, + }), + encodeTranscriptRecord({ + type: "response_item", + payload: { + type: "message", + role: "user", + internal_chat_message_metadata_passthrough: { turn_id: " " }, + content: [{ type: "input_text", text: "Keep this prompt with a blank turn ID" }], + }, + }), + ].join("\n"), + source: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + fallbackSessionId: "fallback", + lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), + }); + + expect(thread?.messages.map((message) => message.text)).toEqual([ + "Keep this legacy prompt", + "Keep this prompt with a blank turn ID", + ]); + }); + + it("uses the first valid Codex session ID when a fork copies ancestor metadata", () => { + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "fork-session", forked_from_id: "parent-session" }, + }), + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "parent-session" }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Continue in the fork" }, + }), + ].join("\n"), + source: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + fallbackSessionId: "fallback", + lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), + }); + + expect(thread?.providerSessionId).toBe("fork-session"); + }); + + it("skips Codex transcripts without a resumable session ID", () => { + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "This transcript has no session metadata" }, + }), + source: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + fallbackSessionId: "rollout-2026-08-24T12-00-00-not-a-session-id", + lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), + }); + + expect(thread).toBeNull(); + }); + + it("uses the canonical Codex event when its turn has generated response context", () => { + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: [ + encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }), + encodeTranscriptRecord({ + type: "response_item", + payload: { + type: "message", + role: "user", + internal_chat_message_metadata_passthrough: { turn_id: "turn-1" }, + content: [ + { + type: "input_text", + text: "\n/tmp/project\nzsh\n", + }, + ], + }, + }), + encodeTranscriptRecord({ + type: "response_item", + payload: { + type: "message", + role: "user", + internal_chat_message_metadata_passthrough: { turn_id: "turn-1" }, + content: [ + { + type: "input_text", + text: "# AGENTS.md instructions for /tmp/project\n\n\nPrivate project rules\n", + }, + ], + }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { + type: "user_message", + message: "Do something here so it looks like a real project.", + }, + }), + encodeTranscriptRecord({ + type: "response_item", + payload: { + type: "message", + role: "user", + internal_chat_message_metadata_passthrough: { turn_id: "turn-1" }, + content: [ + { + type: "input_text", + text: "Do something here so it looks like a real project.", + }, + ], + }, + }), + encodeTranscriptRecord({ + type: "response_item", + payload: { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "Created the project." }], + }, + }), + ].join("\n"), + source: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + fallbackSessionId: "fallback", + lastActiveAtMs: Date.parse("2026-08-25T08:00:00.000Z"), + }); + + expect(thread?.title).toBe("Do something here so it looks like a real project."); + expect(thread?.messages.map((message) => message.text)).toEqual([ + "Do something here so it looks like a real project.", + "Created the project.", + ]); + }); + + it("preserves context markup in response-only Codex messages", () => { + const context = "\n/tmp/project\n"; + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: [ + encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }), + encodeTranscriptRecord({ + type: "response_item", + payload: { + type: "message", + role: "user", + content: [ + { + type: "input_text", + text: context, + }, + ], + }, + }), + encodeTranscriptRecord({ + type: "response_item", + payload: { + type: "message", + role: "user", + content: [{ type: "input_text", text: "Initialize Git and add a README." }], + }, + }), + ].join("\n"), + source: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + fallbackSessionId: "fallback", + lastActiveAtMs: Date.parse("2026-08-25T08:00:00.000Z"), + }); + + expect(thread?.title).toBe(""); + expect(thread?.messages.map((message) => message.text)).toEqual([ + context, + "Initialize Git and add a README.", + ]); + }); + + it("preserves a canonical Codex event that starts with context markup", () => { + const prompt = + "\n/tmp/project\n\n\nCreate a useful project."; + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: [ + encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { + type: "user_message", + message: prompt, + }, + }), + ].join("\n"), + source: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + fallbackSessionId: "fallback", + lastActiveAtMs: Date.parse("2026-08-25T08:00:00.000Z"), + }); + + expect(thread?.title).toBe(""); + expect(thread?.messages.map((message) => message.text)).toEqual([prompt]); + }); + + it("preserves a Codex request heading in a canonical event", () => { + const prompt = "\n ## My request for Codex:\n\nFix the visible bug"; + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: [ + encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { + type: "user_message", + message: prompt, + }, + }), + ].join("\n"), + source: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + fallbackSessionId: "fallback", + lastActiveAtMs: Date.parse("2026-08-25T08:00:00.000Z"), + }); + + expect(thread?.title).toBe("## My request for Codex:"); + expect(thread?.messages.map((message) => message.text)).toEqual([prompt]); + }); + + it("keeps context markup quoted inside visible Codex user text", () => { + const quoted = + "Do not remove this example:\n\n/tmp/example\n"; + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: [ + encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: quoted }, + }), + ].join("\n"), + source: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + fallbackSessionId: "fallback", + lastActiveAtMs: Date.parse("2026-08-25T08:00:00.000Z"), + }); + + expect(thread?.messages.map((message) => message.text)).toEqual([quoted]); + }); + + it("skips sessions without a visible user message", () => { + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: JSON.stringify({ + type: "assistant", + message: { role: "assistant", content: "Done" }, + }), + source: "claudeAgent", + providerInstanceId: ProviderInstanceId.make("claudeAgent"), + fallbackSessionId: "claude-session", + lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), + }); + + expect(thread).toBeNull(); + }); + + it("keeps the first prompt when later assistant output exceeds the message limit", () => { + const transcript = [ + encodeTranscriptRecord({ + type: "user", + sessionId: "claude-session", + message: { role: "user", content: "Keep this prompt" }, + }), + ...Array.from({ length: 250 }, (_, index) => + encodeTranscriptRecord({ + type: "assistant", + message: { role: "assistant", content: `Assistant update ${index}` }, + }), + ), + ].join("\n"); + + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: transcript, + source: "claudeAgent", + providerInstanceId: ProviderInstanceId.make("claudeAgent"), + fallbackSessionId: "fallback", + lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), + }); + + expect(thread?.messages).toHaveLength(200); + expect(thread?.messages[0]?.text).toBe("Keep this prompt"); + expect(thread?.messages.at(-1)?.text).toBe("Assistant update 249"); + }); +}); diff --git a/apps/server/src/project/AgentSessionScanner.ts b/apps/server/src/project/AgentSessionScanner.ts new file mode 100644 index 000000000000..bc6d093d42a1 --- /dev/null +++ b/apps/server/src/project/AgentSessionScanner.ts @@ -0,0 +1,1313 @@ +/** + * AgentSessionScanner - discovery of projects a user already works on. + * + * Claude Code and Codex both keep a per-session transcript on disk, and each + * transcript records the directory the session ran in. Reading those `cwd` + * values gives us the set of directories worth offering as projects during + * onboarding, without asking the user to browse the filesystem. + * + * The scan is read-only and best-effort: an unreadable home, a malformed + * transcript, or a directory that has since been deleted is skipped rather + * than failing the scan. Project creation stays with the client, which + * dispatches `project.create` for whichever candidates the user picks. + * + * @module project/AgentSessionScanner + */ +import * as NodeOS from "node:os"; + +import { + AgentSessionScanError, + ClaudeSettings, + CodexSettings, + ProviderDriverKind, + ProviderInstanceId, + resolveProviderInstanceEnabled, + type AgentSessionImportSource, + type AgentSessionProjectCandidate, + type AgentSessionScanResult, + type ProviderInstanceConfig, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; + +import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { normalizeProjectPathForComparison } from "@t3tools/shared/path"; + +import * as ServerConfig from "../config.ts"; +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts"; +import { expandHomePath } from "../pathExpansion.ts"; +import * as ServerSettings from "../serverSettings.ts"; + +/** Chunk size for full transcript reads. */ +const TRANSCRIPT_PREFIX_BYTES = 32 * 1024; +/** Small reads avoid wasting the metadata budget on long Codex instruction headers. */ +const METADATA_READ_BYTES = 8 * 1024; +/** Prevent malformed transcripts from turning project discovery into a full file scan. */ +const MAX_TRANSCRIPT_SCAN_BYTES = 1024 * 1024; + +/** + * Upper bound on transcripts inspected (first line read) per source. + * Newest-first ordering means the cap drops only stale sessions when a home + * directory is unusually large. + */ +const MAX_TRANSCRIPTS_PER_SOURCE = 5000; + +/** + * Upper bound on discovery filesystem operations per source. Newest-first + * ordering needs mtimes before the read cap can be applied, so directory reads + * and candidate stats share a larger budget. Once it runs out the scan stops. + */ +const MAX_DISCOVERY_OPERATIONS_PER_SOURCE = MAX_TRANSCRIPTS_PER_SOURCE * 4; +const MAX_METADATA_BYTES_PER_SOURCE = 64 * 1024 * 1024; +const MAX_METADATA_OPERATIONS_PER_SOURCE = MAX_TRANSCRIPTS_PER_SOURCE * 4; +const MAX_METADATA_RECORDS_PER_SOURCE = 100_000; +const MAX_METADATA_RECORDS_PER_TRANSCRIPT = 1_000; +const RECENT_THREAD_WINDOW_MS = 30 * 24 * 60 * 60 * 1000; +const MAX_IMPORTED_TRANSCRIPT_BYTES = 16 * 1024 * 1024; +const MAX_IMPORTED_MESSAGES = 200; +const MAX_IMPORT_BYTES = 64 * 1024 * 1024; +const MAX_IMPORT_TRANSCRIPTS = 100; +const MAX_IMPORT_RECORDS = 100_000; + +const TranscriptContentBlock = Schema.Struct({ + type: Schema.optional(Schema.String), + text: Schema.optional(Schema.String), +}); + +const TranscriptMessage = Schema.Struct({ + role: Schema.optional(Schema.String), + content: Schema.optional(Schema.Union([Schema.String, Schema.Array(TranscriptContentBlock)])), + model: Schema.optional(Schema.String), +}); + +const CodexTurnMetadata = Schema.Struct({ + turn_id: Schema.optional(Schema.Union([Schema.String, Schema.Null])), +}); + +const TranscriptRecord = Schema.Struct({ + type: Schema.optional(Schema.String), + timestamp: Schema.optional(Schema.String), + sessionId: Schema.optional(Schema.String), + aiTitle: Schema.optional(Schema.String), + isSidechain: Schema.optional(Schema.Boolean), + isMeta: Schema.optional(Schema.Boolean), + isCompactSummary: Schema.optional(Schema.Boolean), + message: Schema.optional(TranscriptMessage), + payload: Schema.optional( + Schema.Struct({ + id: Schema.optional(Schema.String), + session_id: Schema.optional(Schema.String), + type: Schema.optional(Schema.String), + role: Schema.optional(Schema.String), + message: Schema.optional(Schema.String), + model: Schema.optional(Schema.String), + content: Schema.optional(Schema.Array(TranscriptContentBlock)), + internal_chat_message_metadata_passthrough: Schema.optional(Schema.Unknown), + }), + ), +}); + +const decodeClaudeSettings = Schema.decodeUnknownOption(ClaudeSettings); +const decodeCodexSettings = Schema.decodeUnknownOption(CodexSettings); +const decodeTranscriptRecord = Schema.decodeUnknownOption(Schema.fromJsonString(TranscriptRecord)); +const decodeCodexTurnMetadata = Schema.decodeUnknownOption(CodexTurnMetadata); + +export interface AgentSessionThreadMessage { + readonly role: "user" | "assistant"; + readonly text: string; + readonly createdAt: string; +} + +export interface AgentSessionThread { + readonly source: AgentSessionSource; + readonly providerInstanceId: ProviderInstanceId; + readonly providerSessionId: string; + readonly title: string; + readonly model: string | null; + readonly createdAt: string; + readonly updatedAt: string; + readonly messages: ReadonlyArray; +} + +export type AgentSessionRecentThread = + | { + readonly _tag: "Importable"; + readonly thread: AgentSessionThread; + readonly source: AgentSessionImportSource; + } + | { readonly _tag: "AlreadyImported"; readonly source: AgentSessionImportSource } + | { readonly _tag: "Duplicate"; readonly source: AgentSessionImportSource } + | { readonly _tag: "Skipped" }; + +/** Service tag for agent session discovery. */ +export class AgentSessionScanner extends Context.Service< + AgentSessionScanner, + { + /** + * Discover every directory the configured Claude and Codex homes have run + * a session in. Candidates are returned newest-first; the client decides + * which ones to import and how far back to look. Fails with the contract + * error directly — there is no server-local context worth wrapping. + */ + readonly scan: Effect.Effect; + readonly recentThreads: ( + workspaceRoot: string, + completedSources?: ReadonlyArray, + ) => Stream.Stream; + } +>()("t3/project/AgentSessionScanner") {} + +type AgentSessionSource = AgentSessionProjectCandidate["sources"][number]; + +/** A single directory's worth of evidence from one source. */ +interface RawCandidate { + readonly cwd: string; + readonly source: AgentSessionSource; + readonly providerInstanceId: ProviderInstanceId; + readonly threadCount: number; + readonly lastActiveAtMs: number | null; + readonly transcripts: ReadonlyArray<{ + readonly filePath: string; + readonly mtimeMs: number | null; + }>; +} + +interface TranscriptCandidate { + readonly filePath: string; + readonly mtimeMs: number; + readonly providerInstanceId: ProviderInstanceId; + readonly size: number; +} + +interface MetadataReadBudget { + bytesRemaining: number; + operationsRemaining: number; + recordsRemaining: number; + truncated: boolean; +} + +function selectMetadataTranscripts(transcripts: ReadonlyArray) { + const selected: Array = []; + let pending = Array.from( + Map.groupBy(transcripts, (transcript) => transcript.providerInstanceId).values(), + (entries) => entries.values(), + ); + while (pending.length > 0 && selected.length < MAX_TRANSCRIPTS_PER_SOURCE) { + const nextRound: typeof pending = []; + for (const iterator of pending) { + if (selected.length === MAX_TRANSCRIPTS_PER_SOURCE) break; + const next = iterator.next(); + if (next.done) continue; + selected.push(next.value); + nextRound.push(iterator); + } + pending = nextRound; + } + return selected; +} + +function splitTranscriptRecords(contents: string, limit: number): string[] { + const records = contents.endsWith("\n") ? contents.slice(0, -1) : contents; + return records.split("\n", limit); +} + +function extractText( + content: string | ReadonlyArray | undefined, +): string { + if (typeof content === "string") return content.trim(); + if (content === undefined) return ""; + return content + .filter( + (block) => + block.type === "text" || block.type === "input_text" || block.type === "output_text", + ) + .map((block) => block.text?.trim() ?? "") + .filter((text) => text.length > 0) + .join("\n"); +} + +function normalizeTimestamp(value: string | undefined, fallback: string): string { + if (value === undefined) return fallback; + const parsed = DateTime.make(value); + return Option.isSome(parsed) ? DateTime.formatIso(parsed.value) : fallback; +} + +function codexTurnId(metadata: unknown): string | null { + const decoded = decodeCodexTurnMetadata(metadata); + if ( + Option.isNone(decoded) || + typeof decoded.value.turn_id !== "string" || + decoded.value.turn_id.trim().length === 0 + ) { + return null; + } + return decoded.value.turn_id; +} + +/** Keep visible user and assistant text while ignoring tools, reasoning, and malformed records. */ +export function parseAgentSessionTranscript( + input: { + readonly contents: string; + readonly source: AgentSessionSource; + readonly providerInstanceId: ProviderInstanceId; + readonly fallbackSessionId: string; + readonly lastActiveAtMs: number; + }, + lines = splitTranscriptRecords(input.contents, MAX_IMPORT_RECORDS + 1), +): AgentSessionThread | null { + if (lines.length > MAX_IMPORT_RECORDS) return null; + const fallbackTimestamp = DateTime.formatIso(DateTime.makeUnsafe(input.lastActiveAtMs)); + // Claude filenames are session IDs. Codex rollout filenames include extra + // timestamp text, so only transcript metadata can provide a resumable ID. + let providerSessionId = input.source === "codex" ? "" : input.fallbackSessionId; + let title: string | null = null; + let model: string | null = null; + let hasCodexSessionId = false; + const messages: Array = []; + let firstUserMessage: + | (AgentSessionThreadMessage & { readonly codexResponseUser: boolean }) + | undefined; + function* decodedRecords() { + for (const line of lines) { + const decoded = decodeTranscriptRecord(line); + if (Option.isSome(decoded)) yield decoded.value; + } + } + + // A Codex response item can include generated setup text beside the real + // prompt. Suppress response-user records only when the shared turn ID and a + // verbatim event copy prove which prompt the user submitted. + const canonicalCodexResponseUserIndices = new Set(); + let canonicalUserTextsInTurn = new Set(); + let responseUsersInTurn: Array<{ + readonly index: number; + readonly turnId: string; + readonly text: string; + }> = []; + const finishCodexTurn = () => { + const canonicalTurnIds = new Set( + responseUsersInTurn.flatMap((responseUser) => + canonicalUserTextsInTurn.has(responseUser.text) ? [responseUser.turnId] : [], + ), + ); + for (const responseUser of responseUsersInTurn) { + if (canonicalTurnIds.has(responseUser.turnId)) { + canonicalCodexResponseUserIndices.add(responseUser.index); + } + } + canonicalUserTextsInTurn = new Set(); + responseUsersInTurn = []; + }; + if (input.source === "codex") { + let recordIndex = -1; + for (const record of decodedRecords()) { + recordIndex += 1; + if ( + record.type === "response_item" && + record.payload?.type === "message" && + record.payload.role === "assistant" + ) { + finishCodexTurn(); + continue; + } + if (record.type === "event_msg" && record.payload?.type === "user_message") { + const text = record.payload.message?.trim() ?? ""; + if (text.length > 0) canonicalUserTextsInTurn.add(text); + continue; + } + if ( + record.type === "response_item" && + record.payload?.type === "message" && + record.payload.role === "user" + ) { + const turnId = codexTurnId(record.payload.internal_chat_message_metadata_passthrough); + const text = extractText(record.payload.content); + if (turnId !== null && text.length > 0) { + responseUsersInTurn.push({ index: recordIndex, turnId, text }); + } + } + } + finishCodexTurn(); + } + + const retainMessage = ( + message: AgentSessionThreadMessage & { readonly codexResponseUser: boolean }, + ) => { + if (firstUserMessage === undefined && message.role === "user") { + firstUserMessage = message; + } + messages.push(message); + if (messages.length > MAX_IMPORTED_MESSAGES) messages.shift(); + }; + + const hasMatchingCodexEventInTurn = (text: string) => { + const comparisonText = text.trim(); + for (let index = messages.length - 1; index >= 0; index--) { + const message = messages[index]; + if (message?.role === "assistant") return false; + if ( + message?.role === "user" && + !message.codexResponseUser && + message.text.trim() === comparisonText + ) { + return true; + } + } + return false; + }; + + let recordIndex = -1; + for (const record of decodedRecords()) { + recordIndex += 1; + if (input.source === "claudeAgent") { + if ( + record.isSidechain === true || + record.isMeta === true || + record.isCompactSummary === true + ) { + continue; + } + if (record.sessionId?.trim()) providerSessionId = record.sessionId.trim(); + if (record.aiTitle?.trim()) title = record.aiTitle.trim(); + const messageModel = record.message?.model?.trim(); + // Claude uses this sentinel for local error responses. It is not a + // model ID that can be selected when the imported session resumes. + if (messageModel && messageModel !== "") model = messageModel; + if (record.type !== "user" && record.type !== "assistant") { + continue; + } + + const text = extractText(record.message?.content); + if (text.length === 0) continue; + retainMessage({ + role: record.type, + text, + createdAt: normalizeTimestamp(record.timestamp, fallbackTimestamp), + codexResponseUser: false, + }); + continue; + } + + if (record.type === "session_meta") { + const sessionId = record.payload?.id?.trim() || record.payload?.session_id?.trim(); + if (!hasCodexSessionId && sessionId) { + providerSessionId = sessionId; + hasCodexSessionId = true; + } + continue; + } + if (record.type === "turn_context" && record.payload?.model?.trim()) { + model = record.payload.model.trim(); + continue; + } + if (record.type === "event_msg" && record.payload?.type === "user_message") { + const text = record.payload.message ?? ""; + if (text.trim().length === 0) continue; + // Codex can write the same prompt as both a response item and an event. + // Remove only the matching response copy so mixed-format logs keep every + // distinct user message. + for (let index = messages.length - 1; index >= 0; index--) { + const message = messages[index]; + if (message?.role === "assistant") break; + if (message?.codexResponseUser === true && message.text.trim() === text.trim()) { + if (firstUserMessage === message) firstUserMessage = undefined; + messages.splice(index, 1); + break; + } + } + retainMessage({ + role: "user", + text, + createdAt: normalizeTimestamp(record.timestamp, fallbackTimestamp), + codexResponseUser: false, + }); + continue; + } + if ( + record.type !== "response_item" || + record.payload?.type !== "message" || + (record.payload.role !== "user" && record.payload.role !== "assistant") + ) { + continue; + } + + const extractedText = extractText(record.payload.content); + if (extractedText.length === 0) continue; + if (record.payload.role === "user" && canonicalCodexResponseUserIndices.has(recordIndex)) { + continue; + } + if (record.payload.role === "user" && hasMatchingCodexEventInTurn(extractedText)) { + continue; + } + retainMessage({ + role: record.payload.role, + text: extractedText, + createdAt: normalizeTimestamp(record.timestamp, fallbackTimestamp), + codexResponseUser: record.payload.role === "user", + }); + } + + const visibleMessages = messages.map( + ({ codexResponseUser: _codexResponseUser, ...message }) => message, + ); + if (providerSessionId.trim().length === 0 || firstUserMessage === undefined) return null; + const firstUserMessageRetained = messages.includes(firstUserMessage); + const { codexResponseUser: _codexResponseUser, ...visibleFirstUserMessage } = firstUserMessage; + const retainedMessages = firstUserMessageRetained + ? visibleMessages + : [visibleFirstUserMessage, ...visibleMessages.slice(-(MAX_IMPORTED_MESSAGES - 1))]; + const derivedTitle = visibleFirstUserMessage.text.trim().split("\n")[0]?.slice(0, 100).trim(); + + return { + source: input.source, + providerInstanceId: input.providerInstanceId, + providerSessionId, + title: title ?? (derivedTitle && derivedTitle.length > 0 ? derivedTitle : "Imported thread"), + model, + createdAt: retainedMessages[0]?.createdAt ?? fallbackTimestamp, + updatedAt: fallbackTimestamp, + messages: retainedMessages, + }; +} + +/** + * T3 Code runs its own agent sessions inside disposable worktrees. Their + * transcripts look exactly like user sessions, but re-importing the app's own + * sandboxes as projects is never right. Matches this server's configured + * worktrees directory plus the conventional `.t3/worktrees` layout, which + * also catches sandboxes from other T3 homes on the same machine. Separators + * are normalized (and, on Windows, case folded) so the prefix match holds + * there too. Callers check both the recorded spelling and its realpath so a + * symlink into the worktrees directory cannot bypass the filter. + */ +function normalizeForWorktreeMatch(value: string, caseFold: boolean): string { + const normalized = `${value.replaceAll("\\", "/")}/`; + return caseFold ? normalized.toLowerCase() : normalized; +} + +function isT3ManagedWorktree( + candidatePath: string, + worktreesDir: string, + caseFold: boolean, +): boolean { + const normalized = normalizeForWorktreeMatch(candidatePath, caseFold); + return ( + normalized.startsWith(normalizeForWorktreeMatch(worktreesDir, caseFold)) || + normalized.includes("/.t3/worktrees/") + ); +} + +/** Extract `cwd` from a session-meta record, tolerating the shapes each CLI writes. */ +function extractCwd(line: string): string | null { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + return null; + } + if (typeof parsed !== "object" || parsed === null) return null; + + const record = parsed as Record; + if (typeof record.cwd === "string" && record.cwd.trim().length > 0) { + return record.cwd; + } + // Codex nests session metadata under `payload`. + const payload = record.payload; + if (typeof payload === "object" && payload !== null) { + const nested = (payload as Record).cwd; + if (typeof nested === "string" && nested.trim().length > 0) { + return nested; + } + } + return null; +} + +function transcriptIdentity(filePath: string, stats: FileSystem.File.Info) { + return { + filePath, + size: Number(stats.size), + mtimeMs: Option.match(stats.mtime, { onNone: () => null, onSome: (date) => date.getTime() }), + device: stats.dev, + inode: Option.getOrNull(stats.ino), + birthtimeMs: Option.match(stats.birthtime, { + onNone: () => null, + onSome: (date) => date.getTime(), + }), + }; +} + +function sameTranscriptIdentity( + left: ReturnType, + right: ReturnType, +): boolean { + return ( + left.filePath === right.filePath && + left.size === right.size && + left.mtimeMs === right.mtimeMs && + left.device === right.device && + left.inode === right.inode && + left.birthtimeMs === right.birthtimeMs + ); +} + +export const make = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const serverConfig = yield* ServerConfig.ServerConfig; + const serverSettings = yield* ServerSettings.ServerSettingsService; + const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const baseDir = path.resolve(serverConfig.baseDir); + const worktreesDir = path.resolve(serverConfig.worktreesDir); + // Windows filesystems are case-insensitive, so path prefix checks there + // must case fold. + const foldWorktreeCase = (yield* HostProcessPlatform) === "win32"; + const hostEnvironment = yield* HostProcessEnvironment; + const excludedProjectRoots = new Set( + [NodeOS.homedir(), NodeOS.tmpdir()].map((directory) => + normalizeProjectPathForComparison(path.resolve(directory)), + ), + ); + + const isExcludedProjectPath = (candidatePath: string) => + excludedProjectRoots.has(normalizeProjectPathForComparison(candidatePath)) || + normalizeForWorktreeMatch(candidatePath, foldWorktreeCase).startsWith( + normalizeForWorktreeMatch(baseDir, foldWorktreeCase), + ) || + isT3ManagedWorktree(candidatePath, worktreesDir, foldWorktreeCase); + + const listDirectory = (directory: string) => + fileSystem.readDirectory(directory).pipe(Effect.orElseSucceed((): ReadonlyArray => [])); + + const statOption = (target: string) => + fileSystem.stat(target).pipe(Effect.map(Option.some), Effect.orElseSucceed(Option.none)); + + /** Match directory aliases without assuming the host volume is case-insensitive. */ + const directoryIdentity = Effect.fn("AgentSessionScanner.directoryIdentity")(function* ( + target: string, + knownStats?: FileSystem.File.Info, + ) { + const resolved = path.resolve(target); + const stats = knownStats === undefined ? yield* statOption(resolved) : Option.some(knownStats); + if ( + Option.isSome(stats) && + Option.isSome(stats.value.ino) && + Number.isSafeInteger(stats.value.ino.value) && + stats.value.ino.value > 0 + ) { + return `inode:${stats.value.dev}:${stats.value.ino.value}`; + } + const realPath = yield* fileSystem + .realPath(resolved) + .pipe(Effect.orElseSucceed(() => resolved)); + return `path:${normalizeProjectPathForComparison(realPath)}`; + }); + + // A large history snapshot can precede session metadata. Read bounded + // chunks until a complete record names its cwd or the safety budget ends. + const readCwd = Effect.fn("AgentSessionScanner.readCwd")(function* ( + transcript: TranscriptCandidate, + budget: MetadataReadBudget, + ) { + if (transcript.size === 0) return null; + if ( + budget.bytesRemaining === 0 || + budget.operationsRemaining < 2 || + budget.recordsRemaining === 0 + ) { + budget.truncated = true; + return null; + } + budget.operationsRemaining -= 1; + return yield* Effect.scoped( + fileSystem.open(transcript.filePath, { flag: "r" }).pipe( + Effect.flatMap((file) => + Effect.gen(function* () { + const decoder = new TextDecoder(); + let remaining = ""; + let bytesRead = 0; + let recordsRead = 0; + const maxBytes = Math.min(MAX_TRANSCRIPT_SCAN_BYTES, transcript.size); + const reserveRecord = () => { + if ( + recordsRead === MAX_METADATA_RECORDS_PER_TRANSCRIPT || + budget.recordsRemaining === 0 + ) { + budget.truncated = true; + return false; + } + recordsRead += 1; + budget.recordsRemaining -= 1; + return true; + }; + const readLastRecord = () => { + const record = remaining + decoder.decode(); + return record.length === 0 || !reserveRecord() ? null : extractCwd(record.trim()); + }; + + while (bytesRead < maxBytes) { + if (budget.bytesRemaining === 0 || budget.operationsRemaining === 0) { + budget.truncated = true; + return null; + } + const readSize = Math.min( + METADATA_READ_BYTES, + maxBytes - bytesRead, + budget.bytesRemaining, + ); + budget.operationsRemaining -= 1; + budget.bytesRemaining -= readSize; + const next = yield* file.readAlloc(readSize); + if (Option.isNone(next)) { + return readLastRecord(); + } + + bytesRead += next.value.byteLength; + remaining += decoder.decode(next.value, { stream: true }); + const lines = remaining.split("\n"); + remaining = lines.pop() ?? ""; + + for (const line of lines) { + if (!reserveRecord()) return null; + const cwd = extractCwd(line.trim()); + if (cwd !== null) return cwd; + } + } + + if (bytesRead < transcript.size) { + budget.truncated = true; + return null; + } + return readLastRecord(); + }), + ), + ), + ).pipe(Effect.orElseSucceed(() => null)); + }); + + /** Check the open file before and after reading, without reading past its reserved byte budget. */ + const readTranscript = Effect.fn("AgentSessionScanner.readTranscript")(function* ( + filePath: string, + expected: ReturnType, + ) { + if (expected.size > MAX_IMPORTED_TRANSCRIPT_BYTES) return null; + + return yield* Effect.scoped( + fileSystem.open(filePath, { flag: "r" }).pipe( + Effect.flatMap((file) => + Effect.gen(function* () { + if (!sameTranscriptIdentity(expected, transcriptIdentity(filePath, yield* file.stat))) { + return null; + } + const decoder = new TextDecoder(); + let contents = ""; + let bytesRead = 0; + + while (bytesRead < expected.size) { + const next = yield* file.readAlloc( + Math.min(TRANSCRIPT_PREFIX_BYTES, expected.size - bytesRead), + ); + if (Option.isNone(next)) { + return null; + } + + bytesRead += next.value.byteLength; + contents += decoder.decode(next.value, { stream: true }); + } + + return sameTranscriptIdentity(expected, transcriptIdentity(filePath, yield* file.stat)) + ? contents + decoder.decode() + : null; + }), + ), + ), + ).pipe(Effect.orElseSucceed(() => null)); + }); + + /** + * Resolve the Claude config directory the CLI would use, matching the + * precedence the spawned CLI sees: the instance's `homePath` (exported as + * `CLAUDE_CONFIG_DIR`), then a `CLAUDE_CONFIG_DIR` already in the + * environment, then `~/.claude`. + */ + const resolveClaudeConfigDir = (homePath: string, environmentHome?: string): string => { + const configured = homePath.trim(); + if (configured.length > 0) { + return path.resolve(expandHomePath(configured)); + } + const fromEnvironment = environmentHome?.trim() ?? ""; + if (fromEnvironment.length > 0) { + return path.resolve(expandHomePath(fromEnvironment)); + } + return path.join(NodeOS.homedir(), ".claude"); + }; + + const discoverClaudeTranscripts = Effect.fn("AgentSessionScanner.discoverClaudeTranscripts")( + function* (homePath: string, providerInstanceId: ProviderInstanceId, operationBudget: number) { + const projectsDir = path.join(homePath, "projects"); + let operationsRemaining = operationBudget; + let truncated = false; + const readDirectory = (directory: string) => { + if (operationsRemaining <= 0) { + truncated = true; + return Effect.succeed>([]); + } + operationsRemaining -= 1; + return listDirectory(directory); + }; + const projectDirectories = yield* readDirectory(projectsDir); + const transcripts: Array = []; + + for (const projectDirectory of projectDirectories) { + if (operationsRemaining <= 0) { + truncated = true; + break; + } + const directory = path.join(projectsDir, projectDirectory); + const directoryTranscripts = (yield* readDirectory(directory)) + .filter((entry) => entry.endsWith(".jsonl")) + .map((entry) => path.join(directory, entry)); + + for (const filePath of directoryTranscripts) { + if (operationsRemaining <= 0) { + truncated = true; + break; + } + operationsRemaining -= 1; + const stats = yield* statOption(filePath); + if ( + Option.isNone(stats) || + stats.value.type !== "File" || + Option.isNone(stats.value.mtime) + ) { + continue; + } + transcripts.push({ + filePath, + mtimeMs: stats.value.mtime.value.getTime(), + providerInstanceId, + size: Number(stats.value.size), + }); + } + } + return { transcripts, truncated }; + }, + ); + + const discoverCodexTranscripts = Effect.fn("AgentSessionScanner.discoverCodexTranscripts")( + function* (homePath: string, providerInstanceId: ProviderInstanceId, operationBudget: number) { + const sessionsDir = path.join(homePath, "sessions"); + + const transcripts: Array = []; + let operationsRemaining = operationBudget; + let truncated = false; + const readDirectory = (directory: string) => { + if (operationsRemaining <= 0) { + truncated = true; + return Effect.succeed>([]); + } + operationsRemaining -= 1; + return listDirectory(directory); + }; + // Date-partitioned directories sort chronologically, so walking them in + // reverse spends each home's share of the operation budget on recent sessions. + for (const year of (yield* readDirectory(sessionsDir)).toSorted().toReversed()) { + if (operationsRemaining <= 0) { + truncated = true; + break; + } + for (const month of (yield* readDirectory(path.join(sessionsDir, year))) + .toSorted() + .toReversed()) { + if (operationsRemaining <= 0) { + truncated = true; + break; + } + for (const day of (yield* readDirectory(path.join(sessionsDir, year, month))) + .toSorted() + .toReversed()) { + if (operationsRemaining <= 0) { + truncated = true; + break; + } + const directory = path.join(sessionsDir, year, month, day); + for (const entry of (yield* readDirectory(directory)).toSorted().toReversed()) { + if (!entry.startsWith("rollout-") || !entry.endsWith(".jsonl")) continue; + if (operationsRemaining <= 0) { + truncated = true; + break; + } + const filePath = path.join(directory, entry); + operationsRemaining -= 1; + const stats = yield* statOption(filePath); + if ( + Option.isSome(stats) && + stats.value.type === "File" && + Option.isSome(stats.value.mtime) + ) { + transcripts.push({ + filePath, + mtimeMs: stats.value.mtime.value.getTime(), + providerInstanceId, + size: Number(stats.value.size), + }); + } + } + } + } + } + return { transcripts, truncated }; + }, + ); + + const groupTranscriptsByCwd = Effect.fn("AgentSessionScanner.groupTranscriptsByCwd")(function* ( + source: AgentSessionSource, + transcripts: ReadonlyArray, + budget: MetadataReadBudget, + ) { + const byOwnerAndCwd = new Map< + string, + { + cwd: string; + providerInstanceId: ProviderInstanceId; + lastActiveAtMs: number; + transcripts: Array<{ filePath: string; mtimeMs: number }>; + } + >(); + + for (const transcript of transcripts) { + const cwd = yield* readCwd(transcript, budget); + if (cwd === null) continue; + const key = `${transcript.providerInstanceId}\0${cwd}`; + const existing = byOwnerAndCwd.get(key); + if (existing) { + existing.lastActiveAtMs = Math.max(existing.lastActiveAtMs, transcript.mtimeMs); + existing.transcripts.push(transcript); + } else { + byOwnerAndCwd.set(key, { + cwd, + providerInstanceId: transcript.providerInstanceId, + lastActiveAtMs: transcript.mtimeMs, + transcripts: [transcript], + }); + } + } + + return Array.from(byOwnerAndCwd.values(), (group): RawCandidate => ({ + cwd: group.cwd, + source, + providerInstanceId: group.providerInstanceId, + threadCount: group.transcripts.length, + lastActiveAtMs: group.lastActiveAtMs, + transcripts: group.transcripts, + })); + }); + + const collectCandidates = Effect.fn("AgentSessionScanner.collectCandidates")(function* () { + const settings = yield* serverSettings.getSettings.pipe( + Effect.mapError((cause) => new AgentSessionScanError({ operation: "read-settings", cause })), + ); + + const raw: Array = []; + let truncated = false; + + for (const source of ["claudeAgent", "codex"] as const) { + const instances: Array<{ + readonly instanceId: ProviderInstanceId; + readonly config: ProviderInstanceConfig; + }> = Object.entries(settings.providerInstances) + .filter( + ([, instance]) => instance.driver === source && resolveProviderInstanceEnabled(instance), + ) + .map(([instanceId, config]) => ({ + instanceId: ProviderInstanceId.make(instanceId), + config, + })); + if (!Object.hasOwn(settings.providerInstances, source)) { + const legacyInstance = { + instanceId: ProviderInstanceId.make(source), + config: { + driver: ProviderDriverKind.make(source), + config: settings.providers[source], + }, + }; + if (resolveProviderInstanceEnabled(legacyInstance.config)) { + instances.push(legacyInstance); + } + } + + // A shared home contains one copy of each session. Prefer the built-in + // instance as its owner, then keep configured order for custom accounts. + instances.sort((left, right) => { + const leftDefault = left.instanceId === source ? 0 : 1; + const rightDefault = right.instanceId === source ? 0 : 1; + return leftDefault - rightDefault; + }); + const homes: Array<{ homePath: string; providerInstanceId: ProviderInstanceId }> = []; + const seenHomes = new Set(); + for (const { instanceId, config: instance } of instances) { + const homeVariable = source === "claudeAgent" ? "CLAUDE_CONFIG_DIR" : "CODEX_HOME"; + const environmentHome = + instance.environment?.findLast((variable) => variable.name === homeVariable)?.value ?? + hostEnvironment[homeVariable]; + + let homePath: string; + if (source === "claudeAgent") { + const config = decodeClaudeSettings(instance.config ?? {}); + if (Option.isNone(config)) continue; + homePath = resolveClaudeConfigDir(config.value.homePath, environmentHome); + } else { + const config = decodeCodexSettings(instance.config ?? {}); + if (Option.isNone(config)) continue; + const codexSettings = + config.value.homePath.trim().length === 0 && + config.value.shadowHomePath.trim().length === 0 && + environmentHome?.trim() + ? { ...config.value, homePath: environmentHome } + : config.value; + const layout = yield* resolveCodexHomeLayout(codexSettings).pipe( + Effect.provideService(Path.Path, path), + ); + homePath = layout.sharedHomePath; + } + + const homeKey = `${source}\0${yield* directoryIdentity(homePath)}`; + if (seenHomes.has(homeKey)) continue; + seenHomes.add(homeKey); + homes.push({ homePath, providerInstanceId: instanceId }); + } + + const transcriptCandidates: Array = []; + const baseOperationBudget = Math.floor( + MAX_DISCOVERY_OPERATIONS_PER_SOURCE / Math.max(1, homes.length), + ); + const extraOperationBudgets = MAX_DISCOVERY_OPERATIONS_PER_SOURCE % Math.max(1, homes.length); + for (const [index, home] of homes.entries()) { + const operationBudget = baseOperationBudget + (index < extraOperationBudgets ? 1 : 0); + if (operationBudget === 0) { + truncated = true; + continue; + } + const discovered = yield* source === "claudeAgent" + ? discoverClaudeTranscripts(home.homePath, home.providerInstanceId, operationBudget) + : discoverCodexTranscripts(home.homePath, home.providerInstanceId, operationBudget); + truncated ||= discovered.truncated; + transcriptCandidates.push(...discovered.transcripts); + } + + transcriptCandidates.sort( + (left, right) => + right.mtimeMs - left.mtimeMs || left.filePath.localeCompare(right.filePath), + ); + if (transcriptCandidates.length > MAX_TRANSCRIPTS_PER_SOURCE) { + truncated = true; + } + // Give each account a turn before taking another file from the same home. + const selectedTranscripts = selectMetadataTranscripts(transcriptCandidates); + const metadataBudget: MetadataReadBudget = { + bytesRemaining: MAX_METADATA_BYTES_PER_SOURCE, + operationsRemaining: MAX_METADATA_OPERATIONS_PER_SOURCE, + recordsRemaining: MAX_METADATA_RECORDS_PER_SOURCE, + truncated: false, + }; + raw.push(...(yield* groupTranscriptsByCwd(source, selectedTranscripts, metadataBudget))); + truncated ||= metadataBudget.truncated; + } + + return { candidates: raw, truncated }; + }); + + let cachedCandidates: ReadonlyArray | null = null; + + const scan: AgentSessionScanner["Service"]["scan"] = Effect.gen(function* () { + const { candidates: raw, truncated } = yield* collectCandidates(); + cachedCandidates = raw; + + // Filesystem identity merges symlinks and case aliases without collapsing + // distinct case-sensitive directories. + const merged = new Map< + string, + { + path: string; + sources: Array; + threadCount: number; + lastActiveAtMs: number | null; + } + >(); + const directoryKeys = new Map(); + + for (const candidate of raw) { + const expanded = expandHomePath(candidate.cwd.trim()); + if (!path.isAbsolute(expanded)) continue; + const resolved = path.resolve(expanded); + if (isExcludedProjectPath(resolved)) continue; + let key = directoryKeys.get(resolved); + if (key === undefined) { + const stats = yield* statOption(resolved); + // Directories that no longer exist can't be imported. + if (Option.isNone(stats) || stats.value.type !== "Directory") { + directoryKeys.set(resolved, ""); + continue; + } + const realPath = yield* fileSystem + .realPath(resolved) + .pipe(Effect.orElseSucceed(() => resolved)); + // A symlink can point into the worktrees directory even when its own + // spelling doesn't; check again with links resolved. + if (isExcludedProjectPath(realPath)) { + key = ""; + } else { + key = yield* directoryIdentity(resolved, stats.value); + } + directoryKeys.set(resolved, key); + } + if (key === "") continue; + + const existing = merged.get(key); + if (!existing) { + merged.set(key, { + path: resolved, + sources: [candidate.source], + threadCount: candidate.threadCount, + lastActiveAtMs: candidate.lastActiveAtMs, + }); + continue; + } + if (!existing.sources.includes(candidate.source)) { + existing.sources.push(candidate.source); + } + existing.threadCount += candidate.threadCount; + existing.lastActiveAtMs = + existing.lastActiveAtMs === null || candidate.lastActiveAtMs === null + ? (existing.lastActiveAtMs ?? candidate.lastActiveAtMs) + : Math.max(existing.lastActiveAtMs, candidate.lastActiveAtMs); + } + + // Resolve persisted roots too. A project and a transcript can name + // different symlinks to the same directory. + const shellSnapshot = yield* projectionSnapshotQuery + .getShellSnapshot() + .pipe( + Effect.mapError( + (cause) => new AgentSessionScanError({ operation: "read-projects", cause }), + ), + ); + const importedProjectsByRoot = new Map(); + for (const project of shellSnapshot.projects) { + const projectRoot = path.resolve(expandHomePath(project.workspaceRoot)); + importedProjectsByRoot.set(normalizeProjectPathForComparison(projectRoot), project); + importedProjectsByRoot.set(yield* directoryIdentity(projectRoot), project); + } + + const candidates: Array = []; + for (const [key, entry] of merged.entries()) { + // Keep the path key for missing roots and use filesystem identity for + // aliases that resolve to the same directory. + const importedProject = + importedProjectsByRoot.get(normalizeProjectPathForComparison(entry.path)) ?? + importedProjectsByRoot.get(key); + const candidatePath = importedProject?.workspaceRoot ?? entry.path; + candidates.push({ + path: candidatePath, + title: path.basename(candidatePath) || candidatePath, + ...(importedProject === undefined ? {} : { projectId: importedProject.id }), + sources: entry.sources, + threadCount: entry.threadCount, + lastActiveAt: + entry.lastActiveAtMs === null + ? null + : DateTime.formatIso(DateTime.makeUnsafe(entry.lastActiveAtMs)), + alreadyImported: importedProject !== undefined, + }); + } + + // Newest first, undated candidates last. + candidates.sort((left, right) => { + if (left.lastActiveAt === right.lastActiveAt) return left.path.localeCompare(right.path); + if (left.lastActiveAt === null) return 1; + if (right.lastActiveAt === null) return -1; + return right.lastActiveAt.localeCompare(left.lastActiveAt); + }); + + return { + candidates, + scannedAt: DateTime.formatIso(yield* DateTime.now), + ...(truncated ? { truncated: true } : {}), + }; + }); + + const prepareRecentThreads = Effect.fn("AgentSessionScanner.prepareRecentThreads")(function* ( + workspaceRoot: string, + completedSources: ReadonlyArray, + ) { + const root = path.resolve(expandHomePath(workspaceRoot)); + const realRoot = yield* fileSystem.realPath(root).pipe(Effect.orElseSucceed(() => root)); + if (isExcludedProjectPath(root) || isExcludedProjectPath(realRoot)) return Stream.empty; + const rootIdentity = yield* directoryIdentity(root); + const nowMs = DateTime.toEpochMillis(yield* DateTime.now); + const cutoffMs = nowMs - RECENT_THREAD_WINDOW_MS; + + const candidates = cachedCandidates ?? (yield* collectCandidates()).candidates; + cachedCandidates = candidates; + + const eligibleTranscripts: Array<{ + readonly candidate: RawCandidate; + readonly transcript: RawCandidate["transcripts"][number] & { readonly mtimeMs: number }; + }> = []; + for (const candidate of candidates) { + const expanded = expandHomePath(candidate.cwd.trim()); + if (!path.isAbsolute(expanded)) continue; + const resolved = path.resolve(expanded); + if ((yield* directoryIdentity(resolved)) !== rootIdentity) continue; + + for (const transcript of candidate.transcripts) { + if ( + transcript.mtimeMs === null || + transcript.mtimeMs < cutoffMs || + transcript.mtimeMs > nowMs + ) { + continue; + } + eligibleTranscripts.push({ + candidate, + transcript: { ...transcript, mtimeMs: transcript.mtimeMs }, + }); + } + } + + eligibleTranscripts.sort((left, right) => { + if (left.transcript.mtimeMs !== right.transcript.mtimeMs) { + return right.transcript.mtimeMs - left.transcript.mtimeMs; + } + return left.transcript.filePath.localeCompare(right.transcript.filePath); + }); + + const completedByFile = Map.groupBy( + completedSources, + (source) => `${source.providerInstanceId}\0${source.filePath}`, + ); + const importedSessions = new Set(); + let bytesRemaining = MAX_IMPORT_BYTES; + let transcriptsRemaining = MAX_IMPORT_TRANSCRIPTS; + let recordsRemaining = MAX_IMPORT_RECORDS; + return Stream.fromIteratorSucceed(eligibleTranscripts.values(), 1).pipe( + Stream.mapEffect(({ candidate, transcript }) => + Effect.gen(function* () { + const completed = completedByFile.get( + `${candidate.providerInstanceId}\0${transcript.filePath}`, + ); + if ( + completed === undefined && + (transcriptsRemaining === 0 || bytesRemaining === 0 || recordsRemaining === 0) + ) { + return Option.some({ _tag: "Skipped" }); + } + const stats = yield* statOption(transcript.filePath); + if (Option.isNone(stats) || stats.value.type !== "File") { + return Option.some({ _tag: "Skipped" }); + } + const identity = transcriptIdentity(transcript.filePath, stats.value); + const completedSource = completed?.find( + (source) => + source.provider === candidate.source && sameTranscriptIdentity(source, identity), + ); + if (completedSource !== undefined) { + const sessionKey = `${completedSource.providerInstanceId}\0${completedSource.providerSessionId}`; + if (importedSessions.has(sessionKey)) return Option.none(); + importedSessions.add(sessionKey); + return Option.some({ + _tag: "AlreadyImported", + source: completedSource, + }); + } + if ( + transcriptsRemaining === 0 || + recordsRemaining === 0 || + identity.size > MAX_IMPORTED_TRANSCRIPT_BYTES || + identity.size > bytesRemaining + ) { + return Option.some({ _tag: "Skipped" }); + } + // Reserve the whole file even if its read or parse fails. + transcriptsRemaining -= 1; + bytesRemaining -= identity.size; + const contents = yield* readTranscript(transcript.filePath, identity); + if (contents === null) { + return Option.some({ _tag: "Skipped" }); + } + const lines = splitTranscriptRecords(contents, recordsRemaining + 1); + if (lines.length > recordsRemaining) { + return Option.some({ _tag: "Skipped" }); + } + recordsRemaining -= lines.length; + + // A stable replacement file can belong to a different project than the cached candidate. + let snapshotCwd: string | null = null; + for (const line of lines) { + snapshotCwd = extractCwd(line); + if (snapshotCwd !== null) break; + } + if (snapshotCwd === null) { + return Option.some({ _tag: "Skipped" }); + } + const expandedCwd = expandHomePath(snapshotCwd.trim()); + if ( + !path.isAbsolute(expandedCwd) || + (yield* directoryIdentity(path.resolve(expandedCwd))) !== rootIdentity + ) { + return Option.some({ _tag: "Skipped" }); + } + + const parsedThread = parseAgentSessionTranscript( + { + contents, + source: candidate.source, + providerInstanceId: candidate.providerInstanceId, + fallbackSessionId: path.basename(transcript.filePath, ".jsonl"), + lastActiveAtMs: transcript.mtimeMs, + }, + lines, + ); + if (parsedThread === null) { + return Option.some({ _tag: "Skipped" }); + } + + const source: AgentSessionImportSource = { + ...identity, + provider: parsedThread.source, + providerInstanceId: parsedThread.providerInstanceId, + providerSessionId: parsedThread.providerSessionId, + }; + const sessionKey = `${parsedThread.providerInstanceId}\0${parsedThread.providerSessionId}`; + if (importedSessions.has(sessionKey)) { + return Option.some({ _tag: "Duplicate", source }); + } + importedSessions.add(sessionKey); + return Option.some({ + _tag: "Importable", + thread: parsedThread, + source, + }); + }), + ), + Stream.map(Option.toArray), + Stream.flattenIterable, + ); + }); + + const recentThreads: AgentSessionScanner["Service"]["recentThreads"] = ( + workspaceRoot, + completedSources = [], + ) => Stream.unwrap(prepareRecentThreads(workspaceRoot, completedSources)); + + return AgentSessionScanner.of({ scan, recentThreads }); +}); + +export const layer = Layer.effect(AgentSessionScanner, make); diff --git a/apps/server/src/project/ProjectLifecycleScriptRunner.test.ts b/apps/server/src/project/ProjectLifecycleScriptRunner.test.ts index 3db3ad293fe5..ee941cb48c43 100644 --- a/apps/server/src/project/ProjectLifecycleScriptRunner.test.ts +++ b/apps/server/src/project/ProjectLifecycleScriptRunner.test.ts @@ -92,6 +92,7 @@ const makeProjectionSnapshotQueryLayer = ( getProjectShellById: (projectId) => Effect.succeed(project && projectId === project.id ? Option.some(project) : Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.die("unused"), + getImportedAgentSessionSources: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.die("unused"), getFullThreadDiffContext: () => Effect.die("unused"), getThreadShellById: () => Effect.die("unused"), diff --git a/apps/server/src/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts index 3ab63972cbb6..ee35fecb9ca7 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts @@ -42,6 +42,7 @@ const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) => getProjectShellById: (projectId) => Effect.succeed(projectId === project.id ? Option.some(project) : Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.die("unused"), + getImportedAgentSessionSources: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.die("unused"), getFullThreadDiffContext: () => Effect.die("unused"), getThreadRuntimeContext: () => Effect.die("unused"), diff --git a/apps/server/src/provider/CodexDeveloperInstructions.ts b/apps/server/src/provider/CodexDeveloperInstructions.ts index 2bb9647bd414..1c2439a9ad9a 100644 --- a/apps/server/src/provider/CodexDeveloperInstructions.ts +++ b/apps/server/src/provider/CodexDeveloperInstructions.ts @@ -22,7 +22,7 @@ Do not switch to global browser skills, Chrome, Node REPL browser automation, st const browserToolInstructions = (browserToolsAvailable: boolean): string => browserToolsAvailable ? T3_CODE_BROWSER_TOOL_INSTRUCTIONS : ""; -export const codexPlanModeDeveloperInstructions = ( +const codexPlanModeDeveloperInstructions = ( browserToolsAvailable: boolean, ): string => `# Plan Mode (Conversational) @@ -155,7 +155,7 @@ If the user stays in Plan mode and asks for revisions after a prior \``; -export const codexDefaultModeDeveloperInstructions = ( +const codexDefaultModeDeveloperInstructions = ( browserToolsAvailable: boolean, ): string => `# Collaboration Mode: Default diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index 71aa501614a6..9f5fd05f5627 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -26,6 +26,7 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import { makeClaudeTextGeneration } from "../../textGeneration/ClaudeTextGeneration.ts"; import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; import { ServerConfig } from "../../config.ts"; +import { expandHomePath } from "../../pathExpansion.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { DirenvEnvironment } from "../DirenvEnvironment.ts"; import { ProviderDriverError } from "../Errors.ts"; @@ -122,7 +123,11 @@ export const ClaudeDriver: ProviderDriver = { driverKind: DRIVER_KIND, instanceId, }); - const effectiveConfig = { ...config, enabled } satisfies ClaudeSettings; + const effectiveConfig = { + ...config, + enabled, + binaryPath: expandHomePath(config.binaryPath), + } satisfies ClaudeSettings; const resolveMaintenance = yield* makeCachedProviderMaintenanceResolution( resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { binaryPath: effectiveConfig.binaryPath, diff --git a/apps/server/src/provider/Drivers/CodexDriver.test.ts b/apps/server/src/provider/Drivers/CodexDriver.test.ts index e449216a4959..e819dad8d588 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.test.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.test.ts @@ -8,7 +8,10 @@ import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; import { HttpClient } from "effect/unstable/http"; +import * as ChildProcess from "effect/unstable/process/ChildProcess"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; @@ -18,6 +21,11 @@ import { layerTest as codexResetCreditLayerTest } from "../Layers/codexResetCred import { NoOpProviderEventLoggers, ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; import * as ModelManifest from "../ModelManifest.ts"; import * as DirenvEnvironment from "../DirenvEnvironment.ts"; +import { + createProviderVersionAdvisory, + ProviderVersionCache, + resolveLatestProviderVersion, +} from "../providerMaintenance.ts"; import { CodexDriver } from "./CodexDriver.ts"; const testLayer = ServerConfig.layerTest(process.cwd(), { @@ -220,4 +228,147 @@ it.layer(testLayer)("CodexDriver", (it) => { ), ); } + + it.effect.each([ + { + name: "conventional shim", + dataRoot: "mise", + commandName: "codex", + version: "0.153.4", + nodeFirst: false, + }, + { + name: "custom data directory", + dataRoot: "custom-tool-data", + commandName: "codex", + version: "0.153.4", + nodeFirst: false, + }, + { + name: "renamed configured command", + dataRoot: "mise", + commandName: "custom-codex", + version: "0.153.4", + nodeFirst: false, + }, + { + name: "outdated provider", + dataRoot: "mise", + commandName: "codex", + version: "0.153.3", + nodeFirst: false, + }, + { + name: "npm before shim", + dataRoot: "mise", + commandName: "codex", + version: "0.153.4", + nodeFirst: true, + }, + ])( + "does not mistake Homebrew mise for Codex's installer: $name", + (fixture) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-codex-mise-shim-" }); + const brewPrefix = NodePath.join(tempDir, "homebrew"); + const brewPath = NodePath.join(brewPrefix, "bin", "brew"); + const misePath = NodePath.join(brewPrefix, "Cellar", "mise", "2026.9.1", "bin", "mise"); + const shimDir = NodePath.join(tempDir, fixture.dataRoot, "shims"); + const npmPrefix = NodePath.join(tempDir, "mise", "installs", "node", "24.13.0"); + const npmBin = NodePath.join(npmPrefix, "bin"); + const npmEntry = NodePath.join( + npmPrefix, + "lib", + "node_modules", + "@openai", + "codex", + "bin", + "codex.js", + ); + for (const file of [brewPath, misePath, npmEntry]) { + yield* fs.makeDirectory(NodePath.dirname(file), { recursive: true }); + yield* fs.writeFileString(file, "#!/bin/sh\n"); + yield* fs.chmod(file, 0o755); + } + yield* fs.makeDirectory(shimDir, { recursive: true }); + yield* fs.makeDirectory(npmBin, { recursive: true }); + yield* fs.symlink(misePath, NodePath.join(shimDir, fixture.commandName)); + yield* fs.symlink(npmEntry, NodePath.join(npmBin, fixture.commandName)); + const lookupPath = [ + ...(fixture.nodeFirst ? [npmBin, shimDir] : [shimDir, npmBin]), + NodePath.dirname(brewPath), + ].join(NodePath.delimiter); + const probes: Array> = []; + const metadataSpawner = ChildProcessSpawner.make((command) => { + if (!ChildProcess.isStandardCommand(command) || command.command !== brewPath) { + return Effect.die("Provider resolution must not execute a provider or updater"); + } + probes.push(command.args); + const stdout = + command.args[0] === "--prefix" + ? brewPrefix + : JSON.stringify({ formulae: [{ versions: { stable: "2026.9.1" } }] }); + return Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.encodeText(Stream.make(stdout)), + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }), + ); + }); + const instance = yield* CodexDriver.create({ + instanceId: ProviderInstanceId.make("codex-mise-shim"), + displayName: "Codex shim test", + enabled: false, + environment: [{ name: "PATH", value: lookupPath, sensitive: false }], + config: { + ...CodexDriver.defaultConfig(), + binaryPath: fixture.commandName, + homePath: NodePath.join(tempDir, "codex-home"), + }, + }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, metadataSpawner)); + const capabilities = yield* instance.snapshot.resolveMaintenance(); + const latestVersion = yield* resolveLatestProviderVersion(capabilities).pipe( + Effect.provideService( + ProviderVersionCache, + new Map([ + ["@openai/codex", { expiresAt: Number.MAX_SAFE_INTEGER, version: "0.153.4" }], + ]), + ), + ); + expect(probes).toEqual([]); + expect(latestVersion).toBe("0.153.4"); + expect( + createProviderVersionAdvisory({ + driver: CodexDriver.driverKind, + currentVersion: fixture.version, + latestVersion, + maintenanceCapabilities: capabilities, + }), + ).toMatchObject({ + status: fixture.version === "0.153.4" ? "current" : "behind_latest", + currentVersion: fixture.version, + latestVersion: "0.153.4", + canUpdate: fixture.nodeFirst, + }); + if (fixture.nodeFirst) { + expect(capabilities.update).toMatchObject({ + executable: "npm", + args: expect.arrayContaining(["--prefix", npmPrefix, "@openai/codex@latest"]), + }); + } else { + expect(capabilities.update).toBeNull(); + } + }).pipe(Effect.scoped), + { skip: windowsHost }, + ); }); diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index 95415199af71..1a29408cfef7 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -33,6 +33,7 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import { makeCodexTextGeneration } from "../../textGeneration/CodexTextGeneration.ts"; import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; import { ServerConfig } from "../../config.ts"; +import { expandHomePath } from "../../pathExpansion.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { DirenvEnvironment } from "../DirenvEnvironment.ts"; import { ProviderDriverError } from "../Errors.ts"; @@ -160,6 +161,7 @@ export const CodexDriver: ProviderDriver = { const effectiveConfig = { ...config, enabled, + binaryPath: expandHomePath(config.binaryPath), homePath: homeLayout.effectiveHomePath ?? "", } satisfies CodexSettings; const resolveMaintenance = yield* makeCachedProviderMaintenanceResolution( diff --git a/apps/server/src/provider/Drivers/GrokSkills.test.ts b/apps/server/src/provider/Drivers/GrokSkills.test.ts index 13415bc35de3..ce8a31b51985 100644 --- a/apps/server/src/provider/Drivers/GrokSkills.test.ts +++ b/apps/server/src/provider/Drivers/GrokSkills.test.ts @@ -1,141 +1,172 @@ import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { discoverGrokSkills, parseGrokInspectSkills } from "./GrokSkills.ts"; +import { discoverGrokSkills } from "./GrokSkills.ts"; const inspectPayload = (skills: ReadonlyArray) => JSON.stringify({ skills }); -describe("parseGrokInspectSkills", () => { - it("maps inspect entries onto provider skills, sorted by name", () => { - const skills = parseGrokInspectSkills( - inspectPayload([ - { - name: "writing-docs", - description: "Write user docs.", - source: { type: "user", path: "/home/dev/.grok/skills/writing-docs/SKILL.md" }, - userInvocable: true, - }, +const makeInspectSpawner = (stdout: string, exitCode = 0, spawnCwds?: Array) => + ChildProcessSpawner.make((command) => { + spawnCwds?.push(command._tag === "StandardCommand" ? command.options.cwd : undefined); + return Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(exitCode)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.encodeText(Stream.make(stdout)), + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }), + ); + }); + +describe("discoverGrokSkills", () => { + it.effect("maps inspect entries onto provider skills, sorted by name", () => + Effect.gen(function* () { + const skills = yield* discoverGrokSkills({ binaryPath: "grok" }, {}); + + expect(skills).toEqual([ { name: "deploy", description: "Deploy the app.", - source: { - type: "plugin", - path: "/home/dev/.grok/installed-plugins/pkg/plug/skills/deploy/SKILL.md", - }, - userInvocable: true, + path: "/home/dev/.grok/installed-plugins/pkg/plug/skills/deploy/SKILL.md", + scope: "plugin", + enabled: true, }, - ]), - ); + { + name: "writing-docs", + description: "Write user docs.", + path: "/home/dev/.grok/skills/writing-docs/SKILL.md", + scope: "user", + enabled: true, + }, + ]); + }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + makeInspectSpawner( + inspectPayload([ + { + name: "writing-docs", + description: "Write user docs.", + source: { type: "user", path: "/home/dev/.grok/skills/writing-docs/SKILL.md" }, + userInvocable: true, + }, + { + name: "deploy", + description: "Deploy the app.", + source: { + type: "plugin", + path: "/home/dev/.grok/installed-plugins/pkg/plug/skills/deploy/SKILL.md", + }, + userInvocable: true, + }, + ]), + ), + ), + ), + ); - expect(skills).toEqual([ - { - name: "deploy", - description: "Deploy the app.", - path: "/home/dev/.grok/installed-plugins/pkg/plug/skills/deploy/SKILL.md", - scope: "plugin", - enabled: true, - }, - { - name: "writing-docs", - description: "Write user docs.", - path: "/home/dev/.grok/skills/writing-docs/SKILL.md", - scope: "user", - enabled: true, - }, - ]); - }); + it.effect("disables skills the CLI marks as not user-invocable", () => + Effect.gen(function* () { + const skills = yield* discoverGrokSkills({ binaryPath: "grok" }, {}); - it("disables skills the CLI marks as not user-invocable", () => { - const skills = parseGrokInspectSkills( - inspectPayload([ + expect(skills).toEqual([ { name: "internal-helper", - source: { type: "bundled", path: "/opt/grok/bundled/skills/internal-helper/SKILL.md" }, - userInvocable: false, + path: "/opt/grok/bundled/skills/internal-helper/SKILL.md", + scope: "bundled", + enabled: false, }, - ]), - ); - - expect(skills).toEqual([ - { - name: "internal-helper", - path: "/opt/grok/bundled/skills/internal-helper/SKILL.md", - scope: "bundled", - enabled: false, - }, - ]); - }); - - it("skips entries without a name or a filesystem path", () => { - const skills = parseGrokInspectSkills( - inspectPayload([ - { name: " ", source: { type: "user", path: "/tmp/skills/a/SKILL.md" } }, - { name: "no-path", source: { type: "user" } }, - { name: "no-source" }, - "not-an-object", - { name: "kept", source: { type: "project", path: "/repo/.grok/skills/kept/SKILL.md" } }, - ]), - ); + ]); + }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + makeInspectSpawner( + inspectPayload([ + { + name: "internal-helper", + source: { + type: "bundled", + path: "/opt/grok/bundled/skills/internal-helper/SKILL.md", + }, + userInvocable: false, + }, + ]), + ), + ), + ), + ); - expect(skills.map((skill) => skill.name)).toEqual(["kept"]); - }); + it.effect("skips entries without a name or a filesystem path", () => + Effect.gen(function* () { + const skills = yield* discoverGrokSkills({ binaryPath: "grok" }, {}); + expect(skills.map((skill) => skill.name)).toEqual(["kept"]); + }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + makeInspectSpawner( + inspectPayload([ + { name: " ", source: { type: "user", path: "/tmp/skills/a/SKILL.md" } }, + { name: "no-path", source: { type: "user" } }, + { name: "no-source" }, + "not-an-object", + { name: "kept", source: { type: "project", path: "/repo/.grok/skills/kept/SKILL.md" } }, + ]), + ), + ), + ), + ); - it("returns an empty list for malformed or unexpected output", () => { - expect(parseGrokInspectSkills("not json")).toEqual([]); - expect(parseGrokInspectSkills("null")).toEqual([]); - expect(parseGrokInspectSkills(JSON.stringify({ skills: "nope" }))).toEqual([]); - expect(parseGrokInspectSkills(JSON.stringify({}))).toEqual([]); - }); -}); + it.effect("rejects malformed or unexpected output as a decode failure", () => + Effect.gen(function* () { + for (const stdout of ["not json", "null", '{"skills":"nope"}', "{}"]) { + const error = yield* discoverGrokSkills({ binaryPath: "grok" }, {}).pipe( + Effect.flip, + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + makeInspectSpawner(stdout), + ), + ); + expect(error).toMatchObject({ _tag: "GrokSkillsProbeError", stage: "decode" }); + } + }), + ); -describe("discoverGrokSkills", () => { it.effect("spawns in the configured cwd and rejects a failed probe", () => { const spawnCwds: Array = []; - let exitCode = 0; - const spawner = ChildProcessSpawner.make((command) => { - spawnCwds.push(command._tag === "StandardCommand" ? command.options.cwd : undefined); - return Effect.succeed( - ChildProcessSpawner.makeHandle({ - pid: ChildProcessSpawner.ProcessId(1), - exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(exitCode)), - isRunning: Effect.succeed(false), - kill: () => Effect.void, - unref: Effect.succeed(Effect.void), - stdin: Sink.drain, - stdout: Stream.encodeText( - Stream.make( - inspectPayload([ - { - name: "kept", - source: { type: "project", path: "/workspaces/demo/.grok/skills/kept/SKILL.md" }, - }, - ]), - ), - ), - stderr: Stream.empty, - all: Stream.empty, - getInputFd: () => Sink.drain, - getOutputFd: () => Stream.empty, - }), - ); - }); + const stdout = inspectPayload([ + { + name: "kept", + source: { type: "project", path: "/workspaces/demo/.grok/skills/kept/SKILL.md" }, + }, + ]); return Effect.gen(function* () { const skills = yield* discoverGrokSkills({ binaryPath: "grok" }, {}, "/workspaces/demo").pipe( - Effect.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)), + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + makeInspectSpawner(stdout, 0, spawnCwds), + ), ); expect(spawnCwds).toEqual(["/workspaces/demo"]); expect(skills.map((skill) => skill.name)).toEqual(["kept"]); - exitCode = 1; const failed = yield* discoverGrokSkills({ binaryPath: "grok" }).pipe( Effect.result, - Effect.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)), + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + makeInspectSpawner(stdout, 1), + ), ); expect(failed._tag).toBe("Failure"); }); diff --git a/apps/server/src/provider/Drivers/GrokSkills.ts b/apps/server/src/provider/Drivers/GrokSkills.ts index b7962205d346..53a20049ad5d 100644 --- a/apps/server/src/provider/Drivers/GrokSkills.ts +++ b/apps/server/src/provider/Drivers/GrokSkills.ts @@ -91,10 +91,6 @@ function decodeGrokInspectSkills(stdout: string): ReadonlyArray left.name.localeCompare(right.name)); } -export function parseGrokInspectSkills(stdout: string): ReadonlyArray { - return decodeGrokInspectSkills(stdout) ?? []; -} - /** * Run `grok inspect --json` and map the reported catalog onto provider * skills. Callers that need best-effort discovery can recover this effect to diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index bba4054ea9ec..96b1cb9c0348 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -229,6 +229,7 @@ function makeScopedRuntimeFactory(options?: { readonly failConstruction?: boolea const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory, { upsert: () => Effect.void, + recordImportedTranscript: () => Effect.die("unused"), getProvider: () => Effect.die(new Error("ProviderSessionDirectory.getProvider is not used in test")), getBinding: () => Effect.succeed(Option.none()), diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts index 10086d8aa4a9..cfaeef431e84 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts @@ -9,11 +9,7 @@ import * as CodexErrors from "effect-codex-app-server/errors"; import * as CodexRpc from "effect-codex-app-server/rpc"; import * as EffectCodexSchema from "effect-codex-app-server/schema"; -import { - buildCodexDeveloperInstructions, - codexDefaultModeDeveloperInstructions, - codexPlanModeDeveloperInstructions, -} from "../CodexDeveloperInstructions.ts"; +import { buildCodexDeveloperInstructions } from "../CodexDeveloperInstructions.ts"; import { codexSessionAppServerArgs } from "./codexLaunchArgs.ts"; import { buildTurnStartParams, @@ -459,7 +455,7 @@ describe("buildCodexDeveloperInstructions", () => { reasoningEffort: "high", }); - NodeAssert.ok(instructions.startsWith(codexDefaultModeDeveloperInstructions(true))); + NodeAssert.match(instructions, /^# Collaboration Mode: Default/); NodeAssert.match(instructions, /T3 Code/); NodeAssert.match(instructions, /Codex harness/); NodeAssert.match(instructions, /as gpt-5\.3-codex with high reasoning effort/); @@ -484,7 +480,7 @@ describe("buildCodexDeveloperInstructions", () => { reasoningEffort: "medium", }); - NodeAssert.ok(instructions.startsWith(codexPlanModeDeveloperInstructions(true))); + NodeAssert.match(instructions, /^# Plan Mode/); NodeAssert.match(instructions, /as gpt-5\.3-codex with medium reasoning effort/); }); @@ -513,11 +509,11 @@ describe("buildCodexDeveloperInstructions", () => { }); describe("T3 browser developer instructions", () => { + const runtime = { model: "gpt-5.3-codex", reasoningEffort: "high" }; + it("prefers the product-native preview tools in both collaboration modes", () => { - for (const instructions of [ - codexDefaultModeDeveloperInstructions(true), - codexPlanModeDeveloperInstructions(true), - ]) { + for (const mode of ["default", "plan"] as const) { + const instructions = buildCodexDeveloperInstructions(mode, runtime, true); NodeAssert.match(instructions, /t3-code/); NodeAssert.match(instructions, /preview_status/); NodeAssert.match(instructions, /preview_open/); @@ -526,10 +522,8 @@ describe("T3 browser developer instructions", () => { }); it("omits the browser block entirely when the preview tools are not attached", () => { - for (const instructions of [ - codexDefaultModeDeveloperInstructions(false), - codexPlanModeDeveloperInstructions(false), - ]) { + for (const mode of ["default", "plan"] as const) { + const instructions = buildCodexDeveloperInstructions(mode, runtime, false); NodeAssert.doesNotMatch(instructions, /preview_status/); NodeAssert.doesNotMatch(instructions, /preview_open/); NodeAssert.doesNotMatch(instructions, /T3 Code collaborative browser/); @@ -543,7 +537,6 @@ describe("T3 browser developer instructions", () => { }); it("tracks the turn's MCP configuration rather than defaulting to on", () => { - const runtime = { model: "gpt-5.3-codex", reasoningEffort: "high" }; NodeAssert.match(buildCodexDeveloperInstructions("default", runtime, true), /preview_open/); NodeAssert.doesNotMatch( buildCodexDeveloperInstructions("default", runtime, false), diff --git a/apps/server/src/provider/Layers/CursorProvider.test.ts b/apps/server/src/provider/Layers/CursorProvider.test.ts index 78edd8acbd45..4fa382c788d5 100644 --- a/apps/server/src/provider/Layers/CursorProvider.test.ts +++ b/apps/server/src/provider/Layers/CursorProvider.test.ts @@ -16,7 +16,6 @@ import { buildCursorCapabilitiesFromConfigOptions, checkCursorProviderStatus, discoverCursorModelsViaAcp, - getCursorFallbackModels, getCursorParameterizedModelPickerUnsupportedMessage, parseCursorAboutOutput, parseCursorCliConfigChannel, @@ -475,16 +474,6 @@ describe("Cursor skills", () => { }); }); -describe("getCursorFallbackModels", () => { - it("does not publish any built-in cursor models before ACP discovery", () => { - expect( - getCursorFallbackModels({ - customModels: ["internal/cursor-model"], - }).map((model) => model.slug), - ).toEqual(["internal/cursor-model"]); - }); -}); - describe("buildCursorProviderSnapshot", () => { it("downgrades ready status to warning when ACP model discovery times out", () => { expect( diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index 21794597327e..80668749201e 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -573,7 +573,7 @@ export const discoverCursorModelsViaAcp = ( environment?: NodeJS.ProcessEnv, ) => discoverCursorModelsViaListAvailableModels(cursorSettings, environment); -export function getCursorFallbackModels( +function getCursorFallbackModels( cursorSettings: Pick, ): ReadonlyArray { return providerModelsFromSettings([], cursorSettings.customModels, EMPTY_CAPABILITIES); diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 09276e46c6bb..33bd21aa893b 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -44,7 +44,6 @@ import { type OpenCodeRuntimeShape, } from "../opencodeRuntime.ts"; import { - appendOpenCodeAssistantTextDelta, isOpenCodeNotFound, isSameOpenCodeDirectory, makeOpenCodeAdapter, @@ -571,6 +570,7 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory, { upsert: () => Effect.void, + recordImportedTranscript: () => Effect.die("unused"), getProvider: () => Effect.die(new Error("ProviderSessionDirectory.getProvider is not used in test")), getBinding: () => Effect.succeed(Option.none()), @@ -6570,20 +6570,17 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }).pipe(Effect.scoped), ); - it.effect("appends raw assistant text deltas and reconciles part update snapshots", () => + it.effect("reconciles assistant text snapshots", () => Effect.sync(() => { const firstUpdate = mergeOpenCodeAssistantText(undefined, "Hello"); - const overlapDelta = appendOpenCodeAssistantTextDelta(firstUpdate.latestText, "lo world"); - const secondUpdate = mergeOpenCodeAssistantText(overlapDelta.nextText, "Hellolo world"); const appendedUpdate = mergeOpenCodeAssistantText("Hello", "Hello world"); const changedUpdate = mergeOpenCodeAssistantText("Hello world", "Hello there"); const staleUpdate = mergeOpenCodeAssistantText("Hello world", "Hello"); - NodeAssert.deepEqual( - [firstUpdate.deltaToEmit, overlapDelta.deltaToEmit, secondUpdate.deltaToEmit], - ["Hello", "lo world", ""], - ); - NodeAssert.equal(secondUpdate.latestText, "Hellolo world"); + NodeAssert.deepEqual(firstUpdate, { + latestText: "Hello", + deltaToEmit: "Hello", + }); NodeAssert.deepEqual(appendedUpdate, { latestText: "Hello world", deltaToEmit: " world", diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index eb392f051902..1546bcfc964b 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -583,7 +583,7 @@ export function mergeOpenCodeAssistantText( }; } -export function appendOpenCodeAssistantTextDelta( +function appendOpenCodeAssistantTextDelta( previousText: string, delta: string, ): { diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts index 5d5500e71aab..9c212c83b94d 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts @@ -24,7 +24,6 @@ */ import { describe, expect, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import * as Path from "effect/Path"; import { type ClaudeSettings, type CodexSettings, @@ -35,9 +34,12 @@ import { type ProviderInstanceConfigMap, ProviderInstanceId, } from "@t3tools/contracts"; +import { isHostWindows } from "@t3tools/shared/hostProcess"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; import * as Stream from "effect/Stream"; import { HttpClient, HttpClientResponse } from "effect/unstable/http"; @@ -45,6 +47,7 @@ import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; import type { BuiltInDriversEnv } from "../builtInDrivers.ts"; import { AntigravityInstallation } from "../AntigravityInstallation.ts"; import { ServerConfig } from "../../config.ts"; +import { expandHomePath } from "../../pathExpansion.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { ClaudeDriver } from "../Drivers/ClaudeDriver.ts"; import { CodexDriver } from "../Drivers/CodexDriver.ts"; @@ -140,6 +143,80 @@ const makeOpenCodeConfig = (overrides: Partial): OpenCodeSetti ...overrides, }); +const makeTildeProviderFixtures = Effect.fn( + "ProviderInstanceRegistryLive.test.makeTildeProviderFixtures", +)(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const homePath = expandHomePath("~"); + const fixtureDir = yield* fileSystem.makeTempDirectoryScoped({ + directory: homePath, + prefix: ".t3-provider-path-test-", + }); + const codexPath = path.join(fixtureDir, "codex"); + const claudePath = path.join(fixtureDir, "claude"); + const claudeHomePath = path.join(fixtureDir, "claude-home"); + const codexScriptPath = path.join(fixtureDir, "codex-script.json"); + const codexFixtureDir = path.join(import.meta.dirname, "../testFixtures"); + + yield* fileSystem.copyFile(path.join(codexFixtureDir, "codexCollabMockPeer.sh"), codexPath); + yield* fileSystem.copyFile( + path.join(codexFixtureDir, "codexCollabMockPeer.mjs"), + path.join(fixtureDir, "codexCollabMockPeer.mjs"), + ); + yield* fileSystem.copyFile( + path.join(codexFixtureDir, "codexMultiAgentWire.json"), + path.join(fixtureDir, "codexMultiAgentWire.json"), + ); + yield* fileSystem.writeFileString( + codexScriptPath, + // @effect-diagnostics-next-line preferSchemaOverJson:off - fixed script document read by the external Codex mock peer. + JSON.stringify({ rootThreadId: "probe-thread", notifications: [] }), + ); + yield* fileSystem.chmod(codexPath, 0o755); + + yield* fileSystem.writeFileString( + claudePath, + [ + "#!/usr/bin/env node", + 'import * as NodeReadline from "node:readline";', + 'if (process.argv.includes("--version")) {', + ' process.stdout.write("claude 2.1.219\\n");', + " process.exit(0);", + "}", + "const lines = NodeReadline.createInterface({ input: process.stdin });", + 'lines.on("line", (line) => {', + " const message = JSON.parse(line);", + ' if (message.type !== "control_request" || message.request?.subtype !== "initialize") return;', + " process.stdout.write(JSON.stringify({", + ' type: "control_response",', + " response: {", + ' subtype: "success",', + " request_id: message.request_id,", + " response: {", + " commands: [], agents: [], models: [],", + ' output_style: "default", available_output_styles: ["default"],', + ' account: { email: "test@example.com", subscriptionType: "pro", tokenSource: "oauth" },', + " },", + " },", + ' }) + "\\n");', + "});", + "setInterval(() => {}, 1_000);", + "", + ].join("\n"), + ); + yield* fileSystem.chmod(claudePath, 0o755); + yield* fileSystem.makeDirectory(claudeHomePath); + + const asTildePath = (filePath: string) => `~/${path.relative(homePath, filePath)}`; + return { + codexBinaryPath: asTildePath(codexPath), + claudeBinaryPath: asTildePath(claudePath), + claudeHomePath, + codexScriptPath, + }; +}); + describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => { // `ServerConfig.layerTest` needs `FileSystem` to materialize its scratch // directory. `Layer.merge` just unions requirements, so we have to push @@ -263,6 +340,60 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => { }).pipe(Effect.provide(testLayer)), ); + it.live("runs Codex and Claude readiness probes from configured tilde paths", () => + Effect.gen(function* () { + if (yield* isHostWindows) return; + + const fixtures = yield* makeTildeProviderFixtures(); + + const codexId = ProviderInstanceId.make("codex_tilde"); + const claudeId = ProviderInstanceId.make("claude_tilde"); + const configMap: ProviderInstanceConfigMap = { + [codexId]: { + driver: ProviderDriverKind.make("codex"), + enabled: true, + environment: [ + { + name: "T3_CODEX_COLLAB_SCRIPT", + value: fixtures.codexScriptPath, + sensitive: false, + }, + ], + config: makeCodexConfig({ enabled: true, binaryPath: fixtures.codexBinaryPath }), + }, + [claudeId]: { + driver: ProviderDriverKind.make("claudeAgent"), + enabled: true, + config: makeClaudeConfig({ + enabled: true, + binaryPath: fixtures.claudeBinaryPath, + homePath: fixtures.claudeHomePath, + }), + }, + }; + + const { registry } = yield* makeProviderInstanceRegistry({ + drivers: [CodexDriver, ClaudeDriver], + configMap, + }); + const codex = yield* registry.getInstance(codexId); + const claude = yield* registry.getInstance(claudeId); + expect(codex).toBeDefined(); + expect(claude).toBeDefined(); + + const [codexSnapshot, claudeSnapshot] = yield* Effect.all( + [codex!.snapshot.refresh, claude!.snapshot.refresh], + { concurrency: "unbounded" }, + ); + expect(codexSnapshot).toMatchObject({ status: "ready", installed: true, version: "0.0.0" }); + expect(claudeSnapshot).toMatchObject({ + status: "ready", + installed: true, + version: "2.1.219", + }); + }).pipe(Effect.provide(testLayer)), + ); + it.live( "shadows instances whose driver is not registered in this build without failing boot", () => diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index f30dd1c05319..7377c7d7dcc4 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -59,8 +59,8 @@ import { COMPACT_SLASH_COMMAND } from "../providerSnapshot.ts"; import type { ProviderInstance } from "../ProviderDriver.ts"; import * as ProviderInstanceRegistry from "../Services/ProviderInstanceRegistry.ts"; import * as ProviderRegistry from "../Services/ProviderRegistry.ts"; -import * as DirenvEnvironment from "../DirenvEnvironment.ts"; import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; +import * as DirenvEnvironment from "../DirenvEnvironment.ts"; const decodeServerSettings = Schema.decodeSync(ServerSettings); const encodeServerSettings = Schema.encodeSync(ServerSettings); const encodedDefaultServerSettings = encodeServerSettings(DEFAULT_SERVER_SETTINGS); diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index f1ad5c053128..900ab1158c3c 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -4543,6 +4543,7 @@ const getBinding = vi.fn((threadId: ThreadId) => const boundedListing = makeProviderServiceLayer({ directory: { upsert: () => Effect.void, + recordImportedTranscript: () => Effect.die("unused"), getProvider: () => Effect.die("ProviderService.listSessions does not use getProvider"), getBinding, listThreadIds, diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts index 079b7f10ebfd..8b41bd3e518c 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts @@ -4,9 +4,13 @@ import * as NodeOS from "node:os"; import * as NodePath from "node:path"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { ProviderDriverKind, ThreadId } from "@t3tools/contracts"; -import { it, assert } from "@effect/vitest"; -import { assertSome } from "@effect/vitest/utils"; +import { + ProviderDriverKind, + ProviderInstanceId, + ThreadId, + type AgentSessionImportSource, +} from "@t3tools/contracts"; +import { assert, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -20,9 +24,22 @@ import * as ProviderSessionRuntime from "../../persistence/ProviderSessionRuntim import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; import { ProviderSessionDirectoryLive } from "./ProviderSessionDirectory.ts"; +const importedSource = { + provider: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + providerSessionId: "provider-session", + filePath: "/tmp/provider-session.jsonl", + size: 100, + mtimeMs: 1_000, + device: 1, + inode: 123, + birthtimeMs: 500, +} satisfies AgentSessionImportSource; + function makeDirectoryLayer(persistenceLayer: Layer.Layer) { const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe(Layer.provide(persistenceLayer)); return Layer.mergeAll( + persistenceLayer, runtimeRepositoryLayer, ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)), NodeServices.layer, @@ -30,7 +47,7 @@ function makeDirectoryLayer(persistenceLayer: Layer.Layer { - it("upserts and reads thread bindings", () => + it.effect("upserts and reads thread bindings", () => Effect.gen(function* () { const directory = yield* ProviderSessionDirectory; const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; @@ -39,13 +56,14 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL yield* directory.upsert({ provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), threadId: initialThreadId, }); const provider = yield* directory.getProvider(initialThreadId); assert.equal(provider, "codex"); const resolvedBinding = yield* directory.getBinding(initialThreadId); - assertSome(resolvedBinding, { + expect(Option.getOrThrow(resolvedBinding)).toMatchObject({ threadId: initialThreadId, provider: ProviderDriverKind.make("codex"), }); @@ -57,6 +75,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL yield* directory.upsert({ provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), threadId: nextThreadId, }); const updatedBinding = yield* directory.getBinding(nextThreadId); @@ -74,10 +93,11 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL } const threadIds = yield* directory.listThreadIds(); - assert.deepEqual(threadIds, [nextThreadId]); - })); + expect(threadIds).toEqual(expect.arrayContaining([initialThreadId, nextThreadId])); + }), + ); - it("persists runtime fields and merges payload updates", () => + it.effect("persists runtime fields and merges payload updates", () => Effect.gen(function* () { const directory = yield* ProviderSessionDirectory; const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; @@ -86,6 +106,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL yield* directory.upsert({ provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), threadId, status: "starting", resumeCursor: { @@ -99,6 +120,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL yield* directory.upsert({ provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), threadId, status: "running", runtimePayload: { @@ -120,9 +142,158 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL activeTurnId: "turn-1", }); } - })); + }), + ); + + it.effect("keeps the existing binding when an insert conflicts", () => + Effect.gen(function* () { + const directory = yield* ProviderSessionDirectory; + const threadId = ThreadId.make("thread-insert-conflict"); + + yield* directory.upsert({ + provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + threadId, + status: "running", + resumeCursor: { threadId: "active-provider-thread" }, + }); + + yield* directory.upsert( + { + provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + threadId, + status: "stopped", + resumeCursor: { threadId: "stale-provider-thread" }, + }, + { onConflict: "ignore" }, + ); + + const binding = yield* directory.getBinding(threadId); + expect(Option.getOrThrow(binding)).toMatchObject({ + threadId, + status: "running", + resumeCursor: { threadId: "active-provider-thread" }, + }); + }), + ); + + it.effect("records source files without replacing the current provider session", () => + Effect.gen(function* () { + const directory = yield* ProviderSessionDirectory; + const repository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; + const source = { ...importedSource, providerSessionId: "record-source" }; + const threadId = ThreadId.make( + `import:${source.providerInstanceId}:${source.providerSessionId}`, + ); + const runtimePayload = { cwd: "/tmp/project", activeTurnId: "active-turn" }; + yield* directory.upsert({ + threadId, + provider: ProviderDriverKind.make("claudeAgent"), + providerInstanceId: ProviderInstanceId.make("claude-current"), + status: "running", + resumeCursor: { resume: "current-native-session" }, + runtimePayload, + }); + const before = Option.getOrThrow(yield* repository.getByThreadId({ threadId })); + + yield* directory.recordImportedTranscript({ threadId, source }); + const replacement = { ...source, size: 200, mtimeMs: 2_000 }; + yield* directory.recordImportedTranscript({ threadId, source: replacement }); + const secondFile = { ...source, filePath: "/tmp/provider-session-copy.jsonl" }; + yield* directory.recordImportedTranscript({ threadId, source: secondFile }); + + expect(Option.getOrThrow(yield* repository.getByThreadId({ threadId }))).toEqual({ + ...before, + runtimePayload: { ...runtimePayload, importedTranscripts: [replacement, secondFile] }, + }); + }), + ); + + it.effect("does not create a binding when recording an imported transcript", () => + Effect.gen(function* () { + const directory = yield* ProviderSessionDirectory; + const threadId = ThreadId.make("import:codex:missing-source-binding"); + + yield* directory.recordImportedTranscript({ threadId, source: importedSource }); + + expect(Option.isNone(yield* directory.getBinding(threadId))).toBe(true); + }), + ); + + it.effect("keeps newly recorded sources when a runtime write uses a stale payload", () => + Effect.gen(function* () { + const directory = yield* ProviderSessionDirectory; + const repository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; + const firstSource = { ...importedSource, providerSessionId: "stale-source" }; + const threadId = ThreadId.make( + `import:${firstSource.providerInstanceId}:${firstSource.providerSessionId}`, + ); + yield* directory.upsert({ + threadId, + provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + status: "stopped", + resumeCursor: { threadId: "original-native-session" }, + runtimePayload: { cwd: "/tmp/stale-source-project" }, + }); + yield* directory.recordImportedTranscript({ threadId, source: firstSource }); + const stale = Option.getOrThrow(yield* repository.getByThreadId({ threadId })); + const secondSource = { ...firstSource, filePath: "/tmp/stale-source-copy.jsonl" }; + yield* directory.recordImportedTranscript({ threadId, source: secondSource }); + + yield* repository.upsert({ + ...stale, + status: "running", + resumeCursor: { threadId: "new-native-session" }, + lastSeenAt: "2026-08-24T10:00:00.000Z", + }); + + expect(Option.getOrThrow(yield* repository.getByThreadId({ threadId }))).toEqual({ + ...stale, + status: "running", + resumeCursor: { threadId: "new-native-session" }, + lastSeenAt: "2026-08-24T10:00:00.000Z", + runtimePayload: { + cwd: "/tmp/stale-source-project", + importedTranscripts: [firstSource, secondSource], + }, + }); + }), + ); + + it.effect("reserves imported source records for the atomic recording method", () => + Effect.gen(function* () { + const directory = yield* ProviderSessionDirectory; + for (const onConflict of ["update", "ignore"] as const) { + const source = { ...importedSource, providerSessionId: `reserved-source-${onConflict}` }; + const threadId = ThreadId.make( + `import:${source.providerInstanceId}:${source.providerSessionId}`, + ); + const binding = { + threadId, + provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + }; + yield* directory.upsert( + { ...binding, runtimePayload: { cwd: "/tmp/project", importedTranscripts: [source] } }, + { onConflict }, + ); + expect(Option.getOrThrow(yield* directory.getBinding(threadId)).runtimePayload).toEqual({ + cwd: "/tmp/project", + }); + + yield* directory.recordImportedTranscript({ threadId, source }); + yield* directory.upsert({ ...binding, runtimePayload: null }); + + expect(Option.getOrThrow(yield* directory.getBinding(threadId)).runtimePayload).toEqual({ + importedTranscripts: [source], + }); + } + }), + ); - it("lists persisted bindings with metadata in oldest-first order", () => + it.effect("lists persisted bindings with metadata in oldest-first order", () => Effect.gen(function* () { const directory = yield* ProviderSessionDirectory; const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; @@ -162,12 +333,15 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL }, }); - const bindings = yield* directory.listBindings(); + const bindings = (yield* directory.listBindings()).filter( + (binding) => binding.threadId === olderThreadId || binding.threadId === newerThreadId, + ); assert.deepEqual(bindings, [ { threadId: olderThreadId, provider: ProviderDriverKind.make("claudeAgent"), + providerInstanceId: ProviderInstanceId.make("claudeAgent"), adapterKey: "claudeAgent", runtimeMode: "approval-required", status: "starting", @@ -182,6 +356,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL { threadId: newerThreadId, provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), adapterKey: "codex", runtimeMode: "full-access", status: "running", @@ -194,40 +369,45 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL }, }, ]); - })); + }), + ); - it("resets adapterKey to the new provider when provider changes without an explicit adapter key", () => - Effect.gen(function* () { - const directory = yield* ProviderSessionDirectory; - const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; - const threadId = ThreadId.make("thread-provider-change"); + it.effect( + "resets adapterKey to the new provider when provider changes without an explicit adapter key", + () => + Effect.gen(function* () { + const directory = yield* ProviderSessionDirectory; + const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; + const threadId = ThreadId.make("thread-provider-change"); - yield* runtimeRepository.upsert({ - threadId, - providerName: "claudeAgent", - providerInstanceId: null, - adapterKey: "claudeAgent", - runtimeMode: "full-access", - status: "running", - lastSeenAt: "2026-01-01T00:00:00.000Z", - resumeCursor: null, - runtimePayload: null, - }); + yield* runtimeRepository.upsert({ + threadId, + providerName: "claudeAgent", + providerInstanceId: null, + adapterKey: "claudeAgent", + runtimeMode: "full-access", + status: "running", + lastSeenAt: "2026-01-01T00:00:00.000Z", + resumeCursor: null, + runtimePayload: null, + }); - yield* directory.upsert({ - provider: ProviderDriverKind.make("codex"), - threadId, - }); + yield* directory.upsert({ + provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + threadId, + }); - const runtime = yield* runtimeRepository.getByThreadId({ threadId }); - assert.equal(Option.isSome(runtime), true); - if (Option.isSome(runtime)) { - assert.equal(runtime.value.providerName, "codex"); - assert.equal(runtime.value.adapterKey, "codex"); - } - })); + const runtime = yield* runtimeRepository.getByThreadId({ threadId }); + assert.equal(Option.isSome(runtime), true); + if (Option.isSome(runtime)) { + assert.equal(runtime.value.providerName, "codex"); + assert.equal(runtime.value.adapterKey, "codex"); + } + }), + ); - it("rehydrates persisted mappings across layer restart", () => + it.effect("rehydrates persisted mappings across layer restart", () => Effect.gen(function* () { const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-directory-")); const dbPath = NodePath.join(tempDir, "orchestration.sqlite"); @@ -239,6 +419,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL const directory = yield* ProviderSessionDirectory; yield* directory.upsert({ provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), threadId, }); }).pipe(Effect.provide(directoryLayer)); @@ -250,7 +431,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL assert.equal(provider, "codex"); const resolvedBinding = yield* directory.getBinding(threadId); - assertSome(resolvedBinding, { + expect(Option.getOrThrow(resolvedBinding)).toMatchObject({ threadId, provider: ProviderDriverKind.make("codex"), }); @@ -267,5 +448,6 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL }).pipe(Effect.provide(directoryLayer)); NodeFS.rmSync(tempDir, { recursive: true, force: true }); - })); + }), + ); }); diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts index 253a954d2102..29ec8d2ed168 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts @@ -100,7 +100,7 @@ const makeProviderSessionDirectory = Effect.gen(function* () { ), ); - const upsert: ProviderSessionDirectoryShape["upsert"] = Effect.fn(function* (binding) { + const upsert: ProviderSessionDirectoryShape["upsert"] = Effect.fn(function* (binding, options) { const existing = yield* repository .getByThreadId({ threadId: binding.threadId }) .pipe(Effect.mapError(toPersistenceError("ProviderSessionDirectory.upsert:getByThreadId"))); @@ -126,25 +126,30 @@ const makeProviderSessionDirectory = Effect.gen(function* () { }); } yield* repository - .upsert({ - threadId: resolvedThreadId, - providerName: binding.provider, - providerInstanceId, - adapterKey: - binding.adapterKey ?? - (providerChanged ? binding.provider : (existingRuntime?.adapterKey ?? binding.provider)), - runtimeMode: binding.runtimeMode ?? existingRuntime?.runtimeMode ?? "full-access", - status: binding.status ?? existingRuntime?.status ?? "running", - lastSeenAt: now, - resumeCursor: - binding.resumeCursor !== undefined - ? binding.resumeCursor - : (existingRuntime?.resumeCursor ?? null), - runtimePayload: mergeRuntimePayload( - existingRuntime?.runtimePayload ?? null, - binding.runtimePayload, - ), - }) + .upsert( + { + threadId: resolvedThreadId, + providerName: binding.provider, + providerInstanceId, + adapterKey: + binding.adapterKey ?? + (providerChanged + ? binding.provider + : (existingRuntime?.adapterKey ?? binding.provider)), + runtimeMode: binding.runtimeMode ?? existingRuntime?.runtimeMode ?? "full-access", + status: binding.status ?? existingRuntime?.status ?? "running", + lastSeenAt: now, + resumeCursor: + binding.resumeCursor !== undefined + ? binding.resumeCursor + : (existingRuntime?.resumeCursor ?? null), + runtimePayload: mergeRuntimePayload( + existingRuntime?.runtimePayload ?? null, + binding.runtimePayload, + ), + }, + options, + ) .pipe(Effect.mapError(toPersistenceError("ProviderSessionDirectory.upsert:upsert"))); }); @@ -164,6 +169,15 @@ const makeProviderSessionDirectory = Effect.gen(function* () { ), ); + const recordImportedTranscript: ProviderSessionDirectoryShape["recordImportedTranscript"] = ( + input, + ) => + repository + .recordImportedTranscript(input) + .pipe( + Effect.mapError(toPersistenceError("ProviderSessionDirectory.recordImportedTranscript")), + ); + const listThreadIds: ProviderSessionDirectoryShape["listThreadIds"] = () => repository.list().pipe( Effect.mapError(toPersistenceError("ProviderSessionDirectory.listThreadIds:list")), @@ -184,6 +198,7 @@ const makeProviderSessionDirectory = Effect.gen(function* () { return { upsert, + recordImportedTranscript, getProvider, getBinding, listThreadIds, diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts index 9d981bfac394..194f5605be6f 100644 --- a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts @@ -234,6 +234,7 @@ describe("ProviderSessionReaper", () => { getActiveProjectByWorkspaceRoot: () => Effect.die("unused"), getProjectShellById: () => Effect.die("unused"), getFirstActiveThreadIdByProjectId: () => Effect.die("unused"), + getImportedAgentSessionSources: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.die("unused"), getSessionStopContextById: () => Effect.die("unused"), getFullThreadDiffContext: () => Effect.die("unused"), diff --git a/apps/server/src/provider/ModelManifest.test.ts b/apps/server/src/provider/ModelManifest.test.ts index 73049ad01c30..bb592a0a6fc8 100644 --- a/apps/server/src/provider/ModelManifest.test.ts +++ b/apps/server/src/provider/ModelManifest.test.ts @@ -17,7 +17,6 @@ import { make, resolveProviderCatalog, type ModelManifestData, - manifestUpdatedAtMs, encodeManifestCache, } from "./ModelManifest.ts"; @@ -382,7 +381,6 @@ describe("ModelManifest service", () => { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const config = yield* ServerConfig.ServerConfig; - assert.isAbove(manifestUpdatedAtMs(BUNDLED_MODEL_MANIFEST), 0); const cachePath = path.join(config.stateDir, "model-manifest.json"); // A cache of the manifest as it was before the release edited it. The // fetch time is irrelevant: the remote may be unreachable now, so diff --git a/apps/server/src/provider/ModelManifest.ts b/apps/server/src/provider/ModelManifest.ts index c3cb36566c02..67a7334f613d 100644 --- a/apps/server/src/provider/ModelManifest.ts +++ b/apps/server/src/provider/ModelManifest.ts @@ -138,7 +138,7 @@ export const BUNDLED_MODEL_MANIFEST: ModelManifestData = Schema.decodeUnknownSync(ModelManifestSchema)(bundledManifestJson); /** Epoch millis of the manifest's `updatedAt`, or 0 when absent or unparsable. */ -export function manifestUpdatedAtMs(manifest: ModelManifestData): number { +function manifestUpdatedAtMs(manifest: ModelManifestData): number { if (manifest.updatedAt === undefined) return 0; const parsed = Date.parse(manifest.updatedAt); return Number.isNaN(parsed) ? 0 : parsed; diff --git a/apps/server/src/provider/ProviderInstanceEnvironment.test.ts b/apps/server/src/provider/ProviderInstanceEnvironment.test.ts index 7ac3f2f2837d..7d6bbe61a2aa 100644 --- a/apps/server/src/provider/ProviderInstanceEnvironment.test.ts +++ b/apps/server/src/provider/ProviderInstanceEnvironment.test.ts @@ -1,8 +1,55 @@ -import { describe, expect, it } from "vite-plus/test"; +import * as NodeOS from "node:os"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Path from "effect/Path"; import { mergeProviderInstanceEnvironment } from "./ProviderInstanceEnvironment.ts"; describe("mergeProviderInstanceEnvironment", () => { + it.effect.each([ + { value: "~/.account", tail: ".account" }, + { value: "~\\.account\\work", tail: ".account\\work" }, + ])("expands configured provider homes set to $value", ({ value, tail }) => + Effect.gen(function* () { + const path = yield* Path.Path; + const baseEnv = { + CODEX_HOME: "~/.inherited-codex", + CLAUDE_CONFIG_DIR: "~/.inherited-claude", + }; + const environment = mergeProviderInstanceEnvironment( + [ + { name: "CODEX_HOME", value, sensitive: false }, + { name: "CLAUDE_CONFIG_DIR", value, sensitive: false }, + { name: "CUSTOM_VALUE", value, sensitive: false }, + ], + baseEnv, + ); + + expect(environment).toEqual({ + CODEX_HOME: path.join(NodeOS.homedir(), tail), + CLAUDE_CONFIG_DIR: path.join(NodeOS.homedir(), tail), + CUSTOM_VALUE: value, + }); + expect(baseEnv).toEqual({ + CODEX_HOME: "~/.inherited-codex", + CLAUDE_CONFIG_DIR: "~/.inherited-claude", + }); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it("leaves inherited provider homes unchanged", () => { + const baseEnv = { CODEX_HOME: "~/.codex", CLAUDE_CONFIG_DIR: "~\\.claude" }; + + expect( + mergeProviderInstanceEnvironment( + [{ name: "CUSTOM_VALUE", value: "~/.custom", sensitive: false }], + baseEnv, + ), + ).toEqual({ ...baseEnv, CUSTOM_VALUE: "~/.custom" }); + }); + it("overrides inherited environment values and preserves empty strings", () => { expect( mergeProviderInstanceEnvironment( diff --git a/apps/server/src/provider/ProviderInstanceEnvironment.ts b/apps/server/src/provider/ProviderInstanceEnvironment.ts index e469253604e6..77c0c6c2dc88 100644 --- a/apps/server/src/provider/ProviderInstanceEnvironment.ts +++ b/apps/server/src/provider/ProviderInstanceEnvironment.ts @@ -1,5 +1,7 @@ import type { ProviderInstanceEnvironment } from "@t3tools/contracts"; +import { expandHomePath } from "../pathExpansion.ts"; + export function mergeProviderInstanceEnvironment( environment: ProviderInstanceEnvironment | undefined, baseEnv: NodeJS.ProcessEnv = process.env, @@ -10,7 +12,11 @@ export function mergeProviderInstanceEnvironment( const next: NodeJS.ProcessEnv = { ...baseEnv }; for (const variable of environment) { - next[variable.name] = variable.value; + // Child processes do not apply shell expansion to environment values. + next[variable.name] = + variable.name === "CODEX_HOME" || variable.name === "CLAUDE_CONFIG_DIR" + ? expandHomePath(variable.value) + : variable.value; } return next; } diff --git a/apps/server/src/provider/RuntimeInstructions.test.ts b/apps/server/src/provider/RuntimeInstructions.test.ts index 350d8c73c150..320aa332c937 100644 --- a/apps/server/src/provider/RuntimeInstructions.test.ts +++ b/apps/server/src/provider/RuntimeInstructions.test.ts @@ -2,17 +2,6 @@ import { describe, expect, it } from "vite-plus/test"; import { buildRuntimeInstructions } from "./RuntimeInstructions.ts"; describe("buildRuntimeInstructions", () => { - it.each(["Codex", "Claude Code", "Cursor", "Grok", "OpenCode", "Antigravity"])( - "identifies the %s harness and describes media embedding", - (harness) => { - const instructions = buildRuntimeInstructions({ harness }); - expect(instructions).toContain(`running in T3 Code through the ${harness} harness.`); - expect(instructions).toContain("embed images and videos"); - expect(instructions).toContain("Markdown with absolute file paths"); - expect(instructions).not.toContain("undefined"); - }, - ); - it("keeps known model and effort metadata on one line", () => { expect( buildRuntimeInstructions({ diff --git a/apps/server/src/provider/Services/ProviderSessionDirectory.ts b/apps/server/src/provider/Services/ProviderSessionDirectory.ts index f2dd4323f7a3..9dbafd3e804e 100644 --- a/apps/server/src/provider/Services/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Services/ProviderSessionDirectory.ts @@ -1,4 +1,5 @@ import type { + AgentSessionImportSource, ProviderInstanceId, ProviderDriverKind, ProviderSessionRuntimeStatus, @@ -40,11 +41,22 @@ export type ProviderSessionDirectoryWriteError = | ProviderValidationError | ProviderSessionDirectoryPersistenceError; +export interface ProviderSessionDirectoryUpsertOptions { + readonly onConflict?: "update" | "ignore"; +} + export interface ProviderSessionDirectoryShape { readonly upsert: ( binding: ProviderRuntimeBinding, + options?: ProviderSessionDirectoryUpsertOptions, ) => Effect.Effect; + /** Record an imported file without changing the current provider session. */ + readonly recordImportedTranscript: (input: { + readonly threadId: ThreadId; + readonly source: AgentSessionImportSource; + }) => Effect.Effect; + readonly getProvider: ( threadId: ThreadId, ) => Effect.Effect; diff --git a/apps/server/src/provider/providerMaintenance.test.ts b/apps/server/src/provider/providerMaintenance.test.ts index 94fc376f227d..2ceaf21996bf 100644 --- a/apps/server/src/provider/providerMaintenance.test.ts +++ b/apps/server/src/provider/providerMaintenance.test.ts @@ -595,25 +595,29 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { }), ); - it.effect.skipIf(!symlinksSupported)( - "upgrades the Homebrew cask that owns the binary and compares against its version", - () => + it.effect.each([ + { directory: "Caskroom", name: "package-tool", kind: "cask" }, + { directory: "Cellar", name: "package-tool", kind: "formula" }, + { directory: "Cellar", name: "package-tool@latest", kind: "formula" }, + ] as const)( + "upgrades the owning Homebrew $kind $name through an executable alias", + (fixture) => Effect.gen(function* () { const tempDir = yield* makeTempDir("t3-homebrew-capabilities"); const brewBinDir = NodePath.join(tempDir, "brew-bin"); const brewPath = NodePath.join(brewBinDir, "brew"); writeExecutable(brewPath); - const caskBinary = NodePath.join( + const ownedBinary = NodePath.join( tempDir, - "Caskroom", - "package-tool", + fixture.directory, + fixture.name, "0.148.0", - "package-tool", + "package-tool-0.148.0", ); - writeExecutable(caskBinary); - const link = NodePath.join(tempDir, "bin", "package-tool"); + writeExecutable(ownedBinary); + const link = NodePath.join(tempDir, "bin", "custom-package-tool"); NodeFS.mkdirSync(NodePath.dirname(link), { recursive: true }); - NodeFS.symlinkSync(caskBinary, link); + NodeFS.symlinkSync(ownedBinary, link); const spawned: Array> = []; const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect( @@ -630,27 +634,38 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { spawned.push([command, ...args]); return args[0] === "--prefix" ? `${tempDir}\n` - : JSON.stringify({ casks: [{ version: "0.148.0,42" }] }); + : JSON.stringify( + fixture.kind === "cask" + ? { casks: [{ version: "0.148.0,42" }] } + : { formulae: [{ versions: { stable: "0.148.0" } }] }, + ); }), ), ); expect(spawned).toEqual([ [brewPath, "--prefix"], - [brewPath, "info", "--json=v2", "package-tool"], + [brewPath, "info", "--json=v2", fixture.name], ]); expect(capabilities).toEqual({ provider: driver("packageTool"), packageName: "@example/package-tool", latestVersion: "0.148.0", update: { - command: "brew upgrade --cask package-tool", + command: + fixture.kind === "cask" + ? `brew upgrade --cask ${fixture.name}` + : `brew upgrade ${fixture.name}`, executable: brewPath, - args: ["upgrade", "--cask", "package-tool"], + args: + fixture.kind === "cask" + ? ["upgrade", "--cask", fixture.name] + : ["upgrade", fixture.name], lockKey: "homebrew", }, }); }), + { skip: !symlinksSupported }, ); it.effect.skipIf(windowsHost)( diff --git a/apps/server/src/provider/providerMaintenance.ts b/apps/server/src/provider/providerMaintenance.ts index d812f1ab7989..e8ff090a4ec9 100644 --- a/apps/server/src/provider/providerMaintenance.ts +++ b/apps/server/src/provider/providerMaintenance.ts @@ -432,6 +432,10 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn( const homebrew = homebrewOwnershipFromCommandPath(context.realCommandPath); if (homebrew) { + // Mise shims resolve to the version manager, not the provider. + if (homebrew.kind === "formula" && homebrew.name.toLowerCase() === "mise") { + return manual; + } const brewPath = yield* resolveCommandPath("brew", { env: context.env }).pipe( Effect.catchTags({ CommandResolutionError: () => Effect.succeed(null) }), ); diff --git a/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs b/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs index 81e89ba86fe5..1bad5ca46978 100644 --- a/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs +++ b/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs @@ -57,6 +57,14 @@ rl.on("line", (line) => { }); return; } + if (method === "account/read") { + write({ id, result: { account: { type: "apiKey" }, requiresOpenaiAuth: false } }); + return; + } + if (method === "skills/list" || method === "model/list") { + write({ id, result: { data: [] } }); + return; + } if (method === "thread/start") { write({ id, result: fixture.responses.threadStart }); return; diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.test.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.test.ts deleted file mode 100644 index 51d8f74bbc45..000000000000 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { AZURE_DEVOPS_VIEWER_PERMISSIONS } from "./AzureDevOpsPullRequestProvider.ts"; - -describe("azure devops viewer permissions", () => { - it("offers every action to whoever is signed in, because Azure names no permission", () => { - // The same answer for a viewer who can write, one who can only read, and an author with read - // access: `az repos pr show` and `az repos pr list` carry nothing about the caller's standing, - // and an unknown permission is granted rather than guessed away. Azure refuses the ones it - // will not allow, at the moment they are taken, in words this could not have written. - expect(AZURE_DEVOPS_VIEWER_PERMISSIONS).toEqual({ - actions: [ - "merge", - "ready", - "draft", - "close", - "reopen", - "enable-auto-merge", - "disable-auto-merge", - ], - // False because the host itself cannot post one, not because this viewer may not. - comment: false, - resolve: false, - verdicts: [], - // True because `az repos pr reviewer` does take one, and Azure says nothing about who may. - requestReviewers: true, - }); - }); -}); diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts index 8461e57d5685..3d501a32d61b 100644 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts @@ -56,7 +56,7 @@ const CAPABILITIES: PullRequestCapabilities = { * they try. That is the safer half of an unknown: hiding a control from someone entitled to it * leaves them no way through and no reason given. */ -export const AZURE_DEVOPS_VIEWER_PERMISSIONS: PullRequestViewerPermissions = { +const AZURE_DEVOPS_VIEWER_PERMISSIONS: PullRequestViewerPermissions = { actions: CAPABILITIES.actions, comment: CAPABILITIES.comment, resolve: CAPABILITIES.review.resolve, diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index a61c901e59ca..fe5b18a21735 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -40,6 +40,7 @@ import { type ProjectionSnapshotQueryShape, } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; import { + isAgentActivityPublishingEnabledValue, RELAY_ENVIRONMENT_CREDENTIAL_SECRET, RELAY_ISSUER_SECRET, RELAY_URL_SECRET, @@ -138,7 +139,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { expect(AgentAwarenessRelay.eventThreadId(event)).toBe(threadId); }); - it("does not publish start intents, streaming content, or non-awareness activity events", () => { + it("does not publish imported, start-intent, streaming, or non-awareness events", () => { const now = "2026-05-25T00:00:00.000Z"; const base = { sequence: 1, @@ -147,6 +148,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { aggregateKind: "thread", aggregateId: "thread-1" as ThreadId, occurredAt: now, + metadata: {}, }; expect( @@ -202,6 +204,36 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { }, } as unknown as OrchestrationEvent), ).toBe(false); + expect( + AgentAwarenessRelay.shouldPublishAgentAwarenessEvent({ + ...base, + type: "thread.created", + metadata: { historyImport: true }, + payload: { threadId: "thread-1" as ThreadId }, + } as unknown as OrchestrationEvent), + ).toBe(false); + expect( + AgentAwarenessRelay.shouldPublishAgentAwarenessEvent({ + ...base, + type: "thread.settled", + metadata: { historyImport: true }, + payload: { threadId: "thread-1" as ThreadId }, + } as unknown as OrchestrationEvent), + ).toBe(false); + expect( + AgentAwarenessRelay.shouldPublishAgentAwarenessEvent({ + ...base, + type: "thread.created", + payload: { threadId: "thread-1" as ThreadId }, + } as unknown as OrchestrationEvent), + ).toBe(true); + expect( + AgentAwarenessRelay.shouldPublishAgentAwarenessEvent({ + ...base, + type: "thread.settled", + payload: { threadId: "thread-1" as ThreadId }, + } as unknown as OrchestrationEvent), + ).toBe(true); }); it("deduplicates awareness state updates whose only change is their event timestamp", () => { @@ -221,9 +253,10 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { }); it("requires an explicit opt-in before publishing agent activity", () => { - expect(AgentAwarenessRelay.isAgentActivityPublishingEnabled(null)).toBe(false); - expect(AgentAwarenessRelay.isAgentActivityPublishingEnabled("false")).toBe(false); - expect(AgentAwarenessRelay.isAgentActivityPublishingEnabled("true")).toBe(true); + expect(isAgentActivityPublishingEnabledValue(null)).toBe(false); + expect(isAgentActivityPublishingEnabledValue("false")).toBe(false); + expect(isAgentActivityPublishingEnabledValue("TRUE")).toBe(false); + expect(isAgentActivityPublishingEnabledValue("true")).toBe(true); }); it("redacts failed activity details and caps other relay detail", () => { @@ -399,17 +432,32 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { }), ); - it.effect("keeps the orchestration listener armed until relay config is installed", () => + it.effect("keeps the listener armed and skips imported thread work", () => Effect.scoped( Effect.gen(function* () { const events = yield* Queue.unbounded(); const threadShellRequested = yield* Deferred.make(); + const releaseThreadShell = yield* Deferred.make(); + const threadShellRequests: Array = []; + let fetchCallCount = 0; const secrets = makeMemorySecretStore(); const now = "2026-05-25T00:00:00.000Z"; const projectId = "project-1" as ProjectId; const threadId = "thread-1" as ThreadId; + const importedThreadId = "import:codex:session-1" as ThreadId; const environmentId = "env-1" as EnvironmentId; + const originalFetch = globalThis.fetch; + globalThis.fetch = (() => { + fetchCallCount += 1; + return Promise.resolve(Response.json({ ok: true, deliveries: [] })); + }) as unknown as typeof fetch; + yield* Effect.addFinalizer(() => + Effect.sync(() => { + globalThis.fetch = originalFetch; + }), + ); + const project = { id: projectId, title: "T3 Code", @@ -472,15 +520,18 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { getShellSnapshot: () => Effect.succeed({ snapshotSequence: 1, - projects: [project], - threads: [thread], + projects: [], + threads: [], updatedAt: now, } satisfies OrchestrationShellSnapshot), - getThreadShellById: () => - Deferred.succeed(threadShellRequested, undefined).pipe( - Effect.ignore, - Effect.as(Option.some(thread)), - ), + getThreadShellById: (requestedThreadId: ThreadId) => + Effect.gen(function* () { + threadShellRequests.push(requestedThreadId); + if (requestedThreadId !== threadId) return Option.none(); + yield* Deferred.succeed(threadShellRequested, undefined); + yield* Deferred.await(releaseThreadShell); + return Option.some(thread); + }), getProjectShellById: () => Effect.succeed(Option.some(project)), } as unknown as ProjectionSnapshotQueryShape; @@ -510,17 +561,40 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { yield* Effect.gen(function* () { const relay = yield* AgentAwarenessRelay.AgentAwarenessRelay; yield* relay.start(); - yield* secrets.setString(RELAY_URL_SECRET, "http://127.0.0.1:1"); + yield* secrets.setString(RELAY_URL_SECRET, "https://relay.example.test"); yield* secrets.setString(RELAY_ENVIRONMENT_CREDENTIAL_SECRET, "relay-credential"); yield* secrets.setString(PUBLISH_AGENT_ACTIVITY_SECRET, "true"); yield* Queue.offer(events, { - type: "thread.activity-appended", + type: "thread.created", sequence: 1, + eventId: "evt-import-created", + commandId: CommandId.make("cmd-import-created"), + aggregateKind: "thread", + aggregateId: importedThreadId, + metadata: { historyImport: true }, + payload: { threadId: importedThreadId }, + occurredAt: now, + } as unknown as OrchestrationEvent); + yield* Queue.offer(events, { + type: "thread.settled", + sequence: 2, + eventId: "evt-import-settled", + commandId: CommandId.make("cmd-import-settled"), + aggregateKind: "thread", + aggregateId: importedThreadId, + metadata: { historyImport: true }, + payload: { threadId: importedThreadId }, + occurredAt: now, + } as unknown as OrchestrationEvent); + yield* Queue.offer(events, { + type: "thread.activity-appended", + sequence: 3, eventId: "evt-1", commandId: CommandId.make("cmd-1"), aggregateKind: "thread", aggregateId: threadId, actor: { kind: "server" }, + metadata: {}, payload: { threadId, activity: { @@ -531,6 +605,9 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { } as unknown as OrchestrationEvent); yield* Deferred.await(threadShellRequested).pipe(Effect.timeout("2 seconds")); + expect(threadShellRequests).toEqual([threadId]); + expect(fetchCallCount).toBe(0); + yield* Deferred.succeed(releaseThreadShell, undefined); }).pipe( Effect.provide( AgentAwarenessRelay.layer.pipe( @@ -685,6 +762,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { aggregateKind: "thread", aggregateId: threadId, actor: { kind: "server" }, + metadata: {}, payload: { threadId, activity: { diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index 5127ecf7d359..8d7b9e98361e 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -67,6 +67,9 @@ export function eventThreadId(event: OrchestrationEvent): ThreadId | null { } export function shouldPublishAgentAwarenessEvent(event: OrchestrationEvent): boolean { + if (event.metadata.historyImport === true) { + return false; + } switch (event.type) { case "thread.message-sent": case "thread.turn-start-requested": @@ -102,10 +105,6 @@ export function agentAwarenessPublishIdentity(state: RelayAgentActivityState | n return JSON.stringify(meaningfulState); } -export function isAgentActivityPublishingEnabled(value: string | null): boolean { - return isAgentActivityPublishingEnabledValue(value); -} - export function resolveAgentActivityPublishingStartupState(input: { readonly relayConfigured: boolean; readonly publishEnabled: boolean; @@ -322,7 +321,7 @@ export const make = Effect.gen(function* () { }); const readPublishAgentActivityEnabled = readSecretString(PUBLISH_AGENT_ACTIVITY_SECRET).pipe( - Effect.map(isAgentActivityPublishingEnabled), + Effect.map(isAgentActivityPublishingEnabledValue), ); const makeRelayClient = (relayConfig: { diff --git a/apps/server/src/resourceTelemetry/NativeTelemetryClient.test.ts b/apps/server/src/resourceTelemetry/NativeTelemetryClient.test.ts index 8a595bc8b480..7d365d0c0bac 100644 --- a/apps/server/src/resourceTelemetry/NativeTelemetryClient.test.ts +++ b/apps/server/src/resourceTelemetry/NativeTelemetryClient.test.ts @@ -1,6 +1,5 @@ import type { HostPowerSnapshot } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; -import * as Cause from "effect/Cause"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -9,12 +8,9 @@ import * as Ref from "effect/Ref"; import * as Semaphore from "effect/Semaphore"; import { - NativeTelemetryRequestTimedOut, - NativeTelemetryStreamClosed, canCommandNativeTelemetrySidecar, canRequestNativeTelemetryRetry, commitCollectionControlUpdate, - nativeTelemetrySupervisorFailureMessage, retainRecentNativeTelemetryFailures, resolveNativeSampleIntervalMs, synchronizeCollectionControlOnStart, @@ -82,44 +78,6 @@ describe("canCommandNativeTelemetrySidecar", () => { }); }); -describe("NativeTelemetryRequestTimedOut", () => { - it("models history and sample request deadlines without a fabricated cause", () => { - const historyTimeout = new NativeTelemetryRequestTimedOut({ - operation: "readHistory", - timeoutMs: 15_000, - }); - const sampleTimeout = new NativeTelemetryRequestTimedOut({ - operation: "sampleNow", - timeoutMs: 5_000, - }); - - expect(historyTimeout.message).toBe( - "Resource monitor 'readHistory' request timed out after 15000ms.", - ); - expect(sampleTimeout.message).toBe( - "Resource monitor 'sampleNow' request timed out after 5000ms.", - ); - expect("cause" in historyTimeout).toBe(false); - expect("cause" in sampleTimeout).toBe(false); - }); -}); - -describe("native telemetry supervisor failures", () => { - it("distinguishes a closed event stream from a process exit", () => { - expect(new NativeTelemetryStreamClosed().message).toBe( - "Resource monitor event stream closed unexpectedly.", - ); - }); - - it("keeps defect details out of the caller-visible health message", () => { - const secret = "credential=do-not-expose"; - const message = nativeTelemetrySupervisorFailureMessage(Cause.die(new Error(secret))); - - expect(message).toBe("Resource monitor supervisor stopped unexpectedly."); - expect(message).not.toContain(secret); - }); -}); - describe("retainRecentNativeTelemetryFailures", () => { it("expires old failures so an isolated crash restarts from the initial backoff", () => { expect(retainRecentNativeTelemetryFailures([0, 30_000], 90_001)).toEqual([]); diff --git a/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts b/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts index 232079d9dc9b..4af8f1b762d5 100644 --- a/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts +++ b/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts @@ -73,7 +73,7 @@ export class NativeTelemetryHandshakeTimedOut extends Schema.TaggedErrorClass()( +class NativeTelemetryRequestTimedOut extends Schema.TaggedErrorClass()( "NativeTelemetryRequestTimedOut", { operation: Schema.Literals(["readHistory", "sampleNow"]), @@ -131,7 +131,7 @@ export class NativeTelemetryExited extends Schema.TaggedErrorClass()( +class NativeTelemetryStreamClosed extends Schema.TaggedErrorClass()( "NativeTelemetryStreamClosed", {}, ) { @@ -340,10 +340,6 @@ function errorMessage(error: NativeTelemetryClientError): string { return error.message; } -export function nativeTelemetrySupervisorFailureMessage(_cause: Cause.Cause): string { - return "Resource monitor supervisor stopped unexpectedly."; -} - export function canRequestNativeTelemetryRetry( status: ResourceTelemetrySourceStatus, hasHandle: boolean, @@ -734,7 +730,7 @@ export const make = Effect.fn("resourceTelemetry.nativeTelemetryClient.make")(fu ...current, status: "unavailable" as const, hello: Option.none(), - lastError: Option.some(nativeTelemetrySupervisorFailureMessage(cause)), + lastError: Option.some("Resource monitor supervisor stopped unexpectedly."), })).pipe( Effect.andThen(publishHealth), Effect.andThen( diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 3e45a116f405..0f99c14080a9 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -37,6 +37,7 @@ import { type ProviderInstallState, ProviderSetupError, ResolvedKeybindingRule, + type ServerLifecycleStreamEvent, ThreadId, TurnId, WS_METHODS, @@ -48,10 +49,7 @@ import { computeDpopJwkThumbprint, type DpopPublicJwk, } from "@t3tools/shared/dpop"; -import { - appendOmegentT3ProductHandshake, - OMEGENT_T3_CLIENT_REQUIRED_MESSAGE, -} from "@t3tools/shared/productFamily"; +import { appendOmegentT3ProductHandshake } from "@t3tools/shared/productFamily"; import { RELAY_HEALTH_REQUEST_TYP, RELAY_MINT_REQUEST_TYP } from "@t3tools/shared/relayJwt"; import * as RelayClient from "@t3tools/shared/relayClient"; import { assert, it } from "@effect/vitest"; @@ -96,6 +94,7 @@ const decodeTransferThreadSnapshot = Schema.decodeUnknownEffect( const decodeTransferShellSnapshot = Schema.decodeUnknownEffect( Schema.fromJsonString(OrchestrationShellSnapshot), ); +const encodeTestJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; import * as ServerConfig from "./config.ts"; @@ -136,6 +135,7 @@ import { AntigravityInstallationError, } from "./provider/AntigravityInstallation.ts"; import type { ProviderInstance } from "./provider/ProviderDriver.ts"; +import * as ProviderSessionDirectory from "./provider/Services/ProviderSessionDirectory.ts"; import { ProviderAdapterRequestError } from "./provider/Errors.ts"; import { makeManualOnlyProviderMaintenanceCapabilities } from "./provider/providerMaintenance.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; @@ -528,6 +528,9 @@ const buildAppUnderTest = (options?: { projectSetupScriptRunner?: Partial< ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"] >; + providerSessionDirectory?: Partial< + ProviderSessionDirectory.ProviderSessionDirectory["Service"] + >; terminalManager?: Partial; orchestrationEngine?: Partial; threadDeletionReactor?: Partial; @@ -813,6 +816,13 @@ const buildAppUnderTest = (options?: { managedDirectory: "unused-test-antigravity-runtime", ...options?.layers?.antigravityInstallation, }), + Layer.mock(ProviderSessionDirectory.ProviderSessionDirectory)({ + upsert: () => Effect.void, + getBinding: () => Effect.succeed(Option.none()), + listThreadIds: () => Effect.succeed([]), + listBindings: () => Effect.succeed([]), + ...options?.layers?.providerSessionDirectory, + }), ), ), Layer.provide( @@ -1040,6 +1050,7 @@ const buildAppUnderTest = (options?: { }), getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getImportedAgentSessionSources: () => Effect.succeed([]), getThreadCheckpointContext: () => Effect.succeed(Option.none()), getFullThreadDiffContext: () => Effect.succeed(Option.none()), getSessionStopContextById: () => Effect.succeed(Option.none()), @@ -5487,6 +5498,103 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("keeps agent session import project failures structured over websocket rpc", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const projectId = ProjectId.make("missing-import-project"); + const wsUrl = yield* getWsServerUrl("/ws"); + const error = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.agentSessionsImport]({ projectId }).pipe(Effect.flip), + ), + ); + + assert.equal(error._tag, "AgentSessionImportProjectNotFoundError"); + if (error._tag === "AgentSessionImportProjectNotFoundError") { + assert.equal(error.projectId, projectId); + } + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("returns scanner skip counts over websocket rpc", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const codexHome = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-agent-import-rpc-codex-", + }); + const workspaceRoot = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-agent-import-rpc-workspace-", + }); + const transcriptDirectory = path.join(codexHome, "sessions", "2026", "08", "31"); + const transcriptPath = path.join(transcriptDirectory, "rollout-skipped.jsonl"); + yield* fileSystem.makeDirectory(transcriptDirectory, { recursive: true }); + yield* fileSystem.writeFileString( + transcriptPath, + encodeTestJson({ + timestamp: "2026-08-31T12:00:00.000Z", + type: "session_meta", + payload: { id: "rpc-skipped-session", cwd: workspaceRoot }, + }), + ); + yield* fileSystem.utimes(transcriptPath, 0, 0); + + const projectId = ProjectId.make("agent-import-rpc-project"); + const project = { + id: projectId, + title: "Agent import RPC", + workspaceRoot, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-08-31T12:00:00.000Z", + updatedAt: "2026-08-31T12:00:00.000Z", + } as const; + yield* buildAppUnderTest({ + layers: { + serverSettings: { + getSettings: Effect.succeed({ + ...DEFAULT_SERVER_SETTINGS, + providerInstances: { + [ProviderInstanceId.make("codex")]: { + driver: ProviderDriverKind.make("codex"), + config: { homePath: codexHome }, + }, + [ProviderInstanceId.make("claudeAgent")]: { + driver: ProviderDriverKind.make("claudeAgent"), + enabled: false, + config: {}, + }, + }, + }), + }, + projectionSnapshotQuery: { + getProjectShellById: (requestedProjectId) => + Effect.succeed( + requestedProjectId === projectId ? Option.some(project) : Option.none(), + ), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const result = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + Effect.gen(function* () { + const scan = yield* client[WS_METHODS.agentSessionsScan]({}); + assert.deepEqual( + scan.candidates.map((candidate) => candidate.path), + [workspaceRoot], + ); + return yield* client[WS_METHODS.agentSessionsImport]({ projectId }); + }), + ), + ); + + assert.deepEqual(result, { importedCount: 0, skippedCount: 1 }); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("uploads Codex thread feedback through websocket rpc", () => Effect.gen(function* () { const input = { @@ -6396,6 +6504,98 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("subscribeServerLifecycle buffers updates published during snapshot capture", () => + Effect.gen(function* () { + const pubsub = yield* PubSub.unbounded(); + const streamSubscribed = yield* Deferred.make(); + const snapshotPublished = yield* Deferred.make(); + const bootstrapProjectId = ProjectId.make("project-bootstrap"); + const bootstrapThreadId = ThreadId.make("thread-bootstrap"); + const snapshotEvent = { + version: 1 as const, + sequence: 1, + type: "welcome" as const, + payload: { + environment: testEnvironmentDescriptor, + cwd: "/tmp/project", + projectName: "project", + bootstrapStatus: "pending" as const, + }, + }; + const gapEvent = { + version: 1 as const, + sequence: 2, + type: "welcome" as const, + payload: { + environment: testEnvironmentDescriptor, + cwd: "/tmp/project", + projectName: "project", + bootstrapStatus: "complete" as const, + bootstrapProjectId, + bootstrapThreadId, + bootstrapProjectCreated: true, + bootstrapThreadCreated: true, + }, + }; + const sentinelEvent = { + version: 1 as const, + sequence: 3, + type: "ready" as const, + payload: { at: "2026-01-01T00:00:01.000Z", environment: testEnvironmentDescriptor }, + }; + const liveStream = Stream.unwrap( + Effect.gen(function* () { + const subscription = yield* PubSub.subscribe(pubsub); + yield* Deferred.succeed(streamSubscribed, undefined); + return Stream.fromSubscription(subscription); + }), + ); + + yield* buildAppUnderTest({ + layers: { + serverLifecycleEvents: { + snapshot: PubSub.publish(pubsub, gapEvent).pipe( + Effect.andThen(Deferred.succeed(snapshotPublished, undefined)), + Effect.as({ sequence: 1, events: [snapshotEvent] }), + ), + stream: liveStream, + }, + }, + }); + + yield* Effect.gen(function* () { + yield* Deferred.await(snapshotPublished); + yield* Deferred.await(streamSubscribed); + yield* PubSub.publish(pubsub, sentinelEvent); + }).pipe(Effect.forkScoped); + + const wsUrl = yield* getWsServerUrl("/ws"); + const events = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.subscribeServerLifecycle]({}).pipe(Stream.take(2), Stream.runCollect), + ), + ); + + const [first, second] = Array.from(events); + assert.equal(first?.type, "welcome"); + assert.equal(first?.sequence, 1); + if (first?.type !== "welcome") { + assert.fail("expected the pending bootstrap event"); + } + assert.equal(first.payload.bootstrapStatus, "pending"); + assert.equal(second?.type, "welcome"); + assert.equal(second?.sequence, 2); + if (second?.type !== "welcome") { + assert.fail("expected the bootstrap completion event"); + } + assert.equal(second.payload.bootstrapStatus, "complete"); + assert.equal(second.payload.bootstrapProjectId, bootstrapProjectId); + assert.equal(second.payload.bootstrapThreadId, bootstrapThreadId); + assert.equal(second.payload.bootstrapProjectCreated, true); + assert.equal(second.payload.bootstrapThreadCreated, true); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("routes websocket rpc projects.searchEntries", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/serverRuntimeStartup.reconcile.test.ts b/apps/server/src/serverRuntimeStartup.reconcile.test.ts index 4048049b1eb4..d7efda75088f 100644 --- a/apps/server/src/serverRuntimeStartup.reconcile.test.ts +++ b/apps/server/src/serverRuntimeStartup.reconcile.test.ts @@ -152,6 +152,7 @@ it.effect("marks active running sessions that have persisted resume state", () = ), ), upsert: (binding) => Effect.sync(() => upserts.push(binding)), + recordImportedTranscript: () => Effect.die("unused"), getProvider: () => Effect.die("unused"), listThreadIds: () => Effect.die("unused"), listBindings: () => Effect.succeed([]), @@ -266,6 +267,7 @@ it.effect.each(["marked update", "opt-in restart"] as const)( firstMarkerCleared ? Deferred.succeed(continuationCleared, undefined) : Effect.void, ), ), + recordImportedTranscript: () => Effect.die("unused"), getProvider: () => Effect.die("unused"), listThreadIds: () => Effect.die("unused"), listBindings: () => Effect.succeed([]), @@ -392,6 +394,7 @@ it.effect("does not continue archived or deleted marked sessions", () => { ); }, upsert: () => Effect.void, + recordImportedTranscript: () => Effect.die("unused"), getProvider: () => Effect.die("unused"), listThreadIds: () => Effect.die("unused"), listBindings: () => Effect.succeed([]), @@ -447,6 +450,7 @@ it.effect("retries continuation preparation before settling a persistent failure }), ), upsert: () => Effect.void, + recordImportedTranscript: () => Effect.die("unused"), getProvider: () => Effect.die("unused"), listThreadIds: () => Effect.die("unused"), listBindings: () => Effect.succeed([]), @@ -518,6 +522,7 @@ it.effect("reconciles multiple active and archived orphans but skips live sessio ), ), upsert: (binding) => Effect.sync(() => upserts.push(binding)), + recordImportedTranscript: () => Effect.die("unused"), getProvider: () => Effect.die("unused"), listThreadIds: () => Effect.die("unused"), listBindings: () => Effect.succeed([]), @@ -597,6 +602,7 @@ it.effect( }), ), upsert: () => Effect.fail(writeFailure), + recordImportedTranscript: () => Effect.die("unused"), getProvider: () => Effect.die("unused"), listThreadIds: () => Effect.die("unused"), listBindings: () => Effect.succeed([]), @@ -634,6 +640,7 @@ it.effect("retries failed projections and continues after a persistent failure", directory: { getBinding: () => Effect.succeed(Option.none()), upsert: () => Effect.void, + recordImportedTranscript: () => Effect.die("unused"), getProvider: () => Effect.die("unused"), listThreadIds: () => Effect.die("unused"), listBindings: () => Effect.succeed([]), @@ -683,6 +690,7 @@ it.effect("does not fail startup when the live provider session inventory cannot Effect.provideService(ProviderSessionDirectory.ProviderSessionDirectory, { getBinding: () => Effect.die("unused"), upsert: () => Effect.die("unused"), + recordImportedTranscript: () => Effect.die("unused"), getProvider: () => Effect.die("unused"), listThreadIds: () => Effect.die("unused"), listBindings: () => Effect.succeed([]), @@ -755,6 +763,7 @@ for (const scenario of [ Effect.sync(() => { upserts.push(binding); }), + recordImportedTranscript: () => Effect.die("unused"), getProvider: () => Effect.die("unused"), listThreadIds: () => Effect.die("unused"), listBindings: () => Effect.succeed([]), @@ -828,6 +837,7 @@ for (const preparedStatus of [ if (binding.status !== "starting" || sends.length === 0) return; yield* Deferred.succeed(cleared, undefined); }), + recordImportedTranscript: () => Effect.die("unused"), getProvider: () => Effect.die("unused"), listThreadIds: () => Effect.die("unused"), listBindings: () => @@ -933,6 +943,7 @@ it.effect("settles failed opt-in recovery without retrying the provider turn", ( Effect.sync(() => { binding = next; }), + recordImportedTranscript: () => Effect.die("unused"), getProvider: () => Effect.die("unused"), listThreadIds: () => Effect.die("unused"), listBindings: () => Effect.succeed([]), diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index c3a4d160a71f..41dc2c2bd44f 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -15,17 +15,9 @@ import * as ServerRuntime from "@t3tools/shared/serverRuntime"; import * as ServerConfig from "./config.ts"; import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; -import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; -it("uses the canonical Codex default for the auto-bootstrapped welcome thread", () => { - assert.deepStrictEqual(ServerRuntimeStartup.getAutoBootstrapThreadModelSelection(), { - instanceId: ProviderInstanceId.make("codex"), - model: DEFAULT_MODEL, - }); -}); - it("marks a running session with an active turn as interrupted after a server restart", () => { const updatedAt = "2026-07-10T12:00:00.000Z"; assert.deepStrictEqual( @@ -189,59 +181,6 @@ it.effect("enqueueCommand fails queued work when readiness fails", () => ), ); -it.effect("launchStartupHeartbeat does not block the caller while counts are loading", () => - Effect.scoped( - Effect.gen(function* () { - const releaseCounts = yield* Deferred.make(); - const countsStarted = yield* Deferred.make(); - - yield* ServerRuntimeStartup.launchStartupHeartbeat.pipe( - Effect.provideService(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { - getUserInputActivity: () => Effect.die("unused"), - getCommandReadModel: () => Effect.die("unused"), - getSnapshot: () => Effect.die("unused"), - getShellSnapshot: () => Effect.die("unused"), - getArchivedShellSnapshot: () => Effect.die("unused"), - getSnapshotSequence: () => Effect.die("unused"), - getEventReplayStats: () => Effect.die("unused"), - getCounts: () => - Deferred.succeed(countsStarted, undefined).pipe( - Effect.andThen(Deferred.await(releaseCounts)), - Effect.as({ - projectCount: 2, - threadCount: 3, - }), - ), - getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), - getProjectShellById: () => Effect.succeed(Option.none()), - getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), - getThreadCheckpointContext: () => Effect.succeed(Option.none()), - getFullThreadDiffContext: () => Effect.succeed(Option.none()), - getSessionStopContextById: () => Effect.succeed(Option.none()), - getThreadRuntimeContext: () => Effect.die("unused"), - getThreadShellById: () => Effect.succeed(Option.none()), - getThreadDetailById: () => Effect.succeed(Option.none()), - getThreadDetailSnapshot: () => Effect.succeed(Option.none()), - searchThreads: () => Effect.succeed({ matches: [] }), - getThreadActivitiesPage: () => Effect.die("unused"), - getThreadLifecycleById: () => Effect.succeed(Option.none()), - }), - Effect.provideService(AnalyticsService.AnalyticsService, { - record: () => Effect.void, - flush: Effect.void, - }), - ); - - // The heartbeat is forked, so the caller is already back here while - // getCounts is still parked. Awaiting countsStarted proves the forked - // work really ran; releaseCounts staying incomplete proves the caller - // never waited for it. - yield* Deferred.await(countsStarted); - assert.equal(yield* Deferred.isDone(releaseCounts), false); - }), - ), -); - it.effect("resolveWelcomeBase derives cwd and project name from server config", () => Effect.gen(function* () { const welcome = yield* ServerRuntimeStartup.resolveWelcomeBase.pipe( @@ -283,7 +222,10 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa id: bootstrapProjectId, title: "Startup Project", workspaceRoot: "/tmp/startup-project", - defaultModelSelection: ServerRuntimeStartup.getAutoBootstrapThreadModelSelection(), + defaultModelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: DEFAULT_MODEL, + }, scripts: [], createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", @@ -292,6 +234,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa ), getProjectShellById: () => Effect.die("unused"), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.some(bootstrapThreadId)), + getImportedAgentSessionSources: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.succeed(Option.none()), getFullThreadDiffContext: () => Effect.succeed(Option.none()), getSessionStopContextById: () => Effect.die("unused"), @@ -321,6 +264,8 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa assert.deepStrictEqual(targets, { bootstrapProjectId, bootstrapThreadId, + bootstrapProjectCreated: false, + bootstrapThreadCreated: false, }); assert.deepStrictEqual(yield* Ref.get(dispatchCalls), []); }); @@ -352,6 +297,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), getProjectShellById: () => Effect.die("unused"), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getImportedAgentSessionSources: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.succeed(Option.none()), getFullThreadDiffContext: () => Effect.succeed(Option.none()), getSessionStopContextById: () => Effect.die("unused"), @@ -380,19 +326,78 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when assert.equal(typeof targets.bootstrapProjectId, "string"); assert.equal(typeof targets.bootstrapThreadId, "string"); + assert.equal(targets.bootstrapProjectCreated, true); + assert.equal(targets.bootstrapThreadCreated, true); const commands = yield* Ref.get(dispatchCalls); assert.deepStrictEqual( commands.map((command) => command.type), ["project.create", "thread.create"], ); assert.equal("defaultModelSelection" in commands[0]!, false); - assert.deepStrictEqual( - commands[1]?.modelSelection, - ServerRuntimeStartup.getAutoBootstrapThreadModelSelection(), - ); + assert.deepStrictEqual(commands[1]?.modelSelection, { + instanceId: ProviderInstanceId.make("codex"), + model: DEFAULT_MODEL, + }); }), ); +it.effect( + "resolveAutoBootstrapWelcomeTargets preserves a project created before thread failure", + () => + Effect.gen(function* () { + const dispatchCalls = yield* Ref.make>([]); + const targets = yield* ServerRuntimeStartup.resolveAutoBootstrapWelcomeTargets.pipe( + Effect.provideService(ServerConfig.ServerConfig, { + cwd: "/tmp/startup-project", + autoBootstrapProjectFromCwd: true, + } as never), + Effect.provideService(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { + getUserInputActivity: () => Effect.die("unused"), + getCommandReadModel: () => Effect.die("unused"), + getSnapshot: () => Effect.die("unused"), + getShellSnapshot: () => Effect.die("unused"), + getArchivedShellSnapshot: () => Effect.die("unused"), + getSnapshotSequence: () => Effect.die("unused"), + getCounts: () => Effect.die("unused"), + getEventReplayStats: () => Effect.die("unused"), + getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), + getProjectShellById: () => Effect.die("unused"), + getFirstActiveThreadIdByProjectId: () => Effect.die("thread lookup failed"), + getImportedAgentSessionSources: () => Effect.die("unused"), + getThreadCheckpointContext: () => Effect.succeed(Option.none()), + getFullThreadDiffContext: () => Effect.succeed(Option.none()), + getSessionStopContextById: () => Effect.die("unused"), + getThreadRuntimeContext: () => Effect.die("unused"), + getThreadShellById: () => Effect.die("unused"), + getThreadDetailById: () => Effect.die("unused"), + getThreadDetailSnapshot: () => Effect.die("unused"), + searchThreads: () => Effect.succeed({ matches: [] }), + getThreadActivitiesPage: () => Effect.die("unused"), + getThreadLifecycleById: () => Effect.die("unused"), + }), + Effect.provideService(OrchestrationEngine.OrchestrationEngineService, { + readEvents: () => Stream.empty, + readThreadEvents: () => Stream.empty, + getThreadReplayStats: () => Effect.die("unused thread replay stats"), + dispatch: (command) => + Ref.update(dispatchCalls, (calls) => [...calls, command.type]).pipe( + Effect.as({ sequence: 1 }), + ), + streamDomainEvents: Stream.empty, + subscribeDomainEvents: Effect.succeed(Stream.empty), + latestSequence: Effect.succeed(0), + } satisfies OrchestrationEngine.OrchestrationEngineService["Service"]), + Effect.provide(NodeServices.layer), + ); + + assert.equal(typeof targets.bootstrapProjectId, "string"); + assert.equal(targets.bootstrapProjectCreated, true); + assert.equal(targets.bootstrapThreadId, undefined); + assert.equal(targets.bootstrapThreadCreated, undefined); + assert.deepStrictEqual(yield* Ref.get(dispatchCalls), ["project.create"]); + }), +); + it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation failures", () => Effect.gen(function* () { const crypto = yield* Crypto.Crypto; @@ -421,6 +426,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), getProjectShellById: () => Effect.die("unused"), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getImportedAgentSessionSources: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.succeed(Option.none()), getFullThreadDiffContext: () => Effect.succeed(Option.none()), getSessionStopContextById: () => Effect.die("unused"), @@ -481,3 +487,31 @@ it.effect("writes a runtime descriptor the desktop client can decode", () => assert.deepStrictEqual(decoded._tag === "Success" ? decoded.value : null, descriptor); }), ); + +it.effect("completeAutoBootstrapWelcome settles failures without bootstrap targets", () => + Effect.gen(function* () { + const completion = yield* ServerRuntimeStartup.completeAutoBootstrapWelcome( + Effect.fail("bootstrap failed"), + ); + + assert.deepStrictEqual(completion, { bootstrapStatus: "complete" }); + }), +); + +it.effect("completeAutoBootstrapWelcome settles unexpected defects", () => + Effect.gen(function* () { + const completion = yield* ServerRuntimeStartup.completeAutoBootstrapWelcome( + Effect.die("bootstrap defect"), + ); + + assert.deepStrictEqual(completion, { bootstrapStatus: "complete" }); + }), +); + +it.effect("completeAutoBootstrapWelcome settles an empty bootstrap result", () => + Effect.gen(function* () { + const completion = yield* ServerRuntimeStartup.completeAutoBootstrapWelcome(Effect.succeed({})); + + assert.deepStrictEqual(completion, { bootstrapStatus: "complete" }); + }), +); diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 942587831abf..3e4de57a3d55 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -182,14 +182,6 @@ export const recordStartupHeartbeat = Effect.gen(function* () { }); }); -export const launchStartupHeartbeat = recordStartupHeartbeat.pipe( - Effect.annotateSpans({ "startup.phase": "heartbeat.record" }), - Effect.withSpan("server.startup.heartbeat.record"), - Effect.ignoreCause({ log: true }), - Effect.forkScoped, - Effect.asVoid, -); - export const getAutoBootstrapThreadModelSelection = (): ModelSelection => ({ instanceId: ProviderInstanceId.make("codex"), model: DEFAULT_MODEL, @@ -216,6 +208,8 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () { let bootstrapProjectId: ProjectId | undefined; let bootstrapThreadId: ThreadId | undefined; + let bootstrapProjectCreated = false; + let bootstrapThreadCreated = false; if (serverConfig.autoBootstrapProjectFromCwd) { yield* Effect.gen(function* () { @@ -238,45 +232,79 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () { workspaceRoot: serverConfig.cwd, createdAt, }); + bootstrapProjectId = nextProjectId; + bootstrapProjectCreated = true; } else { nextProjectId = existingProject.value.id; + bootstrapProjectId = nextProjectId; nextThreadModelSelection = existingProject.value.defaultModelSelection ?? getAutoBootstrapThreadModelSelection(); } - const existingThreadId = - yield* projectionReadModelQuery.getFirstActiveThreadIdByProjectId(nextProjectId); - if (Option.isNone(existingThreadId)) { - const createdAt = DateTime.formatIso(yield* DateTime.now); - const createdThreadId = ThreadId.make(yield* randomUUID); - yield* orchestrationEngine.dispatch({ - type: "thread.create", - commandId: CommandId.make(yield* randomUUID), - threadId: createdThreadId, - projectId: nextProjectId, - title: "New thread", - modelSelection: nextThreadModelSelection, - interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, - runtimeMode: "full-access", - branch: null, - worktreePath: null, - createdAt, - }); - bootstrapProjectId = nextProjectId; - bootstrapThreadId = createdThreadId; - } else { - bootstrapProjectId = nextProjectId; - bootstrapThreadId = existingThreadId.value; - } + yield* Effect.gen(function* () { + const existingThreadId = + yield* projectionReadModelQuery.getFirstActiveThreadIdByProjectId(nextProjectId); + if (Option.isNone(existingThreadId)) { + const createdAt = DateTime.formatIso(yield* DateTime.now); + const createdThreadId = ThreadId.make(yield* randomUUID); + yield* orchestrationEngine.dispatch({ + type: "thread.create", + commandId: CommandId.make(yield* randomUUID), + threadId: createdThreadId, + projectId: nextProjectId, + title: "New thread", + modelSelection: nextThreadModelSelection, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt, + }); + bootstrapThreadId = createdThreadId; + bootstrapThreadCreated = true; + } else { + bootstrapThreadId = existingThreadId.value; + } + }).pipe( + Effect.catchCause((cause) => + Cause.hasInterrupts(cause) + ? Effect.failCause(cause) + : Effect.logWarning("startup thread auto-bootstrap failed", { + bootstrapProjectId: nextProjectId, + cause, + }), + ), + ); }); } return { ...(bootstrapProjectId ? { bootstrapProjectId } : {}), ...(bootstrapThreadId ? { bootstrapThreadId } : {}), + ...(bootstrapProjectId ? { bootstrapProjectCreated } : {}), + ...(bootstrapThreadId ? { bootstrapThreadCreated } : {}), } as const; }); +export const completeAutoBootstrapWelcome = ( + bootstrap: Effect.Effect, +) => + bootstrap.pipe( + Effect.matchCauseEffect({ + onFailure: (cause) => + Cause.hasInterrupts(cause) + ? Effect.failCause(cause) + : Effect.logWarning("startup auto-bootstrap failed", { cause }).pipe( + Effect.as({ bootstrapStatus: "complete" as const }), + ), + onSuccess: (targets) => + Effect.succeed({ + ...targets, + bootstrapStatus: "complete" as const, + }), + }), + ); + const resolveStartupBrowserTarget = Effect.gen(function* () { const serverConfig = yield* ServerConfig.ServerConfig; const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; @@ -899,36 +927,31 @@ export const make = (options?: StartupOptions) => runStartupPhase( "welcome.autobootstrap", Effect.gen(function* () { - const bootstrapTargets = yield* resolveAutoBootstrapWelcomeTargets.pipe( - Effect.provideService(Crypto.Crypto, crypto), + const bootstrapCompletion = yield* completeAutoBootstrapWelcome( + resolveAutoBootstrapWelcomeTargets.pipe( + Effect.provideService(Crypto.Crypto, crypto), + ), + ); + + yield* Effect.logDebug( + "startup phase: publishing completed bootstrap welcome event", + { + environmentId: environment.environmentId, + cwd: welcomeBase.cwd, + projectName: welcomeBase.projectName, + ...bootstrapCompletion, + }, ); - if (!bootstrapTargets.bootstrapProjectId && !bootstrapTargets.bootstrapThreadId) { - return; - } - - yield* Effect.logDebug("startup phase: publishing bootstrapped welcome event", { - environmentId: environment.environmentId, - cwd: welcomeBase.cwd, - projectName: welcomeBase.projectName, - bootstrapProjectId: bootstrapTargets.bootstrapProjectId, - bootstrapThreadId: bootstrapTargets.bootstrapThreadId, - }); yield* lifecycleEvents.publish({ version: 1, type: "welcome", payload: { environment, ...welcomeBase, - ...bootstrapTargets, + ...bootstrapCompletion, }, }); - }).pipe( - Effect.catch((cause) => - Effect.logWarning("startup auto-bootstrap welcome failed", { - cause, - }), - ), - ), + }).pipe(Effect.ignoreCause({ log: true })), ), ); } @@ -1007,7 +1030,11 @@ export const make = (options?: StartupOptions) => lifecycleEvents.publish({ version: 1, type: "welcome", - payload: { environment, ...welcomeBase }, + payload: { + environment, + ...welcomeBase, + bootstrapStatus: serverConfig.autoBootstrapProjectFromCwd ? "pending" : "complete", + }, }), ); yield* options?.activate ?? Effect.void; diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 4526e2c988a4..6e837308e8cb 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -14,6 +14,7 @@ import * as Duration from "effect/Duration"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; @@ -22,6 +23,7 @@ import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; import * as ServerConfig from "./config.ts"; import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts"; import * as ServerSettingsModule from "./serverSettings.ts"; +import { resolveProviderInstanceTerminalEnvironment } from "./terminal/Manager.ts"; const decodeSettingsPatch = Schema.decodeUnknownEffect(ServerSettingsPatch); const decodeServerSettings = Schema.decodeUnknownEffect(ServerSettings); @@ -1102,4 +1104,39 @@ it.layer(NodeServices.layer)("server settings", (it) => { ); }).pipe(Effect.provide(makeServerSettingsLayer())), ); + + it.effect("materializes provider secrets for terminal environment resolution", () => + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const instanceId = ProviderInstanceId.make("codex_terminal"); + + yield* serverSettings.updateSettings({ + providerInstances: { + [instanceId]: { + driver: ProviderDriverKind.make("codex"), + environment: [ + { name: "OPENROUTER_API_KEY", value: "sk-terminal-secret", sensitive: true }, + ], + config: { homePath: "~/.codex-terminal" }, + }, + }, + }); + + const environment = yield* resolveProviderInstanceTerminalEnvironment({ + serverSettings, + path, + rawProviderInstanceId: instanceId, + env: undefined, + }); + const persisted = yield* fileSystem.readFileString(serverConfig.settingsPath); + + assert.equal(environment.OPENROUTER_API_KEY, "sk-terminal-secret"); + assert.match(environment.CODEX_HOME ?? "", /[\\/][.]codex-terminal$/); + assert.notInclude(persisted, "sk-terminal-secret"); + assert.include(persisted, '"valueRedacted": true'); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); }); diff --git a/apps/server/src/telemetry/Identify.test.ts b/apps/server/src/telemetry/Identify.test.ts index ab151821789a..92d3223267d7 100644 --- a/apps/server/src/telemetry/Identify.test.ts +++ b/apps/server/src/telemetry/Identify.test.ts @@ -33,26 +33,6 @@ const findIdentityLog = ( errorTag: string, ) => logs.find((log) => log.annotations.source === source && log.annotations.errorTag === errorTag); -it("preserves exact telemetry identity causes without deriving messages from them", () => { - const decodeCause = new Error("private nested decode details"); - const decodeError = new Identify.TelemetryIdentityDecodeError({ - source: "codex", - filePath: "/tmp/auth.json", - cause: decodeCause, - }); - const readCause = new Error("private nested read details"); - const readError = new Identify.TelemetryIdentityReadError({ - source: "anonymous", - filePath: "/tmp/anonymous-id", - cause: readCause, - }); - - assert.strictEqual(decodeError.cause, decodeCause); - assert.strictEqual(readError.cause, readCause); - assert.notInclude(decodeError.message, decodeCause.message); - assert.notInclude(readError.message, readCause.message); -}); - it.layer(NodeServices.layer)("telemetry identity", (it) => { it.effect("uses the persisted anonymous id when provider identities are absent", () => Effect.gen(function* () { diff --git a/apps/server/src/telemetry/Identify.ts b/apps/server/src/telemetry/Identify.ts index b6c3d0066dff..15d3bf13f782 100644 --- a/apps/server/src/telemetry/Identify.ts +++ b/apps/server/src/telemetry/Identify.ts @@ -23,7 +23,7 @@ const ClaudeJsonSchema = Schema.Struct({ export const TelemetryIdentitySource = Schema.Literals(["codex", "claude", "anonymous"]); export type TelemetryIdentitySource = typeof TelemetryIdentitySource.Type; -export class TelemetryIdentityReadError extends Schema.TaggedErrorClass()( +class TelemetryIdentityReadError extends Schema.TaggedErrorClass()( "TelemetryIdentityReadError", { source: TelemetryIdentitySource, @@ -36,7 +36,7 @@ export class TelemetryIdentityReadError extends Schema.TaggedErrorClass()( +class TelemetryIdentityDecodeError extends Schema.TaggedErrorClass()( "TelemetryIdentityDecodeError", { source: Schema.Literals(["codex", "claude"]), diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index 24e8aadebb29..d1cb376c502a 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -7,9 +7,14 @@ import { type TerminalMetadataStreamEvent, type TerminalOpenInput, type TerminalRestartInput, + ProviderDriverKind, + ProviderInstanceId, + ServerSettingsError, + TerminalProviderInstanceNotFoundError, } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Data from "effect/Data"; +import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Encoding from "effect/Encoding"; @@ -23,11 +28,16 @@ import * as Path from "effect/Path"; import * as Ref from "effect/Ref"; import * as Schedule from "effect/Schedule"; import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import { ChildProcessSpawner } from "effect/unstable/process"; import { expect } from "vite-plus/test"; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as ServerConfig from "../config.ts"; +import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; import * as ProcessRunner from "../processRunner.ts"; +import * as ServerSettings from "../serverSettings.ts"; import * as TerminalManager from "./Manager.ts"; import * as PtyAdapter from "./PtyAdapter.ts"; @@ -215,6 +225,9 @@ interface CreateManagerOptions { maxRetainedInactiveSessions?: number; historyByteLimit?: number; ptyAdapter?: FakePtyAdapter; + resolveProviderInstanceEnvironment?: Parameters< + typeof TerminalManager.makeWithOptions + >[0]["resolveProviderInstanceEnvironment"]; } interface ManagerFixture { @@ -259,6 +272,9 @@ const createManager = ( ...(options.maxRetainedInactiveSessions !== undefined ? { maxRetainedInactiveSessions: options.maxRetainedInactiveSessions } : {}), + ...(options.resolveProviderInstanceEnvironment !== undefined + ? { resolveProviderInstanceEnvironment: options.resolveProviderInstanceEnvironment } + : {}), }); const eventsRef = yield* Ref.make>([]); const unsubscribe = yield* manager.subscribe((event) => @@ -1736,6 +1752,26 @@ it.layer( }), ); + it.effect("expands provider home paths passed to setup terminals", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(5); + + yield* manager.open({ + ...openInput(), + env: { + CODEX_HOME: "~/.codex-work", + CLAUDE_CONFIG_DIR: "~/.claude-work", + CUSTOM_ACCOUNT: "~/leave-this-value-alone", + }, + }); + + const environment = ptyAdapter.spawnInputs[0]?.env; + expect(environment?.CODEX_HOME).toMatch(/[\\/][.]codex-work$/); + expect(environment?.CLAUDE_CONFIG_DIR).toMatch(/[\\/][.]claude-work$/); + expect(environment?.CUSTOM_ACCOUNT).toBe("~/leave-this-value-alone"); + }), + ); + it.effect("strips AppImage runtime env from terminal sessions", () => Effect.gen(function* () { const appDir = "/tmp/.mount_T3Codeabc123"; @@ -1822,6 +1858,382 @@ it.layer( }), ); + it.effect("resolves a provider instance environment before spawning", () => + Effect.gen(function* () { + const providerInstanceId = ProviderInstanceId.make("codex_work"); + const { manager, ptyAdapter } = yield* createManager(5, { + env: { T3CODE_SECRET: "server-only" }, + resolveProviderInstanceEnvironment: (requestedId, env) => + Effect.succeed({ + ...env, + PROVIDER_SECRET: requestedId === providerInstanceId ? "secret-value" : "wrong", + CODEX_HOME: "/accounts/codex-work", + }), + }); + + const snapshot = yield* manager.open( + openInput({ providerInstanceId, env: { CLIENT_FLAG: "1" } }), + ); + + expect(ptyAdapter.spawnInputs[0]?.env.PROVIDER_SECRET).toBe("secret-value"); + expect(ptyAdapter.spawnInputs[0]?.env.CODEX_HOME).toBe("/accounts/codex-work"); + expect(ptyAdapter.spawnInputs[0]?.env.CLIENT_FLAG).toBe("1"); + expect(ptyAdapter.spawnInputs[0]?.env.T3CODE_SECRET).toBeUndefined(); + expect(snapshot).not.toHaveProperty("env"); + expect(snapshot).not.toHaveProperty("providerInstanceId"); + }), + ); + + it.effect("fails closed when a provider instance is missing", () => + Effect.gen(function* () { + const providerInstanceId = ProviderInstanceId.make("deleted_instance"); + const { manager, ptyAdapter } = yield* createManager(5, { + resolveProviderInstanceEnvironment: (requestedId) => + Effect.fail( + new TerminalProviderInstanceNotFoundError({ + providerInstanceId: ProviderInstanceId.make(requestedId), + }), + ), + }); + + const error = yield* manager.open(openInput({ providerInstanceId })).pipe(Effect.flip); + + assert.deepStrictEqual( + error, + new TerminalProviderInstanceNotFoundError({ providerInstanceId }), + ); + expect(ptyAdapter.spawnInputs).toHaveLength(0); + }), + ); + + it.effect("preserves the settings failure when provider environment resolution fails", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const providerInstanceId = ProviderInstanceId.make("codex_work"); + const settingsCause = new Error("secret store read failed"); + const settingsError = new ServerSettingsError({ + settingsPath: "/test/settings.json", + operation: "read-secret", + providerInstanceId, + environmentVariable: "OPENROUTER_API_KEY", + cause: settingsCause, + }); + const serverSettings = ServerSettings.ServerSettingsService.of({ + start: Effect.void, + ready: Effect.void, + getSettings: Effect.fail(settingsError), + updateSettings: () => Effect.fail(settingsError), + streamChanges: Stream.empty, + subscribeChanges: Effect.succeed(Stream.empty), + }); + + const error = yield* TerminalManager.resolveProviderInstanceTerminalEnvironment({ + serverSettings, + path, + rawProviderInstanceId: providerInstanceId, + env: undefined, + }).pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "TerminalProviderEnvironmentError", + providerInstanceId, + }); + expect(error.cause).toBe(settingsError); + expect(error.message).not.toContain(settingsError.message); + expect(error.message).not.toContain("OPENROUTER_API_KEY"); + }), + ); + + it.effect.each([ + { + name: "Codex home", + driver: "codex", + variable: "CODEX_HOME", + config: { homePath: "/configured/codex" }, + expectedHome: "/configured/codex", + }, + { + name: "Codex shadow home", + driver: "codex", + variable: "CODEX_HOME", + config: { homePath: "/configured/codex", shadowHomePath: "/configured/codex-shadow" }, + expectedHome: "/configured/codex-shadow", + }, + { + name: "Claude home", + driver: "claudeAgent", + variable: "CLAUDE_CONFIG_DIR", + config: { homePath: "/configured/claude" }, + expectedHome: "/configured/claude", + }, + ])("prefers $name over the instance environment", ({ driver, variable, config, expectedHome }) => + Effect.gen(function* () { + const path = yield* Path.Path; + const serverSettings = yield* ServerSettings.ServerSettingsService; + const environment = yield* TerminalManager.resolveProviderInstanceTerminalEnvironment({ + serverSettings, + path, + rawProviderInstanceId: "configured_home", + env: undefined, + }); + + expect(environment[variable]).toBe(path.resolve(expectedHome)); + }).pipe( + Effect.provide( + ServerSettings.layerTest({ + providerInstances: { + [ProviderInstanceId.make("configured_home")]: { + driver: ProviderDriverKind.make(driver), + environment: [{ name: variable, value: "~/.environment-account", sensitive: false }], + config, + }, + }, + }), + ), + ), + ); + + it.effect("resolves the legacy Codex default instance", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const serverSettings = yield* ServerSettings.ServerSettingsService; + const environment = yield* TerminalManager.resolveProviderInstanceTerminalEnvironment({ + serverSettings, + path, + rawProviderInstanceId: "codex", + env: undefined, + }); + + expect(environment.CODEX_HOME).toMatch(/[\\/][.]codex-legacy$/); + }).pipe( + Effect.provide( + ServerSettings.ServerSettingsService.layerTest({ + providerInstances: {}, + providers: { codex: { homePath: "~/.codex-legacy" } }, + }), + ), + ), + ); + + it.effect("resolves the legacy Claude default instance", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const serverSettings = yield* ServerSettings.ServerSettingsService; + const environment = yield* TerminalManager.resolveProviderInstanceTerminalEnvironment({ + serverSettings, + path, + rawProviderInstanceId: "claudeAgent", + env: undefined, + }); + + expect(environment.CLAUDE_CONFIG_DIR).toMatch(/[\\/][.]claude-legacy$/); + }).pipe( + Effect.provide( + ServerSettings.ServerSettingsService.layerTest({ + providerInstances: {}, + providers: { claudeAgent: { homePath: "~/.claude-legacy" } }, + }), + ), + ), + ); + + it.effect("prefers an explicit default instance over legacy provider settings", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const serverSettings = yield* ServerSettings.ServerSettingsService; + const environment = yield* TerminalManager.resolveProviderInstanceTerminalEnvironment({ + serverSettings, + path, + rawProviderInstanceId: "codex", + env: undefined, + }); + + expect(environment.CODEX_HOME).toMatch(/[\\/][.]codex-explicit$/); + }).pipe( + Effect.provide( + ServerSettings.ServerSettingsService.layerTest({ + providers: { codex: { homePath: "~/.codex-legacy" } }, + providerInstances: { + [ProviderInstanceId.make("codex")]: { + driver: "codex", + config: { homePath: "~/.codex-explicit" }, + }, + }, + }), + ), + ), + ); + + it.effect("keeps unknown provider instance ids unavailable after legacy hydration", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const serverSettings = yield* ServerSettings.ServerSettingsService; + const error = yield* TerminalManager.resolveProviderInstanceTerminalEnvironment({ + serverSettings, + path, + rawProviderInstanceId: "codex_unknown", + env: undefined, + }).pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "TerminalProviderInstanceNotFoundError", + providerInstanceId: "codex_unknown", + }); + }).pipe(Effect.provide(ServerSettings.ServerSettingsService.layerTest())), + ); + + it.effect("restarts a running terminal when the resolved provider environment changes", () => + Effect.gen(function* () { + const providerInstanceId = ProviderInstanceId.make("codex_work"); + let providerSecret = "first-secret"; + const { manager, ptyAdapter } = yield* createManager(5, { + resolveProviderInstanceEnvironment: () => + Effect.succeed({ PROVIDER_SECRET: providerSecret }), + }); + + yield* manager.open(openInput({ providerInstanceId })); + providerSecret = "second-secret"; + yield* manager.open(openInput({ providerInstanceId })); + + expect(ptyAdapter.processes[0]?.killed).toBe(true); + expect(ptyAdapter.spawnInputs).toHaveLength(2); + expect(ptyAdapter.spawnInputs[1]?.env.PROVIDER_SECRET).toBe("second-secret"); + }), + ); + + it.effect("restarts with current provider secrets and clears bounded history", () => + Effect.gen(function* () { + const serverSettings = yield* ServerSettings.ServerSettingsService; + const path = yield* Path.Path; + const providerInstanceId = ProviderInstanceId.make("codex_restart"); + const { manager, ptyAdapter, logsDir } = yield* createManager(2, { + historyByteLimit: 8, + resolveProviderInstanceEnvironment: (rawProviderInstanceId, env) => + TerminalManager.resolveProviderInstanceTerminalEnvironment({ + serverSettings, + path, + rawProviderInstanceId, + env, + }), + }); + const homePath = path.join(logsDir, "codex"); + const updateSecret = (value: string) => + serverSettings.updateSettings({ + providerInstances: { + [providerInstanceId]: { + driver: ProviderDriverKind.make("codex"), + config: { homePath }, + environment: [{ name: "PROVIDER_SECRET", value, sensitive: true }], + }, + }, + }); + const input = { + providerInstanceId, + env: { CLIENT_FLAG: "1", PROVIDER_SECRET: "client-value" }, + }; + const outputProcessed = yield* Deferred.make(); + const unsubscribe = yield* manager.subscribe((event) => + event.type === "output" + ? Deferred.succeed(outputProcessed, undefined).pipe(Effect.asVoid) + : Effect.void, + ); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); + + yield* updateSecret("first-secret"); + yield* manager.restart(restartInput(input)); + const firstProcess = ptyAdapter.processes[0]!; + expect(ptyAdapter.spawnInputs[0]?.env.PROVIDER_SECRET).toBe("first-secret"); + firstProcess.emitData("discarded\nold-one\nold-two\n"); + yield* Deferred.await(outputProcessed); + expect((yield* manager.open(openInput(input))).history).toBe("old-two\n"); + + yield* updateSecret("second-secret"); + const restarted = yield* manager.restart(restartInput(input)); + + expect(firstProcess.killed).toBe(true); + expect(ptyAdapter.spawnInputs).toHaveLength(2); + expect(ptyAdapter.spawnInputs[1]?.env).toMatchObject({ + PROVIDER_SECRET: "second-secret", + CODEX_HOME: homePath, + CLIENT_FLAG: "1", + }); + expect(restarted.history).toBe(""); + expect(restarted.status).toBe("running"); + expect(restarted).not.toHaveProperty("env"); + expect(restarted).not.toHaveProperty("providerInstanceId"); + const logPath = yield* historyLogPath(logsDir); + expect(yield* readFileString(logPath)).toBe(""); + + ptyAdapter.processes[1]!.emitData("discarded again\nnew-one\nnew-two\n"); + yield* manager.close({ threadId: "thread-1" }); + expect(yield* readFileString(logPath)).toBe("new-two\n"); + }).pipe( + Effect.provide( + ServerSettings.layer.pipe( + Layer.provide(ServerSecretStore.layer), + Layer.provide(SqlitePersistenceMemory), + Layer.provide( + ServerConfig.layerTest(process.cwd(), { prefix: "t3code-terminal-provider-restart-" }), + ), + ), + ), + ), + ); + + it.effect("attaches to a running provider terminal without resolving the provider again", () => + Effect.gen(function* () { + const providerInstanceId = ProviderInstanceId.make("codex_work"); + let providerAvailable = true; + const { manager, ptyAdapter } = yield* createManager(5, { + resolveProviderInstanceEnvironment: (requestedId) => + providerAvailable + ? Effect.succeed({ PROVIDER_SECRET: "secret-value" }) + : Effect.fail( + new TerminalProviderInstanceNotFoundError({ + providerInstanceId: ProviderInstanceId.make(requestedId), + }), + ), + }); + yield* manager.open(openInput({ providerInstanceId })); + providerAvailable = false; + const events: TerminalAttachStreamEvent[] = []; + + const unsubscribe = yield* manager.attachStream( + { ...openInput({ providerInstanceId }), restartIfNotRunning: true }, + (event) => Effect.sync(() => events.push(event)), + ); + unsubscribe(); + + expect(events[0]?.type).toBe("snapshot"); + expect(ptyAdapter.spawnInputs).toHaveLength(1); + expect(ptyAdapter.processes[0]?.killed).toBe(false); + }), + ); + + it.effect("fails closed when attaching would create a missing provider terminal", () => + Effect.gen(function* () { + const providerInstanceId = ProviderInstanceId.make("deleted_instance"); + const { manager, ptyAdapter } = yield* createManager(5, { + resolveProviderInstanceEnvironment: (requestedId) => + Effect.fail( + new TerminalProviderInstanceNotFoundError({ + providerInstanceId: ProviderInstanceId.make(requestedId), + }), + ), + }); + + const error = yield* manager + .attachStream(openInput({ providerInstanceId }), () => Effect.void) + .pipe(Effect.flip); + + assert.deepStrictEqual( + error, + new TerminalProviderInstanceNotFoundError({ providerInstanceId }), + ); + expect(ptyAdapter.spawnInputs).toHaveLength(0); + }), + ); + it.effect("starts zsh with prompt spacer disabled to avoid `%` end markers", () => Effect.gen(function* () { if ((yield* HostProcessPlatform) === "win32") return; diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 4a3ec35ff57e..e3a0e34cf252 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -15,6 +15,8 @@ import { TerminalError, TerminalHistoryError, TerminalNotRunningError, + TerminalProviderInstanceNotFoundError, + TerminalProviderEnvironmentError, TerminalResizeError, TerminalSessionLookupError, TerminalWriteError, @@ -31,6 +33,9 @@ import { type TerminalSessionStatus, type TerminalSummary, type TerminalWriteInput, + ClaudeSettings, + CodexSettings, + ProviderInstanceId, } from "@t3tools/contracts"; import { makeKeyedCoalescingWorker } from "@t3tools/shared/KeyedCoalescingWorker"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; @@ -53,12 +58,17 @@ import * as SynchronizedRef from "effect/SynchronizedRef"; import * as Stream from "effect/Stream"; import * as ServerConfig from "../config.ts"; -import { ServerSettingsService } from "../serverSettings.ts"; +import { mergeProviderInstanceEnvironment } from "../provider/ProviderInstanceEnvironment.ts"; +import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts"; +import { makeClaudeEnvironment } from "../provider/Drivers/ClaudeHome.ts"; +import { deriveProviderInstanceConfigMap } from "../provider/Layers/ProviderInstanceRegistryHydration.ts"; +import * as ServerSettings from "../serverSettings.ts"; import { increment, terminalRestartsTotal, terminalSessionsTotal, } from "../observability/Metrics.ts"; +import { expandHomePath } from "../pathExpansion.ts"; import * as ProcessRunner from "../processRunner.ts"; import * as PortScanner from "../preview/PortScanner.ts"; import * as PtyAdapter from "./PtyAdapter.ts"; @@ -71,6 +81,8 @@ export { TerminalError, TerminalHistoryError, TerminalNotRunningError, + TerminalProviderInstanceNotFoundError, + TerminalProviderEnvironmentError, TerminalResizeError, TerminalSessionLookupError, TerminalWriteError, @@ -88,6 +100,8 @@ const DEFAULT_OPEN_ROWS = 30; const TERMINAL_ENV_BLOCKLIST = new Set(["PORT", "ELECTRON_RENDERER_PORT", "ELECTRON_RUN_AS_NODE"]); const nowIso = Effect.map(DateTime.now, DateTime.formatIso); const MAX_TERMINAL_LABEL_LENGTH = 128; +const decodeClaudeSettings = Schema.decodeUnknownOption(ClaudeSettings); +const decodeCodexSettings = Schema.decodeUnknownOption(CodexSettings); class TerminalSubprocessCheckError extends Schema.TaggedErrorClass()( "TerminalSubprocessCheckError", @@ -1269,7 +1283,8 @@ function createTerminalSpawnEnv( } if (runtimeEnv) { for (const [key, value] of Object.entries(runtimeEnv)) { - spawnEnv[key] = value; + spawnEnv[key] = + key === "CODEX_HOME" || key === "CLAUDE_CONFIG_DIR" ? expandHomePath(value) : value; } } // Both PTY backends feed truecolor-capable terminal clients. @@ -1308,14 +1323,62 @@ interface TerminalManagerOptions { readonly threadId: string; readonly terminalId: string; }) => Effect.Effect; + resolveProviderInstanceEnvironment?: ( + providerInstanceId: string, + env: Record | undefined, + ) => Effect.Effect< + Record, + TerminalProviderInstanceNotFoundError | TerminalProviderEnvironmentError + >; } +export const resolveProviderInstanceTerminalEnvironment = Effect.fn( + "terminal.resolveProviderInstanceTerminalEnvironment", +)(function* (input: { + readonly serverSettings: ServerSettings.ServerSettingsService["Service"]; + readonly path: Path.Path; + readonly rawProviderInstanceId: string; + readonly env: Record | undefined; +}) { + const providerInstanceId = ProviderInstanceId.make(input.rawProviderInstanceId); + const settings = yield* input.serverSettings.getSettings.pipe( + Effect.mapError((cause) => new TerminalProviderEnvironmentError({ providerInstanceId, cause })), + ); + const instance = deriveProviderInstanceConfigMap(settings)[providerInstanceId]; + if (instance === undefined) { + return yield* new TerminalProviderInstanceNotFoundError({ providerInstanceId }); + } + + let resolved = mergeProviderInstanceEnvironment(instance.environment, input.env ?? {}); + if (instance.driver === "codex") { + const config = decodeCodexSettings(instance.config ?? {}); + if (Option.isSome(config)) { + const layout = yield* resolveCodexHomeLayout(config.value).pipe( + Effect.provideService(Path.Path, input.path), + ); + if (layout.effectiveHomePath) + resolved = { ...resolved, CODEX_HOME: layout.effectiveHomePath }; + } + } else if (instance.driver === "claudeAgent") { + const config = decodeClaudeSettings(instance.config ?? {}); + if (Option.isSome(config)) { + resolved = yield* makeClaudeEnvironment(config.value, resolved).pipe( + Effect.provideService(Path.Path, input.path), + ); + } + } + + return Object.fromEntries( + Object.entries(resolved).filter((entry): entry is [string, string] => entry[1] !== undefined), + ); +}); + export const make = Effect.fn("TerminalManager.make")(function* () { const { terminalLogsDir } = yield* ServerConfig.ServerConfig; const ptyAdapter = yield* PtyAdapter.PtyAdapter; const portDiscovery = yield* PortScanner.PortDiscovery; const platform = yield* HostProcessPlatform; - const serverSettings = yield* ServerSettingsService; + const serverSettings = yield* ServerSettings.ServerSettingsService; // Preferred terminal shell, sourced live from `settings.terminalShell`. // Held in a plain closure variable so the synchronous `shellResolver` @@ -1336,6 +1399,17 @@ export const make = Effect.fn("TerminalManager.make")(function* () { Effect.forkScoped, ); + const path = yield* Path.Path; + const resolveProviderInstanceEnvironment = Effect.fn( + "terminal.resolveProviderInstanceEnvironment", + )((rawProviderInstanceId: string, env: Record | undefined) => + resolveProviderInstanceTerminalEnvironment({ + serverSettings, + path, + rawProviderInstanceId, + env, + }), + ); return yield* makeWithOptions({ logsDir: terminalLogsDir, ptyAdapter, @@ -1345,6 +1419,7 @@ export const make = Effect.fn("TerminalManager.make")(function* () { : defaultShellResolver(platform, process.env), registerTerminalProcesses: portDiscovery.registerTerminalProcesses, unregisterTerminal: portDiscovery.unregisterTerminal, + resolveProviderInstanceEnvironment, }); }); @@ -1367,6 +1442,24 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func const baseEnv = options.env ?? process.env; const shellResolver = options.shellResolver ?? (() => defaultShellResolver(platform, baseEnv)); const processRunner = yield* ProcessRunner.ProcessRunner; + const resolveLaunchInputEnvironment = Effect.fn("terminal.resolveLaunchInputEnvironment")( + function* ( + input: Input, + ): Effect.fn.Return< + Input, + TerminalProviderInstanceNotFoundError | TerminalProviderEnvironmentError + > { + if (input.providerInstanceId === undefined) return input; + const resolver = options.resolveProviderInstanceEnvironment; + if (resolver === undefined) { + return yield* new TerminalProviderInstanceNotFoundError({ + providerInstanceId: ProviderInstanceId.make(input.providerInstanceId), + }); + } + const env = yield* resolver(input.providerInstanceId, input.env); + return { ...input, env }; + }, + ); // One process-table snapshot per poll tick, shared across every terminal. // Per-terminal `pgrep`/`ps` calls multiply spawn load by terminal count and // can exhaust the PID space on hosts with many sessions (#6332). @@ -2500,7 +2593,10 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func }); const open: TerminalManager["Service"]["open"] = (input) => - withThreadLock(input.threadId, openLocked(input)); + withThreadLock( + input.threadId, + resolveLaunchInputEnvironment(input).pipe(Effect.flatMap(openLocked)), + ); const openOrAttachForStream = (input: TerminalAttachInput) => withThreadLock( @@ -2517,11 +2613,12 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func }); } - return yield* openLocked({ + const resolvedInput = yield* resolveLaunchInputEnvironment({ ...input, terminalId, cwd: input.cwd, }); + return yield* openLocked(resolvedInput); } const session = existing.value; @@ -2529,11 +2626,12 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func const targetRows = input.rows ?? session.rows; if (!session.process && input.cwd && input.restartIfNotRunning === true) { - return yield* openLocked({ + const resolvedInput = yield* resolveLaunchInputEnvironment({ ...input, terminalId, cwd: input.cwd, }); + return yield* openLocked(resolvedInput); } if ( @@ -2785,84 +2883,87 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func }), ); + const restartResolved = (input: TerminalRestartInput) => + Effect.gen(function* () { + yield* increment(terminalRestartsTotal, { scope: "thread" }); + const terminalId = input.terminalId; + yield* assertValidCwd(input.cwd); + + const sessionKey = toSessionKey(input.threadId, terminalId); + const existingSession = yield* getSession(input.threadId, terminalId); + let session: TerminalSessionState; + if (Option.isNone(existingSession)) { + const cols = input.cols ?? DEFAULT_OPEN_COLS; + const rows = input.rows ?? DEFAULT_OPEN_ROWS; + session = { + threadId: input.threadId, + terminalId, + cwd: input.cwd, + worktreePath: input.worktreePath ?? null, + status: "starting", + pid: null, + history: new BoundedTerminalHistory(historyLineLimit, "", historyByteLimit), + pendingHistoryControlSequence: "", + pendingProcessEvents: [], + pendingProcessEventIndex: 0, + processEventDrainRunning: false, + exitCode: null, + exitSignal: null, + updatedAt: yield* nowIso, + eventSequence: 0, + cols, + rows, + process: null, + unsubscribeData: null, + unsubscribeExit: null, + hasRunningSubprocess: false, + childCommandLabel: null, + runtimeEnv: normalizedRuntimeEnv(input.env), + }; + const createdSession = session; + yield* modifyManagerState((state) => { + const sessions = new Map(state.sessions); + sessions.set(sessionKey, createdSession); + return [undefined, { ...state, sessions }] as const; + }); + yield* evictInactiveSessionsIfNeeded(); + } else { + session = existingSession.value; + yield* stopProcess(session); + session.cwd = input.cwd; + session.worktreePath = input.worktreePath ?? null; + session.runtimeEnv = normalizedRuntimeEnv(input.env); + } + + const cols = input.cols ?? session.cols; + const rows = input.rows ?? session.rows; + + session.history.clear(); + session.pendingHistoryControlSequence = ""; + session.pendingProcessEvents = []; + session.pendingProcessEventIndex = 0; + session.processEventDrainRunning = false; + yield* persistHistory(input.threadId, terminalId, session.history); + yield* startSession( + session, + { + threadId: input.threadId, + terminalId, + cwd: input.cwd, + ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}), + cols, + rows, + ...(input.env ? { env: input.env } : {}), + }, + "restarted", + ); + return snapshot(session); + }); + const restart: TerminalManager["Service"]["restart"] = (input) => withThreadLock( input.threadId, - Effect.gen(function* () { - yield* increment(terminalRestartsTotal, { scope: "thread" }); - const terminalId = input.terminalId; - yield* assertValidCwd(input.cwd); - - const sessionKey = toSessionKey(input.threadId, terminalId); - const existingSession = yield* getSession(input.threadId, terminalId); - let session: TerminalSessionState; - if (Option.isNone(existingSession)) { - const cols = input.cols ?? DEFAULT_OPEN_COLS; - const rows = input.rows ?? DEFAULT_OPEN_ROWS; - session = { - threadId: input.threadId, - terminalId, - cwd: input.cwd, - worktreePath: input.worktreePath ?? null, - status: "starting", - pid: null, - history: new BoundedTerminalHistory(historyLineLimit, "", historyByteLimit), - pendingHistoryControlSequence: "", - pendingProcessEvents: [], - pendingProcessEventIndex: 0, - processEventDrainRunning: false, - exitCode: null, - exitSignal: null, - updatedAt: yield* nowIso, - eventSequence: 0, - cols, - rows, - process: null, - unsubscribeData: null, - unsubscribeExit: null, - hasRunningSubprocess: false, - childCommandLabel: null, - runtimeEnv: normalizedRuntimeEnv(input.env), - }; - const createdSession = session; - yield* modifyManagerState((state) => { - const sessions = new Map(state.sessions); - sessions.set(sessionKey, createdSession); - return [undefined, { ...state, sessions }] as const; - }); - yield* evictInactiveSessionsIfNeeded(); - } else { - session = existingSession.value; - yield* stopProcess(session); - session.cwd = input.cwd; - session.worktreePath = input.worktreePath ?? null; - session.runtimeEnv = normalizedRuntimeEnv(input.env); - } - - const cols = input.cols ?? session.cols; - const rows = input.rows ?? session.rows; - - session.history.clear(); - session.pendingHistoryControlSequence = ""; - session.pendingProcessEvents = []; - session.pendingProcessEventIndex = 0; - session.processEventDrainRunning = false; - yield* persistHistory(input.threadId, terminalId, session.history); - yield* startSession( - session, - { - threadId: input.threadId, - terminalId, - cwd: input.cwd, - ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}), - cols, - rows, - ...(input.env ? { env: input.env } : {}), - }, - "restarted", - ); - return snapshot(session); - }), + resolveLaunchInputEnvironment(input).pipe(Effect.flatMap(restartResolved)), ); const close: TerminalManager["Service"]["close"] = (input) => diff --git a/apps/server/src/textGeneration/TextGenerationPrompts.test.ts b/apps/server/src/textGeneration/TextGenerationPrompts.test.ts index 253d88779827..05f45b4cb3b0 100644 --- a/apps/server/src/textGeneration/TextGenerationPrompts.test.ts +++ b/apps/server/src/textGeneration/TextGenerationPrompts.test.ts @@ -146,7 +146,7 @@ describe("buildBranchNamePrompt", () => { }); describe("buildThreadTitlePrompt", () => { - it("includes the user message and the title guidance rules", () => { + it("includes the user message without absent attachment metadata", () => { const result = buildThreadTitlePrompt({ message: "Investigate reconnect regressions after session restore", }); @@ -154,18 +154,6 @@ describe("buildThreadTitlePrompt", () => { expect(result.prompt).toContain("User message:"); expect(result.prompt).toContain("Investigate reconnect regressions after session restore"); expect(result.prompt).not.toContain("Attachment metadata:"); - expect(result.prompt).toContain( - "Generate a title that will help the user recognize this T3 Code thread weeks later.", - ); - expect(result.prompt).toContain( - "Title the subject and outcome. Discard incidental instructions.", - ); - expect(result.prompt).toContain( - "Name the product change, not the mock, plan, report, branch, or PR used to produce it.", - ); - expect(result.prompt).not.toContain( - "Title should summarize the user's request, not restate it verbatim.", - ); }); it("includes attachment metadata when attachments are provided", () => { @@ -188,24 +176,6 @@ describe("buildThreadTitlePrompt", () => { expect(result.prompt).toContain("67890 bytes"); }); - it.each([ - { mode: "initial", previousTitle: undefined }, - { mode: "regeneration", previousTitle: "Open Projects in Desktop App" }, - ])( - "tells the $mode prompt not to title linked PRs from local git history", - ({ previousTitle }) => { - const result = buildThreadTitlePrompt({ - message: "$takeover https://github.com/pingdotgg/t3code/pull/8588", - ...(previousTitle === undefined ? {} : { previousTitle }), - }); - - expect(result.prompt).toContain( - "Local git history is not evidence of what a linked PR or issue is about.", - ); - expect(result.prompt).toContain('such as "Take Over PR 8588"'); - }, - ); - it("regenerates from recent thread contents and identifies the previous title", () => { const result = buildThreadTitlePrompt({ message: `USER:\nInvestigate reconnect regressions\n\nASSISTANT:\nThe remaining issue is stale session state`, @@ -216,15 +186,6 @@ describe("buildThreadTitlePrompt", () => { "Regenerate the title for an existing T3 Code thread so the user can recognize it weeks later.", ); expect(result.prompt).toContain('The previous title was "Investigate reconnect regressions".'); - expect(result.prompt).toContain( - "Read the USER messages first. Identify the latest explicit durable goal.", - ); - expect(result.prompt).toContain( - "Do not promote one assistant finding into the thread subject unless the user adopts it as a new goal.", - ); - expect(result.prompt).toContain( - 'A subagent-monitoring review that finds a Codex roster bug remains "Review Subagent Monitoring Risks,"', - ); expect(result.prompt).toContain("Thread contents:"); expect(result.prompt).toContain("The remaining issue is stale session state"); }); diff --git a/apps/server/src/usage/usagePricing.test.ts b/apps/server/src/usage/usagePricing.test.ts index d45dfe2dd09b..713d860999cb 100644 --- a/apps/server/src/usage/usagePricing.test.ts +++ b/apps/server/src/usage/usagePricing.test.ts @@ -4,7 +4,6 @@ import { cacheSavingsUsd, createOverrideRateTable, lookupRate, - normalizeModelName, parseRateTable, priceUsage, } from "./usagePricing.ts"; @@ -83,10 +82,6 @@ describe("usage pricing", () => { } }); - it("keeps the existing model-name normalization contract", () => { - expect(normalizeModelName(" Anthropic/Claude-Opus-5 ")).toBe("claude-opus-5"); - }); - it("keeps the canonical Fable rate separate from DeepInfra in either order", () => { const canonical = ["claude-fable-5", rate(1e-5, 1e-6)] as const; const deepInfra = ["deepinfra/anthropic/claude-fable-5", rate(1e-5)] as const; diff --git a/apps/server/src/usage/usagePricing.ts b/apps/server/src/usage/usagePricing.ts index f82e664a06e4..96d24958436f 100644 --- a/apps/server/src/usage/usagePricing.ts +++ b/apps/server/src/usage/usagePricing.ts @@ -127,16 +127,6 @@ function normalizeRateKey(model: string): string { return model.trim().toLowerCase(); } -/** - * Canonicalises a model name for lookup. - * - * Strips a `provider/` prefix and lowercases, since transcripts are - * inconsistent about casing. - */ -export function normalizeModelName(model: string): string { - return bareModelName(normalizeRateKey(model)); -} - function bareModelName(key: string): string { const slash = key.lastIndexOf("/"); return slash === -1 ? key : key.slice(slash + 1); diff --git a/apps/server/src/vcs/VcsDriverRegistry.test.ts b/apps/server/src/vcs/VcsDriverRegistry.test.ts index b95a8d5ce913..785ad30cc13e 100644 --- a/apps/server/src/vcs/VcsDriverRegistry.test.ts +++ b/apps/server/src/vcs/VcsDriverRegistry.test.ts @@ -137,6 +137,54 @@ describe("VcsDriverRegistry", () => { // Negative detects are TTL-cached (15s); advance so a later repo creation is noticed. yield* TestClock.adjust("16 seconds"); assert.equal((yield* registry.detect({ cwd: "/repo" }))?.repository.rootPath, "/repo"); + assert.equal(probeChecks, 2); + }).pipe(Effect.provide(Layer.mergeAll(layer, TestClock.layer()))); + }); + + it.effect("fresh detect sees a repository created during a negative cache TTL", () => { + let probeChecks = 0; + const layer = Layer.effect(VcsDriverRegistry.VcsDriverRegistry, VcsDriverRegistry.make).pipe( + Layer.provide(NodeServices.layer), + Layer.provide( + Layer.mock(VcsProjectConfig.VcsProjectConfig)({ + resolveKind: (input) => Effect.succeed(input.requestedKind ?? "auto"), + }), + ), + Layer.provide( + Layer.mock(VcsProcess.VcsProcess)({ + run: (input) => + Effect.sync(() => { + const command = normalizeGitArgs(input.args).join(" "); + if (command === "rev-parse --is-bare-repository --is-inside-work-tree") { + probeChecks += 1; + return probeChecks === 1 + ? { + ...processOutput(""), + exitCode: ChildProcessSpawner.ExitCode(128), + stderr: "fatal: not a git repository", + } + : processOutput("false\ntrue\n"); + } + if (command === "rev-parse --show-toplevel") { + return processOutput("/repo\n"); + } + if (command === "rev-parse --git-common-dir") { + return processOutput("/repo/.git\n"); + } + return processOutput(""); + }), + }), + ), + ); + + return Effect.gen(function* () { + const registry = yield* VcsDriverRegistry.VcsDriverRegistry; + + assert.equal(yield* registry.detect({ cwd: "/repo" }), null); + assert.equal( + (yield* registry.detect({ cwd: "/repo", fresh: true }))?.repository.rootPath, + "/repo", + ); // One probe per detect: the combined rev-parse keeps negative detection // to a single git call. assert.equal(probeChecks, 2); diff --git a/apps/server/src/vcs/VcsDriverRegistry.ts b/apps/server/src/vcs/VcsDriverRegistry.ts index a7ce0de2be65..2563e8409fe7 100644 --- a/apps/server/src/vcs/VcsDriverRegistry.ts +++ b/apps/server/src/vcs/VcsDriverRegistry.ts @@ -24,6 +24,8 @@ const DETECTION_NEGATIVE_CACHE_TTL = Duration.seconds(15); export interface VcsDriverResolveInput { readonly cwd: string; readonly requestedKind?: VcsDriverKind | "auto"; + /** Skip a cached miss so a just-created repo is visible on this call. */ + readonly fresh?: boolean; } export interface VcsDriverHandle { @@ -134,7 +136,11 @@ export const make = Effect.gen(function* () { const detect: VcsDriverRegistry["Service"]["detect"] = Effect.fn("VcsDriverRegistry.detect")( function* (input) { const requestedKind = yield* projectConfig.resolveKind(input); - return yield* Cache.get(detectionCache, detectionCacheKey({ cwd: input.cwd, requestedKind })); + const key = detectionCacheKey({ cwd: input.cwd, requestedKind }); + if (input.fresh) { + yield* Cache.invalidate(detectionCache, key); + } + return yield* Cache.get(detectionCache, key); }, ); diff --git a/apps/server/src/vcs/VcsProjectConfig.test.ts b/apps/server/src/vcs/VcsProjectConfig.test.ts index 04f7fcffcda0..88f48e9e8afa 100644 --- a/apps/server/src/vcs/VcsProjectConfig.test.ts +++ b/apps/server/src/vcs/VcsProjectConfig.test.ts @@ -14,22 +14,6 @@ const TestLayer = VcsProjectConfig.layer.pipe( ); describe("VcsProjectConfig", () => { - it("keeps operation context and the original cause on config errors", () => { - const cause = new Error("permission denied"); - const error = new VcsProjectConfig.VcsProjectConfigError({ - operation: "read", - cwd: "/repo/packages/app", - configPath: "/repo/.t3code/vcs.json", - cause, - }); - - assert.equal(error.operation, "read"); - assert.equal(error.cwd, "/repo/packages/app"); - assert.equal(error.configPath, "/repo/.t3code/vcs.json"); - assert.strictEqual(error.cause, cause); - assert.equal(error.message, "Failed to read VCS project config at /repo/.t3code/vcs.json."); - }); - it.layer(TestLayer)("uses an explicit requested VCS kind before config", (it) => { it.effect("returns the requested kind", () => Effect.gen(function* () { diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 6becf741444c..f4811aa6aa48 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -59,6 +59,7 @@ import { type RelayClientInstallProgressEvent, ServerSelfUpdateError, type ServerSelfUpdateProgressEvent, + type ServerLifecycleStreamEvent, type FilesystemBrowseFailure, FilesystemBrowseError, AssetWorkspaceContextNotFoundError, @@ -102,6 +103,7 @@ import { } from "./observability/RpcInstrumentation.ts"; import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts"; import * as ProviderService from "./provider/Services/ProviderService.ts"; +import * as ProviderSessionDirectory from "./provider/Services/ProviderSessionDirectory.ts"; import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts"; import { ProviderAuthService } from "./provider/Services/ProviderAuthService.ts"; import { ProviderInstanceRegistry } from "./provider/Services/ProviderInstanceRegistry.ts"; @@ -127,6 +129,8 @@ import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts"; import * as GitWorkflowService from "./git/GitWorkflowService.ts"; import * as ReviewService from "./review/ReviewService.ts"; import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts"; +import * as AgentSessionScanner from "./project/AgentSessionScanner.ts"; +import { importRecentAgentThreads } from "./project/AgentSessionImporter.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; @@ -562,6 +566,7 @@ const makeWsRpcLayer = ( const aiUsageMonitor = yield* AiUsageMonitorModule.AiUsageMonitor; const providerRegistry = yield* ProviderRegistry.ProviderRegistry; const providerService = yield* ProviderService.ProviderService; + const providerSessionDirectory = yield* ProviderSessionDirectory.ProviderSessionDirectory; const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner; const providerAuth = yield* ProviderAuthService; const providerInstances = yield* ProviderInstanceRegistry; @@ -611,6 +616,7 @@ const makeWsRpcLayer = ( return true; }); const projectSetupScriptRunner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; + const agentSessionScanner = yield* AgentSessionScanner.AgentSessionScanner; const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; const sourceControlDiscovery = yield* SourceControlDiscovery.SourceControlDiscovery; @@ -2707,6 +2713,31 @@ const makeWsRpcLayer = ( deletePendingAttachment(input.attachmentId), { "rpc.aggregate": "workspace" }, ), + [WS_METHODS.agentSessionsScan]: () => + observeRpcEffect(WS_METHODS.agentSessionsScan, agentSessionScanner.scan, { + "rpc.aggregate": "workspace", + }), + [WS_METHODS.agentSessionsImport]: (input) => + observeRpcEffect( + WS_METHODS.agentSessionsImport, + importRecentAgentThreads(input).pipe( + Effect.provideService(AgentSessionScanner.AgentSessionScanner, agentSessionScanner), + Effect.provideService( + OrchestrationEngine.OrchestrationEngineService, + orchestrationEngine, + ), + Effect.provideService( + ProjectionSnapshotQuery.ProjectionSnapshotQuery, + projectionSnapshotQuery, + ), + Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService( + ProviderSessionDirectory.ProviderSessionDirectory, + providerSessionDirectory, + ), + ), + { "rpc.aggregate": "workspace" }, + ), [WS_METHODS.assetsCreateUrl]: (input) => observeRpcEffect( WS_METHODS.assetsCreateUrl, @@ -3157,11 +3188,18 @@ const makeWsRpcLayer = ( observeRpcStreamEffect( WS_METHODS.subscribeServerLifecycle, Effect.gen(function* () { + const liveBuffer = yield* Queue.unbounded(); + yield* Effect.forkScoped( + lifecycleEvents.stream.pipe( + Stream.runForEach((event) => Queue.offer(liveBuffer, event)), + ), + { startImmediately: true }, + ); const snapshot = yield* lifecycleEvents.snapshot; const snapshotEvents = Array.from(snapshot.events).toSorted( (left, right) => left.sequence - right.sequence, ); - const liveEvents = lifecycleEvents.stream.pipe( + const liveEvents = Stream.fromQueue(liveBuffer).pipe( Stream.filter((event) => event.sequence > snapshot.sequence), ); return Stream.concat(Stream.fromIterable(snapshotEvents), liveEvents); @@ -3275,6 +3313,7 @@ export const websocketRpcRouteLayer = Layer.unwrap( productHandshakeValid, ).pipe( Layer.provideMerge(RpcSerialization.layerJson), + Layer.provide(AgentSessionScanner.layer), Layer.provide(ProviderMaintenanceRunner.layer), Layer.provide(Layer.succeed(ServerSelfUpdate.ServerSelfUpdate, serverSelfUpdate)), // One server-lifetime service means clients share the same PR caches, and a WS diff --git a/apps/web/src/AppRoot.test.tsx b/apps/web/src/AppRoot.test.tsx deleted file mode 100644 index 3f326052220f..000000000000 --- a/apps/web/src/AppRoot.test.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { Children, isValidElement, type ReactElement, type ReactNode } from "react"; -import { RouterProvider } from "@tanstack/react-router"; -import { describe, expect, it } from "vite-plus/test"; - -import { ElectronBrowserHost } from "./browser/ElectronBrowserHost"; -import { WebUpdateBanner } from "./components/WebUpdateBanner"; -import { PreviewAutomationHosts } from "./components/preview/PreviewAutomationHosts"; -import { QuitHoldOverlay } from "./components/QuitHoldOverlay"; -import { AppAtomRegistryProvider } from "./rpc/atomRegistry"; -import type { AppRouter } from "./router"; -import { AppRoot } from "./AppRoot"; - -describe("AppRoot", () => { - it("shares the application atom registry with routed UI and renderer-wide desktop hosts", () => { - const root = AppRoot({ router: {} as AppRouter }); - - expect(root.type).toBe(AppAtomRegistryProvider); - const children = Children.toArray( - (root as ReactElement<{ readonly children: ReactNode }>).props.children, - ); - expect(children).toHaveLength(5); - expect(isValidElement(children[0]) && children[0].type).toBe(RouterProvider); - expect(isValidElement(children[1]) && children[1].type).toBe(PreviewAutomationHosts); - expect(isValidElement(children[2]) && children[2].type).toBe(ElectronBrowserHost); - expect(isValidElement(children[3]) && children[3].type).toBe(WebUpdateBanner); - expect(isValidElement(children[4]) && children[4].type).toBe(QuitHoldOverlay); - }); -}); diff --git a/apps/web/src/authBootstrap.test.ts b/apps/web/src/authBootstrap.test.ts index 1a79f729bb03..dfe2b51d0400 100644 --- a/apps/web/src/authBootstrap.test.ts +++ b/apps/web/src/authBootstrap.test.ts @@ -310,6 +310,63 @@ describe("resolveInitialServerAuthGateState", () => { expect(testApi.calls.session).toBe(2); }); + it("keeps manual token submission pending until the session is authenticated", async () => { + vi.useFakeTimers(); + let authenticated = false; + let settled = false; + try { + const testApi = await installAuthApi({ + session: () => + authenticated + ? authenticatedSession(LOOPBACK_AUTH) + : unauthenticatedSession(LOOPBACK_AUTH), + browserSession: () => Effect.succeed(browserSession(["orchestration:read"])), + }); + const { submitServerAuthCredential } = await import("./environments/primary"); + + const submission = submitServerAuthCredential("retry-token").finally(() => { + settled = true; + }); + await vi.advanceTimersByTimeAsync(0); + + expect(testApi.calls.browserSession).toEqual([{ credential: "retry-token" }]); + expect(testApi.calls.session).toBe(1); + expect(settled).toBe(false); + + authenticated = true; + await vi.advanceTimersByTimeAsync(100); + await expect(submission).resolves.toBeUndefined(); + expect(testApi.calls.session).toBe(2); + } finally { + vi.useRealTimers(); + } + }); + + it("fails manual token submission when the session is not established", async () => { + vi.useFakeTimers(); + try { + const testApi = await installAuthApi({ + session: () => unauthenticatedSession(LOOPBACK_AUTH), + browserSession: () => Effect.succeed(browserSession(["orchestration:read"])), + }); + const { PrimaryEnvironmentAuthSessionTimeoutError, submitServerAuthCredential } = + await import("./environments/primary/auth"); + + const submission = submitServerAuthCredential("retry-token"); + const failure = submission.then( + () => null, + (error: unknown) => error, + ); + await vi.advanceTimersByTimeAsync(2_000); + + await expect(failure).resolves.toBeInstanceOf(PrimaryEnvironmentAuthSessionTimeoutError); + expect(testApi.calls.browserSession).toEqual([{ credential: "retry-token" }]); + expect(testApi.calls.session).toBeGreaterThan(1); + } finally { + vi.useRealTimers(); + } + }); + it("rejects a blank pairing token with a structured validation error", async () => { const { PrimaryEnvironmentPairingCredentialRequiredError, submitServerAuthCredential } = await import("./environments/primary/auth"); diff --git a/apps/web/src/browser/BrowserDeviceToolbar.test.ts b/apps/web/src/browser/BrowserDeviceToolbar.test.ts index ee4987794c33..087b5d109b40 100644 --- a/apps/web/src/browser/BrowserDeviceToolbar.test.ts +++ b/apps/web/src/browser/BrowserDeviceToolbar.test.ts @@ -1,10 +1,7 @@ import type { PreviewViewportSetting } from "@t3tools/contracts"; import { describe, expect, it, vi } from "vite-plus/test"; -import { - commitViewportAndAspectRatio, - reconcileLockedAspectRatio, -} from "./browserDeviceToolbarState"; +import { commitViewportAndAspectRatio } from "./browserDeviceToolbarState"; describe("commitViewportAndAspectRatio", () => { it("commits the aspect ratio only after the viewport succeeds", async () => { @@ -39,11 +36,3 @@ describe("commitViewportAndAspectRatio", () => { expect(onAspectRatioChange).not.toHaveBeenCalled(); }); }); - -describe("reconcileLockedAspectRatio", () => { - it("tracks external viewport ratios only while the lock remains active", () => { - expect(reconcileLockedAspectRatio(1.5, 16 / 9)).toBe(16 / 9); - expect(reconcileLockedAspectRatio(null, 16 / 9)).toBeNull(); - expect(reconcileLockedAspectRatio(1.5, null)).toBeNull(); - }); -}); diff --git a/apps/web/src/browser/HostedBrowserWebview.test.tsx b/apps/web/src/browser/HostedBrowserWebview.test.tsx new file mode 100644 index 000000000000..4a241befef74 --- /dev/null +++ b/apps/web/src/browser/HostedBrowserWebview.test.tsx @@ -0,0 +1,200 @@ +import { + DEFAULT_CLIENT_SETTINGS, + EnvironmentId, + FILL_PREVIEW_VIEWPORT, + ThreadId, + type ClientSettings, + type DesktopPreviewBridge, +} from "@t3tools/contracts"; +import { act } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const mocks = vi.hoisted(() => ({ + getClientSettings: vi.fn<() => Promise>(), + setClientSettings: vi.fn<(settings: ClientSettings) => Promise>(), + createTab: vi.fn(), + closeTab: vi.fn(), + registerWebview: vi.fn(), + getPreviewConfig: vi.fn(), + activeRecordings: new Set(), +})); + +vi.mock("~/localApi", () => ({ + ensureLocalApi: () => ({ persistence: mocks }), +})); + +vi.mock("~/components/preview/previewBridge", () => ({ + previewBridge: { + createTab: mocks.createTab, + closeTab: mocks.closeTab, + registerWebview: mocks.registerWebview, + getPreviewConfig: mocks.getPreviewConfig, + }, +})); + +vi.mock("~/components/preview/usePreviewBridge", () => ({ + usePreviewBridge: () => undefined, +})); + +vi.mock("./browserRecording", () => ({ + useActiveBrowserRecordingTabIds: () => mocks.activeRecordings, + stopBrowserRecording: async () => null, +})); + +import { + __resetClientSettingsPersistenceForTests, + ensureClientSettingsHydrated, +} from "~/hooks/useSettings"; +import { useBrowserSurfaceStore } from "./browserSurfaceStore"; +import * as desktopTabLifetime from "./desktopTabLifetime"; +import { HostedBrowserWebview } from "./HostedBrowserWebview"; + +let renderer: ReactTestRenderer | undefined; + +function deferred() { + let resolve!: (value: A) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +beforeEach(() => { + __resetClientSettingsPersistenceForTests(); + useBrowserSurfaceStore.setState({ activityByTabId: {}, byTabId: {} }); + mocks.getClientSettings.mockReset(); + mocks.setClientSettings.mockReset().mockResolvedValue(undefined); + mocks.createTab.mockReset().mockResolvedValue(undefined); + mocks.closeTab.mockReset().mockResolvedValue(undefined); + mocks.registerWebview.mockReset().mockResolvedValue(undefined); + mocks.getPreviewConfig.mockReset().mockResolvedValue({ + partition: "persist:t3-preview-work", + webPreferences: "contextIsolation=yes", + preloadUrl: null, + }); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("window", globalThis); + vi.stubGlobal("navigator", { platform: "Linux" }); + vi.stubGlobal( + "requestAnimationFrame", + vi.fn(() => 0), + ); + vi.stubGlobal("cancelAnimationFrame", vi.fn()); + vi.spyOn(console, "error").mockImplementation(() => undefined); +}); + +afterEach(async () => { + vi.useFakeTimers(); + await act(() => renderer?.unmount()); + renderer = undefined; + await vi.advanceTimersByTimeAsync(0); + vi.useRealTimers(); + __resetClientSettingsPersistenceForTests(); + useBrowserSurfaceStore.setState({ activityByTabId: {}, byTabId: {} }); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("HostedBrowserWebview settings hydration", () => { + it("starts a retained background tab only after a settings read succeeds on retry", async () => { + const firstRead = deferred(); + const retryRead = deferred(); + const tabCreation = deferred(); + mocks.getClientSettings + .mockReturnValueOnce(firstRead.promise) + .mockReturnValueOnce(retryRead.promise); + mocks.createTab.mockReturnValueOnce(tabCreation.promise); + const acquire = vi.spyOn(desktopTabLifetime, "acquireDesktopTab"); + const createGuest = vi.fn((_attributes: unknown) => + Object.assign(new EventTarget(), { getWebContentsId: () => 41 }), + ); + const threadRef = { + environmentId: EnvironmentId.make("host-settings-retry"), + threadId: ThreadId.make("thread-settings-retry"), + }; + const runtimeTabId = "retained-background-tab"; + useBrowserSurfaceStore.getState().acquireActivity(runtimeTabId); + + await act(() => { + renderer = create( + , + { + createNodeMock: (element) => + element.type === "webview" + ? createGuest(element.props) + : { scrollLeft: 0, scrollTop: 0, scrollTo: () => undefined }, + }, + ); + }); + + expect(mocks.getClientSettings).toHaveBeenCalledOnce(); + expect(acquire).not.toHaveBeenCalled(); + expect(createGuest).not.toHaveBeenCalled(); + expect(mocks.createTab).not.toHaveBeenCalled(); + + const failure = new Error("Saved settings are unavailable"); + await act(async () => { + const hydration = ensureClientSettingsHydrated(); + firstRead.reject(failure); + await expect(hydration).rejects.toBe(failure); + }); + expect(acquire).not.toHaveBeenCalled(); + expect(createGuest).not.toHaveBeenCalled(); + expect(mocks.createTab).not.toHaveBeenCalled(); + + let retry!: Promise; + await act(() => { + retry = ensureClientSettingsHydrated(); + }); + expect(mocks.getClientSettings).toHaveBeenCalledTimes(2); + expect(acquire).not.toHaveBeenCalled(); + expect(createGuest).not.toHaveBeenCalled(); + expect(mocks.createTab).not.toHaveBeenCalled(); + + await act(async () => { + retryRead.resolve({ + ...DEFAULT_CLIENT_SETTINGS, + browserDefaultZoomFactor: 1.25, + browserDefaultAppearance: "dark", + browserProfiles: [{ id: "work", name: "Work", kind: "persistent" }], + browserDefaultProfileId: "work", + }); + await retry; + }); + + expect(acquire).toHaveBeenCalledExactlyOnceWith(runtimeTabId); + expect(mocks.getPreviewConfig).toHaveBeenCalledExactlyOnceWith(threadRef.environmentId, "work"); + expect(createGuest).toHaveBeenCalledOnce(); + expect(createGuest).toHaveBeenCalledWith( + expect.objectContaining({ + partition: "persist:t3-preview-work", + src: "https://example.com", + }), + ); + expect(mocks.createTab).toHaveBeenCalledExactlyOnceWith(runtimeTabId, { + zoomFactor: 1.25, + colorScheme: "dark", + }); + expect(mocks.registerWebview).not.toHaveBeenCalled(); + + await act(async () => { + tabCreation.resolve(); + await tabCreation.promise; + }); + expect(mocks.registerWebview).toHaveBeenCalledExactlyOnceWith(runtimeTabId, 41); + expect(mocks.closeTab).not.toHaveBeenCalled(); + expect(mocks.setClientSettings).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/browser/HostedBrowserWebview.tsx b/apps/web/src/browser/HostedBrowserWebview.tsx index 0f01960ce52b..42d5bcfb35b8 100644 --- a/apps/web/src/browser/HostedBrowserWebview.tsx +++ b/apps/web/src/browser/HostedBrowserWebview.tsx @@ -6,6 +6,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { previewBridge } from "~/components/preview/previewBridge"; import { usePreviewBridge } from "~/components/preview/usePreviewBridge"; +import { useClientSettingsHydrated } from "~/hooks/useSettings"; import { cn, isMacPlatform } from "~/lib/utils"; import { resolveBrowserSurfacePanelRect, useBrowserSurfaceStore } from "./browserSurfaceStore"; @@ -66,6 +67,7 @@ export function HostedBrowserWebview(props: { zoomFactor, profileId, } = props; + const clientSettingsHydrated = useClientSettingsHydrated(); const config = usePreviewWebviewConfig(threadRef.environmentId, profileId); const [initialSrc] = useState(() => initialUrl ?? "about:blank"); const tabLeaseRef = useRef(null); @@ -94,6 +96,7 @@ export function HostedBrowserWebview(props: { usePreviewBridge({ threadRef, tabId, runtimeTabId }); useEffect(() => { + if (!clientSettingsHydrated) return; crashRecoveryRef.current = INITIAL_WEBVIEW_CRASH_RECOVERY_STATE; const lease = acquireDesktopTab(runtimeTabId); tabLeaseRef.current = lease; @@ -101,7 +104,7 @@ export function HostedBrowserWebview(props: { if (tabLeaseRef.current === lease) tabLeaseRef.current = null; lease.release(); }; - }, [runtimeTabId]); + }, [clientSettingsHydrated, runtimeTabId]); const [webviewGeneration, setWebviewGeneration] = useState(0); const [recoverySrc, setRecoverySrc] = useState(initialSrc); @@ -118,7 +121,7 @@ export function HostedBrowserWebview(props: { useEffect(() => { const webview = webviewRef.current; const bridge = previewBridge; - if (!webview || !config || !bridge) return; + if (!clientSettingsHydrated || !webview || !config || !bridge) return; let disposed = false; let recoveryTimeout: ReturnType | null = null; const register = () => { @@ -164,7 +167,7 @@ export function HostedBrowserWebview(props: { webview.removeEventListener("dom-ready", register); webview.removeEventListener("render-process-gone", recoverGuest); }; - }, [config, initialSrc, runtimeTabId, webviewGeneration]); + }, [clientSettingsHydrated, config, initialSrc, runtimeTabId, webviewGeneration]); const active = presentation.visible && presentation.rect !== null; const lastRect = presentation.rect; @@ -249,7 +252,7 @@ export function HostedBrowserWebview(props: { wrapper.scrollTo({ left: 0, top: 0 }); }, [runtimeTabId, viewport._tag, viewportHeight, viewportWidth]); - if (!config) return null; + if (!clientSettingsHydrated || !config) return null; const renderingActive = active || backgroundActivity || pictureInPicture || recordingActive; const wrapperStyle = resolveHostedBrowserWebviewWrapperStyle({ diff --git a/apps/web/src/browser/browserDefaults.test.ts b/apps/web/src/browser/browserDefaults.test.ts index bac9600c182b..ed86cde1c9c8 100644 --- a/apps/web/src/browser/browserDefaults.test.ts +++ b/apps/web/src/browser/browserDefaults.test.ts @@ -1,15 +1,17 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { DEFAULT_BROWSER_PROFILE_ID, INCOGNITO_BROWSER_PROFILE_ID } from "@t3tools/contracts"; +import { ensureClientSettingsHydrated } from "~/hooks/useSettings"; + const settings = vi.hoisted(() => ({ current: {} as Record })); vi.mock("~/hooks/useSettings", () => ({ getClientSettings: () => settings.current, useClientSettings: () => undefined, - ensureClientSettingsHydrated: () => Promise.resolve(), + ensureClientSettingsHydrated: vi.fn(async () => undefined), })); -const { getBrowserDefaults } = await import("./browserDefaults"); +const { getBrowserDefaults, resolveBrowserDefaults } = await import("./browserDefaults"); const withDefaultProfile = (browserDefaultProfileId: string) => { settings.current = { @@ -41,3 +43,22 @@ describe("getBrowserDefaults profile resolution", () => { ); }); }); + +describe("resolveBrowserDefaults", () => { + it("rejects failed reads and uses the saved profile after a successful retry", async () => { + withDefaultProfile("work"); + settings.current.browserDefaultZoomFactor = 1.25; + settings.current.browserDefaultAppearance = "dark"; + const failure = new Error("Settings read failed"); + vi.mocked(ensureClientSettingsHydrated).mockRejectedValueOnce(failure); + + await expect(resolveBrowserDefaults()).rejects.toBe(failure); + await expect(resolveBrowserDefaults()).resolves.toMatchObject({ + viewport: { _tag: "fill" }, + zoomFactor: 1.25, + appearance: "dark", + autoShowFloatingPreview: true, + profileId: "work", + }); + }); +}); diff --git a/apps/web/src/browser/browserDefaults.ts b/apps/web/src/browser/browserDefaults.ts index eaae409568a2..6141b1a52fa9 100644 --- a/apps/web/src/browser/browserDefaults.ts +++ b/apps/web/src/browser/browserDefaults.ts @@ -79,6 +79,7 @@ export function getBrowserDefaults(): BrowserDefaults { * Opening a preview is asynchronous anyway, and before hydration the snapshot * is the schema defaults rather than the user's — a tab opened in that window * would be born at the wrong viewport, zoom and appearance and never corrected. + * Read failures reject so a new tab cannot use the wrong profile or viewport. */ export async function resolveBrowserDefaults(): Promise { await ensureClientSettingsHydrated(); diff --git a/apps/web/src/browser/browserDeviceToolbarState.ts b/apps/web/src/browser/browserDeviceToolbarState.ts index 9986ee022829..70a8e597428b 100644 --- a/apps/web/src/browser/browserDeviceToolbarState.ts +++ b/apps/web/src/browser/browserDeviceToolbarState.ts @@ -1,12 +1,5 @@ import type { PreviewViewportSetting } from "@t3tools/contracts"; -export function reconcileLockedAspectRatio( - current: number | null, - viewportAspectRatio: number | null, -): number | null { - return current === null || viewportAspectRatio === null ? null : viewportAspectRatio; -} - export async function commitViewportAndAspectRatio( setting: PreviewViewportSetting, aspectRatio: number | null, diff --git a/apps/web/src/browser/browserLinkTarget.test.ts b/apps/web/src/browser/browserLinkTarget.test.ts index 94f97001c96f..a60362c43bd5 100644 --- a/apps/web/src/browser/browserLinkTarget.test.ts +++ b/apps/web/src/browser/browserLinkTarget.test.ts @@ -1,6 +1,16 @@ -import { describe, expect, it } from "vite-plus/test"; +import type { BrowserLinkTarget } from "@t3tools/contracts"; +import { describe, expect, it, vi } from "vite-plus/test"; -import { resolveLinkTarget } from "./browserLinkTarget"; +import { ensureClientSettingsHydrated } from "~/hooks/useSettings"; + +import { resolveBrowserLinkTargetPreference, resolveLinkTarget } from "./browserLinkTarget"; + +const settings = vi.hoisted(() => ({ browserLinkTarget: "system" as BrowserLinkTarget })); + +vi.mock("~/hooks/useSettings", () => ({ + ensureClientSettingsHydrated: vi.fn(async () => undefined), + getClientSettings: () => settings, +})); const click = { metaKey: false, ctrlKey: false }; @@ -67,3 +77,17 @@ describe("resolveLinkTarget", () => { } }); }); + +describe("resolveBrowserLinkTargetPreference", () => { + it.each(["system", "app"] as const)( + "rejects failed reads instead of using the current %s preference", + async (preference) => { + settings.browserLinkTarget = preference; + const failure = new Error("Settings read failed"); + vi.mocked(ensureClientSettingsHydrated).mockRejectedValueOnce(failure); + + await expect(resolveBrowserLinkTargetPreference()).rejects.toBe(failure); + await expect(resolveBrowserLinkTargetPreference()).resolves.toBe(preference); + }, + ); +}); diff --git a/apps/web/src/browser/browserLinkTarget.ts b/apps/web/src/browser/browserLinkTarget.ts index d03775572747..7ecffb4593d5 100644 --- a/apps/web/src/browser/browserLinkTarget.ts +++ b/apps/web/src/browser/browserLinkTarget.ts @@ -55,6 +55,7 @@ export function isWebUrl(url: string): boolean { * hydration the snapshot is the schema default ("system"), so a link clicked * in the first moments after launch would ignore a persisted "app" — opening * is asynchronous anyway, so waiting costs nothing the user can see. + * Read failures reject rather than choosing a browser without the saved preference. */ export async function resolveBrowserLinkTargetPreference(): Promise { await ensureClientSettingsHydrated(); diff --git a/apps/web/src/browser/browserRecording.test.ts b/apps/web/src/browser/browserRecording.test.ts index 49145f314e98..5cfe614f2985 100644 --- a/apps/web/src/browser/browserRecording.test.ts +++ b/apps/web/src/browser/browserRecording.test.ts @@ -5,6 +5,8 @@ import { } from "@t3tools/contracts"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { ensureClientSettingsHydrated } from "~/hooks/useSettings"; + const { clientSettings, events, @@ -241,6 +243,35 @@ describe("browser recording", () => { await stopBrowserRecording("recording-tab"); }); + it("clears a failed settings read before retrying recording", async () => { + const tabId = "settings-read-failure-tab"; + const error = new Error("Settings read failed"); + vi.mocked(ensureClientSettingsHydrated).mockRejectedValueOnce(error); + + await expect(startBrowserRecording(tabId)).rejects.toBe(error); + + expect(readActiveBrowserRecordingTabIds()).toEqual(new Set()); + expect(useBrowserSurfaceStore.getState().activityByTabId[tabId]).toBeUndefined(); + expect(animationFrameCount).toBe(0); + expect(startScreencast).not.toHaveBeenCalled(); + expect(stopScreencast).not.toHaveBeenCalled(); + expect(getDisplayMedia).not.toHaveBeenCalled(); + expect(FakeMediaRecorder.instances).toHaveLength(0); + + clientSettings.browserRecordingFrameRate = 60; + await startBrowserRecording(tabId); + + expect(getDisplayMedia).toHaveBeenCalledWith({ + audio: false, + video: { frameRate: { max: 60 } }, + }); + await stopBrowserRecording(tabId); + + expect(startScreencast).toHaveBeenCalledOnce(); + expect(readActiveBrowserRecordingTabIds()).toEqual(new Set()); + expect(useBrowserSurfaceStore.getState().activityByTabId[tabId]).toBeUndefined(); + }); + it("stops the native stream when MediaRecorder cleanup fails", async () => { const stopTrack = vi.fn(); getDisplayMedia.mockResolvedValueOnce({ diff --git a/apps/web/src/browser/browserRecording.ts b/apps/web/src/browser/browserRecording.ts index 73bc2708ddf6..c7825961abbc 100644 --- a/apps/web/src/browser/browserRecording.ts +++ b/apps/web/src/browser/browserRecording.ts @@ -516,10 +516,12 @@ export async function startBrowserRecording( activeRecordings.set(tabId, recording); publishActiveRecordingTabIds(); try { - const frameRatePromise = ensureClientSettingsHydrated().then( - () => getClientSettings().browserRecordingFrameRate, - ); - const [frameRate] = await Promise.all([frameRatePromise, waitForBrowserRecordingPaint()]); + await ensureClientSettingsHydrated().catch((cause: unknown) => { + clearActiveRecording(recording); + throw cause; + }); + const frameRate = getClientSettings().browserRecordingFrameRate; + await waitForBrowserRecordingPaint(); const throwIfStartupCancelled = async (): Promise => { // Once a grant starts, a stop lets startup finish so the caller receives an artifact. // Only a contended start can be cancelled before it reaches native capture. diff --git a/apps/web/src/browser/desktopTabLifetime.test.ts b/apps/web/src/browser/desktopTabLifetime.test.ts index 80bfa0d275d7..c5338ecf4ccf 100644 --- a/apps/web/src/browser/desktopTabLifetime.test.ts +++ b/apps/web/src/browser/desktopTabLifetime.test.ts @@ -1,6 +1,7 @@ import { DEFAULT_PREVIEW_APPEARANCE, DEFAULT_PREVIEW_ZOOM_FACTOR, + DEFAULT_CLIENT_SETTINGS, EnvironmentId, ThreadId, } from "@t3tools/contracts"; @@ -21,8 +22,10 @@ vi.mock("./browserRecording", () => ({ })); import { acquireDesktopTab } from "./desktopTabLifetime"; +import * as browserDefaults from "./browserDefaults"; +import { __setClientSettingsForTests } from "~/hooks/useSettings"; -/** Client settings are unset in tests, so creation carries the schema defaults. */ +/** Tests load default settings unless they select other preferences. */ const DEFAULT_TAB_STATE = { zoomFactor: DEFAULT_PREVIEW_ZOOM_FACTOR, colorScheme: DEFAULT_PREVIEW_APPEARANCE, @@ -31,6 +34,7 @@ import { previewRuntimeTabId } from "./previewRuntimeTabId"; describe("desktopTabLifetime", () => { beforeEach(() => { + __setClientSettingsForTests(DEFAULT_CLIENT_SETTINGS); closeTab.mockClear(); createTab.mockClear(); stopBrowserRecording.mockClear(); @@ -40,6 +44,35 @@ describe("desktopTabLifetime", () => { afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("does not create a desktop tab after a failed settings read and permits a later retry", async () => { + vi.useFakeTimers(); + const failure = new Error("Settings read failed"); + vi.spyOn(browserDefaults, "resolveBrowserDefaults").mockRejectedValueOnce(failure); + const failed = acquireDesktopTab("tab_settings_retry"); + + await expect(failed.ready).rejects.toBe(failure); + expect(createTab).not.toHaveBeenCalled(); + failed.release(); + await vi.advanceTimersByTimeAsync(0); + + __setClientSettingsForTests({ + ...DEFAULT_CLIENT_SETTINGS, + browserDefaultZoomFactor: 1.25, + browserDefaultAppearance: "dark", + }); + createTab.mockResolvedValueOnce(undefined); + const retry = acquireDesktopTab("tab_settings_retry"); + await retry.ready; + + expect(createTab).toHaveBeenCalledExactlyOnceWith("tab_settings_retry", { + zoomFactor: 1.25, + colorScheme: "dark", + }); + retry.release(); + await vi.advanceTimersByTimeAsync(0); }); it("shares tab creation readiness across concurrent leases", async () => { diff --git a/apps/web/src/browser/openFileInPreview.ts b/apps/web/src/browser/openFileInPreview.ts index f506e42e73e5..a320e3ba34da 100644 --- a/apps/web/src/browser/openFileInPreview.ts +++ b/apps/web/src/browser/openFileInPreview.ts @@ -38,6 +38,14 @@ export class BrowserPreviewUnavailableError extends Data.TaggedError( readonly message: string; }> {} +export class BrowserSettingsReadError extends Data.TaggedError("BrowserSettingsReadError")<{ + readonly cause: unknown; +}> { + override get message(): string { + return "Saved browser settings could not be loaded."; + } +} + export type OpenPreviewMutation = (input: { readonly environmentId: EnvironmentId; readonly input: PreviewOpenInput; @@ -47,8 +55,13 @@ export async function openUrlInPreview(input: { readonly threadRef: ScopedThreadRef; readonly url: string; readonly openPreview: OpenPreviewMutation; -}): Promise> { - const defaults = await resolveBrowserDefaults(); +}): Promise> { + const defaults = await resolveBrowserDefaults().catch( + (cause: unknown) => new BrowserSettingsReadError({ cause }), + ); + if (defaults instanceof BrowserSettingsReadError) { + return AsyncResult.failure(Cause.fail(defaults)); + } const result = await input.openPreview({ environmentId: input.threadRef.environmentId, input: { @@ -82,7 +95,12 @@ export async function openFileInPreview(input: { readonly input: { readonly resource: AssetResource }; }) => Promise>; readonly openPreview: OpenPreviewMutation; -}): Promise> { +}): Promise< + AtomCommandResult< + void, + AssetError | PreviewError | BrowserPreviewUnavailableError | BrowserSettingsReadError + > +> { if (!isPreviewSupportedInRuntime()) { return AsyncResult.failure( Cause.fail( diff --git a/apps/web/src/browser/useOpenLink.ts b/apps/web/src/browser/useOpenLink.ts index 0e9bf721f82d..2a1d122eedbf 100644 --- a/apps/web/src/browser/useOpenLink.ts +++ b/apps/web/src/browser/useOpenLink.ts @@ -1,5 +1,8 @@ import type { ScopedThreadRef } from "@t3tools/contracts"; -import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; import { useCallback } from "react"; import { recordVisitForThread } from "~/browserHistoryStore"; @@ -12,7 +15,7 @@ import { resolveBrowserLinkTargetPreference, resolveLinkTarget, } from "./browserLinkTarget"; -import { openUrlInPreview } from "./openFileInPreview"; +import { BrowserSettingsReadError, openUrlInPreview } from "./openFileInPreview"; const NO_MODIFIER = { metaKey: false, ctrlKey: false } as const; @@ -24,8 +27,8 @@ const NO_MODIFIER = { metaKey: false, ctrlKey: false } as const; * * An in-app open that fails falls back to the system browser rather than * dropping the click: the user asked for the link, and the setting only says - * where it should go first. The returned promise rejects only when that - * fallback fails too, the same way `shell.openExternal` does. + * where it should go first. Failed settings reads reject without opening a + * browser. The promise also rejects if the system-browser fallback fails. */ export function useOpenLink(threadRef: ScopedThreadRef | null | undefined): ( url: string, @@ -52,6 +55,8 @@ export function useOpenLink(threadRef: ScopedThreadRef | null | undefined): ( recordVisitForThread(targetThreadRef, url); return; } + const failure = squashAtomCommandFailure(result); + if (failure instanceof BrowserSettingsReadError) throw failure; console.error(result.cause); } const api = readLocalApi(); diff --git a/apps/web/src/clientPersistenceStorage.test.ts b/apps/web/src/clientPersistenceStorage.test.ts index db69fe96c80a..a86177b48eb3 100644 --- a/apps/web/src/clientPersistenceStorage.test.ts +++ b/apps/web/src/clientPersistenceStorage.test.ts @@ -52,22 +52,45 @@ describe("clientPersistenceStorage", () => { expect(readBrowserClientSettings()).toEqual(settings); }); - it("reports structured decode failures while preserving the fallback", async () => { + it.each(["not-json", '{"wordWrap":"invalid"}'])( + "does not treat invalid saved settings as absent: %s", + async (value) => { + const testWindow = getTestWindow(); + testWindow.localStorage.setItem("t3code:client-settings:v1", value); + const { readBrowserClientSettings } = await import("./clientPersistenceStorage"); + + expect(() => readBrowserClientSettings()).toThrow( + expect.objectContaining({ + _tag: "LocalStorageOperationError", + operation: "decode", + storageKey: "t3code:client-settings:v1", + }), + ); + expect(testWindow.localStorage.getItem("t3code:client-settings:v1")).toBe(value); + }, + ); + + it("preserves saved settings across a transient read failure", async () => { const testWindow = getTestWindow(); - testWindow.localStorage.setItem("t3code:client-settings:v1", "not-json"); - const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); + const settings = { ...DEFAULT_CLIENT_SETTINGS, timestampFormat: "12-hour" as const }; + testWindow.localStorage.setItem("t3code:client-settings:v1", JSON.stringify(settings)); + const write = vi.spyOn(testWindow.localStorage, "setItem"); + const failure = new Error("storage unavailable"); + vi.spyOn(testWindow.localStorage, "getItem").mockImplementationOnce(() => { + throw failure; + }); const { readBrowserClientSettings } = await import("./clientPersistenceStorage"); - expect(readBrowserClientSettings()).toBeNull(); - expect(consoleError).toHaveBeenCalledWith( - "Could not read persisted client settings.", + expect(() => readBrowserClientSettings()).toThrow( expect.objectContaining({ _tag: "LocalStorageOperationError", - operation: "decode", + operation: "read", storageKey: "t3code:client-settings:v1", - cause: expect.anything(), + cause: failure, }), ); + expect(readBrowserClientSettings()).toEqual(settings); + expect(write).not.toHaveBeenCalled(); }); it("defaults word wrap on and discards obsolete wrapping preferences", async () => { diff --git a/apps/web/src/clientPersistenceStorage.ts b/apps/web/src/clientPersistenceStorage.ts index 5c0ba7c6eccf..f39ea63c5a7c 100644 --- a/apps/web/src/clientPersistenceStorage.ts +++ b/apps/web/src/clientPersistenceStorage.ts @@ -13,12 +13,7 @@ export function readBrowserClientSettings(): ClientSettings | null { return null; } - try { - return getLocalStorageItem(CLIENT_SETTINGS_STORAGE_KEY, ClientSettingsSchema); - } catch (error) { - console.error("Could not read persisted client settings.", error); - return null; - } + return getLocalStorageItem(CLIENT_SETTINGS_STORAGE_KEY, ClientSettingsSchema); } export function writeBrowserClientSettings(settings: ClientSettings): void { diff --git a/apps/web/src/cloud/linkEnvironment.test.ts b/apps/web/src/cloud/linkEnvironment.test.ts index 7ae5e7ed03a9..3ae0dbd74289 100644 --- a/apps/web/src/cloud/linkEnvironment.test.ts +++ b/apps/web/src/cloud/linkEnvironment.test.ts @@ -26,10 +26,7 @@ import { remoteHttpClientLayer } from "@t3tools/client-runtime/rpc"; import { __resetDesktopPrimaryAuthForTests } from "../environments/primary/desktopAuth"; import { - collectCloudLinkTargets, linkPrimaryEnvironmentToCloud, - listManagedCloudEnvironments, - normalizeRelayBaseUrl, readPrimaryCloudLinkState, type CloudLinkTarget, unlinkPrimaryEnvironmentFromCloud, @@ -155,48 +152,6 @@ afterEach(() => { }); describe("web cloud link environment client", () => { - it("normalizes relay URLs and de-duplicates cloud link targets", () => { - expect(normalizeRelayBaseUrl(" https://relay.example.test/// ")).toBe( - "https://relay.example.test", - ); - expect(normalizeRelayBaseUrl(" ")).toBeNull(); - expect( - collectCloudLinkTargets({ - primary: TARGET, - saved: [TARGET, { ...TARGET, environmentId: "environment-2" }], - }).map((target) => target.environmentId), - ).toEqual(["environment-1", "environment-2"]); - }); - - it.effect("lists relay-managed environments through the typed relay client", () => - Effect.gen(function* () { - const fetchMock = vi.fn().mockResolvedValue( - Response.json({ - environments: [ - { - environmentId: "environment-1", - label: "Desktop", - endpoint: { - httpBaseUrl: "https://desktop.example.test", - wsBaseUrl: "wss://desktop.example.test", - providerKind: "cloudflare_tunnel", - }, - linkedAt: "2026-06-06T00:00:00.000Z", - }, - ], - }), - ); - vi.stubGlobal("fetch", fetchMock); - - const environments = yield* withServices( - listManagedCloudEnvironments({ clerkToken: "clerk-token" }), - ); - - expect(environments).toHaveLength(1); - expect(fetchMock.mock.calls[0]?.[1]?.headers.authorization).toBe("Bearer clerk-token"); - }), - ); - it.effect("reads primary cloud link state from the explicit target", () => Effect.gen(function* () { const fetchMock = vi.fn().mockResolvedValue( diff --git a/apps/web/src/cloud/linkEnvironment.ts b/apps/web/src/cloud/linkEnvironment.ts index 29353e480f50..f88e7863969d 100644 --- a/apps/web/src/cloud/linkEnvironment.ts +++ b/apps/web/src/cloud/linkEnvironment.ts @@ -16,7 +16,6 @@ import { WS_METHODS, } from "@t3tools/contracts"; import { - type RelayClientEnvironmentRecord, type RelayEnvironmentLinkResponse, type RelayManagedEndpointProviderKind, } from "@t3tools/contracts/relay"; @@ -33,14 +32,6 @@ import { requestRelayClientInstallConfirmation, } from "./relayClientInstallDialog"; -export function normalizeRelayBaseUrl(value: string | null | undefined): string | null { - const trimmed = value?.trim(); - if (!trimmed) { - return null; - } - return trimmed.replace(/\/+$/g, ""); -} - function relayUrl(): string | null { return resolveCloudPublicConfig().relayUrl; } @@ -194,53 +185,6 @@ export interface CloudLinkTarget { export type CloudLinkState = EnvironmentCloudLinkStateResult; -export function collectCloudLinkTargets(input: { - readonly primary: CloudLinkTarget | null; - readonly saved: ReadonlyArray; -}): ReadonlyArray { - const byId = new Map(); - if (input.primary) { - byId.set(input.primary.environmentId, input.primary); - } - for (const environment of input.saved) { - if (!byId.has(environment.environmentId)) { - byId.set(environment.environmentId, environment); - } - } - return [...byId.values()]; -} - -export function listManagedCloudEnvironments(input: { - readonly clerkToken: string; -}): Effect.Effect< - ReadonlyArray, - CloudEnvironmentLinkError, - ManagedRelay.ManagedRelayClient -> { - return Effect.gen(function* () { - const configuredRelayUrl = relayUrl(); - if (!configuredRelayUrl) { - return yield* new CloudEnvironmentLinkError({ - message: "T3CODE_RELAY_URL is not configured.", - }); - } - const relayClient = yield* ManagedRelay.ManagedRelayClient; - return yield* relayClient - .listEnvironments({ - clerkToken: input.clerkToken, - }) - .pipe( - Effect.mapError( - (cause) => - new CloudEnvironmentLinkError({ - message: "Could not list relay-managed environments.", - cause, - }), - ), - ); - }); -} - export function readPrimaryCloudLinkState(input: { readonly target: CloudLinkTarget; }): Effect.Effect { diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 76fdcd698c0a..6e96c9132c58 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -53,6 +53,7 @@ import React, { Children, Suspense, type CSSProperties, + type ComponentProps, type ClipboardEvent as ReactClipboardEvent, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, @@ -176,6 +177,7 @@ import { openFileInPreview, openUrlInPreview, BrowserPreviewUnavailableError, + BrowserSettingsReadError, } from "../browser/openFileInPreview"; import { resolveLinkTarget } from "../browser/browserLinkTarget"; import { PullRequestLinkPreview } from "./pullRequest/PullRequestLinkPreview"; @@ -359,6 +361,55 @@ type MarkdownImageHastNode = { children?: MarkdownImageHastNode[]; }; +function meaningfulHastChildren(node: MarkdownImageHastNode): MarkdownImageHastNode[] { + return (node.children ?? []).filter( + (child) => !(child.type === "text" && (child as { value?: string }).value?.trim() === ""), + ); +} + +/** + * An image that is the only content of its block (optionally wrapped in a + * link) is almost always a screenshot or figure, so it gets a reserved slot + * while it loads. Images mixed with text or other images — badge rows, icons + * in a sentence — stay inline at their natural size, since a placeholder taller + * than the image would move the page more than the image itself does. + */ +/** Containers whose sole child image reads as a figure rather than part of a sentence. */ +const STANDALONE_IMAGE_BLOCKS = new Set([ + "p", + "div", + "li", + "td", + "th", + "figure", + "center", + "blockquote", +]); + +function soleImageDescendant(node: MarkdownImageHastNode): MarkdownImageHastNode | undefined { + const children = meaningfulHastChildren(node); + if (children.length !== 1) return undefined; + const only = children[0]; + if (only?.type !== "element") return undefined; + if (only.tagName === "img") return only; + // A link, emphasis, or similar inline wrapper around the image still counts + // as long as nothing else shares the block. + return only.tagName === "a" || only.tagName === "strong" || only.tagName === "em" + ? soleImageDescendant(only) + : undefined; +} + +function markStandaloneImages(node: MarkdownImageHastNode) { + // A raw `` on its own line reaches the root without a paragraph. + if (node.type === "root" || (node.tagName && STANDALONE_IMAGE_BLOCKS.has(node.tagName))) { + const image = soleImageDescendant(node); + if (image) image.properties = { ...image.properties, dataStandalone: true }; + } + node.children?.forEach((child) => { + if (child.type === "element") markStandaloneImages(child); + }); +} + /** Carries authored image source metadata through the sanitizer to the image renderer. */ function rehypePreserveImageSourceMeta() { return (tree: MarkdownImageHastNode) => { @@ -376,6 +427,7 @@ function rehypePreserveImageSourceMeta() { }; visit(tree); + markStandaloneImages(tree); }; } @@ -388,7 +440,12 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = { blockquote: [...(defaultSchema.attributes?.blockquote ?? []), "dataAlert"], div: [...(defaultSchema.attributes?.div ?? []), ...CODEX_ARTIFACT_TEMPLATE_HAST_PROPERTIES], a: [...(defaultSchema.attributes?.a ?? []), "dataPullRequestAutolink"], - img: [...(defaultSchema.attributes?.img ?? []), "dataLocalSrc", "dataMarkdownTitle"], + img: [ + ...(defaultSchema.attributes?.img ?? []), + "dataLocalSrc", + "dataMarkdownTitle", + "dataStandalone", + ], }, protocols: { ...defaultSchema.protocols, @@ -1223,7 +1280,6 @@ function authoredImageSizeStyle( } const CHAT_MARKDOWN_WORKSPACE_IMAGE_CLASS_NAME = cn( - CHAT_MARKDOWN_IMAGE_SIZE_CLASS_NAME, CHAT_MARKDOWN_MEDIA_LAYOUT_CLASS_NAME, CHAT_MARKDOWN_MEDIA_FRAME_CLASS_NAME, ); @@ -1265,13 +1321,26 @@ function expandableMarkdownImageProps( }; } +function ChatMarkdownMediaUnavailableLabel(props: { + readonly alt: string; + readonly kind?: "image" | "video" | undefined; +}) { + const label = props.kind === "video" ? "Video unavailable" : "Image unavailable"; + return ( + + + {props.alt.length > 0 ? `${label} · ${props.alt}` : label} + + ); +} + +/** Inline chip for an image that sits in a line of text or can never load. */ function ChatMarkdownImageFallback(props: { readonly alt: string; readonly copyMarkdown?: string | undefined; readonly kind?: "image" | "video"; - readonly actionsSource?: MediaActionSource; + readonly actionsSource?: MediaActionSource | undefined; }) { - const label = props.kind === "video" ? "Video unavailable" : "Image unavailable"; const content = ( - - - {props.alt.length > 0 ? `${label} · ${props.alt}` : label} - + ); return props.actionsSource ? ( @@ -1293,6 +1359,144 @@ function ChatMarkdownImageFallback(props: { ); } +const CHAT_MARKDOWN_IMAGE_FRAME_CLASS_NAME = cn( + "aspect-video w-full overflow-hidden bg-muted/60", + CHAT_MARKDOWN_MEDIA_MAX_WIDTH_CLASS_NAME, + CHAT_MARKDOWN_MEDIA_FRAME_CLASS_NAME, +); + +/** + * A standalone image holds a 16:9 slot (or its authored size) until it has + * decoded, and keeps that slot if it fails, so a timeline row moves at most + * once: when the natural size arrives. A bare `` is zero height until + * then. Once decoded the image renders bare again so its box, hit area, and + * alignment are exactly the image's own. Inline images (badges, icons in a + * sentence) skip the slot: a placeholder taller than the image would move the + * page more than the image does. + * + * Callers key this on the file's identity, not its URL: a re-signed URL for + * the same file keeps the decoded image on screen while the new bytes arrive, + * and a different file starts from the slot again. + */ +function ChatMarkdownImage(props: { + /** Null while the URL is being resolved; the last decoded image stays up. */ + readonly src: string | null; + readonly sourceFailed?: boolean | undefined; + readonly alt: string; + readonly copyMarkdown: string | undefined; + readonly standalone: boolean; + readonly className?: string | undefined; + readonly style?: CSSProperties | undefined; + /** Sanitized authored attributes (`id`, `align`, …) that fragment links and layout rely on. */ + readonly imageProps?: + | Omit, "src" | "alt" | "className" | "style"> + | undefined; + readonly actionsSource: MediaActionSource; + readonly originalUrl?: string | undefined; + readonly onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined; +}) { + const [loadedSrc, setLoadedSrc] = useState(null); + const [failedSrc, setFailedSrc] = useState(null); + const src = props.src ?? loadedSrc; + const failed = props.sourceFailed === true || (src !== null && failedSrc === src); + // A failure forgets the decoded image so the next URL loads behind the slot. + const settled = src !== null && !failed && (!props.standalone || loadedSrc !== null); + // Cached images are complete before `onLoad` can fire. + const markLoadedIfComplete = useCallback((image: HTMLImageElement | null) => { + if (image?.complete && image.naturalWidth > 0) setLoadedSrc(image.currentSrc || image.src); + }, []); + const imageEvents = (loadingSrc: string) => ({ + onLoad: () => { + setLoadedSrc(loadingSrc); + setFailedSrc(null); + }, + onError: () => { + setFailedSrc(loadingSrc); + setLoadedSrc(null); + }, + }); + + if (settled) { + return ( + + {props.alt} + + ); + } + if (!props.standalone) { + return failed ? ( + + ) : ( + + ); + } + return ( + + + {failed ? ( + + + + ) : src !== null ? ( + {props.alt} + ) : null} + + + ); +} + function ChatMarkdownVideo(props: { readonly src: string | null; readonly alt: string; @@ -1339,13 +1543,14 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props readonly alt: string; readonly copyMarkdown?: string; readonly srcFragment?: string; + /** Reserve a slot while loading; off for images that share a line with text. */ + readonly standalone?: boolean | undefined; readonly style?: CSSProperties | undefined; readonly workspaceRoot?: string | undefined; readonly onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined; }) { const assetUrl = useAssetUrlState(props.environmentId, props.resource); const refreshAssetUrl = useAssetUrlRefresh(props.environmentId, props.resource); - const [failedUrl, setFailedUrl] = useState(null); const resource = props.resource; const path = resource._tag === "media-file" @@ -1390,56 +1595,19 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props ); } - if (assetUrl._tag === "Failure" || (assetUrl._tag === "Success" && failedUrl === assetUrl.url)) { - return ( - - ); - } - if (assetUrl._tag !== "Success") { - return ( - - - - ); - } return ( - - {props.alt} setFailedUrl(assetUrl.url)} - /> - + ); }); @@ -2213,6 +2381,18 @@ function useChatMarkdownState({ } return openUrlInPreview({ threadRef, url, openPreview }).then((result) => { if (result._tag === "Success") recordVisitForThread(threadRef, url); + else if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + if (error instanceof BrowserSettingsReadError) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to open link in browser", + description: error.message, + }), + ); + } + } return result; }); }, @@ -2645,14 +2825,14 @@ const CHAT_MARKDOWN_COMPONENTS = { } event.preventDefault(); event.stopPropagation(); - // The click was taken from the shell, so an in-app open that fails - // hands the link to the system browser instead of dropping it. + // Keep the link here if saved settings could not be read. void openExternalLinkInPreview(href).then((result) => { if (result._tag === "Success" || isAtomCommandInterrupted(result)) return; reportMarkdownActionFailure( { operation: "open-link-in-preview", target: href }, result.cause, ); + if (squashAtomCommandFailure(result) instanceof BrowserSettingsReadError) return; void readLocalApi()?.shell.openExternal(href); }); }} @@ -2795,6 +2975,7 @@ const CHAT_MARKDOWN_COMPONENTS = { const imageExpand = use(MarkdownLinkContext) ? undefined : expandMedia; const localSrc = node?.properties?.dataLocalSrc; const markdownTitle = node?.properties?.dataMarkdownTitle; + const standalone = node?.properties?.dataStandalone === true; const authoredSrc = typeof localSrc === "string" ? localSrc : src; const authoredTitle = typeof markdownTitle === "string" ? markdownTitle : title; const srcString = @@ -2803,7 +2984,8 @@ const CHAT_MARKDOWN_COMPONENTS = { typeof localSrc === "string" ? srcString.replaceAll("\\", "/") : srcString; const altText = alt ?? ""; const copyMarkdown = markdownImageCopy(altText, srcString, authoredTitle); - const authoredSizeStyle = authoredImageSizeStyle(props.width, props.height); + const { className, style: _style, width, height, ...imageProps } = props; + const authoredSizeStyle = authoredImageSizeStyle(width, height); const imageSource = classifyMarkdownImageSource(classifiedSrc, imageBaseDir ?? cwd); const kind = mediaKindFromPath(classifiedSrc) ?? "image"; if (imageSource._tag === "Direct") { @@ -2830,27 +3012,19 @@ const CHAT_MARKDOWN_COMPONENTS = { ); } return ( - - {altText} - + ); } if (imageSource._tag === "WorkspaceFile" && threadRef) { @@ -2866,6 +3040,7 @@ const CHAT_MARKDOWN_COMPONENTS = { kind={kind} copyMarkdown={copyMarkdown} srcFragment={markdownImageSourceFragment(classifiedSrc)} + standalone={standalone} style={authoredSizeStyle} workspaceRoot={cwd} onImageExpand={imageExpand} diff --git a/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx b/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx index 39be0eedafe2..ad3e951d89a9 100644 --- a/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx +++ b/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx @@ -139,9 +139,10 @@ describe("ChatMarkdown workspace images", () => { path: "\\\\server\\share\\workspace-image.svg", }, ]); - expect(html.match(/https:\/\/signed\.test\/workspace-image\.svg/g)).toHaveLength(4); + expect(html.match(/]*src="https:\/\/signed\.test\/workspace-image\.svg"/g)).toHaveLength( + 4, + ); expect(html.match(/max-w-\[min\(100%,30rem\)\]/g)).toHaveLength(4); - expect(html.match(/max-h-\[30rem\]/g)).toHaveLength(4); expect(html).not.toContain("Image unavailable"); }); @@ -203,23 +204,49 @@ describe("ChatMarkdown workspace images", () => { expect(loadedStyle).toHaveProperty(constraint, expectedValue); }); - it("keeps all images baseline-aligned and workspace images inline", () => { + it("keeps images that share a line inline and lets a standalone one reserve a slot", () => { const html = render( "![remote](https://example.com/badge.svg) ![workspace](.t3/workspace-image.svg)", ); - const classNames = Array.from(html.matchAll(/]*class="([^"]*)"/g), (match) => - match[1]?.split(" "), - ); - expect(classNames).toHaveLength(2); - expect(classNames[1]).toContain("inline-block!"); + // Two images in one paragraph are badges: neither reserves a slot. + expect(html).not.toContain("aspect-video"); + expect(html).toContain('src="https://example.com/badge.svg"'); + expect(html).toContain('src="https://signed.test/workspace-image.svg"'); + expect(html.match(/]*class="[^"]*inline-block![^"]*"/g)).toHaveLength(1); + expect(html).not.toContain("invisible"); const centeredHtml = render( '

logo

', ); - const centeredClassName = /]*class="([^"]*)"/.exec(centeredHtml)?.[1]; + const frame = /]*role="status"[^>]*>/.exec(centeredHtml)?.[0]; + + expect(frame).toContain("inline-block!"); + expect(frame).toContain("aspect-video"); + }); + + it("reserves a slot for an image that is the only content of its link", () => { + const html = render("[![shot](.t3/workspace-image.svg)](https://example.com)"); + + expect(html).toContain("aspect-video"); + }); + + it.each([ + ["a link", "Figure: [![shot](.t3/workspace-image.svg)](https://example.com)"], + ["emphasis", "**![shot](.t3/workspace-image.svg)** caption"], + ])("keeps an image wrapped in %s inline when text shares its block", (_wrapper, markdown) => { + expect(render(markdown)).not.toContain("aspect-video"); + }); + + it("keeps an authored id on a remote image so fragment links resolve", () => { + const html = render('diagram'); + + // The sanitizer prefixes authored ids; the loading slot carries it too. + expect(html).toContain(' { + expect(render("- ![shot](.t3/workspace-image.svg)")).toContain("aspect-video"); }); it("retains an authored SVG fragment on the signed URL", () => { @@ -278,15 +305,35 @@ describe("ChatMarkdown workspace images", () => { ); }); - it("uses a static bounded-width placeholder while a signed asset URL loads", () => { + it("reserves the same 16:9 frame while the URL, the bytes, and a failure resolve", () => { + const frameClassName = (html: string) => { + const frame = /]*role="(?:status|alert)"[^>]*>/.exec(html)?.[0] ?? ""; + return /class="([^"]*)"/.exec(frame)?.[1]?.split(" ") ?? []; + }; + const markdown = "![shot](.t3/workspace-image.svg)"; + testState.assetState = "loading"; + const loadingUrl = frameClassName(render(markdown)); + testState.assetState = "success"; + const loadingBytes = render(markdown); + testState.assetState = "failure"; + const failure = render(markdown); + + expect(loadingUrl).toEqual(expect.arrayContaining(["aspect-video", "w-full"])); + expect(loadingUrl).not.toContain("animate-pulse"); + expect(frameClassName(loadingBytes)).toEqual(loadingUrl); + expect(frameClassName(failure)).toEqual(loadingUrl); + expect(failure).toContain("Image unavailable"); + // The bytes are requested inside the frame but never paint at an unknown size. + expect(loadingBytes).toMatch(/]*src="https:\/\/signed[^>]*class="invisible/); + expect(loadingBytes).not.toContain('loading="lazy"'); + }); - const html = render("![loading](.t3/workspace-image.svg)"); - const className = /]*aria-label="Loading image"[^>]*class="([^"]*)"/.exec(html)?.[1]; + it("gives a standalone remote image the same frame instead of a bare tag", () => { + const html = render("![remote](https://example.com/shot.png)"); expect(html).toContain('aria-label="Loading image"'); - expect(html).not.toContain("animate-pulse"); - expect(className?.split(" ")).toContain("w-64"); + expect(html).toContain("aspect-video"); }); it("never passes a workspace source to a raw image when thread context is unavailable", () => { @@ -313,7 +360,6 @@ describe("ChatMarkdown workspace images", () => { expect(testState.resources).toEqual([]); expect(html).toContain('src="https://example.com/image.png"'); expect(html).toContain("max-w-[min(100%,30rem)]"); - expect(html).toContain("max-h-[30rem]"); expect(html).not.toContain("Image unavailable"); }); }); diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 28ab092d7a5d..93f15d1ba0ba 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -1161,6 +1161,56 @@ describe("buildRevertTurnCountByUserMessageId", () => { }).size, ).toBe(0); }); + + it.each([true, false])( + "returns the previous map when contents are unchanged (rollback supported: %s)", + (supportsConversationRollback) => { + const input = { + supportsConversationRollback, + timelineEntries, + turnDiffSummaryByAssistantMessageId, + inferredCheckpointTurnCountByTurnId: {}, + }; + const previous = buildRevertTurnCountByUserMessageId(input); + const streamed = timelineEntries.map((entry) => + entry.message.role === "assistant" + ? { ...entry, message: { ...entry.message, text: "Updated the file again" } } + : entry, + ); + + expect( + buildRevertTurnCountByUserMessageId({ ...input, timelineEntries: streamed }, previous), + ).toBe(previous); + }, + ); + + it("returns a new map when a revert target changes", () => { + const input = { + supportsConversationRollback: true, + timelineEntries, + turnDiffSummaryByAssistantMessageId, + inferredCheckpointTurnCountByTurnId: {}, + }; + const previous = buildRevertTurnCountByUserMessageId(input); + const next = buildRevertTurnCountByUserMessageId( + { + ...input, + turnDiffSummaryByAssistantMessageId: new Map([ + [ + assistantMessageId, + { + ...turnDiffSummaryByAssistantMessageId.get(assistantMessageId)!, + checkpointTurnCount: 3, + }, + ], + ]), + }, + previous, + ); + + expect(next).not.toBe(previous); + expect(next).toEqual(new Map([[userMessageId, 2]])); + }); }); describe("deriveComposerSendState", () => { @@ -1519,6 +1569,33 @@ describe("shouldWriteThreadErrorToCurrentServerThread", () => { }); }); +describe("startNewThreadForProject", () => { + it("starts a thread through the supplied shared handler for the active project", () => { + const calls: Array<{ environmentId: EnvironmentId; projectId: ProjectId }> = []; + const projectRef = { environmentId, projectId }; + + expect( + startNewThreadForProject(projectRef, (nextProjectRef) => { + calls.push(nextProjectRef); + return Promise.resolve(); + }), + ).toBe(true); + expect(calls).toEqual([projectRef]); + }); + + it("does nothing when the active project is unavailable", () => { + let called = false; + + expect( + startNewThreadForProject(null, () => { + called = true; + return Promise.resolve(); + }), + ).toBe(false); + expect(called).toBe(false); + }); +}); + describe("server thread liveness", () => { it("requires shell and detail before treating a server thread as active", () => { expect( diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 744bb2ec96b0..60201fca51e0 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -38,6 +38,7 @@ import { } from "../types"; import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore"; import * as Schema from "effect/Schema"; +import { shallow } from "zustand/vanilla/shallow"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { environmentThreadDetails } from "../state/threads"; import { @@ -553,17 +554,24 @@ export function getAntigravitySendBlockReason( return null; } -export function buildRevertTurnCountByUserMessageId(input: { - supportsConversationRollback: boolean; - timelineEntries: ReadonlyArray; - turnDiffSummaryByAssistantMessageId: ReadonlyMap; - inferredCheckpointTurnCountByTurnId: Readonly>; -}) { +/** + * Maps each user message to the checkpoint turn count a revert should target. + * Returns `previous` when the result is unchanged: streaming text deltas + * rebuild `timelineEntries` per token, and the timeline row projection only + * reuses rows while this Map keeps its identity. + */ +export function buildRevertTurnCountByUserMessageId( + input: { + supportsConversationRollback: boolean; + timelineEntries: ReadonlyArray; + turnDiffSummaryByAssistantMessageId: ReadonlyMap; + inferredCheckpointTurnCountByTurnId: Readonly>; + }, + previous: Map | null = null, +): Map { const byUserMessageId = new Map(); - if (!input.supportsConversationRollback) { - return byUserMessageId; - } - for (let index = 0; index < input.timelineEntries.length; index += 1) { + const entryCount = input.supportsConversationRollback ? input.timelineEntries.length : 0; + for (let index = 0; index < entryCount; index += 1) { const entry = input.timelineEntries[index]; if (!entry || entry.kind !== "message" || entry.message.role !== "user") { continue; @@ -590,7 +598,7 @@ export function buildRevertTurnCountByUserMessageId(input: { break; } } - return byUserMessageId; + return previous !== null && shallow(previous, byUserMessageId) ? previous : byUserMessageId; } export function reconcileMountedTerminalThreadIds(input: { diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index b8bf8c06568b..ff4d9a72e912 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -171,6 +171,7 @@ import { useThreadPreviewState, } from "../previewStateStore"; import { previewRuntimeTabId } from "../browser/previewRuntimeTabId"; +import { BrowserSettingsReadError } from "../browser/openFileInPreview"; import { addBrowserSurface } from "./preview/addBrowserSurface"; import { closePreviewSession } from "./preview/closePreviewSession"; import { ThreadPreviewMiniPlayer } from "./preview/ThreadPreviewMiniPlayer"; @@ -3277,21 +3278,25 @@ export default function ChatView(props: ChatViewProps) { } return byMessageId; }, [turnDiffSummaries]); - const revertTurnCountByUserMessageId = useMemo( - () => - buildRevertTurnCountByUserMessageId({ + const lastRevertTurnCountRef = useRef | null>(null); + const revertTurnCountByUserMessageId = useMemo(() => { + const next = buildRevertTurnCountByUserMessageId( + { supportsConversationRollback, timelineEntries, turnDiffSummaryByAssistantMessageId, inferredCheckpointTurnCountByTurnId, - }), - [ - supportsConversationRollback, - inferredCheckpointTurnCountByTurnId, - timelineEntries, - turnDiffSummaryByAssistantMessageId, - ], - ); + }, + lastRevertTurnCountRef.current, + ); + lastRevertTurnCountRef.current = next; + return next; + }, [ + supportsConversationRollback, + inferredCheckpointTurnCountByTurnId, + timelineEntries, + turnDiffSummaryByAssistantMessageId, + ]); const gitCwd = activeProject ? projectScriptCwd({ @@ -4011,6 +4016,18 @@ export default function ChatView(props: ChatViewProps) { threadRef: activeThreadRef, openPreview, ...(profileId === undefined ? {} : { profileId }), + }).then((result) => { + if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return; + const error = squashAtomCommandFailure(result); + if (error instanceof BrowserSettingsReadError) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to open browser", + description: error.message, + }), + ); + } }); }, [activeThreadRef, openPreview], diff --git a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts index 2ee06a6b6620..12f63f3e16fd 100644 --- a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts +++ b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts @@ -18,17 +18,14 @@ import { environmentGroupsWithUpdates, firstFailedProviderUpdateMessage, firstRejectedProviderUpdateMessage, - firstUnsuccessfulSecondaryProviderOutcome, getProviderUpdateInitialToastView, getProviderUpdateProgressToastView, getProviderUpdateRejectedToastView, getProviderUpdateSidebarPillView, - getSingleProviderUpdateProgressToastView, hasOneClickUpdateProviderCandidate, isProviderUpdateCandidate, isTerminalProviderUpdatePhase, localEnvironmentUpdateNotificationKey, - parseWslDistroFromInstanceId, providerUpdateNotificationKey, resolveEnvironmentUpdateRowStatus, shouldShowPrimaryProviderUpdateToast, @@ -368,28 +365,6 @@ describe("provider update launch notification logic", () => { }); }); - it("resolves a single-provider completion view from the returned provider snapshot", () => { - const view = getSingleProviderUpdateProgressToastView( - provider({ - driver: driver("codex"), - updateState: { - status: "failed", - startedAt: checkedAt, - finishedAt: checkedAt, - message: "command failed", - output: "stderr", - }, - }), - ); - - expect(view).toMatchObject({ - phase: "failed", - type: "error", - title: "Codex v1.1.0 update failed", - description: "command failed", - }); - }); - it("keeps unchanged providers actionable from settings", () => { const view = getProviderUpdateProgressToastView({ providers: [ @@ -444,31 +419,6 @@ describe("provider update launch notification logic", () => { }); }); - it("uses the updated version in the single-provider success toast title", () => { - const view = getSingleProviderUpdateProgressToastView( - provider({ - driver: driver("codex"), - version: "1.1.0", - latestVersion: "1.1.0", - advisoryStatus: "current", - updateState: { - status: "succeeded", - startedAt: checkedAt, - finishedAt: checkedAt, - message: "Provider updated.", - output: null, - }, - }), - ); - - expect(view).toMatchObject({ - phase: "succeeded", - type: "success", - title: "Codex updated: v1.1.0", - description: "New sessions will use the updated provider.", - }); - }); - it("falls back to a rejected RPC message for transport-level failures", () => { const results = [AsyncResult.failure(Cause.die(new Error("WebSocket closed")))]; @@ -814,39 +764,6 @@ describe("provider update launch notification logic", () => { expect(snapshots).toEqual([primary]); }); - it("flags the first unsuccessful secondary outcome, skipping the primary and successes", () => { - const primaryFailed = provider({ - driver: driver("codex"), - updateState: terminalState("failed", "primary boom"), - }); - - expect( - firstUnsuccessfulSecondaryProviderOutcome([ - fulfilledOutcome(true, primaryFailed), - fulfilledOutcome( - false, - provider({ - driver: driver("codex"), - updateState: terminalState("succeeded", "ok"), - }), - ), - ]), - ).toBeNull(); - - expect( - firstUnsuccessfulSecondaryProviderOutcome([ - fulfilledOutcome(true, primaryFailed), - fulfilledOutcome( - false, - provider({ - driver: driver("codex"), - updateState: terminalState("failed", "wsl boom"), - }), - ), - ]), - ).toMatchObject({ status: "failed", provider: { updateState: { message: "wsl boom" } } }); - }); - it("treats a rejected dispatch as not contributing a snapshot", () => { const primary = provider({ driver: driver("codex"), @@ -995,14 +912,6 @@ describe("provider update launch notification logic", () => { }), ).toBe("My Device"); }); - - it("parses the WSL distro from the backend instance id", () => { - expect(parseWslDistroFromInstanceId("wsl:ubuntu")).toBe("ubuntu"); - expect(parseWslDistroFromInstanceId("wsl:default")).toBeNull(); - expect(parseWslDistroFromInstanceId("wsl:")).toBeNull(); - expect(parseWslDistroFromInstanceId("ssh:host")).toBeNull(); - expect(parseWslDistroFromInstanceId(undefined)).toBeNull(); - }); }); describe("isTerminalProviderUpdatePhase", () => { diff --git a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts index 10c2f144bff6..16184ac070bc 100644 --- a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts +++ b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts @@ -326,41 +326,6 @@ export function getProviderUpdateProgressToastView(input: { return getProviderUpdateRunningToastView(input.providerCount); } -export function getSingleProviderUpdateProgressToastView( - provider: ServerProvider, -): ProviderUpdateToastView { - const view = getProviderUpdateProgressToastView({ - providers: [provider], - providerCount: 1, - }); - const providerName = PROVIDER_DISPLAY_NAMES[provider.driver] ?? provider.driver; - - switch (view.phase) { - case "running": - return { - ...view, - title: `Updating ${providerName}`, - }; - case "failed": - return { - ...view, - title: getProviderFailedUpdateTitle(provider), - }; - case "unchanged": - return { - ...view, - title: `${providerName} still needs an update`, - }; - case "succeeded": - return { - ...view, - title: getProviderUpdatedTitle(provider), - }; - default: - return view; - } -} - export function collectUpdatedProviderSnapshots(input: { readonly results: ReadonlyArray< AtomCommandResult<{ readonly providers: ReadonlyArray }, unknown> @@ -649,42 +614,6 @@ export function collectProviderUpdateOutcomeSnapshots( return [...worstByDriver.values()]; } -/** - * The first secondary (non-primary) backend whose update resolved without - * succeeding. The primary's own failed/unchanged state is already surfaced - * inline in settings, so only secondaries (which have no inline row) need an - * explicit callout. - */ -export function firstUnsuccessfulSecondaryProviderOutcome( - results: ReadonlyArray>, -): { readonly provider: ServerProvider; readonly status: "failed" | "unchanged" } | null { - for (const result of results) { - if (result.status !== "fulfilled") { - continue; - } - const outcome = result.value; - if (outcome.isPrimary || outcome.provider === null) { - continue; - } - const status = outcome.provider.updateState?.status; - if (status === "failed" || status === "unchanged") { - return { provider: outcome.provider, status }; - } - } - return null; -} - -const WSL_INSTANCE_ID_PREFIX = "wsl:"; - -/** The distro name from a WSL backend instance id ("wsl:ubuntu" -> "ubuntu"), or null for the default. */ -export function parseWslDistroFromInstanceId(instanceId: string | undefined): string | null { - if (!instanceId || !instanceId.startsWith(WSL_INSTANCE_ID_PREFIX)) { - return null; - } - const distro = instanceId.slice(WSL_INSTANCE_ID_PREFIX.length).trim(); - return distro.length === 0 || distro === "default" ? null : distro; -} - /** * A human label that distinguishes local environments by platform (so the * popover shows "Windows" / "WSL" rather than the account name twice). WSL is diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 4eec8ae521de..79825c52c63b 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -8,18 +8,15 @@ import { buildMultiSelectThreadContextMenuItems, buildSidebarThreadWorktreeSections, buildSidebarV2ThreadContextMenuItems, - buildThreadContextMenuItems, createThreadJumpHintVisibilityController, filterSidebarProjectScopeItems, formatWorktreeGroupLabel, getSidebarThreadIdsToPrewarm, resolveSidebarThreadPrewarmLimit, SIDEBAR_THREAD_PREWARM_LIMIT, - getVisibleSidebarThreadIds, resolveAdjacentThreadId, reduceSidebarProjectScopeMenuState, getFallbackThreadIdAfterDelete, - getVisibleThreadsForProject, getProjectSortTimestamp, hasUnseenCompletion, isContextMenuPointerDown, @@ -40,7 +37,6 @@ import { resolveWorkingStartedAt, searchSidebarThreadsByTitle, formatWorkingDurationLabel, - shouldNavigateAfterProjectRemoval, shouldClearThreadSelectionOnMouseDown, shouldRecedeSidebarThread, sortLogicalProjectsForSidebar, @@ -121,56 +117,6 @@ describe("animatePinnedLayoutChanges", () => { }); }); -describe("shouldNavigateAfterProjectRemoval", () => { - const projectThreads = [{ environmentId: "environment-local", id: "thread-1" }]; - - it("navigates away from a draft route owned by the removed project", () => { - expect( - shouldNavigateAfterProjectRemoval({ - routeTarget: { kind: "draft", draftId: "draft-1" as never }, - projectThreads, - projectDraftId: "draft-1", - }), - ).toBe(true); - }); - - it("does not navigate away from a different draft route", () => { - expect( - shouldNavigateAfterProjectRemoval({ - routeTarget: { kind: "draft", draftId: "draft-2" as never }, - projectThreads, - projectDraftId: "draft-1", - }), - ).toBe(false); - }); - - it("navigates away from a server thread owned by the removed project", () => { - expect( - shouldNavigateAfterProjectRemoval({ - routeTarget: { - kind: "server", - threadRef: { - environmentId: EnvironmentId.make("environment-local"), - threadId: ThreadId.make("thread-1"), - }, - }, - projectThreads, - projectDraftId: null, - }), - ).toBe(true); - }); - - it("does not navigate from an unrelated route", () => { - expect( - shouldNavigateAfterProjectRemoval({ - routeTarget: null, - projectThreads, - projectDraftId: null, - }), - ).toBe(false); - }); -}); - describe("archiveSelectedThreadEntries", () => { const entries = [{ threadKey: "one" }, { threadKey: "two" }, { threadKey: "three" }] as const; const success = { _tag: "Success" } as const; @@ -1104,46 +1050,6 @@ describe("resolveAdjacentThreadId", () => { }); }); -describe("getVisibleSidebarThreadIds", () => { - it("returns only the rendered visible thread order across projects", () => { - expect( - getVisibleSidebarThreadIds([ - { - renderedThreadIds: [ - ThreadId.make("thread-12"), - ThreadId.make("thread-11"), - ThreadId.make("thread-10"), - ], - }, - { - renderedThreadIds: [ThreadId.make("thread-8"), ThreadId.make("thread-6")], - }, - ]), - ).toEqual([ - ThreadId.make("thread-12"), - ThreadId.make("thread-11"), - ThreadId.make("thread-10"), - ThreadId.make("thread-8"), - ThreadId.make("thread-6"), - ]); - }); - - it("skips threads from collapsed projects whose thread panels are not shown", () => { - expect( - getVisibleSidebarThreadIds([ - { - shouldShowThreadPanel: false, - renderedThreadIds: [ThreadId.make("thread-hidden-2"), ThreadId.make("thread-hidden-1")], - }, - { - shouldShowThreadPanel: true, - renderedThreadIds: [ThreadId.make("thread-12"), ThreadId.make("thread-11")], - }, - ]), - ).toEqual([ThreadId.make("thread-12"), ThreadId.make("thread-11")]); - }); -}); - describe("isContextMenuPointerDown", () => { it("treats secondary-button presses as context menu gestures on all platforms", () => { expect( @@ -2065,57 +1971,6 @@ describe("resolveProjectStatusIndicator", () => { }); }); -describe("getVisibleThreadsForProject", () => { - it("includes the active thread even when it falls below the folded preview", () => { - const threads = Array.from({ length: 8 }, (_, index) => - makeThread({ - id: ThreadId.make(`thread-${index + 1}`), - title: `Thread ${index + 1}`, - }), - ); - - const result = getVisibleThreadsForProject({ - threads, - activeThreadId: ThreadId.make("thread-8"), - isThreadListExpanded: false, - previewLimit: 6, - }); - - expect(result.hasHiddenThreads).toBe(true); - expect(result.visibleThreads.map((thread) => thread.id)).toEqual([ - ThreadId.make("thread-1"), - ThreadId.make("thread-2"), - ThreadId.make("thread-3"), - ThreadId.make("thread-4"), - ThreadId.make("thread-5"), - ThreadId.make("thread-6"), - ThreadId.make("thread-8"), - ]); - expect(result.hiddenThreads.map((thread) => thread.id)).toEqual([ThreadId.make("thread-7")]); - }); - - it("returns all threads when the list is expanded", () => { - const threads = Array.from({ length: 8 }, (_, index) => - makeThread({ - id: ThreadId.make(`thread-${index + 1}`), - }), - ); - - const result = getVisibleThreadsForProject({ - threads, - activeThreadId: ThreadId.make("thread-8"), - isThreadListExpanded: true, - previewLimit: 6, - }); - - expect(result.hasHiddenThreads).toBe(true); - expect(result.visibleThreads.map((thread) => thread.id)).toEqual( - threads.map((thread) => thread.id), - ); - expect(result.hiddenThreads).toEqual([]); - }); -}); - function makeProject(overrides: Partial = {}): Project { const { defaultModelSelection, ...rest } = overrides; return { diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 8244be5ad706..408288f17eb2 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -17,7 +17,6 @@ import { type ThreadSortInput, } from "../lib/threadSort"; import type { SidebarThreadSummary, Thread } from "../types"; -import type { ThreadRouteTarget } from "../threadRoutes"; import { cn } from "../lib/utils"; import { sessionNeedsWakeUp } from "@t3tools/shared/sessionWake"; import { isLatestTurnSettled, shouldShowPlanReadyStatus } from "../session-logic"; @@ -818,17 +817,6 @@ export function orderItemsByPreferredIds(input: { return [...ordered, ...remaining]; } -export function getVisibleSidebarThreadIds( - renderedProjects: readonly { - shouldShowThreadPanel?: boolean; - renderedThreadIds: readonly TThreadId[]; - }[], -): TThreadId[] { - return renderedProjects.flatMap((renderedProject) => - renderedProject.shouldShowThreadPanel === false ? [] : renderedProject.renderedThreadIds, - ); -} - export function getSidebarThreadIdsToPrewarm( visibleThreadIds: readonly TThreadId[], limit = SIDEBAR_THREAD_PREWARM_LIMIT, @@ -876,28 +864,6 @@ export function resolveAdjacentThreadId(input: { return currentIndex < threadIds.length - 1 ? (threadIds[currentIndex + 1] ?? null) : null; } -export function shouldNavigateAfterProjectRemoval(input: { - routeTarget: ThreadRouteTarget | null; - projectThreads: readonly { - environmentId: string; - id: string; - }[]; - projectDraftId: string | null; -}): boolean { - const { projectDraftId, projectThreads, routeTarget } = input; - if (routeTarget?.kind === "draft") { - return projectDraftId === routeTarget.draftId; - } - if (routeTarget?.kind !== "server") { - return false; - } - return projectThreads.some( - (thread) => - thread.environmentId === routeTarget.threadRef.environmentId && - thread.id === routeTarget.threadRef.threadId, - ); -} - export function isContextMenuPointerDown(input: { button: number; ctrlKey: boolean; @@ -1384,54 +1350,6 @@ export function resolveProjectStatusIndicator( return highestPriorityStatus; } -export function getVisibleThreadsForProject>(input: { - threads: readonly T[]; - activeThreadId: T["id"] | undefined; - isThreadListExpanded: boolean; - previewLimit: number; -}): { - hasHiddenThreads: boolean; - visibleThreads: T[]; - hiddenThreads: T[]; -} { - const { activeThreadId, isThreadListExpanded, previewLimit, threads } = input; - const hasHiddenThreads = threads.length > previewLimit; - - if (!hasHiddenThreads || isThreadListExpanded) { - return { - hasHiddenThreads, - hiddenThreads: [], - visibleThreads: [...threads], - }; - } - - const previewThreads = threads.slice(0, previewLimit); - if (!activeThreadId || previewThreads.some((thread) => thread.id === activeThreadId)) { - return { - hasHiddenThreads: true, - hiddenThreads: threads.slice(previewLimit), - visibleThreads: previewThreads, - }; - } - - const activeThread = threads.find((thread) => thread.id === activeThreadId); - if (!activeThread) { - return { - hasHiddenThreads: true, - hiddenThreads: threads.slice(previewLimit), - visibleThreads: previewThreads, - }; - } - - const visibleThreadIds = new Set([...previewThreads, activeThread].map((thread) => thread.id)); - - return { - hasHiddenThreads: true, - hiddenThreads: threads.filter((thread) => !visibleThreadIds.has(thread.id)), - visibleThreads: threads.filter((thread) => visibleThreadIds.has(thread.id)), - }; -} - export function getFallbackThreadIdAfterDelete< T extends Pick & ThreadSortInput, >(input: { diff --git a/apps/web/src/components/ThreadTerminalDrawer.test.ts b/apps/web/src/components/ThreadTerminalDrawer.test.ts index d9dcd6e79936..1624a739bb1a 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.test.ts +++ b/apps/web/src/components/ThreadTerminalDrawer.test.ts @@ -1,11 +1,98 @@ -import { describe, expect, it } from "vite-plus/test"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { shouldClearTerminalSelectionAction, shouldHandleTerminalExit, + terminalContextMenuItems, terminalSelectionLineRange, + terminalSelectionMenuItems, + terminalThemeFromApp, } from "./ThreadTerminalDrawer"; +describe("terminal selection menus", () => { + it("omits Add to chat when the terminal has no chat target", () => { + expect(terminalSelectionMenuItems().map(({ id }) => id)).toEqual(["add-to-chat", "copy"]); + expect(terminalContextMenuItems({ hasSelection: true }).map(({ id }) => id)).toEqual([ + "add-to-chat", + "copy", + "paste", + ]); + + expect(terminalSelectionMenuItems({ canAddToChat: false }).map(({ id }) => id)).toEqual([ + "copy", + ]); + expect( + terminalContextMenuItems({ hasSelection: true, canAddToChat: false }).map(({ id }) => id), + ).toEqual(["copy", "paste"]); + }); +}); + +describe("terminalThemeFromApp", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("uses terminal colors inherited by the mount instead of a light document theme", () => { + const root = { classList: { contains: () => false } }; + const body = {}; + const drawer = {}; + let canvasColor = "#000"; + const colors: Record = { + "#000": [0, 0, 0, 255], + "#fff": [255, 255, 255, 255], + "#ddd": [221, 221, 221, 255], + "#111": [17, 17, 17, 255], + }; + + vi.stubGlobal("document", { + documentElement: root, + body, + querySelector: () => drawer, + createElement: () => ({ + width: 0, + height: 0, + getContext: () => ({ + clearRect: () => undefined, + fillRect: () => undefined, + get fillStyle() { + return canvasColor; + }, + set fillStyle(value: string) { + canvasColor = value; + }, + getImageData: () => ({ data: colors[canvasColor] ?? [0, 0, 0, 0] }), + }), + }), + }); + vi.stubGlobal("getComputedStyle", (element: object) => { + const local = element === drawer; + const values = local + ? { + "--terminal-background": "#000", + "--terminal-foreground": "#fff", + "--terminal-cursor": "#ddd", + "--terminal-selection-background": "rgba(255, 255, 255, 0.2)", + } + : { + "--terminal-background": "#fff", + "--terminal-foreground": "#111", + }; + return { + backgroundColor: local ? "#000" : "#fff", + color: local ? "#fff" : "#111", + colorScheme: local ? "dark" : "light", + getPropertyValue: (name: string) => values[name as keyof typeof values] ?? "", + }; + }); + + const theme = terminalThemeFromApp(); + + expect(theme.background).toEqual({ r: 0, g: 0, b: 0 }); + expect(theme.foreground).toEqual({ r: 255, g: 255, b: 255 }); + expect(theme.cursor).toEqual({ r: 221, g: 221, b: 221 }); + }); +}); + describe("terminal selection actions", () => { it("clears a pending or currently owned menu when the selection disappears", () => { expect( diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 9f4956aae682..d9ddf9225bdf 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -20,6 +20,7 @@ import { } from "lucide-react"; import { type ContextMenuItem, + type ProviderInstanceId, type ResolvedKeybindingsConfig, type ScopedThreadRef, type ThreadId, @@ -41,6 +42,7 @@ import { import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; import { Button } from "~/components/ui/button"; import { PanelTabCloseButton } from "~/components/ui/panel-tab-close-button"; +import { stackedThreadToast, toastManager } from "~/components/ui/toast"; import { readTextFromClipboard, writeTextToClipboard } from "~/hooks/useCopyToClipboard"; import { cn } from "~/lib/utils"; import { type TerminalContextSelection } from "~/lib/terminalContext"; @@ -172,16 +174,23 @@ function terminalFontOptions(family: string, size: number): { family?: string; s } export function terminalThemeFromApp(mountElement?: HTMLElement | null): GhosttyTheme { - 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 themeStyles = mountElement ? getComputedStyle(mountElement) : drawerStyles; + const colorScheme = themeStyles.colorScheme; + const isDark = + colorScheme === "dark" + ? true + : colorScheme === "light" + ? false + : 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 bodyStyles = getComputedStyle(document.body); - const themeStyles = getComputedStyle(document.documentElement); + const rootThemeStyles = getComputedStyle(document.documentElement); const background = normalizeComputedColor( drawerStyles.backgroundColor, normalizeComputedColor(bodyStyles.backgroundColor, fallbackBackground), @@ -190,8 +199,16 @@ export function terminalThemeFromApp(mountElement?: HTMLElement | null): Ghostty drawerStyles.color, normalizeComputedColor(bodyStyles.color, fallbackForeground), ); - const terminalBackground = readThemeColor(themeStyles, "--terminal-background", background); - const terminalForeground = readThemeColor(themeStyles, "--terminal-foreground", foreground); + const terminalBackground = readThemeColor( + themeStyles, + "--terminal-background", + readThemeColor(rootThemeStyles, "--terminal-background", background), + ); + const terminalForeground = readThemeColor( + themeStyles, + "--terminal-foreground", + readThemeColor(rootThemeStyles, "--terminal-foreground", foreground), + ); const terminalCursor = readThemeColor( themeStyles, "--terminal-cursor", @@ -232,10 +249,14 @@ export function terminalSelectionLineRange(position: { export type TerminalContextMenuAction = "add-to-chat" | "copy" | "paste"; -/** Post-selection popup: just the two selection actions, always enabled. */ -export function terminalSelectionMenuItems(): ContextMenuItem<"add-to-chat" | "copy">[] { +/** Post-selection popup: available selection actions, always enabled. */ +export function terminalSelectionMenuItems(options?: { + canAddToChat?: boolean; +}): ContextMenuItem<"add-to-chat" | "copy">[] { return [ - { id: "add-to-chat", label: "Add to chat" }, + ...(options?.canAddToChat === false + ? [] + : ([{ id: "add-to-chat", label: "Add to chat" }] satisfies ContextMenuItem<"add-to-chat">[])), { id: "copy", label: "Copy" }, ]; } @@ -248,11 +269,13 @@ export function terminalSelectionMenuItems(): ContextMenuItem<"add-to-chat" | "c */ export function terminalContextMenuItems(options: { hasSelection: boolean; + canAddToChat?: boolean; }): ContextMenuItem[] { + const { hasSelection, canAddToChat = true } = options; return [ - ...terminalSelectionMenuItems().map((item) => ({ + ...terminalSelectionMenuItems({ canAddToChat }).map((item) => ({ ...item, - disabled: !options.hasSelection, + disabled: !hasSelection, })), { id: "paste", label: "Paste" }, ]; @@ -292,8 +315,9 @@ interface TerminalViewportProps { cwd: string; worktreePath?: string | null; runtimeEnv?: Record; + providerInstanceId?: ProviderInstanceId; onSessionExited: () => void; - onAddTerminalContext: (selection: TerminalContextSelection) => void; + onAddTerminalContext?: (selection: TerminalContextSelection) => void; focusRequestId: number; autoFocus: boolean; visible: boolean; @@ -317,6 +341,7 @@ export function TerminalViewport({ cwd, worktreePath, runtimeEnv, + providerInstanceId, onSessionExited, onAddTerminalContext, focusRequestId, @@ -357,8 +382,9 @@ export function TerminalViewport({ onSessionExited(); }); const handleAddTerminalContext = useEffectEvent((selection: TerminalContextSelection) => { - onAddTerminalContext(selection); + onAddTerminalContext?.(selection); }); + const canAddSelectionToChat = useEffectEvent(() => onAddTerminalContext !== undefined); const readTerminalLabel = useEffectEvent(() => terminalLabel); const terminalFontFamily = useClientSettings((settings) => resolveTerminalFontPreference({ @@ -383,6 +409,7 @@ export function TerminalViewport({ cwd, ...(worktreePath !== undefined ? { worktreePath } : {}), ...(runtimeEnv ? { env: runtimeEnv } : {}), + ...(providerInstanceId ? { providerInstanceId } : {}), }, }); const writeTerminal = useEffectEvent((data: string) => @@ -635,7 +662,10 @@ export function TerminalViewport({ let clicked: TerminalContextMenuAction | null; try { clicked = await localApi.contextMenu.show( - terminalContextMenuItems({ hasSelection: selectionAction !== null }), + terminalContextMenuItems({ + hasSelection: selectionAction !== null, + canAddToChat: canAddSelectionToChat(), + }), { x: event.clientX, y: event.clientY }, ); } catch (error) { @@ -648,7 +678,9 @@ export function TerminalViewport({ } switch (clicked) { case "add-to-chat": - if (selectionAction) addSelectionToChat(selectionAction.selection); + if (selectionAction && canAddSelectionToChat()) { + addSelectionToChat(selectionAction.selection); + } return; case "copy": if (selectionAction) await copySelection(selectionAction.clipboardText, requestId); @@ -675,7 +707,10 @@ export function TerminalViewport({ const requestId = ++selectionActionRequestIdRef.current; openSelectionMenuRequestIdRef.current = requestId; const clicked = await localApi.contextMenu - .show(terminalSelectionMenuItems(), nextAction.position) + .show( + terminalSelectionMenuItems({ canAddToChat: canAddSelectionToChat() }), + nextAction.position, + ) .finally(() => { if (openSelectionMenuRequestIdRef.current === requestId) { openSelectionMenuRequestIdRef.current = null; @@ -686,7 +721,7 @@ export function TerminalViewport({ } switch (clicked) { case "add-to-chat": - addSelectionToChat(nextAction.selection); + if (canAddSelectionToChat()) addSelectionToChat(nextAction.selection); return; case "copy": await copySelection(nextAction.clipboardText, requestId); @@ -768,6 +803,14 @@ export function TerminalViewport({ threadRef, openPreview, fallbackToBrowser, + }).catch((error: unknown) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to open link", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); }); return; } diff --git a/apps/web/src/components/chat/ComposerControl.test.tsx b/apps/web/src/components/chat/ComposerControl.test.tsx deleted file mode 100644 index 397578179b9f..000000000000 --- a/apps/web/src/components/chat/ComposerControl.test.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import { renderToStaticMarkup } from "react-dom/server"; -import { BotIcon } from "lucide-react"; -import { describe, expect, it } from "vite-plus/test"; - -import { - ComposerControl, - ComposerControlChevron, - ComposerControlIcon, - ComposerControlSeparator, -} from "./ComposerControl"; - -describe("ComposerControl", () => { - it("preserves the expanded composer geometry by default", () => { - const markup = renderToStaticMarkup(Model); - - expect(markup).toContain("h-7"); - expect(markup).toContain("min-h-7"); - expect(markup).toContain("gap-1.5"); - expect(markup).toContain("px-2.5"); - }); - - it("uses the shared xs geometry for resting controls", () => { - const markup = renderToStaticMarkup( - - Model - - , - ); - - expect(markup).toContain("sm:h-6"); - expect(markup).toContain("font-normal"); - expect(markup).toContain("text-muted-foreground/70"); - expect(markup).toContain("[--control-icon-color:currentColor]"); - expect(markup).toContain("svg[data-composer-control-chevron]]:ms-0"); - expect(markup).toContain("svg[data-composer-control-chevron]]:-me-1"); - expect(markup).not.toContain("min-h-7"); - expect(markup).not.toContain("gap-1.5"); - expect(markup).not.toContain("px-2.5"); - }); - - it("keeps the expanded chevron treatment unless resting overrides it", () => { - const expanded = renderToStaticMarkup(); - const resting = renderToStaticMarkup(); - - expect(expanded).toContain("size-3.5"); - expect(expanded).toContain("text-icon-muted"); - expect(expanded).toContain('stroke-width="2.25"'); - expect(resting).toContain("size-3"); - expect(resting).toContain("text-current"); - expect(resting).toContain("opacity-50"); - expect(resting).not.toContain("size-3.5"); - expect(resting).not.toContain("text-icon-muted"); - }); - - it("owns resting icon geometry", () => { - const expanded = renderToStaticMarkup(); - const resting = renderToStaticMarkup(); - - expect(expanded).toContain("size-4"); - expect(resting).toContain("size-3"); - expect(resting).not.toContain("size-4"); - }); - - it("owns separator geometry for both composer sizes", () => { - const expanded = renderToStaticMarkup(); - const resting = renderToStaticMarkup( - , - ); - - expect(expanded).toContain("h-4"); - expect(expanded).not.toContain("h-3.5!"); - expect(resting).toContain("h-3.5!"); - expect(resting).not.toContain("h-4"); - expect(resting).toContain('data-resting-controls-separator="true"'); - }); -}); diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.test.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.test.tsx index 3ea8b5023e87..79b733ac074a 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.test.tsx +++ b/apps/web/src/components/chat/ComposerPrimaryActions.test.tsx @@ -40,7 +40,6 @@ vi.mock("../SidebarStageBackdrop", () => ({ import { ComposerPrimaryActions, - formatPendingPrimaryActionLabel, shouldDisableCollapsedComposerSubmitAction, } from "./ComposerPrimaryActions"; @@ -119,96 +118,6 @@ afterEach(() => { stageArtworkState.variant = null; }); -describe("formatPendingPrimaryActionLabel", () => { - it("returns 'Submitting...' while responding", () => { - expect( - formatPendingPrimaryActionLabel({ - compact: false, - isLastQuestion: false, - isResponding: true, - questionIndex: 0, - }), - ).toBe("Submitting..."); - }); - - it("returns 'Submitting...' while responding regardless of other flags", () => { - expect( - formatPendingPrimaryActionLabel({ - compact: true, - isLastQuestion: true, - isResponding: true, - questionIndex: 3, - }), - ).toBe("Submitting..."); - }); - - it("returns 'Submit' in compact mode on the last question", () => { - expect( - formatPendingPrimaryActionLabel({ - compact: true, - isLastQuestion: true, - isResponding: false, - questionIndex: 0, - }), - ).toBe("Submit"); - }); - - it("returns 'Next' in compact mode when not the last question", () => { - expect( - formatPendingPrimaryActionLabel({ - compact: true, - isLastQuestion: false, - isResponding: false, - questionIndex: 1, - }), - ).toBe("Next"); - }); - - it("returns 'Next question' when not the last question", () => { - expect( - formatPendingPrimaryActionLabel({ - compact: false, - isLastQuestion: false, - isResponding: false, - questionIndex: 0, - }), - ).toBe("Next question"); - }); - - it("returns singular 'Submit answer' on the last question when it is the only question", () => { - expect( - formatPendingPrimaryActionLabel({ - compact: false, - isLastQuestion: true, - isResponding: false, - questionIndex: 0, - }), - ).toBe("Submit answer"); - }); - - it("returns plural 'Submit answers' on the last question when there are multiple questions", () => { - expect( - formatPendingPrimaryActionLabel({ - compact: false, - isLastQuestion: true, - isResponding: false, - questionIndex: 1, - }), - ).toBe("Submit answers"); - }); - - it("returns plural 'Submit answers' for higher question indices", () => { - expect( - formatPendingPrimaryActionLabel({ - compact: false, - isLastQuestion: true, - isResponding: false, - questionIndex: 5, - }), - ).toBe("Submit answers"); - }); -}); - describe("ComposerPrimaryActions", () => { it("disables and labels the send button while feedback is uploading", () => { const markup = renderSendButton("Sending feedback"); diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.tsx index 85557214af72..fe63450caf01 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.tsx +++ b/apps/web/src/components/chat/ComposerPrimaryActions.tsx @@ -45,7 +45,7 @@ export const shouldDisableCollapsedComposerSubmitAction = (input: { }): boolean => input.isRunning || input.isSendBusy || input.isConnecting || !input.hasSendableContent; -export const formatPendingPrimaryActionLabel = (input: { +const formatPendingPrimaryActionLabel = (input: { compact: boolean; isLastQuestion: boolean; isResponding: boolean; diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 24622250c80f..01d083ca3519 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vite-plus/test"; -import { MessageId, TurnId } from "@t3tools/contracts"; +import { CheckpointRef, MessageId, TurnId } from "@t3tools/contracts"; import { clearSteerTimelineBoundaryStore, observeSteerTextBoundary, @@ -27,6 +27,7 @@ import { type TimelineEntry, type WorkLogEntry, } from "../../session-logic"; +import { buildRevertTurnCountByUserMessageId } from "../ChatView.logic"; import { isImageAttachment, type ChatMessage, type TurnDiffSummary } from "../../types"; describe("streaming row projection", () => { @@ -249,6 +250,61 @@ describe("streaming row projection", () => { }, ); + it("reuses rows when the revert map is rebuilt from the streamed entries", () => { + const initial = fixture("Partial"); + const inferredCheckpointTurnCountByTurnId = { [initial.historyTurnId]: 1 }; + const turnDiffSummaryByAssistantMessageId = new Map([ + [ + MessageId.make("history-assistant"), + { + turnId: initial.historyTurnId, + checkpointTurnCount: 1, + checkpointRef: CheckpointRef.make("refs/t3/checkpoints/history-turn"), + status: "ready", + files: [], + assistantMessageId: MessageId.make("history-assistant"), + completedAt: initial.time(4), + }, + ], + ]); + let revertMap: Map | null = null; + // Mirrors ChatView: the map is derived from each delta's entries. + const build = (timelineEntries: typeof initial.timeline.entries) => { + revertMap = buildRevertTurnCountByUserMessageId( + { + supportsConversationRollback: true, + timelineEntries, + turnDiffSummaryByAssistantMessageId, + inferredCheckpointTurnCountByTurnId, + }, + revertMap, + ); + return { + ...initial.input, + timelineEntries, + turnDiffSummaryByAssistantMessageId, + revertTurnCountByUserMessageId: revertMap, + }; + }; + const previous = deriveMessagesTimelineRowsWithState(build(initial.timeline.entries)); + expect(previous.rows.some((row) => row.kind === "message" && row.revertTurnCount === 0)).toBe( + true, + ); + const last = initial.messages.at(-1)!; + const messages = [...initial.messages.slice(0, -1), { ...last, text: "Partial token" }]; + const timeline = deriveTimelineEntriesWithState(messages, [], initial.work, initial.timeline); + const next = deriveMessagesTimelineRowsWithState(build(timeline.entries), previous); + + expect(next.rows).toEqual(deriveMessagesTimelineRows(build(timeline.entries))); + for (const [index, row] of previous.rows.entries()) { + if ((row.kind === "message" || row.kind === "assistant-meta") && row.message === last) { + expect(next.rows[index]).toMatchObject({ message: { text: "Partial token" } }); + } else { + expect(next.rows[index]).toBe(row); + } + } + }); + it.each(["completion", "turn", "role", "ordering"] as const)( "rebuilds row structure for a %s change with otherwise unchanged controls", (change) => { diff --git a/apps/web/src/components/cloud/CloudEnvironmentConnectList.test.tsx b/apps/web/src/components/cloud/CloudEnvironmentConnectList.test.tsx new file mode 100644 index 000000000000..e82135dc44bd --- /dev/null +++ b/apps/web/src/components/cloud/CloudEnvironmentConnectList.test.tsx @@ -0,0 +1,214 @@ +import type { Discovery } from "@t3tools/client-runtime/relay"; +import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; +import { EnvironmentId } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { act, type ButtonHTMLAttributes } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +type DiscoveredEnvironments = Discovery.RelayEnvironmentDiscoveryState["environments"]; + +const discovery = vi.hoisted(() => ({ + state: null as Discovery.RelayEnvironmentDiscoveryState | null, + listeners: new Set<() => void>(), + refreshCommand: Symbol("refresh"), + registerCommand: Symbol("register"), + refresh: vi.fn<() => Promise>>(), + register: vi.fn(), + listEnvironments: vi.fn<() => Promise>(), +})); + +vi.mock("~/state/relay", () => ({ + relayEnvironmentDiscovery: { refresh: discovery.refreshCommand }, +})); +vi.mock("~/connection/catalog", () => ({ + environmentCatalog: { register: discovery.registerCommand }, +})); +vi.mock("~/state/use-atom-command", () => ({ + useAtomCommand: (command: unknown) => + command === discovery.refreshCommand ? discovery.refresh : discovery.register, +})); +vi.mock("~/state/environments", async () => { + const { useSyncExternalStore } = await import("react"); + const subscribe = (listener: () => void) => { + discovery.listeners.add(listener); + return () => discovery.listeners.delete(listener); + }; + const read = () => { + if (discovery.state === null) throw new Error("Discovery fixture is not initialized"); + return discovery.state; + }; + return { useRelayEnvironmentDiscovery: () => useSyncExternalStore(subscribe, read, read) }; +}); +vi.mock("../ConnectionStatusDot", () => ({ ConnectionStatusDot: () => null })); +vi.mock("../ui/button", () => ({ + Button: ({ children, ...props }: ButtonHTMLAttributes) => ( + + ), +})); +vi.mock("../ui/toast", () => ({ toastManager: { add: vi.fn() } })); + +import { CloudEnvironmentConnectRows } from "./CloudEnvironmentConnectList"; + +const newMachineId = EnvironmentId.make("new-computer"); +const linkedMachines: DiscoveredEnvironments = new Map([ + [ + newMachineId, + { + environment: { + environmentId: newMachineId, + label: "Work laptop", + endpoint: { + httpBaseUrl: "https://relay.example.test", + wsBaseUrl: "wss://relay.example.test/ws", + providerKind: "manual", + }, + linkedAt: "2026-09-05T12:00:00.000Z", + }, + availability: "online", + status: Option.none(), + error: Option.none(), + }, + ], +]); + +let renderer: ReactTestRenderer | null; +let page: EventTarget & { visibilityState: DocumentVisibilityState }; +let browserWindow: EventTarget; + +function publish(state: Discovery.RelayEnvironmentDiscoveryState) { + discovery.state = state; + for (const listener of discovery.listeners) listener(); +} + +async function mount(refreshWhileEmpty = true) { + await act(async () => { + renderer = create( + Waiting for your computer to connect.

} + />, + ); + }); +} + +async function advance(milliseconds: number) { + await act(async () => { + await vi.advanceTimersByTimeAsync(milliseconds); + }); +} + +beforeEach(() => { + vi.useFakeTimers(); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + page = Object.assign(new EventTarget(), { visibilityState: "visible" as const }); + browserWindow = new EventTarget(); + vi.stubGlobal("document", page); + vi.stubGlobal("window", browserWindow); + renderer = null; + discovery.listeners.clear(); + discovery.state = { + environments: new Map(), + refreshing: false, + offline: false, + error: Option.none(), + }; + discovery.listEnvironments.mockReset().mockResolvedValue(new Map()); + discovery.refresh.mockReset().mockImplementation(async () => { + publish({ environments: new Map(), refreshing: true, offline: false, error: Option.none() }); + const environments = await discovery.listEnvironments(); + publish({ environments, refreshing: false, offline: false, error: Option.none() }); + return AsyncResult.success(undefined); + }); +}); + +afterEach(async () => { + await act(async () => renderer?.unmount()); + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +describe("cloud onboarding discovery", () => { + it("shows a newly linked computer without remounting and stops polling once found", async () => { + discovery.listEnvironments + .mockResolvedValueOnce(new Map()) + .mockResolvedValueOnce(linkedMachines); + await mount(); + expect(renderer!.root.findByType("p").children).toEqual([ + "Waiting for your computer to connect.", + ]); + + await advance(5_000); + + expect(renderer!.root.findAllByType("p").map((node) => node.children)).toContainEqual([ + "Work laptop", + ]); + expect(renderer!.root.findByType("button").children).toEqual(["Connect"]); + await advance(30_000); + expect(discovery.listEnvironments).toHaveBeenCalledTimes(2); + }); + + it("waits while hidden and refreshes immediately when visible again", async () => { + page.visibilityState = "hidden"; + await mount(); + await advance(30_000); + expect(discovery.listEnvironments).not.toHaveBeenCalled(); + + await act(async () => { + page.visibilityState = "visible"; + page.dispatchEvent(new Event("visibilitychange")); + }); + expect(discovery.listEnvironments).toHaveBeenCalledTimes(1); + + page.visibilityState = "hidden"; + page.dispatchEvent(new Event("visibilitychange")); + browserWindow.dispatchEvent(new Event("focus")); + await advance(30_000); + expect(discovery.listEnvironments).toHaveBeenCalledTimes(1); + }); + + it("does not overlap a slow refresh or restart polling after unmount", async () => { + let resolveRefresh!: (environments: DiscoveredEnvironments) => void; + const pending = new Promise((resolve) => { + resolveRefresh = resolve; + }); + discovery.listEnvironments.mockResolvedValueOnce(new Map()).mockReturnValueOnce(pending); + await mount(); + await advance(5_000); + expect(renderer!.root.findByType("p").children).toEqual([ + "Waiting for your computer to connect.", + ]); + + browserWindow.dispatchEvent(new Event("focus")); + page.dispatchEvent(new Event("visibilitychange")); + await advance(30_000); + expect(discovery.listEnvironments).toHaveBeenCalledTimes(2); + + await act(async () => renderer!.unmount()); + renderer = null; + await act(async () => resolveRefresh(new Map())); + await advance(30_000); + expect(discovery.listEnvironments).toHaveBeenCalledTimes(2); + }); + + it("pauses while offline and resumes when discovery is online", async () => { + await mount(); + await act(async () => publish({ ...discovery.state!, offline: true })); + await advance(30_000); + expect(discovery.listEnvironments).toHaveBeenCalledTimes(1); + + await act(async () => publish({ ...discovery.state!, offline: false })); + await advance(5_000); + expect(discovery.listEnvironments).toHaveBeenCalledTimes(2); + }); + + it("does not add polling to other cloud lists", async () => { + await mount(false); + await advance(30_000); + expect(discovery.listEnvironments).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx b/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx index 460a253812a0..7f29c69c3208 100644 --- a/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx +++ b/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx @@ -11,7 +11,7 @@ import { import type { EnvironmentId } from "@t3tools/contracts"; import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay"; import * as Option from "effect/Option"; -import { type ReactNode, useCallback, useEffect, useState } from "react"; +import { type ReactNode, useCallback, useEffect, useEffectEvent, useState } from "react"; import { environmentCatalog } from "~/connection/catalog"; import { cn } from "~/lib/utils"; @@ -25,6 +25,8 @@ import { Skeleton } from "../ui/skeleton"; import { toastManager } from "../ui/toast"; import { presentSavedCloudEnvironmentConnection } from "./cloudEnvironmentConnectionPresentation"; +const EMPTY_DISCOVERY_REFRESH_INTERVAL_MS = 5_000; + export interface SavedCloudEnvironmentConnection { readonly environmentId: EnvironmentId; readonly connection: EnvironmentConnectionPresentation; @@ -55,11 +57,13 @@ export function CloudEnvironmentConnectRows({ primaryEnvironmentId, savedEnvironments, showSavedEnvironments = false, + refreshWhileEmpty = false, empty = null, }: { readonly primaryEnvironmentId: EnvironmentId | null; readonly savedEnvironments: ReadonlyArray; readonly showSavedEnvironments?: boolean; + readonly refreshWhileEmpty?: boolean; readonly empty?: ReactNode; }) { const environmentsState = useRelayEnvironmentDiscovery(); @@ -69,6 +73,10 @@ export function CloudEnvironmentConnectRows({ const refreshRelayEnvironments = useAtomCommand(relayEnvironmentDiscovery.refresh, { reportFailure: false, }); + const refreshDiscoveryWhenIdle = useEffectEvent(async () => { + if (environmentsState.refreshing || environmentsState.offline) return; + await refreshRelayEnvironments(); + }); const connectRelayEnvironment = useCallback( (environment: RelayClientEnvironmentRecord) => registerEnvironment( @@ -89,8 +97,10 @@ export function CloudEnvironmentConnectRows({ ); useEffect(() => { - void refreshRelayEnvironments(); - }, [refreshRelayEnvironments]); + if (!refreshWhileEmpty || document.visibilityState === "visible") { + void refreshRelayEnvironments(); + } + }, [refreshRelayEnvironments, refreshWhileEmpty]); const connectEnvironment = async (environment: RelayClientEnvironmentRecord) => { setConnectingEnvironmentId(environment.environmentId); @@ -132,10 +142,54 @@ export function CloudEnvironmentConnectRows({ environment.environmentId !== primaryEnvironmentId && (showSavedEnvironments || !savedById.has(environment.environmentId)), ); + // Discovery clears its list on refresh, so poll only until a machine appears. + const shouldRefreshWhileEmpty = + refreshWhileEmpty && visibleEnvironments.length === 0 && !environmentsState.offline; + + useEffect(() => { + if (!shouldRefreshWhileEmpty) return; + let timer: ReturnType | undefined; + let disposed = false; + let pending = false; + const visible = () => document.visibilityState === "visible"; + const schedule = () => { + clearTimeout(timer); + if (!disposed && visible()) { + timer = setTimeout(() => void refresh(), EMPTY_DISCOVERY_REFRESH_INTERVAL_MS); + } + }; + const refresh = async () => { + if (disposed || pending || !visible()) return; + clearTimeout(timer); + pending = true; + try { + await refreshDiscoveryWhenIdle(); + } finally { + pending = false; + schedule(); + } + }; + const onFocus = () => void refresh(); + const onVisibilityChange = () => { + clearTimeout(timer); + if (visible()) void refresh(); + }; + + schedule(); + window.addEventListener("focus", onFocus); + document.addEventListener("visibilitychange", onVisibilityChange); + return () => { + disposed = true; + clearTimeout(timer); + window.removeEventListener("focus", onFocus); + document.removeEventListener("visibilitychange", onVisibilityChange); + }; + }, [shouldRefreshWhileEmpty]); const standalone = showSavedEnvironments || savedEnvironments.length === 0; if ( + !refreshWhileEmpty && standalone && visibleEnvironments.length === 0 && environmentsState.refreshing && diff --git a/apps/web/src/components/desktopUpdate.logic.test.ts b/apps/web/src/components/desktopUpdate.logic.test.ts index dd05693d28b2..fcc97825b681 100644 --- a/apps/web/src/components/desktopUpdate.logic.test.ts +++ b/apps/web/src/components/desktopUpdate.logic.test.ts @@ -12,7 +12,6 @@ import { isDesktopUpdateButtonDisabled, resolveDesktopUpdateButtonAction, shouldShowArm64IntelBuildWarning, - shouldShowDesktopUpdateButton, shouldToastDesktopUpdateActionResult, } from "./desktopUpdate.logic"; @@ -42,7 +41,6 @@ describe("desktop update button state", () => { status: "available", availableVersion: "1.1.0", }; - expect(shouldShowDesktopUpdateButton(state)).toBe(true); expect(resolveDesktopUpdateButtonAction(state)).toBe("download"); }); @@ -55,7 +53,6 @@ describe("desktop update button state", () => { errorContext: "download", canRetry: true, }; - expect(shouldShowDesktopUpdateButton(state)).toBe(true); expect(resolveDesktopUpdateButtonAction(state)).toBe("download"); expect(getDesktopUpdateButtonTooltip(state)).toContain("Click to retry"); }); @@ -70,7 +67,6 @@ describe("desktop update button state", () => { errorContext: "install", canRetry: true, }; - expect(shouldShowDesktopUpdateButton(state)).toBe(true); expect(resolveDesktopUpdateButtonAction(state)).toBe("install"); expect(getDesktopUpdateButtonTooltip(state)).toContain("Click to retry"); }); @@ -85,7 +81,6 @@ describe("desktop update button state", () => { errorContext: null, canRetry: true, }; - expect(shouldShowDesktopUpdateButton(state)).toBe(true); expect(resolveDesktopUpdateButtonAction(state)).toBe("install"); expect(getDesktopUpdateButtonTooltip(state)).toContain("Click to restart and install"); }); @@ -111,7 +106,7 @@ describe("desktop update button state", () => { expect(resolveDesktopUpdateButtonAction(state)).toBe("none"); }); - it("hides the button for non-actionable check errors", () => { + it("has no action for non-actionable check errors", () => { const state: DesktopUpdateState = { ...baseState, status: "error", @@ -119,7 +114,6 @@ describe("desktop update button state", () => { errorContext: "check", canRetry: true, }; - expect(shouldShowDesktopUpdateButton(state)).toBe(false); expect(resolveDesktopUpdateButtonAction(state)).toBe("none"); }); @@ -130,7 +124,6 @@ describe("desktop update button state", () => { availableVersion: "1.1.0", downloadPercent: 42.5, }; - expect(shouldShowDesktopUpdateButton(state)).toBe(true); expect(isDesktopUpdateButtonDisabled(state)).toBe(true); expect(getDesktopUpdateButtonTooltip(state)).toContain("42%"); }); diff --git a/apps/web/src/components/desktopUpdate.logic.ts b/apps/web/src/components/desktopUpdate.logic.ts index 656ffbea8198..4a169cb3ef40 100644 --- a/apps/web/src/components/desktopUpdate.logic.ts +++ b/apps/web/src/components/desktopUpdate.logic.ts @@ -47,16 +47,6 @@ export function resolveDesktopUpdateButtonAction( return "none"; } -export function shouldShowDesktopUpdateButton(state: DesktopUpdateState | null): boolean { - if (!state || !state.enabled) { - return false; - } - if (state.status === "downloading") { - return true; - } - return resolveDesktopUpdateButtonAction(state) !== "none"; -} - export function shouldShowArm64IntelBuildWarning(state: DesktopUpdateState | null): boolean { return state?.hostArch === "arm64" && state.appArch === "x64"; } diff --git a/apps/web/src/components/diffs/AnnotatableCodeView.test.tsx b/apps/web/src/components/diffs/AnnotatableCodeView.test.tsx deleted file mode 100644 index 81cd5625e62b..000000000000 --- a/apps/web/src/components/diffs/AnnotatableCodeView.test.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import type { ReactNode } from "react"; -import { renderToStaticMarkup } from "react-dom/server"; -import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; - -const testState = vi.hoisted(() => ({ - codeViewOptions: null as Record | null, -})); - -vi.mock("@pierre/diffs/react", () => ({ - CodeView: (props: { options: Record }) => { - testState.codeViewOptions = props.options; - return null; - }, -})); - -vi.mock("../DiffWorkerPoolProvider", () => ({ - DiffWorkerPoolProvider: ({ children }: { children?: ReactNode }) => children, -})); - -vi.mock("~/composerDraftStore", () => ({ - useComposerDraftStore: (selector: (store: Record) => unknown) => - selector({ - addReviewComment: vi.fn(), - removeReviewComment: vi.fn(), - getComposerDraft: () => undefined, - }), -})); - -vi.mock("./DiffCommentAnnotation", () => ({ - DiffCommentAnnotation: () => null, -})); - -vi.mock("../files/fileCommentAnnotations", () => ({ - nextFileCommentId: () => "comment-test", -})); - -import { AnnotatableCodeView } from "./AnnotatableCodeView"; - -describe("AnnotatableCodeView", () => { - beforeEach(() => { - testState.codeViewOptions = null; - }); - - it("opens comments from Pierre's gutter action without ending line selection", () => { - renderToStaticMarkup( - null} - renderHeaderFilenameSuffix={() => null} - />, - ); - - expect(testState.codeViewOptions).toMatchObject({ - enableGutterUtility: true, - enableLineSelection: true, - onGutterUtilityClick: expect.any(Function), - }); - expect(testState.codeViewOptions).not.toHaveProperty("onLineSelectionEnd"); - }); -}); diff --git a/apps/web/src/components/diffs/StyledDiffCodeView.test.tsx b/apps/web/src/components/diffs/StyledDiffCodeView.test.tsx index 6a746b40f9af..ee249888a415 100644 --- a/apps/web/src/components/diffs/StyledDiffCodeView.test.tsx +++ b/apps/web/src/components/diffs/StyledDiffCodeView.test.tsx @@ -18,13 +18,10 @@ import { useState, type Ref, } from "react"; -import { renderToStaticMarkup } from "react-dom/server"; import { create, type ReactTestRenderer } from "react-test-renderer"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; const testState = vi.hoisted(() => ({ - codeViewClassName: null as string | null, - codeViewOptions: null as Record | null, workers: [] as NodeWorkerThreads.Worker[], terminations: [] as Promise[], requests: [] as WorkerRequest[], @@ -102,8 +99,6 @@ vi.mock("@pierre/diffs/worker/worker.js?worker", async () => { vi.mock("@pierre/diffs/react", async (importOriginal) => ({ ...(await importOriginal()), CodeView: (props: CodeViewProps) => { - testState.codeViewClassName = props.className ?? null; - testState.codeViewOptions = props.options ? { ...props.options } : null; return props.items?.map((item) => item.type === "file" ? : null, ); @@ -158,49 +153,6 @@ function renderViews(count: number) { ); } -describe("StyledDiffCodeView", () => { - beforeEach(() => { - testState.codeViewClassName = null; - testState.codeViewOptions = null; - }); - - it("always pairs the shared diff styling with its virtualized geometry", () => { - const loadDiffFiles = vi.fn(async () => ({ - oldFile: { name: "before.ts", contents: "before\n" }, - newFile: { name: "after.ts", contents: "after\n" }, - })); - renderToStaticMarkup( - , - ); - - expect(testState.codeViewClassName).toBe( - "diff-render-surface [--code-background:var(--background)] outline-none min-h-0", - ); - expect(testState.codeViewOptions).toMatchObject({ - theme: "pierre-dark", - stickyHeaders: true, - loadDiffFiles, - itemMetrics: { - diffHeaderHeight: 32, - hunkSeparatorHeight: 24, - paddingTop: 0, - paddingBottom: 8, - }, - layout: { paddingTop: 0, paddingBottom: 0, gap: 0 }, - }); - expect(testState.codeViewOptions?.unsafeCSS).toEqual( - expect.stringContaining("[data-unmodified-lines]::before"), - ); - expect(testState.codeViewOptions?.unsafeCSS).toEqual( - expect.stringContaining(")[data-expand-index]\n [data-unmodified-lines]"), - ); - }); -}); - describe("code-view worker lifecycle", () => { let renderer: ReactTestRenderer | undefined; diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index 3f0c92742ca7..33d4d9a4b6cf 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -1266,7 +1266,11 @@ export default function FilePreviewPanel({ ) : relativePath && file.data ? ( isMarkdown && renderMarkdown ? ( + // Markdown reconciles in place across text updates, so a file + // switch needs a new key or the previous file's disclosure and + // wrap state carries into the next document. = {}; + nextElementSibling: MeasuredElement | null = null; + width = 283; + + constructor(readonly height = 0) {} + + getBoundingClientRect() { + MeasuredElement.geometryReads += 1; + return { top: 0, height: this.height, width: this.width }; + } +} + +class MeasuredCodeElement extends MeasuredElement { + readonly tagName = "CODE"; + + get firstElementChild() { + return this.children[0] ?? null; + } +} + +const observers: RecordedResizeObserver[] = []; +const animationFrames = new Map(); +let nextFrameId = 0; + +class RecordedResizeObserver { + readonly targets = new Set(); + + constructor(readonly callback: ResizeObserverCallback) { + observers.push(this); + } + + observe(target: Element) { + this.targets.add(target); + } + + unobserve(target: Element) { + this.targets.delete(target); + } + + disconnect() { + this.targets.clear(); + } + + deliver(target: HTMLElement) { + const rect = target.getBoundingClientRect(); + const size = { inlineSize: rect.width, blockSize: rect.height }; + this.callback( + [ + { + target, + contentRect: rect, + contentBoxSize: [size], + borderBoxSize: [size], + devicePixelContentBoxSize: [size], + }, + ], + this as unknown as ResizeObserver, + ); + } +} + +function drainRenderFrames() { + const errors = vi.spyOn(console, "error"); + try { + for (let frame = 0; animationFrames.size > 0; frame += 1) { + if (frame === 10) throw new Error("The production render queue did not settle"); + const callbacks = [...animationFrames.values()]; + animationFrames.clear(); + for (const callback of callbacks) callback(frame); + } + expect(errors).not.toHaveBeenCalled(); + } finally { + errors.mockRestore(); + } +} + +function measuredElement(element: MeasuredElement): HTMLElement { + return element as unknown as HTMLElement; +} + +class LayoutVirtualizer extends Virtualizer { + override getOffsetInScrollContainer(_element: HTMLElement) { + return 0; + } +} + +class MeasuredFile extends VirtualizedFile { + override top = 0; + + override attachEditor(editor: Parameters[0]) { + this.editor = editor; + return () => { + this.editor = undefined; + }; + } + + async initialize(file: FileContents) { + this.prepareCodeViewItem(file, 0); + await this.fileRenderer.initializeHighlighter(); + expect( + this.fileRenderer.renderFile(file, { + startingLine: 5950, + totalLines: 51, + bufferBefore: 0, + bufferAfter: 0, + }), + ).toBeDefined(); + this.fileContainer = measuredElement(new MeasuredElement()); + } + + measure( + rows: ReadonlyArray, + contentWidth = 226.25, + ) { + const content = new MeasuredElement(); + content.width = contentWidth; + content.children = rows.map(([lineIndex, height]) => { + const row = new MeasuredElement(height); + row.dataset.lineIndex = String(lineIndex); + return row; + }); + const code = new MeasuredCodeElement(); + code.width = contentWidth + 56.75; + code.children = [new MeasuredElement(), content]; + this.code = measuredElement(code); + this.reconcileHeights(); + } + + resizeContent(contentWidth: number, codeWidth = contentWidth + 56.75) { + const code = this.code; + const content = code?.children[1]; + if (!(code instanceof MeasuredElement) || !(content instanceof MeasuredElement)) { + throw new Error("Expected measured code and content"); + } + code.width = codeWidth; + content.width = contentWidth; + } + + observeLayout() { + const pre = new MeasuredElement(); + if (!(this.code instanceof MeasuredElement)) throw new Error("Expected measured code"); + pre.children = [this.code]; + this.resizeManager.setup(measuredElement(pre) as HTMLPreElement, { + disableAnnotations: true, + columnVariables: "measure", + }); + const code = this.code; + const observer = observers.find((candidate) => candidate.targets.has(code)); + if (observer === undefined) throw new Error("The real resize manager did not observe code"); + return () => observer.deliver(code); + } + + dispose() { + this.fileContainer = undefined; + this.code = undefined; + this.cleanUp(); + } +} + +const instances: MeasuredFile[] = []; +const editors: Editor[] = []; + +beforeAll(async () => { + await getSharedHighlighter({ + themes: ["pierre-dark"], + langs: ["text"], + preferredHighlighter: "shiki-wasm", + }); +}); + +beforeEach(() => { + observers.length = 0; + animationFrames.clear(); + MeasuredElement.geometryReads = 0; + vi.stubGlobal("HTMLElement", MeasuredElement); + vi.stubGlobal("Document", MeasuredElement); + vi.stubGlobal("ResizeObserver", RecordedResizeObserver); + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { + const id = ++nextFrameId; + animationFrames.set(id, callback); + return id; + }); + vi.stubGlobal("cancelAnimationFrame", (id: number) => animationFrames.delete(id)); +}); + +afterEach(() => { + for (const editor of editors.splice(0)) editor.cleanUp(); + for (const instance of instances.splice(0)) instance.dispose(); + clearRenderQueue(); + animationFrames.clear(); + vi.unstubAllGlobals(); +}); + +async function makeFixture( + overflow: "wrap" | "scroll" = "wrap", + lineCount = 6001, + contentWidth = 226.25, +) { + const contents = Array.from({ length: lineCount }, (_, index) => `line ${index}`).join("\n"); + const file: FileContents = { + name: "wrapped.txt", + contents, + cacheKey: `wrapped:${overflow}`, + lang: "text", + }; + const document = new TextDocument(file.name, contents, "text"); + const instance = new MeasuredFile( + { + overflow, + disableFileHeader: true, + theme: "pierre-dark", + preferredHighlighter: "shiki-wasm", + useTokenTransformer: true, + controlledSelection: true, + }, + new LayoutVirtualizer(), + ); + instances.push(instance); + await instance.initialize(file); + instance.measure( + [ + [0, 80], + [120, 60], + [4999, 100], + [5000, 80], + [5999, 100], + [6000, 60], + ], + contentWidth, + ); + const apply = (change: { startLine: number } | undefined, passStartLine = true) => { + if (change === undefined) throw new Error("Expected a document change"); + file.contents = document.getText(); + instance.applyDocumentChange( + document, + undefined, + false, + passStartLine ? change.startLine : undefined, + ); + }; + const append = () => { + const position = document.positionAt(document.getText().length); + apply(document.applyEdits([{ range: { start: position, end: position }, newText: "\n" }])); + }; + return { instance, document, file, apply, append }; +} + +describe("wrapped editor document changes", () => { + it("preserves the position above an EOF insertion across layout checkpoints", async () => { + const { instance, document, append } = await makeFixture(); + const previousLastLine = document.lineCount; + const before = instance.getLinePosition(previousLastLine); + expect(before).toEqual({ top: 120328, height: 60 }); + const viewport = { top: before!.top - 100, bottom: before!.top + 80 }; + expect(instance.getAdvancedStickySpecs(viewport)).toEqual({ topOffset: 118240, height: 2156 }); + + append(); + + expect(document.lineCount).toBe(previousLastLine + 1); + expect(instance.getLinePosition(previousLastLine)).toEqual({ top: before!.top, height: 20 }); + expect(instance.getLinePosition(document.lineCount)).toEqual({ top: 120348, height: 20 }); + expect(instance.getVirtualizedHeight()).toBe(120376); + expect(instance.getAdvancedStickySpecs(viewport)).toEqual({ topOffset: 118240, height: 2136 }); + }); + + it("invalidates changed and shifted rows after an insertion in the middle", async () => { + const { instance, document, apply } = await makeFixture(); + const before = instance.getLinePosition(5001); + const position = { line: 5000, character: 2 }; + apply(document.applyEdits([{ range: { start: position, end: position }, newText: "\n" }])); + + expect(instance.getLinePosition(5001)).toEqual({ top: before!.top, height: 20 }); + expect(instance.getLineHeight(4999)).toBe(100); + expect(instance.getLineHeight(5000)).toBe(20); + expect(instance.getLineHeight(5999)).toBe(20); + expect(instance.getLinePosition(document.lineCount)).toEqual({ top: 120208, height: 20 }); + }); + + it("keeps preceding measurements when a deletion crosses a checkpoint", async () => { + const { instance, document, apply } = await makeFixture(); + const before = instance.getLinePosition(5000); + apply( + document.applyEdits([ + { + range: { start: { line: 4999, character: 2 }, end: { line: 5001, character: 2 } }, + newText: "", + }, + ]), + ); + + expect(document.lineCount).toBe(5999); + expect(instance.getLinePosition(5000)).toEqual({ top: before!.top, height: 20 }); + expect(instance.getLineHeight(120)).toBe(60); + expect(instance.getLineHeight(4999)).toBe(20); + expect(instance.getLineHeight(6000)).toBe(20); + expect(instance.getLinePosition(document.lineCount)).toEqual({ top: 120068, height: 20 }); + }); + + it("uses the earliest changed line for edits at multiple selections", async () => { + const { instance, document, apply } = await makeFixture(); + const before = instance.getLinePosition(121); + apply( + document.applyEdits( + [120, 5000].map((line) => ({ + range: { start: { line, character: 2 }, end: { line, character: 2 } }, + newText: "\n", + })), + ), + ); + + expect(document.lineCount).toBe(6003); + expect(instance.getLinePosition(121)).toEqual({ top: before!.top, height: 20 }); + expect(instance.getLineHeight(0)).toBe(80); + expect(instance.getLineHeight(120)).toBe(20); + expect(instance.getLineHeight(4999)).toBe(20); + }); + + it("retains the unchanged prefix through repeated Enter, undo and redo", async () => { + const { instance, document, apply, append } = await makeFixture(); + const before = instance.getLinePosition(6001); + for (let count = 0; count < 60; count += 1) append(); + expect(document.lineCount).toBe(6061); + expect(instance.getLinePosition(6001)?.top).toBe(before!.top); + apply(document.undo()?.[0]); + expect(instance.getLinePosition(6001)?.top).toBe(before!.top); + apply(document.redo()?.[0]); + expect(document.lineCount).toBe(6061); + expect(instance.getLinePosition(6001)?.top).toBe(before!.top); + }); + + it("keeps unwrapped positions unchanged", async () => { + const { instance, append } = await makeFixture("scroll"); + const before = instance.getLinePosition(6001); + append(); + expect(instance.getLinePosition(6001)).toEqual(before); + expect(instance.getLinePosition(6002)).toEqual({ top: 120028, height: 20 }); + }); + + it("fully invalidates measurements when the first changed line is unknown", async () => { + const { instance, document, apply } = await makeFixture(); + const position = document.positionAt(document.getText().length); + apply( + document.applyEdits([{ range: { start: position, end: position }, newText: "\n" }]), + false, + ); + expect(instance.getLinePosition(6001)).toEqual({ top: 120008, height: 20 }); + expect(instance.getLineHeight(0)).toBe(20); + }); + + it("still discards all measured rows after a metric change", async () => { + const { instance, file, append } = await makeFixture(); + append(); + instance.setMetrics({ hunkLineCount: 50, lineHeight: 24, diffHeaderHeight: 44, spacing: 8 }); + instance.prepareCodeViewItem(file, 0); + expect(instance.getLinePosition(6001)).toEqual({ top: 144008, height: 24 }); + }); + + it("still discards all measured rows when annotations change", async () => { + const { instance, file, append } = await makeFixture(); + append(); + instance.setLineAnnotations([{ lineNumber: 10, metadata: undefined }]); + instance.prepareCodeViewItem(file, 0); + expect(instance.getLinePosition(6001)).toEqual({ top: 120008, height: 20 }); + }); +}); + +describe("wrapped measurement widths", () => { + it.each([ + [226.25, 482.25], + [482.25, 226.25], + ])( + "drops offscreen measurements when content changes from %spx to %spx", + async (before, after) => { + const { instance } = await makeFixture("wrap", 6001, before); + instance.measure([[6000, 60]], after); + expect(instance.getLineHeight(0)).toBe(20); + expect(instance.getLineHeight(120)).toBe(20); + expect(instance.getLineHeight(6000)).toBe(60); + expect(instance.getVirtualizedHeight()).toBe(120076); + }, + ); + + it.each([ + [226.25, 482.25], + [482.25, 226.25], + ])( + "does not retain old-width prefix heights after a %spx to %spx resize and edit", + async (before, after) => { + const { instance, append } = await makeFixture("wrap", 6001, before); + instance.measure([[6000, 60]], after); + append(); + expect(instance.getLineHeight(0)).toBe(20); + expect(instance.getLineHeight(120)).toBe(20); + expect(instance.getLinePosition(6001)).toEqual({ top: 120008, height: 20 }); + }, + ); + + it("repairs an edit before resize delivery when the real resize and render queues drain", async () => { + const { instance, append } = await makeFixture(); + instance.measure([[6000, 60]]); + const deliverResize = instance.observeLayout(); + instance.resizeContent(482.25); + const readsBeforeEdit = MeasuredElement.geometryReads; + append(); + expect(MeasuredElement.geometryReads).toBe(readsBeforeEdit); + // No synchronous geometry read: the resize entry owns invalidation. + expect(instance.getLineHeight(0)).toBe(80); + deliverResize(); + drainRenderFrames(); + expect(instance.getLineHeight(0)).toBe(20); + expect(instance.getLinePosition(6001)).toEqual({ top: 120008, height: 60 }); + }); + + it("keeps measured prefixes through same-width reconciliation and editing", async () => { + const { instance, append } = await makeFixture(); + instance.measure([[6000, 60]]); + append(); + expect(instance.getLineHeight(0)).toBe(80); + expect(instance.getLineHeight(120)).toBe(60); + expect(instance.getLinePosition(6001)).toEqual({ top: 120328, height: 20 }); + }); + + it("preserves measurements on first and repeated same-width resize deliveries", async () => { + const { instance } = await makeFixture(); + const before = instance.getVirtualizedHeight(); + const deliverResize = instance.observeLayout(); + deliverResize(); + deliverResize(); + drainRenderFrames(); + expect(instance.getLineHeight(0)).toBe(80); + expect(instance.getVirtualizedHeight()).toBe(before); + }); + + it.each([ + [226.25, 482.25], + [482.25, 226.25], + ])("handles a %spx to %spx resize before first observer delivery", async (before, after) => { + const { instance } = await makeFixture("wrap", 6001, before); + instance.measure([[6000, 20]], before); + const deliverResize = instance.observeLayout(); + instance.resizeContent(after); + deliverResize(); + drainRenderFrames(); + expect(instance.getLineHeight(0)).toBe(20); + expect(instance.getVirtualizedHeight()).toBe(120036); + }); + + it("keeps width validity when a new editor attaches to the same file", async () => { + const { instance } = await makeFixture(); + instance.measure([[6000, 20]]); + const deliverResize = instance.observeLayout(); + vi.stubGlobal("SVGSVGElement", EditorElement); + vi.stubGlobal( + "document", + Object.assign(new EditorElement(), { createElement: () => new EditorElement() }), + ); + const first = new Editor(); + editors.push(first); + first.edit(instance); + first.cleanUp(); + const second = new Editor(); + editors.push(second); + second.edit(instance); + instance.resizeContent(482.25); + deliverResize(); + drainRenderFrames(); + expect(instance.getLineHeight(0)).toBe(20); + expect(instance.getVirtualizedHeight()).toBe(120036); + }); + + it("ignores stale resize deliveries after cleanup", async () => { + const { instance } = await makeFixture(); + const deliverResize = instance.observeLayout(); + instance.dispose(); + clearRenderQueue(); + animationFrames.clear(); + deliverResize(); + expect(animationFrames.size).toBe(0); + }); + + it("does not discard a stable code width for gutter subpixel rounding", async () => { + const { instance } = await makeFixture("wrap", 6001, 482.25); + const deliverResize = instance.observeLayout(); + instance.resizeContent(482.234375, 539); + deliverResize(); + drainRenderFrames(); + expect(instance.getLineHeight(0)).toBe(80); + }); + + it("waits for a visible width instead of caching measurements while hidden", async () => { + const { instance } = await makeFixture(); + instance.measure([[6000, 20]]); + const deliverResize = instance.observeLayout(); + instance.resizeContent(0, 0); + deliverResize(); + drainRenderFrames(); + expect(instance.getLineHeight(0)).toBe(80); + instance.resizeContent(482.25); + deliverResize(); + drainRenderFrames(); + expect(instance.getLineHeight(0)).toBe(20); + expect(instance.getVirtualizedHeight()).toBe(120036); + }); +}); + +// Supply inert DOM transport so public Editor edits execute its real tokenizer +// and layout handoff. No native wrapping, observer delivery or scrolling is modeled. +class EditorElement extends MeasuredElement { + style: Record = {}; + parentElement: EditorElement | null = null; + + appendChild(child: EditorElement) { + child.parentElement = this; + this.children.push(child); + return child; + } + + prepend(child: EditorElement) { + child.parentElement = this; + this.children.unshift(child); + } + + replaceChildren(...children: (EditorElement | string)[]) { + this.children = []; + for (const child of children) if (typeof child !== "string") this.appendChild(child); + } + + setAttribute() {} + removeAttribute() {} + addEventListener() {} + removeEventListener() {} + after() {} + + remove() { + if (this.parentElement) { + this.parentElement.children = this.parentElement.children.filter((child) => child !== this); + } + } + + set innerHTML(value: string) { + expect(value.startsWith(" "code" in child.dataset); + } + + querySelector(selector: string) { + expect(selector).toBe("[data-deletions]"); + return null; + } + + getContext() { + return { measureText: (text: string) => ({ width: text.length * 8 }) }; + } +} + +async function makeEditorFixture(lineCount: number) { + const { instance, file } = await makeFixture("wrap", lineCount); + vi.stubGlobal("SVGSVGElement", EditorElement); + vi.stubGlobal("Document", EditorElement); + vi.stubGlobal( + "document", + Object.assign(new EditorElement(), { createElement: () => new EditorElement() }), + ); + vi.stubGlobal("window", { matchMedia: () => ({ matches: true }) }); + vi.stubGlobal("requestAnimationFrame", () => 1); + vi.stubGlobal("cancelAnimationFrame", () => {}); + vi.stubGlobal("getComputedStyle", () => ({ + paddingTop: "0px", + fontSize: "13px", + fontFamily: "monospace", + tabSize: "2", + lineHeight: "20px", + })); + vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + disconnect() {} + }, + ); + instance.setOptions({ + ...instance.options, + useTokenTransformer: true, + controlledSelection: true, + themeType: "dark", + }); + const content = new EditorElement(); + content.dataset.content = ""; + const gutter = new EditorElement(); + gutter.dataset.gutter = ""; + const code = new EditorElement(); + code.dataset.code = ""; + code.appendChild(gutter); + code.appendChild(content); + const shadow = new EditorElement(); + shadow.appendChild(code); + const host = Object.assign(new EditorElement(), { shadowRoot: shadow }); + const highlighter = await getSharedHighlighter({ + themes: ["pierre-dark"], + langs: ["text"], + preferredHighlighter: "shiki-wasm", + }); + const editor = new Editor(); + editors.push(editor); + editor.edit(instance); + editor.__syncRenderView(highlighter, measuredElement(host), file, undefined, { + startingLine: 0, + totalLines: 1, + bufferBefore: 0, + bufferAfter: 0, + }); + const append = (count: number) => { + const lines = editor.getText().split("\n"); + const end = { line: lines.length - 1, character: lines.at(-1)!.length }; + editor.applyEdits([{ range: { start: end, end }, newText: "\n".repeat(count) }]); + }; + const remove = (count: number) => { + const lines = editor.getText().split("\n"); + const startLine = lines.length - count - 1; + editor.applyEdits([ + { + range: { + start: { line: startLine, character: lines[startLine]!.length }, + end: { line: lines.length - 1, character: lines.at(-1)!.length }, + }, + newText: "", + }, + ]); + }; + return { instance, editor, append, remove }; +} + +describe("editor gutter-width changes", () => { + it.each([ + [9999, 1], + [9998, 3], + ])( + "clears prefix measurements when %i lines grow by %i across a digit boundary", + async (lines, count) => { + const { instance, editor, append } = await makeEditorFixture(lines); + expect(instance.getLineHeight(0)).toBe(80); + append(count); + expect(editor.getText().split("\n")).toHaveLength(lines + count); + expect(instance.getLineHeight(0)).toBe(20); + expect(instance.getLineHeight(5000)).toBe(20); + }, + ); + + it.each([ + [10000, 1], + [10002, 4], + ])( + "clears prefix measurements when %i lines shrink by %i across a digit boundary", + async (lines, count) => { + const { instance, editor, remove } = await makeEditorFixture(lines); + remove(count); + expect(editor.getText().split("\n")).toHaveLength(lines - count); + expect(instance.getLineHeight(0)).toBe(20); + expect(instance.getLineHeight(5000)).toBe(20); + }, + ); + + it("clears newly measured prefixes on undo and redo across a digit boundary", async () => { + const { instance, editor, append } = await makeEditorFixture(9999); + append(1); + instance.measure([[0, 100]]); + editor.undo(); + expect(editor.getText().split("\n")).toHaveLength(9999); + expect(instance.getLineHeight(0)).toBe(20); + instance.measure([[0, 80]]); + editor.redo(); + expect(editor.getText().split("\n")).toHaveLength(10000); + expect(instance.getLineHeight(0)).toBe(20); + }); + + it.each([ + [9998, 1], + [10000, 2], + ])( + "retains prefix measurements when %i lines grow by %i without changing digit width", + async (lines, count) => { + const { instance, editor, append } = await makeEditorFixture(lines); + append(count); + expect(editor.getText().split("\n")).toHaveLength(lines + count); + expect(instance.getLineHeight(0)).toBe(80); + expect(instance.getLineHeight(5000)).toBe(80); + editor.undo(); + expect(instance.getLineHeight(0)).toBe(80); + editor.redo(); + expect(instance.getLineHeight(0)).toBe(80); + }, + ); +}); diff --git a/apps/web/src/components/media/MediaVideoPlayer.tsx b/apps/web/src/components/media/MediaVideoPlayer.tsx index f436beeb3855..90c18984e17b 100644 --- a/apps/web/src/components/media/MediaVideoPlayer.tsx +++ b/apps/web/src/components/media/MediaVideoPlayer.tsx @@ -127,7 +127,9 @@ export function MediaVideoPlayer({ diff --git a/apps/web/src/components/onboarding/FirstRunGate.tsx b/apps/web/src/components/onboarding/FirstRunGate.tsx new file mode 100644 index 000000000000..a9df5cb00b59 --- /dev/null +++ b/apps/web/src/components/onboarding/FirstRunGate.tsx @@ -0,0 +1,244 @@ +import { useAtomValue } from "@effect/atom-react"; +import { useLocation, useNavigate } from "@tanstack/react-router"; +import { Atom } from "effect/unstable/reactivity"; +import { RotateCcwIcon } from "lucide-react"; +import { useEffect, useLayoutEffect, useState } from "react"; + +import { + ensureClientSettingsHydrated, + useClientSettings, + useClientSettingsHydrationStatus, +} from "../../hooks/useSettings"; +import { mountOnboardingTheme } from "../../hooks/useTheme"; +import { useCompleteOnboarding } from "../../onboarding/firstRun"; +import { + isFirstRunWorkspaceProvenanceAuthoritative, + isFreshFirstRunWorkspace, + resolveFirstRunDecision, + resolveHostedFirstRunDecision, + transitionFirstRunGateState, + type FirstRunGateState, +} from "../../onboarding/firstRun.logic"; +import { + useAllEnvironmentShellsBootstrapped, + useProjects, + useThreadShells, +} from "../../state/entities"; +import { useEnvironments } from "../../state/environments"; +import { environmentProjects } from "../../state/projects"; +import { primaryServerConfigAtom, primaryServerWelcomeAtom } from "../../state/server"; +import { environmentShell } from "../../state/shell"; +import { environmentThreadShells } from "../../state/threads"; +import { Button } from "../ui/button"; + +/** + * Holds back authenticated and hosted app trees until the first-run decision + * is known, so a fresh install never flashes the main screen before the wizard. + * Nothing renders while pending — no shell, no EventRouter (whose welcome + * payload would otherwise navigate into a thread), no dialogs. + * + * Decision order: a set `onboardingCompletedAt` resolves to the app as soon as + * settings hydrate (the common case, no server round-trip). A `null` flag also + * covers installs that predate the field, so it alone is not enough — the gate + * waits for environment shells to bootstrap and inspects the workspace. + * Hosted mode instead checks its saved environment catalog. A timeout shows + * recovery for an unreachable primary server without mounting the app tree. + */ + +const FIRST_RUN_DECISION_TIMEOUT_MS = 4_000; + +const primaryShellLiveAtom = Atom.make((get) => { + const serverConfig = get(primaryServerConfigAtom); + return ( + serverConfig !== null && + get(environmentShell.stateValueAtom(serverConfig.environment.environmentId)).status === "live" + ); +}).pipe(Atom.withLabel("web-onboarding-primary-shell-live")); + +const workspaceEvidenceLiveAtom = Atom.make((get) => { + const environmentIds = new Set([ + ...get(environmentProjects.projectsAtom).map((project) => project.environmentId), + ...get(environmentThreadShells.threadShellsAtom).map((thread) => thread.environmentId), + ]); + + for (const environmentId of environmentIds) { + if (get(environmentShell.stateValueAtom(environmentId)).status !== "live") { + return false; + } + } + + return true; +}).pipe(Atom.withLabel("web-onboarding-workspace-evidence-live")); + +export function FirstRunGate({ + enabled, + hostedStatic, + children, +}: { + readonly enabled: boolean; + readonly hostedStatic: boolean; + readonly children: React.ReactNode; +}) { + const navigate = useNavigate(); + const pathname = useLocation({ select: (location) => location.pathname }); + const hydrationStatus = useClientSettingsHydrationStatus(); + const hydrated = hydrationStatus === "ready"; + const completeOnboarding = useCompleteOnboarding(); + const onboardingCompletedAt = useClientSettings((settings) => settings.onboardingCompletedAt); + const bootstrapped = useAllEnvironmentShellsBootstrapped(); + const { environments, isReady: environmentCatalogReady } = useEnvironments(); + const projects = useProjects(); + const threads = useThreadShells(); + const serverConfig = useAtomValue(primaryServerConfigAtom); + const serverWelcome = useAtomValue(primaryServerWelcomeAtom); + const primaryShellLive = useAtomValue(primaryShellLiveAtom); + const workspaceEvidenceLive = useAtomValue(workspaceEvidenceLiveAtom); + // Within a session settings stay hydrated, so remounts (e.g. returning from + // the wizard) resolve synchronously instead of blanking a frame. + const [gateState, setGateState] = useState(() => ({ + decision: + (!enabled && !hostedStatic) || (hydrated && onboardingCompletedAt !== null) + ? "app" + : "pending", + stalled: false, + })); + const { decision, stalled } = gateState; + const settingsReadFailed = hydrationStatus === "failed" || hydrationStatus === "retrying"; + const ownsOnboardingTheme = settingsReadFailed || stalled || decision === "wizard"; + + useLayoutEffect(() => { + if (!ownsOnboardingTheme) return; + return mountOnboardingTheme(); + }, [ownsOnboardingTheme]); + + // A workspace still counts as fresh when its only content is the server's + // own cwd auto-bootstrap: web mode creates a project + thread from cwd at + // startup (`autoBootstrapProjectFromCwd` defaults on there), so "no + // projects at all" would mean `npx t3` users never see the wizard. Any + // other project, more than one thread, or state in a non-primary + // environment is real user state — the aggregate hooks span every + // environment, and a saved remote's project must never read as "the + // bootstrap project" just because its root string matches the primary cwd. + const serverCwd = serverConfig?.cwd ?? null; + const primaryEnvironmentId = serverConfig?.environment.environmentId ?? null; + const workspaceFresh = isFreshFirstRunWorkspace({ + primaryEnvironmentId, + serverCwd, + bootstrapProjectId: serverWelcome?.bootstrapProjectId, + bootstrapThreadId: serverWelcome?.bootstrapThreadId, + bootstrapProjectCreated: serverWelcome?.bootstrapProjectCreated, + bootstrapThreadCreated: serverWelcome?.bootstrapThreadCreated, + projects, + threads, + }); + + const { decision: nextDecision, persistCompletion } = hostedStatic + ? resolveHostedFirstRunDecision({ + hydrated, + completed: onboardingCompletedAt !== null, + catalogReady: environmentCatalogReady, + environmentCount: environments.length, + }) + : resolveFirstRunDecision({ + enabled, + hydrated, + completed: onboardingCompletedAt !== null, + bootstrapped, + authoritative: primaryShellLive, + workspaceAuthoritative: workspaceEvidenceLive, + workspaceProvenanceAuthoritative: isFirstRunWorkspaceProvenanceAuthoritative({ + welcomeReceived: serverWelcome !== null, + bootstrapStatus: serverWelcome?.bootstrapStatus ?? null, + }), + catalogReady: environmentCatalogReady, + serverConfigAvailable: serverConfig !== null, + workspaceFresh, + projectCount: projects.length, + threadCount: threads.length, + }); + + useEffect(() => { + if (decision === "wizard" || !hydrated) return; + + if (persistCompletion && onboardingCompletedAt === null) { + void completeOnboarding().catch(() => undefined); + } + + setGateState((state) => + transitionFirstRunGateState(state, { type: "evidence", decision: nextDecision }), + ); + }, [ + completeOnboarding, + decision, + hydrated, + nextDecision, + onboardingCompletedAt, + persistCompletion, + ]); + + // A stalled server read gets a recovery screen, but never mounts the app. + // The timer starts after settings hydrate so slow local hydration does not + // show a false connection failure. + useEffect(() => { + if (!enabled || decision !== "pending" || !hydrated) return; + const timer = window.setTimeout( + () => setGateState((state) => transitionFirstRunGateState(state, { type: "timeout" })), + FIRST_RUN_DECISION_TIMEOUT_MS, + ); + return () => window.clearTimeout(timer); + }, [decision, enabled, hydrated]); + + useEffect(() => { + if (decision === "wizard" && pathname !== "/welcome") { + void navigate({ to: "/welcome", replace: true }); + } + }, [decision, navigate, pathname]); + + if (settingsReadFailed) { + return ; + } + if (decision !== "app") { + return stalled ? : null; + } + return children; +} + +function FirstRunRecovery({ + reason, + retrying = false, +}: { + readonly reason: "settings" | "connection"; + readonly retrying?: boolean; +}) { + const settingsReadFailed = reason === "settings"; + return ( +
+
+

+ {settingsReadFailed ? "Could not read settings" : "Still connecting"} +

+

+ {settingsReadFailed + ? "Your saved settings could not be loaded." + : "T3 Code could not confirm this workspace."} +

+ +
+
+ ); +} diff --git a/apps/web/src/components/onboarding/WelcomeWizard.tsx b/apps/web/src/components/onboarding/WelcomeWizard.tsx new file mode 100644 index 000000000000..cb31ef259667 --- /dev/null +++ b/apps/web/src/components/onboarding/WelcomeWizard.tsx @@ -0,0 +1,1478 @@ +import { useAuth } from "@clerk/react"; +import { useAtomValue } from "@effect/atom-react"; +import type { + AgentSessionProjectCandidate, + EnvironmentId, + ProjectId, + ScopedProjectRef, + ServerConfig, + ServerProvider, +} from "@t3tools/contracts"; +import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { CommandId, ProviderDriverKind, ThreadId } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import { + ArrowRightIcon, + CheckIcon, + ChevronLeftIcon, + ChevronRightIcon, + CloudIcon, + CopyIcon, + LinkIcon, + MonitorIcon, + TerminalIcon, + type LucideIcon, +} from "lucide-react"; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; + +import { TYPOGRAPHY_ADVANCED_STORAGE_KEY } from "../../appearanceFonts"; +import { useLocalStorage } from "../../hooks/useLocalStorage"; +import { mountOnboardingTheme } from "../../hooks/useTheme"; +import { hasCloudPublicConfig } from "../../cloud/publicConfig"; +import { useT3ConnectAuthPrompt } from "../clerk/useT3ConnectAuthPrompt"; +import { useCompleteOnboarding } from "../../onboarding/firstRun"; +import { + partitionOnboardingProjects, + resolveOnboardingLandingProject, + resolveOnboardingProjectId, +} from "../../onboarding/projectImport.logic"; +import { + getOnboardingProviderState, + resolveOnboardingProviderLoginCommand, + selectOnboardingProvidersByDriver, +} from "../../onboarding/providerReadiness.logic"; +import { + isOnboardingRelayEnvironment, + resolveOnboardingTargetEnvironment, +} from "../../onboarding/targetEnvironment.logic"; +import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; +import { newProjectId, randomUUID } from "../../lib/utils"; +import { resolveDefaultProviderModelSelection } from "../../providerInstances"; +import { agentSessionImport, agentSessionScan } from "../../state/agentSessions"; +import { readProjects, useProjects } from "../../state/entities"; +import { useEnvironments, usePrimaryEnvironment } from "../../state/environments"; +import { useEnvironmentQuery } from "../../state/query"; +import { projectEnvironment } from "../../state/projects"; +import { serverEnvironment } from "../../state/server"; +import { terminalEnvironment } from "../../state/terminal"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { connectPairing } from "../../connection/onboarding"; +import { isElectron } from "../../env"; +import { formatRelativeTimeLabel } from "../../timestampFormat"; +import { getProviderSummary } from "../settings/providerStatus"; +import { getDriverOption } from "../settings/providerDriverMeta"; +import { CloudEnvironmentConnectRows } from "../cloud/CloudEnvironmentConnectList"; +import { TerminalViewport } from "../ThreadTerminalDrawer"; +import { Button } from "../ui/button"; +import { Checkbox } from "../ui/checkbox"; +import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; +import { Input } from "../ui/input"; +import { toastManager } from "../ui/toast"; +import { cn } from "../../lib/utils"; + +/** + * First-run welcome wizard. Rendered as the full-screen `/welcome` route on a + * fresh install (no completed-onboarding flag, empty workspace). Flow per the + * onboarding overhaul spec: connection choice → sign-in/pair (remote paths) → + * agent setup with inline install terminal → project import → main screen. + * Every step past the connection gate is skippable; the whole wizard is + * re-runnable by clearing the flag. + */ + +type WizardStep = "connection" | "connect-machines" | "pair-direct" | "agents" | "import"; + +type ConnectionMode = "local" | "connect" | "direct"; + +/** + * The machine the agent and import steps run against. Local mode targets the + * primary environment; the remote modes prefer the machine the user just + * connected (the most recently added connected non-primary environment), so + * probing and import happen where their code lives rather than on the local + * server that happens to serve the app. Deliberately not a persisted + * "primary machine" concept — just whichever machine fits the chosen path + * right now, labeled inline on each step. + */ +function useOnboardingTargetEnvironment( + mode: ConnectionMode, + pairedEnvironmentId: EnvironmentId | null, +) { + const { environments } = useEnvironments(); + const primaryEnvironment = usePrimaryEnvironment(); + return resolveOnboardingTargetEnvironment({ + mode, + environments, + primaryEnvironment, + pairedEnvironmentId, + }); +} + +const AGENT_ONBOARDING_THREAD_ID = ThreadId.make("onboarding-agent-setup"); +const ONBOARDING_STAGES = ["Connect", "Agents", "Projects"] as const; +const SCAN_LIMIT_MESSAGE = "Scan limit reached. Some projects or conversations may be missing."; + +export function WelcomeWizard({ + localAvailable, + onDone, +}: { + /** + * Whether the "Local Only" card is offered. True whenever the app is served + * by an authenticated primary server — desktop, `npx t3`, or a dev server — + * since that server is "this machine" regardless of the hostname the app + * was opened from. Only hosted-static (app.t3.codes) has no local server. + */ + readonly localAvailable: boolean; + readonly onDone: (projectRef?: ScopedProjectRef) => void; +}) { + useLayoutEffect(() => mountOnboardingTheme(), []); + const completeOnboarding = useCompleteOnboarding(); + const [step, setStep] = useState("connection"); + const [mode, setMode] = useState("local"); + const [pairedEnvironmentId, setPairedEnvironmentId] = useState(null); + const finishingPromiseRef = useRef | null>(null); + const completionErrorToastIdRef = useRef | null>(null); + const targetEnvironment = useOnboardingTargetEnvironment(mode, pairedEnvironmentId); + const stageIndex = step === "agents" ? 1 : step === "import" ? 2 : 0; + const finish = useCallback( + (projectRef?: ScopedProjectRef) => { + if (finishingPromiseRef.current !== null) return finishingPromiseRef.current; + if (completionErrorToastIdRef.current !== null) { + toastManager.close(completionErrorToastIdRef.current); + completionErrorToastIdRef.current = null; + } + + const completion = completeOnboarding() + .then(() => { + if (completionErrorToastIdRef.current !== null) { + toastManager.close(completionErrorToastIdRef.current); + completionErrorToastIdRef.current = null; + } + onDone(projectRef); + return true; + }) + .catch(() => { + const errorToast = { + type: "error", + title: "Could not finish setup", + description: "Your settings could not be saved. Try again.", + } as const; + if (completionErrorToastIdRef.current === null) { + completionErrorToastIdRef.current = toastManager.add(errorToast); + } else { + toastManager.update(completionErrorToastIdRef.current, errorToast); + } + return false; + }) + .finally(() => { + if (finishingPromiseRef.current === completion) { + finishingPromiseRef.current = null; + } + }); + finishingPromiseRef.current = completion; + return completion; + }, + [completeOnboarding, onDone], + ); + + return ( +
+ {isElectron ? ( +
+ ) : null} +
+
+ + +
+ {step === "connection" ? ( + { + setMode("local"); + setPairedEnvironmentId(null); + setStep("agents"); + }} + onConnect={() => { + setMode("connect"); + setPairedEnvironmentId(null); + setStep("connect-machines"); + }} + onDirect={() => { + setMode("direct"); + setPairedEnvironmentId(null); + setStep("pair-direct"); + }} + /> + ) : step === "connect-machines" ? ( + setStep("connection")} + onContinue={() => setStep("agents")} + /> + ) : step === "pair-direct" ? ( + setStep("connection")} + onPaired={(environmentId) => { + setPairedEnvironmentId(environmentId); + setStep("agents"); + }} + /> + ) : step === "agents" ? ( + + setStep( + mode === "local" + ? "connection" + : mode === "connect" + ? "connect-machines" + : "pair-direct", + ) + } + onContinue={() => setStep("import")} + onSkip={() => setStep("import")} + /> + ) : ( + setStep("agents")} + onDone={finish} + /> + )} +
+
+
+
+ ); +} + +// ── Step 1: connection choice ──────────────────────────────── + +function ConnectionStep({ + localAvailable, + localLabel, + onLocal, + onConnect, + onDirect, +}: { + readonly localAvailable: boolean; + readonly localLabel: string; + readonly onLocal: () => void; + readonly onConnect: () => void; + readonly onDirect: () => void; +}) { + const cloudEnabled = hasCloudPublicConfig(); + const [choice, setChoice] = useState<"local" | "connect" | "direct">( + localAvailable ? "local" : cloudEnabled ? "connect" : "direct", + ); + + const advance = () => { + if (choice === "local") onLocal(); + else if (choice === "connect") onConnect(); + else onDirect(); + }; + + return ( + <> +

Where is your code?

+

Choose where your agents will run.

+
+ {localAvailable ? ( + setChoice("local")} + /> + ) : null} + {cloudEnabled ? ( + setChoice("connect")} + /> + ) : null} + setChoice("direct")} + /> +
+
+ +
+ + ); +} + +function ConnectionOption({ + icon: Icon, + title, + description, + truncateDescription = false, + detail, + selected, + onSelect, +}: { + readonly icon: LucideIcon; + readonly title: string; + readonly description: string; + readonly truncateDescription?: boolean; + readonly detail: string; + readonly selected: boolean; + readonly onSelect: () => void; +}) { + return ( + + ); +} + +// ── Step 2: T3 Connect (sign in, then connect machines) ────── + +const CONNECT_LOGIN_COMMAND = "npx t3 connect"; + +/** + * Sign-in and machine-connection combined: signed out shows the Clerk prompt, + * signed in forks on account state — zero connected machines blocks on the + * `npx t3 connect` command and auto-advance is left to the user pressing + * Continue once their machine appears; existing machines show a confirmation + * list with the command folded away. There is deliberately no "primary + * machine" selection. + */ +function ConnectMachinesStep({ + onBack, + onContinue, +}: { + readonly onBack: () => void; + readonly onContinue: () => void; +}) { + // Mirrors ManagedRelayAuthProvider: a pending Clerk session must not read + // as signed-out mid-transition. + const { isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); + const { openAuthPrompt } = useT3ConnectAuthPrompt(); + const { environments } = useEnvironments(); + const primaryEnvironment = usePrimaryEnvironment(); + const savedEnvironments = environments.filter(isOnboardingRelayEnvironment); + // Only a live connection counts: a saved-but-offline machine must not show + // the "connected" confirmation (the agents step would find nothing to + // probe). Its row still renders in the list either way. + const hasRemoteMachines = savedEnvironments.some( + (environment) => environment.connection.phase === "connected", + ); + + if (!isLoaded) { + return ; + } + + if (!isSignedIn) { + return ( + +
+ +
+
+ ); + } + + return ( + + {hasRemoteMachines ? ( + <> +
+ +
+ + + + Add another machine + + + +

+ Keep T3 Code running on that computer. If it is not running, open T3 Code or run{" "} + npx t3 serve. +

+
+
+
+ +
+ + ) : ( + <> + +

+ Keep T3 Code running on that computer. If it is not running, open T3 Code or run{" "} + npx t3 serve. +

+
+ + Waiting for your computer to connect. +

+ } + /> +
+
+ +
+ + Waiting for connection + + +
+
+ + )} +
+ ); +} + +// ── Step 2′: Direct pairing ────────────────────────────────── + +/** + * Server-minted pairing, D-B treatment: numbered steps, `t3 pair` on the + * server, paste the URL here. Registers the remote environment in this + * browser's catalog (same path the hosted /pair surface uses). + */ +function PairDirectStep({ + onBack, + onPaired, +}: { + readonly onBack: () => void; + readonly onPaired: (environmentId: EnvironmentId) => void; +}) { + const connectPairingEnvironment = useAtomCommand(connectPairing, { reportFailure: false }); + const [pairingUrl, setPairingUrl] = useState(""); + const [errorMessage, setErrorMessage] = useState(""); + const [isPairing, setIsPairing] = useState(false); + const mountedRef = useRef(true); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + const submit = async () => { + setIsPairing(true); + setErrorMessage(""); + const result = await connectPairingEnvironment({ pairingUrl }); + if (!mountedRef.current) return; + setIsPairing(false); + if (result._tag === "Success") { + onPaired(result.value); + return; + } + if (isAtomCommandInterrupted(result)) return; + const cause = squashAtomCommandFailure(result); + setErrorMessage(cause instanceof Error ? cause.message : "Pairing failed."); + }; + + return ( + +
+
+

+ 01 Run this on your server +

+ +

+ Start the server with npx t3 serve first. Add{" "} + --tailscale to use your tailnet. +

+
+
+ + setPairingUrl(event.currentTarget.value)} + onKeyDown={(event) => { + if (event.nativeEvent.isComposing || event.keyCode === 229) return; + if (event.key === "Enter" && pairingUrl.trim().length > 0) void submit(); + }} + /> +
+ {errorMessage ? ( +
+ {errorMessage} +
+ ) : null} +
+
+ +
+
+ ); +} + +// ── Step 3: agents ─────────────────────────────────────────── + +const PRIMARY_AGENT_DRIVERS = ["claudeAgent", "codex"] as const; +type OnboardingAgentDriver = (typeof PRIMARY_AGENT_DRIVERS)[number]; + +const AGENT_INSTALL_COMMANDS: Record = { + claudeAgent: "npm install -g @anthropic-ai/claude-code", + codex: "npm install -g @openai/codex", +}; + +/** Setup values stay fixed while provider probes refresh the surrounding cards. */ +interface AgentTerminalSession { + readonly environmentId: EnvironmentId; + readonly driver: OnboardingAgentDriver; + readonly providerInstanceId: ServerProvider["instanceId"]; + readonly cwd: string; + readonly command: string; + readonly keybindings: ServerConfig["keybindings"]; +} + +/** + * Claude Code and Codex use live probe status. Install opens the built-in terminal inline + * with the command pre-typed — the update RPC can't install a binary that + * isn't there yet (it infers the package manager from the installed binary's + * path), and the terminal also handles the interactive login that follows. + */ +function AgentsStep({ + mode, + pairedEnvironmentId, + onBack, + onContinue, + onSkip, +}: { + readonly mode: ConnectionMode; + readonly pairedEnvironmentId: EnvironmentId | null; + readonly onBack: () => void; + readonly onContinue: () => void; + readonly onSkip: () => void; +}) { + const targetEnvironment = useOnboardingTargetEnvironment(mode, pairedEnvironmentId); + if (targetEnvironment === null) { + return ( + +
+ +
+
+ ); + } + return ( + + ); +} + +function ConnectedAgentsStep({ + environmentId, + machineLabel, + onBack, + onContinue, + onSkip, +}: { + readonly environmentId: EnvironmentId; + readonly machineLabel: string; + readonly onBack: () => void; + readonly onContinue: () => void; + readonly onSkip: () => void; +}) { + const providers = useAtomValue(serverEnvironment.providersValueAtom(environmentId)); + const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, { + reportFailure: false, + }); + const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId)); + const [terminalSession, setTerminalSession] = useState(null); + + // Re-probe on entry so freshly installed CLIs show up without a manual + // refresh; harmless when nothing changed (single-flighted per environment). + useEffect(() => { + void refreshProviders({ environmentId, input: {} }); + }, [environmentId, refreshProviders]); + + const byDriver = useMemo(() => selectOnboardingProvidersByDriver(providers), [providers]); + + const primaryAgents = PRIMARY_AGENT_DRIVERS.map((driver) => ({ + driver, + provider: byDriver.get(driver), + })); + const readyCount = primaryAgents.filter( + ({ provider }) => getOnboardingProviderState(provider) === "ready", + ).length; + return ( + +
+ {primaryAgents.map(({ driver, provider }) => ( + { + if (provider === undefined || serverConfig === null) return; + setTerminalSession({ + environmentId, + driver, + providerInstanceId: provider.instanceId, + cwd: serverConfig.cwd, + command: provider.installed + ? resolveOnboardingProviderLoginCommand( + provider, + serverConfig.settings, + serverConfig.environment.platform.os, + ) + : AGENT_INSTALL_COMMANDS[driver], + keybindings: serverConfig.keybindings, + }); + }} + /> + ))} +
+ {terminalSession !== null ? ( + { + setTerminalSession(null); + void refreshProviders({ environmentId, input: {} }); + }} + /> + ) : null} +
+ +
+ + {readyCount} of {primaryAgents.length} ready + + +
+
+
+ ); +} + +function AgentCard({ + driver, + provider, + terminalOpen, + terminalAvailable, + onOpenTerminal, +}: { + readonly driver: OnboardingAgentDriver; + readonly provider: ServerProvider | undefined; + readonly terminalOpen: boolean; + readonly terminalAvailable: boolean; + readonly onOpenTerminal: () => void; +}) { + const meta = getDriverOption(ProviderDriverKind.make(driver)); + const Icon = meta?.icon; + const displayName = driver === "claudeAgent" ? "Claude Code" : (meta?.label ?? driver); + const summary = getProviderSummary(provider); + const providerState = getOnboardingProviderState(provider); + + return ( +
+ {Icon ? ( + + ) : null} +
+ {displayName} +

+ {summary.headline} + {summary.detail ? ` · ${summary.detail}` : ""} +

+
+
+ {providerState === "ready" ? ( + + + Ready + + ) : providerState === "checking" ? ( + Checking... + ) : providerState === "disabled" ? ( + Disabled + ) : providerState === "attention" ? ( + {summary.headline} + ) : ( + + )} +
+
+ ); +} + +/** + * Inline install terminal. Opens a PTY on the connected environment under a + * synthetic onboarding thread id (terminals are keyed by free-form thread id; + * the server validates only the cwd) and pre-types the install or login + * command without submitting, so the user reviews and presses Enter. + */ +function AgentInstallTerminal({ + session, + onClose, +}: { + readonly session: AgentTerminalSession; + readonly onClose: () => void; +}) { + const { command, cwd, driver, environmentId, keybindings, providerInstanceId } = session; + // Same terminal typography preference the thread drawer honors. + const [advancedTypography] = useLocalStorage( + TYPOGRAPHY_ADVANCED_STORAGE_KEY, + false, + Schema.Boolean, + ); + const openTerminal = useAtomCommand(terminalEnvironment.open, { reportFailure: false }); + const writeTerminal = useAtomCommand(terminalEnvironment.write, { reportFailure: false }); + const closeTerminal = useAtomCommand(terminalEnvironment.close, { reportFailure: false }); + const setupQueueRef = useRef(Promise.resolve()); + const setupGenerationRef = useRef(0); + const activeSetupGenerationRef = useRef(null); + const [terminalId] = useState(() => `onboarding-${driver}-${randomUUID()}`); + const threadRef = useMemo( + () => scopeThreadRef(environmentId, AGENT_ONBOARDING_THREAD_ID), + [environmentId], + ); + const [setupAttempt, setSetupAttempt] = useState(0); + const [setupState, setSetupState] = useState< + "preparing" | "ready" | "openFailed" | "writeFailed" + >("preparing"); + const terminalReady = setupState === "ready" || setupState === "writeFailed"; + + // Keep each setup generation distinct. In Strict Mode, a canceled open can + // finish after the replacement setup starts; it must not close or pre-type + // into the replacement session that shares this terminal id. + useEffect(() => { + const generation = setupGenerationRef.current + 1; + setupGenerationRef.current = generation; + activeSetupGenerationRef.current = generation; + setSetupState("preparing"); + + setupQueueRef.current = setupQueueRef.current.then(async () => { + if (activeSetupGenerationRef.current !== generation) return; + const opened = await openTerminal({ + environmentId, + input: { + threadId: AGENT_ONBOARDING_THREAD_ID, + terminalId, + cwd, + providerInstanceId, + }, + }); + if (opened._tag !== "Success") { + if (activeSetupGenerationRef.current === generation) setSetupState("openFailed"); + return; + } + + if (activeSetupGenerationRef.current !== generation) return; + + const wrote = await writeTerminal({ + environmentId, + input: { threadId: AGENT_ONBOARDING_THREAD_ID, terminalId, data: command }, + }); + if (activeSetupGenerationRef.current !== generation) return; + setSetupState(wrote._tag === "Success" ? "ready" : "writeFailed"); + }); + + // Every exit path unmounts the drawer (Done, Continue/Skip, card switch, + // session exit), so this cleanup is the single place the PTY dies — + // nothing is left running behind the wizard. An interrupted install is + // re-runnable from the card. + return () => { + if (activeSetupGenerationRef.current === generation) { + activeSetupGenerationRef.current = null; + } + setupQueueRef.current = setupQueueRef.current.then(async () => { + await closeTerminal({ + environmentId, + input: { threadId: AGENT_ONBOARDING_THREAD_ID, terminalId, deleteHistory: true }, + }); + }); + }; + }, [ + closeTerminal, + command, + cwd, + environmentId, + openTerminal, + providerInstanceId, + setupAttempt, + terminalId, + writeTerminal, + ]); + + return ( +
+
+ + {setupState === "writeFailed" ? ( + <> + Run {command} in this + terminal. + + ) : setupState === "ready" ? ( + "Review the command, then press Enter to run it." + ) : setupState === "openFailed" ? ( + "Could not open the setup terminal." + ) : ( + "Preparing command..." + )} + +
+ {setupState === "openFailed" ? ( + + ) : null} + +
+
+
+ {terminalReady ? ( + + ) : null} +
+
+ ); +} + +// ── Step 4: import ─────────────────────────────────────────── + +/** + * One-decision import (4B): a summary line with Import recent / Choose / + * Skip. The default imports only projects touched in the last 30 days; + * Choose expands a checklist including older ones. Imported projects also + * receive Codex and Claude threads active within the last 30 days. + */ +function ImportStep({ + mode, + pairedEnvironmentId, + onBack, + onDone, +}: { + readonly mode: ConnectionMode; + readonly pairedEnvironmentId: EnvironmentId | null; + readonly onBack: () => void; + readonly onDone: (projectRef?: ScopedProjectRef) => Promise; +}) { + const targetEnvironment = useOnboardingTargetEnvironment(mode, pairedEnvironmentId); + const environmentId = targetEnvironment?.environmentId ?? null; + const machineLabel = targetEnvironment?.label ?? "this machine"; + const providers = useAtomValue( + serverEnvironment.providersValueAtom(environmentId ?? ("" as EnvironmentId)), + ); + const scan = useEnvironmentQuery( + environmentId === null ? null : agentSessionScan({ environmentId, input: {} }), + ); + const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false }); + const importThreads = useAtomCommand(agentSessionImport, { reportFailure: false }); + const projects = useProjects(); + const [choosing, setChoosing] = useState(false); + const [deselected, setDeselected] = useState>(new Set()); + const [isImporting, setIsImporting] = useState(false); + const [importError, setImportError] = useState(""); + const [landingProject, setLandingProject] = useState(null); + // Keep project creation attempts separate from completed history imports so both can retry. + const importedProjectsRef = useRef(new Map()); + const projectsWithImportedHistoryRef = useRef(new Map()); + const lastImportSelectionRef = useRef>([]); + const projectAttemptsRef = useRef( + new Map(), + ); + const importGenerationRef = useRef(0); + + // Candidate paths are per-environment; a target switch would otherwise + // leave stale entries in the deselection set (and stale success records). + useEffect(() => { + importGenerationRef.current += 1; + setDeselected(new Set()); + setIsImporting(false); + setImportError(""); + setLandingProject(null); + importedProjectsRef.current = new Map(); + projectsWithImportedHistoryRef.current = new Map(); + lastImportSelectionRef.current = []; + projectAttemptsRef.current = new Map(); + return () => { + importGenerationRef.current += 1; + }; + }, [environmentId]); + + useEffect(() => { + if ( + landingProject !== null && + landingProject.environmentId === environmentId && + projects.some( + (project) => + project.id === landingProject.projectId && + project.environmentId === landingProject.environmentId, + ) + ) { + setLandingProject(null); + void onDone(landingProject).then((completed) => { + if (!completed) setIsImporting(false); + }); + } + }, [environmentId, landingProject, onDone, projects]); + + const { available: candidates, recent } = useMemo( + () => partitionOnboardingProjects(scan.data?.candidates ?? []), + [scan.data], + ); + const more = candidates.length - recent.length; + const scanTruncated = scan.data?.truncated === true; + const scanLimitNotice = scanTruncated ? ( +

+ {SCAN_LIMIT_MESSAGE} +

+ ) : null; + + const finishAfterImport = () => { + const projectRef = resolveOnboardingLandingProject( + lastImportSelectionRef.current, + projectsWithImportedHistoryRef.current, + importedProjectsRef.current, + ); + if (projectRef === undefined) { + void onDone(); + return; + } + setIsImporting(true); + setLandingProject(projectRef); + }; + + const runImport = async (selection: ReadonlyArray) => { + if (environmentId === null || selection.length === 0) { + void onDone(); + return; + } + setIsImporting(true); + setImportError(""); + lastImportSelectionRef.current = selection.map((candidate) => candidate.path); + const importGeneration = importGenerationRef.current; + const importedProjects = importedProjectsRef.current; + const projectAttempts = projectAttemptsRef.current; + const defaultModelSelection = resolveDefaultProviderModelSelection(providers ?? [], null); + // Interrupted imports are neither failures nor successes — the command was + // superseded or the environment dropped — but they still didn't land, so + // they must not read as "imported everything". Retries skip paths that + // already landed this session (re-creating them would only trip the + // duplicate-root invariant and read as a failure). + let importedProjectsCount = + importedProjects.size > 0 + ? selection.filter((candidate) => importedProjects.has(candidate.path)).length + : 0; + let importedThreadCount = 0; + let skippedThreadCount = 0; + let shouldRefreshScan = false; + for (const candidate of selection) { + if ( + importGeneration !== importGenerationRef.current || + importedProjects !== importedProjectsRef.current + ) { + return; + } + if (importedProjects.has(candidate.path)) continue; + let projectId = resolveOnboardingProjectId(readProjects(), environmentId, candidate); + if (projectId === null) { + let attempt = projectAttempts.get(candidate.path); + if (attempt === undefined) { + const nextProjectId = newProjectId(); + attempt = { + projectId: nextProjectId, + commandId: CommandId.make(`onboarding:project:create:${nextProjectId}`), + }; + projectAttempts.set(candidate.path, attempt); + } + projectId = attempt.projectId; + const result = await createProject({ + environmentId, + input: { + projectId, + commandId: attempt.commandId, + title: candidate.title, + workspaceRoot: candidate.path, + createWorkspaceRootIfMissing: false, + defaultModelSelection, + }, + }); + if ( + importGeneration !== importGenerationRef.current || + importedProjects !== importedProjectsRef.current + ) { + return; + } + if (result._tag !== "Success") { + if (!isAtomCommandInterrupted(result)) { + projectAttempts.delete(candidate.path); + shouldRefreshScan = true; + } + continue; + } + } + + const threadImportResult = await importThreads({ + environmentId, + input: { projectId, expectedWorkspaceRoot: candidate.path }, + }); + if ( + importGeneration !== importGenerationRef.current || + importedProjects !== importedProjectsRef.current + ) { + return; + } + if (threadImportResult._tag === "Success") { + importedThreadCount += threadImportResult.value.importedCount; + skippedThreadCount += threadImportResult.value.skippedCount; + if (threadImportResult.value.importedCount > 0) { + projectsWithImportedHistoryRef.current.set( + candidate.path, + scopeProjectRef(environmentId, projectId), + ); + } + if (threadImportResult.value.skippedCount === 0) { + importedProjectsCount += 1; + importedProjects.set(candidate.path, scopeProjectRef(environmentId, projectId)); + } + } else if (!isAtomCommandInterrupted(threadImportResult)) { + projectAttempts.delete(candidate.path); + shouldRefreshScan = true; + } + } + if (shouldRefreshScan) scan.refresh(); + setIsImporting(false); + if (importedProjectsCount < selection.length) { + if (importedThreadCount > 0 && skippedThreadCount > 0) { + setImportError( + `Imported ${importedThreadCount} ${importedThreadCount === 1 ? "thread" : "threads"}. ${skippedThreadCount} ${skippedThreadCount === 1 ? "thread" : "threads"} could not be imported.`, + ); + } else if (skippedThreadCount > 0) { + setImportError( + `${skippedThreadCount} ${skippedThreadCount === 1 ? "thread could" : "threads could"} not be imported.`, + ); + } else if (importedThreadCount > 0) { + setImportError( + `Imported ${importedThreadCount} ${importedThreadCount === 1 ? "thread" : "threads"}. Some thread history could not be imported.`, + ); + } else { + setImportError("Could not import thread history."); + } + return; + } + finishAfterImport(); + }; + + if (environmentId === null || (scan.isPending && scan.data === null)) { + return ( + +
+ +
+
+ ); + } + + if (scan.error !== null || candidates.length === 0) { + return ( + + {scan.error !== null ? ( +

You can add projects later.

+ ) : null} +
+ {scan.error !== null ? ( + + ) : null} + +
+
+ ); + } + + if (choosing) { + const selected = candidates.filter((candidate) => !deselected.has(candidate.path)); + return ( + setChoosing(false)} + backDisabled={isImporting} + description={`${candidates.length} found on ${machineLabel}.`} + > + {scanLimitNotice} +
+ {candidates.map((candidate) => ( + + ))} +
+ {importError ?

{importError}

: null} +
+ + +
+
+ ); + } + + return ( + 0 ? ` ${more} more available.` : ""}`} + onBack={onBack} + backDisabled={isImporting} + > + {scanLimitNotice} +
+ {recent.slice(0, 4).map((candidate) => ( +
+ + + {candidate.path} + + + {candidate.sources.map(formatSource).join(", ")} + +
+ ))} + {recent.length > 4 ? ( +

+ {recent.length - 4} more projects +

+ ) : null} +
+ {importError ?

{importError}

: null} +
+ +
+ + +
+
+
+ ); +} + +// ── Shared bits ────────────────────────────────────────────── + +function StepShell({ + title, + description, + onBack, + backDisabled = false, + children, +}: { + readonly title: string; + readonly description?: string; + readonly onBack?: () => void; + readonly backDisabled?: boolean; + readonly children?: React.ReactNode; +}) { + return ( + <> + {onBack ? ( + + ) : null} +

{title}

+ {description ? ( +

{description}

+ ) : null} + {children} + + ); +} + +function CommandBlock({ + command, + className, + prominent = false, +}: { + readonly command: string; + readonly className?: string; + readonly prominent?: boolean; +}) { + const { copyToClipboard, isCopied } = useCopyToClipboard({ + timeout: 1500, + target: "command", + }); + return ( +
+ + $ + {command} + + +
+ ); +} + +function formatSource(source: "claudeAgent" | "codex"): string { + return source === "claudeAgent" ? "Claude" : "Codex"; +} diff --git a/apps/web/src/components/preview/PreviewAutomationHosts.test.tsx b/apps/web/src/components/preview/PreviewAutomationHosts.test.tsx new file mode 100644 index 000000000000..75ed20cc4fe7 --- /dev/null +++ b/apps/web/src/components/preview/PreviewAutomationHosts.test.tsx @@ -0,0 +1,190 @@ +import { + DEFAULT_CLIENT_SETTINGS, + EnvironmentId, + ThreadId, + type ClientSettings, + type PreviewAutomationResponse, + type PreviewAutomationStreamEvent, + type PreviewOpenInput, + type PreviewSessionSnapshot, +} from "@t3tools/contracts"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; +import { act } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { __resetClientSettingsPersistenceForTests } from "~/hooks/useSettings"; +import { readThreadPreviewState, resetPreviewStateForTests } from "~/previewStateStore"; +import { appAtomRegistry, AppAtomRegistryProvider } from "~/rpc/atomRegistry"; + +import { PreviewAutomationHosts } from "./PreviewAutomationHosts"; + +const mocks = vi.hoisted(() => ({ + getClientSettings: vi.fn<() => Promise>(), + setClientSettings: vi.fn(), + open: vi.fn(async (_target: { environmentId: EnvironmentId; input: PreviewOpenInput }) => + AsyncResult.success(snapshot), + ), + list: vi.fn(async () => AsyncResult.success(emptyList)), + resize: vi.fn(), + respond: + vi.fn< + (target: { environmentId: EnvironmentId; input: PreviewAutomationResponse }) => Promise + >(), + focus: vi.fn(async () => undefined), +})); + +vi.mock("~/localApi", () => ({ + ensureLocalApi: () => ({ persistence: mocks }), +})); +vi.mock("~/env", () => ({ isElectron: true })); +vi.mock("~/state/environments", () => ({ + useEnvironments: () => ({ environments: [{ environmentId }] }), +})); +vi.mock("~/state/preview", () => ({ + previewEnvironment: { + automationRequests: () => requestsAtom, + list: () => listAtom, + open: mocks.open, + resize: mocks.resize, + respondToAutomation: mocks.respond, + focusAutomationHost: mocks.focus, + }, +})); +vi.mock("~/state/use-atom-command", () => ({ + useAtomCommand: (command: unknown) => command, +})); +vi.mock("~/state/use-atom-query-runner", () => ({ + useAtomQueryRunner: () => mocks.list, +})); +vi.mock("./previewBridge", () => ({ previewBridge: { automation: {} } })); + +const environmentId = EnvironmentId.make("automation-environment"); +const threadId = ThreadId.make("automation-thread"); +const threadRef = { environmentId, threadId }; +const viewport = { _tag: "freeform", width: 1440, height: 900 } as const; +const savedSettings: ClientSettings = { + ...DEFAULT_CLIENT_SETTINGS, + browserDefaultViewport: viewport, + browserDefaultProfileId: "work", + browserProfiles: [{ id: "work", name: "Work", kind: "persistent" }], +}; +const snapshot: PreviewSessionSnapshot = { + threadId, + tabId: "automation-tab", + navStatus: { _tag: "Idle" }, + canGoBack: false, + canGoForward: false, + viewport, + profileId: "work", + updatedAt: "2026-09-05T00:00:00.000Z", +}; +const emptyList = { sessions: [], serverEpoch: "test-server", revision: 0 }; +const listAtom = Atom.make(AsyncResult.success(emptyList)); +const requestsAtom = Atom.make>( + AsyncResult.initial(false), +); +const requestEvent: PreviewAutomationStreamEvent = { + type: "request", + connectionId: "automation-connection", + request: { + requestId: "open-request", + threadId, + operation: "open", + input: { open: false, reuseExistingTab: false }, + timeoutMs: 15_000, + }, +}; + +function deferred
() { + let resolve!: (value: A) => void; + const promise = new Promise((complete) => { + resolve = complete; + }); + return { promise, resolve }; +} + +let renderer: ReactTestRenderer | null = null; + +beforeEach(async () => { + vi.clearAllMocks(); + mocks.getClientSettings.mockReset().mockResolvedValue(savedSettings); + mocks.respond.mockReset(); + __resetClientSettingsPersistenceForTests(); + resetPreviewStateForTests(); + appAtomRegistry.set(requestsAtom, AsyncResult.initial(false)); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("window", { addEventListener: vi.fn(), removeEventListener: vi.fn() }); + vi.stubGlobal("document", { hasFocus: () => false, querySelectorAll: () => [] }); + await act(() => { + renderer = create( + + + , + ); + }); +}); + +afterEach(async () => { + await act(() => renderer?.unmount()); + renderer = null; + resetPreviewStateForTests(); + __resetClientSettingsPersistenceForTests(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe("PreviewAutomationHosts open", () => { + it("waits for saved settings before opening a tab with the configured profile and viewport", async () => { + const readStarted = deferred(); + const read = deferred(); + const response = deferred(); + mocks.getClientSettings.mockImplementationOnce(() => { + readStarted.resolve(); + return read.promise; + }); + mocks.respond.mockImplementationOnce(async ({ input }) => response.resolve(input)); + + await act(async () => { + appAtomRegistry.set(requestsAtom, AsyncResult.success(requestEvent)); + await readStarted.promise; + }); + expect(mocks.open).not.toHaveBeenCalled(); + + await act(async () => { + read.resolve(savedSettings); + await response.promise; + }); + + expect(mocks.open).toHaveBeenCalledExactlyOnceWith({ + environmentId, + input: { threadId, viewport, profileId: "work" }, + }); + expect(mocks.getClientSettings).toHaveBeenCalledOnce(); + await expect(response.promise).resolves.toMatchObject({ requestId: "open-request", ok: true }); + expect(readThreadPreviewState(threadRef).snapshot).toEqual(snapshot); + expect(mocks.setClientSettings).not.toHaveBeenCalled(); + }); + + it("reports a settings read failure without opening a tab", async () => { + vi.spyOn(console, "error").mockImplementation(() => undefined); + mocks.getClientSettings.mockRejectedValueOnce(new Error("Settings read failed")); + const response = deferred(); + mocks.respond.mockImplementationOnce(async ({ input }) => response.resolve(input)); + + await act(async () => { + appAtomRegistry.set(requestsAtom, AsyncResult.success(requestEvent)); + await response.promise; + }); + + await expect(response.promise).resolves.toMatchObject({ + requestId: "open-request", + ok: false, + error: { _tag: "PreviewAutomationExecutionError" }, + }); + expect(mocks.getClientSettings).toHaveBeenCalledOnce(); + expect(mocks.open).not.toHaveBeenCalled(); + expect(readThreadPreviewState(threadRef).snapshot).toBeNull(); + expect(mocks.setClientSettings).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/components/preview/PreviewAutomationHosts.tsx b/apps/web/src/components/preview/PreviewAutomationHosts.tsx index 85df89ccd80d..8c8d510c64cf 100644 --- a/apps/web/src/components/preview/PreviewAutomationHosts.tsx +++ b/apps/web/src/components/preview/PreviewAutomationHosts.tsx @@ -41,7 +41,11 @@ import { acquireBrowserSurfaceActivity, useBrowserSurfaceStore, } from "~/browser/browserSurfaceStore"; -import { browserDefaultOpenViewport, resolveBrowserDefaults } from "~/browser/browserDefaults"; +import { + browserDefaultOpenProfileId, + browserDefaultOpenViewport, + resolveBrowserDefaults, +} from "~/browser/browserDefaults"; import { runBrowserViewportMutation } from "~/browser/browserViewportActions"; import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId"; import { isElectron } from "~/env"; @@ -422,6 +426,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) const reusedExistingTab = activeTabId !== null; tabId = activeTabId; if (!activeTabId) { + const defaults = await resolveBrowserDefaults(); const result = await open({ environmentId, input: { @@ -429,7 +434,8 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) ...(resolvedInputUrl ? { url: resolvedInputUrl } : {}), // An agent that didn't state a size gets the user's // configured default, same as a hand-opened tab. - viewport: browserDefaultOpenViewport(await resolveBrowserDefaults()), + viewport: browserDefaultOpenViewport(defaults), + profileId: browserDefaultOpenProfileId(defaults), }, }); if (result._tag === "Failure") { diff --git a/apps/web/src/components/preview/PreviewView.test.tsx b/apps/web/src/components/preview/PreviewView.test.tsx index 9f2e10d240d7..f8c5b6b44f16 100644 --- a/apps/web/src/components/preview/PreviewView.test.tsx +++ b/apps/web/src/components/preview/PreviewView.test.tsx @@ -261,7 +261,7 @@ vi.mock("~/browser/browserTargetResolver", () => ({ resolveNavigableUrl: mocks.resolveNavigableUrl, })); -import { PreviewView, previewProfileName } from "./PreviewView"; +import { PreviewView } from "./PreviewView"; import { toastManager } from "~/components/ui/toast"; import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId"; @@ -363,12 +363,6 @@ describe("PreviewView navigation", () => { mocks.recordVisitForThread.mockClear(); }); - it("labels a tab whose saved profile was removed", () => { - expect(previewProfileName(BUILT_IN_BROWSER_PROFILES, "profile-removed")).toBe( - "Removed profile", - ); - }); - it("does not rerender while loading time passes", async () => { vi.useFakeTimers(); mocks.loading = true; diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx index 58de8bb17409..308234090710 100644 --- a/apps/web/src/components/preview/PreviewView.tsx +++ b/apps/web/src/components/preview/PreviewView.tsx @@ -1,7 +1,10 @@ "use client"; import { scopedThreadKey } from "@t3tools/client-runtime/environment"; -import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; import { DEFAULT_BROWSER_PROFILE_ID, FILL_PREVIEW_VIEWPORT, @@ -28,7 +31,7 @@ import { useThreadPreviewState, } from "~/previewStateStore"; import { resolveNavigableUrl } from "~/browser/browserTargetResolver"; -import { useEnvironment, useEnvironmentHttpBaseUrl } from "~/state/environments"; +import { useEnvironmentHttpBaseUrl } from "~/state/environments"; import { previewEnvironment } from "~/state/preview"; import { useAtomCommand } from "~/state/use-atom-command"; import { selectThreadPreviewMiniPlayer, usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore"; @@ -46,6 +49,7 @@ import { } from "~/browser/browserViewportActions"; import { browserResponsiveViewportForToggle, useBrowserDefaults } from "~/browser/browserDefaults"; import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId"; +import { BrowserSettingsReadError } from "~/browser/openFileInPreview"; import { PreviewUnreachable } from "./PreviewUnreachable"; import { revealInFileExplorerLabel } from "./fileExplorerLabel"; import { shouldShowPreviewEmptyState } from "./previewEmptyStateLogic"; @@ -76,7 +80,7 @@ interface Props { ) => void; } -export function previewProfileName( +function previewProfileName( profiles: ReadonlyArray<{ readonly id: string; readonly name: string }>, profileId: string, ): string { @@ -186,6 +190,16 @@ export function PreviewView({ return true; } const result = await openPreviewSession({ openPreview: open, threadRef, url: resolvedUrl }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + if (error instanceof BrowserSettingsReadError) { + toastManager.add({ + type: "error", + title: "Unable to open browser", + description: error.message, + }); + } + } return result._tag === "Success"; }, [open, runtimeTabId, threadRef], diff --git a/apps/web/src/components/preview/addBrowserSurface.test.ts b/apps/web/src/components/preview/addBrowserSurface.test.ts index f26cb0fff9e1..d34de83a23b4 100644 --- a/apps/web/src/components/preview/addBrowserSurface.test.ts +++ b/apps/web/src/components/preview/addBrowserSurface.test.ts @@ -1,5 +1,6 @@ import { DEFAULT_BROWSER_PROFILE_ID, + DEFAULT_CLIENT_SETTINGS, FILL_PREVIEW_VIEWPORT, type PreviewOpenInput, type PreviewSessionSnapshot, @@ -14,6 +15,7 @@ import { resetPreviewStateForTests, } from "~/previewStateStore"; import { selectThreadRightPanelState, useRightPanelStore } from "~/rightPanelStore"; +import { __setClientSettingsForTests } from "~/hooks/useSettings"; import { addBrowserSurface } from "./addBrowserSurface"; @@ -32,6 +34,7 @@ const snapshot = (tabId: string): PreviewSessionSnapshot => ({ }); beforeEach(() => { + __setClientSettingsForTests(DEFAULT_CLIENT_SETTINGS); resetPreviewStateForTests(); useRightPanelStore.setState({ byThreadKey: {} }); }); diff --git a/apps/web/src/components/preview/addBrowserSurface.ts b/apps/web/src/components/preview/addBrowserSurface.ts index 622cdbec2f1c..e0cd83501201 100644 --- a/apps/web/src/components/preview/addBrowserSurface.ts +++ b/apps/web/src/components/preview/addBrowserSurface.ts @@ -4,7 +4,7 @@ import { } from "@t3tools/client-runtime/state/runtime"; import type { ScopedThreadRef } from "@t3tools/contracts"; -import type { OpenPreviewMutation } from "~/browser/openFileInPreview"; +import type { BrowserSettingsReadError, OpenPreviewMutation } from "~/browser/openFileInPreview"; import { useRightPanelStore } from "~/rightPanelStore"; import { openPreviewSession } from "./openPreviewSession"; @@ -15,7 +15,7 @@ export async function addBrowserSurface(input: { readonly openPreview: OpenPreviewMutation; /** Omit to use the configured default profile. */ readonly profileId?: string | undefined; -}): Promise> { +}): Promise> { const result = await openPreviewSession({ openPreview: input.openPreview, threadRef: input.threadRef, diff --git a/apps/web/src/components/preview/openDiscoveredPort.ts b/apps/web/src/components/preview/openDiscoveredPort.ts index 94b2e217dd72..288db101e7a5 100644 --- a/apps/web/src/components/preview/openDiscoveredPort.ts +++ b/apps/web/src/components/preview/openDiscoveredPort.ts @@ -4,8 +4,8 @@ import { type AtomCommandResult, } from "@t3tools/client-runtime/state/runtime"; -import { resolveNavigableUrl } from "~/browser/browserTargetResolver"; -import type { OpenPreviewMutation } from "~/browser/openFileInPreview"; +import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver"; +import type { BrowserSettingsReadError, OpenPreviewMutation } from "~/browser/openFileInPreview"; import { recordVisitForThread } from "~/browserHistoryStore"; import { useRightPanelStore } from "~/rightPanelStore"; import { openPreviewSession } from "./openPreviewSession"; @@ -14,11 +14,8 @@ export async function openDiscoveredPort(input: { readonly threadRef: ScopedThreadRef; readonly port: DiscoveredLocalServer; readonly openPreview: OpenPreviewMutation; -}): Promise> { - const resolvedUrl = await resolveNavigableUrl(input.threadRef.environmentId, { - kind: "url", - url: input.port.url, - }); +}): Promise> { + const resolvedUrl = resolveDiscoveredServerUrl(input.threadRef.environmentId, input.port.url); const result = await openPreviewSession({ openPreview: input.openPreview, threadRef: input.threadRef, diff --git a/apps/web/src/components/preview/openPreviewSession.test.ts b/apps/web/src/components/preview/openPreviewSession.test.ts index ef3d51a9e7fa..fe14211280c2 100644 --- a/apps/web/src/components/preview/openPreviewSession.test.ts +++ b/apps/web/src/components/preview/openPreviewSession.test.ts @@ -1,5 +1,6 @@ import { DEFAULT_BROWSER_PROFILE_ID, + DEFAULT_CLIENT_SETTINGS, FILL_PREVIEW_VIEWPORT, type PreviewOpenInput, type PreviewSessionSnapshot, @@ -7,8 +8,11 @@ import { } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/unstable/reactivity"; -import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import * as browserDefaults from "~/browser/browserDefaults"; +import { BrowserSettingsReadError, openUrlInPreview } from "~/browser/openFileInPreview"; +import { __setClientSettingsForTests } from "~/hooks/useSettings"; import { readThreadPreviewState, resetPreviewStateForTests } from "~/previewStateStore"; import { openPreviewSession } from "./openPreviewSession"; @@ -31,7 +35,14 @@ const snapshot: PreviewSessionSnapshot = { updatedAt: "2026-06-11T23:00:00.000Z", }; -beforeEach(resetPreviewStateForTests); +beforeEach(() => { + resetPreviewStateForTests(); + __setClientSettingsForTests(DEFAULT_CLIENT_SETTINGS); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); describe("openPreviewSession", () => { it("creates an idle tab without recording a recently visited URL", async () => { @@ -88,4 +99,44 @@ describe("openPreviewSession", () => { expect(readThreadPreviewState(threadRef).snapshot).toBeNull(); expect(readThreadPreviewState(threadRef).recentlySeenUrls).toEqual([]); }); + + it.each(["session", "link"] as const)( + "does not open a %s with unread settings and uses the saved profile on retry", + async (entryPoint) => { + const failure = new Error("Settings read failed"); + vi.spyOn(browserDefaults, "resolveBrowserDefaults").mockRejectedValueOnce(failure); + const viewport = { _tag: "freeform", width: 1280, height: 720 } as const; + __setClientSettingsForTests({ + ...DEFAULT_CLIENT_SETTINGS, + browserDefaultViewport: viewport, + browserDefaultProfileId: "work", + browserProfiles: [{ id: "work", name: "Work", kind: "persistent" }], + }); + const openPreview = vi.fn(async () => AsyncResult.success(snapshot)); + const input = { openPreview, threadRef, url: "https://t3.chat/" }; + const open = entryPoint === "session" ? openPreviewSession : openUrlInPreview; + + const result = await open(input); + + expect(result._tag).toBe("Failure"); + if (result._tag === "Failure") { + expect(Cause.squash(result.cause)).toBeInstanceOf(BrowserSettingsReadError); + expect(Cause.squash(result.cause)).toMatchObject({ cause: failure }); + } + expect(openPreview).not.toHaveBeenCalled(); + expect(readThreadPreviewState(threadRef).snapshot).toBeNull(); + expect(readThreadPreviewState(threadRef).recentlySeenUrls).toEqual([]); + + await expect(open(input)).resolves.toMatchObject({ _tag: "Success" }); + expect(openPreview).toHaveBeenCalledExactlyOnceWith({ + environmentId: threadRef.environmentId, + input: { + threadId: threadRef.threadId, + url: input.url, + viewport, + profileId: "work", + }, + }); + }, + ); }); diff --git a/apps/web/src/components/preview/openPreviewSession.ts b/apps/web/src/components/preview/openPreviewSession.ts index deb5465ebc28..07dab9a0b36d 100644 --- a/apps/web/src/components/preview/openPreviewSession.ts +++ b/apps/web/src/components/preview/openPreviewSession.ts @@ -6,12 +6,15 @@ import type { ScopedThreadRef, } from "@t3tools/contracts"; import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; +import * as Cause from "effect/Cause"; +import { AsyncResult } from "effect/unstable/reactivity"; import { browserDefaultOpenProfileId, browserDefaultOpenViewport, resolveBrowserDefaults, } from "~/browser/browserDefaults"; +import { BrowserSettingsReadError } from "~/browser/openFileInPreview"; import { applyPreviewServerSnapshot, rememberPreviewUrl } from "~/previewStateStore"; interface OpenPreviewSessionInput { @@ -29,10 +32,15 @@ interface OpenPreviewSessionInput { export async function openPreviewSession( input: OpenPreviewSessionInput, -): Promise> { +): Promise> { // Resolved once: a tab opened before client settings hydrate would otherwise // be born at the schema defaults and never corrected. - const defaults = await resolveBrowserDefaults(); + const defaults = await resolveBrowserDefaults().catch( + (cause: unknown) => new BrowserSettingsReadError({ cause }), + ); + if (defaults instanceof BrowserSettingsReadError) { + return AsyncResult.failure(Cause.fail(defaults)); + } const result = await input.openPreview({ environmentId: input.threadRef.environmentId, input: { diff --git a/apps/web/src/components/preview/openTerminalLinkInPreview.test.ts b/apps/web/src/components/preview/openTerminalLinkInPreview.test.ts index 46dd33f7beb4..2ce81cb06af2 100644 --- a/apps/web/src/components/preview/openTerminalLinkInPreview.test.ts +++ b/apps/web/src/components/preview/openTerminalLinkInPreview.test.ts @@ -68,6 +68,33 @@ afterEach(() => { }); describe("openTerminalLinkInPreview", () => { + it.each(["target", "defaults"] as const)( + "does not open either browser when reading %s fails", + async (setting) => { + const failure = new Error("Settings read failed"); + if (setting === "target") { + linkTargetMocks.preference.mockImplementationOnce(() => { + throw failure; + }); + } else { + browserDefaultsMocks.resolve.mockRejectedValueOnce(failure); + } + const fallbackToBrowser = vi.fn(); + const openPreview = vi.fn(async () => AsyncResult.success(snapshot)); + + await expect( + openTerminalLinkInPreview({ + url: "https://example.com/docs", + threadRef, + openPreview, + fallbackToBrowser, + }), + ).rejects.toBe(failure); + expect(fallbackToBrowser).not.toHaveBeenCalled(); + expect(openPreview).not.toHaveBeenCalled(); + }, + ); + it("opens in the system browser while that is the configured target", async () => { linkTargetMocks.preference.mockReturnValue("system"); const fallbackToBrowser = vi.fn(); diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts index c9f37042108c..f5ddfcc38be3 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts @@ -39,7 +39,6 @@ import { shouldRefreshPullRequestActivity, resolveBaseFreshness, buildPullRequestTimeline, - describePullRequestState, editPullRequestThreadComment, writePullRequestDetailSnapshot, } from "./pullRequestDetail.logic"; @@ -200,15 +199,6 @@ describe("pull request primary control", () => { }); }); -describe("pull request state description", () => { - it("keeps draft and conflicts orthogonal to the terminal states", () => { - expect(describePullRequestState("open", true)).toBe("Draft"); - expect(describePullRequestState("open", false)).toBe("Ready for review"); - expect(describePullRequestState("merged", true)).toBe("Merged"); - expect(describePullRequestState("closed", false)).toBe("Closed"); - }); -}); - describe("pull request handoff labels", () => { it("names the open thread when actions write to its composer", () => { expect(pullRequestHandoffLabels(true)).toEqual({ diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts index aa0a3816957a..725f26251a24 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts @@ -199,13 +199,6 @@ export function isStackedPullRequestBase( return defaultBranch !== baseBranch; } -/** Plain-language state, shown beside the author. Conflicts are a merge signal, not a state. */ -export function describePullRequestState(state: PullRequestState, isDraft: boolean): string { - if (state === "merged") return "Merged"; - if (state === "closed") return "Closed"; - return isDraft ? "Draft" : "Ready for review"; -} - /** Chronological ascending, oldest to newest — reversed for the "newest" reading order. */ export function orderPullRequestComments( comments: ReadonlyArray, diff --git a/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.test.ts b/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.test.ts index db105f97fe95..eb6f47b4c803 100644 --- a/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.test.ts @@ -1,15 +1,8 @@ import { describe, expect, it } from "vite-plus/test"; -import { openOnHostLabel, pullRequestLinkContextMenuItems } from "./pullRequestLinkContextMenu"; +import { openOnHostLabel } from "./pullRequestLinkContextMenu"; describe("pull request link context menu", () => { - it("offers the copy first and the host's own page after it", () => { - expect(pullRequestLinkContextMenuItems("Open on GitHub")).toEqual([ - { id: "copy-link", label: "Copy link", icon: "copy" }, - { id: "open-external", label: "Open on GitHub" }, - ]); - }); - it("names every host it knows, and says nothing false about one it does not", () => { expect(openOnHostLabel("github")).toBe("Open on GitHub"); expect(openOnHostLabel("gitlab")).toBe("Open on GitLab"); diff --git a/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.ts b/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.ts index 16b749445d4c..ef554d0eb3ee 100644 --- a/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.ts +++ b/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.ts @@ -19,7 +19,7 @@ export const openOnHostLabel = (provider: string): string => OPEN_ON_HOST_LABELS[provider] ?? "Open on host"; /** Copy first: it is the reason to right-click a number rather than click it. */ -export function pullRequestLinkContextMenuItems( +function pullRequestLinkContextMenuItems( openLabel: string, ): readonly ContextMenuItem[] { return [ diff --git a/apps/web/src/components/settings/providerStatus.test.ts b/apps/web/src/components/settings/providerStatus.test.ts new file mode 100644 index 000000000000..46dc7e262512 --- /dev/null +++ b/apps/web/src/components/settings/providerStatus.test.ts @@ -0,0 +1,71 @@ +import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { getProviderSummary } from "./providerStatus"; + +const provider: ServerProvider = { + instanceId: ProviderInstanceId.make("codex"), + driver: ProviderDriverKind.make("codex"), + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { status: "authenticated", label: "ChatGPT" }, + checkedAt: "2026-08-23T00:00:00.000Z", + models: [], + slashCommands: [], + skills: [], +}; + +describe("getProviderSummary", () => { + it("reports ready providers with unknown authentication as available", () => { + expect(getProviderSummary({ ...provider, auth: { status: "unknown" } })).toEqual({ + headline: "Available", + detail: null, + }); + }); + + it("does not hide a provider error behind a previous authenticated state", () => { + expect( + getProviderSummary({ + ...provider, + status: "error", + message: "The provider process failed to start.", + }), + ).toEqual({ + headline: "Unavailable", + detail: "The provider process failed to start.", + }); + }); + + it("does not hide a provider warning behind an authenticated state", () => { + expect( + getProviderSummary({ + ...provider, + status: "warning", + message: "The provider version is unsupported.", + }), + ).toEqual({ + headline: "Needs attention", + detail: "The provider version is unsupported.", + }); + }); + + it("keeps authentication failures actionable when their provider status is error", () => { + expect( + getProviderSummary({ + ...provider, + status: "error", + auth: { status: "unauthenticated" }, + message: "Run codex login.", + }), + ).toEqual({ + headline: "Not authenticated", + detail: "Run codex login.", + }); + }); + + it("treats a disabled provider status as disabled even before its enabled flag updates", () => { + expect(getProviderSummary({ ...provider, status: "disabled" }).headline).toBe("Disabled"); + }); +}); diff --git a/apps/web/src/components/settings/providerStatus.ts b/apps/web/src/components/settings/providerStatus.ts index 0f39f643f5ce..90c618f5daa7 100644 --- a/apps/web/src/components/settings/providerStatus.ts +++ b/apps/web/src/components/settings/providerStatus.ts @@ -26,7 +26,8 @@ export type ProviderStatusKey = keyof typeof PROVIDER_STATUS_STYLES; * settings page. Prefers `provider.message` for server-supplied detail and * falls back to generic phrasing when the server has not yet reported any * state — which happens before the first probe or when an instance names a - * driver this build does not ship. + * driver this build does not ship. A ready provider without account metadata + * remains available and does not imply an authentication failure. */ export function getProviderSummary(provider: ServerProvider | undefined) { if (!provider) { @@ -35,7 +36,7 @@ export function getProviderSummary(provider: ServerProvider | undefined) { detail: "Waiting for the server to report installation and authentication details.", }; } - if (!provider.enabled) { + if (!provider.enabled || provider.status === "disabled") { return { headline: "Disabled", detail: @@ -48,13 +49,6 @@ export function getProviderSummary(provider: ServerProvider | undefined) { detail: provider.message ?? "CLI not detected on PATH.", }; } - if (provider.auth.status === "authenticated") { - const authLabel = provider.auth.label ?? provider.auth.type; - return { - headline: authLabel ? `Authenticated · ${authLabel}` : "Authenticated", - detail: provider.message ?? null, - }; - } if (provider.auth.status === "unauthenticated") { return { headline: "Not authenticated", @@ -74,9 +68,16 @@ export function getProviderSummary(provider: ServerProvider | undefined) { detail: provider.message ?? "The provider failed its startup checks.", }; } + if (provider.auth.status === "authenticated") { + const authLabel = provider.auth.label ?? provider.auth.type; + return { + headline: authLabel ? `Authenticated · ${authLabel}` : "Authenticated", + detail: provider.message ?? null, + }; + } return { headline: "Available", - detail: provider.message ?? "Installed and ready, but authentication could not be verified.", + detail: provider.message ?? null, }; } diff --git a/apps/web/src/components/ui/sidebar.test.tsx b/apps/web/src/components/ui/sidebar.test.tsx index e2d29d607e13..784c5e087963 100644 --- a/apps/web/src/components/ui/sidebar.test.tsx +++ b/apps/web/src/components/ui/sidebar.test.tsx @@ -2,7 +2,6 @@ import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vite-plus/test"; import { - SidebarMenuAction, SidebarMenuButton, SidebarMenuSubButton, SidebarProvider, @@ -89,17 +88,6 @@ describe("sidebar interactive cursors", () => { expect(html).not.toContain("cursor-pointer"); }); - it("uses a pointer cursor for menu actions", () => { - const html = renderToStaticMarkup( - - + - , - ); - - expect(html).toContain('data-slot="sidebar-menu-action"'); - expect(html).toContain("cursor-pointer"); - }); - it("uses a pointer cursor for submenu buttons", () => { const html = renderToStaticMarkup( }>Show more, diff --git a/apps/web/src/components/ui/sidebar.tsx b/apps/web/src/components/ui/sidebar.tsx index 624798e19f54..22cb4808fadd 100644 --- a/apps/web/src/components/ui/sidebar.tsx +++ b/apps/web/src/components/ui/sidebar.tsx @@ -800,7 +800,7 @@ function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) { } const sidebarMenuButtonVariants = cva( - "peer/menu-button flex w-full cursor-pointer items-center gap-[var(--sidebar-control-gap)] overflow-hidden text-left outline-hidden ring-ring transition-[width,height,padding] hover:bg-sidebar-row-hover hover:text-sidebar-foreground focus-visible:ring-2 active:bg-sidebar-row-active active:text-sidebar-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pe-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-row-selected data-[active=true]:font-medium data-[active=true]:text-sidebar-foreground data-[state=open]:hover:bg-sidebar-row-hover data-[state=open]:hover:text-sidebar-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-[var(--sidebar-content-inset)]! [&>span:last-child]:truncate [&>svg:not([class*='size-'])]:size-4 [&>svg]:shrink-0 [&>svg]:text-[var(--sidebar-icon-color)] hover:[&>svg]:text-sidebar-foreground active:[&>svg]:text-sidebar-foreground data-[active=true]:[&>svg]:text-sidebar-foreground", + "peer/menu-button flex w-full cursor-pointer items-center gap-[var(--sidebar-control-gap)] overflow-hidden text-left outline-hidden ring-ring transition-[width,height,padding] hover:bg-sidebar-row-hover hover:text-sidebar-foreground focus-visible:ring-2 active:bg-sidebar-row-active active:text-sidebar-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-row-selected data-[active=true]:font-medium data-[active=true]:text-sidebar-foreground data-[state=open]:hover:bg-sidebar-row-hover data-[state=open]:hover:text-sidebar-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-[var(--sidebar-content-inset)]! [&>span:last-child]:truncate [&>svg:not([class*='size-'])]:size-4 [&>svg]:shrink-0 [&>svg]:text-[var(--sidebar-icon-color)] hover:[&>svg]:text-sidebar-foreground active:[&>svg]:text-sidebar-foreground data-[active=true]:[&>svg]:text-sidebar-foreground", { defaultVariants: { size: "default", @@ -875,38 +875,6 @@ function SidebarMenuButton({ ); } -function SidebarMenuAction({ - className, - showOnHover = false, - render, - ...props -}: useRender.ComponentProps<"button"> & { - showOnHover?: boolean; -}) { - const defaultProps = { - className: cn( - "absolute top-1.5 right-1 flex aspect-square w-5 cursor-pointer items-center justify-center rounded-lg p-0 text-sidebar-foreground outline-hidden ring-ring transition-transform hover:bg-sidebar-row-hover hover:text-sidebar-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-foreground [&>svg:not([class*='size-'])]:size-4 [&>svg]:shrink-0", - // Increases the hit area of the button on mobile. - "after:-inset-2 after:absolute md:after:hidden", - "peer-data-[size=sm]/menu-button:top-1", - "peer-data-[size=default]/menu-button:top-1.5", - "peer-data-[size=lg]/menu-button:top-2.5", - "group-data-[collapsible=icon]:hidden", - showOnHover && - "group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-foreground md:opacity-0", - className, - ), - "data-sidebar": "menu-action", - "data-slot": "sidebar-menu-action", - }; - - return useRender({ - defaultTagName: "button", - props: mergeProps<"button">(defaultProps, props), - render, - }); -} - function SidebarMenuBadge({ className, ...props }: React.ComponentProps<"div">) { return (
{ @@ -42,7 +42,7 @@ describe("niceScale", () => { }); }); -describe("buildDayColumns", () => { +describe("buildPeriodColumns", () => { const days = ["2026-08-01", "2026-08-02", "2026-08-03"]; const byDay = new Map([ [ @@ -70,11 +70,13 @@ describe("buildDayColumns", () => { ]); it("plots each day on its own", () => { - expect(buildDayColumns(days, byDay, "cost").map((column) => column.total)).toEqual([30, 0, 5]); + expect(buildPeriodColumns(days, byDay, "cost").map((column) => column.total)).toEqual([ + 30, 0, 5, + ]); }); it("reads the requested metric", () => { - expect(buildDayColumns(days, byDay, "tokens").map((column) => column.total)).toEqual([ + expect(buildPeriodColumns(days, byDay, "tokens").map((column) => column.total)).toEqual([ 300, 0, 50, ]); }); @@ -82,7 +84,7 @@ describe("buildDayColumns", () => { it("keeps band values absolute rather than cumulative", () => { // Regression: the bands were once stack offsets, which drew Claude Code // permanently above Codex regardless of which provider spent more. - const [first] = buildDayColumns(days, byDay, "cost"); + const [first] = buildPeriodColumns(days, byDay, "cost"); // Only the providers this fixture has data for; asserting the full band // list would re-break every time a provider joins PROVIDER_ORDER, which @@ -97,7 +99,7 @@ describe("buildDayColumns", () => { }); it("reports the total as the sum of its bands", () => { - for (const column of buildDayColumns(days, byDay, "cost")) { + for (const column of buildPeriodColumns(days, byDay, "cost")) { const sum = column.bands.reduce((running, band) => running + band.value, 0); expect(column.total).toBeCloseTo(sum, 9); } @@ -131,7 +133,7 @@ describe("hourly chart columns", () => { ]); expect( - buildDayColumns( + buildPeriodColumns( ["2026-08-11T08:37:00.000Z", "2026-08-11T09:37:00.000Z", "2026-08-11T10:37:00.000Z"], byHour, "cost", diff --git a/apps/web/src/components/usage/UsageProviderChart.tsx b/apps/web/src/components/usage/UsageProviderChart.tsx index 26d49e664804..4a66349ddfa5 100644 --- a/apps/web/src/components/usage/UsageProviderChart.tsx +++ b/apps/web/src/components/usage/UsageProviderChart.tsx @@ -54,7 +54,7 @@ function valueFor( return metric === "tokens" ? entry.totalTokens : entry.costUsd; } -function buildPeriodColumns( +export function buildPeriodColumns( periods: readonly string[], byPeriod: ReadonlyMap, metric: UsageChartMetric, @@ -169,24 +169,6 @@ export function niceScale(peak: number, count: number): { max: number; ticks: re return { max, ticks }; } -/** - * Turns the merged daily totals into one column per day. - * - * Values are absolute, not cumulative: each provider is drawn from the same - * zero baseline so the chart never implies that one provider is always larger. - * - * The chart paths and the hover readout both consume this, so the number under - * the cursor is by construction the number that was plotted rather than a - * second derivation that can drift from it. - */ -export function buildDayColumns( - days: readonly string[], - byDay: ReadonlyMap, - metric: UsageChartMetric, -): readonly DayColumn[] { - return buildPeriodColumns(days, byDay, metric); -} - export function UsageProviderChart({ providers, days, diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index 124e1b0800d2..546ede9db72c 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -68,10 +68,7 @@ import { clearComposerDraftsEnvironment, composerDraftHasUserContent, finalizePromotedDraftThreadByRef, - markPromotedDraftThread, markPromotedDraftThreadByRef, - markPromotedDraftThreads, - markPromotedDraftThreadsByRef, type ComposerFileAttachment, type ComposerImageAttachment, composerFileNeedsReattach, @@ -1212,7 +1209,7 @@ describe("composerDraftStore project draft thread mapping", () => { interactionMode: "plan", }); store.setPrompt(draftId, "keep this prompt"); - markPromotedDraftThread(threadId); + markPromotedDraftThreadByRef(scopeThreadRef(TEST_ENVIRONMENT_ID, threadId)); store.setLogicalProjectDraftThreadId(scopedProjectKey(projectRef), projectRef, draftId, { threadId: retryThreadId, @@ -1375,12 +1372,12 @@ describe("composerDraftStore project draft thread mapping", () => { expect(draftByKey(draftId)).toBeUndefined(); }); - it("marks a promoted draft by thread id without deleting composer state", () => { + it("marks a promoted draft by scoped ref without deleting composer state", () => { const store = useComposerDraftStore.getState(); store.setProjectDraftThreadId(projectRef, draftId, { threadId }); store.setPrompt(draftId, "promote me"); - markPromotedDraftThread(threadId); + markPromotedDraftThreadByRef(scopeThreadRef(TEST_ENVIRONMENT_ID, threadId)); expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)).toBeNull(); expect(useComposerDraftStore.getState().getDraftThread(draftId)?.promotedTo).toEqual( @@ -1405,20 +1402,20 @@ describe("composerDraftStore project draft thread mapping", () => { const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); store.setPrompt(threadRef, "keep me"); - markPromotedDraftThread(threadId); + markPromotedDraftThreadByRef(scopeThreadRef(TEST_ENVIRONMENT_ID, threadId)); expect(useComposerDraftStore.getState().getDraftThread(threadRef)).toBeNull(); expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.prompt).toBe("keep me"); }); - it("marks promoted drafts from an iterable of server thread ids", () => { + it("promotes a draft without changing another thread's draft", () => { const store = useComposerDraftStore.getState(); store.setProjectDraftThreadId(projectRef, draftId, { threadId }); store.setPrompt(draftId, "promote me"); store.setProjectDraftThreadId(otherProjectRef, otherDraftId, { threadId: otherThreadId }); store.setPrompt(otherDraftId, "keep me"); - markPromotedDraftThreads([threadId]); + markPromotedDraftThreadByRef(scopeThreadRef(TEST_ENVIRONMENT_ID, threadId)); expect(useComposerDraftStore.getState().getDraftThread(draftId)?.promotedTo).toEqual( scopeThreadRef(TEST_ENVIRONMENT_ID, threadId), @@ -1430,7 +1427,7 @@ describe("composerDraftStore project draft thread mapping", () => { expect(draftByKey(otherDraftId)?.prompt).toBe("keep me"); }); - it("marks every matching scoped draft when multiple environments share a thread id", () => { + it("promotes matching thread ids separately for each environment", () => { const store = useComposerDraftStore.getState(); const localThreadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); const remoteThreadRef = scopeThreadRef(OTHER_TEST_ENVIRONMENT_ID, threadId); @@ -1440,7 +1437,16 @@ describe("composerDraftStore project draft thread mapping", () => { store.setProjectDraftThreadId(remoteProjectRef, remoteDraftId, { threadId }); store.setPrompt(remoteDraftId, "remote draft"); - markPromotedDraftThread(threadId); + markPromotedDraftThreadByRef(localThreadRef); + + expect(store.getDraftThreadByProjectRef(projectRef)).toBeNull(); + expect(store.getDraftThreadByProjectRef(remoteProjectRef)?.threadId).toBe(threadId); + expect(store.getDraftThreadByRef(localThreadRef)?.promotedTo).toEqual(localThreadRef); + expect(store.getDraftThreadByRef(remoteThreadRef)?.promotedTo).toBeNull(); + expect(draftByKey(localDraftId)?.prompt).toBe("local draft"); + expect(draftByKey(remoteDraftId)?.prompt).toBe("remote draft"); + + markPromotedDraftThreadByRef(remoteThreadRef); expect(store.getDraftThreadByProjectRef(projectRef)).toBeNull(); expect(store.getDraftThreadByProjectRef(remoteProjectRef)).toBeNull(); @@ -1463,34 +1469,10 @@ describe("composerDraftStore project draft thread mapping", () => { 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(); - const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); - store.setPrompt(threadRef, "keep me"); - - markPromotedDraftThreads([threadId]); - - expect(useComposerDraftStore.getState().getDraftThread(threadRef)).toBeNull(); - expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.prompt).toBe("keep me"); - }); - it("moves composer edits made during promotion to the canonical thread", () => { const store = useComposerDraftStore.getState(); store.setProjectDraftThreadId(projectRef, draftId, { threadId }); - markPromotedDraftThread(threadId); + markPromotedDraftThreadByRef(scopeThreadRef(TEST_ENVIRONMENT_ID, threadId)); store.setPrompt(draftId, "typed during setup"); finalizePromotedDraftThreadByRef(scopeThreadRef(TEST_ENVIRONMENT_ID, threadId)); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 885f1e534150..721c7d938431 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -4156,29 +4156,6 @@ export function useEffectiveComposerModelState(input: { ); } -/** - * 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 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.make(draftId)); - } - } - if (draftThreadTargets.length === 0) { - return; - } - 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)) { @@ -4191,18 +4168,6 @@ export function markPromotedDraftThreadByRef(threadRef: ScopedThreadRef): void { } } -export function markPromotedDraftThreads(serverThreadIds: Iterable): void { - for (const threadId of serverThreadIds) { - 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)) { diff --git a/apps/web/src/environments/primary/auth.ts b/apps/web/src/environments/primary/auth.ts index fe0345e41524..d76002a93298 100644 --- a/apps/web/src/environments/primary/auth.ts +++ b/apps/web/src/environments/primary/auth.ts @@ -353,6 +353,8 @@ export async function submitServerAuthCredential(credential: string): Promise { } }); - it("preserves decode failure context", async () => { + it("retries when access to browser storage becomes available", async () => { + const storage = createStorage(); + storage.setItem("read-key", JSON.stringify("saved value")); + let blocked = true; + vi.stubGlobal("window", { + get localStorage() { + if (blocked) throw new Error("storage unavailable"); + return storage; + }, + }); + const { getLocalStorageItem, LocalStorageOperationError } = await import("./useLocalStorage"); + + expect(() => getLocalStorageItem("read-key", Schema.String)).toThrow( + LocalStorageOperationError, + ); + blocked = false; + expect(getLocalStorageItem("read-key", Schema.String)).toBe("saved value"); + }); + + it.each(["", "not-json"])("preserves decode failure context for %j", async (value) => { const { getLocalStorageItem, LocalStorageOperationError } = await loadWithStorage( - createStorage({ getItem: () => "not-json" }), + createStorage({ getItem: () => value }), ); try { diff --git a/apps/web/src/hooks/useLocalStorage.ts b/apps/web/src/hooks/useLocalStorage.ts index 0577f51aba52..9bad8cb2d0b2 100644 --- a/apps/web/src/hooks/useLocalStorage.ts +++ b/apps/web/src/hooks/useLocalStorage.ts @@ -15,26 +15,26 @@ export class LocalStorageOperationError extends Schema.TaggedErrorClass(); - return { - clear: () => store.clear(), - getItem: (_) => store.get(_) ?? null, - key: (_) => Record.keys(store).at(_) ?? null, - get length() { - return store.size; - }, - removeItem: (_) => store.delete(_), - setItem: (_, value) => store.set(_, value), - }; - })(); +const fallbackStorage: Storage = (() => { + const store = new Map(); + return { + clear: () => store.clear(), + getItem: (_) => store.get(_) ?? null, + key: (_) => Record.keys(store).at(_) ?? null, + get length() { + return store.size; + }, + removeItem: (_) => store.delete(_), + setItem: (_, value) => store.set(_, value), + }; +})(); + +const getStorage = (): Storage => + typeof window !== "undefined" ? window.localStorage : fallbackStorage; const read = (key: string) => { try { - return isomorphicLocalStorage.getItem(key); + return getStorage().getItem(key); } catch (cause) { throw new LocalStorageOperationError({ operation: "read", storageKey: key, cause }); } @@ -58,13 +58,13 @@ const encode = (key: string, schema: Schema.Codec, value: T) => { export const getLocalStorageItem = (key: string, schema: Schema.Codec): T | null => { const item = read(key); - return item ? decode(key, schema, item) : null; + return item === null ? null : decode(key, schema, item); }; export const setLocalStorageItem = (key: string, value: T, schema: Schema.Codec) => { const valueToSet = encode(key, schema, value); try { - isomorphicLocalStorage.setItem(key, valueToSet); + getStorage().setItem(key, valueToSet); } catch (cause) { throw new LocalStorageOperationError({ operation: "write", storageKey: key, cause }); } @@ -72,7 +72,7 @@ export const setLocalStorageItem = (key: string, value: T, schema: Schema. export const removeLocalStorageItem = (key: string) => { try { - isomorphicLocalStorage.removeItem(key); + getStorage().removeItem(key); } catch (cause) { throw new LocalStorageOperationError({ operation: "remove", storageKey: key, cause }); } diff --git a/apps/web/src/hooks/useSettings.test.ts b/apps/web/src/hooks/useSettings.test.ts index 200e14241e13..d55424766883 100644 --- a/apps/web/src/hooks/useSettings.test.ts +++ b/apps/web/src/hooks/useSettings.test.ts @@ -3,12 +3,22 @@ import { ProviderDriverKind, ProviderInstanceId, } from "@t3tools/contracts"; -import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; -import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { DEFAULT_CLIENT_SETTINGS, type ClientSettings } from "@t3tools/contracts/settings"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const persistenceMocks = vi.hoisted(() => ({ + getClientSettings: vi.fn<() => Promise>(), + setClientSettings: vi.fn<(settings: ClientSettings) => Promise>(), +})); + +vi.mock("~/localApi", () => ({ + ensureLocalApi: () => ({ persistence: persistenceMocks }), +})); import { __resetClientSettingsPersistenceForTests, __setClientSettingsForTests, + ensureClientSettingsHydrated, getClientSettings, mergeEnvironmentSettings, persistClientSettingsPatch, @@ -17,9 +27,138 @@ import { } from "./useSettings"; beforeEach(() => { + persistenceMocks.getClientSettings.mockReset().mockResolvedValue(null); + persistenceMocks.setClientSettings.mockReset().mockResolvedValue(undefined); __resetClientSettingsPersistenceForTests(); }); +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("client settings hydration", () => { + const savedSettings = { + ...DEFAULT_CLIENT_SETTINGS, + timestampFormat: "12-hour" as const, + favorites: [{ provider: ProviderInstanceId.make("codex_work"), model: "gpt-5.6" }], + }; + const onboardingCompletedAt = "2026-09-05T12:00:00.000Z"; + const complete = (current: ClientSettings) => ({ ...current, onboardingCompletedAt }); + + it("rejects completion after a failed read and preserves saved preferences on retry", async () => { + const failure = new Error("storage unavailable"); + vi.spyOn(console, "error").mockImplementation(() => undefined); + persistenceMocks.getClientSettings + .mockRejectedValueOnce(failure) + .mockResolvedValue(savedSettings); + + await expect(persistClientSettingsUpdate(complete)).rejects.toBe(failure); + expect(persistenceMocks.setClientSettings).not.toHaveBeenCalled(); + expect(getClientSettings()).toBe(DEFAULT_CLIENT_SETTINGS); + + const completedSettings = { ...savedSettings, onboardingCompletedAt }; + await expect(persistClientSettingsUpdate(complete)).resolves.toEqual(completedSettings); + expect(persistenceMocks.setClientSettings).toHaveBeenCalledExactlyOnceWith(completedSettings); + expect(persistenceMocks.getClientSettings).toHaveBeenCalledTimes(2); + }); + + it("uses defaults only after storage confirms no saved settings exist", async () => { + const completedSettings = { ...DEFAULT_CLIENT_SETTINGS, onboardingCompletedAt }; + + await expect(persistClientSettingsUpdate(complete)).resolves.toEqual(completedSettings); + expect(persistenceMocks.getClientSettings).toHaveBeenCalledOnce(); + expect(persistenceMocks.setClientSettings).toHaveBeenCalledExactlyOnceWith(completedSettings); + }); + + it("holds patches until a pending read supplies the saved preferences", async () => { + let finishRead!: (settings: ClientSettings) => void; + persistenceMocks.getClientSettings.mockImplementationOnce( + () => + new Promise((resolve) => { + finishRead = resolve; + }), + ); + const persisted = new Promise((resolve) => { + persistenceMocks.setClientSettings.mockImplementationOnce(async (settings) => { + resolve(settings); + }); + }); + + const hydration = ensureClientSettingsHydrated(); + persistClientSettingsPatch({ wordWrap: false }); + expect(getClientSettings()).toBe(DEFAULT_CLIENT_SETTINGS); + expect(persistenceMocks.setClientSettings).not.toHaveBeenCalled(); + + finishRead(savedSettings); + await hydration; + await expect(persisted).resolves.toEqual({ ...savedSettings, wordWrap: false }); + expect(persistenceMocks.getClientSettings).toHaveBeenCalledOnce(); + }); + + it("handles failed patch reads without writing and retries with the saved preferences", async () => { + const failure = new Error("storage unavailable"); + vi.spyOn(console, "error").mockImplementation(() => undefined); + persistenceMocks.getClientSettings.mockRejectedValue(failure); + + const hydration = ensureClientSettingsHydrated(); + persistClientSettingsPatch({ wordWrap: false }); + await expect(hydration).rejects.toBe(failure); + expect(persistenceMocks.setClientSettings).not.toHaveBeenCalled(); + + persistenceMocks.getClientSettings.mockResolvedValue(savedSettings); + const persisted = new Promise((resolve) => { + persistenceMocks.setClientSettings.mockImplementationOnce(async (settings) => { + resolve(settings); + }); + }); + persistClientSettingsPatch({ wordWrap: false }); + + await expect(persisted).resolves.toEqual({ ...savedSettings, wordWrap: false }); + }); + + it("preserves patch order across hydration and a blocked completion write", async () => { + let finishRead!: (settings: ClientSettings) => void; + const read = new Promise((resolve) => { + finishRead = resolve; + }); + persistenceMocks.getClientSettings.mockReturnValue(read); + let finishCompletionWrite!: () => void; + const blockedWrite = new Promise((resolve) => { + finishCompletionWrite = resolve; + }); + let signalCompletionWrite!: () => void; + const completionWriteStarted = new Promise((resolve) => { + signalCompletionWrite = resolve; + }); + let durableSettings: ClientSettings = savedSettings; + const persist = vi + .fn<(settings: ClientSettings) => Promise>() + .mockImplementationOnce(async (settings) => { + signalCompletionWrite(); + await blockedWrite; + durableSettings = settings; + }) + .mockImplementation(async (settings) => { + durableSettings = settings; + }); + + const completion = persistClientSettingsUpdate(complete, persist); + persistClientSettingsPatch({ wordWrap: false }, persist); + finishRead(savedSettings); + await completionWriteStarted; + persistClientSettingsPatch({ wordWrap: true }, persist); + const finalWrite = persistClientSettingsUpdate((current) => current, persist); + + finishCompletionWrite(); + await completion; + await finalWrite; + + const expected = { ...savedSettings, onboardingCompletedAt, wordWrap: true }; + expect(getClientSettings()).toEqual(expected); + expect(durableSettings).toEqual(expected); + }); +}); + describe("persistClientSettingsUpdate", () => { it("publishes the update only after persistence succeeds", async () => { let finishPersistence!: () => void; @@ -245,3 +384,40 @@ describe("mergeEnvironmentSettings", () => { expect(settings.sidebarAutoSettleOnMerge).toBe(false); }); }); + +describe("onboarding completion persistence", () => { + it("keeps onboarding incomplete after a failed save and preserves preferences on retry", async () => { + const failure = new Error("disk full"); + const persist = vi + .fn<(settings: typeof DEFAULT_CLIENT_SETTINGS) => Promise>() + .mockRejectedValueOnce(failure) + .mockResolvedValue(undefined); + const existingSettings = { + ...DEFAULT_CLIENT_SETTINGS, + timestampFormat: "12-hour" as const, + favorites: [ + { + provider: ProviderInstanceId.make("codex_work"), + model: "gpt-5.6", + }, + ], + }; + __setClientSettingsForTests(existingSettings); + const onboardingCompletedAt = "2026-09-01T12:00:00.000Z"; + const complete = (current: typeof DEFAULT_CLIENT_SETTINGS) => ({ + ...current, + onboardingCompletedAt, + }); + + await expect(persistClientSettingsUpdate(complete, persist)).rejects.toBe(failure); + expect(getClientSettings()).toBe(existingSettings); + expect(getClientSettings().onboardingCompletedAt).toBeNull(); + + const completedSettings = { ...existingSettings, onboardingCompletedAt }; + await expect(persistClientSettingsUpdate(complete, persist)).resolves.toEqual( + completedSettings, + ); + expect(getClientSettings()).toEqual(completedSettings); + expect(persist).toHaveBeenLastCalledWith(completedSettings); + }); +}); diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index 52a6fec12f0f..9b428b08ea50 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -54,11 +54,13 @@ type UnifiedSettingsPatch = ServerSettingsPatch & ClientSettingsPatch; const clientSettingsListeners = new Set<() => void>(); const clientSettingsHydrationListeners = new Set<() => void>(); +type ClientSettingsHydrationStatus = "pending" | "ready" | "failed" | "retrying"; let clientSettingsSnapshot = DEFAULT_CLIENT_SETTINGS; -let clientSettingsHydrated = false; +let clientSettingsHydrationStatus: ClientSettingsHydrationStatus = "pending"; let clientSettingsHydrationPromise: Promise | null = null; let clientSettingsHydrationGeneration = 0; let clientSettingsPersistenceQueue: Promise = Promise.resolve(); +let deferredClientSettingsPatchCount = 0; function emitClientSettingsChange() { for (const listener of clientSettingsListeners) { @@ -81,36 +83,40 @@ function replaceClientSettingsSnapshot(settings: ClientSettings): void { emitClientSettingsChange(); } -function setClientSettingsHydrated(nextHydrated: boolean): void { - if (clientSettingsHydrated === nextHydrated) { +function setClientSettingsHydrationStatus(nextStatus: ClientSettingsHydrationStatus): void { + if (clientSettingsHydrationStatus === nextStatus) { return; } - clientSettingsHydrated = nextHydrated; + clientSettingsHydrationStatus = nextStatus; emitClientSettingsHydrationChange(); } function subscribeClientSettings(listener: () => void): () => void { clientSettingsListeners.add(listener); - void hydrateClientSettings(); + void hydrateClientSettings().catch(() => undefined); return () => { clientSettingsListeners.delete(listener); }; } function getClientSettingsHydratedSnapshot(): boolean { - return clientSettingsHydrated; + return clientSettingsHydrationStatus === "ready"; +} + +function getClientSettingsHydrationStatusSnapshot(): ClientSettingsHydrationStatus { + return clientSettingsHydrationStatus; } function subscribeClientSettingsHydration(listener: () => void): () => void { clientSettingsHydrationListeners.add(listener); - void hydrateClientSettings(); + void hydrateClientSettings().catch(() => undefined); return () => { clientSettingsHydrationListeners.delete(listener); }; } async function hydrateClientSettings(): Promise { - if (clientSettingsHydrated) { + if (clientSettingsHydrationStatus === "ready") { return; } if (clientSettingsHydrationPromise) { @@ -118,6 +124,11 @@ async function hydrateClientSettings(): Promise { } const hydrationGeneration = clientSettingsHydrationGeneration; + setClientSettingsHydrationStatus( + clientSettingsHydrationStatus === "failed" || clientSettingsHydrationStatus === "retrying" + ? "retrying" + : "pending", + ); const nextHydration = (async () => { try { const persistedSettings = await ensureLocalApi().persistence.getClientSettings(); @@ -127,15 +138,16 @@ async function hydrateClientSettings(): Promise { if (persistedSettings) { replaceClientSettingsSnapshot({ ...DEFAULT_CLIENT_SETTINGS, ...persistedSettings }); } + setClientSettingsHydrationStatus("ready"); } catch (error) { + if (hydrationGeneration === clientSettingsHydrationGeneration) { + setClientSettingsHydrationStatus("failed"); + } console.error(`${CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE} hydrate failed`, { operation: "hydrate", ...safeErrorLogAttributes(error), }); - } finally { - if (hydrationGeneration === clientSettingsHydrationGeneration) { - setClientSettingsHydrated(true); - } + throw error; } })(); @@ -165,15 +177,32 @@ export function persistClientSettingsPatch( patch: ClientSettingsPatch, persist: (settings: ClientSettings) => Promise = defaultClientSettingsPersistence, ): void { - replaceClientSettingsSnapshot({ ...getClientSettingsSnapshot(), ...patch }); - void enqueueClientSettingsPersistence(() => persist(getClientSettingsSnapshot())).catch( - (error) => { - console.error(`${CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE} persist failed`, { - operation: "persist", - ...safeErrorLogAttributes(error), - }); - }, - ); + // Patches queued before hydration must publish before newer optimistic patches. + const deferPatch = + clientSettingsHydrationStatus !== "ready" || deferredClientSettingsPatchCount > 0; + if (deferPatch) { + deferredClientSettingsPatchCount += 1; + } else { + replaceClientSettingsSnapshot({ ...getClientSettingsSnapshot(), ...patch }); + } + void enqueueClientSettingsPersistence(async () => { + if (deferPatch) { + try { + if (clientSettingsHydrationStatus !== "ready") { + await hydrateClientSettings(); + } + replaceClientSettingsSnapshot({ ...getClientSettingsSnapshot(), ...patch }); + } finally { + deferredClientSettingsPatchCount -= 1; + } + } + await persist(getClientSettingsSnapshot()); + }).catch((error) => { + console.error(`${CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE} persist failed`, { + operation: "persist", + ...safeErrorLogAttributes(error), + }); + }); } /** @@ -187,6 +216,9 @@ export async function persistClientSettingsUpdate( persist: (settings: ClientSettings) => Promise = defaultClientSettingsPersistence, ): Promise { return enqueueClientSettingsPersistence(async () => { + if (clientSettingsHydrationStatus !== "ready") { + await hydrateClientSettings(); + } for (;;) { const current = getClientSettingsSnapshot(); const next = update(current); @@ -234,7 +266,9 @@ export function getClientSettings(): ClientSettings { } /** - * Resolves once client settings have been read from disk. + * Resolves after settings load or storage confirms no saved settings exist. + * Failed reads reject and remain retryable. They must not allow defaults to + * overwrite saved preferences. * * The pre-hydration snapshot is just the schema defaults, so imperative paths * that open a preview must await this or they bake the built-in viewport, zoom @@ -252,6 +286,14 @@ export function useClientSettingsHydrated(): boolean { ); } +export function useClientSettingsHydrationStatus(): ClientSettingsHydrationStatus { + return useSyncExternalStore( + subscribeClientSettingsHydration, + getClientSettingsHydrationStatusSnapshot, + () => "pending", + ); +} + function useClientSettingsValue(): ClientSettings { return useSyncExternalStore( subscribeClientSettings, @@ -524,9 +566,10 @@ export function useUpdateClientSettings() { export function __resetClientSettingsPersistenceForTests(): void { clientSettingsHydrationGeneration += 1; clientSettingsSnapshot = DEFAULT_CLIENT_SETTINGS; - clientSettingsHydrated = false; + clientSettingsHydrationStatus = "pending"; clientSettingsHydrationPromise = null; clientSettingsPersistenceQueue = Promise.resolve(); + deferredClientSettingsPatchCount = 0; clientSettingsListeners.clear(); clientSettingsHydrationListeners.clear(); } @@ -534,6 +577,6 @@ export function __resetClientSettingsPersistenceForTests(): void { export function __setClientSettingsForTests(settings: ClientSettings): void { clientSettingsHydrationGeneration += 1; clientSettingsSnapshot = settings; - clientSettingsHydrated = true; + clientSettingsHydrationStatus = "ready"; clientSettingsHydrationPromise = null; } diff --git a/apps/web/src/hooks/useTheme.test.ts b/apps/web/src/hooks/useTheme.test.ts index ab87388ff298..9a1748dc4d24 100644 --- a/apps/web/src/hooks/useTheme.test.ts +++ b/apps/web/src/hooks/useTheme.test.ts @@ -204,3 +204,200 @@ describe("theme failure handling", () => { } }); }); + +describe("onboarding theme", () => { + it("clears custom palettes and restores the latest selected theme", async () => { + const storage = createStorage(); + const classes = new Set(); + const styleValues = new Map(); + const root = { + classList: { + add: (name: string) => classes.add(name), + remove: (name: string) => classes.delete(name), + toggle: (name: string, force?: boolean) => { + const next = force ?? !classes.has(name); + if (next) classes.add(name); + else classes.delete(name); + return next; + }, + }, + dataset: {} as Record, + offsetHeight: 0, + style: { + backgroundColor: "", + removeProperty: (name: string) => styleValues.delete(name), + setProperty: (name: string, value: string) => styleValues.set(name, value), + }, + }; + vi.doMock("react", () => ({ + useCallback: (callback: A) => callback, + useEffect: () => undefined, + useSyncExternalStore: ( + subscribe: (listener: () => void) => () => void, + getSnapshot: () => unknown, + ) => { + subscribe(() => undefined); + return getSnapshot(); + }, + })); + vi.stubGlobal("window", { + addEventListener: () => undefined, + localStorage: storage, + matchMedia: () => ({ + matches: false, + addEventListener: () => undefined, + removeEventListener: () => undefined, + }), + removeEventListener: () => undefined, + }); + vi.stubGlobal("document", { + body: { style: { backgroundColor: "" } }, + createElement: () => ({ name: "", setAttribute: () => undefined }), + documentElement: root, + head: { append: () => undefined }, + querySelector: () => null, + querySelectorAll: () => [], + }); + vi.stubGlobal("getComputedStyle", () => ({ + backgroundColor: "rgb(0, 0, 0)", + getPropertyValue: () => "", + })); + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { + callback(0); + return 0; + }); + + const { EMBER_THEME, installCustomTheme } = await import("../themePalette"); + const firstTheme = installCustomTheme({ + ...EMBER_THEME, + id: "first-custom", + label: "First Custom", + }); + const secondTheme = installCustomTheme({ + ...EMBER_THEME, + id: "second-custom", + label: "Second Custom", + colors: { ...EMBER_THEME.colors, error: "#123456" }, + }); + storage.setItem("t3code:theme", firstTheme.id); + + const { mountOnboardingTheme, useTheme } = await import("./useTheme"); + expect(root.dataset.themeId).toBe(firstTheme.id); + expect(styleValues.get("--app-theme-error")).toBe(firstTheme.colors.error); + + const cleanup = mountOnboardingTheme(); + expect(root.dataset.themeId).toBeUndefined(); + expect(styleValues.size).toBe(0); + + expect(useTheme().setTheme(secondTheme.id)).toBe(true); + expect(root.dataset.themeId).toBeUndefined(); + expect(styleValues.size).toBe(0); + + cleanup(); + expect(root.dataset.themeId).toBe(secondTheme.id); + expect(styleValues.get("--app-theme-error")).toBe(secondTheme.colors.error); + }); + + it("stays dark during storage changes and restores the latest saved theme", async () => { + const storage = createStorage(); + storage.setItem("t3code:theme", "light"); + const classes = new Set(); + const styleValues = new Map(); + const style = { + backgroundColor: "", + removeProperty: (name: string) => styleValues.delete(name), + setProperty: (name: string, value: string) => styleValues.set(name, value), + }; + const root = { + classList: { + add: (name: string) => classes.add(name), + contains: (name: string) => classes.has(name), + remove: (name: string) => classes.delete(name), + toggle: (name: string, force?: boolean) => { + const next = force ?? !classes.has(name); + if (next) classes.add(name); + else classes.delete(name); + return next; + }, + }, + dataset: {} as Record, + offsetHeight: 0, + style, + }; + const body = { style: { backgroundColor: "" } }; + let storageHandler: ((event: StorageEvent) => void) | undefined; + const setDesktopTheme = vi.fn().mockResolvedValue(undefined); + vi.doMock("react", () => ({ + useCallback: (callback: A) => callback, + useEffect: () => undefined, + useSyncExternalStore: ( + subscribe: (listener: () => void) => () => void, + getSnapshot: () => unknown, + ) => { + subscribe(() => undefined); + return getSnapshot(); + }, + })); + vi.stubGlobal("window", { + addEventListener: (type: string, listener: (event: StorageEvent) => void) => { + if (type === "storage") storageHandler = listener; + }, + localStorage: storage, + matchMedia: () => ({ + matches: false, + addEventListener: () => undefined, + removeEventListener: () => undefined, + }), + removeEventListener: () => undefined, + desktopBridge: { setTheme: setDesktopTheme }, + }); + vi.stubGlobal("document", { + body, + createElement: () => ({ name: "", setAttribute: () => undefined }), + documentElement: root, + head: { append: () => undefined }, + querySelector: () => null, + querySelectorAll: () => [], + }); + vi.stubGlobal("getComputedStyle", () => ({ + backgroundColor: + root.dataset.onboardingSurface !== undefined + ? "rgb(0, 0, 0)" + : classes.has("dark") + ? "rgb(10, 10, 10)" + : "rgb(255, 255, 255)", + getPropertyValue: () => "", + })); + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { + callback(0); + return 0; + }); + + const { mountOnboardingTheme, useTheme } = await import("./useTheme"); + expect(useTheme().resolvedTheme).toBe("light"); + const cleanup = mountOnboardingTheme(); + + expect(root.dataset.onboardingSurface).toBe(""); + expect(classes.has("dark")).toBe(true); + expect(root.style.backgroundColor).toBe("#000"); + expect(body.style.backgroundColor).toBe("#000"); + expect(useTheme().resolvedTheme).toBe("dark"); + expect(setDesktopTheme).toHaveBeenLastCalledWith("dark"); + + storage.setItem("t3code:theme", "dark"); + storageHandler?.({ key: "t3code:theme" } as StorageEvent); + storage.setItem("t3code:theme", "light"); + storageHandler?.({ key: "t3code:theme" } as StorageEvent); + expect(classes.has("dark")).toBe(true); + expect(useTheme().resolvedTheme).toBe("dark"); + + cleanup(); + expect(root.dataset.onboardingSurface).toBeUndefined(); + expect(classes.has("dark")).toBe(false); + expect(root.style.backgroundColor).toBe("rgb(255, 255, 255)"); + expect(body.style.backgroundColor).toBe("rgb(255, 255, 255)"); + expect(storage.getItem("t3code:theme")).toBe("light"); + expect(useTheme().resolvedTheme).toBe("light"); + expect(setDesktopTheme).toHaveBeenLastCalledWith("light"); + }); +}); diff --git a/apps/web/src/hooks/useTheme.ts b/apps/web/src/hooks/useTheme.ts index 726a03dac7b8..01928552acb0 100644 --- a/apps/web/src/hooks/useTheme.ts +++ b/apps/web/src/hooks/useTheme.ts @@ -98,6 +98,14 @@ function readStoredThemeHalvesRaw(): { light?: string; dark?: string } { function themeHalvesSignature(halves: ThemeHalves | null): string { return `${halves?.light ?? ""}|${halves?.dark ?? ""}`; } + +function isOnboardingThemeActive(): boolean { + return ( + typeof document !== "undefined" && + document.documentElement.dataset?.onboardingSurface !== undefined + ); +} + const THEME_COLOR_META_NAME = "theme-color"; const DYNAMIC_THEME_COLOR_SELECTOR = `meta[name="${THEME_COLOR_META_NAME}"][data-dynamic-theme-color="true"]`; @@ -292,15 +300,19 @@ function resolveBrowserChromeSurface(): HTMLElement { export function syncBrowserChromeTheme() { if (typeof document === "undefined" || typeof getComputedStyle === "undefined") return; + const onboardingActive = isOnboardingThemeActive(); const rootStyles = getComputedStyle(document.documentElement); - const themeChromeColor = document.documentElement.dataset.themeId - ? normalizeThemeColor(rootStyles.getPropertyValue("--app-chrome-background")) - : null; + const themeChromeColor = + !onboardingActive && document.documentElement.dataset.themeId + ? normalizeThemeColor(rootStyles.getPropertyValue("--app-chrome-background")) + : null; const surfaceColor = normalizeThemeColor( getComputedStyle(resolveBrowserChromeSurface()).backgroundColor, ); const fallbackColor = normalizeThemeColor(getComputedStyle(document.body).backgroundColor); - const backgroundColor = themeChromeColor ?? surfaceColor ?? fallbackColor; + const backgroundColor = onboardingActive + ? "#000" + : (themeChromeColor ?? surfaceColor ?? fallbackColor); if (!backgroundColor) return; document.documentElement.style.backgroundColor = backgroundColor; @@ -321,8 +333,15 @@ export function syncBrowserChromeTheme() { function applyTheme(theme: Theme, { suppressTransitions = false, preservePreview = true } = {}) { if (typeof document === "undefined" || typeof window === "undefined") return; + const onboardingActive = isOnboardingThemeActive(); // Keep the editor's draft visible until an explicit refresh restores the selection. - if (preservePreview && document.documentElement.dataset?.themeId === THEME_PREVIEW_ID) return; + if ( + preservePreview && + !onboardingActive && + document.documentElement.dataset?.themeId === THEME_PREVIEW_ID + ) { + return; + } const appearanceMode = readAppearanceModePreference(theme); const followSystem = appearanceMode === "system"; const systemDark = followSystem ? getSystemDark() : false; @@ -334,7 +353,13 @@ function applyTheme(theme: Theme, { suppressTransitions = false, preservePreview lastAppliedTheme.appearanceMode === appearanceMode && themeHalvesSignature(lastAppliedTheme.themeHalves) === themeHalvesSignature(themeHalves) ) { - syncDesktopTheme(theme, followSystem, appearanceMode); + if (onboardingActive) { + document.documentElement.classList.add("dark"); + syncBrowserChromeTheme(); + syncDesktopTheme("dark", false, "dark"); + } else { + syncDesktopTheme(theme, followSystem, appearanceMode); + } return; } @@ -348,12 +373,19 @@ function applyTheme(theme: Theme, { suppressTransitions = false, preservePreview appearanceMode, themeHalves, ); - applyThemePalette(resolveThemeHalf(theme, themeHalves, resolvedAppearance), resolvedAppearance); - const isDark = resolvedAppearance === "dark"; - document.documentElement.classList.toggle("dark", isDark); + if (onboardingActive) { + document.documentElement.classList.add("dark"); + } else { + applyThemePalette(resolveThemeHalf(theme, themeHalves, resolvedAppearance), resolvedAppearance); + document.documentElement.classList.toggle("dark", resolvedAppearance === "dark"); + } lastAppliedTheme = { theme, systemDark, followSystem, appearanceMode, themeHalves }; syncBrowserChromeTheme(); - syncDesktopTheme(theme, followSystem, appearanceMode); + if (onboardingActive) { + syncDesktopTheme("dark", false, "dark"); + } else { + syncDesktopTheme(theme, followSystem, appearanceMode); + } if (suppressTransitions) { // Force a reflow so the no-transitions class takes effect before removal void document.documentElement.offsetHeight; @@ -363,6 +395,28 @@ function applyTheme(theme: Theme, { suppressTransitions = false, preservePreview } } +/** Own the document-wide dark palette used by the first-run wizard and its portals. */ +export function mountOnboardingTheme(): () => void { + if (typeof document === "undefined" || typeof window === "undefined") return () => {}; + + const root = document.documentElement; + applyThemePalette("dark", "dark"); + root.dataset.onboardingSurface = ""; + root.classList.add("dark"); + syncBrowserChromeTheme(); + syncDesktopTheme("dark", false, "dark"); + emitChange(); + + return () => { + delete root.dataset.onboardingSurface; + root.style.backgroundColor = ""; + document.body.style.backgroundColor = ""; + lastAppliedTheme = null; + applyTheme(getStored(), { suppressTransitions: true, preservePreview: false }); + emitChange(); + }; +} + export async function syncDesktopThemePreference( bridge: DesktopThemeBridge, theme: Theme, @@ -424,13 +478,9 @@ function getSnapshot(): ThemeSnapshot { const systemDark = followSystem ? getSystemDark() : false; const themeHalves = readStoredThemeHalves(); - const resolvedTheme = resolveThemeAppearance( - theme, - systemDark, - followSystem, - appearanceMode, - themeHalves, - ); + const resolvedTheme = isOnboardingThemeActive() + ? "dark" + : resolveThemeAppearance(theme, systemDark, followSystem, appearanceMode, themeHalves); if ( lastSnapshot && lastSnapshot.theme === theme && diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 90412b8e5ce5..4b2bd27474b7 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1184,6 +1184,39 @@ html[data-theme-id]:not([data-theme-id=""]) { --terminal-selection-background: var(--app-theme-terminal-selection-background); } +/* The first-run flow owns the whole document so portaled menus and tooltips + use the same fixed palette as the wizard. This follows the theme mapping so + saved custom themes cannot override it while onboarding is mounted. */ +html[data-onboarding-surface]:root { + color-scheme: dark; + --accent: #262626; + --accent-foreground: #fff; + --appearance-contrast-target: #fff; + --app-chrome-background: #000; + --background: #000; + --border: #262626; + --card: #000; + --card-foreground: #fff; + --destructive: var(--color-red-400); + --foreground: #fff; + --icon-muted: #a1a1aa; + --input: #262626; + --muted: #171717; + --muted-foreground: #a1a1aa; + --placeholder: #71717a; + --popover: #171717; + --popover-foreground: #fff; + --ring: #737373; + --secondary: #171717; + --secondary-foreground: #fff; + --secondary-label: #a1a1aa; + --success-foreground: var(--color-emerald-400); + --terminal-background: #000; + --terminal-cursor: #fff; + --terminal-foreground: #fff; + --terminal-selection-background: rgb(255 255 255 / 20%); +} + /* Theme-token dependency probes are restored synchronously, before paint. Keep transitions from observing the temporary sentinel color in between. */ html[data-theme-token-probe], @@ -1385,11 +1418,10 @@ html[data-theme-id="t3-chat"] [data-app-sidebar] { } } -/* Contrast stays in ordinary custom properties so both Tailwind utilities and - global/imperative chrome styles resolve the same adjusted role. Redeclare on - the sidebar because it owns a local semantic palette. */ +/* Recompute contrast wherever a subtree owns its own semantic color palette. */ :root, -[data-app-sidebar] { +[data-app-sidebar], +[data-onboarding-surface] { --contrast-toolbar-foreground: color-mix( in oklab, color-mix( diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index e63c3e962f3f..bd5b90ad8fda 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -8,8 +8,6 @@ import { } from "@t3tools/contracts"; import { formatShortcutLabel, - isChatNewShortcut, - isChatNewLocalShortcut, isDiffToggleShortcut, modelPickerJumpCommandForIndex, modelPickerJumpIndexFromCommand, @@ -21,8 +19,7 @@ import { isTerminalSplitVerticalShortcut, isTerminalToggleShortcut, resolveShortcutCommand, - shouldShowModelPickerJumpHints, - shouldShowThreadJumpHints, + shouldShowThreadJumpHintsForModifiers, shortcutLabelForCommand, terminalDeleteShortcutData, terminalNavigationShortcutData, @@ -491,17 +488,21 @@ describe("thread navigation helpers", () => { it("shows jump hints only when configured modifiers match", () => { assert.isTrue( - shouldShowThreadJumpHints(event({ metaKey: true }), DEFAULT_BINDINGS, { + shouldShowThreadJumpHintsForModifiers(event({ metaKey: true }), DEFAULT_BINDINGS, { platform: "MacIntel", }), ); assert.isFalse( - shouldShowThreadJumpHints(event({ metaKey: true, shiftKey: true }), DEFAULT_BINDINGS, { - platform: "MacIntel", - }), + shouldShowThreadJumpHintsForModifiers( + event({ metaKey: true, shiftKey: true }), + DEFAULT_BINDINGS, + { + platform: "MacIntel", + }, + ), ); assert.isTrue( - shouldShowThreadJumpHints(event({ ctrlKey: true }), DEFAULT_BINDINGS, { + shouldShowThreadJumpHintsForModifiers(event({ ctrlKey: true }), DEFAULT_BINDINGS, { platform: "Linux", }), ); @@ -509,13 +510,13 @@ describe("thread navigation helpers", () => { it("never shows jump hints while the terminal is focused, even with an unrestricted binding", () => { assert.isFalse( - shouldShowThreadJumpHints(event({ metaKey: true }), DEFAULT_BINDINGS, { + shouldShowThreadJumpHintsForModifiers(event({ metaKey: true }), DEFAULT_BINDINGS, { platform: "MacIntel", context: { terminalFocus: true }, }), ); assert.isTrue( - shouldShowThreadJumpHints(event({ metaKey: true }), DEFAULT_BINDINGS, { + shouldShowThreadJumpHintsForModifiers(event({ metaKey: true }), DEFAULT_BINDINGS, { platform: "MacIntel", context: { terminalFocus: false }, }), @@ -532,21 +533,6 @@ describe("model picker navigation helpers", () => { assert.strictEqual(modelPickerJumpIndexFromCommand("modelPicker.jump.3"), 2); assert.isNull(modelPickerJumpIndexFromCommand("thread.jump.1")); }); - - it("shows jump hints only while the model picker context is active", () => { - assert.isFalse( - shouldShowModelPickerJumpHints(event({ metaKey: true }), DEFAULT_BINDINGS, { - platform: "MacIntel", - context: { modelPickerOpen: false }, - }), - ); - assert.isTrue( - shouldShowModelPickerJumpHints(event({ metaKey: true }), DEFAULT_BINDINGS, { - platform: "MacIntel", - context: { modelPickerOpen: true }, - }), - ); - }); }); describe("chat/editor shortcuts", () => { @@ -560,28 +546,32 @@ describe("chat/editor shortcuts", () => { }); it("matches chat.new shortcut", () => { - assert.isTrue( - isChatNewShortcut(event({ key: "o", metaKey: true, shiftKey: true }), DEFAULT_BINDINGS, { + assert.strictEqual( + resolveShortcutCommand(event({ key: "o", metaKey: true, shiftKey: true }), DEFAULT_BINDINGS, { platform: "MacIntel", }), + "chat.new", ); - assert.isTrue( - isChatNewShortcut(event({ key: "o", ctrlKey: true, shiftKey: true }), DEFAULT_BINDINGS, { + assert.strictEqual( + resolveShortcutCommand(event({ key: "o", ctrlKey: true, shiftKey: true }), DEFAULT_BINDINGS, { platform: "Linux", }), + "chat.new", ); }); it("matches chat.newLocal shortcut", () => { - assert.isTrue( - isChatNewLocalShortcut(event({ key: "n", metaKey: true, shiftKey: true }), DEFAULT_BINDINGS, { + assert.strictEqual( + resolveShortcutCommand(event({ key: "n", metaKey: true, shiftKey: true }), DEFAULT_BINDINGS, { platform: "MacIntel", }), + "chat.newLocal", ); - assert.isTrue( - isChatNewLocalShortcut(event({ key: "n", ctrlKey: true, shiftKey: true }), DEFAULT_BINDINGS, { + assert.strictEqual( + resolveShortcutCommand(event({ key: "n", ctrlKey: true, shiftKey: true }), DEFAULT_BINDINGS, { platform: "Linux", }), + "chat.newLocal", ); }); @@ -701,11 +691,12 @@ describe("cross-command precedence", () => { context: { terminalFocus: true }, }), ); - assert.isFalse( - isChatNewShortcut(event({ key: "n", metaKey: true }), keybindings, { + assert.strictEqual( + resolveShortcutCommand(event({ key: "n", metaKey: true }), keybindings, { platform: "MacIntel", context: { terminalFocus: true }, }), + "terminal.new", ); assert.isFalse( isTerminalNewShortcut(event({ key: "n", metaKey: true }), keybindings, { @@ -713,11 +704,12 @@ describe("cross-command precedence", () => { context: { terminalFocus: false }, }), ); - assert.isTrue( - isChatNewShortcut(event({ key: "n", metaKey: true }), keybindings, { + assert.strictEqual( + resolveShortcutCommand(event({ key: "n", metaKey: true }), keybindings, { platform: "MacIntel", context: { terminalFocus: false }, }), + "chat.new", ); }); @@ -737,11 +729,12 @@ describe("cross-command precedence", () => { context: { terminalFocus: true }, }), ); - assert.isTrue( - isChatNewShortcut(event({ key: "n", ctrlKey: true }), keybindings, { + assert.strictEqual( + resolveShortcutCommand(event({ key: "n", ctrlKey: true }), keybindings, { platform: "Linux", context: { terminalFocus: true }, }), + "chat.new", ); }); }); diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts index 2a8d385cb42b..1b4fa072d5a1 100644 --- a/apps/web/src/keybindings.ts +++ b/apps/web/src/keybindings.ts @@ -288,14 +288,6 @@ export function threadTraversalDirectionFromCommand( return null; } -export function shouldShowThreadJumpHints( - event: ShortcutEventLike, - keybindings: ResolvedKeybindingsConfig, - options?: ShortcutMatchOptions, -): boolean { - return shouldShowThreadJumpHintsForModifiers(event, keybindings, options); -} - export function shouldShowThreadJumpHintsForModifiers( modifiers: ShortcutModifierStateLike, keybindings: ResolvedKeybindingsConfig, @@ -337,32 +329,6 @@ export function modelPickerJumpIndexFromCommand(command: string): number | null return index === -1 ? null : index; } -export function shouldShowModelPickerJumpHints( - event: ShortcutEventLike, - keybindings: ResolvedKeybindingsConfig, - options?: ShortcutMatchOptions, -): boolean { - return shouldShowModelPickerJumpHintsForModifiers(event, keybindings, options); -} - -export function shouldShowModelPickerJumpHintsForModifiers( - modifiers: ShortcutModifierStateLike, - keybindings: ResolvedKeybindingsConfig, - options?: ShortcutMatchOptions, -): boolean { - const platform = resolvePlatform(options); - - for (const command of MODEL_PICKER_JUMP_KEYBINDING_COMMANDS) { - const shortcut = findEffectiveShortcutForCommand(keybindings, command, options); - if (!shortcut) continue; - if (matchesShortcutModifiers(modifiers, shortcut, platform)) { - return true; - } - } - - return false; -} - export function isTerminalToggleShortcut( event: ShortcutEventLike, keybindings: ResolvedKeybindingsConfig, @@ -419,22 +385,6 @@ export function isPreviewRefreshShortcut( return matchesCommandShortcut(event, keybindings, "preview.refresh", options); } -export function isChatNewShortcut( - event: ShortcutEventLike, - keybindings: ResolvedKeybindingsConfig, - options?: ShortcutMatchOptions, -): boolean { - return matchesCommandShortcut(event, keybindings, "chat.new", options); -} - -export function isChatNewLocalShortcut( - event: ShortcutEventLike, - keybindings: ResolvedKeybindingsConfig, - options?: ShortcutMatchOptions, -): boolean { - return matchesCommandShortcut(event, keybindings, "chat.newLocal", options); -} - export function isOpenFavoriteEditorShortcut( event: ShortcutEventLike, keybindings: ResolvedKeybindingsConfig, diff --git a/apps/web/src/lib/openPullRequestLink.test.ts b/apps/web/src/lib/openPullRequestLink.test.ts index 6029c9bb3dab..ad4971d331d8 100644 --- a/apps/web/src/lib/openPullRequestLink.test.ts +++ b/apps/web/src/lib/openPullRequestLink.test.ts @@ -1,14 +1,12 @@ -import { describe, expect, it, vi } from "vite-plus/test"; +import { describe, expect, it } from "vite-plus/test"; import { changeRequestRepositoryUrl, findProjectForChangeRequest, gitHubPullRequestBrowserUrl, matchesLinkedPullRequestUrl, - openPullRequestLink, parseChangeRequestUrl, pullRequestCandidateUrlFromReferenceAutolink, - PullRequestLinkOpenError, shouldOpenPullRequestExternally, } from "./openPullRequestLink"; import { ProjectId, type RepositoryIdentity } from "@t3tools/contracts"; @@ -184,33 +182,6 @@ describe("matchesLinkedPullRequestUrl", () => { }); }); -describe("openPullRequestLink", () => { - it("opens the requested pull request URL", async () => { - const openExternal = vi.fn(async () => undefined); - const targetUrl = "https://github.com/pingdotgg/t3code/pull/123"; - - await openPullRequestLink({ openExternal }, targetUrl); - - expect(openExternal).toHaveBeenCalledExactlyOnceWith(targetUrl); - }); - - it("reports bridge failures with a safe target origin", async () => { - const cause = new Error("desktop shell unavailable"); - const targetUrl = "https://github.com/pingdotgg/t3code/pull/123?token=secret"; - const openExternal = vi.fn(async () => Promise.reject(cause)); - - const result = openPullRequestLink({ openExternal }, targetUrl); - - await expect(result).rejects.toEqual( - new PullRequestLinkOpenError({ - targetOrigin: "https://github.com", - cause, - }), - ); - await expect(result).rejects.not.toHaveProperty("message", expect.stringContaining("secret")); - }); -}); - describe("shouldOpenPullRequestExternally", () => { it("uses the browser for command-click and control-click", () => { expect(shouldOpenPullRequestExternally({ metaKey: true, ctrlKey: false })).toBe(true); diff --git a/apps/web/src/lib/openPullRequestLink.ts b/apps/web/src/lib/openPullRequestLink.ts index 0050c62bc727..2d46e3984fd9 100644 --- a/apps/web/src/lib/openPullRequestLink.ts +++ b/apps/web/src/lib/openPullRequestLink.ts @@ -1,12 +1,10 @@ import type { EnvironmentId, - LocalApi, RepositoryIdentity, ScopedThreadRef, ThreadLinkedPullRequest, } from "@t3tools/contracts"; import { useNavigate } from "@tanstack/react-router"; -import * as Schema from "effect/Schema"; import { type MouseEvent, useCallback } from "react"; import { pullRequestHostOf, type SourceControlProviderKind } from "@t3tools/contracts"; @@ -19,41 +17,6 @@ import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import { useProjects, useServerConfigs } from "../state/entities"; import { usePrimaryEnvironmentId } from "../state/environments"; -export class PullRequestLinkOpenError extends Schema.TaggedErrorClass()( - "PullRequestLinkOpenError", - { - targetOrigin: Schema.NullOr(Schema.String), - cause: Schema.Defect(), - }, -) { - static fromCause(targetUrl: string, cause: unknown): PullRequestLinkOpenError { - let targetOrigin: string | null = null; - try { - targetOrigin = new URL(targetUrl).origin; - } catch { - // Keep malformed URLs out of diagnostics while preserving the open failure below. - } - return new PullRequestLinkOpenError({ targetOrigin, cause }); - } - - override get message(): string { - return this.targetOrigin === null - ? "Unable to open pull request link." - : `Unable to open pull request link at ${this.targetOrigin}.`; - } -} - -export async function openPullRequestLink( - shell: Pick, - targetUrl: string, -): Promise { - try { - await shell.openExternal(targetUrl); - } catch (cause) { - throw PullRequestLinkOpenError.fromCause(targetUrl, cause); - } -} - /** Builds a GitHub URL that remains available when the pull request API cannot be read. */ export function gitHubPullRequestBrowserUrl( identity: RepositoryIdentity | null | undefined, diff --git a/apps/web/src/lib/terminalContext.test.ts b/apps/web/src/lib/terminalContext.test.ts index 4b520c9bef4a..199054b1d84b 100644 --- a/apps/web/src/lib/terminalContext.test.ts +++ b/apps/web/src/lib/terminalContext.test.ts @@ -3,7 +3,6 @@ import { describe, expect, it } from "vite-plus/test"; import { appendTerminalContextsToPrompt, - buildTerminalContextPreviewTitle, buildTerminalContextBlock, countInlineTerminalContextPlaceholders, deriveDisplayedUserMessageState, @@ -135,20 +134,6 @@ describe("terminalContext", () => { }); }); - it("returns null preview title when every context is invalid", () => { - expect( - buildTerminalContextPreviewTitle([ - makeContext({ - terminalId: " ", - }), - makeContext({ - id: "context-2", - text: "\n\n", - }), - ]), - ).toBeNull(); - }); - it("tracks inline terminal context placeholders in prompt text", () => { const placeholder = INLINE_TERMINAL_CONTEXT_PLACEHOLDER; expect(countInlineTerminalContextPlaceholders(`a${placeholder}b${placeholder}`)).toBe(2); diff --git a/apps/web/src/lib/terminalContext.ts b/apps/web/src/lib/terminalContext.ts index 72f49a2f22d2..4cdbc019255d 100644 --- a/apps/web/src/lib/terminalContext.ts +++ b/apps/web/src/lib/terminalContext.ts @@ -65,20 +65,6 @@ export function filterTerminalContextsWithText( return contexts.filter((context) => hasTerminalContextText(context)); } -function previewTerminalContextText(text: string): string { - const normalized = normalizeTerminalContextText(text); - if (normalized.length === 0) { - return ""; - } - const lines = normalized.split("\n"); - const visibleLines = lines.slice(0, 3); - if (lines.length > 3) { - visibleLines.push("..."); - } - const preview = visibleLines.join("\n"); - return preview.length > 180 ? `${preview.slice(0, 177)}...` : preview; -} - export function normalizeTerminalContextSelection( selection: TerminalContextSelection, ): TerminalContextSelection | null { @@ -129,27 +115,6 @@ export function formatInlineTerminalContextLabel(selection: { return `@${terminalLabel}:${range}`; } -export function buildTerminalContextPreviewTitle( - contexts: ReadonlyArray, -): string | null { - if (contexts.length === 0) { - return null; - } - const previewParts: string[] = []; - for (const context of contexts) { - const normalized = normalizeTerminalContextSelection(context); - if (!normalized) continue; - const preview = previewTerminalContextText(normalized.text); - previewParts.push( - preview.length > 0 - ? `${formatTerminalContextLabel(normalized)}\n${preview}` - : formatTerminalContextLabel(normalized), - ); - } - const previews = previewParts.join("\n\n"); - return previews.length > 0 ? previews : null; -} - function buildTerminalContextBodyLines(selection: TerminalContextSelection): string[] { return normalizeTerminalContextText(selection.text) .split("\n") diff --git a/apps/web/src/onboarding/firstRun.logic.test.ts b/apps/web/src/onboarding/firstRun.logic.test.ts new file mode 100644 index 000000000000..ca35f6322e3b --- /dev/null +++ b/apps/web/src/onboarding/firstRun.logic.test.ts @@ -0,0 +1,514 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + isFirstRunWorkspaceProvenanceAuthoritative, + isFreshFirstRunWorkspace, + resolveFirstRunDecision, + resolveHostedFirstRunDecision, + transitionFirstRunGateState, +} from "./firstRun.logic"; + +const freshWorkspace = { + enabled: true, + hydrated: true, + completed: false, + bootstrapped: true, + authoritative: true, + workspaceAuthoritative: true, + workspaceProvenanceAuthoritative: true, + catalogReady: true, + serverConfigAvailable: true, + workspaceFresh: true, + projectCount: 1, + threadCount: 1, +} as const; + +describe("resolveFirstRunDecision", () => { + it("opens the wizard for an authoritative fresh workspace", () => { + expect(resolveFirstRunDecision(freshWorkspace)).toEqual({ + decision: "wizard", + persistCompletion: false, + }); + }); + + it("does not permanently complete onboarding from cached project counts", () => { + expect( + resolveFirstRunDecision({ + ...freshWorkspace, + authoritative: false, + projectCount: 3, + workspaceFresh: false, + }), + ).toEqual({ + decision: "app", + persistCompletion: false, + }); + }); + + it("backfills completion once existing projects are confirmed by the server", () => { + expect( + resolveFirstRunDecision({ + ...freshWorkspace, + projectCount: 3, + workspaceFresh: false, + }), + ).toEqual({ + decision: "app", + persistCompletion: true, + }); + }); + + it("does not complete onboarding while another environment is still bootstrapping", () => { + expect( + resolveFirstRunDecision({ + ...freshWorkspace, + bootstrapped: false, + projectCount: 3, + workspaceFresh: false, + }), + ).toEqual({ + decision: "app", + persistCompletion: false, + }); + }); + + it("waits for managed environments before treating a workspace as new", () => { + expect(resolveFirstRunDecision({ ...freshWorkspace, catalogReady: false })).toEqual({ + decision: "pending", + persistCompletion: false, + }); + }); + + it("does not complete onboarding before the environment catalog is ready", () => { + expect( + resolveFirstRunDecision({ + ...freshWorkspace, + catalogReady: false, + projectCount: 3, + workspaceFresh: false, + }), + ).toEqual({ + decision: "app", + persistCompletion: false, + }); + }); + + it("does not complete onboarding before the server configuration is available", () => { + expect( + resolveFirstRunDecision({ + ...freshWorkspace, + projectCount: 3, + serverConfigAvailable: false, + workspaceFresh: false, + }), + ).toEqual({ + decision: "app", + persistCompletion: false, + }); + }); + + it("does not complete onboarding from cached remote projects", () => { + expect( + resolveFirstRunDecision({ + ...freshWorkspace, + projectCount: 3, + workspaceAuthoritative: false, + workspaceFresh: false, + }), + ).toEqual({ + decision: "app", + persistCompletion: false, + }); + }); + + it("does not complete onboarding from a single cached remote project", () => { + expect( + resolveFirstRunDecision({ + ...freshWorkspace, + workspaceAuthoritative: false, + workspaceFresh: false, + }), + ).toEqual({ + decision: "app", + persistCompletion: false, + }); + }); + + it("waits for live data before judging a single cached project", () => { + expect( + resolveFirstRunDecision({ + ...freshWorkspace, + authoritative: false, + workspaceFresh: false, + }), + ).toEqual({ + decision: "pending", + persistCompletion: false, + }); + }); + + it("waits for the completed bootstrap welcome before judging a nonempty workspace", () => { + expect( + resolveFirstRunDecision({ + ...freshWorkspace, + workspaceProvenanceAuthoritative: false, + }), + ).toEqual({ + decision: "pending", + persistCompletion: false, + }); + }); + + it("waits when the initial welcome is pending and opens the wizard after completion", () => { + const pendingProvenance = isFirstRunWorkspaceProvenanceAuthoritative({ + welcomeReceived: true, + bootstrapStatus: "pending", + }); + expect( + resolveFirstRunDecision({ + ...freshWorkspace, + workspaceProvenanceAuthoritative: pendingProvenance, + }), + ).toEqual({ decision: "pending", persistCompletion: false }); + + const completedProvenance = isFirstRunWorkspaceProvenanceAuthoritative({ + welcomeReceived: true, + bootstrapStatus: "complete", + }); + expect( + resolveFirstRunDecision({ + ...freshWorkspace, + workspaceProvenanceAuthoritative: completedProvenance, + }), + ).toEqual({ decision: "wizard", persistCompletion: false }); + }); + + it("does not wait for server data after onboarding is already complete", () => { + expect( + resolveFirstRunDecision({ + ...freshWorkspace, + authoritative: false, + bootstrapped: false, + completed: true, + serverConfigAvailable: false, + }), + ).toEqual({ + decision: "app", + persistCompletion: false, + }); + }); +}); + +describe("isFirstRunWorkspaceProvenanceAuthoritative", () => { + it("waits for cwd bootstrap when the initial catalog is empty", () => { + expect( + isFirstRunWorkspaceProvenanceAuthoritative({ + welcomeReceived: true, + bootstrapStatus: "pending", + }), + ).toBe(false); + }); + + it("accepts an empty catalog after cwd bootstrap completes", () => { + expect( + isFirstRunWorkspaceProvenanceAuthoritative({ + welcomeReceived: true, + bootstrapStatus: "complete", + }), + ).toBe(true); + }); + + it("waits for a welcome before treating an empty catalog as final", () => { + expect( + isFirstRunWorkspaceProvenanceAuthoritative({ + welcomeReceived: false, + bootstrapStatus: null, + }), + ).toBe(false); + }); + + it("accepts a legacy welcome without bootstrap status", () => { + expect( + isFirstRunWorkspaceProvenanceAuthoritative({ + welcomeReceived: true, + bootstrapStatus: null, + }), + ).toBe(true); + }); +}); + +describe("transitionFirstRunGateState", () => { + it("shows recovery without mounting the app when evidence stalls", () => { + expect( + transitionFirstRunGateState({ decision: "pending", stalled: false }, { type: "timeout" }), + ).toEqual({ decision: "pending", stalled: true }); + }); + + it.each(["app", "wizard"] as const)( + "resolves stalled recovery to %s only after authoritative evidence", + (decision) => { + expect( + transitionFirstRunGateState( + { decision: "pending", stalled: true }, + { type: "evidence", decision }, + ), + ).toEqual({ decision, stalled: false }); + }, + ); + + it("keeps recovery visible while evidence remains pending", () => { + const state = { decision: "pending", stalled: true } as const; + expect(transitionFirstRunGateState(state, { type: "evidence", decision: "pending" })).toBe( + state, + ); + }); + + it("allows authoritative wizard evidence to replace an app decision", () => { + expect( + transitionFirstRunGateState( + { decision: "app", stalled: false }, + { type: "evidence", decision: "wizard" }, + ), + ).toEqual({ decision: "wizard", stalled: false }); + }); +}); + +describe("resolveHostedFirstRunDecision", () => { + it("keeps the shell hidden until client settings are hydrated", () => { + expect( + resolveHostedFirstRunDecision({ + hydrated: false, + completed: false, + catalogReady: true, + environmentCount: 0, + }), + ).toEqual({ + decision: "pending", + persistCompletion: false, + }); + }); + + it("waits for the saved environment catalog before judging a hosted install", () => { + expect( + resolveHostedFirstRunDecision({ + hydrated: true, + completed: false, + catalogReady: false, + environmentCount: 0, + }), + ).toEqual({ + decision: "pending", + persistCompletion: false, + }); + }); + + it("opens onboarding when a hosted install has no saved environments", () => { + expect( + resolveHostedFirstRunDecision({ + hydrated: true, + completed: false, + catalogReady: true, + environmentCount: 0, + }), + ).toEqual({ + decision: "wizard", + persistCompletion: false, + }); + }); + + it("backfills onboarding for a hosted install with saved environments", () => { + expect( + resolveHostedFirstRunDecision({ + hydrated: true, + completed: false, + catalogReady: true, + environmentCount: 1, + }), + ).toEqual({ + decision: "app", + persistCompletion: true, + }); + }); + + it("opens the app immediately after hosted onboarding is complete", () => { + expect( + resolveHostedFirstRunDecision({ + hydrated: true, + completed: true, + catalogReady: false, + environmentCount: 0, + }), + ).toEqual({ + decision: "app", + persistCompletion: false, + }); + }); +}); + +const primaryEnvironmentId = "primary-environment"; +const bootstrapProject = { + id: "bootstrap-project", + environmentId: primaryEnvironmentId, + workspaceRoot: "/projects/current", +}; +const bootstrapThread = { + id: "bootstrap-thread", + projectId: bootstrapProject.id, + environmentId: primaryEnvironmentId, + latestTurn: null, + latestUserMessageAt: null, + session: null, +}; + +describe("isFreshFirstRunWorkspace", () => { + it("accepts an empty workspace", () => { + expect( + isFreshFirstRunWorkspace({ + primaryEnvironmentId, + serverCwd: "/projects/current", + projects: [], + threads: [], + }), + ).toBe(true); + }); + + it("accepts only the unused project and thread created from the server cwd", () => { + expect( + isFreshFirstRunWorkspace({ + primaryEnvironmentId, + serverCwd: "/projects/current/", + bootstrapProjectId: bootstrapProject.id, + bootstrapThreadId: bootstrapThread.id, + bootstrapProjectCreated: true, + bootstrapThreadCreated: true, + projects: [bootstrapProject], + threads: [bootstrapThread], + }), + ).toBe(true); + }); + + it("rejects an existing unused cwd project and thread reused by startup", () => { + expect( + isFreshFirstRunWorkspace({ + primaryEnvironmentId, + serverCwd: "/projects/current", + bootstrapProjectId: bootstrapProject.id, + bootstrapThreadId: bootstrapThread.id, + bootstrapProjectCreated: false, + bootstrapThreadCreated: false, + projects: [bootstrapProject], + threads: [bootstrapThread], + }), + ).toBe(false); + }); + + it("rejects a nonempty workspace when an older server omits creation provenance", () => { + expect( + isFreshFirstRunWorkspace({ + primaryEnvironmentId, + serverCwd: "/projects/current", + bootstrapProjectId: bootstrapProject.id, + bootstrapThreadId: bootstrapThread.id, + projects: [bootstrapProject], + threads: [bootstrapThread], + }), + ).toBe(false); + }); + + it("normalizes Windows project paths before checking the bootstrap workspace", () => { + expect( + isFreshFirstRunWorkspace({ + primaryEnvironmentId, + serverCwd: "C:\\Projects\\Current\\", + bootstrapProjectId: bootstrapProject.id, + bootstrapThreadId: bootstrapThread.id, + bootstrapProjectCreated: true, + bootstrapThreadCreated: true, + projects: [{ ...bootstrapProject, workspaceRoot: "c:/projects/current" }], + threads: [bootstrapThread], + }), + ).toBe(true); + }); + + it("rejects projects from another environment even when their paths match", () => { + expect( + isFreshFirstRunWorkspace({ + primaryEnvironmentId, + serverCwd: "/projects/current", + projects: [{ ...bootstrapProject, environmentId: "remote-environment" }], + threads: [], + }), + ).toBe(false); + }); + + it("rejects threads from another environment", () => { + expect( + isFreshFirstRunWorkspace({ + primaryEnvironmentId, + serverCwd: "/projects/current", + projects: [bootstrapProject], + threads: [{ ...bootstrapThread, environmentId: "remote-environment" }], + }), + ).toBe(false); + }); + + it("rejects a thread that does not belong to the bootstrap project", () => { + expect( + isFreshFirstRunWorkspace({ + primaryEnvironmentId, + serverCwd: "/projects/current", + projects: [bootstrapProject], + threads: [{ ...bootstrapThread, projectId: "another-project" }], + }), + ).toBe(false); + }); + + it("rejects a thread when there is no bootstrap project", () => { + expect( + isFreshFirstRunWorkspace({ + primaryEnvironmentId, + serverCwd: "/projects/current", + projects: [], + threads: [bootstrapThread], + }), + ).toBe(false); + }); + + it("rejects a bootstrap thread that already has a user message", () => { + expect( + isFreshFirstRunWorkspace({ + primaryEnvironmentId, + serverCwd: "/projects/current", + projects: [bootstrapProject], + threads: [ + { + ...bootstrapThread, + latestUserMessageAt: "2026-08-23T12:00:00.000Z", + }, + ], + }), + ).toBe(false); + }); + + it("rejects a bootstrap thread that has started a turn", () => { + expect( + isFreshFirstRunWorkspace({ + primaryEnvironmentId, + serverCwd: "/projects/current", + projects: [bootstrapProject], + threads: [{ ...bootstrapThread, latestTurn: { id: "first-turn" } }], + }), + ).toBe(false); + }); + + it("rejects a bootstrap thread that has a provider session", () => { + expect( + isFreshFirstRunWorkspace({ + primaryEnvironmentId, + serverCwd: "/projects/current", + projects: [bootstrapProject], + threads: [{ ...bootstrapThread, session: { status: "ready" } }], + }), + ).toBe(false); + }); +}); diff --git a/apps/web/src/onboarding/firstRun.logic.ts b/apps/web/src/onboarding/firstRun.logic.ts new file mode 100644 index 000000000000..013dbb02d527 --- /dev/null +++ b/apps/web/src/onboarding/firstRun.logic.ts @@ -0,0 +1,184 @@ +import { normalizeProjectPathForComparison } from "@t3tools/shared/path"; + +export type FirstRunDecision = "pending" | "app" | "wizard"; + +export interface FirstRunGateState { + readonly decision: FirstRunDecision; + readonly stalled: boolean; +} + +type FirstRunGateEvent = + | { readonly type: "evidence"; readonly decision: FirstRunDecision } + | { readonly type: "timeout" }; + +interface FirstRunWorkspaceInput { + readonly primaryEnvironmentId: string | null; + readonly serverCwd: string | null; + readonly bootstrapProjectId?: string | undefined; + readonly bootstrapThreadId?: string | undefined; + readonly bootstrapProjectCreated?: boolean | undefined; + readonly bootstrapThreadCreated?: boolean | undefined; + readonly projects: ReadonlyArray<{ + readonly id: string; + readonly environmentId: string; + readonly workspaceRoot: string; + }>; + readonly threads: ReadonlyArray<{ + readonly id: string; + readonly projectId: string; + readonly environmentId: string; + readonly latestTurn: unknown; + readonly latestUserMessageAt: string | null; + readonly session: unknown; + }>; +} + +interface FirstRunDecisionInput { + readonly enabled: boolean; + readonly hydrated: boolean; + readonly completed: boolean; + readonly bootstrapped: boolean; + readonly authoritative: boolean; + readonly workspaceAuthoritative: boolean; + readonly workspaceProvenanceAuthoritative: boolean; + readonly catalogReady: boolean; + readonly serverConfigAvailable: boolean; + readonly workspaceFresh: boolean; + readonly projectCount: number; + readonly threadCount: number; +} + +interface HostedFirstRunDecisionInput { + readonly hydrated: boolean; + readonly completed: boolean; + readonly catalogReady: boolean; + readonly environmentCount: number; +} + +export function isFirstRunWorkspaceProvenanceAuthoritative(input: { + readonly welcomeReceived: boolean; + readonly bootstrapStatus: "pending" | "complete" | null; +}): boolean { + // An empty catalog is not final while cwd auto-bootstrap is pending. Older + // servers omit bootstrapStatus, so a received welcome with null stays valid. + return input.welcomeReceived && input.bootstrapStatus !== "pending"; +} + +/** Keeps the authenticated app unmounted until workspace evidence settles. */ +export function transitionFirstRunGateState( + state: FirstRunGateState, + event: FirstRunGateEvent, +): FirstRunGateState { + if (event.type === "timeout") { + return state.decision === "pending" && !state.stalled ? { ...state, stalled: true } : state; + } + + if ( + state.decision === "wizard" || + event.decision === "pending" || + (state.decision === "app" && event.decision !== "wizard") + ) { + return state; + } + + return { decision: event.decision, stalled: false }; +} + +/** Only a project and thread created by this startup count as a fresh nonempty workspace. */ +export function isFreshFirstRunWorkspace(input: FirstRunWorkspaceInput): boolean { + if (input.projects.length > 1 || input.threads.length > 1) { + return false; + } + + const bootstrapProject = input.projects[0]; + if (bootstrapProject !== undefined) { + if ( + input.bootstrapProjectCreated !== true || + input.bootstrapProjectId !== bootstrapProject.id || + input.serverCwd === null || + bootstrapProject.environmentId !== input.primaryEnvironmentId || + normalizeProjectPathForComparison(bootstrapProject.workspaceRoot) !== + normalizeProjectPathForComparison(input.serverCwd) + ) { + return false; + } + } + + const bootstrapThread = input.threads[0]; + if (bootstrapThread === undefined) { + return true; + } + + return ( + bootstrapProject !== undefined && + input.bootstrapThreadCreated === true && + input.bootstrapThreadId === bootstrapThread.id && + bootstrapThread.environmentId === input.primaryEnvironmentId && + bootstrapThread.projectId === bootstrapProject.id && + bootstrapThread.latestTurn === null && + bootstrapThread.latestUserMessageAt === null && + bootstrapThread.session === null + ); +} + +/** Cached projects may open the app, but only live workspace data may complete onboarding. */ +export function resolveFirstRunDecision(input: FirstRunDecisionInput): { + readonly decision: FirstRunDecision; + readonly persistCompletion: boolean; +} { + if (!input.enabled || (input.hydrated && input.completed)) { + return { decision: "app", persistCompletion: false }; + } + + if (!input.hydrated) { + return { decision: "pending", persistCompletion: false }; + } + + if (input.projectCount > 1 || input.threadCount > 1) { + return { + decision: "app", + persistCompletion: + input.bootstrapped && + input.authoritative && + input.workspaceAuthoritative && + input.catalogReady && + input.serverConfigAvailable, + }; + } + + if ( + !input.bootstrapped || + !input.authoritative || + !input.workspaceProvenanceAuthoritative || + !input.catalogReady || + !input.serverConfigAvailable + ) { + return { decision: "pending", persistCompletion: false }; + } + + return input.workspaceFresh + ? { decision: "wizard", persistCompletion: false } + : { decision: "app", persistCompletion: input.workspaceAuthoritative }; +} + +/** Hosted onboarding depends on saved environments because there is no primary server. */ +export function resolveHostedFirstRunDecision(input: HostedFirstRunDecisionInput): { + readonly decision: FirstRunDecision; + readonly persistCompletion: boolean; +} { + if (!input.hydrated) { + return { decision: "pending", persistCompletion: false }; + } + + if (input.completed) { + return { decision: "app", persistCompletion: false }; + } + + if (!input.catalogReady) { + return { decision: "pending", persistCompletion: false }; + } + + return input.environmentCount === 0 + ? { decision: "wizard", persistCompletion: false } + : { decision: "app", persistCompletion: true }; +} diff --git a/apps/web/src/onboarding/firstRun.ts b/apps/web/src/onboarding/firstRun.ts new file mode 100644 index 000000000000..4daafc7cc924 --- /dev/null +++ b/apps/web/src/onboarding/firstRun.ts @@ -0,0 +1,16 @@ +import { useCallback } from "react"; + +import { ensureClientSettingsHydrated, persistClientSettingsUpdate } from "../hooks/useSettings"; + +/** + * Marks first-run onboarding finished (or skipped) so FirstRunGate never + * routes to the welcome wizard again. The gate itself lives in + * `components/onboarding/FirstRunGate.tsx`. + */ +export function useCompleteOnboarding(): () => Promise { + return useCallback(async () => { + await ensureClientSettingsHydrated(); + const onboardingCompletedAt = new Date().toISOString(); + await persistClientSettingsUpdate((current) => ({ ...current, onboardingCompletedAt })); + }, []); +} diff --git a/apps/web/src/onboarding/projectImport.logic.test.ts b/apps/web/src/onboarding/projectImport.logic.test.ts new file mode 100644 index 000000000000..07028abd28b4 --- /dev/null +++ b/apps/web/src/onboarding/projectImport.logic.test.ts @@ -0,0 +1,245 @@ +import { EnvironmentId, ProjectId, type AgentSessionProjectCandidate } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + partitionOnboardingProjects, + resolveOnboardingLandingProject, + resolveOnboardingProjectId, +} from "./projectImport.logic"; + +const now = Date.parse("2026-08-22T12:00:00.000Z"); + +function candidate( + path: string, + overrides: Partial = {}, +): AgentSessionProjectCandidate { + return { + title: path.split("/").at(-1) ?? path, + path, + sources: ["codex"], + threadCount: 1, + lastActiveAt: "2026-08-20T12:00:00.000Z", + alreadyImported: false, + ...overrides, + }; +} + +describe("partitionOnboardingProjects", () => { + it("keeps existing projects available for thread history import", () => { + const imported = candidate("/projects/current", { alreadyImported: true }); + const available = candidate("/projects/other"); + + expect(partitionOnboardingProjects([imported, available], now)).toEqual({ + available: [imported, available], + recent: [imported, available], + }); + }); + + it("keeps projects older than 30 days out of the default selection", () => { + const recent = candidate("/projects/recent"); + const older = candidate("/projects/older", { + lastActiveAt: "2026-07-01T12:00:00.000Z", + }); + + expect(partitionOnboardingProjects([recent, older], now)).toEqual({ + available: [recent, older], + recent: [recent], + }); + }); + + it("keeps future activity out of the default selection", () => { + const recent = candidate("/projects/recent"); + const future = candidate("/projects/future", { + lastActiveAt: "2026-08-23T12:00:00.000Z", + }); + + expect(partitionOnboardingProjects([recent, future], now)).toEqual({ + available: [recent, future], + recent: [recent], + }); + }); +}); + +describe("resolveOnboardingProjectId", () => { + const localEnvironmentId = EnvironmentId.make("local"); + const remoteEnvironmentId = EnvironmentId.make("remote"); + const localProjectId = ProjectId.make("local-project"); + + it("uses the scanned project ID before the project reaches the client", () => { + expect( + resolveOnboardingProjectId( + [], + localEnvironmentId, + candidate("/projects/repo", { projectId: localProjectId }), + ), + ).toBe(localProjectId); + }); + + it("uses the scanned project ID when the client still has an older project at that root", () => { + expect( + resolveOnboardingProjectId( + [ + { + id: ProjectId.make("stale-project"), + environmentId: localEnvironmentId, + workspaceRoot: "/projects/repo", + }, + ], + localEnvironmentId, + candidate("/projects/repo", { projectId: localProjectId }), + ), + ).toBe(localProjectId); + }); + + it("returns null to create a project when neither the scan nor the client has a project ID", () => { + expect( + resolveOnboardingProjectId([], localEnvironmentId, candidate("/projects/new")), + ).toBeNull(); + }); + + it("finds an existing project by normalized root in the target environment", () => { + expect( + resolveOnboardingProjectId( + [ + { + id: ProjectId.make("remote-project"), + environmentId: remoteEnvironmentId, + workspaceRoot: "C:\\Work\\Repo", + }, + { + id: localProjectId, + environmentId: localEnvironmentId, + workspaceRoot: "C:\\Work\\Repo\\", + }, + ], + localEnvironmentId, + candidate("c:/work/repo"), + ), + ).toBe(localProjectId); + }); + + it("does not reuse a project from another environment", () => { + expect( + resolveOnboardingProjectId( + [ + { + id: ProjectId.make("remote-project"), + environmentId: remoteEnvironmentId, + workspaceRoot: "/projects/repo", + }, + ], + localEnvironmentId, + candidate("/projects/repo"), + ), + ).toBeNull(); + }); + + it("finds an alias after the scanner returns its persisted project root", () => { + expect( + resolveOnboardingProjectId( + [ + { + id: localProjectId, + environmentId: localEnvironmentId, + workspaceRoot: "/real/projects/repo", + }, + ], + localEnvironmentId, + candidate("/real/projects/repo"), + ), + ).toBe(localProjectId); + }); + + it("finds the current root owner when the scan has no project ID", () => { + const recreatedProjectId = ProjectId.make("recreated-project"); + expect( + resolveOnboardingProjectId( + [ + { + id: localProjectId, + environmentId: localEnvironmentId, + workspaceRoot: "/projects/other", + }, + { + id: recreatedProjectId, + environmentId: localEnvironmentId, + workspaceRoot: "/projects/repo", + }, + ], + localEnvironmentId, + candidate("/projects/repo"), + ), + ).toBe(recreatedProjectId); + }); + + it("does not reuse a moved project when the scan has no project ID", () => { + expect( + resolveOnboardingProjectId( + [ + { + id: localProjectId, + environmentId: localEnvironmentId, + workspaceRoot: "/projects/moved", + }, + ], + localEnvironmentId, + candidate("/projects/repo"), + ), + ).toBeNull(); + }); +}); + +describe("resolveOnboardingLandingProject", () => { + it("skips a failed first project for a later project with imported history", () => { + expect( + resolveOnboardingLandingProject( + ["/projects/failed", "/projects/imported"], + new Map([["/projects/imported", "imported"]]), + new Map([["/projects/imported", "imported"]]), + ), + ).toBe("imported"); + }); + + it("prefers a partial first import that added history", () => { + expect( + resolveOnboardingLandingProject( + ["/projects/partial", "/projects/complete"], + new Map([["/projects/partial", "partial"]]), + new Map([["/projects/complete", "complete"]]), + ), + ).toBe("partial"); + }); + + it("uses a completed zero-history project when no import added history", () => { + expect( + resolveOnboardingLandingProject( + ["/projects/empty", "/projects/failed"], + new Map(), + new Map([["/projects/empty", "empty"]]), + ), + ).toBe("empty"); + }); + + it("keeps an earlier successful import available on retry", () => { + expect( + resolveOnboardingLandingProject( + ["/projects/imported", "/projects/retry"], + new Map([["/projects/imported", "imported"]]), + new Map([["/projects/imported", "imported"]]), + ), + ).toBe("imported"); + }); + + it("ignores cached successes outside the current retry selection", () => { + expect( + resolveOnboardingLandingProject( + ["/projects/current"], + new Map([["/projects/previous", "previous"]]), + new Map([ + ["/projects/previous", "previous"], + ["/projects/current", "current"], + ]), + ), + ).toBe("current"); + }); +}); diff --git a/apps/web/src/onboarding/projectImport.logic.ts b/apps/web/src/onboarding/projectImport.logic.ts new file mode 100644 index 000000000000..d723b911b665 --- /dev/null +++ b/apps/web/src/onboarding/projectImport.logic.ts @@ -0,0 +1,55 @@ +import { findProjectByPath } from "@t3tools/client-runtime/state/projects"; +import type { AgentSessionProjectCandidate, EnvironmentId, ProjectId } from "@t3tools/contracts"; + +const RECENT_PROJECT_WINDOW_MS = 30 * 24 * 60 * 60 * 1000; + +/** Existing projects still need their agent history imported, so every scan candidate is offered. */ +export function partitionOnboardingProjects( + candidates: ReadonlyArray, + now = Date.now(), +) { + const cutoff = now - RECENT_PROJECT_WINDOW_MS; + + return { + available: candidates, + recent: candidates.filter((candidate) => { + if (candidate.lastActiveAt === null) return false; + const lastActiveAt = Date.parse(candidate.lastActiveAt); + return lastActiveAt >= cutoff && lastActiveAt <= now; + }), + }; +} + +/** Use the server's project match before the client snapshot, which can lag behind the scan. */ +export function resolveOnboardingProjectId( + projects: ReadonlyArray<{ + readonly id: ProjectId; + readonly environmentId: EnvironmentId; + readonly workspaceRoot: string; + }>, + environmentId: EnvironmentId, + candidate: Pick, +): ProjectId | null { + if (candidate.projectId !== undefined) return candidate.projectId; + const environmentProjects = projects.filter((project) => project.environmentId === environmentId); + const currentRootMatch = findProjectByPath(environmentProjects, candidate.path); + if (currentRootMatch !== undefined) return currentRootMatch.id; + return null; +} + +/** Prefer a selected project with imported history, then a completed empty import. */ +export function resolveOnboardingLandingProject( + selection: ReadonlyArray, + projectsWithImportedHistory: ReadonlyMap, + completedProjects: ReadonlyMap, +): T | undefined { + for (const path of selection) { + const project = projectsWithImportedHistory.get(path); + if (project !== undefined) return project; + } + for (const path of selection) { + const project = completedProjects.get(path); + if (project !== undefined) return project; + } + return undefined; +} diff --git a/apps/web/src/onboarding/providerReadiness.logic.test.ts b/apps/web/src/onboarding/providerReadiness.logic.test.ts new file mode 100644 index 000000000000..ab742ac51b44 --- /dev/null +++ b/apps/web/src/onboarding/providerReadiness.logic.test.ts @@ -0,0 +1,317 @@ +import { + DEFAULT_SERVER_SETTINGS, + ProviderDriverKind, + ProviderInstanceId, + type ServerProvider, +} from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + getOnboardingProviderState, + resolveOnboardingProviderLoginCommand, + selectOnboardingProvidersByDriver, +} from "./providerReadiness.logic"; + +const readyCodex: ServerProvider = { + instanceId: ProviderInstanceId.make("codex"), + driver: ProviderDriverKind.make("codex"), + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { status: "unknown" }, + checkedAt: "2026-08-23T00:00:00.000Z", + models: [], + slashCommands: [], + skills: [], +}; + +describe("getOnboardingProviderState", () => { + it("treats an enabled Codex provider with ready status and unknown authentication as ready", () => { + expect(getOnboardingProviderState(readyCodex)).toBe("ready"); + }); + + it("treats authenticated providers as ready only when their provider status is ready", () => { + expect(getOnboardingProviderState({ ...readyCodex, auth: { status: "authenticated" } })).toBe( + "ready", + ); + expect( + getOnboardingProviderState({ + ...readyCodex, + auth: { status: "authenticated" }, + status: "error", + }), + ).toBe("attention"); + expect( + getOnboardingProviderState({ + ...readyCodex, + auth: { status: "authenticated" }, + status: "warning", + }), + ).toBe("attention"); + }); + + it("offers sign-in only when the server reports an authentication failure", () => { + expect( + getOnboardingProviderState({ + ...readyCodex, + status: "error", + auth: { status: "unauthenticated" }, + }), + ).toBe("signIn"); + expect(getOnboardingProviderState({ ...readyCodex, status: "error" })).toBe("attention"); + expect(getOnboardingProviderState({ ...readyCodex, status: "warning" })).toBe("attention"); + }); + + it("does not offer installation or sign-in for disabled providers", () => { + expect(getOnboardingProviderState({ ...readyCodex, enabled: false, installed: false })).toBe( + "disabled", + ); + expect(getOnboardingProviderState({ ...readyCodex, status: "disabled" })).toBe("disabled"); + }); + + it("offers installation only when an enabled provider is missing", () => { + expect(getOnboardingProviderState({ ...readyCodex, installed: false, status: "error" })).toBe( + "install", + ); + }); + + it("waits for a provider snapshot before offering an action", () => { + expect(getOnboardingProviderState(undefined)).toBe("checking"); + }); +}); + +describe("selectOnboardingProvidersByDriver", () => { + it("prefers a ready instance with unknown authentication to an unauthenticated instance", () => { + const signedOutCodex: ServerProvider = { + ...readyCodex, + instanceId: ProviderInstanceId.make("codex_work"), + status: "error", + auth: { status: "unauthenticated" }, + }; + + expect(selectOnboardingProvidersByDriver([signedOutCodex, readyCodex]).get("codex")).toBe( + readyCodex, + ); + }); + + it("prefers a provider with an actionable sign-in over a failed provider", () => { + const failedCodex: ServerProvider = { ...readyCodex, status: "error" }; + const signedOutCodex: ServerProvider = { + ...readyCodex, + instanceId: ProviderInstanceId.make("codex_work"), + status: "error", + auth: { status: "unauthenticated" }, + }; + + expect(selectOnboardingProvidersByDriver([failedCodex, signedOutCodex]).get("codex")).toBe( + signedOutCodex, + ); + }); + + it("prefers installed providers over missing or disabled instances", () => { + const disabledCodex: ServerProvider = { ...readyCodex, enabled: false }; + const missingCodex: ServerProvider = { + ...readyCodex, + instanceId: ProviderInstanceId.make("codex_work"), + installed: false, + status: "error", + }; + + expect( + selectOnboardingProvidersByDriver([disabledCodex, missingCodex, readyCodex]).get("codex"), + ).toBe(readyCodex); + }); + + it("handles provider snapshots that have not arrived", () => { + expect(selectOnboardingProvidersByDriver(undefined).size).toBe(0); + }); + + it("keeps a ready custom account when the default account is signed out", () => { + const signedOutDefault: ServerProvider = { + ...readyCodex, + status: "error", + auth: { status: "unauthenticated" }, + }; + const readyCustom: ServerProvider = { + ...readyCodex, + instanceId: ProviderInstanceId.make("codex_work"), + }; + + expect(selectOnboardingProvidersByDriver([signedOutDefault, readyCustom]).get("codex")).toBe( + readyCustom, + ); + }); +}); + +describe("resolveOnboardingProviderLoginCommand", () => { + it("uses the selected Codex account binary", () => { + const provider = { ...readyCodex, instanceId: ProviderInstanceId.make("codex_work") }; + + expect( + resolveOnboardingProviderLoginCommand( + provider, + { + ...DEFAULT_SERVER_SETTINGS, + providerInstances: { + [provider.instanceId]: { + driver: provider.driver, + config: { binaryPath: "/opt/codex-work/bin/codex" }, + }, + }, + }, + "linux", + ), + ).toBe("/opt/codex-work/bin/codex login"); + }); + + it("uses the selected Claude account binary", () => { + const provider: ServerProvider = { + ...readyCodex, + driver: ProviderDriverKind.make("claudeAgent"), + instanceId: ProviderInstanceId.make("claude_work"), + }; + + expect( + resolveOnboardingProviderLoginCommand( + provider, + { + ...DEFAULT_SERVER_SETTINGS, + providerInstances: { + [provider.instanceId]: { + driver: provider.driver, + config: { binaryPath: "/opt/claude-work/bin/claude" }, + }, + }, + }, + "linux", + ), + ).toBe("/opt/claude-work/bin/claude auth login"); + }); + + it("quotes a Codex path with spaces for PowerShell", () => { + expect( + resolveOnboardingProviderLoginCommand( + readyCodex, + { + ...DEFAULT_SERVER_SETTINGS, + providers: { + ...DEFAULT_SERVER_SETTINGS.providers, + codex: { + ...DEFAULT_SERVER_SETTINGS.providers.codex, + binaryPath: "C:\\Program Files\\Codex & Tools\\codex.exe", + }, + }, + }, + "windows", + ), + ).toBe("& 'C:\\Program Files\\Codex & Tools\\codex.exe' login"); + }); + + it("quotes a Claude path with shell metacharacters on POSIX", () => { + const provider: ServerProvider = { + ...readyCodex, + driver: ProviderDriverKind.make("claudeAgent"), + instanceId: ProviderInstanceId.make("claude"), + }; + + expect( + resolveOnboardingProviderLoginCommand( + provider, + { + ...DEFAULT_SERVER_SETTINGS, + providers: { + ...DEFAULT_SERVER_SETTINGS.providers, + claudeAgent: { + ...DEFAULT_SERVER_SETTINGS.providers.claudeAgent, + binaryPath: "/opt/Claude Tools/$current/claude", + }, + }, + }, + "linux", + ), + ).toBe("'/opt/Claude Tools/$current/claude' auth login"); + }); + + it.each([ + ["~/my tools/codex", "~/'my tools/codex' login"], + ["~\\my tools/codex", "~/'my tools/codex' login"], + ["~/tools/codex's build", `~/'tools/codex'"'"'s build' login`], + ["~\\tools\\codex's build", `~/'tools\\codex'"'"'s build' login`], + ["~/tools/codex; echo unsafe", "~/'tools/codex; echo unsafe' login"], + ])("keeps the home prefix expandable while quoting %s", (binaryPath, expectedCommand) => { + expect( + resolveOnboardingProviderLoginCommand( + readyCodex, + { + ...DEFAULT_SERVER_SETTINGS, + providers: { + ...DEFAULT_SERVER_SETTINGS.providers, + codex: { + ...DEFAULT_SERVER_SETTINGS.providers.codex, + binaryPath, + }, + }, + }, + "linux", + ), + ).toBe(expectedCommand); + }); + + it.each(["darwin", "linux"] as const)("quotes backslashes in a Codex path on %s", (platform) => { + expect( + resolveOnboardingProviderLoginCommand( + readyCodex, + { + ...DEFAULT_SERVER_SETTINGS, + providers: { + ...DEFAULT_SERVER_SETTINGS.providers, + codex: { + ...DEFAULT_SERVER_SETTINGS.providers.codex, + binaryPath: "/opt/codex\\work/codex", + }, + }, + }, + platform, + ), + ).toBe("'/opt/codex\\work/codex' login"); + }); + + it("keeps a plain Windows path unquoted", () => { + expect( + resolveOnboardingProviderLoginCommand( + readyCodex, + { + ...DEFAULT_SERVER_SETTINGS, + providers: { + ...DEFAULT_SERVER_SETTINGS.providers, + codex: { + ...DEFAULT_SERVER_SETTINGS.providers.codex, + binaryPath: "C:\\Tools\\codex.exe", + }, + }, + }, + "windows", + ), + ).toBe("C:\\Tools\\codex.exe login"); + }); + + it("uses the default command when an old server reports an unknown shell", () => { + expect( + resolveOnboardingProviderLoginCommand( + readyCodex, + { + ...DEFAULT_SERVER_SETTINGS, + providers: { + ...DEFAULT_SERVER_SETTINGS.providers, + codex: { + ...DEFAULT_SERVER_SETTINGS.providers.codex, + binaryPath: "/opt/Codex Tools/codex", + }, + }, + }, + "unknown", + ), + ).toBe("codex login"); + }); +}); diff --git a/apps/web/src/onboarding/providerReadiness.logic.ts b/apps/web/src/onboarding/providerReadiness.logic.ts new file mode 100644 index 000000000000..939b4c64cd64 --- /dev/null +++ b/apps/web/src/onboarding/providerReadiness.logic.ts @@ -0,0 +1,99 @@ +import { + ClaudeSettings, + CodexSettings, + type ExecutionEnvironmentPlatformOs, + type ServerProvider, + type ServerSettings, +} from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +const decodeClaudeSettings = Schema.decodeUnknownOption(ClaudeSettings); +const decodeCodexSettings = Schema.decodeUnknownOption(CodexSettings); +const SAFE_SHELL_BINARY_PATTERN = /^[A-Za-z0-9_./:\\-]+$/; + +function quoteProviderBinary( + binaryPath: string, + fallback: string, + platform: ExecutionEnvironmentPlatformOs, +): string { + if ( + SAFE_SHELL_BINARY_PATTERN.test(binaryPath) && + (platform === "windows" || !binaryPath.includes("\\")) + ) { + return binaryPath; + } + if (platform === "windows") return `& '${binaryPath.replaceAll("'", "''")}'`; + if (platform === "darwin" || platform === "linux") { + if (binaryPath.startsWith("~/") || binaryPath.startsWith("~\\")) { + return `~/'${binaryPath.slice(2).replaceAll("'", `'"'"'`)}'`; + } + return `'${binaryPath.replaceAll("'", `'"'"'`)}'`; + } + return fallback; +} + +export function getOnboardingProviderState(provider: ServerProvider | undefined) { + if (provider === undefined) return "checking"; + if (!provider.enabled || provider.status === "disabled") return "disabled"; + if (!provider.installed) return "install"; + if (provider.auth.status === "unauthenticated") return "signIn"; + if (provider.status === "ready") return "ready"; + return "attention"; +} + +const PROVIDER_STATE_PRIORITY = { + checking: 0, + disabled: 1, + install: 2, + attention: 3, + signIn: 4, + ready: 5, +} as const; + +/** Select the most usable configured instance for each provider driver. */ +export function selectOnboardingProvidersByDriver( + providers: ReadonlyArray | null | undefined, +) { + const providersByDriver = new Map(); + + for (const provider of providers ?? []) { + const existing = providersByDriver.get(provider.driver); + if ( + existing === undefined || + PROVIDER_STATE_PRIORITY[getOnboardingProviderState(provider)] > + PROVIDER_STATE_PRIORITY[getOnboardingProviderState(existing)] + ) { + providersByDriver.set(provider.driver, provider); + } + } + + return providersByDriver; +} + +/** Use the selected provider instance's binary when the setup terminal opens its login flow. */ +export function resolveOnboardingProviderLoginCommand( + provider: ServerProvider, + settings: ServerSettings, + platform: ExecutionEnvironmentPlatformOs, +): string { + const instance = settings.providerInstances[provider.instanceId]; + + if (provider.driver === "claudeAgent") { + const config = decodeClaudeSettings( + instance ? (instance.config ?? {}) : settings.providers.claudeAgent, + ); + const binaryPath = Option.isSome(config) ? config.value.binaryPath : "claude"; + return `${quoteProviderBinary(binaryPath, "claude", platform)} auth login`; + } + + if (provider.driver === "codex") { + const config = decodeCodexSettings( + instance ? (instance.config ?? {}) : settings.providers.codex, + ); + const binaryPath = Option.isSome(config) ? config.value.binaryPath : "codex"; + return `${quoteProviderBinary(binaryPath, "codex", platform)} login`; + } + + return provider.driver; +} diff --git a/apps/web/src/onboarding/targetEnvironment.logic.test.ts b/apps/web/src/onboarding/targetEnvironment.logic.test.ts new file mode 100644 index 000000000000..9928f83b27db --- /dev/null +++ b/apps/web/src/onboarding/targetEnvironment.logic.test.ts @@ -0,0 +1,211 @@ +import { + BearerConnectionTarget, + PrimaryConnectionTarget, + RelayConnectionTarget, + SshConnectionTarget, +} from "@t3tools/client-runtime/connection"; +import { EnvironmentId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + isOnboardingRelayEnvironment, + resolveOnboardingTargetEnvironment, +} from "./targetEnvironment.logic"; + +const primaryEnvironment = { + environmentId: EnvironmentId.make("primary"), + connection: { phase: "connected" }, + entry: { + target: new PrimaryConnectionTarget({ + environmentId: EnvironmentId.make("primary"), + label: "This computer", + httpBaseUrl: "http://127.0.0.1:3773", + wsBaseUrl: "ws://127.0.0.1:3773", + }), + }, + label: "This computer", +} as const; + +const olderRelay = { + environmentId: EnvironmentId.make("older-remote"), + connection: { phase: "connected" }, + entry: { + target: new RelayConnectionTarget({ + environmentId: EnvironmentId.make("older-remote"), + label: "Older computer", + }), + }, + label: "Older computer", +} as const; + +const newerRelay = { + environmentId: EnvironmentId.make("newer-relay"), + connection: { phase: "connected" }, + entry: { + target: new RelayConnectionTarget({ + environmentId: EnvironmentId.make("newer-relay"), + label: "New computer", + }), + }, + label: "New computer", +} as const; + +const pairedRemote = { + environmentId: EnvironmentId.make("paired-remote"), + connection: { phase: "connected" }, + entry: { + target: new BearerConnectionTarget({ + environmentId: EnvironmentId.make("paired-remote"), + label: "Direct computer", + connectionId: "paired-remote", + }), + }, + label: "Direct computer", +} as const; + +const sshEnvironment = { + environmentId: EnvironmentId.make("ssh-remote"), + connection: { phase: "connected" }, + entry: { + target: new SshConnectionTarget({ + environmentId: EnvironmentId.make("ssh-remote"), + label: "SSH computer", + connectionId: "ssh-remote", + }), + }, + label: "SSH computer", +} as const; + +const desktopLocalEnvironment = { + environmentId: EnvironmentId.make("desktop-local-wsl"), + connection: { phase: "connected" }, + entry: { + target: new BearerConnectionTarget({ + environmentId: EnvironmentId.make("desktop-local-wsl"), + label: "WSL", + connectionId: "local:wsl:Ubuntu", + }), + }, + label: "WSL", +} as const; + +describe("resolveOnboardingTargetEnvironment", () => { + it("waits for the exact paired machine instead of using an older connected machine", () => { + const pendingPairedRemote = { ...pairedRemote, connection: { phase: "connecting" } }; + + expect( + resolveOnboardingTargetEnvironment({ + mode: "direct", + environments: [primaryEnvironment, olderRelay, pendingPairedRemote], + primaryEnvironment, + pairedEnvironmentId: pairedRemote.environmentId, + }), + ).toBeNull(); + }); + + it("uses the exact paired machine once it connects", () => { + expect( + resolveOnboardingTargetEnvironment({ + mode: "direct", + environments: [primaryEnvironment, olderRelay, pairedRemote], + primaryEnvironment, + pairedEnvironmentId: pairedRemote.environmentId, + }), + ).toBe(pairedRemote); + }); + + it("waits for a newly paired machine that has not appeared in the catalog", () => { + expect( + resolveOnboardingTargetEnvironment({ + mode: "direct", + environments: [primaryEnvironment, olderRelay], + primaryEnvironment, + pairedEnvironmentId: pairedRemote.environmentId, + }), + ).toBeNull(); + }); + + it("uses the primary machine for local onboarding", () => { + expect( + resolveOnboardingTargetEnvironment({ + mode: "local", + environments: [primaryEnvironment, olderRelay], + primaryEnvironment, + pairedEnvironmentId: null, + }), + ).toBe(primaryEnvironment); + }); + + it("does not substitute a remote machine when the local primary is offline", () => { + const offlinePrimary = { ...primaryEnvironment, connection: { phase: "disconnected" } }; + + expect( + resolveOnboardingTargetEnvironment({ + mode: "local", + environments: [offlinePrimary, olderRelay], + primaryEnvironment: offlinePrimary, + pairedEnvironmentId: null, + }), + ).toBeNull(); + }); + + it("uses the newest connected remote when no exact machine was selected", () => { + expect( + resolveOnboardingTargetEnvironment({ + mode: "connect", + environments: [primaryEnvironment, olderRelay, newerRelay], + primaryEnvironment, + pairedEnvironmentId: null, + }), + ).toBe(newerRelay); + }); + + it("ignores direct, SSH, and desktop-managed connections in Connect mode", () => { + expect( + resolveOnboardingTargetEnvironment({ + mode: "connect", + environments: [ + primaryEnvironment, + olderRelay, + pairedRemote, + sshEnvironment, + desktopLocalEnvironment, + ], + primaryEnvironment, + pairedEnvironmentId: null, + }), + ).toBe(olderRelay); + }); + + it("uses the primary computer when no relay connection exists", () => { + expect( + resolveOnboardingTargetEnvironment({ + mode: "connect", + environments: [primaryEnvironment, pairedRemote, sshEnvironment, desktopLocalEnvironment], + primaryEnvironment, + pairedEnvironmentId: null, + }), + ).toBe(primaryEnvironment); + }); + + it("falls back to the connected primary when no remote is available", () => { + expect( + resolveOnboardingTargetEnvironment({ + mode: "connect", + environments: [primaryEnvironment], + primaryEnvironment, + pairedEnvironmentId: null, + }), + ).toBe(primaryEnvironment); + }); +}); + +describe("isOnboardingRelayEnvironment", () => { + it("includes only T3 Connect relay targets", () => { + expect( + [olderRelay, pairedRemote, sshEnvironment, desktopLocalEnvironment].filter( + isOnboardingRelayEnvironment, + ), + ).toEqual([olderRelay]); + }); +}); diff --git a/apps/web/src/onboarding/targetEnvironment.logic.ts b/apps/web/src/onboarding/targetEnvironment.logic.ts new file mode 100644 index 000000000000..6045b8c441e2 --- /dev/null +++ b/apps/web/src/onboarding/targetEnvironment.logic.ts @@ -0,0 +1,49 @@ +import type { ConnectionTarget } from "@t3tools/client-runtime/connection"; +import type { EnvironmentId } from "@t3tools/contracts"; + +interface OnboardingEnvironment { + readonly environmentId: EnvironmentId; + readonly connection: { readonly phase: string }; + readonly entry: { readonly target: ConnectionTarget }; +} + +export function isOnboardingRelayEnvironment( + environment: Pick, +): boolean { + return environment.entry.target._tag === "RelayConnectionTarget"; +} + +/** Keep a directly paired machine pinned while its initial connection completes. */ +export function resolveOnboardingTargetEnvironment({ + mode, + environments, + primaryEnvironment, + pairedEnvironmentId, +}: { + readonly mode: "local" | "connect" | "direct"; + readonly environments: ReadonlyArray; + readonly primaryEnvironment: TEnvironment | null; + readonly pairedEnvironmentId: EnvironmentId | null; +}): TEnvironment | null { + if (mode === "direct" && pairedEnvironmentId !== null) { + const pairedEnvironment = environments.find( + (environment) => environment.environmentId === pairedEnvironmentId, + ); + return pairedEnvironment?.connection.phase === "connected" ? pairedEnvironment : null; + } + + const connectedRelayEnvironments = environments.filter( + (environment) => + environment.connection.phase === "connected" && isOnboardingRelayEnvironment(environment), + ); + + if (mode === "connect" && connectedRelayEnvironments.length > 0) { + return connectedRelayEnvironments[connectedRelayEnvironments.length - 1] ?? null; + } + + if (primaryEnvironment?.connection.phase === "connected") { + return primaryEnvironment; + } + + return mode === "local" ? null : (connectedRelayEnvironments[0] ?? null); +} diff --git a/apps/web/src/previewStateStore.test.ts b/apps/web/src/previewStateStore.test.ts index 321dd68c09aa..f9956f9c02c8 100644 --- a/apps/web/src/previewStateStore.test.ts +++ b/apps/web/src/previewStateStore.test.ts @@ -18,12 +18,11 @@ import { readThreadPreviewState, reconcilePreviewServerSessions, rememberPreviewUrl, - removePreviewThread, resetPreviewStateForTests, - subscribeThreadPreviewState, setActivePreviewTab, updatePreviewServerSnapshot, } from "./previewStateStore"; +import { appAtomRegistry } from "./rpc/atomRegistry"; const environmentId = "env-1" as EnvironmentId; const ref = scopeThreadRef(environmentId, ThreadId.make("thread-1")); @@ -353,7 +352,7 @@ describe("previewStateStore (single-tab)", () => { }, }; let updateCount = 0; - const unsubscribe = subscribeThreadPreviewState(ref, () => { + const unsubscribe = appAtomRegistry.subscribe(previewStateAtom(scopedThreadKey(ref)), () => { updateCount += 1; }); @@ -609,12 +608,4 @@ describe("previewStateStore (single-tab)", () => { `http://localhost:${5000 + __testing.RECENT_URL_LIMIT + 4}/`, ); }); - - it("removeThread strips the entry", () => { - const snapshot = makeSnapshot(); - applyPreviewServerSnapshot(ref, snapshot); - removePreviewThread(ref); - const state = readThreadPreviewState(ref); - expect(state).toEqual(__testing.EMPTY_THREAD_PREVIEW_STATE); - }); }); diff --git a/apps/web/src/previewStateStore.ts b/apps/web/src/previewStateStore.ts index a000fa993ea1..880a4d8e606b 100644 --- a/apps/web/src/previewStateStore.ts +++ b/apps/web/src/previewStateStore.ts @@ -184,19 +184,6 @@ export function readThreadPreviewState(ref: ScopedThreadRef): ThreadPreviewState return appAtomRegistry.get(previewStateAtom(scopedThreadKey(ref))); } -export function subscribeThreadPreviewState( - ref: ScopedThreadRef, - listener: (state: ThreadPreviewState, previous: ThreadPreviewState) => void, -): () => void { - const atom = previewStateAtom(scopedThreadKey(ref)); - let previous = appAtomRegistry.get(atom); - return appAtomRegistry.subscribe(atom, (state) => { - const prior = previous; - previous = state; - listener(state, prior); - }); -} - export function applyPreviewServerEvent(ref: ScopedThreadRef, event: PreviewEvent): void { updateThreadPreviewState(ref, (current) => { if (current.serverEpoch !== null && event.serverEpoch !== current.serverEpoch) return current; diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index bbcce36a9edf..07c902681d43 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -9,6 +9,7 @@ // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. import { Route as rootRouteImport } from './routes/__root' +import { Route as WelcomeRouteImport } from './routes/welcome' import { Route as UsageRouteImport } from './routes/usage' import { Route as SettingsRouteImport } from './routes/settings' import { Route as PairRouteImport } from './routes/pair' @@ -32,6 +33,11 @@ import { Route as ChatBoardRouteImport } from './routes/_chat.board' import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId' import { Route as ChatEnvironmentIdThreadIdRouteImport } from './routes/_chat.$environmentId.$threadId' +const WelcomeRoute = WelcomeRouteImport.update({ + id: '/welcome', + path: '/welcome', + getParentRoute: () => rootRouteImport, +} as any) const UsageRoute = UsageRouteImport.update({ id: '/usage', path: '/usage', @@ -151,6 +157,7 @@ export interface FileRoutesByFullPath { '/usage': typeof UsageRoute '/board': typeof ChatBoardRoute '/jump': typeof ChatJumpRoute + '/welcome': typeof WelcomeRoute '/pull-requests': typeof ChatPullRequestsRoute '/connect/callback': typeof ConnectCallbackRoute '/projects/$projectKey': typeof ProjectsProjectKeyRoute @@ -173,6 +180,7 @@ export interface FileRoutesByTo { '/usage': typeof UsageRoute '/board': typeof ChatBoardRoute '/jump': typeof ChatJumpRoute + '/welcome': typeof WelcomeRoute '/pull-requests': typeof ChatPullRequestsRoute '/connect/callback': typeof ConnectCallbackRoute '/projects/$projectKey': typeof ProjectsProjectKeyRoute @@ -198,6 +206,7 @@ export interface FileRoutesById { '/usage': typeof UsageRoute '/_chat/board': typeof ChatBoardRoute '/_chat/jump': typeof ChatJumpRoute + '/welcome': typeof WelcomeRoute '/_chat/pull-requests': typeof ChatPullRequestsRoute '/connect_/callback': typeof ConnectCallbackRoute '/projects/$projectKey': typeof ProjectsProjectKeyRoute @@ -224,6 +233,7 @@ export interface FileRouteTypes { | '/usage' | '/board' | '/jump' + | '/welcome' | '/pull-requests' | '/connect/callback' | '/projects/$projectKey' @@ -246,6 +256,7 @@ export interface FileRouteTypes { | '/usage' | '/board' | '/jump' + | '/welcome' | '/pull-requests' | '/connect/callback' | '/projects/$projectKey' @@ -270,6 +281,7 @@ export interface FileRouteTypes { | '/usage' | '/_chat/board' | '/_chat/jump' + | '/welcome' | '/_chat/pull-requests' | '/connect_/callback' | '/projects/$projectKey' @@ -293,12 +305,20 @@ export interface RootRouteChildren { PairRoute: typeof PairRoute SettingsRoute: typeof SettingsRouteWithChildren UsageRoute: typeof UsageRoute + WelcomeRoute: typeof WelcomeRoute ConnectCallbackRoute: typeof ConnectCallbackRoute ProjectsProjectKeyRoute: typeof ProjectsProjectKeyRoute } declare module '@tanstack/react-router' { interface FileRoutesByPath { + '/welcome': { + id: '/welcome' + path: '/welcome' + fullPath: '/welcome' + preLoaderRoute: typeof WelcomeRouteImport + parentRoute: typeof rootRouteImport + } '/usage': { id: '/usage' path: '/usage' @@ -510,6 +530,7 @@ const rootRouteChildren: RootRouteChildren = { PairRoute: PairRoute, SettingsRoute: SettingsRouteWithChildren, UsageRoute: UsageRoute, + WelcomeRoute: WelcomeRoute, ConnectCallbackRoute: ConnectCallbackRoute, ProjectsProjectKeyRoute: ProjectsProjectKeyRoute, } diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index cb7e6aaf0fac..429a3c736249 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -16,6 +16,7 @@ import { resolveServerBackedAppDisplayName } from "../branding.logic"; import { AppSidebarLayout } from "../components/AppSidebarLayout"; import { CommandPalette } from "../components/CommandPalette"; import { ConfirmDialogHost } from "../components/ConfirmDialogHost"; +import { FirstRunGate } from "../components/onboarding/FirstRunGate"; import { ConnectOnboardingDialog } from "../components/cloud/ConnectOnboardingDialog"; import { RelayClientInstallDialog } from "../components/cloud/RelayClientInstallDialog"; import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPromptDialog"; @@ -100,6 +101,13 @@ function RootRouteView() { const pathname = useLocation({ select: (location) => location.pathname }); const { authGateState } = Route.useRouteContext(); const primaryEnvironmentAuthenticated = authGateState.status === "authenticated"; + const returningFromWelcomeRef = useRef(pathname === "/welcome"); + + useEffect(() => { + if (pathname === "/welcome") { + returningFromWelcomeRef.current = true; + } + }, [pathname]); useEffect(() => { const frame = window.requestAnimationFrame(() => { @@ -119,6 +127,19 @@ function RootRouteView() { ); } + // The welcome wizard is full-screen like /pair, but keeps toasts so its + // connect/import actions can report failures. + if (pathname === "/welcome") { + return ( + + + + + + + ); + } + if (authGateState.status !== "authenticated" && authGateState.status !== "hosted-static") { return ( <> @@ -136,6 +157,10 @@ function RootRouteView() { ); + // FirstRunGate holds back everything below it — including EventRouter, + // whose welcome payload navigates into a thread — until the first-run + // decision is known, so a fresh install renders nothing (not the shell, + // not a flash of threads) before landing on the welcome wizard. return ( @@ -144,26 +169,32 @@ function RootRouteView() { - {primaryEnvironmentAuthenticated ? : null} - {primaryEnvironmentAuthenticated ? : null} - - - - - - - {primaryEnvironmentAuthenticated ? : null} - {primaryEnvironmentAuthenticated ? : null} - {primaryEnvironmentAuthenticated ? : null} - {primaryEnvironmentAuthenticated ? : null} - {/* Claim gate: primary auth OR hosted-static (paired remotes still need identity). */} - {primaryEnvironmentAuthenticated || authGateState.status === "hosted-static" ? ( - - ) : null} - {appShell} - {/* Above the router: a theme draft is judged by walking the app, so the - editor has to survive navigation away from settings. */} - + + {primaryEnvironmentAuthenticated ? : null} + {primaryEnvironmentAuthenticated ? : null} + + + + + + + {primaryEnvironmentAuthenticated ? ( + + ) : null} + {primaryEnvironmentAuthenticated ? : null} + {primaryEnvironmentAuthenticated ? : null} + {primaryEnvironmentAuthenticated ? : null} + {primaryEnvironmentAuthenticated || authGateState.status === "hosted-static" ? ( + + ) : null} + {appShell} + {/* Above the router: a theme draft is judged by walking the app, so the + editor has to survive navigation away from settings. */} + + ); @@ -389,7 +420,11 @@ function AuthenticatedTracingBootstrap() { return null; } -function EventRouter() { +function EventRouter({ + skipInitialBootstrapNavigation, +}: { + readonly skipInitialBootstrapNavigation: boolean; +}) { const navigate = useNavigate(); const pathname = useLocation({ select: (loc) => loc.pathname }); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); @@ -402,6 +437,7 @@ function EventRouter() { const serverWelcome = useAtomValue(primaryServerWelcomeAtom); const readPathname = useEffectEvent(() => pathname); const handledBootstrapThreadIdRef = useRef(null); + const skipInitialBootstrapNavigationRef = useRef(skipInitialBootstrapNavigation); const handledConfigEventRef = useRef(serverConfigEvent); const [keybindingsToastController] = useState(() => createKeybindingsUpdateToastController({}), @@ -437,6 +473,11 @@ function EventRouter() { if (hasThreadDeepLinkIntent()) { return; } + if (skipInitialBootstrapNavigationRef.current) { + skipInitialBootstrapNavigationRef.current = false; + handledBootstrapThreadIdRef.current = payload.bootstrapThreadId; + return; + } if (handledBootstrapThreadIdRef.current === payload.bootstrapThreadId) { return; } diff --git a/apps/web/src/routes/_chat.index.tsx b/apps/web/src/routes/_chat.index.tsx index 49dbf9e21e8b..9bf45027b7f1 100644 --- a/apps/web/src/routes/_chat.index.tsx +++ b/apps/web/src/routes/_chat.index.tsx @@ -22,10 +22,11 @@ import { hasCloudPublicConfig } from "~/cloud/publicConfig"; function ChatIndexRouteView() { const { authGateState } = Route.useRouteContext(); - const { environments } = useEnvironments(); + const { environments, isReady } = useEnvironments(); - if (authGateState.status === "hosted-static" && environments.length === 0) { - return ; + if (authGateState.status === "hosted-static") { + if (!isReady) return null; + if (environments.length === 0) return ; } return ; @@ -86,6 +87,8 @@ function IndexDraftLanding() { /> ) : null; } + // First-run routing to the welcome wizard happens in FirstRunGate at the + // root, before this route ever renders. return ; } diff --git a/apps/web/src/routes/welcome.tsx b/apps/web/src/routes/welcome.tsx new file mode 100644 index 000000000000..10caa4dd46fb --- /dev/null +++ b/apps/web/src/routes/welcome.tsx @@ -0,0 +1,45 @@ +import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router"; + +import { WelcomeWizard } from "../components/onboarding/WelcomeWizard"; +import { useNewThreadHandler } from "../hooks/useHandleNewThread"; + +/** + * First-run welcome wizard. Full-screen, outside the sidebar shell (the root + * route mounts this path bare, like /pair). Reached only via the first-run + * gate on the index route; visiting it directly after onboarding is harmless — + * finishing again just refreshes the completion flag. + */ +export const Route = createFileRoute("/welcome")({ + beforeLoad: ({ context }) => { + const { authGateState } = context; + if (authGateState.status !== "authenticated" && authGateState.status !== "hosted-static") { + throw redirect({ to: "/pair", replace: true }); + } + }, + component: WelcomeRouteView, +}); + +function WelcomeRouteView() { + const { authGateState } = Route.useRouteContext(); + const navigate = useNavigate(); + const openNewThread = useNewThreadHandler(); + // An authenticated gate means a primary server is serving this app — + // desktop, `npx t3`, or a dev server — and that server is "this machine" + // no matter what hostname the browser used. Only hosted-static has no + // local server to offer. + const localAvailable = authGateState.status === "authenticated"; + return ( + { + if (projectRef !== undefined) { + void openNewThread(projectRef, { replace: true }).catch(() => { + void navigate({ to: "/", replace: true }); + }); + return; + } + void navigate({ to: "/", replace: true }); + }} + /> + ); +} diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 4d50b1903c0f..178effafbbe9 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -35,7 +35,7 @@ import { type TurnDiffSummary, } from "./types"; -export { formatDuration, formatElapsed } from "@t3tools/shared/orchestrationTiming"; +export { formatDuration } from "@t3tools/shared/orchestrationTiming"; export type WorkLogToolLifecycleStatus = | "inProgress" diff --git a/apps/web/src/state/agentSessions.ts b/apps/web/src/state/agentSessions.ts new file mode 100644 index 000000000000..996ddb0ea730 --- /dev/null +++ b/apps/web/src/state/agentSessions.ts @@ -0,0 +1,25 @@ +import { WS_METHODS } from "@t3tools/contracts"; +import { + createEnvironmentRpcCommand, + createEnvironmentRpcQueryAtomFamily, +} from "@t3tools/client-runtime/state/runtime"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +/** + * Scan of Claude Code / Codex home directories on an environment, surfacing + * project candidates for the welcome wizard's import step. The scan walks the + * filesystem server-side, so results are cached briefly and refreshed when the + * import step remounts. + */ +export const agentSessionScan = createEnvironmentRpcQueryAtomFamily(connectionAtomRuntime, { + label: "environment-data:agent-sessions:scan", + tag: WS_METHODS.agentSessionsScan, + staleTimeMs: 30_000, + idleTtlMs: 5 * 60_000, +}); + +export const agentSessionImport = createEnvironmentRpcCommand(connectionAtomRuntime, { + label: "environment-data:agent-sessions:import", + tag: WS_METHODS.agentSessionsImport, +}); diff --git a/apps/web/src/terminal-links.test.ts b/apps/web/src/terminal-links.test.ts index 34c6c9830a0a..3c466378ba8b 100644 --- a/apps/web/src/terminal-links.test.ts +++ b/apps/web/src/terminal-links.test.ts @@ -6,8 +6,6 @@ import { isTerminalLinkActivation, isTerminalUrl, resolvePathLinkTarget, - resolveWrappedTerminalLinkRange, - wrappedTerminalLinkRangeIntersectsBufferLine, type TerminalBufferLineLike, } from "./terminal-links"; @@ -154,46 +152,6 @@ describe("collectWrappedTerminalLinkLine", () => { }); }); -describe("resolveWrappedTerminalLinkRange", () => { - it("maps wrapped URL matches back to the correct buffer rows", () => { - const prefix = "see "; - const firstSegment = `${prefix}https://example.com/a`; - const secondSegment = "/bc?x=1"; - const lines = [ - createBufferLine("prompt> "), - createBufferLine(firstSegment), - createBufferLine(secondSegment, true), - ]; - const wrappedLine = collectWrappedTerminalLinkLine(2, (index) => lines[index]); - - expect(wrappedLine).not.toBeNull(); - if (!wrappedLine) { - throw new Error("Expected wrapped terminal line to be present."); - } - - const [match] = extractTerminalLinks(wrappedLine.text); - expect(match).toEqual({ - kind: "url", - text: "https://example.com/a/bc?x=1", - start: prefix.length, - end: firstSegment.length + secondSegment.length, - }); - if (!match) { - throw new Error("Expected wrapped URL match to be present."); - } - - const range = resolveWrappedTerminalLinkRange(wrappedLine, match); - - expect(range).toEqual({ - start: { x: prefix.length + 1, y: 2 }, - end: { x: secondSegment.length, y: 3 }, - }); - expect(wrappedTerminalLinkRangeIntersectsBufferLine(range, 2)).toBe(true); - expect(wrappedTerminalLinkRangeIntersectsBufferLine(range, 3)).toBe(true); - expect(wrappedTerminalLinkRangeIntersectsBufferLine(range, 4)).toBe(false); - }); -}); - describe("resolvePathLinkTarget", () => { it("resolves relative paths against cwd", () => { expect( diff --git a/apps/web/src/terminal-links.ts b/apps/web/src/terminal-links.ts index 204d0a742a05..59e2082a7359 100644 --- a/apps/web/src/terminal-links.ts +++ b/apps/web/src/terminal-links.ts @@ -14,16 +14,6 @@ export interface TerminalLinkMatch { end: number; } -export interface TerminalLinkBufferPosition { - x: number; - y: number; -} - -export interface TerminalLinkBufferRange { - start: TerminalLinkBufferPosition; - end: TerminalLinkBufferPosition; -} - export interface TerminalBufferLineLike { readonly isWrapped?: boolean; translateToString(trimRight?: boolean): string; @@ -199,43 +189,6 @@ export function collectWrappedTerminalLinkLine( }; } -function resolveCharacterPosition( - segments: ReadonlyArray, - characterIndex: number, -): TerminalLinkBufferPosition { - for (const segment of segments) { - if (characterIndex < segment.endIndex) { - return { - x: characterIndex - segment.startIndex + 1, - y: segment.bufferLineNumber, - }; - } - } - - const lastSegment = segments[segments.length - 1]; - return { - x: Math.max(lastSegment?.text.length ?? 0, 1), - y: lastSegment?.bufferLineNumber ?? 1, - }; -} - -export function resolveWrappedTerminalLinkRange( - wrappedLine: WrappedTerminalLinkLine, - match: Pick, -): TerminalLinkBufferRange { - return { - start: resolveCharacterPosition(wrappedLine.segments, match.start), - end: resolveCharacterPosition(wrappedLine.segments, match.end - 1), - }; -} - -export function wrappedTerminalLinkRangeIntersectsBufferLine( - range: TerminalLinkBufferRange, - bufferLineNumber: number, -): boolean { - return range.start.y <= bufferLineNumber && bufferLineNumber <= range.end.y; -} - export function isTerminalLinkActivation( event: Pick, platform = typeof navigator === "undefined" ? "" : navigator.platform, diff --git a/apps/web/src/terminal/ghostty/surface.test.ts b/apps/web/src/terminal/ghostty/surface.test.ts index e9933cdb4b73..ee3240b41b08 100644 --- a/apps/web/src/terminal/ghostty/surface.test.ts +++ b/apps/web/src/terminal/ghostty/surface.test.ts @@ -23,8 +23,6 @@ import { terminalGridCellAt, terminalScrollbarGeometry, terminalScrollbarOffsetAtPointer, - terminalLinkAtColumn, - terminalLinkAtPosition, terminalLinkAtPositionWithRange, terminalContentOriginY, terminalFontFamily, @@ -433,7 +431,7 @@ describe("shouldBlinkTerminalCursor", () => { }); }); -describe("terminalLinkAtColumn", () => { +describe("terminalLinkAtPositionWithRange", () => { it("maps terminal cells to UTF-16 offsets after a wide emoji", () => { const cells = [ cell("🙂"), @@ -450,9 +448,11 @@ describe("terminalLinkAtColumn", () => { wrapsToNext: false, }; - expect(terminalLinkAtColumn(row, 2)).toBe("https://t3.codes"); - expect(terminalLinkAtColumn(row, cells.length - 1)).toBe("https://t3.codes"); - expect(terminalLinkAtColumn(row, 0)).toBeNull(); + expect(terminalLinkAtPositionWithRange([row], 0, 2)?.text).toBe("https://t3.codes"); + expect(terminalLinkAtPositionWithRange([row], 0, cells.length - 1)?.text).toBe( + "https://t3.codes", + ); + expect(terminalLinkAtPositionWithRange([row], 0, 0)).toBeNull(); expect(terminalLinkAtPositionWithRange([row], 0, 8)?.range).toEqual({ start: { x: 2, y: 0 }, end: { x: cells.length - 1, y: 0 }, @@ -473,10 +473,10 @@ describe("terminalLinkAtColumn", () => { row("C:\\repo\\file.ts", false), ]; - expect(terminalLinkAtPosition(rows, 0, 8)).toBe("https://example.com/reference"); - expect(terminalLinkAtPosition(rows, 1, 4)).toBe("https://example.com/reference"); - expect(terminalLinkAtPosition(rows, 2, 2)).toBe("~/project/file"); - expect(terminalLinkAtPosition(rows, 3, 4)).toBe("C:\\repo\\file.ts"); + expect(terminalLinkAtPositionWithRange(rows, 0, 8)?.text).toBe("https://example.com/reference"); + expect(terminalLinkAtPositionWithRange(rows, 1, 4)?.text).toBe("https://example.com/reference"); + expect(terminalLinkAtPositionWithRange(rows, 2, 2)?.text).toBe("~/project/file"); + expect(terminalLinkAtPositionWithRange(rows, 3, 4)?.text).toBe("C:\\repo\\file.ts"); expect(terminalLinkAtPositionWithRange(rows, 1, 4)).toEqual({ text: "https://example.com/reference", range: { @@ -495,13 +495,13 @@ describe("terminalLinkAtColumn", () => { }); // The head of the wrapped line scrolled above the viewport. const headCut = [row("ple.com/missing", true), row("head", true)]; - expect(terminalLinkAtPosition(headCut, 0, 4)).toBeNull(); + expect(terminalLinkAtPositionWithRange(headCut, 0, 4)).toBeNull(); // The bottom row soft-wraps on below the viewport. const tailCut = [row("https://t3.codes", false, true)]; - expect(terminalLinkAtPosition(tailCut, 0, 8)).toBeNull(); + expect(terminalLinkAtPositionWithRange(tailCut, 0, 8)).toBeNull(); // A partial bottom row is provably complete and still resolves. const complete = [row("https://t3.codes", false), row("", false)]; - expect(terminalLinkAtPosition(complete, 0, 8)).toBe("https://t3.codes"); + expect(terminalLinkAtPositionWithRange(complete, 0, 8)?.text).toBe("https://t3.codes"); // A wide grapheme earlier in the row must not break truncation detection: // the soft-wrap flag decides, not string-length-versus-cell-count. const wideFull: GhosttyRow = { @@ -514,7 +514,7 @@ describe("terminalLinkAtColumn", () => { isWrapContinuation: false, wrapsToNext: true, }; - expect(terminalLinkAtPosition([wideFull], 0, 8)).toBeNull(); + expect(terminalLinkAtPositionWithRange([wideFull], 0, 8)).toBeNull(); // Unwritten trailing cells prove the bottom row is complete. const unwrittenTail: GhosttyRow = { cells: [ @@ -526,7 +526,7 @@ describe("terminalLinkAtColumn", () => { isWrapContinuation: false, wrapsToNext: false, }; - expect(terminalLinkAtPosition([unwrittenTail], 0, 8)).toBe("https://t3.codes"); + expect(terminalLinkAtPositionWithRange([unwrittenTail], 0, 8)?.text).toBe("https://t3.codes"); }); }); diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index 826dcd014053..29aaac6f6abd 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -244,14 +244,6 @@ function terminalColumnOffset(row: GhosttySnapshot["rowData"][number], column: n return offset; } -export function terminalLinkAtPosition( - rows: GhosttySnapshot["rowData"], - rowIndex: number, - column: number, -): string | null { - return terminalLinkAtPositionWithRange(rows, rowIndex, column)?.text ?? null; -} - export interface TerminalLinkWithRange { readonly text: string; readonly range: GhosttyCellRange; @@ -325,10 +317,6 @@ export function terminalLinkAtPositionWithRange( return null; } -export function terminalLinkAtColumn(row: GhosttySnapshot["rowData"][number], column: number) { - return terminalLinkAtPosition([row], 0, column); -} - export function isTerminalCopyShortcut( event: Pick, platform = navigator.platform, diff --git a/apps/web/src/themePalette.test.ts b/apps/web/src/themePalette.test.ts index a76885a862ee..e1d9bfb74cab 100644 --- a/apps/web/src/themePalette.test.ts +++ b/apps/web/src/themePalette.test.ts @@ -34,7 +34,6 @@ import { OCEAN_THEME, updateCustomTheme, CUSTOM_THEMES_STORAGE_KEY, - createManagedThemeColors, createVividThemeColors, getDefaultThemeColors, themeColorToHex, @@ -91,50 +90,6 @@ describe("theme files", () => { } }); - it("derives a readable palette from extreme simple-editor colors", () => { - const light = createManagedThemeColors("light", "#111827", "#ffff00"); - const dark = createManagedThemeColors("dark", "#ffffff", "#ffff00"); - const darkDefaults = getDefaultThemeColors("dark"); - - expect(asHex(light.canvas)).not.toBe("#111827"); - expect(asHex(dark.canvas)).not.toBe("#ffffff"); - expect(contrastRatio(light.accent, light.canvas)).toBeGreaterThanOrEqual(4.5); - expect(contrastRatio(dark.accent, dark.canvas)).toBeGreaterThanOrEqual(4.5); - expect(contrastRatio(light.textMuted, light.canvas)).toBeGreaterThanOrEqual(4.5); - expect(contrastRatio(dark.textMuted, dark.canvas)).toBeGreaterThanOrEqual(4.5); - expect(contrastRatio(light.textMuted, light.canvas)).toBeLessThan(5.5); - expect(contrastRatio(dark.textMuted, dark.canvas)).toBeLessThan(5.5); - expect(contrastRatio(light.textMuted, light.canvas)).toBeCloseTo(4.705, 1); - expect(contrastRatio(dark.textMuted, dark.canvas)).toBeCloseTo(5.082, 1); - expect(light.secondaryLabel).toBe(light.textMuted); - expect(dark.secondaryLabel).toBe(dark.textMuted); - expect(contrastRatio(light.accentForeground, light.accent)).toBeGreaterThanOrEqual(4.5); - expect(contrastRatio(dark.accentForeground, dark.accent)).toBeGreaterThanOrEqual(4.5); - // Status colors fall back to T3 Code's standard red and amber rather than - // the flagship palette's, so no generated theme inherits a brand tint. - const channels = (value: string) => - [1, 3, 5].map((index) => Number.parseInt(asHex(value).slice(index, index + 2), 16)) as [ - number, - number, - number, - ]; - for (const colors of [light, dark]) { - const [errorRed, errorGreen, errorBlue] = channels(colors.error); - // Red leads by a wide margin; the old default was a pink whose blue sat - // close behind its red. - expect(errorRed).toBeGreaterThan(errorGreen * 2); - expect(errorRed).toBeGreaterThan(errorBlue * 2); - expect(contrastRatio(colors.error, "#ffffff")).toBeGreaterThanOrEqual(2.5); - expect(contrastRatio(colors.errorForeground, colors.errorSurface)).toBeGreaterThanOrEqual( - 4.5, - ); - const [warnRed, warnGreen, warnBlue] = channels(colors.warning); - expect(warnRed).toBeGreaterThan(warnBlue); - expect(warnGreen).toBeGreaterThan(warnBlue); - } - expect(asHex(dark.error)).not.toBe(asHex(darkDefaults.error)); - }); - it("keeps stock dark controls in the neutral-black surface hierarchy", () => { expectThemeColors(getStandardThemeColors("dark"), { canvas: "#0a0a0a", @@ -158,6 +113,12 @@ describe("theme files", () => { ["light", "#111827", "#8ab4f8"], ["dark", "#f5ecf5", "#a84370"], ]; + const channels = (value: string) => + [1, 3, 5].map((index) => Number.parseInt(asHex(value).slice(index, index + 2), 16)) as [ + number, + number, + number, + ]; for (const [appearance, canvas, accent] of seeds) { const colors = createVividThemeColors(appearance, canvas, accent); // Exact seeds are honored. @@ -193,6 +154,17 @@ describe("theme files", () => { expect(colors.messageAction).not.toBe(colors.accent); // Update family follows the theme, not the default palette. expect(asHex(colors.update)).toBe(accent); + // Semantic statuses stay red and amber instead of inheriting a brand tint. + const [errorRed, errorGreen, errorBlue] = channels(colors.error); + expect(errorRed).toBeGreaterThan(errorGreen * 2); + expect(errorRed).toBeGreaterThan(errorBlue * 2); + expect(contrastRatio(colors.error, "#ffffff")).toBeGreaterThanOrEqual(2.5); + expect(contrastRatio(colors.errorForeground, colors.errorSurface)).toBeGreaterThanOrEqual( + 4.5, + ); + const [warnRed, warnGreen, warnBlue] = channels(colors.warning); + expect(warnRed).toBeGreaterThan(warnBlue); + expect(warnGreen).toBeGreaterThan(warnBlue); } }); @@ -202,8 +174,8 @@ describe("theme files", () => { const inverted = [ createVividThemeColors("light", "#111827", "#8ab4f8"), createVividThemeColors("dark", "#f5ecf5", "#a84370"), - createManagedThemeColors("light", "#0d1117", "#69b1ff", { exactSeeds: true }), - createManagedThemeColors("dark", "#fdfdfd", "#c2571b", { exactSeeds: true }), + createVividThemeColors("light", "#0d1117", "#69b1ff"), + createVividThemeColors("dark", "#fdfdfd", "#c2571b"), ]; for (const colors of inverted) { expect(contrastRatio(colors.errorForeground, colors.errorSurface)).toBeGreaterThanOrEqual( diff --git a/apps/web/src/themePalette.ts b/apps/web/src/themePalette.ts index f67eb943e3fb..f4b5c83df395 100644 --- a/apps/web/src/themePalette.ts +++ b/apps/web/src/themePalette.ts @@ -475,12 +475,6 @@ type ThemeRgbColor = { b: number; }; -type ThemeHslColor = { - h: number; - s: number; - l: number; -}; - type ThemeOklch = { L: number; C: number; h: number }; type ParsedThemeColor = { color: ThemeOklch; alpha: number }; @@ -601,48 +595,6 @@ function canonicalizeThemeDefinition(theme: ThemeDefinition): ThemeDefinition { }; } -function themeRgbToHsl(color: ThemeRgbColor): ThemeHslColor { - const red = color.r / 255; - const green = color.g / 255; - const blue = color.b / 255; - const max = Math.max(red, green, blue); - const min = Math.min(red, green, blue); - const delta = max - min; - const lightness = (max + min) / 2; - - if (delta === 0) return { h: 0, s: 0, l: lightness }; - - const saturation = delta / (1 - Math.abs(2 * lightness - 1)); - let hue = 0; - if (max === red) hue = ((green - blue) / delta) % 6; - else if (max === green) hue = (blue - red) / delta + 2; - else hue = (red - green) / delta + 4; - - return { h: (hue * 60 + 360) % 360, s: saturation, l: lightness }; -} - -function themeHslToRgb(color: ThemeHslColor): ThemeRgbColor { - const hue = ((color.h % 360) + 360) % 360; - const chroma = (1 - Math.abs(2 * color.l - 1)) * color.s; - const hueSector = hue / 60; - const secondary = chroma * (1 - Math.abs((hueSector % 2) - 1)); - const match = color.l - chroma / 2; - const [red, green, blue] = - hueSector < 1 - ? [chroma, secondary, 0] - : hueSector < 2 - ? [secondary, chroma, 0] - : hueSector < 3 - ? [0, chroma, secondary] - : hueSector < 4 - ? [0, secondary, chroma] - : hueSector < 5 - ? [secondary, 0, chroma] - : [chroma, 0, secondary]; - - return { r: (red + match) * 255, g: (green + match) * 255, b: (blue + match) * 255 }; -} - function mixThemeRgbColors( base: ThemeRgbColor, overlay: ThemeRgbColor, @@ -1046,205 +998,6 @@ function standardMutedThemeText( return readableThemeText(background, foreground, 1, target); } -function managedThemeBackground(value: string, appearance: ThemeAppearance): ThemeRgbColor { - const selected = parseThemeRgbColor( - value, - appearance === "dark" ? { r: 24, g: 15, b: 27 } : { r: 250, g: 245, b: 250 }, - ); - const hsl = themeRgbToHsl(selected); - return themeHslToRgb({ - h: hsl.h, - // A background tint should support the selected mode, not turn the whole - // app into a high-saturation surface. - s: Math.min(hsl.s, appearance === "dark" ? 0.3 : 0.2), - l: - appearance === "dark" - ? Math.min(0.13, Math.max(0.07, hsl.l)) - : Math.min(0.985, Math.max(0.94, hsl.l)), - }); -} - -function managedThemeAccent( - value: string, - appearance: ThemeAppearance, - background: ThemeRgbColor, -): ThemeRgbColor { - const selected = parseThemeRgbColor(value, { r: 168, g: 67, b: 112 }); - const hsl = themeRgbToHsl(selected); - const preferredLightness = - appearance === "dark" - ? Math.min(0.72, Math.max(0.42, hsl.l)) - : Math.min(0.58, Math.max(0.35, hsl.l)); - const lightnessRange: readonly [number, number] = - appearance === "dark" ? [0.42, 0.82] : [0.22, 0.58]; - const saturation = Math.min(hsl.s, 0.82); - const candidates = Array.from({ length: 61 }, (_, index) => { - const lightness = - lightnessRange[0] + ((lightnessRange[1] - lightnessRange[0]) * index) / (61 - 1); - const color = themeHslToRgb({ h: hsl.h, s: saturation, l: lightness }); - return { color, lightness, contrast: themeContrastRatio(color, background) }; - }); - // Leave a little room for browser color conversion at render time. - const readableCandidates = candidates.filter((candidate) => candidate.contrast >= 4.7); - const pool = readableCandidates.length > 0 ? readableCandidates : candidates; - - return pool.reduce((best, candidate) => { - const distance = Math.abs(candidate.lightness - preferredLightness); - const bestDistance = Math.abs(best.lightness - preferredLightness); - return distance < bestDistance || - (distance === bestDistance && candidate.contrast > best.contrast) - ? candidate - : best; - }).color; -} - -/** - * Creates the guided palette used by the basic theme editor. The two user - * colors control the mood, while dependent roles are generated together so - * text, surfaces, message actions, code, and terminal UI stay coherent. - */ -export function createManagedThemeColors( - appearance: ThemeAppearance, - backgroundValue: string, - accentValue: string, - options?: { - /** Use the seeds exactly as given instead of nudging them into the - * readability envelope. Derived foregrounds still adapt for contrast. */ - exactSeeds?: boolean; - }, -): ThemeColors { - const defaults = getDefaultThemeColors(appearance); - const canvas = options?.exactSeeds - ? parseThemeRgbColor( - backgroundValue, - appearance === "dark" ? { r: 24, g: 15, b: 27 } : { r: 250, g: 245, b: 250 }, - ) - : managedThemeBackground(backgroundValue, appearance); - const accent = options?.exactSeeds - ? parseThemeRgbColor(accentValue, { r: 168, g: 67, b: 112 }) - : managedThemeAccent(accentValue, appearance, canvas); - const text = readableThemeForeground(canvas); - const textMuted = standardMutedThemeText(canvas, text); - // The top bar is part of the main panel, not a separate chrome layer: it - // shares the canvas, and its controls sit on the panel's own surfaces. - const chrome = canvas; - const sidebar = mixThemeRgbColors(canvas, accent, 0.08); - const surfaceRaised = mixThemeRgbColors(canvas, text, appearance === "dark" ? 0.12 : 0.035); - const surfaceOverlay = mixThemeRgbColors(canvas, text, appearance === "dark" ? 0.18 : 0.06); - const secondary = mixThemeRgbColors(canvas, accent, appearance === "dark" ? 0.2 : 0.08); - const muted = mixThemeRgbColors(canvas, accent, appearance === "dark" ? 0.13 : 0.06); - const mutedForeground = readableThemeText(muted, text, 1, 4.6); - const placeholder = readableThemeText(surfaceRaised, text, 1, 4.6); - const accentSurface = mixThemeRgbColors(canvas, accent, appearance === "dark" ? 0.3 : 0.14); - const messageSurface = mixThemeRgbColors(canvas, accent, appearance === "dark" ? 0.36 : 0.18); - const toolbarControl = mixThemeRgbColors(chrome, accent, appearance === "dark" ? 0.2 : 0.08); - const toolbarBorder = mixThemeRgbColors(chrome, accent, appearance === "dark" ? 0.35 : 0.14); - const accentForeground = readableThemeForeground(accent); - // Code and terminal are large surfaces: they keep the canvas hue instead of - // drifting toward the foreground grey. Code sits just above the canvas — - // a whisper of the text tint — and the terminal sits on the canvas itself. - const codeBackground = mixThemeRgbColors(canvas, text, appearance === "dark" ? 0.06 : 0.025); - const terminalBackground = canvas; - const messageActionHover = mixThemeRgbColors( - accent, - accentForeground === THEME_LIGHT_FOREGROUND || accentForeground === THEME_WHITE_FOREGROUND - ? THEME_BLACK_FOREGROUND - : THEME_WHITE_FOREGROUND, - 0.12, - ); - - // The update family follows the accent instead of inheriting the default - // palette's brand color, so generated themes carry their own identity in - // update pills and banners. Error and warning stay semantic defaults. - const updateSurface = mixThemeRgbColors(canvas, accent, appearance === "dark" ? 0.32 : 0.16); - const updateForeground = mixThemeRgbColors( - accent, - appearance === "dark" ? THEME_WHITE_FOREGROUND : THEME_BLACK_FOREGROUND, - 0.35, - ); - - return { - ...defaults, - ...standardStatusColors(canvas), - update: themeRgbToThemeColor(accent), - updateForeground: themeRgbToThemeColor(updateForeground), - updateSurface: themeRgbToThemeColor(updateSurface), - canvas: themeRgbToThemeColor(canvas), - chrome: themeRgbToThemeColor(chrome), - toolbar: themeRgbToThemeColor(chrome), - toolbarForeground: themeRgbToThemeColor(text), - toolbarBorder: themeRgbToThemeColor(toolbarBorder), - toolbarControl: themeRgbToThemeColor(toolbarControl), - toolbarControlForeground: themeRgbToThemeColor(text), - toolbarControlHover: themeRgbToThemeColor(accentSurface), - surface: themeRgbToThemeColor(canvas), - surfaceRaised: themeRgbToThemeColor(surfaceRaised), - surfaceOverlay: themeRgbToThemeColor(surfaceOverlay), - text: themeRgbToThemeColor(text), - textMuted: themeRgbToThemeColor(textMuted), - // Borders blend through the accent before lightening so control chrome - // carries the theme hue like the hand-tuned palettes (#5c345b, #e0d3e1) - // instead of flattening to grey. - border: themeRgbToThemeColor( - mixThemeRgbColors( - mixThemeRgbColors(canvas, accent, appearance === "dark" ? 0.22 : 0.1), - text, - 0.1, - ), - ), - input: themeRgbToThemeColor( - mixThemeRgbColors( - mixThemeRgbColors(canvas, accent, appearance === "dark" ? 0.3 : 0.14), - text, - appearance === "dark" ? 0.14 : 0.13, - ), - ), - focus: themeRgbToThemeColor(accent), - accent: themeRgbToThemeColor(accent), - accentForeground: themeRgbToThemeColor(accentForeground), - secondary: themeRgbToThemeColor(secondary), - secondaryForeground: themeRgbToThemeColor(readableThemeForeground(secondary)), - muted: themeRgbToThemeColor(muted), - mutedForeground: themeRgbToThemeColor(mutedForeground), - placeholder: themeRgbToThemeColor(placeholder), - secondaryLabel: themeRgbToThemeColor(textMuted), - iconMuted: themeRgbToThemeColor(textMuted), - accentSurface: themeRgbToThemeColor(accentSurface), - accentSurfaceForeground: themeRgbToThemeColor(readableThemeForeground(accentSurface)), - messageSurface: themeRgbToThemeColor(messageSurface), - messageForeground: themeRgbToThemeColor(readableThemeForeground(messageSurface)), - messageAction: themeRgbToThemeColor(accent), - messageActionForeground: themeRgbToThemeColor(accentForeground), - messageActionHover: themeRgbToThemeColor(messageActionHover), - codeBackground: themeRgbToThemeColor(codeBackground), - codeForeground: themeRgbToThemeColor(readableThemeForeground(codeBackground)), - sidebar: themeRgbToThemeColor(sidebar), - sidebarForeground: themeRgbToThemeColor(readableThemeForeground(sidebar)), - sidebarMutedForeground: themeRgbToThemeColor(standardMutedThemeText(sidebar, text)), - sidebarControlSurface: themeRgbToThemeColor( - mixThemeRgbColors(sidebar, text, appearance === "dark" ? 0.16 : 0.08), - ), - sidebarRowHover: themeRgbToThemeColor(mixThemeRgbColors(sidebar, accent, 0.12)), - sidebarRowActive: themeRgbToThemeColor(mixThemeRgbColors(sidebar, accent, 0.2)), - sidebarRowSelected: themeRgbToThemeColor(mixThemeRgbColors(sidebar, accent, 0.24)), - sidebarBorder: themeRgbToThemeColor( - mixThemeRgbColors(sidebar, text, appearance === "dark" ? 0.35 : 0.12), - ), - terminalBackground: themeRgbToThemeColor(terminalBackground), - terminalForeground: themeRgbToThemeColor(readableThemeForeground(terminalBackground)), - terminalCursor: themeRgbToThemeColor(accent), - terminalSelection: themeRgbToThemeColor( - mixThemeRgbColors(canvas, accent, appearance === "dark" ? 0.35 : 0.18), - ), - terminalScrollbar: themeRgbToThemeColor( - mixThemeRgbColors(canvas, text, appearance === "dark" ? 0.42 : 0.22), - ), - terminalScrollbarHover: themeRgbToThemeColor( - mixThemeRgbColors(canvas, text, appearance === "dark" ? 0.55 : 0.32), - ), - }; -} - /** Theme-file defaults follow the flagship palette for the requested mode. */ export function getDefaultThemeColors(appearance: ThemeAppearance): ThemeColors { return appearance === "dark" ? T3_CHAT_THEME.variants!.dark! : T3_CHAT_THEME.colors; diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index fd660f91e574..247179c001a8 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -79,6 +79,11 @@ const isolatedUnitTestFiles = [ // Mocks `~/hooks/useSettings`; under isolate:false an earlier file binds // the real store and getBrowserDefaults never sees the test profile list. "src/browser/browserDefaults.test.ts", + // Mocks `~/hooks/useSettings`; under isolate:false the real hydrate path + // already bound ensureLocalApi, so failed-read tests throw "Local API not found". + "src/browser/browserLinkTarget.test.ts", + // Mocks `~/localApi` persistence; same isolate:false binding as useSettings. + "src/browser/HostedBrowserWebview.test.tsx", "src/browser/browserTargetResolver.test.ts", "src/browser/desktopTabLifetime.test.ts", "src/branding.test.ts", @@ -113,6 +118,9 @@ const isolatedUnitTestFiles = [ // applies — the icon falls back to the browser mockup and the stored-favicon // assertion fails, depending only on how files land across workers. "src/components/preview/PreviewFaviconIcon.test.tsx", + // Mocks `~/localApi` getClientSettings; under isolate:false the real + // localApi is already bound so open waits forever or never sees the mock. + "src/components/preview/PreviewAutomationHosts.test.tsx", "src/components/preview/PreviewView.test.tsx", "src/components/preview/openPreviewSession.test.ts", "src/components/preview/openTerminalLinkInPreview.test.ts", @@ -144,6 +152,9 @@ const isolatedUnitTestFiles = [ // Mocks `react` (useCallback/useMemo) and `@effect/atom-react`; under // isolate:false an earlier file binds real React and useContext is null. "src/hooks/useHandleNewThread.test.ts", + // Mocks `~/localApi` persistence; under isolate:false the real ensureLocalApi + // is already bound and hydration throws "Local API not found". + "src/hooks/useSettings.test.ts", "src/hooks/useLocalStorage.test.ts", "src/hooks/useTheme.test.ts", // Mocks `react` (useSyncExternalStore) like useTheme.test.ts; under diff --git a/docs/operations/development.md b/docs/operations/development.md index d415badb61d0..39686807d6dd 100644 --- a/docs/operations/development.md +++ b/docs/operations/development.md @@ -71,10 +71,13 @@ Windows investigation while that suite is not a required gate. ### Unused code -`vp run knip:check` runs the unused-file and dependency check enforced by CI. +`vp run knip:check` checks unused files and dependencies across the repo, then +unused exports and types in `packages/tailscale` and `packages/effect-codex-app-server`. +CI enforces both checks. Use `vp run knip --workspace apps/web` to audit one workspace, including exports, or `vp run knip:production --workspace apps/web` to find code kept alive only by tests. -The full export audit still has findings and is not a CI gate. Review callers before +The full export audit still has findings and is not a repo-wide CI gate. Extend the +export check's workspace selectors as more workspaces become clean. Review callers before deleting code; production mode can also report development scripts and test fixtures. Runtime-discovered entrypoints and dependency exceptions belong in [knip.jsonc](../../knip.jsonc). diff --git a/docs/user/welcome-wizard.md b/docs/user/welcome-wizard.md new file mode 100644 index 000000000000..9a2aa116670d --- /dev/null +++ b/docs/user/welcome-wizard.md @@ -0,0 +1,62 @@ +# Welcome wizard + +T3 Code shows a setup flow when you open a new installation or connect to the +hosted app for the first time. Existing workspaces skip this flow. + +## Choose a connection + +- **This computer** runs agents on the computer that hosts T3 Code. It does not + require an account. +- **T3 Connect** connects computers that are signed in to your account. Run + `npx t3 connect` on each computer you want to add, then start T3 Code or run + `npx t3 serve` so the computer stays available. +- **Pair a server** connects directly to a server on your network or tailnet. + Start the server with `npx t3 serve`, then run `npx t3 pair --tailscale` and + paste the pairing link. You can also run `npx t3 serve --host
` and + use `npx t3 pair` when the server is already reachable on your network. + +If T3 Code cannot confirm the workspace during startup, the setup flow shows +**Still connecting** instead of opening the app. Select **Reload** to try again. + +If T3 Code cannot read your saved settings, it shows **Could not read settings**. +Select **Retry** after storage becomes available. Setup does not replace +unreadable settings with defaults. + +## Check your agents + +T3 Code checks the selected computer for Claude Code and Codex. If an agent is +not installed or signed in, select its action to open a terminal with the +correct command ready to run. Other providers can be enabled in Settings. + +The setup terminal uses the home directory and environment configured for the +selected provider instance. Sensitive values remain redacted in Settings and +terminal metadata while the terminal process can use them. + +## Import your projects + +T3 Code finds directories that Claude Code or Codex has used. The default +selection includes projects active within the last 30 days. Select **Choose** +to include older projects or change the selection. + +A large or malformed history can reach the scan limit. T3 Code keeps the +projects it found and warns when projects or conversations may be missing. + +Imported projects include Codex and Claude conversations active within the last +30 days. You can continue those conversations in T3 Code. + +Conversation import is best effort. T3 Code keeps the first user prompt and the +newest remaining visible user and assistant messages, with 200 messages total. +It omits tool activity and attachments. For Codex, it omits generated setup +context only when a canonical user event and a valid shared turn ID identify the +same user turn. Ambiguous legacy or response-only context stays in the imported +conversation so T3 Code does not remove user text. It reads one conversation at +a time and skips files larger than 16 MiB. It ignores malformed records and skips +unreadable or unparseable conversations. + +Each import attempt reads up to 100 conversation files and 64 MiB per project, +with up to 100,000 input records. Run import again to continue a large batch. +Completed conversations are not imported again. You can continue without the +remaining history. + +You can skip agent setup and project import. Select **Back** to return to a +previous step. diff --git a/infra/relay/src/deploymentConfig.test.ts b/infra/relay/src/deploymentConfig.test.ts index 44c7627a4daf..f090c70ee22b 100644 --- a/infra/relay/src/deploymentConfig.test.ts +++ b/infra/relay/src/deploymentConfig.test.ts @@ -11,17 +11,10 @@ import { RelayPublicDomainLabelTooLongError, relayPublicDomainForStage, relayResourceNameForStage, - relayStageSlug, } from "./deploymentConfig.ts"; const isRelayPublicDomainLabelTooLongError = Schema.is(RelayPublicDomainLabelTooLongError); -describe("relayStageSlug", () => { - it("matches Alchemy physical-name sanitization for default developer stages", () => { - expect(relayStageSlug("dev_julius")).toBe("dev-julius"); - }); -}); - describe("relayPublicDomainForStage", () => { it("uses the canonical relay hostname for production", () => { expect(relayPublicDomainForStage("prod", ".example.com.")).toBe("relay.example.com"); diff --git a/infra/relay/src/deploymentConfig.ts b/infra/relay/src/deploymentConfig.ts index fe9d37b29988..565e16422dd2 100644 --- a/infra/relay/src/deploymentConfig.ts +++ b/infra/relay/src/deploymentConfig.ts @@ -56,7 +56,7 @@ function appendDnsSafeSuffix(prefix: string, suffix: string): string { * Alchemy's physical-name helper sanitizes resource names after adding the * stage. Keep custom domains and runtime-created resources aligned with it. */ -export function relayStageSlug(stage: string): string { +function relayStageSlug(stage: string): string { return stage .toLowerCase() .replaceAll(/[^a-z0-9-]/g, "-") diff --git a/package.json b/package.json index d4cde83efabc..fcddace2a8ea 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "tc": "vp run -r --concurrency-limit 2 typecheck", "lint": "vp lint --report-unused-disable-directives", "knip": "knip", - "knip:check": "knip --include files,dependencies --no-config-hints", + "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace packages/tailscale --workspace packages/effect-codex-app-server --exports --no-config-hints", "knip:production": "knip --production", "lint:mobile": "node scripts/mobile-native-static-check.ts", "test": "vp run -r test", diff --git a/packages/client-runtime/src/connection/presentation.test.ts b/packages/client-runtime/src/connection/presentation.test.ts index 44bc833eb0dc..97ca0e05613e 100644 --- a/packages/client-runtime/src/connection/presentation.test.ts +++ b/packages/client-runtime/src/connection/presentation.test.ts @@ -10,7 +10,6 @@ import { } from "./model.ts"; import { connectionCatalogDisplayUrl, - connectionPhaseMessage, connectionStatusText, connectionStatusTitle, presentEnvironmentConnection, @@ -119,10 +118,6 @@ describe("connection presentation", () => { }); }); - it("gives offline status precedence in global messaging", () => { - expect(connectionPhaseMessage("connected", TARGET.label, "offline")).toBe("You are offline"); - }); - it("combines reconnect progress with the latest failure", () => { const connection = { phase: "reconnecting", diff --git a/packages/client-runtime/src/connection/presentation.ts b/packages/client-runtime/src/connection/presentation.ts index edbb094b9a63..4c53f41b6b61 100644 --- a/packages/client-runtime/src/connection/presentation.ts +++ b/packages/client-runtime/src/connection/presentation.ts @@ -2,7 +2,7 @@ import type { ServerConfig } from "@t3tools/contracts"; import * as Option from "effect/Option"; import type { ConnectionCatalogEntry } from "./catalog.ts"; -import type { NetworkStatus, SupervisorConnectionState } from "./model.ts"; +import type { SupervisorConnectionState } from "./model.ts"; export type EnvironmentConnectionPhase = | "available" @@ -113,25 +113,3 @@ export function connectionCatalogDisplayUrl(entry: ConnectionCatalogEntry): stri : null; } } - -export function connectionPhaseMessage( - phase: EnvironmentConnectionPhase, - label: string, - networkStatus: NetworkStatus, -): string { - if (networkStatus === "offline" || phase === "offline") { - return "You are offline"; - } - switch (phase) { - case "available": - return "Available"; - case "connecting": - return `Connecting to ${label}...`; - case "reconnecting": - return `Reconnecting to ${label}...`; - case "connected": - return "Connected"; - case "error": - return "Connection failed"; - } -} diff --git a/packages/client-runtime/src/markdownLinks.test.ts b/packages/client-runtime/src/markdownLinks.test.ts index 42cd7a35e473..4aea947b87d6 100644 --- a/packages/client-runtime/src/markdownLinks.test.ts +++ b/packages/client-runtime/src/markdownLinks.test.ts @@ -3,7 +3,6 @@ import { describe, expect, it } from "vite-plus/test"; import { fileBasename, inlineCodeFilePathCandidate, - isConventionalFilePosition, parseFileUrlHref, parseMarkdownFileLink, splitFilePathPosition, @@ -28,15 +27,6 @@ describe("inlineCodeFilePathCandidate", () => { }); }); -describe("isConventionalFilePosition", () => { - it("distinguishes extensionless file locations from labels and ports", () => { - expect(isConventionalFilePosition("Dockerfile:8:2")).toBe(true); - expect(isConventionalFilePosition("Makefile")).toBe(false); - expect(isConventionalFilePosition("TODO:12")).toBe(false); - expect(isConventionalFilePosition("port:3000")).toBe(false); - }); -}); - describe("parseFileUrlHref", () => { it.each([ ["file:///Users/julius/project/src/main.ts#L42", "/Users/julius/project/src/main.ts", "#L42"], diff --git a/packages/client-runtime/src/markdownLinks.ts b/packages/client-runtime/src/markdownLinks.ts index 2e455655d005..29a337e49c42 100644 --- a/packages/client-runtime/src/markdownLinks.ts +++ b/packages/client-runtime/src/markdownLinks.ts @@ -15,7 +15,6 @@ const INLINE_CODE_DISQUALIFIER_PATTERN = /[\s`]/; const PATH_SEPARATOR_PATTERN = /[\\/]/; const FILE_EXTENSION_PATTERN = /\.[A-Za-z0-9_-]+$/; const NUMERIC_DOTTED_PATTERN = /^\d+(?:\.\d+)+$/; -const BARE_EXTENSIONLESS_POSITION_PATTERN = /^[A-Za-z0-9_-]+(?::\d+){1,2}$/; // Standard OS and dev-container roots; deliberately excludes app-route-ish // prefixes like /app/ or /chat/ so SPA routes never read as files. const POSIX_FILE_ROOT_PREFIXES = [ @@ -152,14 +151,6 @@ function looksLikeHostname(segment: string, hasPosition: boolean): boolean { return !hasPosition && COUNTRY_HOSTNAME_TLDS.has(lastLabel); } -/** Recognizes conventional extensionless filenames with an explicit line position. */ -export function isConventionalFilePosition(path: string): boolean { - return ( - BARE_EXTENSIONLESS_POSITION_PATTERN.test(path) && - EXTENSIONLESS_FILE_NAMES.has(path.replace(POSITION_SUFFIX_PATTERN, "")) - ); -} - /** * Picks path-shaped inline code for the client's markdown file-link resolver. * It does not resolve paths or turn plain prose and fenced code into links. diff --git a/packages/client-runtime/src/rpc/client.test.ts b/packages/client-runtime/src/rpc/client.test.ts index 4e6baba8bef4..f2141add930f 100644 --- a/packages/client-runtime/src/rpc/client.test.ts +++ b/packages/client-runtime/src/rpc/client.test.ts @@ -3,10 +3,12 @@ import { EnvironmentId, type RelayClientInstallProgressEvent, type ServerConfigStreamEvent, + type ServerLifecycleStreamEvent, WS_METHODS, } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; import * as Cause from "effect/Cause"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; @@ -27,7 +29,13 @@ import { import * as EnvironmentSupervisor from "../connection/supervisor.ts"; import * as RpcSession from "../rpc/session.ts"; import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; -import { EnvironmentRpcRequestObserver, request, runStream, subscribe } from "./client.ts"; +import { + EnvironmentRpcRequestObserver, + request, + runStream, + subscribe, + subscribeDynamicWithSession, +} from "./client.ts"; const TARGET = new PrimaryConnectionTarget({ environmentId: EnvironmentId.make("environment-1"), @@ -221,6 +229,72 @@ describe("environment RPC", () => { }), ); + it.effect("keeps the producer session on an old value buffered across a session switch", () => + Effect.gen(function* () { + const firstSubscribed = yield* Deferred.make(); + const secondSubscribed = yield* Deferred.make(); + const firstValueBlocked = yield* Deferred.make(); + const releaseFirstValue = yield* Deferred.make(); + const firstValue = { source: "first", index: 1 } as unknown as ServerLifecycleStreamEvent; + const bufferedFirstValue = { + source: "first", + index: 2, + } as unknown as ServerLifecycleStreamEvent; + const secondValue = { source: "second", index: 1 } as unknown as ServerLifecycleStreamEvent; + const firstClient = { + [WS_METHODS.subscribeServerLifecycle]: () => + Stream.fromEffect(Deferred.succeed(firstSubscribed, undefined)).pipe( + Stream.drain, + Stream.concat(Stream.fromIterable([firstValue, bufferedFirstValue])), + Stream.concat(Stream.never), + ), + } as unknown as WsRpcProtocolClient; + const secondClient = { + [WS_METHODS.subscribeServerLifecycle]: () => + Stream.fromEffect(Deferred.succeed(secondSubscribed, undefined)).pipe( + Stream.drain, + Stream.concat(Stream.make(secondValue)), + Stream.concat(Stream.never), + ), + } as unknown as WsRpcProtocolClient; + const firstSession = session(firstClient); + const secondSession = session(secondClient); + const { activeSession, supervisor } = yield* makeHarness(); + + const resultFiber = yield* subscribeDynamicWithSession( + WS_METHODS.subscribeServerLifecycle, + () => Effect.succeed({}), + ).pipe( + Stream.mapEffect(([producerSession, value]) => + value === firstValue + ? Deferred.succeed(firstValueBlocked, undefined).pipe( + Effect.andThen(Deferred.await(releaseFirstValue)), + Effect.as([producerSession, value] as const), + ) + : Effect.succeed([producerSession, value] as const), + ), + Stream.take(3), + Stream.runCollect, + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.forkChild, + ); + + yield* SubscriptionRef.set(activeSession, Option.some(firstSession)); + yield* Deferred.await(firstSubscribed); + yield* Deferred.await(firstValueBlocked); + yield* SubscriptionRef.set(activeSession, Option.some(secondSession)); + yield* Deferred.await(secondSubscribed); + yield* Deferred.succeed(releaseFirstValue, undefined); + + const result = yield* Fiber.join(resultFiber); + expect(result).toEqual([ + [firstSession, firstValue], + [firstSession, bufferedFirstValue], + [secondSession, secondValue], + ]); + }), + ); + it.effect("keeps durable subscriptions alive across a transport failure and new session", () => Effect.gen(function* () { const subscriptions: string[] = []; diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index 72ff2c667575..8cd56764e0f6 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -189,15 +189,15 @@ interface SubscriptionOptions { readonly resubscribe?: Stream.Stream; } -export function subscribeDynamic( +function subscribeDynamicMapped( tag: TTag, makeInput: (session: RpcSession) => Effect.Effect>, + mapStream: ( + session: RpcSession, + stream: Stream.Stream, EnvironmentRpcStreamFailure>, + ) => Stream.Stream>, options?: SubscriptionOptions, -): Stream.Stream< - EnvironmentRpcStreamValue, - EnvironmentRpcStreamFailure, - EnvironmentSupervisor -> { +): Stream.Stream, EnvironmentSupervisor> { return Stream.unwrap( Effect.gen(function* () { const supervisor = yield* EnvironmentSupervisor; @@ -227,10 +227,7 @@ export function subscribeDynamic( EnvironmentRpcStreamValue, EnvironmentRpcStreamFailure >; - const subscribeToSession = (): Stream.Stream< - EnvironmentRpcStreamValue, - EnvironmentRpcStreamFailure - > => + const subscribeToSession = (): Stream.Stream> => Stream.suspend(() => Stream.unwrap( Effect.gen(function* () { @@ -240,7 +237,7 @@ export function subscribeDynamic( method: tag, input, }); - return method(input).pipe( + return mapStream(session, method(input)).pipe( Stream.ensuring(completeObservation), Stream.catchCause((cause) => { const hasOnlyExpectedFailures = @@ -301,6 +298,36 @@ export function subscribeDynamic( ); } +export function subscribeDynamic( + tag: TTag, + makeInput: (session: RpcSession) => Effect.Effect>, + options?: SubscriptionOptions, +): Stream.Stream< + EnvironmentRpcStreamValue, + EnvironmentRpcStreamFailure, + EnvironmentSupervisor +> { + return subscribeDynamicMapped(tag, makeInput, (_session, stream) => stream, options); +} + +/** Tags each value before `switchMap` can buffer it across a session change. */ +export function subscribeDynamicWithSession( + tag: TTag, + makeInput: (session: RpcSession) => Effect.Effect>, + options?: SubscriptionOptions, +): Stream.Stream< + readonly [session: RpcSession, value: EnvironmentRpcStreamValue], + EnvironmentRpcStreamFailure, + EnvironmentSupervisor +> { + return subscribeDynamicMapped( + tag, + makeInput, + (session, stream) => stream.pipe(Stream.map((value) => [session, value] as const)), + options, + ); +} + export function subscribe( tag: TTag, input: EnvironmentRpcInput, diff --git a/packages/client-runtime/src/state/server.test.ts b/packages/client-runtime/src/state/server.test.ts index dab6382080b2..30835376c5fe 100644 --- a/packages/client-runtime/src/state/server.test.ts +++ b/packages/client-runtime/src/state/server.test.ts @@ -7,8 +7,8 @@ import { } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; import * as Cause from "effect/Cause"; -import * as Duration from "effect/Duration"; import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; @@ -31,14 +31,16 @@ import * as Persistence from "../platform/persistence.ts"; import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; import type { RpcSession } from "../rpc/session.ts"; import { + applyServerWelcomeEvent, + makeEnvironmentServerWelcomeState, makeEnvironmentServerConfigState, isLegacyUpdateHandoffLoss, matchesServerUpdateReadyEvent, projectServerWebVersion, matchesServerUpdateResumeEvent, nudgeReconnectDuringUpdateRestart, - projectServerWelcome, resolveServerConfigValue, + resolveServerWelcomeState, resolveServerUpdateProgressResult, serverUpdateStateForProgressEvent, serverUpdateStateForServerVersion, @@ -495,25 +497,227 @@ describe("server state projection", () => { expect(Option.getOrThrow(downgraded).config.environmentThemes).toBeUndefined(); }); - it("retains welcome when a ready event follows in the same stream chunk", () => { + it("keeps a current welcome on ready and rejects a buffered welcome from the old session", () => { + const firstSession = session({} as WsRpcProtocolClient); + const secondSession = session({} as WsRpcProtocolClient); const welcome = { environment: {} as ServerLifecycleWelcomePayload["environment"], cwd: "/repo", projectName: "repo", } as ServerLifecycleWelcomePayload; - const [afterWelcome] = projectServerWelcome(Option.none(), { + const initial = { + currentSession: firstSession, + welcomeSession: firstSession, + welcome: null, + }; + const afterWelcome = applyServerWelcomeEvent(initial, firstSession, { type: "welcome", payload: welcome, }); - const [afterReady, emitted] = projectServerWelcome(afterWelcome, { + const afterReady = applyServerWelcomeEvent(afterWelcome, firstSession, { type: "ready", payload: {}, }); + const afterSwitch = { ...afterReady, currentSession: secondSession }; + const afterBufferedOldWelcome = applyServerWelcomeEvent(afterSwitch, firstSession, { + type: "welcome", + payload: { ...welcome, cwd: "/stale" }, + }); - expect(Option.getOrThrow(afterReady)).toBe(welcome); - expect(emitted).toEqual([]); + expect(afterReady).toBe(afterWelcome); + expect(resolveServerWelcomeState(afterReady)).toBe(welcome); + expect(afterBufferedOldWelcome).toBe(afterSwitch); + expect(resolveServerWelcomeState(afterBufferedOldWelcome)).toBeNull(); }); + it.effect("checks the authoritative session before accepting a buffered welcome", () => + Effect.gen(function* () { + const firstEvents = yield* Queue.unbounded<{ + readonly type: "welcome" | "ready"; + readonly payload: unknown; + }>(); + const firstSubscribed = yield* Deferred.make(); + const firstClient = { + [WS_METHODS.subscribeServerLifecycle]: () => + Stream.fromEffect(Deferred.succeed(firstSubscribed, undefined)).pipe( + Stream.drain, + Stream.concat(Stream.fromQueue(firstEvents)), + ), + } as unknown as WsRpcProtocolClient; + const firstSession = session(firstClient); + const secondSession = session({} as WsRpcProtocolClient); + const supervisorSession = yield* SubscriptionRef.make(Option.some(firstSession)); + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: TARGET, + state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE), + session: supervisorSession, + prepared: yield* SubscriptionRef.make(Option.none()), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); + const staleWelcome = { + environment: {} as ServerLifecycleWelcomePayload["environment"], + cwd: "/stale", + projectName: "stale", + } as ServerLifecycleWelcomePayload; + + yield* Effect.scoped( + Effect.gen(function* () { + const state = yield* makeEnvironmentServerWelcomeState().pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + ); + yield* Deferred.await(firstSubscribed); + + // Model the point after the ref changed but before either subscriber + // processed its publication. + supervisorSession.value = Option.some(secondSession); + const handled = yield* SubscriptionRef.changes(state).pipe( + Stream.filter( + (value) => value.currentSession === secondSession || value.welcome === staleWelcome, + ), + Stream.runHead, + Effect.map(Option.getOrThrow), + Effect.forkChild, + ); + yield* Queue.offer(firstEvents, { type: "welcome", payload: staleWelcome }); + + const next = yield* Fiber.join(handled); + expect(next.currentSession).toBe(secondSession); + expect(resolveServerWelcomeState(next)).toBeNull(); + }), + ); + }), + ); + + it.effect("reads the authoritative session after waiting for the welcome state lock", () => + Effect.gen(function* () { + const firstSubscribed = yield* Deferred.make(); + const firstClient = { + [WS_METHODS.subscribeServerLifecycle]: () => + Stream.fromEffect(Deferred.succeed(firstSubscribed, undefined)).pipe(Stream.drain), + } as unknown as WsRpcProtocolClient; + const secondClient = { + [WS_METHODS.subscribeServerLifecycle]: () => Stream.never, + } as unknown as WsRpcProtocolClient; + const firstSession = session(firstClient); + const secondSession = session(secondClient); + const thirdSession = session({} as WsRpcProtocolClient); + const supervisorSession = yield* SubscriptionRef.make(Option.some(firstSession)); + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: TARGET, + state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE), + session: supervisorSession, + prepared: yield* SubscriptionRef.make(Option.none()), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); + + yield* Effect.scoped( + Effect.gen(function* () { + const state = yield* makeEnvironmentServerWelcomeState().pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + ); + yield* Deferred.await(firstSubscribed); + const changed = yield* SubscriptionRef.changes(state).pipe( + Stream.filter((value) => value.currentSession !== firstSession), + Stream.runHead, + Effect.map(Option.getOrThrow), + Effect.forkChild, + ); + + yield* state.semaphore.withPermit( + Effect.gen(function* () { + yield* SubscriptionRef.set(supervisorSession, Option.some(secondSession)); + yield* Effect.yieldNow; + yield* Effect.yieldNow; + yield* Effect.yieldNow; + supervisorSession.value = Option.some(thirdSession); + }), + ); + + expect((yield* Fiber.join(changed)).currentSession).toBe(thirdSession); + }), + ); + }), + ); + + it.effect("clears a welcome until the reconnected session sends its own", () => + Effect.gen(function* () { + const firstEvents = yield* Queue.unbounded<{ + readonly type: "welcome" | "ready"; + readonly payload: unknown; + }>(); + const secondEvents = yield* Queue.unbounded<{ + readonly type: "welcome" | "ready"; + readonly payload: unknown; + }>(); + const firstClient = { + [WS_METHODS.subscribeServerLifecycle]: () => Stream.fromQueue(firstEvents), + } as unknown as WsRpcProtocolClient; + const secondClient = { + [WS_METHODS.subscribeServerLifecycle]: () => Stream.fromQueue(secondEvents), + } as unknown as WsRpcProtocolClient; + const firstSession = session(firstClient); + const secondSession = session(secondClient); + const supervisorSession = yield* SubscriptionRef.make(Option.some(firstSession)); + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: TARGET, + state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE), + session: supervisorSession, + prepared: yield* SubscriptionRef.make(Option.none()), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); + const firstWelcome = { + environment: {} as ServerLifecycleWelcomePayload["environment"], + cwd: "/first", + projectName: "first", + } as ServerLifecycleWelcomePayload; + const secondWelcome = { + environment: {} as ServerLifecycleWelcomePayload["environment"], + cwd: "/second", + projectName: "second", + } as ServerLifecycleWelcomePayload; + + yield* Effect.scoped( + Effect.gen(function* () { + const state = yield* makeEnvironmentServerWelcomeState().pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + ); + const nextResolved = ( + predicate: (value: ServerLifecycleWelcomePayload | null) => boolean, + ) => + SubscriptionRef.changes(state).pipe( + Stream.map(resolveServerWelcomeState), + Stream.filter(predicate), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + + const first = yield* nextResolved((value) => value === firstWelcome).pipe( + Effect.forkChild, + ); + yield* Queue.offer(firstEvents, { type: "welcome", payload: firstWelcome }); + expect(yield* Fiber.join(first)).toBe(firstWelcome); + + const cleared = yield* nextResolved((value) => value === null).pipe(Effect.forkChild); + yield* SubscriptionRef.set(supervisorSession, Option.some(secondSession)); + expect(yield* Fiber.join(cleared)).toBeNull(); + expect(resolveServerWelcomeState(yield* SubscriptionRef.get(state))).toBeNull(); + + const second = yield* nextResolved((value) => value === secondWelcome).pipe( + Effect.forkChild, + ); + yield* Queue.offer(secondEvents, { type: "welcome", payload: secondWelcome }); + expect(yield* Fiber.join(second)).toBe(secondWelcome); + }), + ); + }), + ); + it("tracks the latest web version and ignores non-web lifecycle events", () => { const [seeded, seededEmit] = projectServerWebVersion(Option.none(), { type: "webVersionChanged", diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index 66797607c063..5ad4c3ed043c 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -40,8 +40,10 @@ import { request, runStream, subscribe, + subscribeDynamicWithSession, type EnvironmentRpcInput, } from "../rpc/client.ts"; +import type { RpcSession } from "../rpc/session.ts"; import { followStreamInEnvironment } from "./runtime.ts"; import { applyServerConfigProjection, @@ -476,21 +478,119 @@ export function serverConfigStateChanges( ); } -export function projectServerWelcome( - current: Option.Option, +export function applyServerWelcomeEvent( + current: EnvironmentServerWelcomeState, + session: RpcSession, event: { readonly type: "welcome" | "ready" | "webVersionChanged"; readonly payload: unknown; }, -): readonly [ - Option.Option, - ReadonlyArray, -] { - if (event.type !== "welcome") { - return [current, []]; - } - const welcome = event.payload as ServerLifecycleWelcomePayload; - return [Option.some(welcome), [welcome]]; +): EnvironmentServerWelcomeState { + return event.type === "welcome" && current.currentSession === session + ? { + ...current, + welcomeSession: session, + welcome: event.payload as ServerLifecycleWelcomePayload, + } + : current; +} + +export interface EnvironmentServerWelcomeState { + readonly currentSession: RpcSession | null; + readonly welcomeSession: RpcSession | null; + readonly welcome: ServerLifecycleWelcomePayload | null; +} + +export function resolveServerWelcomeState( + state: EnvironmentServerWelcomeState, +): ServerLifecycleWelcomePayload | null { + return state.currentSession === state.welcomeSession ? state.welcome : null; +} + +export const makeEnvironmentServerWelcomeState = Effect.fn("EnvironmentServerWelcomeState.make")( + function* () { + const supervisor = yield* EnvironmentSupervisor; + const initialSession = Option.getOrNull(yield* SubscriptionRef.get(supervisor.session)); + const state = yield* SubscriptionRef.make({ + currentSession: initialSession, + welcomeSession: null, + welcome: null, + }); + + const updateWithCurrentSession = Effect.fn( + "EnvironmentServerWelcomeState.updateWithCurrentSession", + )(function* ( + update: ( + current: EnvironmentServerWelcomeState, + currentSession: RpcSession | null, + ) => EnvironmentServerWelcomeState, + ) { + return yield* SubscriptionRef.modifyEffect(state, (current) => + SubscriptionRef.get(supervisor.session).pipe( + Effect.map( + (latestSession) => + [undefined, update(current, Option.getOrNull(latestSession))] as const, + ), + ), + ); + }); + + yield* SubscriptionRef.changes(supervisor.session).pipe( + Stream.runForEach(() => + updateWithCurrentSession((current, currentSession) => ({ + ...current, + currentSession, + })), + ), + Effect.forkScoped, + ); + + yield* subscribeDynamicWithSession( + WS_METHODS.subscribeServerLifecycle, + Effect.fn("EnvironmentServerWelcomeState.makeSubscribeInput")(function* (session) { + yield* updateWithCurrentSession((current, currentSession) => + currentSession === session + ? { + ...current, + currentSession, + welcomeSession: session, + welcome: null, + } + : { ...current, currentSession }, + ); + return {}; + }), + ).pipe( + Stream.runForEach(([session, event]) => + updateWithCurrentSession((current, currentSession) => + applyServerWelcomeEvent( + { + ...current, + currentSession, + }, + session, + event, + ), + ), + ), + Effect.forkScoped, + ); + + return state; + }, +); + +export function serverWelcomeStateChanges(environmentId: EnvironmentId) { + return followStreamInEnvironment( + environmentId, + Stream.unwrap( + makeEnvironmentServerWelcomeState().pipe( + Effect.map((state) => + SubscriptionRef.changes(state).pipe(Stream.map(resolveServerWelcomeState)), + ), + ), + ), + ); } /** @@ -852,6 +952,27 @@ export function createServerEnvironmentAtoms( Atom.withLabel(`environment-data:server:providers:${environmentId}`), ), ); + const welcomeStateFamily = Atom.family((environmentId: EnvironmentId) => + runtime + .atom(serverWelcomeStateChanges(environmentId), { initialValue: null }) + .pipe( + Atom.setIdleTTL(5 * 60_000), + Atom.withLabel(`environment-data:server:welcome-state:${environmentId}`), + ), + ); + const welcomeFamily = Atom.family((environmentId: EnvironmentId) => + Atom.make((get) => { + const result = get(welcomeStateFamily(environmentId)); + if (result._tag !== "Success") return result; + return result.value === null + ? AsyncResult.initial(result.waiting) + : AsyncResult.success(result.value, result); + }).pipe(Atom.withLabel(`environment-data:server:welcome:${environmentId}`)), + ); + const welcome = (target: { + readonly environmentId: EnvironmentId; + readonly input: EnvironmentRpcInput; + }) => welcomeFamily(target.environmentId); return { configValueAtom, @@ -939,14 +1060,7 @@ export function createServerEnvironmentAtoms( refreshTrigger: ({ environmentId }) => usagePricesAtom(environmentId), }), configProjection, - welcome: createEnvironmentRpcSubscriptionAtomFamily(runtime, { - label: "environment-data:server:welcome", - tag: WS_METHODS.subscribeServerLifecycle, - transform: (stream) => - stream.pipe( - Stream.mapAccum(Option.none, projectServerWelcome), - ), - }), + welcome, webVersion: createEnvironmentRpcSubscriptionAtomFamily(runtime, { label: "environment-data:server:web-version", tag: WS_METHODS.subscribeServerLifecycle, diff --git a/packages/client-runtime/src/state/subagentRuntime.test.ts b/packages/client-runtime/src/state/subagentRuntime.test.ts index 19a09d3933c3..1685e16061b8 100644 --- a/packages/client-runtime/src/state/subagentRuntime.test.ts +++ b/packages/client-runtime/src/state/subagentRuntime.test.ts @@ -5,10 +5,6 @@ import { foldSubagentActivities, formatSubagentModelLabel, formatSubagentTokenCount, - isAgentAttributedToolActivity, - isSubagentActivityKind, - isTimelineBypassActivity, - workflowCardMembers, } from "./subagentRuntime.ts"; let sequence = 0; @@ -557,64 +553,6 @@ describe("deriveAgentPanelModel", () => { }); }); -describe("workflowCardMembers", () => { - it("orders by urgency (failed, running, waiting) and reports overflow", () => { - const roster = fold([ - activity("task.started", { taskId: "wf-1", taskType: "local_workflow" }), - ...[..."abcdefghij"].map((letter, index) => - activity("task.progress", { - taskId: `wf-1:wf:${index}`, - title: `agent-${letter}`, - status: index === 3 ? "failed" : index < 3 ? "completed" : "running", - ...(index === 3 ? { error: "died" } : {}), - parentAgentId: "wf-1", - agentIndex: index, - phaseIndex: 0, - phaseTitle: "Work", - }), - ), - ]); - const model = deriveAgentPanelModel({ agents: roster }); - const { visible, overflow } = workflowCardMembers(model.workflows[0]!, 8); - expect(visible).toHaveLength(8); - expect(overflow).toBe(2); - expect(visible[0]!.status).toBe("failed"); - expect(visible.filter((agent) => agent.status === "completed").length).toBeLessThanOrEqual(2); - }); -}); - -describe("timeline predicates", () => { - it("recognizes subagent activity kinds as fold input", () => { - for (const kind of [ - "task.started", - "task.progress", - "task.updated", - "task.completed", - "tool.progress", - ]) { - expect(isSubagentActivityKind(kind)).toBe(true); - } - expect(isSubagentActivityKind("tool.completed")).toBe(false); - }); - - it("attributed tool rows are re-homed; unattributed rows stay in the timeline", () => { - expect(isAgentAttributedToolActivity(activity("tool.completed", { agentId: "task-1" }))).toBe( - true, - ); - expect(isAgentAttributedToolActivity(activity("tool.completed", {}))).toBe(false); - expect(isAgentAttributedToolActivity(activity("tool.completed", { agentId: " " }))).toBe( - false, - ); - }); - - it("timelineBypass rows never render in the parent chat", () => { - expect(isTimelineBypassActivity(activity("task.progress", { timelineBypass: true }))).toBe( - true, - ); - expect(isTimelineBypassActivity(activity("task.progress", {}))).toBe(false); - }); -}); - describe("formatSubagentTokenCount", () => { it("formats plain counters", () => { expect(formatSubagentTokenCount(950)).toBe("950"); diff --git a/packages/client-runtime/src/state/subagentRuntime.ts b/packages/client-runtime/src/state/subagentRuntime.ts index 532cda20d050..e441de32db48 100644 --- a/packages/client-runtime/src/state/subagentRuntime.ts +++ b/packages/client-runtime/src/state/subagentRuntime.ts @@ -861,61 +861,6 @@ export function deriveAgentPanelModel({ }; } -/** - * Members ordered by urgency for the capped inline workflow card: running and - * failed first, then waiting, then most recently updated. - */ -export function workflowCardMembers( - group: AgentPanelWorkflowGroup, - limit: number, -): { readonly visible: ReadonlyArray; readonly overflow: number } { - const all = [...group.phases.flatMap((phase) => phase.members), ...group.unphasedMembers]; - const urgency = (agent: RuntimeSubagent): number => { - if (agent.status === "failed") return 0; - if (agent.status === "running") return 1; - if (agent.status === "waiting") return 2; - return 3; - }; - const ordered = all - .slice() - .sort((a, b) => urgency(a) - urgency(b) || b.updatedAt.localeCompare(a.updatedAt)); - return { - visible: ordered.slice(0, limit), - overflow: Math.max(0, ordered.length - limit), - }; -} - -/** Kinds the timeline should not render as generic rows (fold input only). */ -export function isSubagentActivityKind(kind: string): boolean { - return ( - kind === "task.started" || - kind === "task.progress" || - kind === "task.updated" || - kind === "task.completed" || - kind === "tool.progress" - ); -} - -/** - * Quiet-timeline guarantee: tool rows attributed to an owning agent belong in - * the Agents surface, not the parent chat. Unattributed rows must stay. - */ -export function isAgentAttributedToolActivity(activity: OrchestrationThreadActivity): boolean { - if (typeof activity.payload !== "object" || activity.payload === null) { - return false; - } - const payload = activity.payload as Record; - return typeof payload.agentId === "string" && payload.agentId.trim().length > 0; -} - -/** Timeline-bypassing synthesized rows (Codex children, workflow members). */ -export function isTimelineBypassActivity(activity: OrchestrationThreadActivity): boolean { - if (typeof activity.payload !== "object" || activity.payload === null) { - return false; - } - return (activity.payload as Record).timelineBypass === true; -} - /** * Compact model chip text: strips vendor prefixes/date-or-context suffixes * ("claude-sonnet-5[1m]" → "sonnet-5[1m]", "claude-opus-4-20250514" → diff --git a/packages/client-runtime/src/state/threadReducer.test.ts b/packages/client-runtime/src/state/threadReducer.test.ts index 2750d2c11db9..167993c18672 100644 --- a/packages/client-runtime/src/state/threadReducer.test.ts +++ b/packages/client-runtime/src/state/threadReducer.test.ts @@ -435,6 +435,40 @@ describe("applyThreadDetailEvent", () => { } }); + it("keeps imported replies turnless when delivered again", () => { + const event = { + ...baseEventFields, + sequence: 6, + occurredAt: "2026-04-01T06:00:00.000Z", + aggregateKind: "thread", + aggregateId: baseThread.id, + type: "thread.message-sent", + payload: { + threadId: baseThread.id, + messageId: MessageId.make("import:codex:session-1:000001"), + role: "assistant", + text: "Imported reply", + turnId: null, + streaming: false, + createdAt: "2026-03-01T06:00:00.000Z", + updatedAt: "2026-03-01T06:00:00.000Z", + }, + } as const; + + const imported = applyThreadDetailEvent(baseThread, event); + expect(imported.kind).toBe("updated"); + if (imported.kind !== "updated") return; + expect(imported.thread.latestTurn).toBeNull(); + expect(imported.thread.checkpoints).toBe(baseThread.checkpoints); + + const repeated = applyThreadDetailEvent(imported.thread, { ...event, sequence: 7 }); + expect(repeated.kind).toBe("updated"); + if (repeated.kind !== "updated") return; + expect(repeated.thread.messages).toEqual(imported.thread.messages); + expect(repeated.thread.latestTurn).toBeNull(); + expect(repeated.thread.checkpoints).toBe(baseThread.checkpoints); + }); + it("appends text for streaming messages", () => { const threadWithMessage: OrchestrationThread = { ...baseThread, @@ -1306,6 +1340,100 @@ describe("applyThreadDetailEvent", () => { }); describe("thread.reverted", () => { + it("keeps imported history and removes the first live prompt at checkpoint zero", () => { + const threadWithImportedHistory: OrchestrationThread = { + ...baseThread, + messages: [ + { + id: MessageId.make("import:codex:session-1:000000"), + role: "user", + text: "Imported prompt", + turnId: null, + streaming: false, + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + }, + { + id: MessageId.make("import:codex:session-1:000001"), + role: "assistant", + text: "Imported answer", + turnId: null, + streaming: false, + createdAt: "2026-03-01T00:01:00.000Z", + updatedAt: "2026-03-01T00:01:00.000Z", + }, + { + id: MessageId.make("live-user-message"), + role: "user", + text: "New work", + turnId: null, + streaming: false, + createdAt: "2026-04-01T01:00:00.000Z", + updatedAt: "2026-04-01T01:00:00.000Z", + }, + ], + }; + + const result = applyThreadDetailEvent(threadWithImportedHistory, { + ...baseEventFields, + sequence: 14, + occurredAt: "2026-04-01T02:00:00.000Z", + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + type: "thread.reverted", + payload: { threadId: ThreadId.make("thread-1"), turnCount: 0 }, + }); + + expect(result.kind).toBe("updated"); + if (result.kind === "updated") { + expect(result.thread.messages.map((message) => message.text)).toEqual([ + "Imported prompt", + "Imported answer", + ]); + } + }); + + it("fallback-retains the earliest absolute timestamp across offsets", () => { + const threadWithOffsetMessages: OrchestrationThread = { + ...baseThread, + messages: [ + { + id: MessageId.make("earlier-by-offset"), + role: "user", + text: "Earlier", + turnId: null, + streaming: false, + createdAt: "2026-04-01T10:30:00.000+02:00", + updatedAt: "2026-04-01T10:30:00.000+02:00", + }, + { + id: MessageId.make("later-in-utc"), + role: "user", + text: "Later", + turnId: null, + streaming: false, + createdAt: "2026-04-01T09:00:00.000Z", + updatedAt: "2026-04-01T09:00:00.000Z", + }, + ], + }; + + const result = applyThreadDetailEvent(threadWithOffsetMessages, { + ...baseEventFields, + sequence: 14, + occurredAt: "2026-04-01T10:00:00.000Z", + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + type: "thread.reverted", + payload: { threadId: ThreadId.make("thread-1"), turnCount: 1 }, + }); + + expect(result.kind).toBe("updated"); + if (result.kind === "updated") { + expect(result.thread.messages.map((message) => message.id)).toEqual(["earlier-by-offset"]); + } + }); + it("filters entities to retained turns", () => { const threadWithData: OrchestrationThread = { ...baseThread, diff --git a/packages/client-runtime/src/state/threadReducer.ts b/packages/client-runtime/src/state/threadReducer.ts index caeaebc2122b..8633bb5341ba 100644 --- a/packages/client-runtime/src/state/threadReducer.ts +++ b/packages/client-runtime/src/state/threadReducer.ts @@ -12,6 +12,8 @@ import type { OrchestrationThreadActivity, TurnId, } from "@t3tools/contracts"; +import { isImportedAgentSessionMessageId } from "@t3tools/contracts"; +import { compareDateTimeStrings } from "@t3tools/shared/dateTime"; export type ThreadDetailReducerResult = | { readonly kind: "updated"; readonly thread: OrchestrationThread } @@ -684,7 +686,11 @@ export function applyThreadDetailEvent( ); const retainedTurnIds = new Set(Arr.map(checkpoints, (entry) => entry.turnId)); - const messages = retainMessagesAfterRevert(thread.messages, retainedTurnIds); + const messages = retainMessagesAfterRevert( + thread.messages, + retainedTurnIds, + event.payload.turnCount, + ); const proposedPlans = pipe( thread.proposedPlans, Arr.filter((plan) => plan.turnId === null || retainedTurnIds.has(plan.turnId)), @@ -878,16 +884,42 @@ function rebindCheckpointAssistantMessage( function retainMessagesAfterRevert( messages: ReadonlyArray, retainedTurnIds: ReadonlySet, + turnCount: number, ): OrchestrationMessage[] { - // Keep messages that belong to a retained turn, plus system messages and - // messages without a turn binding (pre-turn-0 user messages). - return Arr.filter(messages, (message) => { - if (message.role === "system") { - return true; + const retainedMessageIds = new Set(); + for (const message of messages) { + if (message.role === "system" || isImportedAgentSessionMessageId(message.id)) { + retainedMessageIds.add(message.id); + } else if (message.turnId !== null && retainedTurnIds.has(message.turnId)) { + retainedMessageIds.add(message.id); } - if (message.turnId === null) { - return true; + } + + for (const role of ["user", "assistant"] as const) { + const retainedCount = messages.filter( + (message) => + message.role === role && + !isImportedAgentSessionMessageId(message.id) && + retainedMessageIds.has(message.id), + ).length; + const missingCount = Math.max(0, turnCount - retainedCount); + const fallbackMessages = messages + .filter( + (message) => + message.role === role && + !retainedMessageIds.has(message.id) && + (message.turnId === null || retainedTurnIds.has(message.turnId)), + ) + .sort( + (left, right) => + compareDateTimeStrings(left.createdAt, right.createdAt) || + left.id.localeCompare(right.id), + ) + .slice(0, missingCount); + for (const message of fallbackMessages) { + retainedMessageIds.add(message.id); } - return retainedTurnIds.has(message.turnId); - }); + } + + return Arr.filter(messages, (message) => retainedMessageIds.has(message.id)); } diff --git a/packages/client-runtime/src/state/threads-atoms.test.ts b/packages/client-runtime/src/state/threads-atoms.test.ts index 452d0aaf25b4..797288cf82ae 100644 --- a/packages/client-runtime/src/state/threads-atoms.test.ts +++ b/packages/client-runtime/src/state/threads-atoms.test.ts @@ -27,6 +27,7 @@ import { PrimaryConnectionTarget, type NetworkStatus, type PreparedConnection, + type SupervisorConnectionState, } from "../connection/model.ts"; import { EnvironmentSupervisor } from "../connection/supervisor.ts"; import { EnvironmentCacheStore } from "../platform/persistence.ts"; @@ -75,8 +76,18 @@ const THREAD: OrchestrationThread = { }; const SNAPSHOT: OrchestrationThreadDetailSnapshot = { snapshotSequence: 7, thread: THREAD }; +const CONNECTED_STATE: SupervisorConnectionState = { + ...AVAILABLE_CONNECTION_STATE, + desired: true, + network: "online", + phase: "connected", + attempt: 1, + generation: 1, +}; + const makeHarness = Effect.fn("TestThreadAtoms.makeHarness")(function* (options?: { readonly snapshot?: OrchestrationThreadDetailSnapshot; + readonly connected?: boolean; }) { const subscriptions = yield* Queue.unbounded<{ readonly afterSequence: number | undefined; @@ -125,10 +136,14 @@ const makeHarness = Effect.fn("TestThreadAtoms.makeHarness")(function* (options? probe: Effect.void, closed: Effect.never, }; + const connectionState = yield* SubscriptionRef.make( + options?.connected ? CONNECTED_STATE : AVAILABLE_CONNECTION_STATE, + ); + const sessionRef = yield* SubscriptionRef.make(Option.some(session)); const supervisor = EnvironmentSupervisor.of({ target: TARGET, - state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE), - session: yield* SubscriptionRef.make(Option.some(session)), + state: connectionState, + session: sessionRef, prepared: yield* SubscriptionRef.make>( Option.some({ environmentId: TARGET.environmentId, @@ -230,6 +245,9 @@ const makeHarness = Effect.fn("TestThreadAtoms.makeHarness")(function* (options? ref, subscriptions, olderLoads, + connectionState, + session, + sessionRef, counts: () => ({ httpLoads, diskLoads, opened, active }), }; }); @@ -321,6 +339,75 @@ describe("createEnvironmentThreadStateAtoms", () => { }), ); + it.effect.each([ + { replayed: false, statuses: ["live"] }, + { replayed: true, statuses: ["live", "synchronizing", "live"] }, + ])( + "keeps a warm resume live until it replays events (replayed: $replayed)", + ({ replayed, statuses }) => + Effect.gen(function* () { + const h = yield* makeHarness({ connected: true }); + const unmount = h.registry.mount(h.stateAtom); + const first = yield* Queue.take(h.subscriptions); + yield* Queue.offer(first.events, { kind: "synchronized" }); + yield* observeState(h.registry, h.stateAtom, (state) => state.status === "live"); + unmount(); + yield* Deferred.await(first.closed); + + const observed: Array = []; + const stop = h.registry.subscribe(h.stateAtom, (state) => observed.push(state.status), { + immediate: true, + }); + const remount = h.registry.mount(h.stateAtom); + const next = yield* Queue.take(h.subscriptions); + expect(next.afterSequence).toBe(7); + if (replayed) { + yield* Queue.offer(next.events, { + kind: "snapshot", + snapshot: { snapshotSequence: 9, thread: { ...THREAD, title: "Replayed" } }, + }); + yield* observeState(h.registry, h.stateAtom, (state) => state.status === "synchronizing"); + } + yield* Queue.offer(next.events, { kind: "synchronized" }); + yield* observeState(h.registry, h.stateAtom, (state) => state.status === "live"); + expect(observed.filter((status, index) => observed[index - 1] !== status)).toEqual( + statuses, + ); + stop(); + remount(); + yield* Deferred.await(next.closed); + }), + ); + + it.effect("downgrades a warm resume when the connection dropped while away", () => + Effect.gen(function* () { + const h = yield* makeHarness({ connected: true }); + const unmount = h.registry.mount(h.stateAtom); + const first = yield* Queue.take(h.subscriptions); + yield* Queue.offer(first.events, { kind: "synchronized" }); + yield* observeState(h.registry, h.stateAtom, (state) => state.status === "live"); + unmount(); + yield* Deferred.await(first.closed); + + yield* SubscriptionRef.set(h.sessionRef, Option.none()); + yield* SubscriptionRef.set(h.connectionState, AVAILABLE_CONNECTION_STATE); + const remount = h.registry.mount(h.stateAtom); + yield* observeState(h.registry, h.stateAtom, (state) => state.status === "cached"); + expect(currentThread(h.registry, h.stateAtom)).toBe(THREAD); + expect(h.counts().opened).toBe(1); + + yield* SubscriptionRef.set(h.connectionState, CONNECTED_STATE); + yield* SubscriptionRef.set(h.sessionRef, Option.some(h.session)); + const next = yield* Queue.take(h.subscriptions); + expect(next.afterSequence).toBe(7); + expect(h.registry.get(h.stateAtom).status).toBe("synchronizing"); + yield* Queue.offer(next.events, { kind: "synchronized" }); + yield* observeState(h.registry, h.stateAtom, (state) => state.status === "live"); + remount(); + yield* Deferred.await(next.closed); + }), + ); + it.effect("keeps warm data when the raw atom family's weak entry is collected", () => Effect.gen(function* () { const h = yield* makeHarness(); diff --git a/packages/client-runtime/src/state/threads.ts b/packages/client-runtime/src/state/threads.ts index b402c84935b8..b93e40750a03 100644 --- a/packages/client-runtime/src/state/threads.ts +++ b/packages/client-runtime/src/state/threads.ts @@ -186,10 +186,18 @@ function matchesThreadSnapshot( currentPage.hasMore === page.hasMore; } +// A retained "live" state stays live: the cursor resume that follows only +// replays what the thread missed, and on servers that send the completion +// marker the first replayed event moves the status to "synchronizing" on its +// own. Downgrading here would flash a sync label on every return to a +// recently viewed thread. function cachedThreadState(value: EnvironmentThreadState): EnvironmentThreadState { return { ...value, - status: value.status === "deleted" ? "deleted" : statusWithoutLiveData(value.data), + status: + value.status === "deleted" || (value.status === "live" && Option.isSome(value.data)) + ? value.status + : statusWithoutLiveData(value.data), error: Option.none(), page: Option.map(value.page, (page) => ({ ...page, loadingOlder: false })), }; @@ -682,7 +690,16 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make service.changes.pipe(Stream.filter(ConnectionWakeups.shouldResubscribeAfterWakeup)), }); - yield* setSynchronizing; + // Only the first subscription after a warm live resume keeps the retained + // status. A replacement session or foreground resubscribe on the same scope + // may have missed events, so those show sync progress until confirmed. + const resumingLive = yield* Ref.make(initialState.status === "live"); + const markSynchronizing = Effect.gen(function* () { + if (yield* Ref.get(resumingLive)) return; + yield* setSynchronizing; + }); + + yield* markSynchronizing; yield* Effect.forkScoped( subscribeDynamic( ORCHESTRATION_WS_METHODS.subscribeThread, @@ -703,7 +720,8 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make const supportsPagination = config.threadSnapshotPagination === true; yield* Ref.set(paginationSupported, supportsPagination); yield* Ref.set(awaitingCompletion, supportsCompletionMarker); - yield* setSynchronizing; + yield* markSynchronizing; + yield* Ref.set(resumingLive, false); let current = yield* SubscriptionRef.get(state); // A windowed cache resuming against a server without pagination is a diff --git a/packages/contracts/src/agentSessions.ts b/packages/contracts/src/agentSessions.ts new file mode 100644 index 000000000000..ffd90dd79db8 --- /dev/null +++ b/packages/contracts/src/agentSessions.ts @@ -0,0 +1,98 @@ +import * as Schema from "effect/Schema"; +import { IsoDateTime, NonNegativeInt, ProjectId, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { ProviderInstanceId } from "./providerInstance.ts"; + +/** Coding agent home directories the scanner knows how to read. */ +export const AgentSessionSource = Schema.Literals(["claudeAgent", "codex"]); +export type AgentSessionSource = typeof AgentSessionSource.Type; + +/** File identity saved with an imported session so bounded retries can skip unchanged history. */ +export const AgentSessionImportSource = Schema.Struct({ + provider: AgentSessionSource, + providerInstanceId: ProviderInstanceId, + providerSessionId: TrimmedNonEmptyString, + filePath: TrimmedNonEmptyString, + size: NonNegativeInt, + mtimeMs: Schema.NullOr(Schema.Number), + device: Schema.Number, + inode: Schema.NullOr(Schema.Number), + birthtimeMs: Schema.NullOr(Schema.Number), +}); +export type AgentSessionImportSource = typeof AgentSessionImportSource.Type; + +/** Imported message ids retain their origin after event metadata is projected into SQLite. */ +export function isImportedAgentSessionMessageId(messageId: string): boolean { + return messageId.startsWith("import:"); +} + +/** + * Empty for now. Kept as a struct so future scan options (source filters, + * explicit roots) can be added without a new method. + */ +export const AgentSessionScanInput = Schema.Struct({}); +export type AgentSessionScanInput = typeof AgentSessionScanInput.Type; + +/** + * A directory that at least one agent CLI has run in, suitable for import as a + * T3 Code project. `alreadyImported` marks candidates that already have an + * active project rooted at the same path. + */ +export const AgentSessionProjectCandidate = Schema.Struct({ + path: TrimmedNonEmptyString, + title: TrimmedNonEmptyString, + projectId: Schema.optional(ProjectId), + sources: Schema.Array(AgentSessionSource), + threadCount: NonNegativeInt, + lastActiveAt: Schema.NullOr(IsoDateTime), + alreadyImported: Schema.Boolean, +}); +export type AgentSessionProjectCandidate = typeof AgentSessionProjectCandidate.Type; + +export const AgentSessionScanResult = Schema.Struct({ + candidates: Schema.Array(AgentSessionProjectCandidate), + scannedAt: IsoDateTime, + truncated: Schema.optional(Schema.Boolean), +}); +export type AgentSessionScanResult = typeof AgentSessionScanResult.Type; + +export const AgentSessionImportInput = Schema.Struct({ + projectId: ProjectId, + expectedWorkspaceRoot: Schema.optional(TrimmedNonEmptyString), +}); +export type AgentSessionImportInput = typeof AgentSessionImportInput.Type; + +export class AgentSessionImportProjectNotFoundError extends Schema.TaggedErrorClass()( + "AgentSessionImportProjectNotFoundError", + { projectId: ProjectId }, +) { + override get message(): string { + return `Project '${this.projectId}' does not exist.`; + } +} + +export class AgentSessionImportProjectChangedError extends Schema.TaggedErrorClass()( + "AgentSessionImportProjectChangedError", + { projectId: ProjectId }, +) { + override get message(): string { + return `Project '${this.projectId}' changed directories. Scan for projects again before importing history.`; + } +} + +export const AgentSessionImportResult = Schema.Struct({ + importedCount: NonNegativeInt, + skippedCount: NonNegativeInt, +}); +export type AgentSessionImportResult = typeof AgentSessionImportResult.Type; + +export class AgentSessionScanError extends Schema.TaggedErrorClass()( + "AgentSessionScanError", + { + operation: Schema.Literals(["read-settings", "read-projects"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to scan agent sessions during ${this.operation}.`; + } +} diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 671f0205aed1..42b606530b5c 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -31,6 +31,7 @@ export * from "./editor.ts"; export * from "./openWith.ts"; export * from "./project.ts"; export * from "./filesystem.ts"; +export * from "./agentSessions.ts"; export * from "./assets.ts"; export * from "./review.ts"; export * from "./browserImport.ts"; diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index 266c7001cdb3..5f71a5d3e469 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -1137,6 +1137,21 @@ it.effect("project icon overrides accept Lucide icons, colors, and emoji", () => }), ); +it.effect("rejects thread history imports without messages", () => + Effect.gen(function* () { + const result = yield* Effect.exit( + decodeOrchestrationCommand({ + type: "thread.history.import", + commandId: "command-empty-history", + threadId: "thread-1", + messages: [], + }), + ); + + assert.strictEqual(result._tag, "Failure"); + }), +); + it("isProviderSendTurnSupportedImageMimeType accepts raster formats and rejects svg", () => { assert.strictEqual(isProviderSendTurnSupportedImageMimeType("image/png"), true); assert.strictEqual(isProviderSendTurnSupportedImageMimeType("IMAGE/JPEG"), true); diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 92fe88b8f41e..f17d2284da12 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -862,6 +862,7 @@ const ThreadCreateCommand = Schema.Struct({ branch: Schema.NullOr(TrimmedNonEmptyString), worktreePath: Schema.NullOr(TrimmedNonEmptyString), createdAt: IsoDateTime, + historyImport: Schema.optional(Schema.Literal(true)), }); const ThreadDeleteCommand = Schema.Struct({ @@ -1248,6 +1249,20 @@ const ThreadMessageAssistantCompleteCommand = Schema.Struct({ createdAt: IsoDateTime, }); +const ThreadHistoryImportCommand = Schema.Struct({ + type: Schema.Literal("thread.history.import"), + commandId: CommandId, + threadId: ThreadId, + messages: Schema.Array( + Schema.Struct({ + messageId: MessageId, + role: Schema.Literals(["user", "assistant"]), + text: Schema.String, + createdAt: IsoDateTime, + }), + ).check(Schema.isNonEmpty()), +}); + const ThreadProposedPlanUpsertCommand = Schema.Struct({ type: Schema.Literal("thread.proposed-plan.upsert"), commandId: CommandId, @@ -1330,6 +1345,7 @@ const InternalOrchestrationCommand = Schema.Union([ ThreadSessionSetCommand, ThreadMessageAssistantDeltaCommand, ThreadMessageAssistantCompleteCommand, + ThreadHistoryImportCommand, ThreadProposedPlanUpsertCommand, ThreadTurnDiffCompleteCommand, ThreadActivityAppendCommand, @@ -1687,6 +1703,7 @@ export const OrchestrationEventMetadata = Schema.Struct({ adapterKey: Schema.optional(TrimmedNonEmptyString), requestId: Schema.optional(ApprovalRequestId), ingestedAt: Schema.optional(IsoDateTime), + historyImport: Schema.optional(Schema.Boolean), origin: Schema.optional(OrchestrationClientOrigin), }); export type OrchestrationEventMetadata = typeof OrchestrationEventMetadata.Type; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 0770bc088cc0..01e413e83ced 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -35,6 +35,15 @@ import { FilesystemBrowseResult, FilesystemBrowseError, } from "./filesystem.ts"; +import { + AgentSessionImportInput, + AgentSessionImportProjectChangedError, + AgentSessionImportProjectNotFoundError, + AgentSessionImportResult, + AgentSessionScanInput, + AgentSessionScanResult, + AgentSessionScanError, +} from "./agentSessions.ts"; import { AssetAccessError, AssetCreateUrlInput, @@ -263,6 +272,8 @@ export const WS_METHODS = { // Filesystem methods filesystemBrowse: "filesystem.browse", + agentSessionsScan: "agentSessions.scan", + agentSessionsImport: "agentSessions.import", assetsCreateUrl: "assets.createUrl", attachmentsCreateUploadUrl: "attachments.createUploadUrl", attachmentsDelete: "attachments.delete", @@ -898,6 +909,23 @@ export const WsFilesystemBrowseRpc = Rpc.make(WS_METHODS.filesystemBrowse, { error: Schema.Union([FilesystemBrowseError, EnvironmentAuthorizationError]), }); +export const WsAgentSessionsScanRpc = Rpc.make(WS_METHODS.agentSessionsScan, { + payload: AgentSessionScanInput, + success: AgentSessionScanResult, + error: Schema.Union([AgentSessionScanError, EnvironmentAuthorizationError]), +}); + +export const WsAgentSessionsImportRpc = Rpc.make(WS_METHODS.agentSessionsImport, { + payload: AgentSessionImportInput, + success: AgentSessionImportResult, + error: Schema.Union([ + AgentSessionImportProjectChangedError, + AgentSessionImportProjectNotFoundError, + AgentSessionScanError, + EnvironmentAuthorizationError, + ]), +}); + export const WsAssetsCreateUrlRpc = Rpc.make(WS_METHODS.assetsCreateUrl, { payload: AssetCreateUrlInput, success: AssetCreateUrlResult, @@ -1365,6 +1393,8 @@ export const WsRpcGroup = RpcGroup.make( WsProjectsWriteFileRpc, WsShellOpenInEditorRpc, WsFilesystemBrowseRpc, + WsAgentSessionsScanRpc, + WsAgentSessionsImportRpc, WsAssetsCreateUrlRpc, WsAttachmentsCreateUploadUrlRpc, WsAttachmentsDeleteRpc, diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index a63f69296ee6..89db8236c2fe 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -748,8 +748,11 @@ export const ServerLifecycleWelcomePayload = Schema.Struct({ environment: ExecutionEnvironmentDescriptor, cwd: TrimmedNonEmptyString, projectName: TrimmedNonEmptyString, + bootstrapStatus: Schema.optional(Schema.Literals(["pending", "complete"])), bootstrapProjectId: Schema.optional(ProjectId), bootstrapThreadId: Schema.optional(ThreadId), + bootstrapProjectCreated: Schema.optional(Schema.Boolean), + bootstrapThreadCreated: Schema.optional(Schema.Boolean), }); export type ServerLifecycleWelcomePayload = typeof ServerLifecycleWelcomePayload.Type; diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 770965bfa670..52777f20fd3f 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -7,7 +7,6 @@ import { ClientSettingsPatch, ClaudeSettings, DEFAULT_SERVER_SETTINGS, - defaultEnabledForDriver, resolveProviderInstanceEnabled, ServerSettings, ServerSettingsPatch, @@ -470,14 +469,6 @@ describe("provider enabled defaults", () => { expect(decoded.providers.opencode.enabled).toBe(false); }); - it("derives per-driver defaults from the settings schemas", () => { - expect(defaultEnabledForDriver(ProviderDriverKind.make("codex"))).toBe(true); - expect(defaultEnabledForDriver(ProviderDriverKind.make("cursor"))).toBe(false); - expect(defaultEnabledForDriver(ProviderDriverKind.make("grok"))).toBe(false); - // Unknown fork drivers stay enabled; their own build decides otherwise. - expect(defaultEnabledForDriver(ProviderDriverKind.make("ollama"))).toBe(true); - }); - it("keeps Cursor enabled when an existing user explicitly opted in", () => { const cursor = ProviderDriverKind.make("cursor"); const cursorId = ProviderInstanceId.make("cursor"); @@ -498,6 +489,10 @@ describe("provider enabled defaults", () => { // No flags anywhere: driver default applies. expect(resolveProviderInstanceEnabled({ driver: grok, config: {} })).toBe(false); expect(resolveProviderInstanceEnabled({ driver: codex, config: {} })).toBe(true); + // Unknown fork drivers stay enabled. + expect( + resolveProviderInstanceEnabled({ driver: ProviderDriverKind.make("ollama"), config: {} }), + ).toBe(true); // Envelope flag wins over the driver default. expect(resolveProviderInstanceEnabled({ driver: grok, enabled: true, config: {} })).toBe(true); expect(resolveProviderInstanceEnabled({ driver: codex, enabled: false, config: {} })).toBe( diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 762a50f4c6b7..23612c44ddb4 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -289,6 +289,13 @@ export const ClientSettingsSchema = Schema.Struct({ // Grayscale `-webkit-font-smoothing: antialiased` (thinner strokes); // disabling restores the platform's heavier default. No effect off macOS. fontSmoothing: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + // When the first-run welcome wizard finished (or was skipped), as an ISO + // timestamp. `null` alone does not mean "show the wizard" — every install + // that predates this field decodes to `null` — so the gate also requires an + // empty workspace before it treats the client as a fresh install. + onboardingCompletedAt: Schema.NullOr(Schema.String).pipe( + Schema.withDecodingDefault(Effect.succeed(null)), + ), // Model favorites. Historically keyed by provider kind, now // widened to `ProviderInstanceId` so users can favorite a specific model // on a custom provider instance (e.g. "Codex Personal · gpt-5") without @@ -1023,7 +1030,7 @@ export const providerInstanceConfigEnabledFlag = (config: unknown): boolean | un * through `DEFAULT_SERVER_SETTINGS`, so the schema's decoding default stays * the single source of truth. Unknown (fork) drivers default to enabled. */ -export const defaultEnabledForDriver = (driver: ProviderDriverKind): boolean => { +const defaultEnabledForDriver = (driver: ProviderDriverKind): boolean => { const legacyDefaults = DEFAULT_SERVER_SETTINGS.providers as Record< string, { readonly enabled?: boolean } | undefined @@ -1245,6 +1252,7 @@ export const ClientSettingsPatch = Schema.Struct({ diffLayout: Schema.optionalKey(DiffLayout), environmentIdentificationMode: Schema.optionalKey(EnvironmentIdentificationMode), glassOpacity: Schema.optionalKey(GlassOpacity), + onboardingCompletedAt: Schema.optionalKey(Schema.NullOr(Schema.String)), fontSizeInterface: Schema.optionalKey(InterfaceFontSize), fontSizePrompt: Schema.optionalKey(PromptFontSize), fontSizeCode: Schema.optionalKey(CodeFontSize), diff --git a/packages/contracts/src/terminal.test.ts b/packages/contracts/src/terminal.test.ts index a08ed4923888..066253602a49 100644 --- a/packages/contracts/src/terminal.test.ts +++ b/packages/contracts/src/terminal.test.ts @@ -7,12 +7,18 @@ import { TerminalClearInput, TerminalCloseInput, TerminalEvent, + TerminalError, TerminalOpenInput, + TerminalProviderEnvironmentError, TerminalResizeInput, TerminalSessionSnapshot, TerminalThreadInput, TerminalWriteInput, } from "./terminal.ts"; +import { ProviderInstanceId } from "./providerInstance.ts"; + +const encodeTerminalError = Schema.encodeUnknownSync(TerminalError); +const decodeTerminalError = Schema.decodeUnknownSync(TerminalError); function decodeSync(schema: S, input: unknown): Schema.Schema.Type { return Schema.decodeUnknownSync(schema as never)(input) as Schema.Schema.Type; @@ -27,6 +33,28 @@ function decodes(schema: S, input: unknown): boolean { } } +describe("TerminalProviderEnvironmentError", () => { + it("round-trips its required cause without exposing it in the message", () => { + const cause = { operation: "read-secret", detail: "secret backend unavailable" }; + const error = new TerminalProviderEnvironmentError({ + providerInstanceId: ProviderInstanceId.make("codex_work"), + cause, + }); + const encoded = encodeTerminalError(error); + const decoded = decodeTerminalError(encoded); + + expect(decoded).toMatchObject({ + _tag: "TerminalProviderEnvironmentError", + providerInstanceId: "codex_work", + cause, + }); + expect(decoded.message).toBe( + "Could not prepare the terminal environment for provider instance: codex_work", + ); + expect(decoded.message).not.toContain("secret backend unavailable"); + }); +}); + describe("TerminalOpenInput", () => { it("accepts valid open input", () => { expect( @@ -87,12 +115,14 @@ describe("TerminalOpenInput", () => { T3CODE_PROJECT_ROOT: "/tmp/project", CUSTOM_FLAG: "1", }, + providerInstanceId: "codex_work", }); expect(parsed.env).toMatchObject({ T3CODE_PROJECT_ROOT: "/tmp/project", CUSTOM_FLAG: "1", }); expect(parsed.worktreePath).toBe("/tmp/project/.t3/worktrees/feature-a"); + expect(parsed.providerInstanceId).toBe("codex_work"); }); it("rejects invalid env keys", () => { @@ -108,6 +138,19 @@ describe("TerminalOpenInput", () => { }), ).toBe(false); }); + + it("rejects invalid provider instance ids", () => { + for (const providerInstanceId of ["", "1invalid", "invalid id"]) { + expect( + decodes(TerminalOpenInput, { + threadId: "thread-1", + terminalId: DEFAULT_TERMINAL_ID, + cwd: "/tmp/project", + providerInstanceId, + }), + ).toBe(false); + } + }); }); describe("TerminalAttachInput", () => { diff --git a/packages/contracts/src/terminal.ts b/packages/contracts/src/terminal.ts index fa5f18211695..36e3d339f521 100644 --- a/packages/contracts/src/terminal.ts +++ b/packages/contracts/src/terminal.ts @@ -1,5 +1,6 @@ import * as Schema from "effect/Schema"; import { TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { ProviderInstanceId } from "./providerInstance.ts"; /** * Client-side id for the first shell opened on a thread. Ids are uniformly @@ -43,8 +44,9 @@ export const TerminalOpenInput = Schema.Struct({ cols: Schema.optional(TerminalColsSchema), rows: Schema.optional(TerminalRowsSchema), env: Schema.optional(TerminalEnvSchema), + providerInstanceId: Schema.optional(ProviderInstanceId), }); -export type TerminalOpenInput = Schema.Codec.Encoded; +export type TerminalOpenInput = typeof TerminalOpenInput.Type; export const TerminalAttachInput = Schema.Struct({ ...TerminalSessionInput.fields, @@ -53,9 +55,10 @@ export const TerminalAttachInput = Schema.Struct({ cols: Schema.optional(TerminalColsSchema), rows: Schema.optional(TerminalRowsSchema), env: Schema.optional(TerminalEnvSchema), + providerInstanceId: Schema.optional(ProviderInstanceId), restartIfNotRunning: Schema.optional(Schema.Boolean), }); -export type TerminalAttachInput = Schema.Codec.Encoded; +export type TerminalAttachInput = typeof TerminalAttachInput.Type; export const TerminalWriteInput = Schema.Struct({ ...TerminalSessionInput.fields, @@ -80,8 +83,9 @@ export const TerminalRestartInput = Schema.Struct({ cols: TerminalColsSchema, rows: TerminalRowsSchema, env: Schema.optional(TerminalEnvSchema), + providerInstanceId: Schema.optional(ProviderInstanceId), }); -export type TerminalRestartInput = Schema.Codec.Encoded; +export type TerminalRestartInput = typeof TerminalRestartInput.Type; export const TerminalCloseInput = Schema.Struct({ ...TerminalThreadInput.fields, @@ -299,6 +303,29 @@ export class TerminalSessionLookupError extends Schema.TaggedErrorClass()( + "TerminalProviderInstanceNotFoundError", + { + providerInstanceId: ProviderInstanceId, + }, +) { + override get message() { + return `Provider instance is not available: ${this.providerInstanceId}`; + } +} + +export class TerminalProviderEnvironmentError extends Schema.TaggedErrorClass()( + "TerminalProviderEnvironmentError", + { + providerInstanceId: ProviderInstanceId, + cause: Schema.Defect(), + }, +) { + override get message() { + return `Could not prepare the terminal environment for provider instance: ${this.providerInstanceId}`; + } +} + export class TerminalNotRunningError extends Schema.TaggedErrorClass()( "TerminalNotRunningError", { @@ -345,6 +372,8 @@ export const TerminalError = Schema.Union([ TerminalCwdError, TerminalHistoryError, TerminalSessionLookupError, + TerminalProviderInstanceNotFoundError, + TerminalProviderEnvironmentError, TerminalNotRunningError, TerminalWriteError, TerminalResizeError, diff --git a/packages/effect-codex-app-server/src/_internal/shared.ts b/packages/effect-codex-app-server/src/_internal/shared.ts index 34155348abfa..8bcb59467d3d 100644 --- a/packages/effect-codex-app-server/src/_internal/shared.ts +++ b/packages/effect-codex-app-server/src/_internal/shared.ts @@ -5,7 +5,7 @@ import * as CodexError from "../errors.ts"; export const JsonRpcId = Schema.Union([Schema.Number, Schema.String]); -export const JsonRpcError = Schema.Struct({ +const JsonRpcError = Schema.Struct({ code: Schema.Number, message: Schema.String, data: Schema.optional(Schema.Unknown), diff --git a/packages/effect-codex-app-server/src/client.ts b/packages/effect-codex-app-server/src/client.ts index c0cb5b1dc23a..78d719626139 100644 --- a/packages/effect-codex-app-server/src/client.ts +++ b/packages/effect-codex-app-server/src/client.ts @@ -84,7 +84,7 @@ type ServerNotificationHandler = ( payload: unknown, ) => Effect.Effect; -export const make = Effect.fn("effect-codex-app-server/CodexAppServerClient.make")(function* ( +const make = Effect.fn("effect-codex-app-server/CodexAppServerClient.make")(function* ( stdio: Stdio.Stdio, options: CodexAppServerClientOptions = {}, terminationError?: Effect.Effect, @@ -250,11 +250,6 @@ export const make = Effect.fn("effect-codex-app-server/CodexAppServerClient.make }); }); -export const layer = ( - stdio: Stdio.Stdio, - options: CodexAppServerClientOptions = {}, -): Layer.Layer => Layer.effect(CodexAppServerClient, make(stdio, options)); - export const layerChildProcess = ( handle: ChildProcessSpawner.ChildProcessHandle, options: CodexAppServerClientOptions = {}, diff --git a/packages/effect-codex-app-server/src/errors.ts b/packages/effect-codex-app-server/src/errors.ts index 3803b4d40659..f0bf470c251c 100644 --- a/packages/effect-codex-app-server/src/errors.ts +++ b/packages/effect-codex-app-server/src/errors.ts @@ -1,15 +1,15 @@ import * as Schema from "effect/Schema"; import type * as SchemaIssue from "effect/SchemaIssue"; -export const CodexAppServerRequestOperation = Schema.Literals([ +const CodexAppServerRequestOperation = Schema.Literals([ "decode-payload", "encode-payload", "handle-request", "receive-response", ]); -export type CodexAppServerRequestOperation = typeof CodexAppServerRequestOperation.Type; +type CodexAppServerRequestOperation = typeof CodexAppServerRequestOperation.Type; -export const CodexAppServerSchemaIssueKind = Schema.Literals([ +const CodexAppServerSchemaIssueKind = Schema.Literals([ "Filter", "Encoding", "Pointer", @@ -22,9 +22,9 @@ export const CodexAppServerSchemaIssueKind = Schema.Literals([ "Forbidden", "OneOf", ]); -export type CodexAppServerSchemaIssueKind = typeof CodexAppServerSchemaIssueKind.Type; +type CodexAppServerSchemaIssueKind = typeof CodexAppServerSchemaIssueKind.Type; -export interface CodexAppServerSchemaIssueDiagnostics { +interface CodexAppServerSchemaIssueDiagnostics { readonly issueCount: number; readonly issueKinds: ReadonlyArray; readonly maximumPathDepth: number; @@ -62,7 +62,7 @@ const schemaIssueDiagnostics = (root: SchemaIssue.Issue): CodexAppServerSchemaIs }; }; -export const CodexAppServerPayloadKind = Schema.Literals([ +const CodexAppServerPayloadKind = Schema.Literals([ "null", "array", "string", @@ -74,7 +74,7 @@ export const CodexAppServerPayloadKind = Schema.Literals([ "function", "undefined", ]); -export type CodexAppServerPayloadKind = typeof CodexAppServerPayloadKind.Type; +type CodexAppServerPayloadKind = typeof CodexAppServerPayloadKind.Type; const payloadKind = (payload: unknown): CodexAppServerPayloadKind => { if (payload === null) return "null"; @@ -84,8 +84,7 @@ const payloadKind = (payload: unknown): CodexAppServerPayloadKind => { const protocolMessageFields = ["id", "method", "params", "result", "error"] as const; -export const CodexAppServerProtocolMessageField = Schema.Literals(protocolMessageFields); -export type CodexAppServerProtocolMessageField = typeof CodexAppServerProtocolMessageField.Type; +const CodexAppServerProtocolMessageField = Schema.Literals(protocolMessageFields); export interface CodexAppServerRequestDiagnostics { readonly method?: string; diff --git a/packages/shared/package.json b/packages/shared/package.json index 0b5a94bf0ff1..9035ff104693 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -334,6 +334,10 @@ "./hostClassification": { "types": "./src/hostClassification.ts", "import": "./src/hostClassification.ts" + }, + "./dateTime": { + "types": "./src/dateTime.ts", + "import": "./src/dateTime.ts" } }, "scripts": { diff --git a/packages/shared/src/composerTrigger.test.ts b/packages/shared/src/composerTrigger.test.ts index c4f3c95f8bab..ac198611c582 100644 --- a/packages/shared/src/composerTrigger.test.ts +++ b/packages/shared/src/composerTrigger.test.ts @@ -3,23 +3,8 @@ import { describe, expect, it } from "vite-plus/test"; import { parseStandaloneComposerSlashCommand, serializeComposerFileLink, - serializeComposerMentionPath, } from "./composerTrigger.ts"; -describe("serializeComposerMentionPath", () => { - it("keeps simple mention paths unquoted", () => { - expect(serializeComposerMentionPath("src/index.ts")).toBe("src/index.ts"); - }); - - it("quotes mention paths containing whitespace", () => { - expect(serializeComposerMentionPath("docs/My File.md")).toBe('"docs/My File.md"'); - }); - - it("escapes quoted mention path content", () => { - expect(serializeComposerMentionPath('docs/My "File".md')).toBe('"docs/My \\"File\\".md"'); - }); -}); - describe("serializeComposerFileLink", () => { it("uses the basename as the markdown label", () => { expect(serializeComposerFileLink("path/to/package.json")).toBe( diff --git a/packages/shared/src/composerTrigger.ts b/packages/shared/src/composerTrigger.ts index d6fdb4b61644..00ea722641a1 100644 --- a/packages/shared/src/composerTrigger.ts +++ b/packages/shared/src/composerTrigger.ts @@ -8,15 +8,6 @@ export interface ComposerTrigger { rangeEnd: number; } -const SIMPLE_MENTION_PATH_REGEX = /^[^\s@"\\]+$/; - -export function serializeComposerMentionPath(path: string): string { - if (SIMPLE_MENTION_PATH_REGEX.test(path)) { - return path; - } - return `"${path.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`; -} - function composerFileLinkBasename(path: string): string { const separatorIndex = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")); return separatorIndex >= 0 ? path.slice(separatorIndex + 1) : path; diff --git a/packages/shared/src/dateTime.test.ts b/packages/shared/src/dateTime.test.ts new file mode 100644 index 000000000000..049ca95d9b2a --- /dev/null +++ b/packages/shared/src/dateTime.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { compareDateTimeStrings } from "./dateTime.ts"; + +describe("compareDateTimeStrings", () => { + it("compares valid date-time strings by absolute time", () => { + expect( + compareDateTimeStrings("2026-09-01T12:00:00.000Z", "2026-09-01T05:00:00.000-07:00"), + ).toBe(0); + expect( + compareDateTimeStrings("2026-09-01T12:00:01.000Z", "2026-09-01T12:00:00.000Z"), + ).toBeGreaterThan(0); + }); + + it.each([ + ["2024-02-29T12:00:00Z", "2024-02-29T17:30:00+05:30"], + ["2000-02-29T00:00:00.100Z", "2000-02-28T20:30:00.1-03:30"], + ["0000-01-01T00:00:00.000Z", "+000000-01-01T00:00:00.000+00:00"], + ["+010000-01-01T00:00:00.000Z", "9999-12-31T23:00:00.000-01:00"], + ["2026-09-01T24:00:00Z", "2026-09-02T00:00:00Z"], + ["2026-09-01T24:00:00.0000Z", "2026-09-02T00:00:00.000Z"], + ["2024-02-29T24:00:00+05:30", "2024-03-01T00:00:00+05:30"], + ["2026-12-31T24:00:00-07:00", "2027-01-01T07:00:00Z"], + ["2026-09-01T12:00Z", "2026-09-01T12:00:00.000Z"], + ["2026-09-01T05:00-07:00", "2026-09-01T12:00:00Z"], + ["2026-09-01T24:00Z", "2026-09-02T00:00:00Z"], + ["2026-09-01T24:00+05:30", "2026-09-02T00:00:00+05:30"], + ])("preserves equal ISO instants %s and %s", (left, right) => { + expect(compareDateTimeStrings(left, right)).toBe(0); + }); + + it("sorts malformed values before valid values", () => { + expect(compareDateTimeStrings("invalid", "2026-09-01T12:00:00.000Z")).toBeLessThan(0); + expect(compareDateTimeStrings("2026-09-01T12:00:00.000Z", "invalid")).toBeGreaterThan(0); + }); + + it.each([ + "2014-02-30", + "2014-03-02", + "2014-03-02T00:00:00", + "2014-03-02T00:00:00.000", + "03/02/2014", + "March 2, 2014", + "Sun, 02 Mar 2014 00:00:00 GMT", + "2014-03-02T00:00:00.000Z\n", + "2014-02-30T00:00:00.000Z", + "1900-02-29T00:00:00.000-07:00", + "2024-04-31T00:00:00.000+05:30", + "2024-03-02T12:00:00.000+24:00", + "2026-09-01T24:01:00Z", + "2026-09-01T24:00:01Z", + "2026-09-01T24:00:00.0001Z", + "2026-09-01T24:01Z", + "2026-09-01T25:00Z", + ])("treats %s as malformed without native date guessing", (malformed) => { + const valid = "1970-01-01T00:00:00.000Z"; + expect(compareDateTimeStrings(malformed, valid)).toBeLessThan(0); + expect(compareDateTimeStrings(valid, malformed)).toBeGreaterThan(0); + expect(compareDateTimeStrings(malformed, "invalid")).toBeLessThan(0); + expect(compareDateTimeStrings("invalid", malformed)).toBeGreaterThan(0); + expect(compareDateTimeStrings(malformed, malformed)).toBe(0); + }); + + it("uses code-unit order for malformed date-time strings", () => { + expect(compareDateTimeStrings("invalid-a", "invalid-B")).toBeGreaterThan(0); + expect(compareDateTimeStrings("invalid-B", "invalid-a")).toBeLessThan(0); + }); + + it("returns zero for equal malformed date-time strings", () => { + expect(compareDateTimeStrings("invalid", "invalid")).toBe(0); + }); + + it("gives every permutation of mixed values the same order", () => { + const early = "2026-09-01T12:00:00.000+14:00"; + const late = "2026-09-01T00:00:00.000-12:00"; + const malformed = "2026-09-01T06:invalid"; + const expected = [malformed, early, late]; + + const permutations = [ + [early, late, malformed], + [early, malformed, late], + [late, early, malformed], + [late, malformed, early], + [malformed, early, late], + [malformed, late, early], + ]; + + for (const values of permutations) { + expect([...values].sort(compareDateTimeStrings)).toEqual(expected); + } + }); +}); diff --git a/packages/shared/src/dateTime.ts b/packages/shared/src/dateTime.ts new file mode 100644 index 000000000000..544dd2d0d73b --- /dev/null +++ b/packages/shared/src/dateTime.ts @@ -0,0 +1,38 @@ +import * as DateTime from "effect/DateTime"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +const isZonedIsoDateTime = Schema.is( + Schema.String.check( + Schema.isPattern( + /^(?:\d{4}|[+-]\d{6})-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?|24:00(?::00(?:\.0+)?)?)(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)$/, + ), + Schema.isTrimmed(), + ), +); + +function parseTimestamp(value: string): number { + if (!isZonedIsoDateTime(value)) return Number.NaN; + + // Engines can normalize invalid calendar dates instead of rejecting them. + const datePart = value.slice(0, value.indexOf("T")); + const date = DateTime.make(`${datePart}T00:00:00.000Z`); + if (Option.isNone(date)) return Number.NaN; + const parts = DateTime.toPartsUtc(date.value); + if (parts.month !== Number(datePart.slice(-5, -3)) || parts.day !== Number(datePart.slice(-2))) { + return Number.NaN; + } + return Date.parse(value); +} + +/** Compare date-time strings by absolute time, with stable handling for malformed stored values. */ +export function compareDateTimeStrings(left: string, right: string): number { + const leftTimestamp = parseTimestamp(left); + const rightTimestamp = parseTimestamp(right); + const leftIsValid = !Number.isNaN(leftTimestamp); + const rightIsValid = !Number.isNaN(rightTimestamp); + + if (leftIsValid !== rightIsValid) return leftIsValid ? 1 : -1; + if (leftIsValid) return leftTimestamp - rightTimestamp; + return left < right ? -1 : left > right ? 1 : 0; +} diff --git a/packages/shared/src/favicon.test.ts b/packages/shared/src/favicon.test.ts index ce80a079b3fd..676f7811011e 100644 --- a/packages/shared/src/favicon.test.ts +++ b/packages/shared/src/favicon.test.ts @@ -1,11 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; -import { - explicitFaviconUrl, - faviconUrlForOrigin, - faviconUrlForPage, - toolActivityFaviconUrl, -} from "./favicon.ts"; +import { faviconUrlForOrigin, toolActivityFaviconUrl } from "./favicon.ts"; describe("faviconUrlForOrigin", () => { it.each([ @@ -46,12 +41,12 @@ describe("faviconUrlForOrigin", () => { ); }); -describe("faviconUrlForPage", () => { +describe("toolActivityFaviconUrl", () => { it("uses the page origin instead of a third-party favicon service", () => { - expect(faviconUrlForPage("https://example.com/docs/page?q=1")).toBe( + expect(toolActivityFaviconUrl({ pageUrl: "https://example.com/docs/page?q=1" }, "light")).toBe( "https://example.com/favicon.ico", ); - expect(faviconUrlForPage("http://localhost:5173/app")).toBe( + expect(toolActivityFaviconUrl({ pageUrl: "http://localhost:5173/app" }, "light")).toBe( "http://localhost:5173/favicon.ico", ); }); @@ -86,7 +81,17 @@ describe("faviconUrlForPage", () => { }); it("accepts provider-supplied image URLs but rejects extension URLs", () => { - expect(explicitFaviconUrl("https://example.com/icon.png")).toBe("https://example.com/icon.png"); - expect(explicitFaviconUrl("chrome-extension://example/_favicon/")).toBeNull(); + expect( + toolActivityFaviconUrl( + { pageUrl: "https://example.com/docs", faviconUrl: "https://example.com/icon.png" }, + "light", + ), + ).toBe("https://example.com/icon.png"); + expect( + toolActivityFaviconUrl( + { pageUrl: "https://example.com/docs", faviconUrl: "chrome-extension://example/_favicon/" }, + "light", + ), + ).toBe("https://example.com/favicon.ico"); }); }); diff --git a/packages/shared/src/favicon.ts b/packages/shared/src/favicon.ts index a3286b28e99a..2c4847115b90 100644 --- a/packages/shared/src/favicon.ts +++ b/packages/shared/src/favicon.ts @@ -5,7 +5,7 @@ import { isPublicFaviconHost } from "./hostClassification.ts"; * conventional favicon and let the image element fall back to a browser glyph. * Chrome-backed tools can pass their tab's explicit favicon URL separately. */ -export function faviconUrlForPage(rawUrl: string | null | undefined, _size = 32): string | null { +function faviconUrlForPage(rawUrl: string | null | undefined, _size = 32): string | null { if (!rawUrl || rawUrl.length > 4096) return null; try { const pageUrl = new URL(rawUrl); @@ -40,7 +40,7 @@ function themedFaviconUrlForPage( } /** Accepts image URLs supplied by a trusted provider event. */ -export function explicitFaviconUrl(rawUrl: string | null | undefined): string | null { +function explicitFaviconUrl(rawUrl: string | null | undefined): string | null { if (!rawUrl || rawUrl.length > 4096) return null; try { const url = new URL(rawUrl); diff --git a/packages/shared/src/preview.test.ts b/packages/shared/src/preview.test.ts index fec4203c5334..14139216194e 100644 --- a/packages/shared/src/preview.test.ts +++ b/packages/shared/src/preview.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it } from "vite-plus/test"; import { isLoopbackHost, - isPreviewableUrl, newPreviewTabId, normalizePreviewUrl, PreviewUrlNormalizationError, @@ -27,24 +26,6 @@ describe("isLoopbackHost", () => { }); }); -describe("isPreviewableUrl", () => { - it.each([ - "http://localhost:5173", - "http://127.0.0.1:3000/path", - "http://0.0.0.0:8080", - "http://[::1]:5173", - ])("%s is previewable", (url) => { - expect(isPreviewableUrl(url)).toBe(true); - }); - - it.each(["https://example.com", "ws://localhost:5173", "file:///etc/passwd", "not-a-url", ""])( - "%s is not previewable", - (url) => { - expect(isPreviewableUrl(url)).toBe(false); - }, - ); -}); - describe("normalizePreviewUrl", () => { it("treats bare loopback hosts as http", () => { expect(normalizePreviewUrl("localhost:5173")).toBe("http://localhost:5173/"); diff --git a/packages/shared/src/preview.ts b/packages/shared/src/preview.ts index 926b30966e52..f0a781290b1c 100644 --- a/packages/shared/src/preview.ts +++ b/packages/shared/src/preview.ts @@ -36,17 +36,6 @@ export function isLoopbackHost(host: string): boolean { return false; } -/** True when a raw URL string looks like a loopback dev URL we can preview. */ -export function isPreviewableUrl(rawUrl: string): boolean { - try { - const parsed = new URL(rawUrl); - if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false; - return isLoopbackHost(parsed.hostname); - } catch { - return false; - } -} - export class PreviewUrlNormalizationError extends Schema.TaggedErrorClass()( "PreviewUrlNormalizationError", { diff --git a/packages/shared/src/previewViewport.test.ts b/packages/shared/src/previewViewport.test.ts index 3222e90d7be5..7a049376c50e 100644 --- a/packages/shared/src/previewViewport.test.ts +++ b/packages/shared/src/previewViewport.test.ts @@ -1,11 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { - PREVIEW_VIEWPORT_PRESETS, - previewViewportLabel, - previewViewportPresetOrientation, - resolvePreviewViewport, -} from "./previewViewport.ts"; +import { PREVIEW_VIEWPORT_PRESETS, resolvePreviewViewport } from "./previewViewport.ts"; describe("previewViewport", () => { it("resolves fill and exact freeform viewports", () => { @@ -59,12 +54,4 @@ describe("previewViewport", () => { "Nest Hub Max", ]); }); - - it("formats settings for compact UI", () => { - expect(previewViewportLabel({ _tag: "fill" })).toBe("Fill panel"); - expect(previewViewportLabel({ _tag: "freeform", width: 393, height: 852 })).toBe("393 × 852"); - expect(previewViewportPresetOrientation({ _tag: "freeform", width: 852, height: 393 })).toBe( - "landscape", - ); - }); }); diff --git a/packages/shared/src/previewViewport.ts b/packages/shared/src/previewViewport.ts index 1d70bca5dfbd..d1e066bee16d 100644 --- a/packages/shared/src/previewViewport.ts +++ b/packages/shared/src/previewViewport.ts @@ -173,14 +173,3 @@ export function resolvePreviewViewport( height: input.height, }; } - -export function previewViewportLabel(viewport: PreviewViewportSetting): string { - return viewport._tag === "fill" ? "Fill panel" : `${viewport.width} × ${viewport.height}`; -} - -export function previewViewportPresetOrientation( - viewport: PreviewViewportSetting, -): "portrait" | "landscape" | null { - if (viewport._tag === "fill" || viewport.width === viewport.height) return null; - return viewport.width > viewport.height ? "landscape" : "portrait"; -} diff --git a/packages/shared/src/relayAuth.test.ts b/packages/shared/src/relayAuth.test.ts index 3abff9b52109..4e1f28eefdaa 100644 --- a/packages/shared/src/relayAuth.test.ts +++ b/packages/shared/src/relayAuth.test.ts @@ -5,7 +5,6 @@ import { ClerkPublishableKeyFrontendApiError, clerkFrontendApiHostnameFromPublishableKey, clerkFrontendApiUrlFromPublishableKey, - isAllowedClerkFrontendApiHostname, } from "./relayAuth.ts"; const clerkPublishableKey = (hostname: string): string => `pk_test_${btoa(`${hostname}$`)}`; @@ -75,14 +74,4 @@ describe("Clerk relay auth", () => { }); expect((error as ClerkPublishableKeyFrontendApiError).cause).toBeInstanceOf(Error); }); - - it("allows standard Clerk hosts and an exact configured custom hostname", () => { - expect(isAllowedClerkFrontendApiHostname("example.clerk.accounts.dev", null)).toBe(true); - expect(isAllowedClerkFrontendApiHostname("example.clerk.accounts.com", null)).toBe(true); - expect(isAllowedClerkFrontendApiHostname("clerk.t3.codes", "clerk.t3.codes")).toBe(true); - expect(isAllowedClerkFrontendApiHostname("attacker.example", "clerk.t3.codes")).toBe(false); - expect(isAllowedClerkFrontendApiHostname("nested.clerk.t3.codes", "clerk.t3.codes")).toBe( - false, - ); - }); }); diff --git a/packages/shared/src/relayAuth.ts b/packages/shared/src/relayAuth.ts index a384db77d8ac..4c5d766c1480 100644 --- a/packages/shared/src/relayAuth.ts +++ b/packages/shared/src/relayAuth.ts @@ -81,17 +81,6 @@ export function clerkFrontendApiHostnameFromPublishableKey(publishableKey: strin return parseClerkFrontendApi(publishableKey).hostname; } -export function isAllowedClerkFrontendApiHostname( - hostname: string, - configuredHostname: string | null, -): boolean { - return ( - hostname.endsWith(".clerk.accounts.dev") || - hostname.endsWith(".clerk.accounts.com") || - hostname === configuredHostname - ); -} - export function relayClerkTokenOptions(template: string) { return { template, diff --git a/packages/shared/src/searchRanking.test.ts b/packages/shared/src/searchRanking.test.ts index 7e2ccce6e063..8ddf02ec8498 100644 --- a/packages/shared/src/searchRanking.test.ts +++ b/packages/shared/src/searchRanking.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; import { - compareRankedSearchResults, insertRankedSearchResult, normalizeSearchQuery, scoreQueryMatch, @@ -90,6 +89,5 @@ describe("insertRankedSearchResult", () => { insertRankedSearchResult(ranked, { item: "c", score: 30, tieBreaker: "c" }, 2); expect(ranked.map((entry) => entry.item)).toEqual(["a", "b"]); - expect(compareRankedSearchResults(ranked[0]!, ranked[1]!)).toBeLessThan(0); }); }); diff --git a/packages/shared/src/searchRanking.ts b/packages/shared/src/searchRanking.ts index b2fb2e223d3b..c8ec69e39703 100644 --- a/packages/shared/src/searchRanking.ts +++ b/packages/shared/src/searchRanking.ts @@ -135,7 +135,7 @@ export function scoreQueryMatch(input: { return null; } -export function compareRankedSearchResults( +function compareRankedSearchResults( left: RankedSearchResult, right: RankedSearchResult, ): number { diff --git a/packages/shared/src/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts index 1f847412b6c7..31f056c211e9 100644 --- a/packages/shared/src/serverSettings.test.ts +++ b/packages/shared/src/serverSettings.test.ts @@ -11,43 +11,34 @@ import { resolveServerBackgroundActivitySettings } from "./backgroundActivitySet import { createModelSelection } from "./model.ts"; import { applyServerSettingsPatch, - extractPersistedServerObservabilitySettings, isModelSelectionProviderEnabled, - normalizePersistedServerSettingString, parsePersistedServerObservabilitySettings, resolveSourceControlWriterModelSelection, } from "./serverSettings.ts"; describe("serverSettings helpers", () => { - it("normalizes optional persisted strings", () => { - expect(normalizePersistedServerSettingString(undefined)).toBeUndefined(); - expect(normalizePersistedServerSettingString(" ")).toBeUndefined(); - expect(normalizePersistedServerSettingString(" http://localhost:4318/v1/traces ")).toBe( - "http://localhost:4318/v1/traces", - ); - }); - - it("extracts persisted observability settings", () => { + it("ignores missing and blank persisted observability URLs", () => { + expect(parsePersistedServerObservabilitySettings("{}")).toEqual({ + otlpTracesUrl: undefined, + otlpMetricsUrl: undefined, + }); expect( - extractPersistedServerObservabilitySettings({ - observability: { - otlpTracesUrl: " http://localhost:4318/v1/traces ", - otlpMetricsUrl: " http://localhost:4318/v1/metrics ", - }, - }), + parsePersistedServerObservabilitySettings( + JSON.stringify({ observability: { otlpTracesUrl: " ", otlpMetricsUrl: "" } }), + ), ).toEqual({ - otlpTracesUrl: "http://localhost:4318/v1/traces", - otlpMetricsUrl: "http://localhost:4318/v1/metrics", + otlpTracesUrl: undefined, + otlpMetricsUrl: undefined, }); }); - it("parses lenient persisted settings JSON", () => { + it("parses lenient persisted settings JSON and trims observability URLs", () => { expect( parsePersistedServerObservabilitySettings( JSON.stringify({ observability: { - otlpTracesUrl: "http://localhost:4318/v1/traces", - otlpMetricsUrl: "http://localhost:4318/v1/metrics", + otlpTracesUrl: " http://localhost:4318/v1/traces ", + otlpMetricsUrl: " http://localhost:4318/v1/metrics ", }, }), ), diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index dfd5b742e4e4..dc50da2d7627 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -69,14 +69,14 @@ export interface PersistedServerObservabilitySettings { readonly otlpMetricsUrl: string | undefined; } -export function normalizePersistedServerSettingString( +function normalizePersistedServerSettingString( value: string | null | undefined, ): string | undefined { const trimmed = value?.trim(); return trimmed && trimmed.length > 0 ? trimmed : undefined; } -export function extractPersistedServerObservabilitySettings(input: { +function extractPersistedServerObservabilitySettings(input: { readonly observability?: { readonly otlpTracesUrl?: string; readonly otlpMetricsUrl?: string; diff --git a/packages/shared/src/shell.test.ts b/packages/shared/src/shell.test.ts index c98c1c452d4b..621fe49b3087 100644 --- a/packages/shared/src/shell.test.ts +++ b/packages/shared/src/shell.test.ts @@ -8,7 +8,6 @@ import * as TestClock from "effect/testing/TestClock"; import { describe, expect, it, vi } from "vite-plus/test"; import { - extractPathFromShellOutput, CommandAvailability, CommandResolutionCache, type CommandAvailabilityChecker, @@ -39,28 +38,6 @@ const withWindowsEnvironmentMocks = ( Effect.provideService(CommandAvailability, commandAvailable), ); -describe("extractPathFromShellOutput", () => { - it("extracts the path between capture markers", () => { - expect( - extractPathFromShellOutput( - "__T3CODE_PATH_START__\n/opt/homebrew/bin:/usr/bin\n__T3CODE_PATH_END__\n", - ), - ).toBe("/opt/homebrew/bin:/usr/bin"); - }); - - it("ignores shell startup noise around the capture markers", () => { - expect( - extractPathFromShellOutput( - "Welcome to fish\n__T3CODE_PATH_START__\n/opt/homebrew/bin:/usr/bin\n__T3CODE_PATH_END__\nBye\n", - ), - ).toBe("/opt/homebrew/bin:/usr/bin"); - }); - - it("returns null when the markers are missing", () => { - expect(extractPathFromShellOutput("/opt/homebrew/bin /usr/bin")).toBeNull(); - }); -}); - describe("readPathFromLoginShell", () => { it("uses a shell-agnostic printenv PATH probe", () => { const execFile = vi.fn< diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index 7d7a7d7b4f41..07ac73f8c6a7 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -12,8 +12,6 @@ import * as Path from "effect/Path"; import { HostProcessEnvironment, HostProcessPlatform } from "./hostProcess.ts"; import * as Context from "effect/Context"; -const PATH_CAPTURE_START = "__T3CODE_PATH_START__"; -const PATH_CAPTURE_END = "__T3CODE_PATH_END__"; const SHELL_ENV_NAME_PATTERN = /^[A-Z0-9_]+$/; const WINDOWS_PATH_DELIMITER = ";"; const POSIX_PATH_DELIMITER = ":"; @@ -179,18 +177,6 @@ export function listLoginShellCandidates( return candidates; } -export function extractPathFromShellOutput(output: string): string | null { - const startIndex = output.indexOf(PATH_CAPTURE_START); - if (startIndex === -1) return null; - - const valueStartIndex = startIndex + PATH_CAPTURE_START.length; - const endIndex = output.indexOf(PATH_CAPTURE_END, valueStartIndex); - if (endIndex === -1) return null; - - const pathValue = output.slice(valueStartIndex, endIndex).trim(); - return pathValue.length > 0 ? pathValue : null; -} - export function readPathFromLoginShell( shell: string, execFile: ExecFileSyncLike = NodeChildProcess.execFileSync, diff --git a/packages/ssh/src/runnerProcess.test.ts b/packages/ssh/src/runnerProcess.test.ts index 98b7d28753d9..a696561d6cf2 100644 --- a/packages/ssh/src/runnerProcess.test.ts +++ b/packages/ssh/src/runnerProcess.test.ts @@ -166,3 +166,144 @@ if (args.includes("--package")) { ); }, ); + +describe.skipIf(HostProcessPlatform.defaultValue() === "win32")( + "remote runner install diagnostics", + () => { + const decodeArguments = Schema.decodeUnknownSync( + Schema.fromJsonString(Schema.Array(Schema.String)), + ); + const cases = (["npx", "npm"] as const).flatMap((packageManager) => + ( + [ + "etarget", + "network", + "empty-success", + "success", + "failed-with-path", + "existing-cli", + "node-override", + ] as const + ).map((mode) => ({ packageManager, mode })), + ); + + it.live.each(cases)("handles $packageManager/$mode", ({ packageManager, mode }) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fixture = yield* fs.makeTempDirectoryScoped({ prefix: "t3-runner-install-" }); + const bin = path.join(fixture, "bin"); + const cliPath = path.join(fixture, "installed cli.mjs"); + const callsPath = path.join(fixture, "installer-calls.jsonl"); + const packageSpec = "t3@0.0.39-nightly.20260905.1286"; + const args = ["serve", "a path with spaces"]; + yield* fs.makeDirectory(bin); + yield* fs.symlink(process.execPath, path.join(bin, "node")); + yield* fs.writeFileString( + cliPath, + `#!/usr/bin/env node +process.stdout.write(JSON.stringify(process.argv.slice(2)) + "\\n"); +`, + ); + yield* fs.chmod(cliPath, 0o700); + yield* fs.writeFileString(callsPath, ""); + yield* fs.writeFileString( + path.join(bin, packageManager), + `#!/usr/bin/env node +const fs = require("node:fs"); +fs.appendFileSync(process.env.T3_TEST_CALLS, JSON.stringify(process.argv.slice(2)) + "\\n"); +const mode = process.env.T3_TEST_MODE; +if (mode === "success" || mode === "failed-with-path") { + process.stdout.write(process.env.T3_TEST_CLI + "\\n"); +} +if (mode === "etarget" || mode === "failed-with-path") { + process.stderr.write("npm error code ETARGET\\nnpm error notarget No matching version found.\\n"); + process.exitCode = 42; +} else if (mode === "network") { + process.stderr.write("npm error code ENETUNREACH\\n"); + process.exitCode = 43; +} +`, + ); + yield* fs.chmod(path.join(bin, packageManager), 0o700); + if (mode === "existing-cli") yield* fs.symlink(cliPath, path.join(bin, "t3")); + + const child = yield* spawner.spawn( + ChildProcess.make("/bin/sh", ["-s", "--", ...args], { + cwd: fixture, + extendEnv: false, + env: { + PATH: bin, + HOME: fixture, + T3_TEST_MODE: mode, + T3_TEST_CLI: cliPath, + T3_TEST_CALLS: callsPath, + }, + stdin: Stream.make( + new TextEncoder().encode( + buildRemoteT3RunnerScript({ + packageSpec, + ...(mode === "node-override" ? { nodeScriptPath: cliPath } : {}), + }), + ), + ), + }), + ); + const { stdout, stderr, exitCode } = yield* Effect.all( + { + stdout: child.stdout.pipe(Stream.decodeText(), Stream.mkString), + stderr: child.stderr.pipe(Stream.decodeText(), Stream.mkString), + exitCode: child.exitCode, + }, + { concurrency: "unbounded" }, + ); + const installFailed = + mode === "etarget" || mode === "network" || mode === "failed-with-path"; + const missingExecutable = mode === "empty-success"; + assert.equal(exitCode, installFailed || missingExecutable ? 1 : 0); + if (installFailed || missingExecutable) { + assert.equal(stdout, ""); + } else { + assert.deepEqual(decodeArguments(stdout), args); + } + if (installFailed) { + const npmError = mode === "network" ? "ENETUNREACH" : "ETARGET"; + assert.include(stderr, `npm error code ${npmError}\n`); + assert.include(stderr, `Remote host could not install ${packageSpec}.`); + assert.notInclude(stderr, "Remote host installed"); + assert.notInclude(stderr, "Install a C toolchain"); + } else if (missingExecutable) { + assert.include(stderr, `Remote host installed ${packageSpec}`); + assert.include(stderr, "npm produced no t3 executable"); + assert.include(stderr, "Install a C toolchain"); + } else { + assert.equal(stderr, ""); + } + const expectedCall = [ + ...(packageManager === "npm" ? ["exec"] : []), + "--yes", + "--package", + packageSpec, + "--", + "sh", + "-c", + "command -v t3", + ]; + const usesInstaller = mode !== "existing-cli" && mode !== "node-override"; + const calls = yield* fs.readFileString(callsPath); + if (usesInstaller) { + assert.deepEqual( + calls + .trim() + .split("\n") + .map((line) => decodeArguments(line)), + [expectedCall], + ); + } else { + assert.equal(calls, ""); + } + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); + }, +); diff --git a/packages/ssh/src/tunnel.ts b/packages/ssh/src/tunnel.ts index a991556bdaae..d87c931df173 100644 --- a/packages/ssh/src/tunnel.ts +++ b/packages/ssh/src/tunnel.ts @@ -438,7 +438,10 @@ fi # never becomes ready. Resolve the CLI once up front so that install failure is # reported here, with npm's own output on stderr. require_installed_t3_cli() { - T3_CLI_PATH="$("$@" -- sh -c 'command -v t3' || true)" + if ! T3_CLI_PATH="$("$@" -- sh -c 'command -v t3')"; then + printf 'Remote host could not install %s. See npm output above for the cause.\\n' @@T3_PACKAGE_SPEC@@ >&2 + return 1 + fi if [ -n "$T3_CLI_PATH" ]; then return 0 fi diff --git a/packages/tailscale/src/tailscale.ts b/packages/tailscale/src/tailscale.ts index 81d77e246d14..a1c147fca7d6 100644 --- a/packages/tailscale/src/tailscale.ts +++ b/packages/tailscale/src/tailscale.ts @@ -9,8 +9,8 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; export const DEFAULT_TAILSCALE_SERVE_PORT = 443; export const TAILSCALE_STATUS_TIMEOUT = Duration.millis(1_500); -export const TAILSCALE_SERVE_TIMEOUT = Duration.seconds(10); -export const TAILSCALE_PROBE_TIMEOUT = Duration.millis(2_500); +const TAILSCALE_SERVE_TIMEOUT = Duration.seconds(10); +const TAILSCALE_PROBE_TIMEOUT = Duration.millis(2_500); // tailscale is a real executable everywhere (`tailscale.exe` on Windows), so // it is always spawned directly rather than through cmd.exe shell mode. @@ -47,7 +47,7 @@ const STDERR_DIAGNOSTIC_PATTERNS: ReadonlyArray< ]; /** Classifies stderr into a safe label, dropping the text itself. */ -export const stderrDiagnosticOf = (stderr: string): TailscaleStderrDiagnostic | undefined => { +const stderrDiagnosticOf = (stderr: string): TailscaleStderrDiagnostic | undefined => { if (stderr.trim().length === 0) { return undefined; } @@ -66,7 +66,7 @@ export class TailscaleCommandSpawnError extends Schema.TaggedErrorClass()( +class TailscaleCommandOutputError extends Schema.TaggedErrorClass()( "TailscaleCommandOutputError", { ...TailscaleCommandContext, @@ -146,7 +146,6 @@ const TailscaleStatusJson = Schema.Struct({ Self: Schema.optional(TailscaleStatusSelf), }); -export type TailscaleStatusSelf = typeof TailscaleStatusSelf.Type; export type TailscaleStatusJson = typeof TailscaleStatusJson.Type; export interface TailscaleStatus { diff --git a/patches/@pierre%2Fdiffs@1.3.0-beta.10.patch b/patches/@pierre%2Fdiffs@1.3.0-beta.10.patch index 0c9819145d3b..5cf80feb3356 100644 --- a/patches/@pierre%2Fdiffs@1.3.0-beta.10.patch +++ b/patches/@pierre%2Fdiffs@1.3.0-beta.10.patch @@ -1,3 +1,79 @@ +diff --git a/dist/components/VirtualizedFile.d.ts b/dist/components/VirtualizedFile.d.ts +--- a/dist/components/VirtualizedFile.d.ts ++++ b/dist/components/VirtualizedFile.d.ts +@@ -42,7 +42,7 @@ declare class VirtualizedFile extends File { + private computeApproximateSize; + setVisibility(visible: boolean): void; + rerender(): void; +- applyDocumentChange(textDocument: DiffsTextDocument, newLineAnnotations?: LineAnnotation[], shouldUpdateBuffer?: boolean): void; ++ applyDocumentChange(textDocument: DiffsTextDocument, newLineAnnotations?: LineAnnotation[], shouldUpdateBuffer?: boolean, startLine?: number): void; + protected renderPreparedFile({ + fileContainer, + file, +diff --git a/dist/components/VirtualizedFile.js b/dist/components/VirtualizedFile.js +--- a/dist/components/VirtualizedFile.js ++++ b/dist/components/VirtualizedFile.js +@@ -20,6 +20,7 @@ + cache = { + heights: /* @__PURE__ */ new Map(), + checkpoints: [], ++ codeWidth: void 0, + fileAnnotationHeight: 0 + }; + isVisible = false; +@@ -31,6 +32,8 @@ + super(options, workerManager, isContainerManaged); + this.virtualizer = virtualizer; + this.metrics = metrics; ++ const simpleVirtualizer = this.getSimpleVirtualizer(); ++ if (simpleVirtualizer != null) this.resizeManager.onResize = () => simpleVirtualizer.requestHeightReconcile(this); + } + setMetrics(metrics, force = false) { + if (!force && areObjectsEqual(this.metrics, metrics)) return; +@@ -70,10 +73,12 @@ + if (this.isAdvancedMode()) throw new Error("VirtualizedFile.setThemeType cannot be used inside CodeView. Update CodeView options instead."); + super.setThemeType(themeType); + } +- resetLayoutCache(recompute = false, resetRenderRange = true) { ++ resetLayoutCache(recompute = false, resetRenderRange = true, startLine = 0) { + this.layoutDirty = true; +- this.cache.fileAnnotationHeight = 0; +- if (this.cache.heights.size > 0) this.cache.heights.clear(); ++ if (startLine === 0) this.cache.fileAnnotationHeight = 0; ++ // Dropping unchanged wrapped rows moves the viewport before they can be remeasured. ++ if (startLine === 0) this.cache.heights.clear(); ++ else for (const lineIndex of this.cache.heights.keys()) if (lineIndex >= startLine) this.cache.heights.delete(lineIndex); + if (this.cache.checkpoints.length > 0) this.cache.checkpoints.length = 0; + if (this.renderRange != null && resetRenderRange) this.renderRange = void 0; + if (recompute && this.isSimpleMode()) this.computeApproximateSize(); +@@ -91,6 +96,13 @@ + if (this.code == null) return hasHeightChange; + const content = this.code.children[1]; + if (!(content instanceof HTMLElement)) return hasHeightChange; ++ const codeWidth = this.code.getBoundingClientRect().width; ++ if (!(codeWidth > 0)) return hasHeightChange; ++ if (this.cache.codeWidth != null && this.cache.codeWidth !== codeWidth) { ++ this.resetLayoutCache(false, false); ++ hasHeightChange = true; ++ } ++ this.cache.codeWidth = codeWidth; + const hasFileAnnotations = includesFileAnnotations(this.lineAnnotations); + if (this.renderRange != null && hasFileAnnotations && shouldRenderFileAnnotations(this.renderRange)) { + const nextFileAnnotationHeight = measureFileAnnotationHeight(content) ?? 0; +@@ -287,11 +299,11 @@ + this.forceRenderOverride = true; + this.virtualizer.instanceChanged(this, false); + } +- applyDocumentChange(textDocument, newLineAnnotations, shouldUpdateBuffer = false) { ++ applyDocumentChange(textDocument, newLineAnnotations, shouldUpdateBuffer = false, startLine = 0) { + const previousRenderRange = this.renderRange; + super.applyDocumentChange(textDocument, newLineAnnotations); + this.getSimpleVirtualizer()?.markDOMDirty(); +- this.resetLayoutCache(this.isSimpleMode(), false); ++ this.resetLayoutCache(this.isSimpleMode(), false, startLine); + if (shouldUpdateBuffer && previousRenderRange !== void 0 && this.file !== void 0) { + const windowSpecs = this.virtualizer.getWindowSpecs(); + const renderRange = this.computeRenderRangeFromWindow(this.file, this.top ?? 0, windowSpecs); diff --git a/dist/editor/editor.js b/dist/editor/editor.js index ff78e2a..f9df318 100644 --- a/dist/editor/editor.js @@ -28,12 +104,18 @@ index ff78e2a..f9df318 100644 const gutterRow = resolveGutterTarget(e.composedPath()[0]); if (gutterRow?.dataset.lineType === "change-deletion") { const code = gutterRow.closest("[data-code]"); -@@ -1522,6 +1520,7 @@ var Editor = class { +@@ -1522,6 +1520,12 @@ var Editor = class { if (gutterEl !== void 0) gutterEl.style.gridRow = "span " + gridRow; } fileInstance.updateRenderCache(dirtyLines, tokenizer.themeType, !didLineCountChange, didLineCountChange); + if (fileInstance.file !== void 0) fileInstance.file.contents = textDocument.getText(); - if (didLineCountChange) fileInstance.applyDocumentChange(textDocument, newLineAnnotations, shouldUpdateBuffer); +- if (didLineCountChange) fileInstance.applyDocumentChange(textDocument, newLineAnnotations, shouldUpdateBuffer); ++ if (didLineCountChange) { ++ const previousLineCount = change.lineCount - change.lineDelta; ++ // A wider or narrower line-number gutter can rewrap unchanged rows. ++ const layoutStartLine = String(previousLineCount).length === String(change.lineCount).length ? change.startLine : 0; ++ fileInstance.applyDocumentChange(textDocument, newLineAnnotations, shouldUpdateBuffer, layoutStartLine); ++ } if (this.#isDiff && (this.#diffSyle === "unified" || didLineCountChange)) this.#resetCache(); if (newLineAnnotations !== void 0) { @@ -1788,6 +1787,7 @@ var Editor = class { @@ -44,6 +126,37 @@ index ff78e2a..f9df318 100644 try { this.#fileInstance?.setSelectedLines(range, { notify: false, +diff --git a/dist/managers/ResizeManager.d.ts b/dist/managers/ResizeManager.d.ts +--- a/dist/managers/ResizeManager.d.ts ++++ b/dist/managers/ResizeManager.d.ts +@@ -5,6 +5,8 @@ + columnVariables?: ResizeManagerColumnVariableMode; + } + declare class ResizeManager { ++ /** Schedule owner measurement after an observed code or gutter size change. */ ++ onResize?: () => void; + private static resizeObserver; + private static managersByElement; + private static getResizeObserver; +diff --git a/dist/managers/ResizeManager.js b/dist/managers/ResizeManager.js +--- a/dist/managers/ResizeManager.js ++++ b/dist/managers/ResizeManager.js +@@ -19,6 +19,7 @@ + for (const [manager, managerEntries] of entriesByManager) manager.handleResizeEntries(managerEntries); + } + observedNodes = /* @__PURE__ */ new Map(); ++ onResize; + setup(pre, { disableAnnotations, columnVariables = "apply" }) { + const annotationUpdates = /* @__PURE__ */ new Set(); + const applyColumnVariables = columnVariables === "apply"; +@@ -212,6 +213,7 @@ + this.applyAnnotationUpdates(annotationUpdates); + annotationUpdates.clear(); + this.applyColumnUpdates(codeUpdates); ++ if (codeUpdates.size > 0) this.onResize?.(); + codeUpdates.clear(); + } + applyAnnotationUpdates(annotationUpdates) { diff --git a/dist/react/utils/useFileInstance.js b/dist/react/utils/useFileInstance.js index e9f62f5..af82a46 100644 --- a/dist/react/utils/useFileInstance.js diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f12ef229b167..4fbb19b4782c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -95,7 +95,7 @@ patchedDependencies: '@expo/metro-config@57.0.12': 96f1a75347e6ea02dc4b7034ace815d8ee39e18b8166ebfb573d9e58328f0dc2 '@ff-labs/fff-node@0.9.4': ab9ff544009e1891cfe3930105862d3699007f38922a79f3c98d90018deca368 '@legendapp/list@3.3.5': 03ec41339cd915ecb9a774a6b90cc2197c29038f7db67c4d2e55cd3971e5be43 - '@pierre/diffs@1.3.0-beta.10': c2ea3a9821addf19641e88074a6d8896c62b0f7cfa762899b0e9e80ff5e8add4 + '@pierre/diffs@1.3.0-beta.10': 0ccee155b93b63d810e2c1a40c1fd676fb6fbcfa72cf6430dcedf1a3ae475ab4 '@react-native-ai/apple@0.12.0': 2d09870c2848d185cb05b53ed823a46e12dba519324d8dd8e584e28731990f9d '@react-native-menu/menu@2.0.0': f63d256bf6a97a873b5e628eb595bd6ef0075ddd5bdd890fc920f7a6024290dd '@react-navigation/native-stack@7.17.6': e667c3cef8c78bb9ff4882ee5bd23a432247b843060a9499eb5f07e9e2295552 @@ -291,7 +291,7 @@ importers: version: 1.9.1 '@pierre/diffs': specifier: 'catalog:' - version: 1.3.0-beta.10(patch_hash=c2ea3a9821addf19641e88074a6d8896c62b0f7cfa762899b0e9e80ff5e8add4)(@shikijs/themes@4.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + version: 1.3.0-beta.10(patch_hash=0ccee155b93b63d810e2c1a40c1fd676fb6fbcfa72cf6430dcedf1a3ae475ab4)(@shikijs/themes@4.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@react-native-ai/apple': specifier: 0.12.0 version: 0.12.0(patch_hash=2d09870c2848d185cb05b53ed823a46e12dba519324d8dd8e584e28731990f9d)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) @@ -663,7 +663,7 @@ importers: version: 1.8.0 '@pierre/diffs': specifier: 'catalog:' - version: 1.3.0-beta.10(patch_hash=c2ea3a9821addf19641e88074a6d8896c62b0f7cfa762899b0e9e80ff5e8add4)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + version: 1.3.0-beta.10(patch_hash=0ccee155b93b63d810e2c1a40c1fd676fb6fbcfa72cf6430dcedf1a3ae475ab4)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@pierre/trees': specifier: 1.0.0-beta.4 version: 1.0.0-beta.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -11173,10 +11173,10 @@ snapshots: '@types/hast': 3.0.4 '@types/mdast': 4.0.4 js-yaml: 4.2.0 - picomatch: 4.0.4 + picomatch: 4.0.7 retext-smartypants: 6.2.0 shiki: 4.2.0 - smol-toml: 1.7.0 + smol-toml: 1.8.0 unified: 11.0.5 '@astrojs/language-server@2.16.10(prettier@3.8.3)(typescript@6.0.3)': @@ -13097,7 +13097,7 @@ snapshots: hermes-parser: 0.36.1 jsc-safe-url: 0.2.4 lightningcss: 1.33.0 - picomatch: 4.0.4 + picomatch: 4.0.7 postcss: 8.5.15 resolve-from: 5.0.0 optionalDependencies: @@ -14352,7 +14352,7 @@ snapshots: tslib: 2.8.1 webcrypto-core: 1.9.2 - '@pierre/diffs@1.3.0-beta.10(patch_hash=c2ea3a9821addf19641e88074a6d8896c62b0f7cfa762899b0e9e80ff5e8add4)(@shikijs/themes@4.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@pierre/diffs@1.3.0-beta.10(patch_hash=0ccee155b93b63d810e2c1a40c1fd676fb6fbcfa72cf6430dcedf1a3ae475ab4)(@shikijs/themes@4.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@pierre/theme': 1.1.0 '@pierre/theming': 0.0.2(@pierre/theme@1.1.0)(@shikijs/themes@4.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(shiki@4.2.0) @@ -14366,7 +14366,7 @@ snapshots: transitivePeerDependencies: - '@shikijs/themes' - '@pierre/diffs@1.3.0-beta.10(patch_hash=c2ea3a9821addf19641e88074a6d8896c62b0f7cfa762899b0e9e80ff5e8add4)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@pierre/diffs@1.3.0-beta.10(patch_hash=0ccee155b93b63d810e2c1a40c1fd676fb6fbcfa72cf6430dcedf1a3ae475ab4)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@pierre/theme': 1.1.0 '@pierre/theming': 0.0.2(@pierre/theme@1.1.0)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(shiki@4.2.0)