diff --git a/AGENTS.md b/AGENTS.md index 01666d91ec..9f21df381e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -90,7 +90,7 @@ The most common defect in this repo is a change that works on the path you teste An empty database is a bad test. Seed your worktree's `.t3` with a copy of real data instead of pointing at live state: -- The first `vp run dev` in a worktree does this for you when its database is missing. `--no-seed` or `T3CODE_DEV_SEED=0` skips it. +- The first `vp run dev` in a worktree does this for you when its database is missing and the source is at most 500 MiB. Larger sources warn and dev starts with an empty database. `--no-seed` or `T3CODE_DEV_SEED=0` skips seeding. Manual migration still accepts larger sources and may use substantial disk space before pruning. - Run `vp run migrate-dev-db` with your dev server stopped to refresh. It rebuilds `/.t3/userdata/statev2.sqlite` from a read-only snapshot of `~/.t3/userdata/statev2.sqlite`, the developer's real data. It keeps recent projects and their stopped threads, and drops scheduled tasks, pending work, and auth sessions, so your dev server never runs the developer's agents. Raise `--projects` and `--threads-per-project` for more data. - Refresh `statev2.sqlite`, not `state.sqlite`. The server copies the V1 `state.sqlite` only when `statev2.sqlite` is missing. - Bring `secrets` and `settings.json` only if the flow under test needs them. @@ -139,7 +139,7 @@ For authorized mobile verification, a missing or outdated native client is an in ## Pull requests - Never make a PR unless the developer explicitly asks you to do so. -- If `origin` is git.cafe, follow [GitCafe mirror](docs/operations/development.md#gitcafe-mirror-optional): push to `origin`, open and track PRs with `cafe` and their git.cafe URLs, and keep `gh` for CI logs and merging. +- If `origin` is git.cafe, follow [GitCafe mirror](docs/operations/development.md#gitcafe-mirror-optional): push to `origin`, open and track PRs with `cafe` and their git.cafe URLs, and keep `gh` for merging. - Conventional commit titles, plain language: `fix(web): new threads no longer spike CPU`. - Body: the problem in a sentence or two, then how you fixed it. End with the model and harness that did the work. - UI changes need before/after images. Motion or timing needs a short video. diff --git a/apps/desktop/src/app/DesktopAppActivation.ts b/apps/desktop/src/app/DesktopAppActivation.ts index c31399f90d..ec9d4f7bc6 100644 --- a/apps/desktop/src/app/DesktopAppActivation.ts +++ b/apps/desktop/src/app/DesktopAppActivation.ts @@ -386,7 +386,10 @@ export const make = Effect.gen(function* () { webContents.send(DESKTOP_APP_ACTIVATION_REQUEST_CHANNEL, request); }); }), - complete: (response) => Effect.sync(() => broker.complete(response)), + // The IPC handler owns the activation-completion span. + complete: Effect.fnUntraced(function* (response) { + return yield* Effect.sync(() => broker.complete(response)); + }), }); }); diff --git a/apps/desktop/src/app/DesktopCliCommand.test.ts b/apps/desktop/src/app/DesktopCliCommand.test.ts index db56aa3c83..3cdfdb6cfa 100644 --- a/apps/desktop/src/app/DesktopCliCommand.test.ts +++ b/apps/desktop/src/app/DesktopCliCommand.test.ts @@ -100,9 +100,15 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { const command = yield* commandIn({ home }); const link = path.join(home, ".local", "bin", "t3"); - expect(yield* command.state).toEqual({ supported: true, installedPath: null, onPath: false }); + expect(yield* command.state).toEqual({ + supported: true, + installedPath: null, + onPath: false, + launcherPath: null, + }); const installed = yield* command.install; expect(installed.installedPath).toBe(link); + expect(installed.launcherPath).toBe(path.join(home, ".t3", "bin", "t3")); expect(yield* fs.readLink(link)).toBe(path.join(home, ".t3", "bin", "t3")); expect((yield* command.install).installedPath).toBe(link); diff --git a/apps/desktop/src/app/DesktopCliCommand.ts b/apps/desktop/src/app/DesktopCliCommand.ts index d0b6463b38..56ecf1698d 100644 --- a/apps/desktop/src/app/DesktopCliCommand.ts +++ b/apps/desktop/src/app/DesktopCliCommand.ts @@ -150,18 +150,26 @@ export const make = Effect.gen(function* () { const state: DesktopCliCommand["Service"]["state"] = Effect.gen(function* () { if (!environment.isPackaged) { - return { supported: false, installedPath: null, onPath: false } as const; + return { supported: false, installedPath: null, onPath: false, launcherPath: null } as const; } + const launcherPath = (yield* exists(launcher)) ? launcher : null; const installed = yield* installedAt; const shadowedBy = yield* foreignFirstOnPath; const shadow = Option.isSome(shadowedBy) ? { shadowedBy: shadowedBy.value } : {}; if (Option.isNone(installed)) { - return { supported: true, installedPath: null, onPath: false, ...shadow }; + return { supported: true, installedPath: null, onPath: false, launcherPath, ...shadow }; } const first = yield* firstOnPath; const onPath = windows || (Option.isSome(first) && (yield* isOurLink(first.value))); - return { supported: true, installedPath: installed.value, onPath, ...shadow }; - }).pipe(Effect.orElseSucceed(() => ({ supported: false, installedPath: null, onPath: false }))); + return { supported: true, installedPath: installed.value, onPath, launcherPath, ...shadow }; + }).pipe( + Effect.orElseSucceed(() => ({ + supported: false, + installedPath: null, + onPath: false, + launcherPath: null, + })), + ); const ensureLauncher = DesktopCliShim.install.pipe( Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), diff --git a/apps/desktop/src/app/DesktopObservability.ts b/apps/desktop/src/app/DesktopObservability.ts index f4b03570b5..40d5a6b7c8 100644 --- a/apps/desktop/src/app/DesktopObservability.ts +++ b/apps/desktop/src/app/DesktopObservability.ts @@ -459,90 +459,91 @@ const makeBackendOutputLogShape = ( ): Effect.Effect => Option.match(sink, { onNone: () => Effect.succeed(DesktopBackendOutputLogNoop), - onSome: (logFile) => - Effect.gen(function* () { - const sessionRef = yield* Ref.make(Option.none()); - const writeFailure = Effect.fn("desktop.observability.backendOutput.writeFailure")( - function* (session: BackendOutputSession, details: string) { - yield* writeBackendChildLogRecord(logFile, { - message: "backend child process failure output start", - level: "ERROR", - annotations: { - component: "desktop-backend-child", - runId: session.runId, - instanceId: id, - phase: "START", - details: session.startDetails, - }, - }); - for (const output of session.chunks) { - yield* writeBackendChildLogRecord(logFile, { - message: "backend child process output", - level: output.streamName === "stderr" ? "ERROR" : "INFO", - annotations: { - component: "desktop-backend-child", - runId: session.runId, - instanceId: id, - stream: output.streamName, - text: textDecoder.decode(output.chunk.subarray(output.offset)), - }, - }); - } - yield* writeBackendChildLogRecord(logFile, { - message: "backend child process failure output end", - level: "ERROR", - annotations: { - component: "desktop-backend-child", - runId: session.runId, - instanceId: id, - phase: "END", - details: sanitizeLogValue(details), - }, - }); + // The enclosing operation owns the span for this inline handler. + onSome: Effect.fnUntraced(function* (logFile) { + const sessionRef = yield* Ref.make(Option.none()); + const writeFailure = Effect.fn("desktop.observability.backendOutput.writeFailure")(function* ( + session: BackendOutputSession, + details: string, + ) { + yield* writeBackendChildLogRecord(logFile, { + message: "backend child process failure output start", + level: "ERROR", + annotations: { + component: "desktop-backend-child", + runId: session.runId, + instanceId: id, + phase: "START", + details: session.startDetails, }, - ); - return { - beginSession: Effect.fn("desktop.observability.backendOutput.beginSession")(function* ({ - details, - }) { - const runId = yield* currentDesktopRunId; - yield* Ref.set( - sessionRef, - Option.some({ - runId, - startDetails: sanitizeLogValue(details), - chunks: [], - byteLength: 0, - }), - ); - }), - writeOutputChunk: Effect.fnUntraced(function* (streamName, chunk) { - if (environment.isDevelopment) { - yield* writeDevelopmentConsoleOutput(streamName, chunk); - } - yield* Ref.update( - sessionRef, - Option.map((session) => appendBoundedOutputChunk(session, streamName, chunk)), - ); - }), - persistFailureSnapshot: Effect.fn( - "desktop.observability.backendOutput.persistFailureSnapshot", - )(function* ({ details }) { - const session = yield* Ref.get(sessionRef); - if (Option.isSome(session)) { - yield* writeFailure(session.value, details); - } - }), - persistFailure: Effect.fn("desktop.observability.backendOutput.persistFailure")( - function* ({ details }) { - const session = yield* Ref.modify(sessionRef, (current) => [current, Option.none()]); - if (Option.isNone(session)) return; - yield* writeFailure(session.value, details); + }); + for (const output of session.chunks) { + yield* writeBackendChildLogRecord(logFile, { + message: "backend child process output", + level: output.streamName === "stderr" ? "ERROR" : "INFO", + annotations: { + component: "desktop-backend-child", + runId: session.runId, + instanceId: id, + stream: output.streamName, + text: textDecoder.decode(output.chunk.subarray(output.offset)), }, - ), - discardSession: Ref.set(sessionRef, Option.none()), - } satisfies DesktopBackendOutputLogShape; - }), + }); + } + yield* writeBackendChildLogRecord(logFile, { + message: "backend child process failure output end", + level: "ERROR", + annotations: { + component: "desktop-backend-child", + runId: session.runId, + instanceId: id, + phase: "END", + details: sanitizeLogValue(details), + }, + }); + }); + return { + beginSession: Effect.fn("desktop.observability.backendOutput.beginSession")(function* ({ + details, + }) { + const runId = yield* currentDesktopRunId; + yield* Ref.set( + sessionRef, + Option.some({ + runId, + startDetails: sanitizeLogValue(details), + chunks: [], + byteLength: 0, + }), + ); + }), + writeOutputChunk: Effect.fnUntraced(function* (streamName, chunk) { + if (environment.isDevelopment) { + yield* writeDevelopmentConsoleOutput(streamName, chunk); + } + yield* Ref.update( + sessionRef, + Option.map((session) => appendBoundedOutputChunk(session, streamName, chunk)), + ); + }), + persistFailureSnapshot: Effect.fn( + "desktop.observability.backendOutput.persistFailureSnapshot", + )(function* ({ details }) { + const session = yield* Ref.get(sessionRef); + if (Option.isSome(session)) { + yield* writeFailure(session.value, details); + } + }), + persistFailure: Effect.fn("desktop.observability.backendOutput.persistFailure")(function* ({ + details, + }) { + const session = yield* Ref.modify(sessionRef, (current) => [current, Option.none()]); + if (Option.isNone(session)) return; + yield* writeFailure(session.value, details); + }), + discardSession: Ref.set(sessionRef, Option.none()), + } satisfies DesktopBackendOutputLogShape; + }), }); const layerBackendOutputLogFactory = Layer.effect( @@ -589,7 +590,9 @@ const layerBackendOutputLogFactory = Layer.effect( }); return DesktopBackendOutputLogFactory.of({ - forInstance: (id) => makeForId(id), + forInstance: Effect.fn("DesktopBackendOutputLogFactory.forInstance")(function (id) { + return makeForId(id); + }), }); }), ); diff --git a/apps/desktop/src/app/DesktopWebLinks.ts b/apps/desktop/src/app/DesktopWebLinks.ts index e38eb72f4e..fc46dc6bf1 100644 --- a/apps/desktop/src/app/DesktopWebLinks.ts +++ b/apps/desktop/src/app/DesktopWebLinks.ts @@ -69,16 +69,15 @@ const make = Effect.gen(function* () { }); return DesktopWebLinks.of({ - receive: (url) => - Effect.suspend(() => { + receive: Effect.fn("DesktopWebLinks.receive")(function (url) { + return Effect.suspend(() => { pending.push(url); return flush; - }).pipe(Effect.withSpan("DesktopWebLinks.receive")), - setRendererReady: (ready) => - (ready ? startListening : Effect.sync(stopListening)).pipe( - Effect.andThen(flush), - Effect.withSpan("DesktopWebLinks.setRendererReady"), - ), + }); + }), + setRendererReady: Effect.fn("DesktopWebLinks.setRendererReady")(function (ready) { + return ready ? startListening : Effect.sync(stopListening); + }, Effect.andThen(flush)), }); }); diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts index 79c9c1be32..3b9a6ff466 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.ts @@ -1043,58 +1043,58 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( }); yield* Option.match(active, { onNone: () => Effect.void, - onSome: (run) => - Effect.gen(function* () { - const closed = yield* closeRun(run, parentScope, options); - if (!closed) { - return; - } - const cleanup = yield* mutex.withPermits(1)( - Ref.modify( - state, - ( - latest, - ): readonly [ - { - readonly needsCleanup: boolean; - readonly shouldStart: boolean; - }, - BackendManagerState, - ] => { - const current = Option.getOrUndefined(latest.active); - if (current?.id !== run.id) { - return [ - { - needsCleanup: false, - shouldStart: - latest.desiredRunning && - Option.isNone(latest.active) && - Option.isNone(latest.restartFiber), - }, - latest, - ]; - } + // The enclosing operation owns the span for this inline handler. + onSome: Effect.fnUntraced(function* (run) { + const closed = yield* closeRun(run, parentScope, options); + if (!closed) { + return; + } + const cleanup = yield* mutex.withPermits(1)( + Ref.modify( + state, + ( + latest, + ): readonly [ + { + readonly needsCleanup: boolean; + readonly shouldStart: boolean; + }, + BackendManagerState, + ] => { + const current = Option.getOrUndefined(latest.active); + if (current?.id !== run.id) { return [ { - needsCleanup: true, - shouldStart: latest.desiredRunning, - }, - { - ...latest, - active: Option.none(), + needsCleanup: false, + shouldStart: + latest.desiredRunning && + Option.isNone(latest.active) && + Option.isNone(latest.restartFiber), }, + latest, ]; - }, - ), - ); - if (cleanup.needsCleanup) { - yield* desktopTelemetryPublisher.removeControlSource(spec.id); - yield* backendOutputLog.discardSession; - } - if (cleanup.shouldStart) { - yield* start; - } - }), + } + return [ + { + needsCleanup: true, + shouldStart: latest.desiredRunning, + }, + { + ...latest, + active: Option.none(), + }, + ]; + }, + ), + ); + if (cleanup.needsCleanup) { + yield* desktopTelemetryPublisher.removeControlSource(spec.id); + yield* backendOutputLog.discardSession; + } + if (cleanup.shouldStart) { + yield* start; + } + }), }); }); diff --git a/apps/desktop/src/backend/DesktopBackendPool.ts b/apps/desktop/src/backend/DesktopBackendPool.ts index 811cfebb8b..fa281d6aed 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.ts @@ -292,13 +292,14 @@ export const layer = Layer.effect( }); return DesktopBackendPool.of({ - get: (id) => - SynchronizedRef.get(instancesRef).pipe( + get: Effect.fn("DesktopBackendPool.get")(function (id) { + return SynchronizedRef.get(instancesRef).pipe( Effect.map((instances) => { const entry = instances.get(id); return entry?._tag === "Active" ? Option.some(entry.instance) : Option.none(); }), - ), + ); + }), list: SynchronizedRef.get(instancesRef).pipe( Effect.map((instances) => Array.from(instances.values()).flatMap((entry) => @@ -327,11 +328,17 @@ export const layerTest = ( ); const primary = instances[0]!; return DesktopBackendPool.of({ - get: (id) => Effect.succeed(Option.fromNullishOr(byId.get(id))), + get: Effect.fn("DesktopBackendPool.get")(function (id) { + return Effect.succeed(Option.fromNullishOr(byId.get(id))); + }), list: Effect.succeed(Array.from(byId.values())), primary: Effect.succeed(primary), - register: () => Effect.die("DesktopBackendPool.layerTest does not support register"), - unregister: () => Effect.die("DesktopBackendPool.layerTest does not support unregister"), + register: Effect.fn("DesktopBackendPool.register")(function () { + return Effect.die("DesktopBackendPool.layerTest does not support register"); + }), + unregister: Effect.fn("DesktopBackendPool.unregister")(function () { + return Effect.die("DesktopBackendPool.layerTest does not support unregister"); + }), }); }), ); diff --git a/apps/desktop/src/electron/ElectronApp.ts b/apps/desktop/src/electron/ElectronApp.ts index f76cdc9553..93b26d9851 100644 --- a/apps/desktop/src/electron/ElectronApp.ts +++ b/apps/desktop/src/electron/ElectronApp.ts @@ -135,59 +135,73 @@ export const make = ElectronApp.of({ quit: Effect.sync(() => { Electron.app.quit(); }), - exit: (code) => - Effect.sync(() => { + exit: Effect.fn("ElectronApp.exit")(function (code) { + return Effect.sync(() => { Electron.app.exit(code); - }), - relaunch: (options) => - Effect.sync(() => { + }); + }), + relaunch: Effect.fn("ElectronApp.relaunch")(function (options) { + return Effect.sync(() => { Electron.app.relaunch(options); - }), - setPath: (name, path) => - Effect.sync(() => { + }); + }), + setPath: Effect.fn("ElectronApp.setPath")(function (name, path) { + return Effect.sync(() => { Electron.app.setPath(name, path); - }), - setName: (name) => - Effect.sync(() => { + }); + }), + setName: Effect.fn("ElectronApp.setName")(function (name) { + return Effect.sync(() => { Electron.app.setName(name); - }), - setAboutPanelOptions: (options) => - Effect.sync(() => { + }); + }), + setAboutPanelOptions: Effect.fn("ElectronApp.setAboutPanelOptions")(function (options) { + return Effect.sync(() => { Electron.app.setAboutPanelOptions(options); - }), - setAppUserModelId: (id) => - Effect.sync(() => { + }); + }), + setAppUserModelId: Effect.fn("ElectronApp.setAppUserModelId")(function (id) { + return Effect.sync(() => { Electron.app.setAppUserModelId(id); - }), + }); + }), getAppMetrics: Effect.sync(() => Electron.app.getAppMetrics()), - setAsDefaultProtocolClient: (protocol, path, args) => - Effect.sync(() => { - if (path === undefined) { - return Electron.app.setAsDefaultProtocolClient(protocol); - } - return Electron.app.setAsDefaultProtocolClient(protocol, path, [...(args ?? [])]); - }), - setDesktopName: (desktopName) => - Effect.sync(() => { + setAsDefaultProtocolClient: Effect.fn("ElectronApp.setAsDefaultProtocolClient")( + function (protocol, path, args) { + return Effect.sync(() => { + if (path === undefined) { + return Electron.app.setAsDefaultProtocolClient(protocol); + } + return Electron.app.setAsDefaultProtocolClient(protocol, path, [...(args ?? [])]); + }); + }, + ), + setDesktopName: Effect.fn("ElectronApp.setDesktopName")(function (desktopName) { + return Effect.sync(() => { const linuxApp = Electron.app as Electron.App & { setDesktopName?: (desktopName: string) => void; }; linuxApp.setDesktopName?.(desktopName); - }), - setDockIcon: (iconPath) => - Effect.sync(() => { + }); + }), + setDockIcon: Effect.fn("ElectronApp.setDockIcon")(function (iconPath) { + return Effect.sync(() => { Electron.app.dock?.setIcon(iconPath); - }), - appendCommandLineSwitch: (switchName, value) => - Effect.sync(() => { - if (value === undefined) { - Electron.app.commandLine.appendSwitch(switchName); - return; - } - Electron.app.commandLine.appendSwitch(switchName, value); - }), - onBeforeQuitForUpdate: (listener) => - Effect.acquireRelease( + }); + }), + appendCommandLineSwitch: Effect.fn("ElectronApp.appendCommandLineSwitch")( + function (switchName, value) { + return Effect.sync(() => { + if (value === undefined) { + Electron.app.commandLine.appendSwitch(switchName); + return; + } + Electron.app.commandLine.appendSwitch(switchName, value); + }); + }, + ), + onBeforeQuitForUpdate: Effect.fn("ElectronApp.onBeforeQuitForUpdate")(function (listener) { + return Effect.acquireRelease( Effect.sync(() => { Electron.autoUpdater.on("before-quit-for-update", listener); }), @@ -195,11 +209,13 @@ export const make = ElectronApp.of({ Effect.sync(() => { Electron.autoUpdater.removeListener("before-quit-for-update", listener); }), - ).pipe(Effect.asVoid), - removeCommandLineSwitch: (switchName) => - Effect.sync(() => { + ).pipe(Effect.asVoid); + }), + removeCommandLineSwitch: Effect.fn("ElectronApp.removeCommandLineSwitch")(function (switchName) { + return Effect.sync(() => { Electron.app.commandLine.removeSwitch(switchName); - }), + }); + }), on: addScopedAppListener, }); diff --git a/apps/desktop/src/electron/ElectronDialog.ts b/apps/desktop/src/electron/ElectronDialog.ts index bc8b8eda00..07dec6068a 100644 --- a/apps/desktop/src/electron/ElectronDialog.ts +++ b/apps/desktop/src/electron/ElectronDialog.ts @@ -166,8 +166,8 @@ export const make = ElectronDialog.of({ }); return result.canceled ? [] : result.filePaths; }), - showMessageBox: (options, owner) => - Effect.tryPromise({ + showMessageBox: Effect.fn("ElectronDialog.showMessageBox")(function (options, owner) { + return Effect.tryPromise({ try: () => owner === undefined ? Electron.dialog.showMessageBox(options) @@ -181,9 +181,10 @@ export const make = ElectronDialog.of({ buttonCount: options.buttons?.length ?? 0, cause, }), - }), - showErrorBox: (title, content) => - Effect.try({ + }); + }), + showErrorBox: Effect.fn("ElectronDialog.showErrorBox")(function (title, content) { + return Effect.try({ try: () => Electron.dialog.showErrorBox(title, content), catch: (cause) => new ElectronDialogShowErrorBoxError({ @@ -191,7 +192,8 @@ export const make = ElectronDialog.of({ contentLength: content.length, cause, }), - }).pipe(Effect.orDie), + }).pipe(Effect.orDie); + }), }); export const layer = Layer.succeed(ElectronDialog, make); diff --git a/apps/desktop/src/electron/ElectronMenu.ts b/apps/desktop/src/electron/ElectronMenu.ts index b4610bb099..e85eded811 100644 --- a/apps/desktop/src/electron/ElectronMenu.ts +++ b/apps/desktop/src/electron/ElectronMenu.ts @@ -187,8 +187,8 @@ export const make = Effect.gen(function* () { }; return ElectronMenu.of({ - setApplicationMenu: (template) => - Effect.try({ + setApplicationMenu: Effect.fn("ElectronMenu.setApplicationMenu")(function (template) { + return Effect.try({ try: () => { Electron.Menu.setApplicationMenu(Electron.Menu.buildFromTemplate([...template])); }, @@ -200,9 +200,10 @@ export const make = Effect.gen(function* () { itemCount: template.length, cause, }), - }).pipe(Effect.orDie), - popupTemplate: (input) => - input.template.length === 0 + }).pipe(Effect.orDie); + }), + popupTemplate: Effect.fn("ElectronMenu.popupTemplate")(function (input) { + return input.template.length === 0 ? Effect.void : Effect.try({ try: () => @@ -218,9 +219,10 @@ export const make = Effect.gen(function* () { itemCount: input.template.length, cause, }), - }).pipe(Effect.orDie), - showContextMenu: (input) => - Effect.callback>((resume) => { + }).pipe(Effect.orDie); + }), + showContextMenu: Effect.fn("ElectronMenu.showContextMenu")(function (input) { + return Effect.callback>((resume) => { const normalizedItems = normalizeContextMenuItems(input.items); if (normalizedItems.length === 0) { resume(Effect.succeedNone); @@ -272,7 +274,8 @@ export const make = Effect.gen(function* () { ), ); } - }), + }); + }), }); }); diff --git a/apps/desktop/src/electron/ElectronPowerMonitor.ts b/apps/desktop/src/electron/ElectronPowerMonitor.ts index 47fc920d33..9140f795a5 100644 --- a/apps/desktop/src/electron/ElectronPowerMonitor.ts +++ b/apps/desktop/src/electron/ElectronPowerMonitor.ts @@ -79,8 +79,11 @@ const onSpeedLimitChange: ElectronPowerMonitor["Service"]["onSpeedLimitChange"] export const make = ElectronPowerMonitor.of({ isOnBatteryPower: Effect.sync(() => Electron.powerMonitor.isOnBatteryPower()), getSystemIdleTime: Effect.sync(() => Electron.powerMonitor.getSystemIdleTime()), - getSystemIdleState: (idleThresholdSeconds) => - Effect.sync(() => Electron.powerMonitor.getSystemIdleState(idleThresholdSeconds)), + getSystemIdleState: Effect.fn("ElectronPowerMonitor.getSystemIdleState")( + function (idleThresholdSeconds) { + return Effect.sync(() => Electron.powerMonitor.getSystemIdleState(idleThresholdSeconds)); + }, + ), getCurrentThermalState: Effect.sync(() => Electron.powerMonitor.getCurrentThermalState()), onSimpleEvent, onThermalStateChange, diff --git a/apps/desktop/src/electron/ElectronSafeStorage.ts b/apps/desktop/src/electron/ElectronSafeStorage.ts index a919c5a47b..c643622119 100644 --- a/apps/desktop/src/electron/ElectronSafeStorage.ts +++ b/apps/desktop/src/electron/ElectronSafeStorage.ts @@ -74,16 +74,18 @@ export const make = Effect.gen(function* () { try: () => Electron.safeStorage.isEncryptionAvailable(), catch: (cause) => new ElectronSafeStorageAvailabilityError({ cause }), }), - encryptString: (value) => - Effect.try({ + encryptString: Effect.fn("ElectronSafeStorage.encryptString")(function (value) { + return Effect.try({ try: () => Electron.safeStorage.encryptString(value), catch: (cause) => new ElectronSafeStorageEncryptError({ cause }), - }), - decryptString: (value) => - Effect.try({ + }); + }), + decryptString: Effect.fn("ElectronSafeStorage.decryptString")(function (value) { + return Effect.try({ try: () => Electron.safeStorage.decryptString(Buffer.from(value)), catch: (cause) => new ElectronSafeStorageDecryptError({ cause }), - }), + }); + }), selectedStorageBackend: Effect.sync(() => { if (platform !== "linux") { return Option.none(); diff --git a/apps/desktop/src/electron/ElectronShell.ts b/apps/desktop/src/electron/ElectronShell.ts index 573b54af32..c1d69af3bb 100644 --- a/apps/desktop/src/electron/ElectronShell.ts +++ b/apps/desktop/src/electron/ElectronShell.ts @@ -63,8 +63,8 @@ export class ElectronShell extends Context.Service< /** @public */ export const make = ElectronShell.of({ - openExternal: (rawUrl) => - Option.match(parseSafeExternalUrl(rawUrl), { + openExternal: Effect.fn("ElectronShell.openExternal")(function (rawUrl) { + return Option.match(parseSafeExternalUrl(rawUrl), { onNone: () => Effect.succeed(false), onSome: (externalUrl) => Effect.promise(() => @@ -73,16 +73,19 @@ export const make = ElectronShell.of({ () => false, ), ), - }), - openSystemSettings: (pane) => - Effect.promise(() => + }); + }), + openSystemSettings: Effect.fn("ElectronShell.openSystemSettings")(function (pane) { + return Effect.promise(() => Electron.shell.openExternal(MAC_PERMISSION_SETTINGS_URLS[pane]).then( () => true, () => false, ), - ), - copyText: (text) => - Effect.promise(() => Electron.clipboard.writeText(text).catch(() => undefined)), + ); + }), + copyText: Effect.fn("ElectronShell.copyText")(function (text) { + return Effect.promise(() => Electron.clipboard.writeText(text).catch(() => undefined)); + }), }); export const layer = Layer.succeed(ElectronShell, make); diff --git a/apps/desktop/src/electron/ElectronTheme.ts b/apps/desktop/src/electron/ElectronTheme.ts index cc211f1167..a1785c54d8 100644 --- a/apps/desktop/src/electron/ElectronTheme.ts +++ b/apps/desktop/src/electron/ElectronTheme.ts @@ -31,15 +31,16 @@ export class ElectronTheme extends Context.Service< /** @public */ export const make = ElectronTheme.of({ shouldUseDarkColors: Effect.sync(() => Electron.nativeTheme.shouldUseDarkColors), - setSource: (theme) => - Effect.try({ + setSource: Effect.fn("ElectronTheme.setSource")(function (theme) { + return Effect.try({ try: () => { Electron.nativeTheme.themeSource = theme; }, catch: (cause) => new ElectronThemeSetSourceError({ source: theme, cause }), - }), - onUpdated: (listener) => - Effect.acquireRelease( + }); + }), + onUpdated: Effect.fn("ElectronTheme.onUpdated")(function (listener) { + return Effect.acquireRelease( Effect.suspend(() => { Electron.nativeTheme.on("updated", listener); return Effect.void; @@ -49,7 +50,8 @@ export const make = ElectronTheme.of({ Electron.nativeTheme.removeListener("updated", listener); return Effect.void; }), - ), + ); + }), }); export const layer = Layer.succeed(ElectronTheme, make); diff --git a/apps/desktop/src/electron/ElectronUpdater.ts b/apps/desktop/src/electron/ElectronUpdater.ts index ba7786eab7..e5b7f18d1b 100644 --- a/apps/desktop/src/electron/ElectronUpdater.ts +++ b/apps/desktop/src/electron/ElectronUpdater.ts @@ -82,47 +82,57 @@ export class ElectronUpdater extends Context.Service< /** @public */ export const make = ElectronUpdater.of({ - setFeedURL: (options) => - Effect.suspend(() => { + setFeedURL: Effect.fn("ElectronUpdater.setFeedURL")(function (options) { + return Effect.suspend(() => { autoUpdater.setFeedURL(options); return Effect.void; - }), - setAutoDownload: (value) => - Effect.suspend(() => { + }); + }), + setAutoDownload: Effect.fn("ElectronUpdater.setAutoDownload")(function (value) { + return Effect.suspend(() => { autoUpdater.autoDownload = value; return Effect.void; - }), - setAutoInstallOnAppQuit: (value) => - Effect.suspend(() => { + }); + }), + setAutoInstallOnAppQuit: Effect.fn("ElectronUpdater.setAutoInstallOnAppQuit")(function (value) { + return Effect.suspend(() => { autoUpdater.autoInstallOnAppQuit = value; return Effect.void; - }), - setChannel: (channel) => - Effect.suspend(() => { + }); + }), + setChannel: Effect.fn("ElectronUpdater.setChannel")(function (channel) { + return Effect.suspend(() => { autoUpdater.channel = channel; return Effect.void; - }), - setAllowPrerelease: (value) => - Effect.suspend(() => { + }); + }), + setAllowPrerelease: Effect.fn("ElectronUpdater.setAllowPrerelease")(function (value) { + return Effect.suspend(() => { autoUpdater.allowPrerelease = value; return Effect.void; - }), + }); + }), allowDowngrade: Effect.sync(() => autoUpdater.allowDowngrade), - setAllowDowngrade: (value) => - Effect.suspend(() => { + setAllowDowngrade: Effect.fn("ElectronUpdater.setAllowDowngrade")(function (value) { + return Effect.suspend(() => { autoUpdater.allowDowngrade = value; return Effect.void; - }), - setFullChangelog: (value) => - Effect.suspend(() => { + }); + }), + setFullChangelog: Effect.fn("ElectronUpdater.setFullChangelog")(function (value) { + return Effect.suspend(() => { autoUpdater.fullChangelog = value; return Effect.void; - }), - setDisableDifferentialDownload: (value) => - Effect.suspend(() => { - autoUpdater.disableDifferentialDownload = value; - return Effect.void; - }), + }); + }), + setDisableDifferentialDownload: Effect.fn("ElectronUpdater.setDisableDifferentialDownload")( + function (value) { + return Effect.suspend(() => { + autoUpdater.disableDifferentialDownload = value; + return Effect.void; + }); + }, + ), checkForUpdates: Effect.suspend(() => { const channel = autoUpdater.channel; return Effect.tryPromise({ @@ -137,8 +147,11 @@ export const make = ElectronUpdater.of({ catch: (cause) => new ElectronUpdaterDownloadUpdateError({ channel, cause }), }).pipe(Effect.asVoid); }), - quitAndInstall: ({ isSilent, isForceRunAfter }) => - Effect.suspend(() => { + quitAndInstall: Effect.fn("ElectronUpdater.quitAndInstall")(function ({ + isSilent, + isForceRunAfter, + }) { + return Effect.suspend(() => { const channel = autoUpdater.channel; return Effect.try({ try: () => autoUpdater.quitAndInstall(isSilent, isForceRunAfter), @@ -150,8 +163,9 @@ export const make = ElectronUpdater.of({ cause, }), }); - }), - on: (eventName, listener) => { + }); + }), + on: Effect.fn("ElectronUpdater.on")(function (eventName, listener) { const eventTarget = autoUpdater as unknown as { on: (eventName: string, listener: (...args: Array) => void) => void; removeListener: (eventName: string, listener: (...args: Array) => void) => void; @@ -166,7 +180,7 @@ export const make = ElectronUpdater.of({ eventTarget.removeListener(eventName, untypedListener); }), ).pipe(Effect.asVoid); - }, + }), }); export const layer = Layer.succeed(ElectronUpdater, make); diff --git a/apps/desktop/src/electron/ElectronWindow.ts b/apps/desktop/src/electron/ElectronWindow.ts index bdb73d8272..94144eca7d 100644 --- a/apps/desktop/src/electron/ElectronWindow.ts +++ b/apps/desktop/src/electron/ElectronWindow.ts @@ -207,7 +207,7 @@ export const make = Effect.gen(function* () { }); return ElectronWindow.of({ - create: (options) => { + create: Effect.fn("ElectronWindow.create")(function (options) { const webPreferences = options.webPreferences; const diagnosticOptions = { title: options.title ?? null, @@ -235,13 +235,15 @@ export const make = Effect.gen(function* () { try: () => new Electron.BrowserWindow(options), catch: (cause) => new ElectronWindowCreateError({ options: diagnosticOptions, cause }), }); - }, + }), main: liveMain, currentMainOrFirst, focusedMainOrFirst, - setMain: (window) => Ref.set(mainWindowRef, Option.some(window)), - clearMain: (window) => - Ref.update(mainWindowRef, (current) => { + setMain: Effect.fn("ElectronWindow.setMain")(function (window) { + return Ref.set(mainWindowRef, Option.some(window)); + }), + clearMain: Effect.fn("ElectronWindow.clearMain")(function (window) { + return Ref.update(mainWindowRef, (current) => { if (Option.isNone(current)) { return current; } @@ -249,9 +251,10 @@ export const make = Effect.gen(function* () { return current; } return Option.none(); - }), - prepareReveal: (window) => - Effect.promise(async () => { + }); + }), + prepareReveal: Effect.fn("ElectronWindow.prepareReveal")(function (window) { + return Effect.promise(async () => { if (platform !== "win32" || window.isDestroyed()) { return false; } @@ -259,9 +262,10 @@ export const make = Effect.gen(function* () { return ensureWindowsForegroundFocus() .prepare(windowsForegroundFocusTarget(window)) .catch(() => false); - }), - reveal: (window) => - Effect.tryPromise({ + }); + }), + reveal: Effect.fn("ElectronWindow.reveal")(function (window) { + return Effect.tryPromise({ try: async () => { if (window.isDestroyed()) { return; @@ -322,7 +326,8 @@ export const make = Effect.gen(function* () { channel: null, cause, }), - }).pipe(Effect.orDie), + }).pipe(Effect.orDie); + }), sendAll: /* Window broadcasts run for streamed updates; avoid a span per broadcast. */ Effect.fnUntraced(function* (channel, ...args) { diff --git a/apps/desktop/src/settings/DesktopAppSettings.test.ts b/apps/desktop/src/settings/DesktopAppSettings.test.ts index 6b895f23cd..a6b5dc037e 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.test.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.test.ts @@ -4,6 +4,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; +import * as Tracer from "effect/Tracer"; import * as DesktopConfig from "../app/DesktopConfig.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; @@ -90,6 +91,37 @@ function writeSettingsPatch(patch: typeof DesktopSettingsPatch.Type) { } describe("DesktopSettings", () => { + it.effect("records one bounds operation span with its input attributes", () => + Effect.gen(function* () { + const spans: Array = []; + const tracer = Tracer.make({ + span: (options) => { + const span = new Tracer.NativeSpan(options); + spans.push(span); + return span; + }, + }); + yield* withSettings( + Effect.gen(function* () { + const settings = yield* DesktopAppSettings.DesktopAppSettings; + yield* settings.setMainWindowBounds({ x: 10, y: 20, width: 900, height: 600 }, true); + }), + ).pipe(Effect.provideService(Tracer.Tracer, tracer)); + const operationSpans = spans.filter( + (span) => span.name === "desktop.settings.setMainWindowBounds", + ); + assert.lengthOf(operationSpans, 1); + assert.deepEqual(Object.fromEntries(operationSpans[0]!.attributes), { + x: 10, + y: 20, + width: 900, + height: 600, + isMaximized: true, + }); + assert.equal(operationSpans[0]!.status._tag, "Ended"); + }), + ); + it.effect( "persists disabling and re-enabling local execution without clearing backend settings", () => diff --git a/apps/desktop/src/settings/DesktopAppSettings.ts b/apps/desktop/src/settings/DesktopAppSettings.ts index bb4603347d..9157bdf68f 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.ts @@ -521,48 +521,56 @@ export const make = Effect.gen(function* () { ); return yield* SynchronizedRef.setAndGet(settingsRef, settings); }).pipe(Effect.withSpan("desktop.settings.load")), - setMainWindowBounds: (bounds, isMaximized) => - persist((settings) => setMainWindowBounds(settings, bounds, isMaximized)).pipe( - Effect.withSpan("desktop.settings.setMainWindowBounds", { - attributes: { - x: bounds.x, - y: bounds.y, - width: bounds.width, - height: bounds.height, - isMaximized, - }, - }), - ), - setServerExposureMode: (mode) => - persist((settings) => setServerExposureMode(settings, mode)).pipe( - Effect.withSpan("desktop.settings.setServerExposureMode", { attributes: { mode } }), - ), - setTailscaleServe: (input) => - persist((settings) => setTailscaleServe(settings, input)).pipe( - Effect.withSpan("desktop.settings.setTailscaleServe", { attributes: input }), - ), - setUpdateChannel: (channel) => - persist((settings) => setUpdateChannel(settings, channel)).pipe( - Effect.withSpan("desktop.settings.setUpdateChannel", { attributes: { channel } }), - ), - setWslBackendEnabled: (enabled) => - persist((settings) => setWslBackendEnabled(settings, enabled)).pipe( - Effect.withSpan("desktop.settings.setWslBackendEnabled", { attributes: { enabled } }), - ), - setWslDistro: (distro) => - persist((settings) => setWslDistro(settings, distro)).pipe( - Effect.withSpan("desktop.settings.setWslDistro", { - attributes: { distro: distro ?? null }, - }), - ), - setWslOnly: (enabled) => - persist((settings) => setWslOnly(settings, enabled)).pipe( - Effect.withSpan("desktop.settings.setWslOnly", { attributes: { enabled } }), - ), - setLocalEnvironmentEnabled: (enabled) => - persist((settings) => setLocalEnvironmentEnabled(settings, enabled)).pipe( - Effect.withSpan("desktop.settings.setLocalEnvironmentEnabled", { attributes: { enabled } }), - ), + setMainWindowBounds: Effect.fn("desktop.settings.setMainWindowBounds")( + function* (bounds, isMaximized) { + yield* Effect.annotateCurrentSpan({ + x: bounds.x, + y: bounds.y, + width: bounds.width, + height: bounds.height, + isMaximized, + }); + + return yield* persist((settings) => setMainWindowBounds(settings, bounds, isMaximized)); + }, + ), + setServerExposureMode: Effect.fn("desktop.settings.setServerExposureMode")(function* (mode) { + yield* Effect.annotateCurrentSpan({ mode }); + + return yield* persist((settings) => setServerExposureMode(settings, mode)); + }), + setTailscaleServe: Effect.fn("desktop.settings.setTailscaleServe")(function* (input) { + yield* Effect.annotateCurrentSpan(input); + + return yield* persist((settings) => setTailscaleServe(settings, input)); + }), + setUpdateChannel: Effect.fn("desktop.settings.setUpdateChannel")(function* (channel) { + yield* Effect.annotateCurrentSpan({ channel }); + + return yield* persist((settings) => setUpdateChannel(settings, channel)); + }), + setWslBackendEnabled: Effect.fn("desktop.settings.setWslBackendEnabled")(function* (enabled) { + yield* Effect.annotateCurrentSpan({ enabled }); + + return yield* persist((settings) => setWslBackendEnabled(settings, enabled)); + }), + setWslDistro: Effect.fn("desktop.settings.setWslDistro")(function* (distro) { + yield* Effect.annotateCurrentSpan({ distro: distro ?? null }); + + return yield* persist((settings) => setWslDistro(settings, distro)); + }), + setWslOnly: Effect.fn("desktop.settings.setWslOnly")(function* (enabled) { + yield* Effect.annotateCurrentSpan({ enabled }); + + return yield* persist((settings) => setWslOnly(settings, enabled)); + }), + setLocalEnvironmentEnabled: Effect.fn("desktop.settings.setLocalEnvironmentEnabled")( + function* (enabled) { + yield* Effect.annotateCurrentSpan({ enabled }); + + return yield* persist((settings) => setLocalEnvironmentEnabled(settings, enabled)); + }, + ), applyWslWindowsFallback: persist(applyWslWindowsFallback).pipe( Effect.withSpan("desktop.settings.applyWslWindowsFallback"), ), @@ -594,18 +602,38 @@ export const layerTest = (initialSettings: DesktopSettings = DEFAULT_DESKTOP_SET return DesktopAppSettings.of({ get: SynchronizedRef.get(settingsRef), load: SynchronizedRef.get(settingsRef), - setMainWindowBounds: (bounds, isMaximized) => - update((settings) => setMainWindowBounds(settings, bounds, isMaximized)), - setServerExposureMode: (mode) => - update((settings) => setServerExposureMode(settings, mode)), - setTailscaleServe: (input) => update((settings) => setTailscaleServe(settings, input)), - setUpdateChannel: (channel) => update((settings) => setUpdateChannel(settings, channel)), - setWslBackendEnabled: (enabled) => - update((settings) => setWslBackendEnabled(settings, enabled)), - setWslDistro: (distro) => update((settings) => setWslDistro(settings, distro)), - setWslOnly: (enabled) => update((settings) => setWslOnly(settings, enabled)), - setLocalEnvironmentEnabled: (enabled) => - update((settings) => setLocalEnvironmentEnabled(settings, enabled)), + setMainWindowBounds: Effect.fn("DesktopAppSettings.setMainWindowBounds")( + function (bounds, isMaximized) { + return update((settings) => setMainWindowBounds(settings, bounds, isMaximized)); + }, + ), + setServerExposureMode: Effect.fn("DesktopAppSettings.setServerExposureMode")( + function (mode) { + return update((settings) => setServerExposureMode(settings, mode)); + }, + ), + setTailscaleServe: Effect.fn("DesktopAppSettings.setTailscaleServe")(function (input) { + return update((settings) => setTailscaleServe(settings, input)); + }), + setUpdateChannel: Effect.fn("DesktopAppSettings.setUpdateChannel")(function (channel) { + return update((settings) => setUpdateChannel(settings, channel)); + }), + setWslBackendEnabled: Effect.fn("DesktopAppSettings.setWslBackendEnabled")( + function (enabled) { + return update((settings) => setWslBackendEnabled(settings, enabled)); + }, + ), + setWslDistro: Effect.fn("DesktopAppSettings.setWslDistro")(function (distro) { + return update((settings) => setWslDistro(settings, distro)); + }), + setWslOnly: Effect.fn("DesktopAppSettings.setWslOnly")(function (enabled) { + return update((settings) => setWslOnly(settings, enabled)); + }), + setLocalEnvironmentEnabled: Effect.fn("DesktopAppSettings.setLocalEnvironmentEnabled")( + function (enabled) { + return update((settings) => setLocalEnvironmentEnabled(settings, enabled)); + }, + ), applyWslWindowsFallback: update(applyWslWindowsFallback), applyWslWindowsFallbackInMemory: update(applyWslWindowsFallback), }); diff --git a/apps/desktop/src/settings/DesktopClientSettings.ts b/apps/desktop/src/settings/DesktopClientSettings.ts index e51097a829..0a1766ec79 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.ts @@ -194,8 +194,8 @@ export const make = Effect.gen(function* () { get: readClientSettings(fileSystem, environment.clientSettingsPath).pipe( Effect.withSpan("desktop.clientSettings.get"), ), - set: (settings) => - crypto.randomUUIDv4().pipe( + set: Effect.fn("desktop.clientSettings.set")(function (settings) { + return crypto.randomUUIDv4().pipe( Effect.map((uuid) => uuid.replace(/-/g, "")), Effect.mapError( (cause) => @@ -214,8 +214,8 @@ export const make = Effect.gen(function* () { suffix, }), ), - Effect.withSpan("desktop.clientSettings.set"), - ), + ); + }), }); }); @@ -228,7 +228,9 @@ export const layerTest = (initialSettings: Option.Option = Optio const settingsRef = yield* Ref.make(initialSettings); return DesktopClientSettings.of({ get: Ref.get(settingsRef), - set: (settings) => Ref.set(settingsRef, Option.some(settings)), + set: Effect.fn("DesktopClientSettings.set")(function (settings) { + return Ref.set(settingsRef, Option.some(settings)); + }), }); }), ); diff --git a/apps/desktop/src/snapShot/DesktopSnapShot.ts b/apps/desktop/src/snapShot/DesktopSnapShot.ts index 2baf788fa5..0f827a3755 100644 --- a/apps/desktop/src/snapShot/DesktopSnapShot.ts +++ b/apps/desktop/src/snapShot/DesktopSnapShot.ts @@ -1716,24 +1716,28 @@ export const make = Effect.gen(function* () { ), ), ), - setAnimationDestination: (id, destination) => - Effect.promise(async () => { - if (linuxFeedback?.id === id && destination.relativeFrame) { - await linuxFeedback.feedback - .animateTo(destination.relativeFrame) - .catch(() => closeLinuxFeedback(id)); - return; - } - transition.animateTo(id, destination); - await transition.waitForLanding(id); - }), - dismissAnimation: (id) => - Effect.sync(() => { + setAnimationDestination: Effect.fn("DesktopSnapShot.setAnimationDestination")( + function (id, destination) { + return Effect.promise(async () => { + if (linuxFeedback?.id === id && destination.relativeFrame) { + await linuxFeedback.feedback + .animateTo(destination.relativeFrame) + .catch(() => closeLinuxFeedback(id)); + return; + } + transition.animateTo(id, destination); + await transition.waitForLanding(id); + }); + }, + ), + dismissAnimation: Effect.fn("DesktopSnapShot.dismissAnimation")(function (id) { + return Effect.sync(() => { closeLinuxFeedback(id); transition.dismiss(id); - }), - acknowledge: (id) => - Effect.promise(async () => { + }); + }), + acknowledge: Effect.fn("DesktopSnapShot.acknowledge")(function (id) { + return Effect.promise(async () => { await completeLinuxFeedback(id); await transition.complete(id); }).pipe( @@ -1749,7 +1753,8 @@ export const make = Effect.gen(function* () { Effect.mapError( (cause) => new DesktopSnapShotError({ operation: "acknowledge", captureId: id, cause }), ), - ), + ); + }), }); }); diff --git a/apps/desktop/src/ssh/DesktopSshEnvironment.ts b/apps/desktop/src/ssh/DesktopSshEnvironment.ts index 945376cd0a..6ed85c29e3 100644 --- a/apps/desktop/src/ssh/DesktopSshEnvironment.ts +++ b/apps/desktop/src/ssh/DesktopSshEnvironment.ts @@ -134,32 +134,26 @@ export const make = Effect.gen(function* () { const passwordPrompt = SshAuth.SshPasswordPrompt.of(makePasswordPrompt(prompts)); return DesktopSshEnvironment.of({ - discoverHosts: (input) => - discoverDesktopSshHostsEffect(input).pipe( - Effect.provide(runtimeContext), - Effect.withSpan("desktop.ssh.discoverHosts"), - ), - resolveHost: (alias) => - resolveSshTarget(alias.trim()).pipe( - Effect.provide(runtimeContext), - Effect.withSpan("desktop.ssh.resolveHost"), - ), - ensureEnvironment: (target, ensureOptions) => - manager - .ensureEnvironment(target, ensureOptions) - .pipe( - Effect.provideService(SshAuth.SshPasswordPrompt, passwordPrompt), - Effect.provide(runtimeContext), - Effect.withSpan("desktop.ssh.ensureEnvironment"), - ), - disconnectEnvironment: (target) => - manager - .disconnectEnvironment(target) - .pipe( - Effect.provideService(SshAuth.SshPasswordPrompt, passwordPrompt), - Effect.provide(runtimeContext), - Effect.withSpan("desktop.ssh.disconnectEnvironment"), - ), + discoverHosts: Effect.fn("desktop.ssh.discoverHosts")(function* (input) { + return yield* discoverDesktopSshHostsEffect(input); + }, Effect.provide(runtimeContext)), + resolveHost: Effect.fn("desktop.ssh.resolveHost")(function* (alias) { + return yield* resolveSshTarget(alias.trim()); + }, Effect.provide(runtimeContext)), + ensureEnvironment: Effect.fn("desktop.ssh.ensureEnvironment")( + function* (target, ensureOptions) { + return yield* manager.ensureEnvironment(target, ensureOptions); + }, + Effect.provideService(SshAuth.SshPasswordPrompt, passwordPrompt), + Effect.provide(runtimeContext), + ), + disconnectEnvironment: Effect.fn("desktop.ssh.disconnectEnvironment")( + function* (target) { + return yield* manager.disconnectEnvironment(target); + }, + Effect.provideService(SshAuth.SshPasswordPrompt, passwordPrompt), + Effect.provide(runtimeContext), + ), }); }); diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index f33d55aba6..fd0a4d4f49 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -1274,7 +1274,9 @@ export const make = Effect.gen(function* () { install: installWithExpectedVersion().pipe( Effect.map(({ accepted, completed, state }) => ({ accepted, completed, state })), ), - installPrepared: (expectedVersion) => installWithExpectedVersion(expectedVersion), + installPrepared: Effect.fn("DesktopUpdates.installPrepared")(function (expectedVersion) { + return installWithExpectedVersion(expectedVersion); + }), }); }); diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.ts index 0c58e2b52d..885289281f 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.ts @@ -1071,33 +1071,57 @@ export const layerTest = (stub: DesktopWslEnvironmentTestStub = {}) => { isAvailable: Effect.succeed(stub.isAvailable ?? false), listDistros: probeDistros.pipe(Effect.orElseSucceed(() => [])), probeDistros, - preWarm: () => Effect.void, - windowsToWslPath: (distro, windowsPath) => - Effect.succeed(stub.windowsToWslPath?.(distro, windowsPath) ?? Option.none()), - getUserHome: (distro) => Effect.succeed(stub.getUserHome?.(distro) ?? Option.none()), - getDistroIp: (distro) => Effect.succeed(stub.getDistroIp?.(distro) ?? Option.none()), - prepareRuntime: (distro, archive) => - Effect.succeed( + preWarm: Effect.fn("DesktopWslEnvironment.preWarm")(function () { + return Effect.void; + }), + windowsToWslPath: Effect.fn("DesktopWslEnvironment.windowsToWslPath")( + function (distro, windowsPath) { + return Effect.succeed(stub.windowsToWslPath?.(distro, windowsPath) ?? Option.none()); + }, + ), + getUserHome: Effect.fn("DesktopWslEnvironment.getUserHome")(function (distro) { + return Effect.succeed(stub.getUserHome?.(distro) ?? Option.none()); + }), + getDistroIp: Effect.fn("DesktopWslEnvironment.getDistroIp")(function (distro) { + return Effect.succeed(stub.getDistroIp?.(distro) ?? Option.none()); + }), + prepareRuntime: Effect.fn("DesktopWslEnvironment.prepareRuntime")(function (distro, archive) { + return Effect.succeed( stub.prepareRuntime?.(distro, archive) ?? { ok: false, reason: "prepareRuntime stub not configured", }, - ), - pruneRuntimes: (distro, runtimeId) => stub.pruneRuntimes?.(distro, runtimeId) ?? Effect.void, - invalidateRuntime: (distro, runtimeId) => - stub.invalidateRuntime?.(distro, runtimeId) ?? Effect.void, - probeRuntime: (distro, linuxAppRoot) => - Effect.succeed( - stub.probeRuntime?.(distro, linuxAppRoot) ?? { ok: true, resolvedPath: "/usr/bin:/bin" }, - ), - ensureNodePty: (distro, linuxAppRoot, options) => - Effect.succeed( - stub.ensureNodePty?.(distro, linuxAppRoot, options) ?? { - ok: false, - reason: "ensureNodePty stub not configured", - fatal: true, - }, - ), + ); + }), + pruneRuntimes: Effect.fn("DesktopWslEnvironment.pruneRuntimes")(function (distro, runtimeId) { + return stub.pruneRuntimes?.(distro, runtimeId) ?? Effect.void; + }), + invalidateRuntime: Effect.fn("DesktopWslEnvironment.invalidateRuntime")( + function (distro, runtimeId) { + return stub.invalidateRuntime?.(distro, runtimeId) ?? Effect.void; + }, + ), + probeRuntime: Effect.fn("DesktopWslEnvironment.probeRuntime")( + function (distro, linuxAppRoot) { + return Effect.succeed( + stub.probeRuntime?.(distro, linuxAppRoot) ?? { + ok: true, + resolvedPath: "/usr/bin:/bin", + }, + ); + }, + ), + ensureNodePty: Effect.fn("DesktopWslEnvironment.ensureNodePty")( + function (distro, linuxAppRoot, options) { + return Effect.succeed( + stub.ensureNodePty?.(distro, linuxAppRoot, options) ?? { + ok: false, + reason: "ensureNodePty stub not configured", + fatal: true, + }, + ); + }, + ), }), ); }; @@ -1152,31 +1176,29 @@ export const layer = Layer.effect( Effect.withSpan("desktop.wsl.listDistros"), ), probeDistros, - preWarm: (distro) => - provideSpawner(preWarmImpl(distro)).pipe(Effect.withSpan("desktop.wsl.preWarm")), + preWarm: Effect.fn("desktop.wsl.preWarm")(function (distro) { + return provideSpawner(preWarmImpl(distro)); + }), windowsToWslPath, getUserHome, getDistroIp, - prepareRuntime: (distro, archive) => - provideSpawner(prepareWslRuntimeImpl(distro, archive, windowsToWslPath)).pipe( - Effect.withSpan("desktop.wsl.prepareRuntime"), - ), - pruneRuntimes: (distro, runtimeId) => - provideSpawner(pruneWslRuntimesImpl(distro, runtimeId)).pipe( - Effect.withSpan("desktop.wsl.pruneRuntimes"), - ), - invalidateRuntime: (distro, runtimeId) => - provideSpawner(invalidateWslRuntimeImpl(distro, runtimeId)).pipe( - Effect.withSpan("desktop.wsl.invalidateRuntime"), - ), - probeRuntime: (distro, linuxAppRoot) => - provideSpawner(probeWslRuntimeImpl(distro, linuxAppRoot)).pipe( - Effect.withSpan("desktop.wsl.probeRuntime"), - ), - ensureNodePty: (distro, linuxAppRoot, options) => - provideSpawner(ensureNodePtyImpl(distro, linuxAppRoot, options)).pipe( - Effect.withSpan("desktop.wsl.ensureNodePty"), - ), + prepareRuntime: Effect.fn("desktop.wsl.prepareRuntime")(function (distro, archive) { + return provideSpawner(prepareWslRuntimeImpl(distro, archive, windowsToWslPath)); + }), + pruneRuntimes: Effect.fn("desktop.wsl.pruneRuntimes")(function (distro, runtimeId) { + return provideSpawner(pruneWslRuntimesImpl(distro, runtimeId)); + }), + invalidateRuntime: Effect.fn("desktop.wsl.invalidateRuntime")(function (distro, runtimeId) { + return provideSpawner(invalidateWslRuntimeImpl(distro, runtimeId)); + }), + probeRuntime: Effect.fn("desktop.wsl.probeRuntime")(function (distro, linuxAppRoot) { + return provideSpawner(probeWslRuntimeImpl(distro, linuxAppRoot)); + }), + ensureNodePty: Effect.fn("desktop.wsl.ensureNodePty")( + function (distro, linuxAppRoot, options) { + return provideSpawner(ensureNodePtyImpl(distro, linuxAppRoot, options)); + }, + ), }); }), ); diff --git a/apps/mobile/deps/react-native-screens-5.0.0-t3.44.tgz b/apps/mobile/deps/react-native-screens-5.0.0-t3.44.tgz deleted file mode 100644 index 9346f85dd0..0000000000 Binary files a/apps/mobile/deps/react-native-screens-5.0.0-t3.44.tgz and /dev/null differ diff --git a/apps/mobile/deps/react-native-screens-5.0.0-t3.45.tgz b/apps/mobile/deps/react-native-screens-5.0.0-t3.45.tgz new file mode 100644 index 0000000000..2e076b177f Binary files /dev/null and b/apps/mobile/deps/react-native-screens-5.0.0-t3.45.tgz differ diff --git a/apps/mobile/eas.json b/apps/mobile/eas.json index 9973c551a4..dac234d0fd 100644 --- a/apps/mobile/eas.json +++ b/apps/mobile/eas.json @@ -18,6 +18,13 @@ "image": "macos-tahoe-26.6-xcode-27.1" } }, + "development-simulator": { + "extends": "development", + "ios": { + "simulator": true, + "image": "macos-tahoe-26.6-xcode-27.1" + } + }, "preview": { "corepack": true, "env": { diff --git a/apps/mobile/modules/t3-terminal/T3TerminalNative.podspec b/apps/mobile/modules/t3-terminal/T3TerminalNative.podspec index 2d40f97769..a4123a5550 100644 --- a/apps/mobile/modules/t3-terminal/T3TerminalNative.podspec +++ b/apps/mobile/modules/t3-terminal/T3TerminalNative.podspec @@ -14,6 +14,10 @@ Pod::Spec.new do |s| s.source = { :path => '.' } s.source_files = 'ios/**/*.{h,m,mm,swift}' s.vendored_frameworks = 'Vendor/libghostty/GhosttyKit.xcframework' + # libghostty has no x86_64 simulator slice, so a simulator build for every + # architecture (EAS's generic destination) fails to resolve GhosttyKit. + s.pod_target_xcconfig = { 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'x86_64' } + s.user_target_xcconfig = { 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'x86_64' } s.frameworks = 'IOSurface', 'Metal', 'MetalKit', 'QuartzCore', 'UIKit' s.libraries = 'c++', 'z' s.swift_version = '5.9' diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 9e54daeb19..52ef3facda 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -134,7 +134,7 @@ "react-native-pdf-jsi": "5.0.0", "react-native-reanimated": "4.7.1", "react-native-safe-area-context": "~5.10.1", - "react-native-screens": "file:deps/react-native-screens-5.0.0-t3.44.tgz", + "react-native-screens": "file:deps/react-native-screens-5.0.0-t3.45.tgz", "react-native-svg": "15.16.0", "react-native-webview": "14.0.1", "react-native-worklets": "0.13.0", diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index 5810bab3b0..a38c723d1c 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -1,4 +1,4 @@ -import { PermissionUpdateNotice } from "./features/connection/PermissionUpdateNotice"; +import { installPermissionUpdateNotice } from "./features/connection/permissionUpdateNotice"; import * as Linking from "expo-linking"; import * as SplashScreen from "expo-splash-screen"; import { useEffect } from "react"; @@ -36,6 +36,12 @@ import { environmentThreadShells } from "./state/threads"; import { environmentShell } from "./state/shell"; import { OverlayPortalHost } from "./components/ui/OverlayPortal"; import { shouldHandleAppLink, restoreDraftLinkState } from "./lib/appLinking"; +import { hasCloudPublicConfig } from "./features/cloud/publicConfig"; +import { takeScheduledTaskNewLink } from "./features/settings/scheduled-task-editor-state"; +import { + disposeOnFoundationReplace, + type FoundationHotModule, +} from "./lib/foundation-fast-refresh"; import { useMobileNavigationTheme } from "./lib/useMobileNavigationTheme"; import { useUiRuntimeMemoryWarningGc } from "./lib/useUiRuntimeMemoryWarningGc"; import { SubscriptionUsageCoordinator } from "./widgets/SubscriptionUsageCoordinator"; @@ -70,7 +76,7 @@ const appLinking = { ], ...infinitusLinkingListeners, config: { initialRouteName: "Home" }, - filter: shouldHandleAppLink, + filter: (url: string) => shouldHandleAppLink(url, hasCloudPublicConfig()), getInitialURL: async () => (await Linking.getInitialURL()) ?? (await launchWorkspaceURL()), getActionFromState: ( state: Parameters[0], @@ -80,15 +86,25 @@ const appLinking = { (workspaceDetailLinkAction(state) as ReturnType) ?? getActionFromState(state, options), getStateFromPath: (path: string, options: Parameters[1]) => - restoreSettingsLinkState( - restoreDraftLinkState(getStateFromPath(path, options), path), - settingsLinkPage(), + takeScheduledTaskNewLink( + restoreSettingsLinkState( + restoreDraftLinkState(getStateFromPath(path, options), path), + settingsLinkPage(), + ), ), }; const Navigation = createStaticNavigation(RootStack); const navigationRef = createNavigationContainerRef(); +declare const module: { readonly hot?: FoundationHotModule } | undefined; + +const disposePermissionUpdateNotice = installPermissionUpdateNotice(); +disposeOnFoundationReplace( + typeof module === "undefined" ? undefined : module.hot, + disposePermissionUpdateNotice, +); + function SplashScreenCoordinator() { const { isReady } = useAppearancePreferences(); @@ -131,7 +147,6 @@ function AppContent() { <> - diff --git a/apps/mobile/src/components/ComposerEditor.tsx b/apps/mobile/src/components/ComposerEditor.tsx index 8f51aa8e2d..d0bae7bf10 100644 --- a/apps/mobile/src/components/ComposerEditor.tsx +++ b/apps/mobile/src/components/ComposerEditor.tsx @@ -3,7 +3,7 @@ import { ComposerContextId } from "@infinitus/contracts"; import { useAtomValue } from "@effect/atom-react"; import { AsyncResult } from "effect/reactivity"; import { settlePromise, squashAtomCommandFailure } from "@infinitus/client-runtime/state/runtime"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useState } from "react"; import type { EnvironmentId } from "@infinitus/contracts"; import { encodeComposerContextFragment } from "@infinitus/shared/composerContextClipboard"; import { collectComposerContextReferences } from "@infinitus/shared/composerContextReferences"; @@ -11,6 +11,7 @@ import { ComposerEditor as NativeComposerEditor } from "../native/T3ComposerEdit import type { ComposerEditorProps as NativeComposerEditorProps } from "../native/T3ComposerEditor"; import { appendComposerDraftAttachments, + composerContextImportsAtom, createComposerDraftContextHistory, getComposerDraftAfterSelection, getComposerDraftSnapshot, @@ -22,6 +23,8 @@ import { useComposerDraft, } from "../state/use-composer-drafts"; import { importComposerContextClipboard } from "../lib/composerContextClipboard"; +import { startComposerDraftOperation } from "../state/composerDraftOperations"; +import { appAtomRegistry } from "../state/atom-registry"; import { mobilePreferencesAtom } from "../state/preferences"; import { useOpenLinkedThread } from "../features/threads/useOpenLinkedThread"; import { ComposerContextSheet } from "./ComposerContextSheet"; @@ -73,32 +76,32 @@ export function ComposerEditor({ const [selected, setSelected] = useState<{ source: string; start: number; end: number } | null>( null, ); - const importRef = useRef(null); - const [importing, setImporting] = useState(false); - useEffect( - () => () => { - importRef.current?.abort(); - }, - [draftKey], - ); + const contextImports = useAtomValue(composerContextImportsAtom); + const importing = draftKey ? contextImports[draftKey] === true : false; const pasteContext = async ( clipboard: Parameters>[0], ) => { - if (!draftKey || importRef.current || props.readOnly || props.editable === false) return; + if ( + !draftKey || + appAtomRegistry.get(composerContextImportsAtom)[draftKey] || + props.readOnly || + props.editable === false + ) + return; const insertion = { text: clipboard.value, ...clipboard.selection }; - const controller = new AbortController(); - importRef.current = controller; - setImporting(true); + const operation = startComposerDraftOperation(draftKey); + const { signal } = operation; setComposerContextImporting(draftKey, true); const settled = await settlePromise(async () => { const retained = getComposerDraftAfterSelection(draftKey, insertion); const result = await importComposerContextClipboard( clipboard, retained.attachments.length, - controller.signal, + signal, retained.context?.records.length ?? 0, environmentId, ); + if (signal.aborted) return; if (!result) { insertComposerDraftText(draftKey, clipboard.text, insertion); return; @@ -125,10 +128,9 @@ export function ComposerEditor({ [...warnings, "Those references are marked unavailable."].join("\n\n"), ); }); + operation.finish(); setComposerContextImporting(draftKey, false); - importRef.current = null; - setImporting(false); - if (AsyncResult.isFailure(settled) && !controller.signal.aborted) { + if (AsyncResult.isFailure(settled) && !signal.aborted) { const error = squashAtomCommandFailure(settled); Dialog.alert( "Could not paste context", diff --git a/apps/mobile/src/components/MediaVideoPlayer.tsx b/apps/mobile/src/components/MediaVideoPlayer.tsx index ec30709c1d..ea39cac15b 100644 --- a/apps/mobile/src/components/MediaVideoPlayer.tsx +++ b/apps/mobile/src/components/MediaVideoPlayer.tsx @@ -1,8 +1,7 @@ -import { useIsFocused } from "@react-navigation/native"; import { useEvent } from "expo"; import { useVideoPlayer, VideoView } from "expo-video"; import { useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; -import { AppState, Pressable, View } from "react-native"; +import { Pressable, View } from "react-native"; import { AppActivityIndicator } from "./ui/AppActivityIndicator"; import { AppText } from "./ui/AppText"; @@ -10,7 +9,7 @@ import { SymbolView } from "./ui/AppSymbol"; import { VideoThumbnailImage } from "./VideoThumbnailImage"; import { useMediaActions } from "../state/mediaActions"; import { type MediaActionsSource } from "../lib/mediaActionsSource"; -import { useFullscreenVideoOpen, useMediaAudioSessionHold } from "../lib/videoAudioSession"; +import { useMediaVideoPlayback } from "../lib/useMediaVideoPlayback"; import { MediaActionsMenu } from "./MediaActionsMenu"; import { createAudioPlaybackSession } from "../lib/audioSession"; @@ -19,13 +18,9 @@ function LoadedMediaVideo(props: { readonly resolvePlaybackUri?: () => Promise; readonly playRequested: boolean; readonly paused: boolean; + readonly visible: boolean; readonly onPlaybackStart?: () => void; }) { - const focused = useIsFocused(); - const fullscreenVideoOpen = useFullscreenVideoOpen(); - const paused = props.paused || fullscreenVideoOpen; - const active = useRef(focused && AppState.currentState === "active"); - const fullscreen = useRef(false); const [attempt, setAttempt] = useState(0); // Expo's Android player also reports completed playback as idle. const [loadState, setLoadState] = useState<"pending" | "complete" | "error">("pending"); @@ -34,6 +29,8 @@ function LoadedMediaVideo(props: { player.bufferOptions = { preferredForwardBufferDuration: 5 }; }); const playback = useMemo(() => createAudioPlaybackSession(player, true), [player]); + const { active, fullscreen, createPlayRequest, onFullscreenEnter, onFullscreenExit } = + useMediaVideoPlayback(player, props, playback); const previousPlaybackRequested = useRef(false); const onPlaybackStart = useEffectEvent(() => props.onPlaybackStart?.()); useEffect(() => { @@ -64,33 +61,22 @@ function LoadedMediaVideo(props: { playback.pause(); previousPlaybackRequested.current = false; }; - }, [player, playback]); + }, [active, fullscreen, player, playback]); const { status } = useEvent(player, "statusChange", { status: player.status }); - const { isPlaying } = useEvent(player, "playingChange", { isPlaying: player.playing }); - useMediaAudioSessionHold(isPlaying); const loadSource = useEffectEvent(async (signal: AbortSignal) => { + const playIfUninterrupted = createPlayRequest(); const uri = props.resolvePlaybackUri ? await props.resolvePlaybackUri() : props.uri; if (signal.aborted) return; if (uri === null) throw new Error("Video unavailable"); playback.pause(); previousPlaybackRequested.current = false; await player.replaceAsync({ uri, contentType: "progressive" }); - if (!signal.aborted && props.playRequested && active.current) await playback.play(); + if (!signal.aborted && props.playRequested) await playIfUninterrupted(); }); - useEffect(() => { - active.current = focused && !paused && AppState.currentState === "active"; - if (!focused || paused || (!active.current && !fullscreen.current)) playback.pause(); - // RN reports both leaving the app and Android fullscreen as background; native handling distinguishes them. - const subscription = AppState.addEventListener("change", (state) => { - active.current = focused && !paused && state === "active"; - if (state === "inactive" || (state === "background" && !fullscreen.current)) playback.pause(); - }); - return () => subscription.remove(); - }, [focused, playback, paused]); - useEffect(() => { const controller = new AbortController(); + // oxlint-disable-next-line react/set-state-in-effect -- Starting a native media load publishes pending before its async result. setLoadState("pending"); // A renewed signature is used on Retry, not as a reason to reset the native player. void loadSource(controller.signal).then( @@ -102,6 +88,7 @@ function LoadedMediaVideo(props: { }, ); return () => controller.abort(); + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- A play request or Retry (attempt) reloads the source into the same player. }, [player, props.playRequested, attempt]); return ( @@ -113,12 +100,8 @@ function LoadedMediaVideo(props: { nativeControls contentFit="contain" fullscreenOptions={{ enable: true }} - onFullscreenEnter={() => { - fullscreen.current = true; - }} - onFullscreenExit={() => { - fullscreen.current = false; - }} + onFullscreenEnter={onFullscreenEnter} + onFullscreenExit={onFullscreenExit} allowsPictureInPicture={false} /> {loadState === "error" || (loadState === "complete" && status === "error") ? ( @@ -148,6 +131,7 @@ interface MediaVideoPlayerProps { readonly name: string; readonly thumbnailKey: string; readonly thumbnailVisible?: boolean; + readonly visible?: boolean; readonly unavailable?: boolean; readonly expanded?: boolean; readonly autoPlay?: boolean; @@ -174,6 +158,7 @@ function MediaVideoPlayerContent(props: MediaVideoPlayerProps) { resolvePlaybackUri={props.resolvePlaybackUri} playRequested={!props.expanded || props.autoPlay === true} paused={(props.paused ?? false) || mediaActions.sharing} + visible={props.visible ?? true} onPlaybackStart={props.onPlaybackStart} /> ) : ( diff --git a/apps/mobile/src/components/ProjectFavicon.tsx b/apps/mobile/src/components/ProjectFavicon.tsx index 7150030d97..4f4326f319 100644 --- a/apps/mobile/src/components/ProjectFavicon.tsx +++ b/apps/mobile/src/components/ProjectFavicon.tsx @@ -168,6 +168,7 @@ function ProjectFaviconImage(props: { if (faviconRequest === null) return; const endRequest = beginProjectFaviconRequest(faviconRequest); + // oxlint-disable-next-line react/set-state-in-effect -- Publishes the favicon request registered with the external loader until its cleanup runs. setActiveFaviconRequest(faviconRequest); return endRequest; }, [faviconRequest]); diff --git a/apps/mobile/src/components/VideoPreviewModal.ios.tsx b/apps/mobile/src/components/VideoPreviewModal.ios.tsx index a317685834..3ad14a088f 100644 --- a/apps/mobile/src/components/VideoPreviewModal.ios.tsx +++ b/apps/mobile/src/components/VideoPreviewModal.ios.tsx @@ -70,6 +70,7 @@ function NativeVideoPreview(props: { return () => fullscreenVideoClosed(); }, []); useEffect(() => { + // oxlint-disable-next-line react/set-state-in-effect -- The native player latches the first resolved URL to keep its playback session. if (playbackUrl === null && resolvedUrl !== null) setPlaybackUrl(resolvedUrl); }, [playbackUrl, resolvedUrl]); useEffect(() => { diff --git a/apps/mobile/src/components/VideoPreviewModal.tsx b/apps/mobile/src/components/VideoPreviewModal.tsx index f6b5db1d73..b6f1748836 100644 --- a/apps/mobile/src/components/VideoPreviewModal.tsx +++ b/apps/mobile/src/components/VideoPreviewModal.tsx @@ -64,6 +64,7 @@ function useLocalPlayback(source: LocalVideoPreviewSource): PlaybackState { ); const { id: attachmentId, fileUri } = attachment; useEffect(() => { + // oxlint-disable-next-line react/set-state-in-effect -- A new attachment clears the previous attachment's result before loading. setUri(null); setError(null); const controller = new AbortController(); @@ -87,6 +88,7 @@ function useLocalPlayback(source: LocalVideoPreviewSource): PlaybackState { () => undefined, ); }; + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- A different attachment restarts the load even when it resolves to the same cached file URI. }, [attachmentId, fileUri]); const mimeType = videoMimeType(attachment) ?? attachment.mimeType; return { @@ -208,9 +210,10 @@ export function VideoPreviewModal(props: { }) { const isFocused = useIsFocused(); const hasSource = props.source !== null; + const { onRequestClose } = props; useEffect(() => { - if (!isFocused && hasSource) props.onRequestClose(); - }, [isFocused, hasSource, props.onRequestClose]); + if (!isFocused && hasSource) onRequestClose(); + }, [isFocused, hasSource, onRequestClose]); const { source } = props; if (source === null || !isFocused) return null; return source.type === "local" ? ( diff --git a/apps/mobile/src/components/ui/AndroidAnchoredMenu.tsx b/apps/mobile/src/components/ui/AndroidAnchoredMenu.tsx index 5149beb96d..a1f6a02cc3 100644 --- a/apps/mobile/src/components/ui/AndroidAnchoredMenu.tsx +++ b/apps/mobile/src/components/ui/AndroidAnchoredMenu.tsx @@ -1,11 +1,12 @@ import type { MenuAction, MenuComponentProps } from "@react-native-menu/menu"; import type { ReactNode } from "react"; -import { use, useCallback, useEffect, useRef, useState } from "react"; +import { use, useRef, useState } from "react"; import type { StyleProp, ViewInstance, ViewStyle } from "react-native"; -import { BackHandler, Pressable, ScrollView, View } from "react-native"; +import { Pressable, ScrollView, View } from "react-native"; import { useKeyboardState } from "react-native-keyboard-controller"; import Animated, { FadeIn } from "react-native-reanimated"; +import { useHardwareBack } from "../../lib/backHandler"; import { deriveMenuHorizontalPlacement } from "../../lib/menu-placement"; import { WindowHingeContext } from "../../native/window-hinge-context"; import { OverlayPortal } from "./OverlayPortal"; @@ -47,46 +48,43 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { const [rootHeight, setRootHeight] = useState(null); const [overlay, setOverlay] = useState(null); const windowHinge = use(WindowHingeContext); - const anchorRef = useRef(null); + // State, not a ref: `open` reaches render-prop children, and the compiler + // bails out of the whole component when a ref might be read during render. + const [anchorNode, setAnchorNode] = useState(null); const overlayRef = useRef(null); const keyboardVisible = useKeyboardState((state) => state.isVisible); const keyboardHeight = useKeyboardState((state) => state.height); - const close = useCallback(() => { + const close = () => { setAnchor(null); setPath([]); setOverlay(null); setRootHeight(null); - }, []); + }; - const open = useCallback(() => { - anchorRef.current?.measureInWindow((x, y, width, height) => { + const open = () => { + anchorNode?.measureInWindow((x, y, width, height) => { setAnchor({ x, y, width, height, keyboardWasVisible: keyboardVisible }); }); - }, [keyboardVisible]); + }; - const measureOverlay = useCallback(() => { + const measureOverlay = () => { overlayRef.current?.measureInWindow((x, y, width, height) => { setOverlay({ x, y, width, height }); setRootHeight(height); }); - }, []); + }; - const submenuDepth = path.length; - useEffect(() => { - if (anchor === null || !anchor.keyboardWasVisible) { - return; + // Only the in-window (keyboard) presentation needs this; the native popup + // handles back itself. + useHardwareBack(anchor?.keyboardWasVisible === true, () => { + if (path.length > 0) { + setPath((current) => current.slice(0, -1)); + } else { + close(); } - const subscription = BackHandler.addEventListener("hardwareBackPress", () => { - if (submenuDepth > 0) { - setPath((current) => current.slice(0, -1)); - } else { - close(); - } - return true; - }); - return () => subscription.remove(); - }, [anchor, close, submenuDepth]); + return true; + }); const parent = path[path.length - 1] ?? null; const levelActions = (parent?.subactions ?? props.actions).filter( @@ -125,31 +123,33 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { const maxHeight = Math.min(opensDown ? spaceBelow : spaceAbove, 480); const placeable = local !== null && rootHeight !== null; - const onPressItem = useCallback( - (action: MenuAction) => { - if ((action.subactions?.length ?? 0) > 0) { - setPath((current) => [...current, action]); - return; - } - close(); - if (action.id !== undefined) { - props.onPressAction?.({ - nativeEvent: { event: action.id }, - } as Parameters>[0]); - } - }, - [close, props.onPressAction], - ); + const onPressItem = (action: MenuAction) => { + if ((action.subactions?.length ?? 0) > 0) { + setPath((current) => [...current, action]); + return; + } + close(); + if (action.id !== undefined) { + props.onPressAction?.({ + nativeEvent: { event: action.id }, + } as Parameters>[0]); + } + }; return ( <> {typeof props.children === "function" ? ( - + {props.children(open)} ) : ( [] as Array<() => void>); + vi.mock("@expo/ui/jetpack-compose", () => { const Slot = (props: { readonly children?: ReactNode }) =>
{props.children}
; - const Button = (props: { readonly children?: ReactNode; readonly onClick?: () => void }) => ( - - ); + const Button = (props: { readonly children?: ReactNode; readonly onClick?: () => void }) => { + if (props.onClick) renderedClicks.push(props.onClick); + return ; + }; return { AlertDialog: Object.assign(Slot, { Title: Slot, @@ -51,6 +56,7 @@ function mountHost() { afterEach(() => { for (const root of roots.splice(0)) act(() => root.unmount()); + renderedClicks.length = 0; document.body.innerHTML = ""; }); @@ -65,3 +71,34 @@ it("keeps presenting dialogs after an older root's Host unmounts", () => { expect(current.container.textContent).toContain("Saved"); }); + +it("presents a dialog requested before any Host mounted", () => { + Dialog.alert("Could not import shared content", undefined, [{ text: "Dismiss" }]); + + const { container } = mountHost(); + + expect(container.textContent).toContain("Could not import shared content"); +}); + +it("keeps a dialog raised while the activity is replaced", () => { + const previous = mountHost(); + act(() => Dialog.alert("Shared content imported", undefined, [{ text: "OK" }])); + const current = mountHost(); + roots.splice(roots.indexOf(previous.root), 1); + act(() => previous.root.unmount()); + + expect(current.container.textContent).toContain("Shared content imported"); +}); + +it("ignores a late action from a dialog that a newer one replaced", () => { + const { container } = mountHost(); + const olderAction = vi.fn(); + act(() => Dialog.alert("Older", undefined, [{ text: "OK", onPress: olderAction }])); + const olderClick = renderedClicks.at(-1)!; + act(() => Dialog.alert("Newer", undefined, [{ text: "OK" }])); + + act(() => olderClick()); + + expect(olderAction).toHaveBeenCalledOnce(); + expect(container.textContent).toContain("Newer"); +}); diff --git a/apps/mobile/src/components/ui/Dialog.android.tsx b/apps/mobile/src/components/ui/Dialog.android.tsx index bc93254c0f..af05275b7f 100644 --- a/apps/mobile/src/components/ui/Dialog.android.tsx +++ b/apps/mobile/src/components/ui/Dialog.android.tsx @@ -14,7 +14,7 @@ import { import { useAppearancePreferences } from "../../features/settings/appearance/AppearancePreferencesProvider"; import { useScaledTextRole } from "../../features/settings/appearance/useScaledTextRole"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useSyncExternalStore } from "react"; import { themeColorWithContrast } from "../../lib/mobileTheme"; import type { Alert, AlertButton } from "react-native"; import type { ConfirmDialogRequest, TextInputDialogRequest } from "./Dialog.types"; @@ -33,63 +33,78 @@ type DialogRequest = | { readonly kind: "confirm"; readonly request: ConfirmDialogRequest } | { readonly kind: "text-input"; readonly request: TextInputDialogRequest }; -let presentRequest: ((request: DialogRequest) => void) | null = null; +type PresentedDialog = DialogRequest & { readonly id: number }; + +// App services raise dialogs before the root mounts, so the request lives in +// the module and the Host renders whatever is current. One dialog shows at a +// time: a new request replaces the presented one. +let presented: PresentedDialog | null = null; +let nextPresentationId = 0; +const listeners = new Set<() => void>(); + +function setPresented(next: PresentedDialog | null) { + presented = next; + for (const listener of listeners) listener(); +} + +function submitRequest(dialog: DialogRequest) { + setPresented({ ...dialog, id: ++nextPresentationId }); +} + +/** Closes the dialog only if it is still the one the action came from. */ +function dismiss(id: number) { + if (presented?.id === id) setPresented(null); +} + +function subscribe(listener: () => void) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +function getPresented() { + return presented; +} export function alert(...[title, message, buttons, options]: Parameters): void { const actions = buttons?.length ? buttons.slice(0, 3) : [{ text: "OK" }]; const normalized = actions.map((action, index) => index === actions.length - 1 ? { ...action, text: action.text || "OK" } : action, ); - presentRequest?.({ + submitRequest({ kind: "alert", request: { title, message, buttons: normalized, options }, }); } export function confirm(request: ConfirmDialogRequest): void { - presentRequest?.({ kind: "confirm", request }); + submitRequest({ kind: "confirm", request }); } export function prompt(request: TextInputDialogRequest): void { - presentRequest?.({ kind: "text-input", request }); + submitRequest({ kind: "text-input", request }); } export function Host() { - const [presented, setPresented] = useState<(DialogRequest & { readonly id: number }) | null>( - null, - ); - const [inputValue, setInputValue] = useState(""); - const nextPresentationId = useRef(0); - useEffect(() => { - const present = (request: DialogRequest) => { - setInputValue(request.kind === "text-input" ? request.request.initialValue : ""); - setPresented({ ...request, id: ++nextPresentationId.current }); - }; - presentRequest = present; - return () => { - // When Android replaces the activity, the new root's Host registers before the old root - // unmounts. Clearing unconditionally would leave the live Host without dialogs. - if (presentRequest === present) presentRequest = null; - }; - }, []); + const presented = useSyncExternalStore(subscribe, getPresented, getPresented); - const handleCancel = useCallback(() => { - setPresented(null); - if (presented?.kind === "alert") presented.request.options?.onDismiss?.(); - else presented?.request.onCancel?.(); - }, [presented]); + const handleCancel = () => { + if (!presented) return; + dismiss(presented.id); + if (presented.kind === "alert") presented.request.options?.onDismiss?.(); + else presented.request.onCancel?.(); + }; - const handleConfirm = useCallback( - (nativeInputValue?: string) => { - setPresented(null); - if (presented?.kind === "confirm") { - presented.request.onConfirm(); - } else if (presented?.kind === "text-input") { - presented.request.onConfirm(nativeInputValue ?? inputValue); - } - }, - [inputValue, presented], - ); + const handleConfirm = (nativeInputValue?: string) => { + if (!presented) return; + dismiss(presented.id); + if (presented.kind === "confirm") { + presented.request.onConfirm(); + } else if (presented.kind === "text-input") { + presented.request.onConfirm(nativeInputValue ?? presented.request.initialValue); + } + }; if (presented?.kind === "alert") { return ( @@ -103,7 +118,7 @@ export function Host() { alertActions={presented.request.buttons ?? [{ text: "OK" }]} dismissable={presented.request.options?.cancelable ?? false} onAlertAction={(action) => { - setPresented(null); + dismiss(presented.id); action.onPress?.(); }} onCancel={handleCancel} @@ -120,7 +135,6 @@ export function Host() { presented.kind === "text-input" ? presented.request.initialValue : undefined } inputPlaceholder={presented.kind === "text-input" ? presented.request.placeholder : undefined} - onInputChange={setInputValue} onCancel={handleCancel} onConfirm={handleConfirm} /> @@ -212,7 +226,6 @@ function DialogPresentation(props: DialogPresentationProps) { singleLine value={inputState} selection={inputSelection} - onValueChange={props.onInputChange} textStyle={inputTypography} keyboardOptions={{ imeAction: "done" }} keyboardActions={{ @@ -267,7 +280,6 @@ interface DialogPresentationProps { >; readonly inputInitialValue?: string; readonly inputPlaceholder?: string; - readonly onInputChange?: (value: string) => void; readonly onCancel: () => void; readonly onConfirm: (inputValue?: string) => void; } diff --git a/apps/mobile/src/components/ui/Menu.tsx b/apps/mobile/src/components/ui/Menu.tsx index 54fb0bec95..17757fe84b 100644 --- a/apps/mobile/src/components/ui/Menu.tsx +++ b/apps/mobile/src/components/ui/Menu.tsx @@ -51,6 +51,7 @@ export function Menu(props: MenuProps) { let children = menuProps.children; if (props.shouldOpenOnLongPress && isValidElement(children)) { const child = children as ReactElement>; + /* oxlint-disable react/refs -- Cloning the trigger installs event callbacks; the refs are accessed when those callbacks run. */ children = cloneElement(child, { onTouchStart: (event) => { // onPressIn can repeat within one gesture when a finger leaves and re-enters the row. @@ -74,6 +75,7 @@ export function Menu(props: MenuProps) { child.props.onPress?.(event); }, }); + /* oxlint-enable react/refs */ menuProps.onMenuInteractionStart = () => { menuPress.current.isPreparing = true; props.onMenuInteractionStart?.(); diff --git a/apps/mobile/src/components/ui/OverlayPortal.tsx b/apps/mobile/src/components/ui/OverlayPortal.tsx index a7939cfa30..a4f07f4602 100644 --- a/apps/mobile/src/components/ui/OverlayPortal.tsx +++ b/apps/mobile/src/components/ui/OverlayPortal.tsx @@ -1,9 +1,8 @@ -import { type ReactNode, useEffect, useState } from "react"; +import { type ReactNode, useEffect, useState, useSyncExternalStore } from "react"; import { View } from "react-native"; // RN Modals move native window focus and close the keyboard. Keep composer menus in-window. type Entries = ReadonlyMap; -type Listener = (entries: Entries) => void; let nextKey = 0; @@ -11,24 +10,41 @@ function allocateKey() { return nextKey++; } const entries = new Map(); -const listeners = new Set(); +const listeners = new Set<() => void>(); +let snapshot: Entries = new Map(); function emit() { - const snapshot = new Map(entries); + snapshot = new Map(entries); for (const listener of listeners) { - listener(snapshot); + listener(); } } +function subscribe(listener: () => void) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +function getSnapshot() { + return snapshot; +} + export function OverlayPortal(props: { readonly children: ReactNode }) { const [key] = useState(allocateKey); - // Re-project every render so open menus reflect content and theme changes. + // RN has no createPortal, so projecting into the out-of-tree host has to + // happen after commit. No dependency array: re-project after every render so + // the host always shows the current content (menus re-render while open — + // drill-in, theme). useEffect(() => { entries.set(key, props.children); emit(); }); + // Removal is unmount-only: deleting on every re-projection would move the + // entry to the end of the map and restack it above overlays opened later. useEffect( () => () => { entries.delete(key); @@ -41,14 +57,7 @@ export function OverlayPortal(props: { readonly children: ReactNode }) { } export function OverlayPortalHost() { - const [current, setCurrent] = useState(() => new Map()); - - useEffect(() => { - listeners.add(setCurrent); - return () => { - listeners.delete(setCurrent); - }; - }, []); + const current = useSyncExternalStore(subscribe, getSnapshot, getSnapshot); if (current.size === 0) { return null; diff --git a/apps/mobile/src/components/ui/SliderRow.android.tsx b/apps/mobile/src/components/ui/SliderRow.android.tsx index ad774efa21..d9ab444f2c 100644 --- a/apps/mobile/src/components/ui/SliderRow.android.tsx +++ b/apps/mobile/src/components/ui/SliderRow.android.tsx @@ -1,7 +1,7 @@ import { Slider } from "@expo/ui/jetpack-compose"; import { fillMaxWidth } from "@expo/ui/jetpack-compose/modifiers"; import * as Haptics from "expo-haptics"; -import { useEffect, useRef } from "react"; +import { useRef } from "react"; import { View } from "react-native"; import { AppText as Text } from "./AppText"; @@ -12,11 +12,8 @@ import type { SliderRowProps } from "./SliderRow.types"; export function SliderRow(props: SliderRowProps) { const { colors } = useControlTheme(); - const draft = useRef(props.value); - const dragging = useRef(false); - useEffect(() => { - if (!dragging.current) draft.current = props.value; - }, [props.value]); + // The value of the drag in progress; null between drags. + const draft = useRef(null); const commit = (value: number) => { if (props.disabled) return; const next = Math.min( @@ -66,12 +63,12 @@ export function SliderRow(props: SliderRowProps) { inactiveTrackColor: colors["--color-secondary"], }} onValueChange={(value) => { - dragging.current = true; draft.current = value; }} onValueChangeFinished={() => { - dragging.current = false; - commit(draft.current); + const value = draft.current; + draft.current = null; + if (value !== null) commit(value); }} /> diff --git a/apps/mobile/src/components/ui/SliderRow.tsx b/apps/mobile/src/components/ui/SliderRow.tsx index 97b5b401a3..1df9e9279b 100644 --- a/apps/mobile/src/components/ui/SliderRow.tsx +++ b/apps/mobile/src/components/ui/SliderRow.tsx @@ -6,7 +6,7 @@ import { tint, } from "@expo/ui/swift-ui/modifiers"; import * as Haptics from "expo-haptics"; -import { useEffect, useRef } from "react"; +import { useRef } from "react"; import { View } from "react-native"; import { AppText } from "./AppText"; @@ -19,11 +19,9 @@ export type { SliderRowProps } from "./SliderRow.types"; export function SliderRow(props: SliderRowProps) { const { colors } = useControlTheme(); - const draft = useRef(props.value); + // The value of the drag in progress; null until the drag moves. + const draft = useRef(null); const editing = useRef(false); - useEffect(() => { - if (!editing.current) draft.current = props.value; - }, [props.value]); const commit = (value: number) => { if (props.disabled) return; const next = Math.min( @@ -60,13 +58,15 @@ export function SliderRow(props: SliderRowProps) { accessibilityValue(props.valueLabel), ]} onValueChange={(value) => { - draft.current = value; // Accessibility adjustments do not begin a touch editing session. - if (!editing.current) commit(value); + if (editing.current) draft.current = value; + else commit(value); }} onEditingChanged={(isEditing) => { editing.current = isEditing; - if (!isEditing) commit(draft.current); + const value = draft.current; + draft.current = null; + if (!isEditing && value !== null) commit(value); }} /> diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index b7c90720c7..2327f5cc13 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -219,6 +219,7 @@ const layerRelayTest = ManagedRelayLayer.layer("https://relay.example.test").pip const runBackgroundOperations = Effect.fn("TestRemoteRegistration.runBackgroundOperations")( function* () { let idlePasses = 0; + // oxlint-disable-next-line t3code/no-polling-loop-in-tests -- This harness drains mocked runtime jobs and their promise continuations, not external I/O or elapsed time. for (;;) { yield* Effect.promise(() => Promise.resolve()); const pending = backgroundRuntime.pending.shift(); diff --git a/apps/mobile/src/features/browser/PreviewStreamWebView.tsx b/apps/mobile/src/features/browser/PreviewStreamWebView.tsx index 66f9810413..2068ae282b 100644 --- a/apps/mobile/src/features/browser/PreviewStreamWebView.tsx +++ b/apps/mobile/src/features/browser/PreviewStreamWebView.tsx @@ -12,7 +12,6 @@ import { } from "@infinitus/client-runtime/preview/server-browser-stream"; import type { EnvironmentId, PreviewStreamHostSetup } from "@infinitus/contracts"; import { - useEffect, useEffectEvent, useImperativeHandle, useLayoutEffect, @@ -52,11 +51,13 @@ export interface PreviewPictureInPictureState { type NativeStreamBridge = { readonly ref?: Ref; readonly onUnauthorized: () => void; + /** Asks for fresh access after a refusal, once `delayMs` has passed. */ + readonly onRefused: (delayMs: number) => void; readonly onGone?: () => void; readonly onViewport?: (viewport: { readonly width: number; readonly height: number }) => void; readonly onControl?: (control: PreviewStreamControl | null) => void; readonly onPictureInPicture?: (state: PreviewPictureInPictureState, detail?: string) => void; - /** True while frames show, so commands reach the page. Pass a stable function. */ + /** True while frames show, so commands reach the page. */ readonly onStreamingChange?: (streaming: boolean) => void; readonly compact?: boolean; readonly pdfUri?: string | null; @@ -113,14 +114,21 @@ const MAX_REFUSALS = 3; export function PreviewStreamWebView( props: Omit & - Omit & { + Omit & { readonly environmentId: EnvironmentId; readonly paused?: boolean; }, ) { - const { access, error, refresh } = usePreviewStreamAccess(props.environmentId); + const { access, error, refresh, retryAfter } = usePreviewStreamAccess(props.environmentId); if (access && !props.paused) { - return ; + return ( + + ); } return ( | null>(null); - useEffect( - () => () => { - if (refreshTimer.current !== null) clearTimeout(refreshTimer.current); - }, - [], - ); const configuration = JSON.stringify({ access: props.access, threadId: props.threadId, @@ -188,14 +189,7 @@ function AuthorizedPreviewStream({ onUnauthorized={() => { const refusals = ++unauthorized.current; if (refusals >= MAX_REFUSALS) return false; - if (refreshTimer.current !== null) clearTimeout(refreshTimer.current); - refreshTimer.current = setTimeout( - () => { - refreshTimer.current = null; - props.onUnauthorized(); - }, - refusals === 1 ? 0 : 1_000 * 2 ** (refusals - 1), - ); + props.onRefused(refusals === 1 ? 0 : 1_000 * 2 ** (refusals - 1)); return true; }} onRetry={() => { @@ -232,7 +226,7 @@ function PreviewStreamDocumentView({ onRetry, onStreaming, onRecoverProcess, -}: Omit & { +}: Omit & { readonly configuration: string; readonly background: string; readonly onUnauthorized: () => boolean; @@ -241,13 +235,13 @@ function PreviewStreamDocumentView({ readonly onRecoverProcess: () => boolean; }) { const webView = useRef>(null); - const active = useRef(true); + const active = useRef(false); const failed = useRef(false); + const bootstrapTimer = useRef | null>(null); const [status, setStatus] = useState<"connecting" | "streaming" | "error">("connecting"); const [error, setError] = useState(null); const [gone, setGone] = useState(false); const [hostSetup, setHostSetup] = useState(null); - const [started, setStarted] = useState(false); const [control, setControl] = useState(null); const dialogRef = useRef(null); const [promptText, setPromptText] = useState(""); @@ -263,7 +257,6 @@ function PreviewStreamDocumentView({ ), ); }; - const controlChanged = useEffectEvent((next: PreviewStreamControl | null) => onControl?.(next)); const command = (input: PreviewStreamInput) => webView.current?.injectJavaScript( `window.T3PreviewStream?.command(${JSON.stringify(input)}); true;`, @@ -277,9 +270,15 @@ function PreviewStreamDocumentView({ }, () => Dialog.alert("Could not read the clipboard"), ); + const clearBootstrapTimer = () => { + if (bootstrapTimer.current === null) return; + clearTimeout(bootstrapTimer.current); + bootstrapTimer.current = null; + }; const fail = (message: string) => { if (!active.current || failed.current) return; failed.current = true; + clearBootstrapTimer(); webView.current?.injectJavaScript("window.T3PreviewStream?.stop(); true;"); onStreamingChange?.(false); setControl(null); @@ -291,11 +290,23 @@ function PreviewStreamDocumentView({ const bootstrapTimedOut = useEffectEvent(() => fail("Browser viewer could not start. Reconnect to try again."), ); - useEffect(() => { - if (started) return; - const timer = setTimeout(bootstrapTimedOut, 15_000); - return () => clearTimeout(timer); - }, [started]); + const streamEnded = useEffectEvent(() => { + onStreamingChange?.(false); + onControl?.(null); + }); + // The stream lives exactly as long as this view. + useLayoutEffect(() => { + active.current = true; + const view = webView.current; + bootstrapTimer.current = setTimeout(bootstrapTimedOut, 15_000); + return () => { + if (bootstrapTimer.current !== null) clearTimeout(bootstrapTimer.current); + bootstrapTimer.current = null; + active.current = false; + view?.injectJavaScript("window.T3PreviewStream?.stop(); true;"); + streamEnded(); + }; + }, []); const source = useMemo( () => ({ html: previewStreamDocument(configuration, previewStreamScript), @@ -309,16 +320,6 @@ function PreviewStreamDocumentView({ togglePictureInPicture: () => webView.current?.injectJavaScript("window.T3PreviewStream?.pictureInPicture(); true;"), })); - useLayoutEffect(() => { - active.current = true; - const view = webView.current; - return () => { - active.current = false; - view?.injectJavaScript("window.T3PreviewStream?.stop(); true;"); - }; - }, []); - useEffect(() => () => onStreamingChange?.(false), [onStreamingChange]); - useEffect(() => () => controlChanged(null), []); const processTerminated = () => { if (!active.current || failed.current) return; if (!onRecoverProcess()) fail("Browser viewer stopped. Reconnect to try again."); @@ -407,7 +408,7 @@ function PreviewStreamDocumentView({ onPictureInPicture?.(message, message.detail); return; case "status": - setStarted(true); + clearBootstrapTimer(); if (message.status === "error") { fail(message.detail ?? "Browser stream failed."); return; diff --git a/apps/mobile/src/features/browser/ThreadBrowserFloat.tsx b/apps/mobile/src/features/browser/ThreadBrowserFloat.tsx index f274d283fd..6b72b13912 100644 --- a/apps/mobile/src/features/browser/ThreadBrowserFloat.tsx +++ b/apps/mobile/src/features/browser/ThreadBrowserFloat.tsx @@ -2,7 +2,7 @@ import { useIsFocused } from "@react-navigation/native"; import type { EnvironmentId, PreviewSessionSnapshot, ThreadId } from "@infinitus/contracts"; import { previewPdfAssetUrl } from "@infinitus/shared/preview"; import * as Option from "effect/Option"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useState } from "react"; import { Pressable, View } from "react-native"; import Animated, { FadeIn, FadeOut, ReduceMotion } from "react-native-reanimated"; @@ -26,21 +26,30 @@ export function ThreadBrowserFloat(props: { readonly onOpen: (tabId: string) => void; }) { const [tabId, setTabId] = useState(null); - const known = useRef | null>(null); - useEffect(() => { - if (!props.loaded) return; - const previous = known.current; - known.current = new Map(props.tabs.map((tab) => [tab.tabId, tab.revealRequest?.id])); - if (previous === null) return; - const opened = props.tabs.findLast( - (tab) => - tab.reveal === true && - (tab.revealRequest - ? previous.get(tab.tabId) !== tab.revealRequest.id - : !previous.has(tab.tabId)), - ); + // The reveal requests already seen, so only a tab revealed after the first + // list pops the player. + const [seen, setSeen] = useState<{ + readonly tabs: ReadonlyArray; + readonly reveals: ReadonlyMap; + } | null>(null); + if (props.loaded && seen?.tabs !== props.tabs) { + setSeen({ + tabs: props.tabs, + reveals: new Map(props.tabs.map((tab) => [tab.tabId, tab.revealRequest?.id])), + }); + const previous = seen?.reveals; + const opened = + previous === undefined + ? undefined + : props.tabs.findLast( + (tab) => + tab.reveal === true && + (tab.revealRequest + ? previous.get(tab.tabId) !== tab.revealRequest.id + : !previous.has(tab.tabId)), + ); if (opened !== undefined) setTabId(opened.tabId); - }, [props.loaded, props.tabs]); + } const focused = useIsFocused(); const foreground = useAppForeground(); const tab = props.tabs.find((entry) => entry.tabId === tabId) ?? null; diff --git a/apps/mobile/src/features/cloud/ConnectOnboardingRouteScreen.tsx b/apps/mobile/src/features/cloud/ConnectOnboardingRouteScreen.tsx index 5fa7d3e14a..796bd360a2 100644 --- a/apps/mobile/src/features/cloud/ConnectOnboardingRouteScreen.tsx +++ b/apps/mobile/src/features/cloud/ConnectOnboardingRouteScreen.tsx @@ -5,8 +5,8 @@ import { Button } from "../../components/ui/Button"; import { ScreenScrollView as ScrollView } from "../../components/ui/ScreenScrollView"; import { useAuth } from "@clerk/expo"; -import { StackActions, useNavigation } from "@react-navigation/native"; -import { useEffect, useState } from "react"; +import { useNavigation } from "@react-navigation/native"; +import { useState } from "react"; import { Platform, RefreshControl, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -20,21 +20,9 @@ import { optOutOfConnectOnboarding } from "./connectOnboardingOptOut"; import { hasCloudPublicConfig } from "./publicConfig"; import { CONNECT_NAME, PRODUCT_NAME } from "@infinitus/shared/productName"; +// Only reachable with cloud config: onboarding is requested after a T3 Connect +// sign-in, and appLinking drops the deep link otherwise. export function ConnectOnboardingRouteScreen() { - const navigation = useNavigation(); - - // Without cloud config, deep links must exit to avoid an empty, undismissable sheet. - useEffect(() => { - if (hasCloudPublicConfig()) { - return; - } - if (navigation.canGoBack()) { - navigation.goBack(); - } else { - navigation.dispatch(StackActions.replace("Home")); - } - }, [navigation]); - return hasCloudPublicConfig() ? : null; } diff --git a/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx b/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx index 0b87240c23..47ce62f99d 100644 --- a/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx +++ b/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx @@ -87,6 +87,7 @@ export function ConnectionsNewRouteScreen({ useEffect(() => { const { host, code } = parsePairingUrl(connectionPairingUrl); + // oxlint-disable-next-line react/set-state-in-effect -- A new pairing URL reseeds the editable host and code fields. setHostInput(host); setCodeInput(code); }, [connectionPairingUrl]); @@ -97,12 +98,14 @@ export function ConnectionsNewRouteScreen({ } const { host, code } = parsePairingUrl(routePairingUrl); + // oxlint-disable-next-line react/set-state-in-effect -- A pairing URL from the route reseeds the editable host and code fields. setHostInput(host); setCodeInput(code); }, [routePairingUrl]); useEffect(() => { if (pairingConnectionError) { + // oxlint-disable-next-line react/set-state-in-effect -- A pairing failure reported by the connection runtime ends the submit. setIsSubmitting(false); } }, [pairingConnectionError]); diff --git a/apps/mobile/src/features/connection/PermissionUpdateNotice.tsx b/apps/mobile/src/features/connection/PermissionUpdateNotice.tsx deleted file mode 100644 index 62f1e6ded8..0000000000 --- a/apps/mobile/src/features/connection/PermissionUpdateNotice.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import * as Dialog from "../../components/ui/Dialog"; -import { useAtomValue } from "@effect/atom-react"; -import { sessionHasLegacyPermissions } from "@infinitus/contracts"; -import { Atom } from "effect/reactivity"; -import * as SecureStore from "expo-secure-store"; -import { useEffect, useMemo, useRef, useState } from "react"; - -import { whenProtectedDataAvailable } from "../../lib/protectedData"; -import { useEnvironments } from "../../state/environments"; -import { environmentSession } from "../../state/session"; - -const storageKey = "t3code.permission-update.v1"; -const dismissedThisLaunch = new Set(); -const shownThisLaunch = new Set(); - -export function PermissionUpdateNotice() { - const { environments } = useEnvironments(); - const [dismissed, setDismissed] = useState | null>(null); - const showing = useRef(false); - const affected = useAtomValue( - useMemo( - () => - Atom.make((get) => - environments.filter(({ environmentId }) => { - const session = get(environmentSession.sessionStateAtom(environmentId)); - return ( - session._tag === "Success" && - !session.waiting && - sessionHasLegacyPermissions(session.value) - ); - }), - ), - [environments], - ), - ); - - useEffect(() => { - let active = true; - void whenProtectedDataAvailable() - .then(() => SecureStore.getItemAsync(storageKey)) - .then((raw) => { - const value: unknown = raw === null ? [] : JSON.parse(raw); - if (active) - setDismissed( - new Set( - Array.isArray(value) - ? value.filter((id): id is string => typeof id === "string") - : [], - ), - ); - }) - .catch(() => { - if (active) setDismissed(new Set()); - }); - return () => { - active = false; - }; - }, []); - - useEffect(() => { - if (dismissed === null || showing.current) return; - const environment = affected.find( - ({ environmentId }) => !dismissed.has(environmentId) && !shownThisLaunch.has(environmentId), - ); - if (!environment) return; - showing.current = true; - shownThisLaunch.add(environment.environmentId); - const dismiss = () => { - dismissedThisLaunch.add(environment.environmentId); - const next = new Set([...dismissed, ...dismissedThisLaunch]); - showing.current = false; - setDismissed(next); - void SecureStore.setItemAsync(storageKey, JSON.stringify([...next])).catch(() => {}); - }; - Dialog.alert( - `Permissions have changed for ${environment.label}`, - "This connection still uses the old permissions, so some actions may no longer be available. Pair again using a new link with the permissions you need.", - [{ text: "Got it", onPress: dismiss }], - { cancelable: false }, - ); - }, [affected, dismissed]); - return null; -} diff --git a/apps/mobile/src/features/connection/permissionUpdateNotice.ts b/apps/mobile/src/features/connection/permissionUpdateNotice.ts new file mode 100644 index 0000000000..4353dc1561 --- /dev/null +++ b/apps/mobile/src/features/connection/permissionUpdateNotice.ts @@ -0,0 +1,86 @@ +import * as Dialog from "../../components/ui/Dialog"; +import { sessionHasLegacyPermissions } from "@infinitus/contracts"; +import { Atom } from "effect/reactivity"; +import * as SecureStore from "expo-secure-store"; + +import { whenProtectedDataAvailable } from "../../lib/protectedData"; +import { appAtomRegistry } from "../../state/atom-registry"; +import { projectEnvironmentPresentation } from "../../state/environments"; +import { environmentPresentations } from "../../state/presentation"; +import { environmentSession } from "../../state/session"; + +const storageKey = "t3code.permission-update.v1"; + +const legacyPermissionEnvironmentsAtom = Atom.make((get) => + [...get(environmentPresentations.presentationsAtom).entries()].flatMap( + ([environmentId, presentation]) => { + const session = get(environmentSession.sessionStateAtom(environmentId)); + return session._tag === "Success" && + !session.waiting && + sessionHasLegacyPermissions(session.value) + ? [projectEnvironmentPresentation(environmentId, presentation)] + : []; + }, + ), +).pipe(Atom.withLabel("mobile:legacy-permission-environments")); + +async function loadDismissedEnvironmentIds(): Promise> { + try { + await whenProtectedDataAvailable(); + const raw = await SecureStore.getItemAsync(storageKey); + const value: unknown = raw === null ? [] : JSON.parse(raw); + return new Set( + Array.isArray(value) ? value.filter((id): id is string => typeof id === "string") : [], + ); + } catch { + return new Set(); + } +} + +/** + * Tells the user, once per environment, that a paired session still carries + * the legacy permission set. App-lifetime; presents one dialog at a time. + */ +export function installPermissionUpdateNotice(): () => void { + let dismissed: Set | null = null; + const shownThisLaunch = new Set(); + let showing = false; + let disposed = false; + + const presentNext = () => { + if (disposed || dismissed === null || showing) return; + const known = dismissed; + const environment = appAtomRegistry + .get(legacyPermissionEnvironmentsAtom) + .find( + ({ environmentId }) => !known.has(environmentId) && !shownThisLaunch.has(environmentId), + ); + if (!environment) return; + showing = true; + shownThisLaunch.add(environment.environmentId); + const dismiss = () => { + known.add(environment.environmentId); + showing = false; + void SecureStore.setItemAsync(storageKey, JSON.stringify([...known])).catch(() => {}); + presentNext(); + }; + Dialog.alert( + `Permissions have changed for ${environment.label}`, + "This connection still uses the old permissions, so some actions may no longer be available. Pair again using a new link with the permissions you need.", + [{ text: "Got it", onPress: dismiss }], + { cancelable: false }, + ); + }; + + const unsubscribe = appAtomRegistry.subscribe(legacyPermissionEnvironmentsAtom, presentNext, { + immediate: true, + }); + void loadDismissedEnvironmentIds().then((ids) => { + dismissed = ids; + presentNext(); + }); + return () => { + disposed = true; + unsubscribe(); + }; +} diff --git a/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx b/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx index f34775f18e..28023c1b2d 100644 --- a/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx +++ b/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx @@ -9,7 +9,7 @@ import { import { useIsFocused, useNavigation, type StaticScreenProps } from "@react-navigation/native"; import { EnvironmentId, ThreadId } from "@infinitus/contracts"; import * as Cause from "effect/Cause"; -import { useCallback, useEffect, useRef, useState, type RefObject } from "react"; +import { useEffect, useRef, useState, type RefObject } from "react"; import * as Haptics from "expo-haptics"; import { Accelerometer } from "expo-sensors"; import { Pressable, StatusBar, View } from "react-native"; @@ -31,7 +31,8 @@ import { useAppForeground } from "../../lib/appState"; import { useHardwareBack } from "../../lib/backHandler"; import { deriveBottomControlInsets } from "../../lib/reserved-regions"; import { useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics"; -import { deviceEnvironment, refreshDeviceHubAccess, useDeviceHubAccess } from "../../state/device"; +import { uuidv4 } from "../../lib/uuid"; +import { deviceEnvironment, useDeviceHubAccess } from "../../state/device"; import { useEnvironmentQuery } from "../../state/query"; import { useAtomCommand } from "../../state/use-atom-command"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; @@ -102,13 +103,10 @@ export function DevicePreviewScreen({ const state = useEnvironmentQuery(deviceEnvironment.state({ environmentId, input: {} })); const previews = threadDevicePreviews(state.data, threadId); const preview = selectedThreadDevicePreview(previews, selectedKey); - const onInputConnected = useCallback( - async (connected: boolean) => { - setInputConnected(connected); - if (connected && !embedded) setControlsVisible(false); - }, - [embedded], - ); + const onInputConnected = async (connected: boolean) => { + setInputConnected(connected); + if (connected && !embedded) setControlsVisible(false); + }; useEffect(() => { if (focused && state.data !== null && previews.length === 0) onClose(); }, [focused, state.data, previews.length, onClose]); @@ -404,12 +402,9 @@ function OpenDevicePreview({ }) { const { session } = preview; const { themeVariables } = useAppearancePreferences(); - const { access, error, refresh } = useDeviceHubAccess(environmentId, session.hostId); - const onUnauthorized = async () => refreshDeviceHubAccess(environmentId); - useEffect(() => { - refreshDeviceHubAccess(environmentId); - return () => void onInputConnected(false); - }, [environmentId, onInputConnected]); + const [viewer] = useState(uuidv4); + const { access, error, refresh } = useDeviceHubAccess(environmentId, session.hostId, viewer); + const onUnauthorized = async () => refresh(); return access ? ( boolean; }) { const webView = useRef>(null); - const active = useRef(true); + const active = useRef(false); const failed = useRef(false); + const bootstrapTimer = useRef | null>(null); const [status, setStatus] = useState("connecting"); const [error, setError] = useState(null); - const [started, setStarted] = useState(false); + const clearBootstrapTimer = () => { + if (bootstrapTimer.current === null) return; + clearTimeout(bootstrapTimer.current); + bootstrapTimer.current = null; + }; const fail = (message: string) => { if (!active.current || failed.current) return; failed.current = true; + clearBootstrapTimer(); void onInputConnected(false); webView.current?.injectJavaScript("window.T3DeviceStream?.stop(); true;"); setError(message); @@ -111,11 +116,21 @@ function DeviceStreamDocumentView({ const bootstrapTimedOut = useEffectEvent(() => fail("Device viewer could not start. Reconnect to try again."), ); - useEffect(() => { - if (started) return; - const timer = setTimeout(bootstrapTimedOut, 15_000); - return () => clearTimeout(timer); - }, [started]); + const resetInput = useEffectEvent(() => void onInputConnected(false)); + // The stream lives exactly as long as this view. + useLayoutEffect(() => { + active.current = true; + resetInput(); + const view = webView.current; + bootstrapTimer.current = setTimeout(bootstrapTimedOut, 15_000); + return () => { + if (bootstrapTimer.current !== null) clearTimeout(bootstrapTimer.current); + bootstrapTimer.current = null; + active.current = false; + view?.injectJavaScript("window.T3DeviceStream?.stop(); true;"); + resetInput(); + }; + }, []); const source = { html: deviceStreamDocument(configuration, deviceStreamScript), baseUrl, @@ -131,16 +146,6 @@ function DeviceStreamDocumentView({ appSwitcher: () => command("appSwitcher"), rotate: () => command("rotate"), })); - const resetInput = useEffectEvent(() => void onInputConnected(false)); - useLayoutEffect(() => { - active.current = true; - resetInput(); - const view = webView.current; - return () => { - active.current = false; - view?.injectJavaScript("window.T3DeviceStream?.stop(); true;"); - }; - }, []); const processTerminated = () => { if (!active.current || failed.current) return; void onInputConnected(false); @@ -173,7 +178,7 @@ function DeviceStreamDocumentView({ else if (message?.type === "input") void onInputConnected(message.connected); else if (message?.type === "retry") onRetry(); else if (message?.type === "status") { - setStarted(true); + clearBootstrapTimer(); if (message.status === "error") fail(message.detail ?? "Device stream failed."); else { setStatus(message.status); diff --git a/apps/mobile/src/features/diagnostics/SettingsDiagnosticsRouteScreen.tsx b/apps/mobile/src/features/diagnostics/SettingsDiagnosticsRouteScreen.tsx index 2473813c5f..4187272934 100644 --- a/apps/mobile/src/features/diagnostics/SettingsDiagnosticsRouteScreen.tsx +++ b/apps/mobile/src/features/diagnostics/SettingsDiagnosticsRouteScreen.tsx @@ -3,7 +3,10 @@ import { ScreenScrollView as ScrollView } from "../../components/ui/ScreenScroll import * as Application from "expo-application"; import Constants from "expo-constants"; import * as Updates from "expo-updates"; -import { useEffect, useState } from "react"; +import { useAtomValue } from "@effect/atom-react"; +import * as Effect from "effect/Effect"; +import { AsyncResult, Atom } from "effect/reactivity"; +import { useState } from "react"; import { View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -27,6 +30,31 @@ type CrashLogState = | { readonly status: "unavailable" } | { readonly status: "ready"; readonly records: ReadonlyArray }; +// Not kept alive: each visit to the screen re-reads the log. +const crashLogAtom = Atom.make( + Effect.tryPromise(() => Updates.readLogEntriesAsync(LOG_WINDOW_MS)).pipe( + Effect.map((entries): CrashLogState => ({ + status: "ready", + records: parseStartupCrashRecords(entries), + })), + Effect.catch((error) => + Effect.sync((): CrashLogState => { + console.warn("[diagnostics] could not read the expo-updates log", error); + return { status: "unavailable" }; + }), + ), + ), +).pipe(Atom.withLabel("mobile:diagnostics-crash-log")); + +const UNAVAILABLE_CRASH_LOG_ATOM = Atom.make( + AsyncResult.success({ status: "unavailable" }), +); + +function useCrashLogState(): CrashLogState { + const result = useAtomValue(Updates.isEnabled ? crashLogAtom : UNAVAILABLE_CRASH_LOG_ATOM); + return AsyncResult.isSuccess(result) ? result.value : { status: "loading" }; +} + function appIdentity() { return { version: Constants.expoConfig?.version ?? "0.0.0", @@ -38,28 +66,9 @@ function appIdentity() { export function SettingsDiagnosticsRouteScreen() { const insets = useSafeAreaInsets(); - const [state, setState] = useState(() => - Updates.isEnabled ? { status: "loading" } : { status: "unavailable" }, - ); + const state = useCrashLogState(); const [copied, setCopied] = useState(false); - useEffect(() => { - if (!Updates.isEnabled) return; - let cancelled = false; - Updates.readLogEntriesAsync(LOG_WINDOW_MS) - .then((entries) => { - if (cancelled) return; - setState({ status: "ready", records: parseStartupCrashRecords(entries) }); - }) - .catch((error: unknown) => { - console.warn("[diagnostics] could not read the expo-updates log", error); - if (!cancelled) setState({ status: "unavailable" }); - }); - return () => { - cancelled = true; - }; - }, []); - const records = state.status === "ready" ? state.records : []; const copyReport = async () => { const ok = await tryCopyTextWithHaptic(formatStartupCrashReport(records, appIdentity()), { diff --git a/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts b/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts index 331d7ec7eb..b82570c11a 100644 --- a/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts +++ b/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts @@ -263,6 +263,7 @@ function useNativeReviewDiffPayload( cancelAnimationFrame(frame); } }; + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- A new resetKey redelivers an unchanged payload after the diff surface resets. }, [method, nativeRef, payload, resetKey, onFailure, onDelivered]); } @@ -387,6 +388,7 @@ function NativeReviewDiffContent( return null; } + /* oxlint-disable react/refs -- The native view receives this ref; only event callbacks access its current value. */ return createElement(RawNativeView, { ...nativeProps, refreshEnabled: props.onPullToRefresh !== undefined, @@ -399,6 +401,7 @@ function NativeReviewDiffContent( }, ref: nativeRef, }); + /* oxlint-enable react/refs */ } export function resolveNativeReviewDiffView(): ComponentType | null { diff --git a/apps/mobile/src/features/files/FileTreeBrowser.tsx b/apps/mobile/src/features/files/FileTreeBrowser.tsx index 9d18782f90..728bb3992c 100644 --- a/apps/mobile/src/features/files/FileTreeBrowser.tsx +++ b/apps/mobile/src/features/files/FileTreeBrowser.tsx @@ -153,6 +153,7 @@ export function FileTreeBrowser(props: { if (!controlledSelectedPath) { return; } + // oxlint-disable-next-line react/set-state-in-effect -- An externally selected file expands its ancestor folders; the user can collapse them afterwards. setExpandedPaths((current) => { const ancestors = ancestorPaths(controlledSelectedPath); if (ancestors.every((ancestor) => current.has(ancestor))) { diff --git a/apps/mobile/src/features/files/SourceFileSurface.tsx b/apps/mobile/src/features/files/SourceFileSurface.tsx index 19547c6a19..207242fcf9 100644 --- a/apps/mobile/src/features/files/SourceFileSurface.tsx +++ b/apps/mobile/src/features/files/SourceFileSurface.tsx @@ -2,7 +2,7 @@ import { settlePromise } from "@infinitus/client-runtime/state/runtime"; import { useAtomValue } from "@effect/atom-react"; import { AsyncResult } from "effect/reactivity"; import type { ComponentType } from "react"; -import { useEffect, useRef, useState } from "react"; +import { useLayoutEffect, useRef, useState } from "react"; import { FlatList, Platform, @@ -37,6 +37,7 @@ import { SourceText } from "@infinitus/mobile-source-text"; import { boundedSelectableSourceTokens, prepareSourceFileDocument } from "./source-file-document"; import { sourceHighlightAtom } from "./sourceHighlightingState"; +import { createSourceLineScroll } from "./source-file-scroll"; interface SourceFileSurfaceProps { readonly contents: string; @@ -243,20 +244,21 @@ function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) { const { normalizedContents, lines, status, targetIndex, tokens } = useSourceFileModel(props); const selectableTokens = props.selectable ? boundedSelectableSourceTokens(tokens) : null; const listRef = useRef>(null); + const [lineScroll] = useState(createSourceLineScroll); const { isPullRefreshing, handlePullToRefresh } = useSourceFileRefresh(props.onRefresh); const refreshControl = props.onRefresh ? ( void handlePullToRefresh()} /> ) : undefined; - useEffect(() => { + // Layout effect so a pending jump is cancelled in the commit that swaps the file. + useLayoutEffect(() => { if (targetIndex === null) { return; } - const frame = requestAnimationFrame(() => { - listRef.current?.scrollToIndex({ index: targetIndex, animated: false, viewPosition: 0.3 }); - }); - return () => cancelAnimationFrame(frame); - }, [props.path, targetIndex]); + lineScroll.scrollToLine(listRef.current, targetIndex); + return lineScroll.cancel; + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Opening another file at the same line index scrolls the new file to it. + }, [lineScroll, props.path, targetIndex]); const renderLine = ({ item, index }: { item: string; index: number }) => ( ); diff --git a/apps/mobile/src/features/files/source-file-scroll.test.ts b/apps/mobile/src/features/files/source-file-scroll.test.ts new file mode 100644 index 0000000000..98fc4b6a2a --- /dev/null +++ b/apps/mobile/src/features/files/source-file-scroll.test.ts @@ -0,0 +1,156 @@ +import { expect, it, vi } from "vite-plus/test"; + +import { createSourceLineScroll } from "./source-file-scroll"; + +const ROW_HEIGHT = 24; + +// Mirrors VirtualizedList.scrollToIndex without getItemLayout: indices past the highest +// measured cell fail, and unmeasured indices below it scroll to an averaged estimate. +function setup({ + lineCount = 1_203, + measuredThrough = 79, + rowHeight = (_index: number): number => ROW_HEIGHT, +} = {}) { + vi.useFakeTimers(); + vi.stubGlobal("requestAnimationFrame", (callback: () => void) => setTimeout(callback, 16)); + vi.stubGlobal("cancelAnimationFrame", (frame: ReturnType) => + clearTimeout(frame), + ); + const measured = new Set(); + const state = { + lineCount, + offset: 0, + measure(from: number, to: number) { + for (let index = from; index <= to; index += 1) measured.add(index); + }, + }; + state.measure(0, measuredThrough); + const offsetOf = (index: number) => { + let offset = 0; + for (let row = 0; row < index; row += 1) offset += rowHeight(row); + return offset; + }; + const averageItemLength = () => { + let total = 0; + for (const index of measured) total += rowHeight(index); + return total / measured.size; + }; + const scrollToIndex = vi.fn((params: { index: number }) => { + if (params.index < 0 || params.index >= state.lineCount) { + throw new Error(`scrollToIndex out of range: requested index ${params.index}`); + } + const highestMeasuredFrameIndex = Math.max(...measured); + if (params.index > highestMeasuredFrameIndex) { + scroll.onScrollToIndexFailed({ + index: params.index, + averageItemLength: averageItemLength(), + highestMeasuredFrameIndex, + }); + return; + } + state.offset = measured.has(params.index) + ? offsetOf(params.index) + : averageItemLength() * params.index; + }); + const scrollToOffset = vi.fn((params: { offset: number }) => { + state.offset = params.offset; + }); + const list = { + get props() { + return { data: { length: state.lineCount } }; + }, + scrollToIndex, + scrollToOffset, + }; + const scroll = createSourceLineScroll(); + return { + scroll, + list, + state, + offsetOf, + scrollToIndex, + scrollToOffset, + [Symbol.dispose]: () => { + vi.unstubAllGlobals(); + vi.useRealTimers(); + }, + }; +} + +it("lands on an already measured line", () => { + using t = setup(); + + t.scroll.scrollToLine(t.list, 40); + vi.runAllTimers(); + + expect(t.state.offset).toBe(t.offsetOf(40)); + expect(t.scrollToOffset).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); +}); + +it("jumps near an unmeasured wrapped line, then lands on it once rows measure", () => { + using t = setup({ rowHeight: (index) => (index >= 80 ? ROW_HEIGHT * 3 : ROW_HEIGHT) }); + + t.scroll.scrollToLine(t.list, 899); + expect(() => vi.advanceTimersToNextTimer()).not.toThrow(); + expect(t.state.offset).toBeGreaterThan(0); + + // The render batch after the estimated jump measures rows past the target first, so + // the next scrollToIndex is accepted against an averaged estimate. + t.state.measure(900, 979); + vi.advanceTimersToNextTimer(); + expect(t.state.offset).not.toBe(t.offsetOf(899)); + + t.state.measure(820, 899); + vi.runAllTimers(); + + expect(t.state.offset).toBe(t.offsetOf(899)); + expect(vi.getTimerCount()).toBe(0); +}); + +it("stops retrying when the target never measures", () => { + using t = setup({ rowHeight: (index) => (index === 899 ? ROW_HEIGHT * 400 : ROW_HEIGHT) }); + + t.scroll.scrollToLine(t.list, 899); + expect(() => vi.runAllTimers()).not.toThrow(); + + expect(t.scrollToIndex.mock.calls.length).toBeLessThanOrEqual(10); + expect(vi.getTimerCount()).toBe(0); +}); + +it("ignores a line outside the document", () => { + using t = setup({ lineCount: 12 }); + + expect(() => t.scroll.scrollToLine(t.list, 899)).not.toThrow(); + vi.runAllTimers(); + expect(t.scrollToIndex).not.toHaveBeenCalled(); + expect(t.scrollToOffset).not.toHaveBeenCalled(); +}); + +it("drops a pending jump when the document shrinks or the jump is cancelled", () => { + using t = setup(); + + t.scroll.scrollToLine(t.list, 899); + vi.advanceTimersToNextTimer(); + t.state.lineCount = 12; + expect(() => vi.runAllTimers()).not.toThrow(); + + t.state.lineCount = 1_203; + t.scroll.scrollToLine(t.list, 899); + vi.advanceTimersToNextTimer(); + const calls = t.scrollToIndex.mock.calls.length; + t.scroll.cancel(); + t.state.measure(80, 999); + vi.runAllTimers(); + expect(t.scrollToIndex).toHaveBeenCalledTimes(calls); +}); + +it("a drag before the first frame cancels the jump", () => { + using t = setup(); + + t.scroll.scrollToLine(t.list, 40); + t.scroll.cancel(); + vi.runAllTimers(); + + expect(t.scrollToIndex).not.toHaveBeenCalled(); +}); diff --git a/apps/mobile/src/features/files/source-file-scroll.ts b/apps/mobile/src/features/files/source-file-scroll.ts new file mode 100644 index 0000000000..eb0f3dbd63 --- /dev/null +++ b/apps/mobile/src/features/files/source-file-scroll.ts @@ -0,0 +1,66 @@ +interface SourceLineList { + readonly props: { readonly data?: { readonly length: number } | null }; + scrollToIndex(params: { index: number; animated: boolean; viewPosition: number }): void; + scrollToOffset(params: { offset: number; animated: boolean }): void; +} + +const RETRY_DELAY_MS = 100; +const MAX_RETRIES = 5; + +// Wrapped rows have no getItemLayout. VirtualizedList fails a jump past its highest measured +// row and silently estimates an unmeasured row below it, so re-aim until the budget runs out. +export function createSourceLineScroll() { + let list: SourceLineList | null = null; + let target: number | null = null; + let retries = 0; + let cancelPending: (() => void) | null = null; + + const cancel = () => { + cancelPending?.(); + cancelPending = null; + target = null; + }; + + const retry = () => { + if (retries >= MAX_RETRIES) { + target = null; + return; + } + retries += 1; + const timer = setTimeout(attempt, RETRY_DELAY_MS); + cancelPending = () => clearTimeout(timer); + }; + + const attempt = () => { + cancelPending = null; + if (target === null || !list || target >= (list.props.data?.length ?? 0)) { + target = null; + return; + } + list.scrollToIndex({ index: target, animated: false, viewPosition: 0.3 }); + // A failed jump has already scheduled its retry. + if (cancelPending === null) retry(); + }; + + return { + scrollToLine(nextList: SourceLineList | null, index: number) { + cancel(); + list = nextList; + if (!Number.isInteger(index) || index < 0) return; + target = index; + retries = 0; + const frame = requestAnimationFrame(attempt); + cancelPending = () => cancelAnimationFrame(frame); + }, + onScrollToIndexFailed(info: { + index: number; + averageItemLength: number; + highestMeasuredFrameIndex: number; + }) { + if (info.index !== target || !list) return; + list.scrollToOffset({ offset: info.averageItemLength * info.index, animated: false }); + retry(); + }, + cancel, + }; +} diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index b194348ec1..1acddc0186 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -485,7 +485,7 @@ export function HomeScreen(props: HomeScreenProps) { bumpSnoozeWakeTick((tick) => tick + 1); }, delayMs); return () => clearTimeout(id); - // snoozeWakeTick re-arms clamped timers even when the wake date exceeds 32-bit setTimeout and stays unchanged. + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- snoozeWakeTick re-arms clamped timers even when the wake date exceeds 32-bit setTimeout and stays unchanged. }, [nextSnoozeWakeAt, snoozeWakeTick]); // Called as `props.onSelectThread(...)`, the compiler would cache this handler on the whole // props object, which is new on every render of Home's parent. @@ -592,9 +592,6 @@ export function HomeScreen(props: HomeScreenProps) { timeLabel={item.timeLabel} showTrailingDivider={item.showTrailingDivider} project={projectByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ?? null} - projectTitle={v2ProjectTitleByProjectKey.get( - scopedProjectKey(thread.environmentId, thread.projectId), - )} providerInstance={resolveProviderInstance(thread)} providers={providersByEnvironmentId.get(thread.environmentId)} environmentLabel={ @@ -736,6 +733,7 @@ export function HomeScreen(props: HomeScreenProps) { {listHeader} {Platform.OS === "ios" && workspace ? ( ({ key: scope.key, label: scope.title }))} selectedEnvironmentId={props.selectedEnvironmentId} diff --git a/apps/mobile/src/features/home/ThreadListFilters.tsx b/apps/mobile/src/features/home/ThreadListFilters.tsx index 93f9e7f68c..a7d6894c35 100644 --- a/apps/mobile/src/features/home/ThreadListFilters.tsx +++ b/apps/mobile/src/features/home/ThreadListFilters.tsx @@ -12,7 +12,10 @@ export function ThreadListFilters( | "selectedProjectKey" | "onEnvironmentChange" | "onProjectChange" - >, + > & { + /** List padding the scroller bleeds through so pills start where row content does. */ + readonly gutter: number; + }, ) { const selectedEnvironment = props.environments.find( (environment) => environment.environmentId === props.selectedEnvironmentId, @@ -24,7 +27,8 @@ export function ThreadListFilters( - {props.children(close)} + { + /* oxlint-disable react/refs -- The render callback receives a close/open handler; only invoking that handler accesses the ref. */ + props.children(close) + /* oxlint-enable react/refs */ + } diff --git a/apps/mobile/src/features/keyboard/CommandPalette.tsx b/apps/mobile/src/features/keyboard/CommandPalette.tsx index cb664ae305..394e80297b 100644 --- a/apps/mobile/src/features/keyboard/CommandPalette.tsx +++ b/apps/mobile/src/features/keyboard/CommandPalette.tsx @@ -507,6 +507,9 @@ export function CommandPalette(props: { results.findIndex((item) => item.key === selection), ); const selectedKey = results[selectedIndex]?.key; + // The native list follows the highlighted row. Key presses move it, but so do + // live thread updates and late message matches that reorder or drop the + // selected row under a kept selection, so no single handler owns the scroll. useEffect(() => { if (selectedIndex === 0) { // Centering before the list measures its height scrolls half the first row out of view. @@ -517,26 +520,30 @@ export function CommandPalette(props: { }, [selectedIndex, selectedKey]); const dismissed = useRef(false); + const dismissFallback = useRef | null>(null); const handleDismissed = useCallback(() => { + if (dismissFallback.current !== null) clearTimeout(dismissFallback.current); + dismissFallback.current = null; if (dismissed.current) return; dismissed.current = true; // Present navigation sheets only after UIKit has dismissed this modal. props.onClose(); pendingAction.current?.(); }, [props]); - - // iOS may drop onDismiss during presentation or competing sheets; a fallback clears stale palette state. - useEffect(() => { - if (visible) return; - const fallback = setTimeout(handleDismissed, 400); - return () => clearTimeout(fallback); - }, [visible, handleDismissed]); + useEffect( + () => () => { + if (dismissFallback.current !== null) clearTimeout(dismissFallback.current); + }, + [], + ); function close(run?: () => void) { if (closing.current) return; closing.current = true; pendingAction.current = run ?? null; setVisible(false); + // iOS may drop onDismiss during presentation or competing sheets; a fallback clears stale palette state. + dismissFallback.current = setTimeout(handleDismissed, 400); } function onCommand(command: HardwareKeyboardCommand) { @@ -606,6 +613,8 @@ export function CommandPalette(props: { onChangeText={(value) => { setQuery(value); setSelection(null); + // The follow effect stays idle when the top row and its key are unchanged, + // so a touch-scrolled list would otherwise keep its old offset. listRef.current?.scrollToOffset({ offset: 0, animated: false }); }} returnKeyType="go" diff --git a/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx b/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx index f457339941..c9291079ea 100644 --- a/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx +++ b/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx @@ -119,6 +119,7 @@ export function HardwareKeyboardCommandProvider({ if (pathname.split("/")[4] !== "terminal") commands.add("copyThreadReference"); } return [...commands]; + // oxlint-disable-next-line react/memo-dependencies -- registrationVersion invalidates the snapshot of the external keyboard-command registry. }, [activeThreadRef, pathname, registrationVersion, navigation]); const onCommand = useCallback( diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index bc24812120..3e88e408e3 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -148,6 +148,7 @@ export function useRegisterWorkspaceInspector(render: (() => ReactNode) | undefi if (focusedRef.current) { syncRegistration(); } + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Changed pane content replaces its existing native workspace registration. }, [wrappedRender]); useEffect( diff --git a/apps/mobile/src/features/layout/workspace-inspector-pane.tsx b/apps/mobile/src/features/layout/workspace-inspector-pane.tsx index fea0b3964f..68a57a2298 100644 --- a/apps/mobile/src/features/layout/workspace-inspector-pane.tsx +++ b/apps/mobile/src/features/layout/workspace-inspector-pane.tsx @@ -1,7 +1,9 @@ import { useEffect, useRef, useState, type ReactNode } from "react"; import Animated, { + cubicBezier, runOnJS, useAnimatedStyle, + useReducedMotion, useSharedValue, withTiming, type SharedValue, @@ -12,6 +14,9 @@ import { RenderErrorBoundary, RenderFailureView } from "../../components/RenderE import { WORKSPACE_PANE_TIMING } from "./workspace-pane-animation"; import { WorkspacePaneDivider } from "./workspace-pane-divider"; +// CSS form of WORKSPACE_PANE_TIMING's Easing.inOut(Easing.cubic). +const PANE_EASING = cubicBezier(0.65, 0, 0.35, 1); + /** Pass layout props to avoid an import cycle with AdaptiveWorkspaceLayout. */ export function WorkspaceInspectorPane(props: { readonly pathname: string; @@ -32,7 +37,7 @@ export function WorkspaceInspectorPane(props: { const inspectorProgress = useSharedValue(inspectorVisible ? 1 : 0); const { renderedInspectorWidth } = props; - const renderedContentWidth = useSharedValue(inspectorWidth ?? 0); + const reduceMotion = useReducedMotion(); const onClosed = props.onClosed; useEffect(() => { @@ -56,15 +61,6 @@ export function WorkspaceInspectorPane(props: { resizing, ]); - useEffect(() => { - const targetWidth = inspectorWidth ?? 0; - if (!inspectorVisible || resizing) { - renderedContentWidth.set(targetWidth); - return; - } - renderedContentWidth.set(withTiming(targetWidth, WORKSPACE_PANE_TIMING)); - }, [inspectorVisible, inspectorWidth, renderedContentWidth, resizing]); - const inspectorStyle = useAnimatedStyle( () => ({ opacity: inspectorProgress.value, @@ -73,7 +69,14 @@ export function WorkspaceInspectorPane(props: { }), [], ); - const inspectorContentStyle = useAnimatedStyle(() => ({ width: renderedContentWidth.value }), []); + // A hidden or resizing pane takes its width at once; a visible one eases with the column. + const inspectorContentStyle = { + width: inspectorWidth ?? 0, + transitionProperty: "width", + transitionDuration: + !inspectorVisible || resizing || reduceMotion ? 0 : WORKSPACE_PANE_TIMING.duration, + transitionTimingFunction: PANE_EASING, + } as const; const beginResize = () => { resizeStartWidth.current = inspectorWidth ?? 0; setResizing(true); diff --git a/apps/mobile/src/features/projects/AddProjectScreen.test.ts b/apps/mobile/src/features/projects/AddProjectScreen.test.ts index 671c005a2c..bbefa2db87 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.test.ts +++ b/apps/mobile/src/features/projects/AddProjectScreen.test.ts @@ -2,25 +2,61 @@ import * as NodeFS from "node:fs"; import * as NodeFSP from "node:fs/promises"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; +import * as NodeModule from "node:module"; +import type { EnvironmentConnectionPhase } from "@infinitus/client-runtime/connection"; +import { workspaceDetailRouter } from "../workspace/workspace-detail-selection"; +import type { NavigationState } from "@react-navigation/native"; import { AuthOrchestrationOperateScope, AuthSourceControlWriteScope } from "@infinitus/contracts"; import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/reactivity"; -import { isValidElement, type ReactNode } from "react"; +import { act, createElement, isValidElement, type ReactNode } from "react"; +import { createRoot } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; const state = vi.hoisted(() => ({ scopes: new Set(), baseDirectory: "", projects: [] as string[], + existingProjects: [] as { + environmentId: string; + id: string; + title: string; + workspaceRoot: string; + }[], + createResult: null as Promise | null, + cloneResult: null as Promise | null, + createNewResult: null as Promise | null, + startCloneResult: null as Promise | null, + connectionStates: {} as Record, + newProjectsRoot: null as string | null, + supportsCloneTracking: false, + waitForProject: vi.fn(), + dispatch: vi.fn(), + alert: vi.fn(), + navigationState: undefined as NavigationState | undefined, + environments: [{ environmentId: "environment", environmentLabel: "Environment" }], })); -vi.mock("react", async (importOriginal) => ({ - ...(await importOriginal()), - useCallback: (callback: unknown) => callback, - useMemo: (factory: () => unknown) => factory(), - useState: (initial: unknown) => [typeof initial === "function" ? initial() : initial, () => {}], - useRef: (current: unknown) => ({ current }), - useEffect: () => {}, +function loadRouters() { + const requireNavigation = NodeModule.createRequire( + NodeModule.createRequire(import.meta.url).resolve("@react-navigation/native/package.json"), + ); + return requireNavigation("@react-navigation/routers") as { + StackRouter: typeof import("@react-navigation/native").StackRouter; + StackActions: typeof import("@react-navigation/native").StackActions; + CommonActions: typeof import("@react-navigation/native").CommonActions; + }; +} + +vi.mock("../workspace/useSelectWorkspaceDetail", async () => { + const { selectWorkspaceDetail } = await import("../workspace/workspace-detail-selection"); + return { + useSelectWorkspaceDetail: () => (detail: Parameters[0]) => + state.dispatch(selectWorkspaceDetail(detail)), + }; +}); +vi.mock("../../native/StackHeader", () => ({ + nativeHeaderScrollEdgeEffects: () => undefined, })); vi.mock("../../components/ui/NavigationRow", () => ({ NavigationRow: "NavigationRow" })); vi.mock("../../components/ui/Button", () => ({ Button: "Button" })); @@ -30,15 +66,30 @@ vi.mock("../../components/ui/ThemedSwitch", () => ({ ThemedSwitch: "Switch" })); vi.mock("react-native", () => ({ Platform: { OS: "ios" }, ActivityIndicator: "ActivityIndicator", - Alert: { alert: () => {} }, + Alert: { alert: state.alert }, Pressable: "Pressable", ScrollView: "ScrollView", View: "View", })); vi.mock("@react-navigation/native", () => ({ - useNavigation: () => ({ dispatch: () => {} }), + useNavigation: () => ({ + dispatch: state.dispatch, + getState: () => + state.navigationState ?? { + index: 4, + routes: [ + { key: "home", name: "Home" }, + { key: "draft", name: "Thread", params: { draft: {} } }, + { key: "picker", name: "NewTaskProject" }, + { key: "add", name: "AddProject" }, + { key: "local", name: "AddProjectLocal" }, + ], + }, + }), CommonActions: { reset: (input: unknown) => input }, - StackActions: {}, + StackActions: { + push: (name: string, params: unknown) => ({ type: "PUSH", payload: { name, params } }), + }, })); vi.mock("react-native-safe-area-context", () => ({ useSafeAreaInsets: () => ({ bottom: 0 }), @@ -56,13 +107,18 @@ vi.mock("../../state/session", () => ({ readEnvironmentScope: (_environmentId: string, scope: string) => state.scopes.has(scope), })); vi.mock("../../state/entities", () => ({ - useProjects: () => [], + useProjects: () => state.existingProjects, + waitForProject: state.waitForProject, useServerConfigs: () => new Map([ [ "environment", { - environment: { platform: { os: "linux" }, capabilities: {} }, + environment: { + platform: { os: "linux" }, + capabilities: { projectCloneTracking: state.supportsCloneTracking }, + }, + newProjectsRoot: state.newProjectsRoot, settings: { addProjectBaseDirectory: state.baseDirectory }, }, ], @@ -71,12 +127,15 @@ vi.mock("../../state/entities", () => ({ vi.mock("../../state/use-remote-environment-registry", () => ({ useRemoteEnvironmentRuntime: () => ({ connectionState: "connected" }), useRemoteConnectionStatus: () => ({ - connectedEnvironments: [{ environmentId: "environment", connectionState: "connected" }], + connectedEnvironments: state.environments.map((environment) => ({ + environmentId: environment.environmentId, + connectionState: state.connectionStates[environment.environmentId] ?? "connected", + })), }), useSavedRemoteConnections: () => ({ - savedConnectionsById: { - connection: { environmentId: "environment", environmentLabel: "Environment" }, - }, + savedConnectionsById: Object.fromEntries( + state.environments.map((environment) => [environment.environmentId, environment]), + ), }), })); vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: (command: unknown) => command })); @@ -88,7 +147,10 @@ vi.mock("../../state/presentation", () => ({ vi.mock("../../state/filesystem", () => ({ filesystemEnvironment: {} })); vi.mock("../../state/sourceControl", () => ({ sourceControlEnvironment: { + discovery: (input: unknown) => input, + startProjectClone: () => state.startCloneResult, cloneRepository: async ({ input }: { input: { destinationPath: string } }) => { + if (state.cloneResult) return state.cloneResult; NodeFS.mkdirSync(input.destinationPath); return AsyncResult.success({ cwd: input.destinationPath }); }, @@ -96,41 +158,90 @@ vi.mock("../../state/sourceControl", () => ({ })); vi.mock("../../state/projects", () => ({ projectEnvironment: { - create: async ({ input }: { input: { workspaceRoot: string } }) => { + createNew: () => state.createNewResult, + create: ({ input }: { input: { workspaceRoot: string } }) => { if (!state.scopes.has(AuthOrchestrationOperateScope)) { - return AsyncResult.failure(Cause.fail(new Error("Project creation denied"))); + return Promise.resolve( + AsyncResult.failure(Cause.fail(new Error("Project creation denied"))), + ); } state.projects.push(input.workspaceRoot); - return AsyncResult.success(undefined); + return state.createResult ?? Promise.resolve(AsyncResult.success(undefined)); }, }, })); -import { AddProjectDestinationScreen } from "./AddProjectScreen"; +vi.mock("../../state/use-composer-drafts", () => ({ + waitForComposerDraftsLoaded: async () => {}, + createNewTaskDraft: () => "prompt-draft", + setComposerDraftText: () => {}, +})); + +import { + AddProjectNewScreen, + AddProjectDestinationScreen, + AddProjectLocalFolderScreen, + AddProjectSourceScreen, +} from "./AddProjectScreen"; +import { AddProjectSourceRoute } from "./AddProjectSourceRoute"; -function findAction(node: ReactNode, label: string): (() => unknown) | null { +function findAction( + node: ReactNode, + label: string, + includeDisabled = false, +): (() => unknown) | null { if (Array.isArray(node)) { for (const child of node) { - const action = findAction(child, label); + const action = findAction(child, label, includeDisabled); if (action) return action; } return null; } - if (!isValidElement<{ label?: string; onPress?: () => unknown; children?: ReactNode }>(node)) { + if ( + !isValidElement<{ + label?: string; + title?: string; + onPress?: () => unknown; + disabled?: boolean; + children?: ReactNode; + }>(node) + ) { return null; } - if (node.props.label === label) return node.props.onPress ?? null; - return findAction(node.props.children, label); + if ((node.props.label ?? node.props.title) === label) { + return node.props.disabled && !includeDisabled ? null : (node.props.onPress ?? null); + } + return findAction(node.props.children, label, includeDisabled); +} + +const mountedRoots: ReturnType[] = []; +afterEach(() => { + for (const root of mountedRoots.splice(0)) act(() => root.unmount()); +}); +function renderScreen(render: () => ReactNode) { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + const root = createRoot(document.createElement("div")); + mountedRoots.push(root); + let screen: ReactNode; + function Observer() { + screen = render(); + return null; + } + act(() => root.render(createElement(Observer))); + return screen; } function cloneAction() { const action = findAction( - AddProjectDestinationScreen({ - environmentId: "environment", - remoteUrl: "https://example.com/repo.git", - repositoryName: "repo", - }), + renderScreen(() => + AddProjectDestinationScreen({ + environmentId: "environment", + remoteUrl: "https://example.com/repo.git", + repositoryName: "repo", + }), + ), "Clone project", + true, ); if (!action) throw new Error("Clone action missing"); return action; @@ -143,6 +254,7 @@ describe("clone project permissions", () => { ); state.scopes = new Set([AuthSourceControlWriteScope]); state.projects = []; + state.createResult = null; }); afterEach(async () => { @@ -169,3 +281,408 @@ describe("clone project permissions", () => { }, ); }); + +function mountProjectFlow( + withDraft: boolean, + withChooser = true, + withPullRequest = false, + screenName = "AddProjectLocal", +) { + const { StackRouter, StackActions, CommonActions } = loadRouters(); + const base = StackRouter({}); + const router = { ...base, ...workspaceDetailRouter(base) }; + const options = { + routeNames: [ + "Home", + "Thread", + "PullRequest", + "PullRequestFile", + "NewTaskProject", + "AddProject", + "AddProjectLocal", + "AddProjectNew", + "AddProjectDestination", + ], + routeParamList: {}, + routeGetIdList: {}, + }; + let current = router.getInitialState(options); + const home = current.routes[0]!; + const apply = (action: Parameters[1]) => { + const next = router.getStateForAction(current, action, options); + if (!next || next.stale !== false) throw new Error("Navigation action rejected"); + current = next; + state.navigationState = current; + }; + if (withPullRequest) { + apply(StackActions.push("PullRequest", { number: 123 })); + apply(StackActions.push("PullRequestFile", { path: "file.ts" })); + } + const underlyingRoutes = current.routes; + if (withDraft) { + apply(StackActions.push("Thread", { draft: { projectId: "previous-project" } })); + } + const draftKey = withDraft ? current.routes[current.index]!.key : null; + for (const name of [...(withChooser ? ["NewTaskProject"] : []), "AddProject", screenName]) { + apply(StackActions.push(name)); + } + state.navigationState = current; + state.dispatch.mockImplementation((action) => + apply( + action.type === "PUSH" || action.type === "SELECT_WORKSPACE_DETAIL" + ? action + : CommonActions.reset(action), + ), + ); + return { + home, + draftKey, + underlyingRoutes, + flowRoutes: current.routes.slice(underlyingRoutes.length), + getState: () => current, + back: () => apply(StackActions.pop()), + setParams: (params: object) => apply(CommonActions.setParams(params)), + push: (name: string) => apply(StackActions.push(name)), + }; +} + +describe("add project source environment", () => { + it("blocks local add on a requested unavailable host until reconnect or manual choice", async () => { + state.scopes = new Set([AuthOrchestrationOperateScope]); + state.dispatch.mockReset(); + state.environments = [ + { environmentId: "environment", environmentLabel: "Environment" }, + { environmentId: "another-environment", environmentLabel: "Another environment" }, + ]; + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + const root = createRoot(document.createElement("div")); + let screen: ReactNode; + function SourceObserver() { + screen = AddProjectSourceScreen({ environmentId: "environment" }); + return null; + } + try { + for (const phase of ["available", "offline", "error"] as const) { + state.connectionStates = { environment: phase }; + await act(() => root.render(createElement(SourceObserver))); + expect(findAction(screen, "Local folder")).toBeNull(); + expect(state.dispatch).not.toHaveBeenCalled(); + } + state.connectionStates = {}; + await act(() => root.render(createElement(SourceObserver))); + const reconnectedLocal = findAction(screen, "Local folder"); + if (!reconnectedLocal) throw new Error("Reconnected local folder action missing"); + reconnectedLocal(); + expect(state.dispatch).toHaveBeenLastCalledWith({ + type: "PUSH", + payload: { name: "AddProjectLocal", params: { environmentId: "environment" } }, + }); + state.connectionStates = { environment: "offline" }; + await act(() => root.render(createElement(SourceObserver))); + expect(findAction(screen, "Local folder")).toBeNull(); + const select = findAction(screen, "Another environment"); + if (!select) throw new Error("Connected environment selection missing"); + await act(() => select()); + const local = findAction(screen, "Local folder"); + if (!local) throw new Error("Manually selected local folder action missing"); + local(); + expect(state.dispatch).toHaveBeenLastCalledWith({ + type: "PUSH", + payload: { name: "AddProjectLocal", params: { environmentId: "another-environment" } }, + }); + } finally { + await act(() => root.unmount()); + state.connectionStates = {}; + state.environments = [{ environmentId: "environment", environmentLabel: "Environment" }]; + } + }); + + it("keeps a manual host selection when the requested host finishes connecting", async () => { + state.scopes = new Set([AuthOrchestrationOperateScope]); + state.dispatch.mockReset(); + const navigation = mountProjectFlow(false, true, false, "AddProject"); + state.environments = [ + { environmentId: "environment", environmentLabel: "Environment" }, + { environmentId: "another-environment", environmentLabel: "Another environment" }, + ]; + state.connectionStates = { environment: "connecting" }; + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + const root = createRoot(document.createElement("div")); + let screen: ReactNode; + function SourceObserver() { + screen = AddProjectSourceScreen({ environmentId: "environment" }); + return null; + } + try { + await act(() => root.render(createElement(SourceObserver))); + expect(findAction(screen, "Local folder")).toBeNull(); + expect(navigation.getState().routes.at(-1)?.name).toBe("AddProject"); + const select = findAction(screen, "Another environment"); + if (!select) throw new Error("Connected environment selection missing"); + await act(() => select()); + state.connectionStates = {}; + await act(() => root.render(createElement(SourceObserver))); + const local = findAction(screen, "Local folder"); + if (!local) throw new Error("Local folder action missing"); + local(); + expect(navigation.getState().routes.at(-1)).toMatchObject({ + name: "AddProjectLocal", + params: { environmentId: "another-environment" }, + }); + } finally { + await act(() => root.unmount()); + state.connectionStates = {}; + state.environments = [{ environmentId: "environment", environmentLabel: "Environment" }]; + } + }); + + it("follows warm environment requests while preserving manual selection between requests", async () => { + state.scopes = new Set([AuthOrchestrationOperateScope]); + state.dispatch.mockReset(); + const navigation = mountProjectFlow(false, true, false, "AddProject"); + state.environments = [ + { environmentId: "environment", environmentLabel: "Environment" }, + { environmentId: "another-environment", environmentLabel: "Another environment" }, + ]; + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + const root = createRoot(document.createElement("div")); + let actions: { local: () => unknown; selectEnvironment: () => unknown } | undefined; + function SourceRouteObserver(props: { environmentId?: string | string[] }) { + const route = AddProjectSourceRoute({ route: { params: props } }); + const screen = AddProjectSourceScreen(route.props); + const local = findAction(screen, "Local folder"); + const selectEnvironment = findAction(screen, "Environment"); + if (!local || !selectEnvironment) throw new Error("Source actions missing"); + actions = { local, selectEnvironment }; + return null; + } + const openLocalFolder = (environmentId: string) => { + state.dispatch.mockClear(); + actions!.local(); + expect(navigation.getState().routes.at(-1)).toMatchObject({ + name: "AddProjectLocal", + params: { environmentId }, + }); + navigation.back(); + }; + try { + await act(() => + root.render(createElement(SourceRouteObserver, { environmentId: "environment" })), + ); + openLocalFolder("environment"); + await act(() => + root.render(createElement(SourceRouteObserver, { environmentId: "another-environment" })), + ); + openLocalFolder("another-environment"); + await act(() => actions!.selectEnvironment()); + await act(() => + root.render(createElement(SourceRouteObserver, { environmentId: ["another-environment"] })), + ); + openLocalFolder("environment"); + await act(() => + root.render(createElement(SourceRouteObserver, { environmentId: "environment" })), + ); + await act(() => + root.render(createElement(SourceRouteObserver, { environmentId: "another-environment" })), + ); + openLocalFolder("another-environment"); + } finally { + await act(() => root.unmount()); + state.environments = [{ environmentId: "environment", environmentLabel: "Environment" }]; + } + }); +}); + +describe("local project navigation", () => { + function setup() { + state.navigationState = undefined; + state.scopes = new Set([AuthOrchestrationOperateScope]); + state.baseDirectory = "/tmp/t3-mobile-audit-fixture"; + state.projects = []; + state.dispatch.mockReset(); + state.alert.mockReset(); + state.waitForProject.mockReset(); + const created = Promise.withResolvers>>(); + const projected = Promise.withResolvers(); + state.createResult = created.promise; + state.waitForProject.mockReturnValue(projected.promise); + const submit = findAction( + renderScreen(() => AddProjectLocalFolderScreen({ environmentId: "environment" })), + "Add project", + ); + if (!submit) throw new Error("Add project action missing"); + return { created, projected, submit }; + } + + it("keeps navigation in place and explains a project projection timeout", async () => { + const { created, projected, submit } = setup(); + const submission = submit(); + created.resolve(AsyncResult.success(undefined)); + projected.resolve(null); + await submission; + + expect(state.projects).toEqual(["/tmp/t3-mobile-audit-fixture"]); + expect(state.dispatch).not.toHaveBeenCalled(); + expect(state.alert).toHaveBeenCalledExactlyOnceWith( + "Project added", + "The project was created but has not reached this device yet. It will appear in the project list once the connection catches up.", + ); + }); + + it("does not replace a reopened flow when the previous submission finishes", async () => { + const { created, projected, submit } = setup(); + const navigation = mountProjectFlow(false); + const submission = submit(); + created.resolve(AsyncResult.success(undefined)); + await created.promise; + navigation.back(); + navigation.push("AddProjectLocal"); + const remaining = navigation.getState(); + projected.resolve({ environmentId: "environment", id: "project" }); + await submission; + + expect(state.projects).toEqual(["/tmp/t3-mobile-audit-fixture"]); + expect(navigation.getState()).toEqual(remaining); + expect(state.dispatch).not.toHaveBeenCalled(); + }); + + it("does not replace a warm-linked flow sharing the original route key", async () => { + const { created, projected, submit } = setup(); + const navigation = mountProjectFlow(false); + const submission = submit(); + created.resolve(AsyncResult.success(undefined)); + await created.promise; + const originalKey = navigation.getState().routes.at(-1)!.key; + navigation.setParams({ environmentId: "other-environment" }); + const remaining = navigation.getState(); + expect(remaining.routes.at(-1)!.key).toBe(originalKey); + projected.resolve({ environmentId: "environment", id: "project" }); + await submission; + + expect(navigation.getState()).toEqual(remaining); + expect(state.dispatch).not.toHaveBeenCalled(); + }); + + it("does not redirect a reopened flow when an earlier legacy clone finishes", async () => { + const { created, projected } = setup(); + state.scopes.add(AuthSourceControlWriteScope); + const cloned = Promise.withResolvers(); + state.cloneResult = cloned.promise; + const navigation = mountProjectFlow(false, true, true, "AddProjectDestination"); + try { + const submission = cloneAction()(); + navigation.back(); + navigation.push("AddProjectDestination"); + const remaining = navigation.getState(); + cloned.resolve(AsyncResult.success({ cwd: "/tmp/t3-mobile-audit-fixture/repo" })); + created.resolve(AsyncResult.success(undefined)); + projected.resolve({ environmentId: "environment", id: "project" }); + await submission; + + expect(state.projects).toEqual(["/tmp/t3-mobile-audit-fixture/repo"]); + expect(navigation.getState()).toEqual(remaining); + expect(state.dispatch).not.toHaveBeenCalled(); + } finally { + state.cloneResult = null; + } + }); + + it("does not redirect a reopened flow when an earlier new project finishes", async () => { + const { projected } = setup(); + state.newProjectsRoot = "/projects"; + const created = Promise.withResolvers(); + const waitingForProject = Promise.withResolvers(); + state.createNewResult = created.promise; + state.waitForProject.mockImplementation(() => { + waitingForProject.resolve(); + return projected.promise; + }); + const navigation = mountProjectFlow(false, true, true, "AddProjectNew"); + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + const root = createRoot(document.createElement("div")); + let screen: ReactNode; + function NewProjectObserver() { + screen = AddProjectNewScreen({ environmentId: "environment" }); + return null; + } + function enterName(node: ReactNode): boolean { + if (Array.isArray(node)) return node.some(enterName); + if ( + !isValidElement<{ + placeholder?: string; + onChangeText?: (text: string) => void; + children?: ReactNode; + }>(node) + ) + return false; + if (node.props.placeholder === "Project name") { + node.props.onChangeText!("new-project"); + return true; + } + return enterName(node.props.children); + } + try { + await act(() => root.render(createElement(NewProjectObserver))); + await act(() => { + expect(enterName(screen)).toBe(true); + }); + const submit = findAction(screen, "Create project"); + if (!submit) throw new Error("Create project action missing"); + await act(async () => { + submit(); + created.resolve( + AsyncResult.success({ + projectId: "project", + workspaceRoot: "/projects/new-project", + }), + ); + await waitingForProject.promise; + }); + navigation.back(); + navigation.push("AddProjectNew"); + const remaining = navigation.getState(); + await act(async () => { + projected.resolve({ environmentId: "environment", id: "project" }); + await projected.promise; + }); + + expect(navigation.getState()).toEqual(remaining); + expect(state.dispatch).not.toHaveBeenCalled(); + } finally { + await act(() => root.unmount()); + state.newProjectsRoot = null; + state.createNewResult = null; + } + }); + + it.each([ + { origin: "Thread", withDraft: true, withChooser: true }, + { origin: "NewTaskProject", withDraft: false, withChooser: true }, + { origin: "AddProject", withDraft: false, withChooser: false }, + ])( + "waits for projection then selects the draft from $origin", + async ({ withDraft, withChooser }) => { + const { created, projected, submit } = setup(); + const navigation = mountProjectFlow(withDraft, withChooser, true); + const before = navigation.getState(); + const submission = submit(); + created.resolve(AsyncResult.success(undefined)); + await created.promise; + expect(state.projects).toEqual(["/tmp/t3-mobile-audit-fixture"]); + expect(navigation.getState()).toBe(before); + projected.resolve({ environmentId: "environment", id: "project" }); + await submission; + expect(state.dispatch).toHaveBeenCalledTimes(1); + expect(navigation.getState().routes.map((route) => route.name)).toEqual(["Home", "Thread"]); + expect(navigation.getState().routes[1]?.params).toEqual({ + draft: { + environmentId: "environment", + projectId: "project", + title: "t3-mobile-audit-fixture", + }, + }); + navigation.back(); + expect(navigation.getState().routes).toEqual([navigation.home]); + }, + ); +}); diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index 16ebe340c4..d091ac44cf 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -1,3 +1,5 @@ +import type { NativeStackNavigationProp } from "@react-navigation/native-stack"; +import { useSelectWorkspaceDetail } from "../workspace/useSelectWorkspaceDetail"; import * as Dialog from "../../components/ui/Dialog"; import { AppActivityIndicator } from "../../components/ui/AppActivityIndicator"; import { NavigationRow } from "../../components/ui/NavigationRow"; @@ -56,7 +58,6 @@ import { SourceControlProviderKind, } from "@infinitus/contracts"; import { - CommonActions, StackActions, useNavigation, type NavigationState, @@ -370,7 +371,9 @@ function useBrowsePathInput(environment: EnvironmentOption | null, pinnedDirecto return { isBrowseNavigating, pathInput, setPathInput, navigateToBrowsePath }; } -function useEnvironmentOptions(): ReadonlyArray { +function useEnvironmentOptions( + selectedEnvironmentId: EnvironmentId | null = null, +): ReadonlyArray { const serverConfigByEnvironmentId = useServerConfigs(); const { savedConnectionsById } = useSavedRemoteConnections(); const { connectedEnvironments } = useRemoteConnectionStatus(); @@ -396,25 +399,30 @@ function useEnvironmentOptions(): ReadonlyArray { }; }); return Arr.sort( - options.filter((environment) => canCreateProjectInEnvironment(environment.connectionState)), + options.filter( + (environment) => + canCreateProjectInEnvironment(environment.connectionState) || + environment.environmentId === selectedEnvironmentId, + ), environmentOptionOrder, ); })(); } -function useSelectedEnvironment(): { +function useSelectedEnvironment(requestedEnvironmentId: EnvironmentId | null): { readonly environmentOptions: ReadonlyArray; readonly selectedEnvironment: EnvironmentOption | null; readonly setSelectedEnvironmentId: (environmentId: EnvironmentId) => void; } { - const [selectedEnvironmentId, setSelectedEnvironmentId] = useState(null); - const environmentOptions = useEnvironmentOptions(); + const [selectedEnvironmentId, setSelectedEnvironmentId] = useState(requestedEnvironmentId); + const [previousRequest, setPreviousRequest] = useState(requestedEnvironmentId); + if (previousRequest !== requestedEnvironmentId) { + setPreviousRequest(requestedEnvironmentId); + setSelectedEnvironmentId(requestedEnvironmentId); + } + const environmentOptions = useEnvironmentOptions(selectedEnvironmentId); const selectedEnvironment = - environmentOptions.find( - (environment) => - environment.environmentId === selectedEnvironmentId && - canCreateProjectInEnvironment(environment.connectionState), - ) ?? + environmentOptions.find((environment) => environment.environmentId === selectedEnvironmentId) ?? environmentOptions.find((environment) => canCreateProjectInEnvironment(environment.connectionState), ) ?? @@ -501,10 +509,10 @@ function SourceControlRow(props: { ); } -export function AddProjectSourceScreen() { +export function AddProjectSourceScreen(props: { readonly environmentId?: string | string[] }) { const navigation = useNavigation(); const { environmentOptions, selectedEnvironment, setSelectedEnvironmentId } = - useSelectedEnvironment(); + useSelectedEnvironment(stringParam(props.environmentId) as EnvironmentId | null); const canWriteSourceControl = useEnvironmentScope( selectedEnvironment?.environmentId ?? null, AuthSourceControlWriteScope, @@ -513,9 +521,10 @@ export function AddProjectSourceScreen() { selectedEnvironment?.environmentId ?? null, AuthOrchestrationOperateScope, ); - const canCloneProject = canWriteSourceControl && canCreateProject; + const isConnected = canCreateProjectInEnvironment(selectedEnvironment?.connectionState); + const canCloneProject = isConnected && canWriteSourceControl && canCreateProject; const discoveryState = useEnvironmentQuery( - selectedEnvironment === null + selectedEnvironment === null || !isConnected ? null : sourceControlEnvironment.discovery({ environmentId: selectedEnvironment.environmentId, @@ -528,7 +537,7 @@ export function AddProjectSourceScreen() { {selectedEnvironment === null ? : null} - {environmentOptions.length > 1 ? ( + {environmentOptions.length > 1 || (selectedEnvironment !== null && !isConnected) ? ( <> Environments @@ -579,6 +588,7 @@ export function AddProjectSourceScreen() { } isFirst={selectedEnvironment.newProjectsRoot === null} - disabled={!canCreateProject} + disabled={!isConnected || !canCreateProject} onPress={() => navigation.dispatch( StackActions.push("AddProjectLocal", { @@ -650,27 +660,37 @@ export function AddProjectSourceScreen() { ); } -function openNewTaskDraft( - navigation: { - dispatch: (action: ReturnType) => void; - getState: () => NavigationState | undefined; - }, - params: { - environmentId: EnvironmentId; - projectId: ProjectId; - title: string; - cloning?: "1"; - draftId?: string; - }, -) { +function getActiveRoute(navigation: { + getState: () => NavigationState | undefined; +}) { const state = navigation.getState(); - if (!state) return; - const threadIndex = state.routes.findLastIndex((route) => route.name === "Thread"); - const routes = [ - ...state.routes.slice(0, threadIndex < 0 ? state.index : threadIndex), - { key: uuidv4(), name: "Thread", params: { draft: params } }, - ]; - navigation.dispatch(CommonActions.reset({ ...state, index: routes.length - 1, routes })); + return state?.routes[state.index]; +} + +function useOpenNewTaskDraft() { + const navigation = useNavigation>(); + const selectDetail = useSelectWorkspaceDetail(); + return ( + params: { + environmentId: EnvironmentId; + projectId: ProjectId; + title: string; + cloning?: "1"; + draftId?: string; + }, + originRoute: NavigationState["routes"][number] | undefined, + ) => { + const activeRoute = getActiveRoute(navigation); + // A warm link can change the task without replacing its native route key. + if ( + !originRoute || + activeRoute?.key !== originRoute.key || + activeRoute.name !== originRoute.name || + activeRoute.params !== originRoute.params + ) + return; + selectDetail({ name: "Thread", params: { draft: params } }); + }; } async function draftWithPrompt( @@ -685,11 +705,12 @@ async function draftWithPrompt( } function useCreateProject(environment: EnvironmentOption | null) { + const openNewTaskDraft = useOpenNewTaskDraft(); const navigation = useNavigation(); const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false }); const projects = useProjects(); - return async (workspaceRoot: string) => { + return async (workspaceRoot: string, originRoute = getActiveRoute(navigation)) => { if ( !environment || !canCreateProjectInEnvironment(environment.connectionState) || @@ -709,11 +730,14 @@ function useCreateProject(environment: EnvironmentOption | null) { }); if (existing) { Dialog.alert("Project already exists", existing.title); - openNewTaskDraft(navigation, { - environmentId: existing.environmentId, - projectId: existing.id, - title: existing.title, - }); + openNewTaskDraft( + { + environmentId: existing.environmentId, + projectId: existing.id, + title: existing.title, + }, + originRoute, + ); return; } @@ -730,11 +754,27 @@ function useCreateProject(environment: EnvironmentOption | null) { if (AsyncResult.isFailure(result)) { return result; } - openNewTaskDraft(navigation, { - environmentId: environment.environmentId, - projectId, - title: inferProjectTitleFromPath(workspaceRoot), - }); + // A successful command can precede its project in the client store. The + // draft treats a missing project as a reason to return to the chooser. + const project = await waitForProject( + { environmentId: environment.environmentId, projectId }, + 15_000, + ); + if (project === null) { + Dialog.alert( + "Project added", + "The project was created but has not reached this device yet. It will appear in the project list once the connection catches up.", + ); + return result; + } + openNewTaskDraft( + { + environmentId: environment.environmentId, + projectId, + title: inferProjectTitleFromPath(workspaceRoot), + }, + originRoute, + ); return result; }; } @@ -943,6 +983,7 @@ function FolderBrowser(props: { } export function AddProjectNewScreen(props: { readonly environmentId?: string | string[] }) { + const openNewTaskDraft = useOpenNewTaskDraft(); const navigation = useNavigation(); const environmentOptions = useEnvironmentOptions().filter( (option) => option.newProjectsRoot !== null, @@ -1017,6 +1058,7 @@ export function AddProjectNewScreen(props: { readonly environmentId?: string | s const submittingRef = useRef(false); const submit = async () => { if (!environment || trimmedName.length === 0 || submittingRef.current) return; + const originRoute = getActiveRoute(navigation); submittingRef.current = true; setError(null); setIsSubmitting(true); @@ -1067,11 +1109,14 @@ export function AddProjectNewScreen(props: { readonly environmentId?: string | s ); return; } - openNewTaskDraft(navigation, { - environmentId: environment.environmentId, - projectId, - title: trimmedName, - }); + openNewTaskDraft( + { + environmentId: environment.environmentId, + projectId, + title: trimmedName, + }, + originRoute, + ); }; return ( @@ -1262,6 +1307,7 @@ export function AddProjectDestinationScreen(props: { readonly repositoryName?: string | string[]; readonly prompt?: string | string[]; }) { + const openNewTaskDraft = useOpenNewTaskDraft(); const cloneRepository = useAtomCommand(sourceControlEnvironment.cloneRepository, { reportFailure: false, }); @@ -1313,6 +1359,7 @@ export function AddProjectDestinationScreen(props: { return; } + const originRoute = getActiveRoute(navigation); setIsSubmitting(true); if (environment.supportsCloneTracking) { const projectId = ProjectId.make(uuidv4()); @@ -1340,18 +1387,21 @@ export function AddProjectDestinationScreen(props: { "The project was created but has not reached this device yet. It will appear in the project list once the connection catches up.", ); } else { - openNewTaskDraft(navigation, { - environmentId: environment.environmentId, - projectId, - title, - cloning: "1", - ...(prompt - ? await draftWithPrompt(environment.environmentId, projectId, prompt).then( - (draftId) => ({ draftId }), - () => ({}), - ) - : {}), - }); + openNewTaskDraft( + { + environmentId: environment.environmentId, + projectId, + title, + cloning: "1", + ...(prompt + ? await draftWithPrompt(environment.environmentId, projectId, prompt).then( + (draftId) => ({ draftId }), + () => ({}), + ) + : {}), + }, + originRoute, + ); } } setIsSubmitting(false); @@ -1367,7 +1417,7 @@ export function AddProjectDestinationScreen(props: { if (AsyncResult.isFailure(cloneResult)) { setError(errorMessage(Cause.squash(cloneResult.cause))); } else { - const createResult = await createProject(cloneResult.value.cwd); + const createResult = await createProject(cloneResult.value.cwd, originRoute); if (createResult && AsyncResult.isFailure(createResult)) { setError(errorMessage(Cause.squash(createResult.cause))); } diff --git a/apps/mobile/src/features/projects/AddProjectSourceRoute.tsx b/apps/mobile/src/features/projects/AddProjectSourceRoute.tsx index 2dc85606be..d69e9141fd 100644 --- a/apps/mobile/src/features/projects/AddProjectSourceRoute.tsx +++ b/apps/mobile/src/features/projects/AddProjectSourceRoute.tsx @@ -1,5 +1,12 @@ +import type { StaticScreenProps } from "@react-navigation/native"; import { AddProjectSourceScreen } from "./AddProjectScreen"; -export function AddProjectSourceRoute() { - return ; +type AddProjectSourceRouteParams = { + readonly environmentId?: string | string[]; +}; + +export function AddProjectSourceRoute({ + route, +}: StaticScreenProps) { + return ; } diff --git a/apps/mobile/src/features/pull-requests/PullRequestInlineCommentScreen.tsx b/apps/mobile/src/features/pull-requests/PullRequestInlineCommentScreen.tsx index 1a6c3edefa..b91600ecea 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestInlineCommentScreen.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestInlineCommentScreen.tsx @@ -33,7 +33,7 @@ export function PullRequestInlineCommentScreen({ route }: StaticScreenProps refreshDetail()); + useFocusEffect(refreshDetail); const permissions = usePullRequestPermissions(params, detail.data); const stale = detail.data && detail.data.headSha !== params.headSha; const pending = useRef(false); diff --git a/apps/mobile/src/features/pull-requests/PullRequestMedia.tsx b/apps/mobile/src/features/pull-requests/PullRequestMedia.tsx index 51d7365a1a..87d9dd2ceb 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestMedia.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestMedia.tsx @@ -22,6 +22,8 @@ import { type FilePreviewItem, } from "../../components/FilePreviewModal"; +export const PullRequestMediaVisibleContext = createContext(true); + export const PullRequestMediaContext = createContext<{ readonly environmentId: EnvironmentId; readonly cwd: string; @@ -54,6 +56,7 @@ export function PullRequestMedia(props: { readonly compact?: boolean; }) { const media = usePullRequestMediaUrl(props.url); + const visible = useContext(PullRequestMediaVisibleContext); const context = useContext(PullRequestMediaContext); const withGallery = useFilePreviewGallery(); const [image, setImage] = useState(null); @@ -86,7 +89,7 @@ export function PullRequestMedia(props: { setVideo({ @@ -105,6 +108,8 @@ export function PullRequestMedia(props: { unavailable={media.unavailable} name={name} thumbnailKey={props.url} + thumbnailVisible={visible} + visible={visible} resolvePlaybackUri={media.resolvePlaybackUri} /> )} diff --git a/apps/mobile/src/features/pull-requests/PullRequestReviewSheet.tsx b/apps/mobile/src/features/pull-requests/PullRequestReviewSheet.tsx index 0ea99a272e..f0ac74a762 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestReviewSheet.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestReviewSheet.tsx @@ -68,7 +68,7 @@ export function PullRequestReviewSheet({ const target = pullRequestTarget(params); const result = useEnvironmentQuery(composerPullRequests.detail(target)); const { refresh: refreshDetail } = result; - useFocusEffect(() => refreshDetail()); + useFocusEffect(refreshDetail); const permissions = usePullRequestPermissions(params, result.data); const submission = useActionSubmission(params); const review = useAtomCommand(composerPullRequests.submitReview, { reportFailure: false }); diff --git a/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx b/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx index 88a7582710..8aa8c36d8b 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx @@ -111,6 +111,7 @@ import { } from "./PullRequestPresentation"; import { usePullRequestFilesSection } from "./PullRequestFiles"; import { PullRequestDescription } from "./PullRequestDescription"; +import { PullRequestMediaVisibleContext } from "./PullRequestMedia"; import { PullRequestMarkdownRepository } from "./PullRequestMarkdown"; import { PullRequestStatusMenu, @@ -387,9 +388,10 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps refreshDetail()); + useFocusEffect(refreshDetail); const pr = usePullRequestLiveChecks(params, result.data); const summary = params.initialSummary; const identity = pr ?? summary; @@ -794,7 +796,9 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps - {item.content} + + {item.content} + )} recycleItems={false} @@ -893,7 +897,7 @@ function PullRequestFileRouteContent({ route }: PullRequestFileRouteProps) { route.params.focus ? route.params.path : undefined, ); const { refresh: refreshDetail } = detail; - useFocusEffect(() => refreshDetail()); + useFocusEffect(refreshDetail); const { loadMore } = result; const parsed = filter.parsed; const reviewDraft = usePullRequestReviewDraft(route.params); diff --git a/apps/mobile/src/features/pull-requests/guide/useReviewGuide.test.tsx b/apps/mobile/src/features/pull-requests/guide/useReviewGuide.test.tsx new file mode 100644 index 0000000000..764cd5f881 --- /dev/null +++ b/apps/mobile/src/features/pull-requests/guide/useReviewGuide.test.tsx @@ -0,0 +1,190 @@ +import { + NavigationContext, + type NavigationProp, + type ParamListBase, +} from "@react-navigation/native"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { expect, it, vi } from "vite-plus/test"; + +const mocks = vi.hoisted(() => ({ + query: { + data: null as { run: { _tag: "Running" | "Completed" } } | null, + isPending: false, + refresh: vi.fn<() => void>(), + }, + otherQuery: { + data: null as { run: { _tag: "Running" | "Completed" } } | null, + isPending: false, + refresh: vi.fn<() => void>(), + }, +})); + +vi.mock("@react-navigation/native", async () => { + const { createRequire } = await import("node:module"); + const { realpathSync } = await import("node:fs"); + const require = createRequire(import.meta.url); + const nativeRequire = createRequire( + realpathSync(require.resolve("@react-navigation/native/package.json")), + ); + // Use the shipped focus hook without importing the React Native view layer. + const { NavigationContext, useFocusEffect }: typeof import("@react-navigation/native") = + nativeRequire("@react-navigation/core"); + return { NavigationContext, useFocusEffect }; +}); +vi.mock("@effect/atom-react", () => ({ + useAtomValue: (atom: unknown) => (atom === "settings" ? { reviewGuidesEnabled: true } : true), +})); +vi.mock("../../../state/server", () => ({ + serverEnvironment: { settingsValueAtom: () => "settings" }, +})); +vi.mock("../../../state/pull-requests", () => ({ + composerPullRequests: { + reviewGuide: (target: { number: number; environmentId: string }) => + target.environmentId === "environment" && target.number === 1 ? 1 : 2, + generateReviewGuide: { permissionAtom: () => "permission" }, + }, +})); +vi.mock("../../../state/query", () => ({ + useEnvironmentQuery: (number: number) => ({ ...(number === 1 ? mocks.query : mocks.otherQuery) }), +})); +vi.mock("../../../state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() })); +vi.mock("../usePullRequestConnected", () => ({ usePullRequestConnected: () => true })); +vi.mock("../usePullRequestDiff", () => ({ + pullRequestTarget: (params: unknown) => params, +})); + +import { useReviewGuide } from "./useReviewGuide"; + +function createNavigation() { + const listeners = { focus: new Set<() => void>(), blur: new Set<() => void>() }; + let focused = true; + return { + value: { + isFocused: () => focused, + addListener: (event: keyof typeof listeners, listener: () => void) => { + listeners[event].add(listener); + return () => listeners[event].delete(listener); + }, + } as unknown as NavigationProp, + focus(next: boolean) { + focused = next; + act(() => { + for (const listener of listeners[next ? "focus" : "blur"]) listener(); + }); + }, + }; +} + +function setup() { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + mocks.query = { data: null, isPending: false, refresh: vi.fn() }; + mocks.otherQuery = { data: null, isPending: false, refresh: vi.fn() }; + const root = createRoot(document.createElement("div")); + const navigation = createNavigation(); + const params = { + environmentId: "environment", + projectId: "project", + repository: "repo", + number: 1, + }; + function Probe({ number, environmentId }: { number: number; environmentId: string }) { + useReviewGuide({ ...params, number, environmentId }); + return null; + } + return { + render(number = 1, environmentId = params.environmentId) { + act(() => + root.render( + + + , + ), + ); + }, + focus: navigation.focus, + [Symbol.dispose]() { + act(() => root.unmount()); + }, + }; +} + +it("refreshes on focus and PR changes without refreshing again for query rerenders", () => { + using screen = setup(); + screen.render(); + expect(mocks.query.refresh).toHaveBeenCalledTimes(1); + + mocks.query = { ...mocks.query, isPending: true }; + screen.render(); + mocks.query = { ...mocks.query, isPending: false, data: { run: { _tag: "Completed" } } }; + screen.render(); + expect(mocks.query.refresh).toHaveBeenCalledTimes(1); + + screen.focus(false); + screen.render(); + expect(mocks.query.refresh).toHaveBeenCalledTimes(1); + screen.focus(true); + expect(mocks.query.refresh).toHaveBeenCalledTimes(2); + + screen.render(2); + expect(mocks.otherQuery.refresh).toHaveBeenCalledTimes(1); + screen.render(2); + expect(mocks.otherQuery.refresh).toHaveBeenCalledTimes(1); + expect(mocks.query.refresh).toHaveBeenCalledTimes(2); +}); + +it("preserves the polling deadline across rerenders and stops polling on blur or completion", () => { + vi.useFakeTimers(); + using _timers = { [Symbol.dispose]: () => vi.useRealTimers() }; + using screen = setup(); + mocks.query.data = { run: { _tag: "Running" } }; + screen.render(); + expect(mocks.query.refresh).toHaveBeenCalledTimes(1); + + act(() => vi.advanceTimersByTime(4_000)); + mocks.query = { ...mocks.query, isPending: true, data: { run: { _tag: "Running" } } }; + screen.render(); + expect(mocks.query.refresh).toHaveBeenCalledTimes(1); + act(() => vi.advanceTimersByTime(1_000)); + expect(mocks.query.refresh).toHaveBeenCalledTimes(2); + + screen.focus(false); + act(() => vi.advanceTimersByTime(10_000)); + expect(mocks.query.refresh).toHaveBeenCalledTimes(2); + screen.focus(true); + expect(mocks.query.refresh).toHaveBeenCalledTimes(3); + act(() => vi.advanceTimersByTime(5_000)); + expect(mocks.query.refresh).toHaveBeenCalledTimes(4); + + mocks.query = { ...mocks.query, isPending: false, data: { run: { _tag: "Completed" } } }; + screen.render(); + const refreshesOnCompletion = mocks.query.refresh.mock.calls.length; + act(() => vi.advanceTimersByTime(10_000)); + expect(mocks.query.refresh).toHaveBeenCalledTimes(refreshesOnCompletion); +}); + +it("retires the old environment's polling on target change and clears polling on unmount", () => { + vi.useFakeTimers(); + using _timers = { [Symbol.dispose]: () => vi.useRealTimers() }; + { + using screen = setup(); + mocks.query.data = { run: { _tag: "Running" } }; + mocks.otherQuery.data = { run: { _tag: "Running" } }; + screen.render(); + act(() => vi.advanceTimersByTime(4_000)); + + screen.render(1, "other-environment"); + expect(mocks.query.refresh).toHaveBeenCalledTimes(1); + expect(mocks.otherQuery.refresh).toHaveBeenCalledTimes(1); + act(() => vi.advanceTimersByTime(1_000)); + expect(mocks.query.refresh).toHaveBeenCalledTimes(1); + expect(mocks.otherQuery.refresh).toHaveBeenCalledTimes(1); + act(() => vi.advanceTimersByTime(4_000)); + expect(mocks.query.refresh).toHaveBeenCalledTimes(1); + expect(mocks.otherQuery.refresh).toHaveBeenCalledTimes(2); + } + act(() => vi.advanceTimersByTime(10_000)); + expect(mocks.query.refresh).toHaveBeenCalledTimes(1); + expect(mocks.otherQuery.refresh).toHaveBeenCalledTimes(2); + expect(vi.getTimerCount()).toBe(0); +}); diff --git a/apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx b/apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx index cb35d6022f..5c4d92ccdc 100644 --- a/apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx +++ b/apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx @@ -30,6 +30,8 @@ import { changeTone, DiffTokenText, ReviewChangeBar } from "./reviewDiffRenderin import { highlightReviewSelectedLines } from "./highlightReviewSelectedLines"; const REVIEW_COMMENT_PREVIEW_MAX_LINES = 5; +const NO_LINES: ReturnType = []; +const NO_HIGHLIGHTS: ReturnType = {}; type ReviewCommentComposerSheetProps = StaticScreenProps<{ readonly environmentId: EnvironmentId; @@ -49,21 +51,18 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp const [attachments, setAttachments] = useState>([]); const [previewFile, setPreviewFile] = useState(null); - const selectedLines = useMemo( - () => (target ? getSelectedReviewCommentLines(target) : []), - [target], - ); - const highlightedLinesById = useMemo( - () => - target - ? highlightReviewSelectedLines({ - filePath: target.filePath, - lines: selectedLines, - theme: selectedTheme, - }) - : {}, - [selectedLines, selectedTheme, target], - ); + const { selectedLines, highlightedLinesById } = useMemo(() => { + if (!target) return { selectedLines: NO_LINES, highlightedLinesById: NO_HIGHLIGHTS }; + const lines = getSelectedReviewCommentLines(target); + return { + selectedLines: lines, + highlightedLinesById: highlightReviewSelectedLines({ + filePath: target.filePath, + lines, + theme: selectedTheme, + }), + }; + }, [selectedTheme, target]); const firstLine = selectedLines[0] ?? null; const lastLine = selectedLines[selectedLines.length - 1] ?? null; const firstNumber = firstLine ? getReviewUnifiedLineNumber(firstLine) : null; diff --git a/apps/mobile/src/features/review/ReviewSheet.tsx b/apps/mobile/src/features/review/ReviewSheet.tsx index 52dee31329..d61d9ed50c 100644 --- a/apps/mobile/src/features/review/ReviewSheet.tsx +++ b/apps/mobile/src/features/review/ReviewSheet.tsx @@ -455,6 +455,7 @@ export function ReviewSheet(props: ReviewSheetProps) { useEffect(() => { showAuxiliaryPane("inspector"); + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Opening review for another thread reselects its inspector pane. }, [environmentId, showAuxiliaryPane, threadId]); const { error, @@ -463,7 +464,6 @@ export function ReviewSheet(props: ReviewSheetProps) { refreshSelectedSection, selectSection, isSelectedSectionPending, - diffPreviewRevision, } = useReviewSections({ enabled: isEnvironmentReady, environmentId, @@ -487,7 +487,6 @@ export function ReviewSheet(props: ReviewSheetProps) { environmentId, cwd: selectedThreadCwd, selectedSection, - revision: diffPreviewRevision, draftMessage, }); // Expo may return null before registration or in older binaries; use the fallback. diff --git a/apps/mobile/src/features/review/reviewCommentSelection.test.ts b/apps/mobile/src/features/review/reviewCommentSelection.test.ts index a5a9503b90..59a0bb6bcb 100644 --- a/apps/mobile/src/features/review/reviewCommentSelection.test.ts +++ b/apps/mobile/src/features/review/reviewCommentSelection.test.ts @@ -1,11 +1,19 @@ import { describe, expect, it } from "vite-plus/test"; import { + clearReviewCommentTarget, + completeReviewCommentRange, formatReviewCommentContext, + getReviewCommentSelection, + reviewCommentSelectionScope, + selectReviewCommentSelection, + setReviewCommentTarget, + startReviewCommentRange, parseReviewCommentMessageSegments, parseReviewInlineComments, type ReviewCommentTarget, } from "./reviewCommentSelection"; +import { setReviewSelectedSectionId } from "./reviewState"; function makeTarget(): ReviewCommentTarget { return { @@ -143,3 +151,85 @@ describe("review comment serialization", () => { expect(comment?.sectionTitle).toBe("Changes > 5"); }); }); + +describe("review comment selection store", () => { + const changes = { id: "git:branch-range", content: "hash-1" }; + const scope = reviewCommentSelectionScope(1, "env:thread", changes)!; + const otherScope = reviewCommentSelectionScope(1, "env:thread", { + id: "turn:2", + content: "diff", + })!; + const target = makeTarget(); + const pending = { + sectionId: target.sectionId, + sectionTitle: target.sectionTitle, + filePath: target.filePath, + lines: target.lines, + lineIndex: 0, + rowId: "row-1", + }; + + it("keeps a long-pressed range start when replacing an earlier target", () => { + setReviewCommentTarget(scope, target); + startReviewCommentRange(scope, pending); + + expect(getReviewCommentSelection()).toEqual({ scope, target: null, pending }); + }); + + it("keeps the range start after a same-line range end and drops it on a plain tap", () => { + startReviewCommentRange(scope, pending); + const sameLine = { ...target, startIndex: 0, endIndex: 0 }; + completeReviewCommentRange(scope, sameLine); + expect(getReviewCommentSelection().pending).toEqual(pending); + + setReviewCommentTarget(scope, sameLine); + expect(getReviewCommentSelection().pending).toBeNull(); + }); + + it("clears the target and the range start together", () => { + startReviewCommentRange(scope, pending); + clearReviewCommentTarget(); + + expect(getReviewCommentSelection()).toEqual({ scope: null, target: null, pending: null }); + }); + + it("reads as empty from another thread or section", () => { + setReviewCommentTarget(scope, target); + + expect(selectReviewCommentSelection(getReviewCommentSelection(), scope).target).toBe(target); + expect(selectReviewCommentSelection(getReviewCommentSelection(), otherScope).target).toBeNull(); + expect(selectReviewCommentSelection(getReviewCommentSelection(), null).target).toBeNull(); + }); + + it("reads as empty in a reopened review sheet", () => { + setReviewCommentTarget(scope, target); + const reopenedScope = reviewCommentSelectionScope(2, "env:thread", changes); + + expect( + selectReviewCommentSelection(getReviewCommentSelection(), reopenedScope).target, + ).toBeNull(); + }); + + it("reads as empty when the section disappears and comes back over a different diff", () => { + setReviewCommentTarget(scope, target); + // The working tree went clean, so the default showed; the section later returns. + const returned = reviewCommentSelectionScope(1, "env:thread", { + ...changes, + content: "hash-2", + }); + const unchanged = reviewCommentSelectionScope(1, "env:thread", { ...changes }); + + expect(selectReviewCommentSelection(getReviewCommentSelection(), returned).target).toBeNull(); + expect(selectReviewCommentSelection(getReviewCommentSelection(), unchanged).target).toBe( + target, + ); + }); + + it("drops the selection when the section changes, so switching back starts empty", () => { + setReviewCommentTarget(scope, target); + setReviewSelectedSectionId("env:thread", "turn:2"); + setReviewSelectedSectionId("env:thread", "git:branch-range"); + + expect(selectReviewCommentSelection(getReviewCommentSelection(), scope).target).toBeNull(); + }); +}); diff --git a/apps/mobile/src/features/review/reviewCommentSelection.ts b/apps/mobile/src/features/review/reviewCommentSelection.ts index b6de404355..be5e7acbd7 100644 --- a/apps/mobile/src/features/review/reviewCommentSelection.ts +++ b/apps/mobile/src/features/review/reviewCommentSelection.ts @@ -36,43 +36,136 @@ export type ReviewCommentMessageSegment = readonly comment: ReviewInlineComment; }; -let currentTarget: ReviewCommentTarget | null = null; +/** The first line of a range picked with a long press, waiting for its other end. */ +export interface PendingReviewCommentRange extends Pick< + ReviewCommentTarget, + "sectionId" | "sectionTitle" | "filePath" | "lines" +> { + readonly lineIndex: number; + readonly rowId: string; +} + +/** The sheet, thread and section a selection was made in, and the diff it was made over. */ +export interface ReviewCommentSelectionScope { + readonly key: string; + /** Compared by `===`: a git section's diff hash, or a turn's diff as answered. */ + readonly content: string | null; +} + +export interface ReviewCommentSelection { + readonly scope: ReviewCommentSelectionScope | null; + readonly target: ReviewCommentTarget | null; + readonly pending: PendingReviewCommentRange | null; +} + +const EMPTY_SELECTION: ReviewCommentSelection = { scope: null, target: null, pending: null }; +let selection = EMPTY_SELECTION; const listeners = new Set<() => void>(); const REVIEW_COMMENT_BLOCK_PATTERN = /]*)>\s*([\s\S]*?)<\/review_comment>/g; const REVIEW_COMMENT_ATTRIBUTE_PATTERN = /([a-zA-Z][a-zA-Z0-9_-]*)="([^"]*)"/g; const REVIEW_COMMENT_FENCE_PATTERN = /(`{3,})([^\s`]*)[^\n]*\n([\s\S]*?)\n\1/g; -function emitChange() { +function setSelection(next: ReviewCommentSelection) { + selection = next; listeners.forEach((listener) => listener()); } -export function subscribeReviewCommentTarget(listener: () => void): () => void { +function subscribeReviewCommentSelection(listener: () => void): () => void { listeners.add(listener); return () => { listeners.delete(listener); }; } -export function getReviewCommentTarget(): ReviewCommentTarget | null { - return currentTarget; +export function getReviewCommentSelection(): ReviewCommentSelection { + return selection; } -export function setReviewCommentTarget(target: ReviewCommentTarget | null) { - currentTarget = target; - emitChange(); +let lastReviewSheetId = 0; + +/** A fresh id per mounted review sheet, so reopening the sheet starts without a selection. */ +export function nextReviewSheetId(): number { + lastReviewSheetId += 1; + return lastReviewSheetId; +} + +export function reviewCommentSelectionScope( + sheetId: number, + threadKey: string | null, + section: { readonly id: string; readonly content: string | null } | null, +): ReviewCommentSelectionScope | null { + return threadKey === null || section === null + ? null + : { key: JSON.stringify([sheetId, threadKey, section.id]), content: section.content }; +} + +function isSameScope( + a: ReviewCommentSelectionScope | null, + b: ReviewCommentSelectionScope | null, +): boolean { + return a !== null && b !== null && a.key === b.key && a.content === b.content; +} + +export function setReviewCommentTarget( + scope: ReviewCommentSelectionScope, + target: ReviewCommentTarget, +) { + setSelection({ scope, target, pending: null }); +} + +/** + * Keeps the pending range: when both ends are the same line the target is a single line, and + * the range still waits for a different end. + */ +export function completeReviewCommentRange( + scope: ReviewCommentSelectionScope, + target: ReviewCommentTarget, +) { + setSelection({ + scope, + target, + pending: isSameScope(selection.scope, scope) ? selection.pending : null, + }); +} + +export function startReviewCommentRange( + scope: ReviewCommentSelectionScope, + pending: PendingReviewCommentRange, +) { + setSelection({ scope, target: null, pending }); } export function clearReviewCommentTarget() { - currentTarget = null; - emitChange(); + setSelection(EMPTY_SELECTION); +} + +/** A selection made in another sheet, thread or section, or over another diff, reads as empty. */ +export function selectReviewCommentSelection( + current: ReviewCommentSelection, + scope: ReviewCommentSelectionScope | null, +): ReviewCommentSelection { + return isSameScope(current.scope, scope) ? current : EMPTY_SELECTION; +} + +export function useReviewCommentSelection( + scope: ReviewCommentSelectionScope | null, +): ReviewCommentSelection { + return selectReviewCommentSelection( + useSyncExternalStore( + subscribeReviewCommentSelection, + getReviewCommentSelection, + getReviewCommentSelection, + ), + scope, + ); } export function useReviewCommentTarget(): ReviewCommentTarget | null { return useSyncExternalStore( - subscribeReviewCommentTarget, - getReviewCommentTarget, - getReviewCommentTarget, - ); + subscribeReviewCommentSelection, + getReviewCommentSelection, + getReviewCommentSelection, + ).target; } export function getSelectedReviewCommentLines( diff --git a/apps/mobile/src/features/review/reviewModel.test.ts b/apps/mobile/src/features/review/reviewModel.test.ts index 39d27de3d6..ef366dc33d 100644 --- a/apps/mobile/src/features/review/reviewModel.test.ts +++ b/apps/mobile/src/features/review/reviewModel.test.ts @@ -1,6 +1,36 @@ import { describe, expect, it } from "vite-plus/test"; -import { applyReviewDiffMetadata, buildReviewParsedDiff } from "./reviewModel"; +import { + applyReviewDiffMetadata, + buildReviewParsedDiff, + resolveReviewSelectedSectionId, + type ReviewSectionItem, +} from "./reviewModel"; + +function makeSection(id: string): ReviewSectionItem { + return { + id, + kind: id.startsWith("turn:") ? "turn" : "branch-range", + title: id, + subtitle: null, + diff: null, + isLoading: false, + }; +} + +describe("resolveReviewSelectedSectionId", () => { + const turn = makeSection("turn:2"); + const changes = makeSection("git:branch-range"); + + it("shows Changes before anything is picked, not the first section", () => { + expect(resolveReviewSelectedSectionId([turn, changes], null)).toBe("git:branch-range"); + }); + + it("shows the default while the picked section loads and returns to the pick once it does", () => { + expect(resolveReviewSelectedSectionId([changes], "turn:2")).toBe("git:branch-range"); + expect(resolveReviewSelectedSectionId([turn, changes], "turn:2")).toBe("turn:2"); + }); +}); describe("buildReviewParsedDiff", () => { it("keeps UI identities compact, stable, and isolated from parser cache keys", () => { diff --git a/apps/mobile/src/features/review/reviewModel.ts b/apps/mobile/src/features/review/reviewModel.ts index 1ec3a03dc0..c7a3ce734c 100644 --- a/apps/mobile/src/features/review/reviewModel.ts +++ b/apps/mobile/src/features/review/reviewModel.ts @@ -499,11 +499,22 @@ export function getReadyReviewCheckpoints( ); } +export function reviewTurnDiffsBySectionId( + checkpoints: ReadonlyArray>, + diffByTurnCount: Readonly>, +): Readonly> { + return Object.fromEntries( + checkpoints.map((checkpoint) => [ + getReviewSectionIdForCheckpoint(checkpoint), + diffByTurnCount[checkpoint.checkpointTurnCount], + ]), + ); +} + export function buildReviewSectionItems(input: { readonly checkpoints: ReadonlyArray; readonly gitSections: ReadonlyArray; readonly turnDiffById: Readonly>; - readonly loadingTurnIds: Readonly>; readonly loadingGitSections: boolean; }): ReadonlyArray { const turnItems = getReadyReviewCheckpoints(input.checkpoints).map( @@ -515,7 +526,7 @@ export function buildReviewSectionItems(input: { title: checkpointTitle(checkpoint), subtitle: checkpointSubtitle(checkpoint), diff: input.turnDiffById[id] ?? null, - isLoading: input.loadingTurnIds[id] === true, + isLoading: false, }; }, ); @@ -556,6 +567,20 @@ export function getDefaultReviewSectionId( return (sections.find((section) => section.id === CHANGES_SECTION_ID) ?? sections[0])?.id ?? null; } +/** + * The store holds only the user's pick. While that section is missing (still loading, or the + * working tree went clean) the default shows, and the pick returns once the section does: the + * thread-feed entry relies on this, as it selects a section before the sections have loaded. + */ +export function resolveReviewSelectedSectionId( + sections: ReadonlyArray, + storedSectionId: string | null, +): string | null { + return storedSectionId !== null && sections.some((section) => section.id === storedSectionId) + ? storedSectionId + : getDefaultReviewSectionId(sections); +} + export function buildReviewParsedDiff( diff: string | null | undefined, cacheScope: string, diff --git a/apps/mobile/src/features/review/reviewState.ts b/apps/mobile/src/features/review/reviewState.ts index 1ed3912979..a14f396435 100644 --- a/apps/mobile/src/features/review/reviewState.ts +++ b/apps/mobile/src/features/review/reviewState.ts @@ -1,57 +1,25 @@ import { useAtomValue } from "@effect/atom-react"; -import type { EnvironmentId, ReviewDiffPreviewSource, ThreadId } from "@infinitus/contracts"; +import type { EnvironmentId, ThreadId } from "@infinitus/contracts"; import { Atom } from "effect/reactivity"; import { scopedThreadKey } from "../../lib/scopedEntities"; import { appAtomRegistry } from "../../state/atom-registry"; +import { clearReviewCommentTarget } from "./reviewCommentSelection"; import { buildReviewParsedDiff, type ReviewParsedDiff } from "./reviewModel"; -const EMPTY_GIT_REVIEW_SECTIONS = Object.freeze>([]); -const EMPTY_REVIEW_TURN_DIFFS = Object.freeze>>({}); -const EMPTY_REVIEW_LOADING_TURN_IDS = Object.freeze>>({}); -const EMPTY_REVIEW_ASYNC_STATE = Object.freeze({ - loadingTurnIds: EMPTY_REVIEW_LOADING_TURN_IDS, - error: null, -}); const EMPTY_REVIEW_SECTION_FILE_IDS = Object.freeze< Readonly | undefined>> >({}); -const EMPTY_REVIEW_GIT_SECTIONS_ATOM = Atom.make(EMPTY_GIT_REVIEW_SECTIONS).pipe( - Atom.keepAlive, - Atom.withLabel("mobile:review:git-sections:null"), -); -const EMPTY_REVIEW_TURN_DIFFS_ATOM = Atom.make(EMPTY_REVIEW_TURN_DIFFS).pipe( - Atom.keepAlive, - Atom.withLabel("mobile:review:turn-diffs:null"), -); const EMPTY_REVIEW_SELECTED_SECTION_ID_ATOM = Atom.make(null).pipe( Atom.keepAlive, Atom.withLabel("mobile:review:selected-section-id:null"), ); -const EMPTY_REVIEW_ASYNC_STATE_ATOM = Atom.make(EMPTY_REVIEW_ASYNC_STATE).pipe( - Atom.keepAlive, - Atom.withLabel("mobile:review:async-state:null"), -); const EMPTY_REVIEW_SECTION_FILE_IDS_ATOM = Atom.make(EMPTY_REVIEW_SECTION_FILE_IDS).pipe( Atom.keepAlive, Atom.withLabel("mobile:review:section-file-ids:null"), ); -const reviewGitSectionsByThreadKeyAtom = Atom.family((threadKey: string) => - Atom.make(EMPTY_GIT_REVIEW_SECTIONS).pipe( - Atom.keepAlive, - Atom.withLabel(`mobile:review:git-sections:${threadKey}`), - ), -); - -const reviewTurnDiffByThreadKeyAtom = Atom.family((threadKey: string) => - Atom.make(EMPTY_REVIEW_TURN_DIFFS).pipe( - Atom.keepAlive, - Atom.withLabel(`mobile:review:turn-diffs:${threadKey}`), - ), -); - const reviewSelectedSectionIdByThreadKeyAtom = Atom.family((threadKey: string) => Atom.make(null).pipe( Atom.keepAlive, @@ -59,13 +27,6 @@ const reviewSelectedSectionIdByThreadKeyAtom = Atom.family((threadKey: string) = ), ); -const reviewAsyncStateByThreadKeyAtom = Atom.family((threadKey: string) => - Atom.make(EMPTY_REVIEW_ASYNC_STATE).pipe( - Atom.keepAlive, - Atom.withLabel(`mobile:review:async-state:${threadKey}`), - ), -); - const reviewExpandedFileIdsByThreadKeyAtom = Atom.family((threadKey: string) => Atom.make(EMPTY_REVIEW_SECTION_FILE_IDS).pipe( Atom.keepAlive, @@ -104,10 +65,7 @@ const reviewParsedDiffCacheAtom = Atom.make(() => ({ export interface ReviewCacheForThread { readonly threadKey: string | null; - readonly gitSections: ReadonlyArray; - readonly turnDiffById: Readonly>; readonly selectedSectionId: string | null; - readonly asyncState: ReviewAsyncState; readonly expandedFileIdsBySection: Readonly | undefined>>; readonly revealedLargeFileIdsBySection: Readonly< Record | undefined> @@ -115,11 +73,6 @@ export interface ReviewCacheForThread { readonly viewedFileIdsBySection: Readonly | undefined>>; } -export interface ReviewAsyncState { - readonly loadingTurnIds: Readonly>; - readonly error: string | null; -} - function buildThreadKey(input: { readonly environmentId?: EnvironmentId; readonly threadId?: ThreadId; @@ -138,20 +91,11 @@ export function useReviewCacheForThread(input: { readonly threadId?: ThreadId; }): ReviewCacheForThread { const threadKey = buildThreadKey(input); - const gitSections = useAtomValue( - threadKey ? reviewGitSectionsByThreadKeyAtom(threadKey) : EMPTY_REVIEW_GIT_SECTIONS_ATOM, - ); - const turnDiffById = useAtomValue( - threadKey ? reviewTurnDiffByThreadKeyAtom(threadKey) : EMPTY_REVIEW_TURN_DIFFS_ATOM, - ); const selectedSectionId = useAtomValue( threadKey ? reviewSelectedSectionIdByThreadKeyAtom(threadKey) : EMPTY_REVIEW_SELECTED_SECTION_ID_ATOM, ); - const asyncState = useAtomValue( - threadKey ? reviewAsyncStateByThreadKeyAtom(threadKey) : EMPTY_REVIEW_ASYNC_STATE_ATOM, - ); const expandedFileIdsBySection = useAtomValue( threadKey ? reviewExpandedFileIdsByThreadKeyAtom(threadKey) @@ -168,74 +112,20 @@ export function useReviewCacheForThread(input: { return { threadKey, - gitSections, - turnDiffById, selectedSectionId, - asyncState, expandedFileIdsBySection, revealedLargeFileIdsBySection, viewedFileIdsBySection, }; } -export function setReviewGitSections( - threadKey: string, - sections: ReadonlyArray, -): void { - appAtomRegistry.set(reviewGitSectionsByThreadKeyAtom(threadKey), sections); -} - -export function setReviewTurnDiff(threadKey: string, sectionId: string, diff: string): void { - const atom = reviewTurnDiffByThreadKeyAtom(threadKey); - const current = appAtomRegistry.get(atom); - appAtomRegistry.set(atom, { - ...current, - [sectionId]: diff, - }); -} - export function setReviewSelectedSectionId(threadKey: string, sectionId: string | null): void { + // A line selection belongs to the section it was made in; switching away drops it, so + // switching back does not bring back a range over lines that may have changed. + clearReviewCommentTarget(); appAtomRegistry.set(reviewSelectedSectionIdByThreadKeyAtom(threadKey), sectionId); } -function updateReviewAsyncState( - threadKey: string, - update: (current: ReviewAsyncState) => ReviewAsyncState, -): void { - const atom = reviewAsyncStateByThreadKeyAtom(threadKey); - appAtomRegistry.set(atom, update(appAtomRegistry.get(atom))); -} - -export function setReviewTurnDiffLoading( - threadKey: string, - sectionId: string, - isLoading: boolean, -): void { - updateReviewAsyncState(threadKey, (current) => { - const loadingTurnIds = { ...current.loadingTurnIds }; - if (isLoading) { - loadingTurnIds[sectionId] = true; - } else { - delete loadingTurnIds[sectionId]; - } - return { - ...current, - loadingTurnIds, - }; - }); -} - -export function setReviewAsyncError(threadKey: string, error: string | null): void { - updateReviewAsyncState(threadKey, (current) => ({ - ...current, - error, - })); -} - -export function getReviewAsyncStateSnapshot(threadKey: string): ReviewAsyncState { - return appAtomRegistry.get(reviewAsyncStateByThreadKeyAtom(threadKey)); -} - export function updateReviewExpandedFileIds( threadKey: string, sectionId: string, diff --git a/apps/mobile/src/features/review/useNativeReviewDiffHighlighting.ts b/apps/mobile/src/features/review/useNativeReviewDiffHighlighting.ts index 8e64cee058..f773ec5a4e 100644 --- a/apps/mobile/src/features/review/useNativeReviewDiffHighlighting.ts +++ b/apps/mobile/src/features/review/useNativeReviewDiffHighlighting.ts @@ -48,6 +48,8 @@ export function useNativeReviewDiffHighlighting(input: { ), ); + // Effect on purpose: the native highlighter's token stream and the scheduler's timers must + // reset with the diff the view shows, and be cancelled when it unmounts. useEffect(() => { traceDiffHighlight("viewport-reset", { reset: diffHighlightTraceId(resetKey), @@ -57,6 +59,7 @@ export function useNativeReviewDiffHighlighting(input: { const previousRange = scheduler.reset(); highlightedRowIdsRef.current = new Set(); pendingPatchesRef.current = []; + // oxlint-disable-next-line react/set-state-in-effect -- Resetting the native highlighter clears the viewport and token-patch snapshots together. setViewport({ scope: viewportScope, range: null }); setTokensPatchJson(createEmptyTokenPatch(resetKey)); // A token reset need not produce another native range event. Reuse only an @@ -65,6 +68,7 @@ export function useNativeReviewDiffHighlighting(input: { return () => scheduler.cancel(); }, [enabled, resetKey, rows.length, scheduler, viewportScope]); + // Effect on purpose: abortable async highlighting tied to the visible range's lifetime. useEffect(() => { if (!enabled || rows.length === 0 || !viewport.range || viewport.scope !== viewportScope) { return; diff --git a/apps/mobile/src/features/review/useReviewCommentSelectionController.ts b/apps/mobile/src/features/review/useReviewCommentSelectionController.ts index 079c95902d..b6808e123b 100644 --- a/apps/mobile/src/features/review/useReviewCommentSelectionController.ts +++ b/apps/mobile/src/features/review/useReviewCommentSelectionController.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useState } from "react"; import type { NativeSyntheticEvent } from "react-native"; import { useNavigation } from "@react-navigation/native"; import * as Arr from "effect/Array"; @@ -6,26 +6,22 @@ import { pipe } from "effect/Function"; import * as Result from "effect/Result"; import type { EnvironmentId, ThreadId } from "@infinitus/contracts"; +import { scopedThreadKey } from "../../lib/scopedEntities"; import { buildReviewCommentTarget, clearReviewCommentTarget, + completeReviewCommentRange, formatReviewSelectedRangeLabel, getSelectedReviewCommentLines, + nextReviewSheetId, + reviewCommentSelectionScope, setReviewCommentTarget, - useReviewCommentTarget, + startReviewCommentRange, + useReviewCommentSelection, } from "./reviewCommentSelection"; -import type { - NativeReviewDiffData, - NativeReviewDiffCommentTarget, -} from "./nativeReviewDiffAdapter"; +import type { NativeReviewDiffData } from "./nativeReviewDiffAdapter"; import type { ReviewSectionItem } from "./reviewModel"; -interface PendingNativeCommentSelection extends NativeReviewDiffCommentTarget { - readonly sectionId: string; - readonly sectionTitle: string; - readonly rowId: string; -} - export function useReviewCommentSelectionController(input: { readonly environmentId?: EnvironmentId; readonly threadId?: ThreadId; @@ -34,11 +30,24 @@ export function useReviewCommentSelectionController(input: { }) { const { environmentId, nativeReviewDiffData, selectedSection, threadId } = input; const navigation = useNavigation(); - const activeCommentTarget = useReviewCommentTarget(); - const [pendingNativeCommentSelection, setPendingNativeCommentSelection] = - useState(null); + const [sheetId] = useState(nextReviewSheetId); + // Scoping the selection to this sheet, thread, section and diff drops it when the sheet + // reopens, either changes, or a section comes back with different lines. + const scope = reviewCommentSelectionScope( + sheetId, + environmentId && threadId ? scopedThreadKey(environmentId, threadId) : null, + selectedSection === null + ? null + : { + id: selectedSection.id, + content: selectedSection.source?.diffHash ?? selectedSection.diff, + }, + ); + const { target: activeCommentTarget, pending: pendingRange } = useReviewCommentSelection(scope); + const hasRangeTarget = + activeCommentTarget !== null && activeCommentTarget.startIndex !== activeCommentTarget.endIndex; - const openReviewCommentSheet = useCallback(() => { + const openReviewCommentSheet = () => { if (!environmentId || !threadId) { return; } @@ -47,152 +56,84 @@ export function useReviewCommentSelectionController(input: { environmentId, threadId, }); - }, [environmentId, navigation, threadId]); + }; - const selectedRowIds = useMemo(() => { - if ( - activeCommentTarget && - activeCommentTarget.sectionTitle === selectedSection?.title && - activeCommentTarget.startIndex !== activeCommentTarget.endIndex - ) { - return pipe( + const selectedRowIds = hasRangeTarget + ? pipe( getSelectedReviewCommentLines(activeCommentTarget), Arr.filterMap((line) => { const rowId = nativeReviewDiffData.rowIdByCommentLineId.get(line.id); return rowId ? Result.succeed(rowId) : Result.failVoid; }), - ); - } - - return pendingNativeCommentSelection ? [pendingNativeCommentSelection.rowId] : []; - }, [ - activeCommentTarget, - nativeReviewDiffData.rowIdByCommentLineId, - pendingNativeCommentSelection, - selectedSection?.title, - ]); + ) + : pendingRange + ? [pendingRange.rowId] + : []; - const selectionAction = useMemo(() => { - if ( - activeCommentTarget && - activeCommentTarget.sectionTitle === selectedSection?.title && - activeCommentTarget.startIndex !== activeCommentTarget.endIndex - ) { - return { + const selectionAction = hasRangeTarget + ? { title: `Comment on ${formatReviewSelectedRangeLabel(activeCommentTarget)}`, onOpenComment: openReviewCommentSheet, - }; + } + : pendingRange + ? { title: "Select range end", onOpenComment: null } + : null; + + const onPressLine = ( + event: NativeSyntheticEvent<{ + readonly rowId?: string; + readonly gesture?: "tap" | "longPress"; + }>, + ) => { + if (!selectedSection || scope === null) { + return; } - if ( - pendingNativeCommentSelection && - pendingNativeCommentSelection.sectionTitle === selectedSection?.title - ) { - return { - title: "Select range end", - onOpenComment: null, - }; + const { rowId, gesture } = event.nativeEvent; + if (!rowId) { + return; } - return null; - }, [ - activeCommentTarget, - openReviewCommentSheet, - pendingNativeCommentSelection, - selectedSection?.title, - ]); - - useEffect(() => { - clearReviewCommentTarget(); - setPendingNativeCommentSelection(null); - }, [selectedSection?.id]); - - useEffect(() => { - if (activeCommentTarget === null) { - setPendingNativeCommentSelection(null); + const target = nativeReviewDiffData.commentTargetsByRowId.get(rowId); + if (!target) { + return; } - }, [activeCommentTarget]); - - const onPressLine = useCallback( - ( - event: NativeSyntheticEvent<{ - readonly rowId?: string; - readonly gesture?: "tap" | "longPress"; - }>, - ) => { - if (!selectedSection) { - return; - } - - const { rowId, gesture } = event.nativeEvent; - if (!rowId) { - return; - } - const target = nativeReviewDiffData.commentTargetsByRowId.get(rowId); - if (!target) { - return; - } - - if (gesture === "longPress") { - clearReviewCommentTarget(); - setPendingNativeCommentSelection({ - ...target, - sectionId: selectedSection.id, - sectionTitle: selectedSection.title, - rowId, - }); - return; - } - - if ( - pendingNativeCommentSelection && - pendingNativeCommentSelection.sectionTitle === selectedSection.title && - pendingNativeCommentSelection.filePath === target.filePath - ) { - setReviewCommentTarget( - buildReviewCommentTarget( - { - sectionTitle: pendingNativeCommentSelection.sectionTitle, - sectionId: pendingNativeCommentSelection.sectionId, - filePath: pendingNativeCommentSelection.filePath, - lines: pendingNativeCommentSelection.lines, - }, - pendingNativeCommentSelection.lineIndex, - target.lineIndex, - ), - ); - return; - } - - setPendingNativeCommentSelection(null); - setReviewCommentTarget({ - sectionTitle: selectedSection.title, + if (gesture === "longPress") { + startReviewCommentRange(scope, { sectionId: selectedSection.id, + sectionTitle: selectedSection.title, filePath: target.filePath, lines: target.lines, - startIndex: target.lineIndex, - endIndex: target.lineIndex, + lineIndex: target.lineIndex, + rowId, }); - openReviewCommentSheet(); - }, - [ - nativeReviewDiffData.commentTargetsByRowId, - openReviewCommentSheet, - pendingNativeCommentSelection, - selectedSection, - ], - ); + return; + } + + if (pendingRange && pendingRange.filePath === target.filePath) { + completeReviewCommentRange( + scope, + buildReviewCommentTarget(pendingRange, pendingRange.lineIndex, target.lineIndex), + ); + return; + } - const clearSelection = useCallback(() => { - clearReviewCommentTarget(); - setPendingNativeCommentSelection(null); - }, []); + setReviewCommentTarget(scope, { + sectionTitle: selectedSection.title, + sectionId: selectedSection.id, + filePath: target.filePath, + lines: target.lines, + startIndex: target.lineIndex, + endIndex: target.lineIndex, + }); + openReviewCommentSheet(); + }; return { selectedRowIds, selectionAction, onPressLine, - clearSelection, + clearSelection: clearReviewCommentTarget, }; } diff --git a/apps/mobile/src/features/review/useReviewDiffData.ts b/apps/mobile/src/features/review/useReviewDiffData.ts index 0caf05ae93..c59373514d 100644 --- a/apps/mobile/src/features/review/useReviewDiffData.ts +++ b/apps/mobile/src/features/review/useReviewDiffData.ts @@ -1,4 +1,4 @@ -import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useContext, useEffect, useMemo, useState } from "react"; import { formatDiffCount } from "@infinitus/client-runtime/diff-count"; import { countReviewCommentContexts, parseReviewInlineComments } from "./reviewCommentSelection"; @@ -126,7 +126,6 @@ export function useReviewDiffData(input: { readonly environmentId: EnvironmentId | undefined; readonly cwd: string | null; readonly selectedSection: ReviewSectionItem | null; - readonly revision: string | undefined; readonly draftMessage: string; }) { const { draftMessage, selectedSection, threadKey } = input; @@ -170,6 +169,8 @@ export function useReviewDiffData(input: { environmentId, input: { cacheKey: scope, + // Newer preview revisions refresh patches read under an older one. + preview: { cwd }, request: { cwd, ...(lazySource.baseRef ? { baseRef: lazySource.baseRef } : {}), @@ -203,24 +204,6 @@ export function useReviewDiffData(input: { [queries, parsedQuery], ), ); - const previousPreview = useRef({ - scope, - revision: input.revision, - queries: [] as typeof queries, - }); - useEffect(() => { - const previous = previousPreview.current; - previousPreview.current = { scope, revision: input.revision, queries }; - for (const query of queries) { - if (!query) continue; - const changed = previous.scope === scope && previous.revision !== input.revision; - const cached = - !previous.queries.includes(query) && - registry.get(query)._tag !== "Initial" && - !registry.get(query).waiting; - if (changed || cached) registry.refresh(query); - } - }, [input.revision, queries, registry, scope]); const loadVisibleFile = useCallback( (fileId: string | null, retry = false) => { const index = diff --git a/apps/mobile/src/features/review/useReviewSections.ts b/apps/mobile/src/features/review/useReviewSections.ts index 35dca265e6..bdc8277e2d 100644 --- a/apps/mobile/src/features/review/useReviewSections.ts +++ b/apps/mobile/src/features/review/useReviewSections.ts @@ -1,34 +1,36 @@ +import { useAtomValue } from "@effect/atom-react"; import { resolveFilesystemReadAccess } from "@infinitus/client-runtime/state/filesystem"; -import { environmentSession } from "../../state/session"; -import { useCallback, useEffect, useMemo } from "react"; -import * as DateTime from "effect/DateTime"; +import { Atom } from "effect/reactivity"; -import { - deriveThreadCheckpointSummaries, - type ThreadCheckpointSummary, -} from "@infinitus/client-runtime/state/thread-checkpoints"; -import type { EnvironmentId, ThreadId } from "@infinitus/contracts"; +import { deriveThreadCheckpointSummaries } from "@infinitus/client-runtime/state/thread-checkpoints"; +import type { EnvironmentId, ReviewDiffPreviewSource, ThreadId } from "@infinitus/contracts"; import { useCheckpointDiff } from "../../state/queries"; import { useEnvironmentQuery } from "../../state/query"; import { useEnvironmentPresentation } from "../../state/presentation"; -import { reviewEnvironment } from "../../state/review"; +import { lastReviewGitSectionsAtom, reviewEnvironment } from "../../state/review"; +import { lastReviewTurnDiffsAtom } from "../../state/reviewTurnDiffs"; +import { environmentSession } from "../../state/session"; import { useSelectedThreadProjection } from "../../state/use-thread-detail"; import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree"; import { buildReviewSectionItems, - getDefaultReviewSectionId, getReadyReviewCheckpoints, getReviewSectionIdForCheckpoint, + resolveReviewSelectedSectionId, + reviewTurnDiffsBySectionId, + type ReviewSectionItem, } from "./reviewModel"; -import { - setReviewAsyncError, - setReviewGitSections, - setReviewSelectedSectionId, - setReviewTurnDiff, - setReviewTurnDiffLoading, - type ReviewCacheForThread, -} from "./reviewState"; +import { setReviewSelectedSectionId, type ReviewCacheForThread } from "./reviewState"; + +const NO_GIT_SECTIONS_ATOM = Atom.make>([]).pipe( + Atom.keepAlive, + Atom.withLabel("mobile:review:git-sections:null"), +); +const NO_TURN_DIFFS_ATOM = Atom.make>>({}).pipe( + Atom.keepAlive, + Atom.withLabel("mobile:review:turn-diffs:null"), +); export function useReviewSections(input: { readonly enabled?: boolean; @@ -59,97 +61,41 @@ export function useReviewSections(input: { }) : null, ); - const { loadingTurnIds } = reviewCache.asyncState; - - useEffect(() => { - if (reviewCache.threadKey && diffPreview.data) { - setReviewGitSections(reviewCache.threadKey, diffPreview.data.sources); - } - }, [diffPreview.data, reviewCache.threadKey]); - - const readyCheckpoints = useMemo( - () => - getReadyReviewCheckpoints( - selectedThread === null ? [] : deriveThreadCheckpointSummaries(selectedThread.projection), - ), - [selectedThread], - ); - const checkpointBySectionId = useMemo( - () => - Object.fromEntries( - readyCheckpoints.map((checkpoint) => [ - getReviewSectionIdForCheckpoint(checkpoint), - checkpoint, - ]), - ) as Record, - [readyCheckpoints], + const lastGitSections = useAtomValue( + environmentId !== undefined && selectedThreadCwd !== null + ? lastReviewGitSectionsAtom(environmentId, selectedThreadCwd) + : NO_GIT_SECTIONS_ATOM, ); - const reviewSections = useMemo(() => { - const sections = buildReviewSectionItems({ - checkpoints: readyCheckpoints, - gitSections: - canReadFiles || fileAccess.isPending - ? (diffPreview.data?.sources ?? reviewCache.gitSections) - : [], - turnDiffById: reviewCache.turnDiffById, - loadingTurnIds, - loadingGitSections: fileAccess.isPending || diffPreview.isPending, - }); - // Keep the selected section while its grant loads, without displaying cached host files. - return fileAccess.isPending - ? sections.map((section) => - section.kind === "turn" ? section : { ...section, diff: null, isLoading: true }, - ) - : sections; - }, [ - canReadFiles, - diffPreview.data?.sources, - diffPreview.isPending, - fileAccess.isPending, - loadingTurnIds, - readyCheckpoints, - reviewCache.gitSections, - reviewCache.turnDiffById, - ]); - const selectedSection = useMemo( - () => - reviewSections.find((section) => section.id === reviewCache.selectedSectionId) ?? - reviewSections[0] ?? - null, - [reviewCache.selectedSectionId, reviewSections], + + const lastTurnDiffs = useAtomValue( + environmentId !== undefined && threadId !== undefined + ? lastReviewTurnDiffsAtom(environmentId, threadId) + : NO_TURN_DIFFS_ATOM, ); - const fallbackSectionId = useMemo( - () => getDefaultReviewSectionId(reviewSections), - [reviewSections], + + const readyCheckpoints = getReadyReviewCheckpoints( + selectedThread === null ? [] : deriveThreadCheckpointSummaries(selectedThread.projection), ); - const selectedSectionIdExists = useMemo( - () => - reviewCache.selectedSectionId - ? reviewSections.some((section) => section.id === reviewCache.selectedSectionId) - : false, - [reviewCache.selectedSectionId, reviewSections], + const checkpointBySectionId = new Map( + readyCheckpoints.map((checkpoint) => [getReviewSectionIdForCheckpoint(checkpoint), checkpoint]), ); - - useEffect(() => { - if ( - reviewSections.length > 0 && - reviewCache.threadKey && - (!reviewCache.selectedSectionId || !selectedSectionIdExists) - ) { - setReviewSelectedSectionId(reviewCache.threadKey, fallbackSectionId); - } - }, [ - fallbackSectionId, + // Turns show their last answered diff; the active turn's live query below overrides it. + // Section ids and the selection do not depend on the live query, so they are resolved first. + const cachedSections = buildReviewSectionItems({ + checkpoints: readyCheckpoints, + gitSections: + canReadFiles || fileAccess.isPending ? (diffPreview.data?.sources ?? lastGitSections) : [], + turnDiffById: reviewTurnDiffsBySectionId(readyCheckpoints, lastTurnDiffs), + loadingGitSections: fileAccess.isPending || diffPreview.isPending, + }); + const selectedSectionId = resolveReviewSelectedSectionId( + cachedSections, reviewCache.selectedSectionId, - reviewCache.threadKey, - reviewSections.length, - selectedSectionIdExists, - ]); + ); - let activeCheckpoint = readyCheckpoints[0] ?? null; - if (selectedSection?.kind === "turn") { - activeCheckpoint = checkpointBySectionId[selectedSection.id] ?? activeCheckpoint; - } + const selectedCheckpoint = + selectedSectionId === null ? undefined : checkpointBySectionId.get(selectedSectionId); + const activeCheckpoint = selectedCheckpoint ?? readyCheckpoints[0] ?? null; const activeSectionId = activeCheckpoint ? getReviewSectionIdForCheckpoint(activeCheckpoint) : null; @@ -162,28 +108,23 @@ export function useReviewSections(input: { ignoreWhitespace: false, }); - useEffect(() => { - if (!reviewCache.threadKey || !activeSectionId) { - return; - } - setReviewTurnDiffLoading(reviewCache.threadKey, activeSectionId, activeTurnDiff.isPending); - }, [activeSectionId, activeTurnDiff.isPending, reviewCache.threadKey]); - - useEffect(() => { - if (!reviewCache.threadKey || !activeSectionId || !activeTurnDiff.data) { - return; - } - setReviewTurnDiff(reviewCache.threadKey, activeSectionId, activeTurnDiff.data.diff); - setReviewAsyncError(reviewCache.threadKey, null); - }, [activeSectionId, activeTurnDiff.data, reviewCache.threadKey]); - - useEffect(() => { - if (reviewCache.threadKey && activeTurnDiff.error) { - setReviewAsyncError(reviewCache.threadKey, activeTurnDiff.error); + const reviewSections = cachedSections.map((section): ReviewSectionItem => { + if (section.kind === "turn") { + return section.id === activeSectionId + ? { + ...section, + diff: activeTurnDiff.data?.diff ?? section.diff, + isLoading: activeTurnDiff.isPending, + } + : section; } - }, [activeTurnDiff.error, reviewCache.threadKey]); + // Keep the selected section while its grant loads, without displaying cached host files. + return fileAccess.isPending ? { ...section, diff: null, isLoading: true } : section; + }); + const selectedSection = + reviewSections.find((section) => section.id === selectedSectionId) ?? null; - const refreshSelectedSection = useCallback(async () => { + const refreshSelectedSection = async () => { if (!enabled) { return; } @@ -192,32 +133,23 @@ export function useReviewSections(input: { return; } diffPreview.refresh(); - }, [activeTurnDiff, diffPreview, enabled, selectedSection?.kind]); + }; - const selectSection = useCallback( - (sectionId: string) => { - if (reviewCache.threadKey) { - setReviewSelectedSectionId(reviewCache.threadKey, sectionId); - } - }, - [reviewCache.threadKey], - ); + const selectSection = (sectionId: string) => { + if (reviewCache.threadKey) { + setReviewSelectedSectionId(reviewCache.threadKey, sectionId); + } + }; return { error: diffPreview.error ?? activeTurnDiff.error ?? - reviewCache.asyncState.error ?? (selectedSection === null && !fileAccess.isPending && !canReadFiles ? (fileAccess.error ?? "This connection cannot read local diffs.") : null), isSelectedSectionPending: selectedSection?.kind === "turn" ? activeTurnDiff.isPending : diffPreview.isPending, - loadingGitDiffs: fileAccess.isPending || diffPreview.isPending, - diffPreviewRevision: diffPreview.data - ? DateTime.formatIso(diffPreview.data.generatedAt) - : undefined, - loadingTurnIds, reviewSections, selectedSection, refreshSelectedSection, diff --git a/apps/mobile/src/features/settings/SettingsAboutRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsAboutRouteScreen.tsx index 4594453c2a..902e695b48 100644 --- a/apps/mobile/src/features/settings/SettingsAboutRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsAboutRouteScreen.tsx @@ -2,12 +2,13 @@ import * as Dialog from "../../components/ui/Dialog"; import { ScreenScrollView as ScrollView } from "../../components/ui/ScreenScrollView"; import Constants from "expo-constants"; import { settlePromise } from "@infinitus/client-runtime/state/runtime"; -import { useEffect, useRef, useState } from "react"; +import { useRef, useState } from "react"; import { Pressable, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppText as Text } from "../../components/ui/AppText"; import { SymbolView } from "../../components/ui/AppSymbol"; +import { useDeadlinePassed } from "../../lib/clock"; import { type AppUpdateCheckState, isAppUpdateCheckAvailable, @@ -38,7 +39,14 @@ export function SettingsAboutRouteScreen() { } function AppSettingsSection() { - const [updateState, setUpdateState] = useState("idle"); + const [checkState, setCheckState] = useState<{ + readonly state: AppUpdateCheckState; + // "Up to date" is a confirmation that fades back to idle. + readonly settledUntil: number | null; + }>({ state: "idle", settledUntil: null }); + const confirmationExpired = useDeadlinePassed(checkState.settledUntil); + const updateState = + checkState.state === "current" && confirmationExpired ? "idle" : checkState.state; const updateInFlight = useRef(false); const hiddenUpdateTapCount = useRef(0); @@ -63,12 +71,6 @@ function AppSettingsSection() { const busy = updateState === "checking" || updateState === "downloading" || updateState === "restarting"; - useEffect(() => { - if (updateState !== "current") return; - const timer = setTimeout(() => setUpdateState("idle"), 3000); - return () => clearTimeout(timer); - }, [updateState]); - async function checkForUpdate() { // disabled updates next render; the ref blocks same-frame double taps. if (updateInFlight.current) return; @@ -78,7 +80,11 @@ function AppSettingsSection() { runAppUpdateCheck({ applyMode: "immediate", onFailure: (message) => Dialog.alert("Update failed", message), - onStateChange: setUpdateState, + onStateChange: (state) => + setCheckState({ + state, + settledUntil: state === "current" ? Date.now() + 3000 : null, + }), }), ); updateInFlight.current = false; diff --git a/apps/mobile/src/features/settings/SettingsAuthRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsAuthRouteScreen.tsx index ea0dcfeb72..3424250bb1 100644 --- a/apps/mobile/src/features/settings/SettingsAuthRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsAuthRouteScreen.tsx @@ -5,7 +5,7 @@ import { UserProfileView, } from "@clerk/expo/native-components"; import { useNavigation } from "@react-navigation/native"; -import { useEffect, useLayoutEffect, useState } from "react"; +import { useEffect, useState } from "react"; import { View } from "react-native"; import { hasCloudPublicConfig } from "../cloud/publicConfig"; @@ -21,15 +21,8 @@ const USER_PROFILE_CUSTOM_PAGES = [ }, ] satisfies UserProfileCustomPage[]; +// Without cloud config the app has no entry point here and appLinking drops the deep link. export function SettingsAuthRouteScreen() { - const navigation = useNavigation(); - - useLayoutEffect(() => { - if (!hasCloudPublicConfig()) { - navigation.goBack(); - } - }, [navigation]); - return hasCloudPublicConfig() ? : null; } @@ -42,6 +35,8 @@ function ConfiguredSettingsAuthRouteScreen() { const [hasBeenSignedIn, setHasBeenSignedIn] = useState(isSignedIn === true); if (isSignedIn && !hasBeenSignedIn) setHasBeenSignedIn(true); + // Clerk reports sign-out only through its auth store (the native profile view + // and the T3 Connect page can both sign out), so this follows that store. useEffect(() => { if (hasBeenSignedIn && isLoaded && isSignedIn === false) { navigation.goBack(); diff --git a/apps/mobile/src/features/settings/SettingsNotificationsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsNotificationsRouteScreen.tsx index d1e5f2d778..d33cb9db44 100644 --- a/apps/mobile/src/features/settings/SettingsNotificationsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsNotificationsRouteScreen.tsx @@ -117,6 +117,7 @@ function ConfiguredSettingsNotificationsRouteScreen() { }; useEffect(() => { + // oxlint-disable-next-line react/set-state-in-effect -- Reads native notification permission on mount and on every foreground. void refreshNotifications(); const subscription = AppState.addEventListener("change", (state) => { if (state === "active") void refreshNotifications(); @@ -126,6 +127,7 @@ function ConfiguredSettingsNotificationsRouteScreen() { useEffect(() => { if (!isLoaded) { + // oxlint-disable-next-line react/set-state-in-effect -- Live Activity status shows checking until stored preferences load. setLiveActivityStatus("checking"); return; } diff --git a/apps/mobile/src/features/settings/SettingsProjectOverviewRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsProjectOverviewRouteScreen.tsx index f5d74ec602..63ff00903c 100644 --- a/apps/mobile/src/features/settings/SettingsProjectOverviewRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsProjectOverviewRouteScreen.tsx @@ -12,7 +12,7 @@ import { import { deriveProjectGroupLabel } from "@infinitus/client-runtime/state/project-grouping"; import type { EnvironmentProject } from "@infinitus/client-runtime/state/shell"; import { useNavigation } from "@react-navigation/native"; -import { useEffect, useRef, useState } from "react"; +import { useRef, useState } from "react"; import { Platform, Pressable, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -58,15 +58,16 @@ export function SettingsProjectOverviewRouteScreen() { setIsRemoving(false); }; const navigation = useNavigation(); - const { selectedTargets, projectGroups, selectedProjectKey, selectProject } = - useSettingsEnvironmentFilter(); - const latestProjectKey = useRef(selectedProjectKey); - useEffect(() => { - latestProjectKey.current = selectedProjectKey; - }, [selectedProjectKey]); + const { + selectedTargets, + projectGroups, + selectedProjectKey, + selectProject, + getSelectedProjectKey, + } = useSettingsEnvironmentFilter(); // The filter can move to another project while a removal is still running. const finishProjectRemoval = (projectKey: string | null) => { - if (latestProjectKey.current !== projectKey) return; + if (getSelectedProjectKey() !== projectKey) return; if (navigation.isFocused()) leaveSettingsPage(navigation, workspaceHasColumns(), DEFAULT_SETTINGS_PAGE); // Otherwise the filter keeps pointing at the removed project as an unavailable one. diff --git a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx index add986dd42..7b357172a2 100644 --- a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx @@ -32,7 +32,7 @@ import { WEBHOOK_FILTER_OPS, type WebhookFilterDraft, } from "@infinitus/client-runtime/scheduled-task-webhook"; -import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react"; +import { useRef, useState, type ReactNode } from "react"; import { useFocusEffect, useNavigation, usePreventRemove } from "@react-navigation/native"; import type { NativeStackNavigationProp } from "@react-navigation/native-stack"; import { AppState, Platform, Pressable, TextInput as RNTextInput, View } from "react-native"; @@ -78,7 +78,11 @@ import { } from "./scheduledTaskDraft"; import { settingsTargetsForProject } from "./settings-environment-filter.logic"; import { useScheduledTaskEditor } from "./scheduled-task-editor"; -import { scheduledTaskEditorSessionAtom } from "./scheduled-task-editor-state"; +import { + discardScheduledTaskEditor, + markScheduledTaskEditorSaved, + scheduledTaskEditorSessionAtom, +} from "./scheduled-task-editor-state"; import { appAtomRegistry } from "../../state/atom-registry"; import { formatNextScheduledTaskRun, @@ -449,15 +453,9 @@ export function SettingsScheduledTasksRouteScreen() { ); } +// The "New task" action and takeScheduledTaskNewLink clear the editor before +// this screen opens, so it starts from the default target. export function SettingsScheduledTaskNewRouteScreen() { - const { resetEditor } = useScheduledTaskEditor(); - const { availableTargets } = useSettingsEnvironmentFilter(); - const initialized = useRef(false); - useLayoutEffect(() => { - if (initialized.current || availableTargets.length === 0) return; - initialized.current = true; - resetEditor(); - }, [availableTargets.length, resetEditor]); return ; } @@ -466,20 +464,14 @@ export function SettingsScheduledTaskEditRouteScreen() { } function SettingsScheduledTaskEditorScreen({ title }: { readonly title: string }) { - const { - editor, - voiceOwnerKey, - readEditor, - setEditor, - startEditor, - hasChanges, - draftForEnvironment, - } = useScheduledTaskEditor(); + const { editor, voiceOwnerKey, readEditor, setEditor, hasChanges, draftForEnvironment } = + useScheduledTaskEditor(); const { availableTargets } = useSettingsEnvironmentFilter(); const navigation = useNavigation(); const insets = useSafeAreaInsets(); const [saving, setSaving] = useState(false); - const [saved, setSaved] = useState(false); + // Set once the save lands, so the removal guard lets the editor close. + const saved = useRef(false); const ownerKey = editor ? `${voiceOwnerKey}:${editor.environmentId}` : null; const prompt = editor?.draft.prompt ?? ""; const [selectionState, setSelectionState] = useState<{ @@ -518,8 +510,12 @@ function SettingsScheduledTaskEditorScreen({ title }: { readonly title: string } }, disabled: saving, }); - const preventRemove = !saved && (hasChanges || saving); + const preventRemove = hasChanges || saving; usePreventRemove(preventRemove, ({ data }) => { + if (saved.current) { + navigation.dispatch(data.action); + return; + } if (saving) { Dialog.alert("Saving task", "Wait for the task to finish saving before leaving."); return; @@ -531,19 +527,12 @@ function SettingsScheduledTaskEditorScreen({ title }: { readonly title: string } style: "destructive", onPress: () => { if (voiceInput.isBusy) voiceInput.cancel(); - startEditor(null); + discardScheduledTaskEditor(); navigation.dispatch(data.action); }, }, ]); }); - useEffect(() => { - if (!saved) return; - const frame = requestAnimationFrame(() => { - if (navigation.isFocused()) navigation.goBack(); - }); - return () => cancelAnimationFrame(frame); - }, [navigation, saved]); return ( {Platform.OS === "ios" ? ( @@ -599,7 +588,11 @@ function SettingsScheduledTaskEditorScreen({ title }: { readonly title: string } setDraft={(draft) => { if (!saving) setEditor({ ...editor, draft }); }} - onSaved={() => setSaved(true)} + onSaved={() => { + saved.current = true; + markScheduledTaskEditorSaved(); + if (navigation.isFocused()) navigation.goBack(); + }} onChangeEnvironment={(target) => { if (target.environmentId === editor.environmentId) return; setEditor({ diff --git a/apps/mobile/src/features/settings/components/SettingsLegalDocumentRouteScreen.tsx b/apps/mobile/src/features/settings/components/SettingsLegalDocumentRouteScreen.tsx index f5baee312f..fe07cf98a8 100644 --- a/apps/mobile/src/features/settings/components/SettingsLegalDocumentRouteScreen.tsx +++ b/apps/mobile/src/features/settings/components/SettingsLegalDocumentRouteScreen.tsx @@ -1,6 +1,6 @@ import { SheetCloseButton } from "../../../components/ui/SheetHeader"; -import { type NavigationProp, type ParamListBase, useNavigation } from "@react-navigation/native"; -import { useCallback, useLayoutEffect, useState } from "react"; +import { useNavigation } from "@react-navigation/native"; +import { useState } from "react"; import { Linking, Pressable, View } from "react-native"; import { WebView } from "react-native-webview"; @@ -10,6 +10,7 @@ import { AppActivityIndicator } from "../../../components/ui/AppActivityIndicato import { AppText as Text } from "../../../components/ui/AppText"; import { LoadingStrip } from "../../../components/ui/LoadingStrip"; import { SymbolView } from "../../../components/ui/AppSymbol"; +import { NativeStackScreenOptions } from "../../../native/StackHeader"; import { isLegalDocumentUrl, LEGAL_URL } from "../lib/legal-document-url"; export function SettingsLegalDocumentCloseHeaderButton() { @@ -39,6 +40,10 @@ export function SettingsLegalDocumentExternalHeaderButton({ ); } +function externalHeaderButton(externalUrl: string) { + return () => ; +} + interface SettingsLegalDocumentRouteScreenProps { readonly documentName: string; readonly documentUrl: string; @@ -48,28 +53,26 @@ export function SettingsLegalDocumentRouteScreen({ documentName, documentUrl, }: SettingsLegalDocumentRouteScreenProps) { - const navigation = useNavigation>(); const [reloadKey, setReloadKey] = useState(0); const [loadProgress, setLoadProgress] = useState(0); const [loadError, setLoadError] = useState(null); const [externalUrl, setExternalUrl] = useState(documentUrl); - const renderExternalHeaderButton = useCallback( - () => , - [externalUrl], + const headerOptions = ( + ); - useLayoutEffect(() => { - navigation.setOptions({ - headerRight: renderExternalHeaderButton, - }); - }, [navigation, renderExternalHeaderButton]); - - const openExternalUrl = useCallback((url: string) => { + const openExternalUrl = (url: string) => { void Linking.openURL(url).catch(() => undefined); - }, []); + }; if (loadError) { return ( + {headerOptions} + {headerOptions} {loadProgress > 0 && loadProgress < 1 ? : null} { expect(appAtomRegistry.get(scheduledTaskEditorSessionAtom)?.current).toBeNull(); }); }); + +describe("new scheduled task link", () => { + const editing = { + environmentId: EnvironmentId.make("environment"), + environmentLabel: "Environment", + draft: createDraft(null, null), + }; + const newTaskLink = { + routes: [ + { name: "Home" }, + { + name: "SettingsSheet", + state: { + routes: [{ name: "SettingsScheduledTasks" }, { name: "SettingsScheduledTaskNew" }], + }, + }, + ], + }; + + it("drops the last edited task so the new-task screen starts from the default", () => { + startScheduledTaskEditor(editing); + expect(takeScheduledTaskNewLink(newTaskLink)).toBe(newTaskLink); + expect(appAtomRegistry.get(scheduledTaskEditorSessionAtom)).toBeNull(); + }); + + it("keeps unsaved changes until the editor's removal guard discards them", () => { + startScheduledTaskEditor(editing); + const edited = { ...editing, draft: { ...editing.draft, prompt: "unsaved" } }; + updateScheduledTaskEditor(edited, null); + + takeScheduledTaskNewLink(newTaskLink); + expect(appAtomRegistry.get(scheduledTaskEditorSessionAtom)?.current).toBe(edited); + + discardScheduledTaskEditor(); + expect(appAtomRegistry.get(scheduledTaskEditorSessionAtom)).toBeNull(); + }); + + it("starts the new-task screen fresh after the edited task was saved", () => { + startScheduledTaskEditor(editing); + updateScheduledTaskEditor({ ...editing, draft: { ...editing.draft, prompt: "saved" } }, null); + markScheduledTaskEditorSaved(); + + takeScheduledTaskNewLink(newTaskLink); + expect(appAtomRegistry.get(scheduledTaskEditorSessionAtom)).toBeNull(); + }); + + it("closes a discarded editor as empty when no new-task link is waiting", () => { + startScheduledTaskEditor(editing); + discardScheduledTaskEditor(); + expect(appAtomRegistry.get(scheduledTaskEditorSessionAtom)?.current).toBeNull(); + }); + + it("keeps the editor for links to other screens", () => { + startScheduledTaskEditor(editing); + takeScheduledTaskNewLink({ routes: [{ name: "SettingsSheet" }] }); + expect(appAtomRegistry.get(scheduledTaskEditorSessionAtom)?.current).toBe(editing); + }); +}); diff --git a/apps/mobile/src/features/settings/scheduled-task-editor-state.ts b/apps/mobile/src/features/settings/scheduled-task-editor-state.ts index cd2f4d52c8..0c660098e9 100644 --- a/apps/mobile/src/features/settings/scheduled-task-editor-state.ts +++ b/apps/mobile/src/features/settings/scheduled-task-editor-state.ts @@ -1,8 +1,11 @@ import type { EnvironmentId } from "@infinitus/contracts"; +import type { NavigationState, PartialState } from "@react-navigation/native"; import { Atom } from "effect/reactivity"; +import { findLinkedRoute } from "../../lib/appLinking"; + import { appAtomRegistry } from "../../state/atom-registry"; -import type { ScheduledTaskDraft } from "./scheduledTaskDraft"; +import { hasScheduledTaskDraftChanges, type ScheduledTaskDraft } from "./scheduledTaskDraft"; export type ScheduledTaskEditor = { readonly environmentId: EnvironmentId; @@ -20,8 +23,22 @@ export const scheduledTaskEditorSessionAtom = Atom.make | undefined, +): PartialState | undefined { + if (findLinkedRoute(state, ["SettingsScheduledTaskNew"])) { + const session = appAtomRegistry.get(scheduledTaskEditorSessionAtom); + if (session && scheduledTaskEditorHasChanges(session.initial, session.current)) { + newTaskLinkAwaitsDiscard = true; + } else { + appAtomRegistry.set(scheduledTaskEditorSessionAtom, null); + } + } + return state; +} + +/** Drops unsaved changes; a new-task link that waited on them then opens a fresh editor. */ +export function discardScheduledTaskEditor(): void { + if (newTaskLinkAwaitsDiscard) { + newTaskLinkAwaitsDiscard = false; + appAtomRegistry.set(scheduledTaskEditorSessionAtom, null); + return; + } + startScheduledTaskEditor(null); +} diff --git a/apps/mobile/src/features/settings/scheduled-task-editor.tsx b/apps/mobile/src/features/settings/scheduled-task-editor.tsx index 0932e85f50..76aa3c6edd 100644 --- a/apps/mobile/src/features/settings/scheduled-task-editor.tsx +++ b/apps/mobile/src/features/settings/scheduled-task-editor.tsx @@ -3,15 +3,12 @@ import type { EnvironmentId } from "@infinitus/contracts"; import { useMemo, useCallback, type Dispatch, type SetStateAction } from "react"; import { useProjects, useServerConfigs } from "../../state/entities"; -import { - createDraft, - scheduledTaskDefaultModel, - hasScheduledTaskDraftChanges, -} from "./scheduledTaskDraft"; +import { createDraft, scheduledTaskDefaultModel } from "./scheduledTaskDraft"; import { useSettingsEnvironmentFilter } from "./settings-environment-filter"; import { settingsTargetsForProject } from "./settings-environment-filter.logic"; import { + scheduledTaskEditorHasChanges, scheduledTaskEditorSessionAtom, startScheduledTaskEditor, updateScheduledTaskEditor, @@ -64,12 +61,7 @@ export function useScheduledTaskEditor() { const voiceOwnerKey = `scheduled-task:${session?.id ?? 0}`; const editor = session ? session.current : defaultEditor; const initial = session ? session.initial : defaultEditor; - const hasChanges = Boolean( - initial && - editor && - (initial.environmentId !== editor.environmentId || - hasScheduledTaskDraftChanges(initial.draft, editor.draft)), - ); + const hasChanges = scheduledTaskEditorHasChanges(initial, editor); const setEditor = useCallback>>( (update) => { updateScheduledTaskEditor(update, defaultEditor); @@ -77,7 +69,7 @@ export function useScheduledTaskEditor() { [defaultEditor], ); const startEditor = startScheduledTaskEditor; - const resetEditor = useCallback(() => startEditor(defaultEditor), [defaultEditor, startEditor]); + const resetEditor = useCallback(() => startEditor(defaultEditor), [defaultEditor]); const readEditor = useCallback( () => readScheduledTaskEditor(voiceOwnerKey, defaultEditor), [defaultEditor, voiceOwnerKey], @@ -93,15 +85,6 @@ export function useScheduledTaskEditor() { resetEditor, draftForEnvironment, }), - [ - editor, - voiceOwnerKey, - readEditor, - setEditor, - startEditor, - hasChanges, - resetEditor, - draftForEnvironment, - ], + [editor, voiceOwnerKey, readEditor, setEditor, hasChanges, resetEditor, draftForEnvironment], ); } diff --git a/apps/mobile/src/features/settings/settings-environment-filter.tsx b/apps/mobile/src/features/settings/settings-environment-filter.tsx index 949a6fa9e4..2a3aa1cc2f 100644 --- a/apps/mobile/src/features/settings/settings-environment-filter.tsx +++ b/apps/mobile/src/features/settings/settings-environment-filter.tsx @@ -2,8 +2,10 @@ import type { EnvironmentId } from "@infinitus/contracts"; import { buildProjectGroups } from "@infinitus/client-runtime/state/project-grouping"; import { createContext, + useCallback, useContext, useMemo, + useRef, useState, type Dispatch, type ReactNode, @@ -57,7 +59,9 @@ interface SettingsEnvironmentSelection { readonly selectedIds: ReadonlySet | null; readonly selectedProjectKey: string | null; readonly setSelectedIds: Dispatch | null>>; - readonly setSelectedProjectKey: Dispatch>; + readonly selectProject: (projectKey: string | null) => void; + /** The selection as of now, for async work that outlives the render it started in. */ + readonly getSelectedProjectKey: () => string | null; } const SettingsEnvironmentSelectionContext = createContext( @@ -76,13 +80,28 @@ export function SettingsEnvironmentFilterProvider(props: { }) { const [selectedIds, setSelectedIds] = useState | null>(null); const [selectedProjectKey, setSelectedProjectKey] = useState(null); + // Written only by selectProject, so it can keep a key that the close reset below cleared. + // Its one reader, a project removal that finishes after Settings closed, then clears an + // already empty selection, which is harmless. + const currentProjectKey = useRef(null); if (!props.open && (selectedIds !== null || selectedProjectKey !== null)) { setSelectedIds(null); setSelectedProjectKey(null); } + const selectProject = useCallback((projectKey: string | null) => { + currentProjectKey.current = projectKey; + setSelectedProjectKey(projectKey); + }, []); + const getSelectedProjectKey = useCallback(() => currentProjectKey.current, []); const value = useMemo( - () => ({ selectedIds, selectedProjectKey, setSelectedIds, setSelectedProjectKey }), - [selectedIds, selectedProjectKey], + () => ({ + selectedIds, + selectedProjectKey, + setSelectedIds, + selectProject, + getSelectedProjectKey, + }), + [selectedIds, selectedProjectKey, selectProject, getSelectedProjectKey], ); return ( @@ -94,7 +113,8 @@ export function SettingsEnvironmentFilterProvider(props: { export function useSettingsEnvironmentFilter() { const selection = useContext(SettingsEnvironmentSelectionContext); if (selection === null) throw new Error("Settings environment filter provider is missing."); - const { selectedIds, selectedProjectKey, setSelectedIds, setSelectedProjectKey } = selection; + const { selectedIds, selectedProjectKey, setSelectedIds, selectProject, getSelectedProjectKey } = + selection; const { environments } = useEnvironments(); const projects = useProjects(); const groupingSettings = useMobileProjectGroupingSettings(); @@ -125,7 +145,8 @@ export function useSettingsEnvironmentFilter() { projectGroups, selectableProjectGroups, selectedProjectKey, - selectProject: setSelectedProjectKey, + selectProject, + getSelectedProjectKey, selectAll: () => setSelectedIds(null), toggleEnvironment: (environmentId: EnvironmentId) => setSelectedIds((current) => @@ -140,7 +161,8 @@ export function useSettingsEnvironmentFilter() { selectableProjectGroups, selectedProjectKey, setSelectedIds, - setSelectedProjectKey, + selectProject, + getSelectedProjectKey, ], ); } diff --git a/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx b/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx index cfe9021224..d49fc5f55e 100644 --- a/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx +++ b/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx @@ -121,6 +121,7 @@ function EnabledShowcaseCaptureCoordinator(props: { readonly pathname: string }) if (orientationSettled) return; const orientation = getNativeShowcaseOrientation(); if (orientation === null) { + // oxlint-disable-next-line react/set-state-in-effect -- Capture readiness follows the native orientation change. setOrientationSettled(true); return; } @@ -295,6 +296,7 @@ function EnabledShowcaseCaptureCoordinator(props: { readonly pathname: string }) (scene === "agent-activity" && !agentActivityStaged) || !isShowcaseNativeContentReady({ scene, themeId, renderSignal }) ) { + // oxlint-disable-next-line react/set-state-in-effect -- Capture readiness clears while the scene, fixture, or native content is not ready. setReadyScene(null); return; } diff --git a/apps/mobile/src/features/threads/McpAppFullscreenScreen.tsx b/apps/mobile/src/features/threads/McpAppFullscreenScreen.tsx index e1a4c3ac76..1c1a2a8a43 100644 --- a/apps/mobile/src/features/threads/McpAppFullscreenScreen.tsx +++ b/apps/mobile/src/features/threads/McpAppFullscreenScreen.tsx @@ -3,7 +3,7 @@ import { EnvironmentId, ThreadId, TurnItemId } from "@infinitus/contracts"; import type { EnvironmentThreadShell } from "@infinitus/client-runtime/state/shell"; import { turnItemDetailRevision } from "@infinitus/client-runtime/work-log/item-detail"; import { mcpAppFromToolItem } from "@infinitus/shared/toolOutput"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { Pressable, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -64,11 +64,19 @@ export function McpAppFullscreenScreen({ route }: McpAppFullscreenScreenProps) { const insets = useSafeAreaInsets(); // Wait for focus before goBack; an exit under another screen must not pop that screen. const focused = useIsFocused(); - const [exitRequested, setExitRequested] = useState(false); - const onClose = useCallback(() => { - if (navigation.isFocused()) navigation.goBack(); - else setExitRequested(true); - }, [navigation]); + const exitPending = useRef(false); + const onClose = () => { + if (navigation.isFocused()) { + navigation.goBack(); + return; + } + if (exitPending.current) return; + exitPending.current = true; + const unsubscribe = navigation.addListener("focus", () => { + unsubscribe(); + navigation.goBack(); + }); + }; const [size, setSize] = useState({ width: 0, height: 0 }); const params = route.params; const environmentId = EnvironmentId.make(params.environmentId); @@ -97,9 +105,10 @@ export function McpAppFullscreenScreen({ route }: McpAppFullscreenScreenProps) { const conversation = useThreadShell(scopeThreadRef(environmentId, conversationThreadId)); const awaitingUser = conversation?.hasPendingApprovals === true || conversation?.hasPendingUserInput === true; + // A request for the user is server state with no local handler to react in. useEffect(() => { - if ((awaitingUser || exitRequested) && focused) navigation.goBack(); - }, [awaitingUser, exitRequested, focused, navigation]); + if (awaitingUser && focused && !exitPending.current) navigation.goBack(); + }, [awaitingUser, focused, navigation]); return ( diff --git a/apps/mobile/src/features/threads/McpAppWebView.tsx b/apps/mobile/src/features/threads/McpAppWebView.tsx index 9837ca5892..de0f948c45 100644 --- a/apps/mobile/src/features/threads/McpAppWebView.tsx +++ b/apps/mobile/src/features/threads/McpAppWebView.tsx @@ -21,9 +21,10 @@ import { type McpAppReference, } from "@infinitus/shared/mcpApp"; import * as Predicate from "effect/Predicate"; +import { AsyncResult } from "effect/reactivity"; import Constants from "expo-constants"; import { useNavigation } from "@react-navigation/native"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; import { Platform, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { WebView, type WebViewMessageEvent } from "react-native-webview"; @@ -34,6 +35,7 @@ import { mobileHtmlRenderTheme } from "../../lib/htmlRenderTheme"; import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; import { uuidv4 } from "../../lib/uuid"; import { useAssetUrlState } from "../../state/assets"; +import { appAtomRegistry } from "../../state/atom-registry"; import { useThreadShell } from "../../state/entities"; import { mcpAppEnvironment } from "../../state/mcpApps"; import { orchestrationEnvironment } from "../../state/orchestration"; @@ -88,6 +90,18 @@ const confirm = (title: string, message: string, action: string) => const MAX_DOWNLOAD_BYTES = 25 * 1024 * 1024; +// The feed omits tool input and output; the app needs both. +function storedToolCall( + item: { readonly type: string; readonly input?: unknown; readonly output?: unknown } | null, +) { + if (item?.type !== "dynamic_tool") return undefined; + const output = item.output as { readonly result?: unknown } | undefined; + return { + arguments: item.input, + result: output?.result as McpAppCallToolResult | undefined, + }; +} + /** WebViews reload when moved; tear down inline before opening fullscreen and recreate it on return. */ export function ThreadMcpApp(props: { readonly environmentId: EnvironmentId; @@ -101,19 +115,29 @@ export function ThreadMcpApp(props: { readonly onExitFullscreen?: () => void; readonly height?: number; }) { - // Equal app content must retain its reference to preserve the live document host. const [height, setHeight] = useState(MCP_APP_INLINE_MAX_HEIGHT); + const [documentKey, setDocumentKeyState] = useState(0); + // The view keeps its first URL: a re-minted one would reload the app. + const [uri, setUri] = useState(null); + const [loaded, setLoaded] = useState(false); + const [navigatedAway, setNavigatedAway] = useState(false); + // Equal app content must retain its reference to preserve the live document host. + // A different app needs a new document even when its asset URL is unchanged, + // so the WebView key moves on with the document key, not only the URL. const [app, setApp] = useState(props.app); if (!mcpAppReferencesEqual(app, props.app)) { setApp(props.app); setHeight(MCP_APP_INLINE_MAX_HEIGHT); + setUri(null); + setLoaded(false); + setNavigatedAway(false); + setDocumentKeyState((value) => value + 1); } const fullscreen = props.displayMode === "fullscreen"; const navigation = useNavigation(); const insets = useSafeAreaInsets(); const [closed, setClosed] = useState(false); const [presentedFullscreen, setPresentedFullscreen] = useState(false); - const [documentKey, setDocumentKeyState] = useState(0); const { themeId, themeAppearance, themeVariables, systemColorsActive } = useAppearancePreferences(); @@ -140,11 +164,7 @@ export function ThreadMcpApp(props: { [app], ); const asset = useAssetUrlState(props.environmentId, resource); - // The view keeps its first URL: a re-minted one would reload the app. - const [uri, setUri] = useState(null); if (uri === null && asset._tag === "Success") setUri(asset.url); - const [loaded, setLoaded] = useState(false); - const [navigatedAway, setNavigatedAway] = useState(false); const [generation, setGeneration] = useState(0); const [crashed, setCrashed] = useState(false); const restart = () => { @@ -159,49 +179,41 @@ export function ThreadMcpApp(props: { setGeneration(1); }; const [secret] = useState(uuidv4); - const setDocumentKey = (next: (value: number) => number) => { + const reopenDocument = () => { setLoaded(false); setHeight(MCP_APP_INLINE_MAX_HEIGHT); setNavigatedAway(false); if (asset._tag === "Success") setUri(asset.url); - setDocumentKeyState(next); + setDocumentKeyState((value) => value + 1); }; - const openNewDocument = useRef(setDocumentKey); - useEffect(() => { - openNewDocument.current = setDocumentKey; + const returnedFromFullscreen = useEffectEvent(() => { + reopenDocument(); + setPresentedFullscreen(false); }); + // oxlint-disable-next-line t3code/react-effect-budget -- Navigation focus is outside React; listen only while the app is presented full screen, and stop on unmount. useEffect(() => { if (!presentedFullscreen) return; - return navigation.addListener("focus", () => { - openNewDocument.current((value) => value + 1); - setPresentedFullscreen(false); - }); + return navigation.addListener("focus", () => returnedFromFullscreen()); }, [navigation, presentedFullscreen]); - // The feed omits tool input and output; the app needs both. - const detail = useEnvironmentQuery( - orchestrationEnvironment.turnItem({ + const detailAtom = orchestrationEnvironment.turnItem({ + environmentId: props.environmentId, + input: { threadId: props.threadId, itemId: props.itemId, revision: props.revision }, + }); + const toolInfo = useEnvironmentQuery( + mcpAppEnvironment.toolInfoQuery({ environmentId: props.environmentId, - input: { threadId: props.threadId, itemId: props.itemId, revision: props.revision }, + input: { threadId: props.threadId, itemId: props.itemId, name: app.tool }, }), ); - const storedItem = detail.data?.item; - const toolCall = useMemo(() => { - if (storedItem?.type !== "dynamic_tool") return undefined; - const output = storedItem.output as { readonly result?: unknown } | undefined; - return { - arguments: storedItem.input, - result: output?.result as McpAppCallToolResult | undefined, - }; - }, [storedItem]); + const toolDefinition = toolInfo.data?.tool; const callTool = useAtomCommand(mcpAppEnvironment.callTool, { reportFailure: false }); - const toolInfo = useAtomCommand(mcpAppEnvironment.toolInfo, { reportFailure: false }); + const toolInfoCommand = useAtomCommand(mcpAppEnvironment.toolInfo, { reportFailure: false }); const readResource = useAtomCommand(mcpAppEnvironment.readResource, { reportFailure: false }); const updateModelContext = useAtomCommand(mcpAppEnvironment.updateModelContext, { reportFailure: false, }); - const [toolDefinition, setToolDefinition] = useState(undefined); // Read current context per message without rebuilding the host and losing its session. const conversation = useThreadShell( scopeThreadRef(props.environmentId, props.conversationThreadId), @@ -212,8 +224,9 @@ export function ThreadMcpApp(props: { awaitingUser, theme, props, + asset, callTool, - toolInfo, + toolInfo: toolInfoCommand, readResource, updateModelContext, insets, @@ -221,237 +234,255 @@ export function ThreadMcpApp(props: { navigation, }; const latest = useRef(live); + // The attached WebView owns its host; state lets the tool-call effect pair + // a host only with an atom from a render after that host existed. + const [host, setHost] = useState(null); useEffect(() => { + const previous = latest.current; latest.current = live; + // Building the context is not free, so resend only when one of its inputs changed. + if ( + previous.theme !== live.theme || + previous.insets !== live.insets || + previous.toolDefinition !== live.toolDefinition || + previous.props.width !== live.props.width || + previous.props.height !== live.props.height || + previous.props.displayMode !== live.props.displayMode + ) { + host?.updateHostContext(); + } }); + // The host sends the call once the app initializes, so a stored item that + // lands late still reaches it. A completed call can arrive under a new + // revision, which is a new atom, so the host follows the current one. + // oxlint-disable-next-line t3code/react-effect-budget -- subscribes the MCP host to the current tool call useEffect(() => { - let cancelled = false; - void latest.current - .toolInfo({ - environmentId: props.environmentId, - input: { threadId: props.threadId, itemId: props.itemId, name: app.tool }, - }) - .then((info) => { - if (!cancelled && info._tag === "Success" && info.value.tool !== undefined) { - setToolDefinition(info.value.tool); - } - }); - return () => { - cancelled = true; - }; - }, [props.environmentId, props.threadId, props.itemId, app.tool]); - const webView = useRef>(null); - const hostRef = useRef(null); + if (host === null) return; + return appAtomRegistry.subscribe( + detailAtom, + (result) => { + const call = AsyncResult.isSuccess(result) ? storedToolCall(result.value.item) : undefined; + if (call !== undefined) host.setToolCall(call); + }, + { immediate: true }, + ); + }, [host, detailAtom]); const userActive = useRef(false); - useEffect(() => { - if (uri === null) return; - const scope = () => { - const { environmentId, threadId, itemId } = latest.current.props; - return { environmentId, input: { threadId, itemId } }; - }; - const hostContext = (): McpAppHostContext => { - const current = latest.current; - const isFullscreen = current.props.displayMode === "fullscreen"; - return { - theme: current.theme.appearance, - styles: { variables: mcpAppStyleVariables(current.theme.variables) }, - displayMode: isFullscreen ? "fullscreen" : "inline", - availableDisplayModes: ["inline", "fullscreen"], - containerDimensions: isFullscreen - ? { - width: current.props.width, - height: current.props.height ?? MCP_APP_INLINE_MAX_HEIGHT, - } - : { width: current.props.width, maxHeight: MCP_APP_INLINE_MAX_HEIGHT }, - platform: "mobile", - locale: Intl.DateTimeFormat().resolvedOptions().locale, - timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, - userAgent: `t3-code/${Constants.expoConfig?.version ?? "0.0.0"}`, - deviceCapabilities: { touch: true, hover: false }, - safeAreaInsets: isFullscreen - ? { top: 0, right: current.insets.right, bottom: 0, left: current.insets.left } - : { top: 0, right: 0, bottom: 0, left: 0 }, - ...(current.toolDefinition === undefined - ? {} - : { toolInfo: { tool: current.toolDefinition } }), + // Each WebView document gets its own host: the view is keyed by document, + // restart generation and URL, and a changed app re-attaches this callback. + // Any other identity change would tear the host down, so per-render values + // are read through `latest`, never captured. + const attachWebView = useCallback( + (view: WebView | null) => { + if (view === null) return; + const reloadInline = () => { + setLoaded(false); + setHeight(MCP_APP_INLINE_MAX_HEIGHT); + setNavigatedAway(false); + const current = latest.current.asset; + if (current._tag === "Success") setUri(current.url); + setDocumentKeyState((value) => value + 1); }; - }; - const next = makeMcpAppHost({ - app, - hostVersion: Constants.expoConfig?.version ?? "0.0.0", - post: (message) => - webView.current?.injectJavaScript( - `window.__t3McpAppReceive&&window.__t3McpAppReceive(${JSON.stringify(message)});true;`, - ), - hostContext, - callTool: async ({ name, arguments: args }) => { - const { environmentId, input } = scope(); - const info = await latest.current.toolInfo({ environmentId, input: { ...input, name } }); - if (info._tag !== "Success") throw commandFailure(info); - if (!info.value.callable) throw new McpAppHostRefusal("This app cannot call that tool."); - if ( - !info.value.readOnly && - !(await confirm( - `Allow ${app.server} to run ${info.value.title ?? name}?`, - JSON.stringify(args, null, 2), - "Allow", - )) - ) { - throw new McpAppHostRefusal("Declined by the user."); - } - const result = await latest.current.callTool({ - environmentId, - input: { ...input, name, arguments: args }, - }); - if (result._tag !== "Success") throw commandFailure(result); - return result.value; - }, - readResource: async ({ uri: resourceUri }) => { - const { environmentId, input } = scope(); - const result = await latest.current.readResource({ - environmentId, - input: { ...input, uri: resourceUri }, - }); - if (result._tag !== "Success") throw commandFailure(result); - return result.value; - }, - openLink: async (url) => { - // WebViews cannot prove user activation; confirm links to prevent timer-driven navigation out of T3. - if (!(await confirm(`Open a link from ${app.server}?`, url, "Open"))) { - throw new McpAppHostRefusal("Declined by the user."); - } - if (!(await tryOpenExternalUrl(url, "mcp-app"))) { - throw new McpAppHostRefusal("The link could not be opened."); - } - }, - sendMessage: async (text) => { - if (!(await confirm(`Send this message from ${app.server}?`, text, "Send"))) { - throw new McpAppHostRefusal("Declined by the user."); - } - await enqueueThreadOutboxMessage({ - environmentId: latest.current.props.environmentId, - threadId: latest.current.props.conversationThreadId, - messageId: MessageId.make(uuidv4()), - commandId: CommandId.make(uuidv4()), - text, - attachments: [], - dispatchMode: "queue", - createdAt: new Date().toISOString(), - }); - }, - updateModelContext: async (context) => { - const { environmentId, input } = scope(); - const result = await latest.current.updateModelContext({ - environmentId, - input: { - ...input, - ...context, - conversationThreadId: latest.current.props.conversationThreadId, - }, - }); - if (result._tag !== "Success") throw commandFailure(result); - }, - requestDisplayMode: async (mode) => { - const current = latest.current.props; - // receive dispatches synchronously; read activation before awaiting teardown. - if ( - mode === "fullscreen" && - (!userActive.current || - latest.current.awaitingUser || - !latest.current.navigation.isFocused()) - ) { - return current.displayMode ?? "inline"; - } - if (mode === "fullscreen" && current.displayMode !== "fullscreen") { - await hostRef.current?.teardown(); - // Approval arrival or navigation during the wait requires reloading inline. - if (latest.current.awaitingUser || !latest.current.navigation.isFocused()) { - openNewDocument.current((value) => value + 1); - return "inline"; + const scope = () => { + const { environmentId, threadId, itemId } = latest.current.props; + return { environmentId, input: { threadId, itemId } }; + }; + const hostContext = (): McpAppHostContext => { + const current = latest.current; + const isFullscreen = current.props.displayMode === "fullscreen"; + return { + theme: current.theme.appearance, + styles: { variables: mcpAppStyleVariables(current.theme.variables) }, + displayMode: isFullscreen ? "fullscreen" : "inline", + availableDisplayModes: ["inline", "fullscreen"], + containerDimensions: isFullscreen + ? { + width: current.props.width, + height: current.props.height ?? MCP_APP_INLINE_MAX_HEIGHT, + } + : { width: current.props.width, maxHeight: MCP_APP_INLINE_MAX_HEIGHT }, + platform: "mobile", + locale: Intl.DateTimeFormat().resolvedOptions().locale, + timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, + userAgent: `t3-code/${Constants.expoConfig?.version ?? "0.0.0"}`, + deviceCapabilities: { touch: true, hover: false }, + safeAreaInsets: isFullscreen + ? { top: 0, right: current.insets.right, bottom: 0, left: current.insets.left } + : { top: 0, right: 0, bottom: 0, left: 0 }, + ...(current.toolDefinition === undefined + ? {} + : { toolInfo: { tool: current.toolDefinition } }), + }; + }; + const documentHost: McpAppHost = makeMcpAppHost({ + app, + hostVersion: Constants.expoConfig?.version ?? "0.0.0", + post: (message) => + view.injectJavaScript( + `window.__t3McpAppReceive&&window.__t3McpAppReceive(${JSON.stringify(message)});true;`, + ), + hostContext, + callTool: async ({ name, arguments: args }) => { + const { environmentId, input } = scope(); + const info = await latest.current.toolInfo({ environmentId, input: { ...input, name } }); + if (info._tag !== "Success") throw commandFailure(info); + if (!info.value.callable) throw new McpAppHostRefusal("This app cannot call that tool."); + if ( + !info.value.readOnly && + !(await confirm( + `Allow ${app.server} to run ${info.value.title ?? name}?`, + JSON.stringify(args, null, 2), + "Allow", + )) + ) { + throw new McpAppHostRefusal("Declined by the user."); } - setPresentedFullscreen(true); - latest.current.navigation.navigate("ThreadMcpApp", { - environmentId: String(current.environmentId), - threadId: String(current.threadId), - conversationThreadId: String(current.conversationThreadId), - itemId: String(current.itemId), - revision: current.revision, + const result = await latest.current.callTool({ + environmentId, + input: { ...input, name, arguments: args }, }); - return "fullscreen"; - } - if (mode === "inline" && current.displayMode === "fullscreen") { - await hostRef.current?.teardown(); - current.onExitFullscreen?.(); - return "inline"; - } - return current.displayMode ?? "inline"; - }, - downloadFile: async (files) => { - const names = files.map((file) => file.name).join(", "); - if (!(await confirm(`Save a file from ${app.server}?`, names, "Save"))) { - throw new McpAppHostRefusal("Declined by the user."); - } - for (const file of files) { - let bytes: Uint8Array | undefined; - let mimeType = file.mimeType ?? "application/octet-stream"; - if (file._tag === "embedded") { - bytes = file.bytes; - } else { - const { environmentId, input } = scope(); - const read = await latest.current.readResource({ - environmentId, - input: { ...input, uri: file.uri }, - }); - if (read._tag !== "Success") throw commandFailure(read); - const content = read.value.contents[0]; - bytes = mcpResourceBytes(content); - const declared = (content as { readonly mimeType?: unknown } | undefined)?.mimeType; - if (typeof declared === "string") mimeType = declared; + if (result._tag !== "Success") throw commandFailure(result); + return result.value; + }, + readResource: async ({ uri: resourceUri }) => { + const { environmentId, input } = scope(); + const result = await latest.current.readResource({ + environmentId, + input: { ...input, uri: resourceUri }, + }); + if (result._tag !== "Success") throw commandFailure(result); + return result.value; + }, + openLink: async (url) => { + // WebViews cannot prove user activation; confirm links to prevent timer-driven navigation out of T3. + if (!(await confirm(`Open a link from ${app.server}?`, url, "Open"))) { + throw new McpAppHostRefusal("Declined by the user."); } - if (bytes === undefined) throw new McpAppHostRefusal(`${file.name} has no contents.`); - if (bytes.byteLength > MAX_DOWNLOAD_BYTES) { - throw new McpAppHostRefusal(`${file.name} is too large to save.`); + if (!(await tryOpenExternalUrl(url, "mcp-app"))) { + throw new McpAppHostRefusal("The link could not be opened."); } - const shared = await shareGeneratedAttachment({ - bytes, - attachment: { name: file.name, mimeType }, - signal: new AbortController().signal, + }, + sendMessage: async (text) => { + if (!(await confirm(`Send this message from ${app.server}?`, text, "Send"))) { + throw new McpAppHostRefusal("Declined by the user."); + } + await enqueueThreadOutboxMessage({ + environmentId: latest.current.props.environmentId, + threadId: latest.current.props.conversationThreadId, + messageId: MessageId.make(uuidv4()), + commandId: CommandId.make(uuidv4()), + text, + attachments: [], + dispatchMode: "queue", + createdAt: new Date().toISOString(), }); - if (!shared) throw new McpAppHostRefusal("Sharing is not available on this device."); - } - }, - onRequestTeardown: () => { - const current = latest.current.props; - if (current.displayMode === "fullscreen") { - void hostRef.current?.teardown().then(() => current.onExitFullscreen?.()); - } else { - void hostRef.current?.teardown().then(() => setClosed(true)); - } - }, - onSizeChanged: (size) => { - if (latest.current.props.displayMode === "fullscreen") return; - const nextHeight = mcpAppInlineHeight(size.height); - if (nextHeight !== undefined) setHeight(nextHeight); - }, - }); - hostRef.current = next; - return () => { - // An unmount cannot wait: the request goes out before the view does. - void next.teardown(); - hostRef.current = null; - }; - // oxlint-disable-next-line react/exhaustive-effect-dependencies -- A restarted view needs a new host. - }, [uri, app, generation, documentKey]); - - useEffect(() => { - hostRef.current?.updateHostContext(); - // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Context changes trigger a resend. - }, [theme, props.width, props.height, insets, toolDefinition]); - useEffect(() => { - if (toolCall !== undefined) hostRef.current?.setToolCall(toolCall); - // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Each new document needs the call. - }, [toolCall, uri, generation, documentKey]); + }, + updateModelContext: async (context) => { + const { environmentId, input } = scope(); + const result = await latest.current.updateModelContext({ + environmentId, + input: { + ...input, + ...context, + conversationThreadId: latest.current.props.conversationThreadId, + }, + }); + if (result._tag !== "Success") throw commandFailure(result); + }, + requestDisplayMode: async (mode) => { + const current = latest.current.props; + // receive dispatches synchronously; read activation before awaiting teardown. + if ( + mode === "fullscreen" && + (!userActive.current || + latest.current.awaitingUser || + !latest.current.navigation.isFocused()) + ) { + return current.displayMode ?? "inline"; + } + if (mode === "fullscreen" && current.displayMode !== "fullscreen") { + await documentHost.teardown(); + // Approval arrival or navigation during the wait requires reloading inline. + const navigation = latest.current.navigation; + if (latest.current.awaitingUser || !navigation.isFocused()) { + reloadInline(); + return "inline"; + } + setPresentedFullscreen(true); + navigation.navigate("ThreadMcpApp", { + environmentId: String(current.environmentId), + threadId: String(current.threadId), + conversationThreadId: String(current.conversationThreadId), + itemId: String(current.itemId), + revision: current.revision, + }); + return "fullscreen"; + } + if (mode === "inline" && current.displayMode === "fullscreen") { + await documentHost.teardown(); + current.onExitFullscreen?.(); + return "inline"; + } + return current.displayMode ?? "inline"; + }, + downloadFile: async (files) => { + const names = files.map((file) => file.name).join(", "); + if (!(await confirm(`Save a file from ${app.server}?`, names, "Save"))) { + throw new McpAppHostRefusal("Declined by the user."); + } + for (const file of files) { + let bytes: Uint8Array | undefined; + let mimeType = file.mimeType ?? "application/octet-stream"; + if (file._tag === "embedded") { + bytes = file.bytes; + } else { + const { environmentId, input } = scope(); + const read = await latest.current.readResource({ + environmentId, + input: { ...input, uri: file.uri }, + }); + if (read._tag !== "Success") throw commandFailure(read); + const content = read.value.contents[0]; + bytes = mcpResourceBytes(content); + const declared = (content as { readonly mimeType?: unknown } | undefined)?.mimeType; + if (typeof declared === "string") mimeType = declared; + } + if (bytes === undefined) throw new McpAppHostRefusal(`${file.name} has no contents.`); + if (bytes.byteLength > MAX_DOWNLOAD_BYTES) { + throw new McpAppHostRefusal(`${file.name} is too large to save.`); + } + const shared = await shareGeneratedAttachment({ + bytes, + attachment: { name: file.name, mimeType }, + signal: new AbortController().signal, + }); + if (!shared) throw new McpAppHostRefusal("Sharing is not available on this device."); + } + }, + onRequestTeardown: () => { + const current = latest.current.props; + if (current.displayMode === "fullscreen") { + void documentHost.teardown().then(() => current.onExitFullscreen?.()); + } else { + void documentHost.teardown().then(() => setClosed(true)); + } + }, + onSizeChanged: (size) => { + if (latest.current.props.displayMode === "fullscreen") return; + const nextHeight = mcpAppInlineHeight(size.height); + if (nextHeight !== undefined) setHeight(nextHeight); + }, + }); + setHost(documentHost); + return () => { + // An unmount cannot wait: the request goes out before the view does. + void documentHost.teardown(); + setHost((current) => (current === documentHost ? null : current)); + }; + }, + [app], + ); const source = useMemo( () => @@ -486,7 +517,7 @@ export function ThreadMcpApp(props: { label="Show app" tone="secondary" onPress={() => { - setDocumentKey((value) => value + 1); + reopenDocument(); setClosed(false); }} /> @@ -505,8 +536,8 @@ export function ThreadMcpApp(props: { ) : uri !== null && !crashed ? ( - key={`${documentKey}:${generation}`} - ref={webView} + key={`${documentKey}:${generation}:${uri}`} + ref={attachWebView} onContentProcessDidTerminate={restart} onRenderProcessGone={restart} source={source!} @@ -534,12 +565,12 @@ export function ThreadMcpApp(props: { if (!Predicate.isObject(envelope) || envelope.secret !== secret) return; const message = envelope.message; if (Predicate.isObject(message) && message.t3 === "navigated") { - hostRef.current?.dispose(); + host?.dispose(); setNavigatedAway(true); return; } userActive.current = envelope.active === true; - hostRef.current?.receive(message); + host?.receive(message); userActive.current = false; }} /> diff --git a/apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx index 65633a200c..c9ab7a7834 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx @@ -37,6 +37,7 @@ export function NewTaskDraftRouteScreen({ route }: StaticScreenProps { reset(); + // oxlint-disable-next-line react/set-state-in-effect -- Draft reset runs once before first paint; initialized gates the screen until then. setInitialized(true); }, [reset]); const pendingTaskId = Array.isArray(params.pendingTaskId) @@ -90,6 +91,7 @@ export function NewTaskDraftRouteScreen({ route }: StaticScreenProps count + 1); // Serialize replacements: ignoring a stale result cannot undo its Git mutation. checkoutTail.current = checkoutTail.current.then(async () => { diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index c4f2044af7..158ba08fe1 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -36,6 +36,7 @@ import { import { ComposerEditor, type ComposerEditorHandle } from "../../components/ComposerEditor"; import { composerContextImportsAtom } from "../../state/use-composer-drafts"; +import { abortComposerDraftOperations } from "../../state/composerDraftOperations"; import { composerContextSendBlockReason } from "../../lib/composerContext"; import { ComposerInlineControl } from "../../components/ComposerToolbar"; import { InfinitusPinAtCreationControl } from "../infinitus/InfinitusPinAtCreationControl"; @@ -356,6 +357,7 @@ export function NewTaskDraftScreen(props: { return; } if (requestedInitialProjectAvailable) { + // oxlint-disable-next-line react/set-state-in-effect -- Ends the return-to-picker flow once the requested project arrives from the catalog. setIsReturningToProjectPicker(false); return; } @@ -494,6 +496,7 @@ export function NewTaskDraftScreen(props: { } if (projects.length > 0) { + // oxlint-disable-next-line react/set-state-in-effect -- Route-driven project resolution starts the return-to-picker flow when no project matches. setIsReturningToProjectPicker(true); } return; @@ -514,6 +517,7 @@ export function NewTaskDraftScreen(props: { projects, flow.draftKey, props.initialProjectRef, + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Opening a draft from a share, pending task, or draft id re-resolves its project. props.incomingShareId, props.pendingTaskId, props.draftId, @@ -523,6 +527,7 @@ export function NewTaskDraftScreen(props: { setProject, ]); + const { loadBranches } = flow; useEffect(() => { if (!selectedProject) { loadedBranchesProjectKeyRef.current = null; @@ -533,8 +538,8 @@ export function NewTaskDraftScreen(props: { return; } loadedBranchesProjectKeyRef.current = projectKey; - flow.loadBranches(); - }, [flow.loadBranches, selectedProject]); + loadBranches(); + }, [loadBranches, selectedProject]); useEffect(() => { const shareId = props.incomingShareId; @@ -594,6 +599,7 @@ export function NewTaskDraftScreen(props: { let didConsumeShare = false; let needsDraftRestore = false; activeShareImportTokenRef.current = importToken; + // oxlint-disable-next-line react/set-state-in-effect -- Publishes the share import started by this effect before its async work runs. setImportingShareKey(importKey); void (async () => { await reserveShare(shareId, { @@ -746,6 +752,7 @@ export function NewTaskDraftScreen(props: { consumeShare, cancelledIncomingShareId, flow.draftKey, + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- hasImportedIncomingShare reflects the draft store; a change re-checks whether the share still needs importing. hasImportedIncomingShare, incomingShare, isIncomingShareInboxLoading, @@ -929,6 +936,9 @@ export function NewTaskDraftScreen(props: { submittedThreadParams(String(message.environmentId), String(message.threadId)), ), ); + // The composer has moved to the submitted thread; a pick still converting + // would otherwise resurrect this draft. + abortComposerDraftOperations(draftKey); const draftSnapshot = getComposerDraftSnapshot(draftKey); if (editingPendingTask) { flow.finishEditingPendingTask(); diff --git a/apps/mobile/src/features/threads/ThreadArrangementSheet.tsx b/apps/mobile/src/features/threads/ThreadArrangementSheet.tsx index 4306307dae..eb95b213a4 100644 --- a/apps/mobile/src/features/threads/ThreadArrangementSheet.tsx +++ b/apps/mobile/src/features/threads/ThreadArrangementSheet.tsx @@ -102,6 +102,7 @@ function DragHandle(props: { canMoveDown: boolean; }) { const latest = useRef(props); + /* oxlint-disable react/refs -- The pan gesture captures its callbacks once per `disabled` change; they read the latest handlers when native gesture events fire. */ latest.current = props; const gesture = useMemo( () => @@ -115,8 +116,9 @@ function DragHandle(props: { .onUpdate((event) => latest.current.onMove(event.translationY)) .onEnd((event) => latest.current.onMove(event.translationY)) .onFinalize((_, success) => latest.current.onEnd(!success)), - [props.disabled], + [props.disabled, latest], ); + /* oxlint-enable react/refs */ return ( void }) { const drag = useRef(null); const frame = useRef(null); const [preview, setPreview] = useState(null); - const translateY = useRef(new Animated.Value(0)).current; + const [translateY] = useState(() => new Animated.Value(0)); const latest = useRef({ rows, planners, moveThread }); + // oxlint-disable-next-line react/refs -- The autoscroll rAF loop started at drag start reads the current rows and planners on every frame. latest.current = { rows, planners, moveThread }; function stop() { @@ -285,6 +288,7 @@ export function ThreadArrangementSheet(props: { onClose: () => void }) { .map((row) => `${row.key}:${row.thread?.pinOrderKey}:${row.thread?.activeOrderKey}`) .join("|"); useEffect(() => { + // oxlint-disable-next-line react/exhaustive-effect-dependencies, react/set-state-in-effect -- A changed thread order cancels the in-progress drag and clears its preview; stop only touches refs and state setters. stop(); }, [orderVersion]); useEffect( @@ -348,6 +352,7 @@ export function ThreadArrangementSheet(props: { onClose: () => void }) { }; setPreview({ ...drag.current }); update(0); + // oxlint-disable-next-line react/purity -- runs in an event handler (oxc#27309) let last = performance.now(); const tick = () => { const current = drag.current; diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index b43400f909..21b60cd007 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -776,11 +776,12 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps settingsSheetPresentation.open(); }; + const { present: presentSettingsRoute } = settingsRoutePresentation; useEffect(() => { if (settingsSheetPresentation.isActive) { - settingsRoutePresentation.present(settingsRouteSession); + presentSettingsRoute(settingsRouteSession); } - }, [settingsRoutePresentation.present, settingsRouteSession, settingsSheetPresentation.isActive]); + }, [presentSettingsRoute, settingsRouteSession, settingsSheetPresentation.isActive]); useEffect(() => { if (!settingsSheetPresentation.isVisible || settingsRoutePresentedRef.current) { diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 3c0951a428..249089ea65 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -421,7 +421,9 @@ function ThreadDetailBody(props: ThreadDetailScreenProps) { }; }, []); useEffect(() => { + // oxlint-disable-next-line react/set-state-in-effect -- Any native keyboard report clears the suspect flag set when the app resumed. setKeyboardStateSuspect(false); + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Any keyboard visibility or height report clears the suspect flag set when the app resumed. }, [isKeyboardVisible, liveKeyboardHeight]); const handleOwnedInputFocusChange = (focused: boolean) => { ownedInputFocusedRef.current = focused; diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index e065aa1df4..21a033fcf1 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -608,6 +608,7 @@ function MessageAttachmentFile(props: { openingRef.current?.abort(); openingRef.current = null; }; + // oxlint-disable-next-line react/memo-dependencies -- Changing the attachment or connection cancels the previous file open even while this screen stays focused. }, [props.environmentId, attachment.id, httpBaseUrl]), ); @@ -2228,6 +2229,7 @@ export const ThreadFeed = memo(function ThreadFeed({ props.threadId, props.contentPresentation.kind, ]); + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- A scope change (thread or presentation) aborts the pending link lookup opened from the old scope. useLayoutEffect(() => () => markdownLinkRequest.current?.abort(), [markdownLinkScope]); const [preview, setPreview] = useState<{ readonly scope: string; @@ -2803,6 +2805,7 @@ export const ThreadFeed = memo(function ThreadFeed({ // commit, and setEndFollow notifies the parent, so it can't run during render. useEffect(() => { if (jumpIndex !== null) landJump(jumpIndex); + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Jumping to a different message whose row index is unchanged must still land the jump. }, [jumpToMessageId, jumpIndex]); const reflowLayout = () => ({ diff --git a/apps/mobile/src/features/threads/ThreadMarkdownImage.test.tsx b/apps/mobile/src/features/threads/ThreadMarkdownImage.test.tsx new file mode 100644 index 0000000000..4a1a75e441 --- /dev/null +++ b/apps/mobile/src/features/threads/ThreadMarkdownImage.test.tsx @@ -0,0 +1,66 @@ +import type { ReactNode } from "react"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { expect, it, vi } from "vite-plus/test"; + +const harness = vi.hoisted(() => ({ + failImage: () => {}, + Passthrough: ({ children }: { children?: unknown }) => children, +})); + +vi.mock("react-native", () => ({ + Pressable: harness.Passthrough, + StyleSheet: { absoluteFill: {} }, + View: harness.Passthrough, +})); +vi.mock("expo-image", () => ({ + Image: (props: { onError: () => void }) => { + harness.failImage = props.onError; + return ; + }, +})); +vi.mock("expo/fetch", () => ({ fetch: vi.fn() })); +vi.mock("react-native-svg", () => ({ parse: vi.fn(), SvgAst: () => null })); +vi.mock("@infinitus/client-runtime/state/assets", () => ({ MISSING_FILE_ACCESS_MESSAGE: "" })); +vi.mock("../../components/ui/AppActivityIndicator", () => ({ AppActivityIndicator: () => null })); +vi.mock("../../components/FilePreviewGallery", () => ({ + useFilePreviewGallery: () => (source: unknown) => source, +})); +vi.mock("../../components/ui/AppText", () => ({ + AppText: ({ children }: { children?: ReactNode }) => {children}, +})); +vi.mock("../../components/MediaActionsMenu", () => ({ MediaActionsMenu: harness.Passthrough })); +vi.mock("../../components/ui/NativePresentation", () => ({ + PresentationSource: harness.Passthrough, +})); +vi.mock("../../state/mediaActions", () => ({ useMediaActions: () => ({ actions: [] }) })); +vi.mock("../../state/assets", () => ({ useAssetUrlState: vi.fn() })); + +import { ThreadMarkdownImageView } from "./ThreadMarkdownImage"; + +it("retries a failed image once its URI comes back through null", async () => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + const container = document.createElement("div"); + const root = createRoot(container); + const view = (uri: string | null) => ( + {}} + /> + ); + try { + await act(async () => root.render(view("https://example.test/a.png"))); + await act(async () => harness.failImage()); + expect(container.textContent).toContain("Image unavailable"); + + await act(async () => root.render(view(null))); + await act(async () => root.render(view("https://example.test/a.png"))); + expect(container.textContent).not.toContain("Image unavailable"); + expect(container.querySelector("img")).not.toBeNull(); + } finally { + await act(async () => root.unmount()); + } +}); diff --git a/apps/mobile/src/features/threads/ThreadMarkdownImage.tsx b/apps/mobile/src/features/threads/ThreadMarkdownImage.tsx index e1cb6aed34..ee8e6bd0d4 100644 --- a/apps/mobile/src/features/threads/ThreadMarkdownImage.tsx +++ b/apps/mobile/src/features/threads/ThreadMarkdownImage.tsx @@ -45,16 +45,15 @@ export function ThreadMarkdownImageView(props: { measuredWidth > 0 && contextWidth > 0 ? Math.min(contextWidth, measuredWidth) : contextWidth || measuredWidth; - const [decodedSize, setDecodedSize] = useState<{ width: number; height: number } | null>(null); - const [failedUri, setFailedUri] = useState(null); - - useEffect(() => { - setDecodedSize(null); - }, [props.sourceKey]); - - useEffect(() => { - setFailedUri(null); - }, [props.uri]); + // Keyed by source so a new source drops the previous image's decoded dimensions. + const [decoded, setDecoded] = useState<{ + sourceKey: string; + size: { width: number; height: number }; + } | null>(null); + const decodedSize = decoded?.sourceKey === props.sourceKey ? decoded.size : null; + // A failure belongs to one URI; any URI change, including through null, retries. + const [failure, setFailure] = useState({ uri: props.uri, failed: false }); + if (failure.uri !== props.uri) setFailure({ uri: props.uri, failed: false }); const sourceSize = decodedSize ?? props.knownSize ?? null; const displaySize: MarkdownImageDisplaySize | null = @@ -65,7 +64,8 @@ export function ThreadMarkdownImageView(props: { sourceHeight: sourceSize.height, availableWidth, }); - const failed = props.unavailable || (props.uri !== null && failedUri === props.uri); + const failed = + props.unavailable || (props.uri !== null && failure.uri === props.uri && failure.failed); const placeholderWidth: ViewStyle["width"] = availableWidth > 0 ? Math.min(availableWidth, MARKDOWN_IMAGE_MAX_WIDTH) : "100%"; const frameStyle: ViewStyle = displaySize ?? { width: placeholderWidth, aspectRatio: 16 / 9 }; @@ -127,8 +127,8 @@ export function ThreadMarkdownImageView(props: { key={props.sourceKey} uri={props.uri} format={props.format} - onLoad={setDecodedSize} - onError={() => setFailedUri(props.uri)} + onLoad={(size) => setDecoded({ sourceKey: props.sourceKey, size })} + onError={() => setFailure({ uri: props.uri, failed: true })} /> diff --git a/apps/mobile/src/features/threads/ThreadMarkdownVideo.tsx b/apps/mobile/src/features/threads/ThreadMarkdownVideo.tsx index 11869a0fcb..5f4b5ec263 100644 --- a/apps/mobile/src/features/threads/ThreadMarkdownVideo.tsx +++ b/apps/mobile/src/features/threads/ThreadMarkdownVideo.tsx @@ -39,7 +39,7 @@ export function ThreadMarkdownVideo(props: { name={source.name} thumbnailKey={thumbnailKey} thumbnailVisible={visible} - paused={!visible} + visible={visible} onPlaybackStart={props.onPlaybackStart} unavailable={"resource" in source && asset._tag === "Failure"} actionsSource={source.actionsSource} diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index e5652020d5..085733272c 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -274,6 +274,7 @@ function ThreadNavigationSidebarPane( const nowMinute = now.slice(0, 16); const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0); useEffect(() => { + // oxlint-disable-next-line react/set-state-in-effect -- Refreshes the minute clock when the minute interval starts. setNow(new Date().toISOString()); const id = setInterval(() => setNow(new Date().toISOString()), 60_000); return () => clearInterval(id); @@ -330,7 +331,7 @@ function ThreadNavigationSidebarPane( bumpSnoozeWakeTick((tick) => tick + 1); }, delayMs); return () => clearTimeout(id); - // snoozeWakeTick re-arms clamped timers even when the wake date exceeds 32-bit setTimeout and stays unchanged. + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- snoozeWakeTick re-arms clamped timers even when the wake date exceeds 32-bit setTimeout and stays unchanged. }, [nextSnoozeWakeAt, snoozeWakeTick]); const listItems: readonly SidebarListItem[] = settledShelfExpanded && threadListV2Layout.hiddenSettledCount > 0 @@ -566,7 +567,6 @@ function ThreadNavigationSidebarPane( snoozeWakeLabelText={item.snoozeWakeLabelText} timeLabel={item.timeLabel} project={projectByKey.get(scopeKey) ?? null} - projectTitle={projectTitleByProjectKey.get(scopeKey)} providerInstance={resolveProviderInstance(thread)} providers={providersByEnvironmentId.get(thread.environmentId)} environmentLabel={ diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index d3bc325bbb..f5c0a34cbc 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -431,6 +431,7 @@ function ThreadRouteContent( ]); useEffect(() => { + // oxlint-disable-next-line react/set-state-in-effect -- Inspector selection follows route ownership but keeps an inspector the user opened. setInspectorSelection((current) => { if (props.renderInspector === undefined) { if (current === null || current.mode === "route") { diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index f66ace0efa..a3124f1ad7 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -418,6 +418,7 @@ export function NewTaskFlowProvider( ) { return; } + // oxlint-disable-next-line react/set-state-in-effect -- Creates the persistent draft once the project selection is ready; the key must exist before the composer mounts. setActiveDraftKey( createNewTaskDraft({ environmentId: selectedProject.environmentId, diff --git a/apps/mobile/src/features/threads/thread-inspector-content-stack.tsx b/apps/mobile/src/features/threads/thread-inspector-content-stack.tsx index 9043a1f918..1dafb439f2 100644 --- a/apps/mobile/src/features/threads/thread-inspector-content-stack.tsx +++ b/apps/mobile/src/features/threads/thread-inspector-content-stack.tsx @@ -58,6 +58,7 @@ export function ThreadInspectorContentStack(props: { ); useEffect(() => { + // oxlint-disable-next-line react/set-state-in-effect -- Keeps opened inspector views mounted so UIKit keeps their focus and scroll. setMountedModes((current) => { if (current.has(props.mode)) { return current; diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 1ed296211c..136719b1a2 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -70,6 +70,12 @@ import { ThreadSearchMatchExcerpt } from "./thread-search-match"; import { PullRequestGlyph } from "../pull-requests/pullRequestGlyphs"; import { pullRequestStatus } from "../pull-requests/pullRequestStatus"; +const TITLE_FAVICON_SIZE = 15; +// Centers the favicon on the first line of the text-base title. +const TITLE_FAVICON_OFFSET = 4; +// Lines below the title start where the title text does. +const TITLE_TEXT_INSET = TITLE_FAVICON_SIZE + 8; + interface StatusLabel { readonly label: string; readonly icon: AppSymbolName; @@ -568,7 +574,6 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { readonly timeLabel: string; readonly snoozePresetMinute: string; readonly project: EnvironmentProject | null; - readonly projectTitle?: string; readonly providers: ReadonlyArray | undefined; readonly providerInstance: ThreadRowProviderInstance | null; readonly environmentLabel: string | null; @@ -731,6 +736,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { const canUnsettle = variant === "slim"; // Keep time in state; cached render-time reads freeze the gate and trigger 50ms expiry loops. const [snoozeGateClock, setSnoozeGateClock] = useState(() => ({ thread, now: Date.now() })); + // oxlint-disable-next-line react/purity -- Reseeds the snooze gate clock only in the render-phase adjustment that runs when the thread changes. if (snoozeGateClock.thread !== thread) setSnoozeGateClock({ thread, now: Date.now() }); const snoozeGateNow = new Date(snoozeGateClock.now).toISOString(); const snoozeGateExpiryMs = props.snoozeSupported @@ -752,7 +758,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { snoozed: snoozedRow, }); const snoozePresets = useMemo( + // oxlint-disable-next-line react/purity -- The memo recomputes on the snoozePresetMinute tick, so preset times refresh every minute. () => (swipeActions.secondary === "snooze" ? resolveSnoozePresets(new Date()) : ([] as const)), + // oxlint-disable-next-line react/memo-dependencies -- The minute tick refreshes relative snooze times while the row remains mounted. [props.snoozePresetMinute, swipeActions.secondary], ); const snoozePresetActions = useMemo( @@ -799,7 +807,6 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { props.reorderSupported, props.pinningSupported, thread.pinnedAt, - variant, ], ); const autoSettleMenuItems = useMemo( @@ -1016,119 +1023,117 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { ? `Opens the thread. Swipe left to ${primaryAction.label.toLowerCase()}.` : `Opens the thread. Swipe left for ${primaryAction.label.toLowerCase()} and snooze actions.`; + const titleTextInset = props.project ? TITLE_TEXT_INSET : 0; const cardContent = ( <> - + {props.project ? ( - + + + ) : null} - {props.projectTitle ?? props.project?.title ?? ""} + {thread.title} - {props.hasQueuedMessages ? : null} - {pinnedRow ? ( - - ) : null} - {showsWoke ? ( - undefined} - style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })} - > + + {props.hasQueuedMessages ? : null} + {pinnedRow ? ( - undefined} + style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })} > - Woke - - - ) : statusLabel ? ( - - + + + Woke + + + ) : statusLabel ? ( + + + + {statusLabel.label} + {status === "working" ? ( + + ) : status === "waiting" ? ( + + ) : null} + + + ) : ( - {statusLabel.label} - {status === "working" ? ( - - ) : status === "waiting" ? ( - - ) : null} + {timeLabel} - - ) : ( - - {timeLabel} - - )} + )} + - - {thread.title} - {props.searchMatch ? ( - + ) : null} +<<<<<<< HEAD {limitStop !== null || ((status === "failed" || status === "limited") && thread.runtime?.lastError) ? ( +======= + + {(status === "failed" || status === "limited") && thread.runtime?.lastError ? ( +>>>>>>> upstream-sync-cc0b86ddf-upstream-renamed {limitStop !== null ? limitStopDetailLine(limitStop) : thread.runtime?.lastError} - ) : thread.branch || props.environmentLabel ? ( + ) : props.environmentLabel ? ( - - {thread.branch ? ( - - {thread.branch} - - ) : null} - {thread.branch && props.environmentLabel ? " · " : null} - {props.environmentLabel ? ( - - {props.environmentLabel} - - ) : null} - - {props.environmentLabel && props.environmentMachine ? ( + {props.environmentMachine ? ( ) : null} + + {props.environmentLabel} + ) : ( diff --git a/apps/mobile/src/features/threads/use-composer-command-menu.test.ts b/apps/mobile/src/features/threads/use-composer-command-menu.test.ts index e064bbd7f0..276e388a27 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.test.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { ProviderDriverKind } from "@infinitus/contracts"; -const refreshProviders = vi.hoisted(() => vi.fn()); vi.mock("react-native", () => ({ Alert: { alert: vi.fn() } })); vi.mock("../../state/queries", () => ({ @@ -15,10 +14,10 @@ vi.mock("../../state/use-composer-drafts", () => ({ })); vi.mock("../../lib/uuid", () => ({ uuidv4: () => "context-id" })); vi.mock("../../state/server", () => ({ - serverEnvironment: { refreshProviders: Symbol("refreshProviders") }, -})); -vi.mock("../../state/use-atom-command", () => ({ - useAtomCommand: () => refreshProviders, + serverEnvironment: { + providersValueAtom: vi.fn(), + refreshProviders: Symbol("refreshProviders"), + }, })); import { diff --git a/apps/mobile/src/features/threads/use-composer-command-menu.ts b/apps/mobile/src/features/threads/use-composer-command-menu.ts index a62fad1317..d31000df85 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.ts @@ -12,10 +12,7 @@ import { matchComposerThreadItems } from "@infinitus/client-runtime/composerThre import type { EnvironmentThreadShell } from "@infinitus/client-runtime/state/models"; const EMPTY_THREAD_SHELLS: ReadonlyArray = []; -import { - COMPOSER_CONTEXT_MAX_RECORDS, - PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS, -} from "@infinitus/contracts"; +import { COMPOSER_CONTEXT_MAX_RECORDS } from "@infinitus/contracts"; import { formatComposerContextReference } from "@infinitus/shared/composerContextReferences"; import { pullRequestComposerContext, threadComposerContext } from "../../lib/composerContext"; import { uuidv4 } from "../../lib/uuid"; @@ -37,20 +34,20 @@ import { scoreQueryMatch, } from "@infinitus/shared/searchRanking"; import { + createProviderWorkspaceSnapshotRefreshAtomFamily, dedupeProviderSkillsByName, getProviderSkillsForSlashMenu, getProviderSlashCommandsForSlashMenu, isProviderSkillUserInvocable, - hasCompleteProviderWorkspaceSnapshot, - hasCurrentProviderWorkspaceSnapshot, resolveProviderSkillsForCwd, resolveProviderSlashCommandsForCwd, } from "@infinitus/client-runtime/providerSkills"; +import { useAtomRefresh } from "@effect/atom-react"; +import { Atom } from "effect/reactivity"; import { useEffect, useRef, useState } from "react"; import type { ComposerEditorSelection } from "../../components/ComposerEditor"; import { serverEnvironment } from "../../state/server"; -import { useAtomCommand } from "../../state/use-atom-command"; import { useComposerPathSearch, useComposerPullRequestSearch, @@ -60,10 +57,21 @@ import type { ComposerCommandItem } from "./ComposerCommandPopover"; import { matchesSlashSkillQuery } from "./composerSlashSkillSearch"; import { promptSnippetCommandItems } from "./promptSnippetItems"; +<<<<<<< HEAD const WORKSPACE_SNAPSHOT_RETRY_COOLDOWN_MS = 10_000; const NO_PROMPT_SNIPPETS: readonly PromptSnippet[] = []; // A cwd that never scans, such as a deleted worktree, would otherwise retry forever. const WORKSPACE_SNAPSHOT_MAX_ATTEMPTS = 3; +======= +const providerWorkspaceSnapshotRefresh = createProviderWorkspaceSnapshotRefreshAtomFamily({ + providers: serverEnvironment.providersValueAtom, + refreshProviders: serverEnvironment.refreshProviders, +}); + +const idleWorkspaceSnapshotRefreshAtom: Atom.Atom = Atom.make(undefined).pipe( + Atom.withLabel("mobile:provider-workspace-snapshot-refresh:idle"), +); +>>>>>>> upstream-sync-cc0b86ddf-upstream-renamed function composerSelectionAtEnd(draftMessage: string): ComposerEditorSelection { return { start: draftMessage.length, end: draftMessage.length }; @@ -223,12 +231,10 @@ export function useComposerCommandMenu({ // Thread mentions rank by recency as of when the composer mounted. const [now] = useState(() => Date.now()); const previousOwnerKeyRef = useRef(ownerKey); - const onSelectionChange = (nextSelection: ComposerEditorSelection) => { - setSelection(nextSelection); - }; useEffect(() => { const inserted = ownerKey ? readComposerDraftSelection(ownerKey, draftMessage) : null; if (inserted) { + // oxlint-disable-next-line react/set-state-in-effect -- Applies a selection inserted into the draft store by another surface. setSelection((current) => current.start === inserted.start && current.end === inserted.end ? current : inserted, ); @@ -253,107 +259,28 @@ export function useComposerCommandMenu({ const skills = selectedProviderStatus ? resolveProviderSkillsForCwd(selectedProviderStatus, projectCwd) : []; - const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, { - reportFailure: false, - }); const selectedProviderInstanceId = selectedProviderStatus?.instanceId; - const hasWorkspaceSnapshot = hasCompleteProviderWorkspaceSnapshot( - selectedProviderStatus, - projectCwd, - ); - // Record client request time to enforce TTL even when the client clock runs ahead of the server. - const workspaceRefreshKeyRef = useRef<{ key: string; requestedAt: number } | null>(null); - const failedRefreshesRef = useRef<{ key: string; count: number } | null>(null); - const [workspaceRefreshRetry, setWorkspaceRefreshRetry] = useState<{ - key: string; - notBefore: number; - } | null>(null); - const workspaceRefreshScopeKey = + const refreshWorkspaceSnapshot = useAtomRefresh( environmentId && projectCwd && selectedProviderInstanceId - ? `${environmentId}:${selectedProviderInstanceId}:${projectCwd}` - : null; - const workspaceSlashCommandsPending = - selectedProviderStatus?.workspaceSnapshots?.some( - (snapshot) => snapshot.cwd === projectCwd && snapshot.slashCommandsPending === true, - ) ?? false; - useEffect(() => { - if ( - !workspaceSlashCommandsPending || - !workspaceRefreshRetry || - workspaceRefreshRetry.key !== workspaceRefreshScopeKey - ) - return; - const timeout = setTimeout( - () => { - setWorkspaceRefreshRetry((current) => (current === workspaceRefreshRetry ? null : current)); - }, - Math.max(0, workspaceRefreshRetry.notBefore - Date.now()), - ); - return () => clearTimeout(timeout); - }, [workspaceRefreshRetry, workspaceRefreshScopeKey, workspaceSlashCommandsPending]); - const hadWorkspaceSnapshotRef = useRef(false); - useEffect(() => { - if (hadWorkspaceSnapshotRef.current && !hasWorkspaceSnapshot) { - workspaceRefreshKeyRef.current = null; - setWorkspaceRefreshRetry(null); - } - hadWorkspaceSnapshotRef.current = hasWorkspaceSnapshot; - }, [hasWorkspaceSnapshot]); + ? providerWorkspaceSnapshotRefresh({ + environmentId, + instanceId: selectedProviderInstanceId, + cwd: projectCwd, + }) + : idleWorkspaceSnapshotRefreshAtom, + ); + // Every draft change rechecks a stale snapshot, including drafts written by + // another surface (a prompt link) that never move the editor selection. useEffect(() => { - if (!environmentId || !projectCwd || !selectedProviderInstanceId) return; - const key = `${environmentId}:${selectedProviderInstanceId}:${projectCwd}`; - const now = Date.now(); - const lastRequest = workspaceRefreshKeyRef.current; - if ( - lastRequest?.key === key && - now - lastRequest.requestedAt < PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS - ) - return; - if (hasCurrentProviderWorkspaceSnapshot(selectedProviderStatus, projectCwd, now)) { - setWorkspaceRefreshRetry(null); - return; - } - const retry = workspaceRefreshRetry; - if (retry?.key === key && now < retry.notBefore) return; - const request = { key, requestedAt: now }; - workspaceRefreshKeyRef.current = request; - const retryLater = () => { - if (workspaceRefreshKeyRef.current !== request) return; - const failed = failedRefreshesRef.current; - const count = failed?.key === key ? failed.count + 1 : 1; - failedRefreshesRef.current = { key, count }; - // Keeping the request marker lets the TTL hold off the next scan. - if (count >= WORKSPACE_SNAPSHOT_MAX_ATTEMPTS) return; - workspaceRefreshKeyRef.current = null; - setWorkspaceRefreshRetry({ - key, - notBefore: Date.now() + WORKSPACE_SNAPSHOT_RETRY_COOLDOWN_MS, - }); - }; - void refreshProviders({ - environmentId, - input: { instanceId: selectedProviderInstanceId, cwd: projectCwd }, - }).then((result) => { - const refreshed = - result._tag === "Success" && - hasCompleteProviderWorkspaceSnapshot( - result.value.providers.find( - (provider) => provider.instanceId === selectedProviderInstanceId, - ), - projectCwd, - ); - if (!refreshed) retryLater(); - else failedRefreshesRef.current = null; - }, retryLater); + refreshWorkspaceSnapshot(); }, [ + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Draft changes are the recheck signal. draftMessage, - environmentId, - projectCwd, - refreshProviders, - selectedProviderInstanceId, - selectedProviderStatus, - workspaceRefreshRetry, + refreshWorkspaceSnapshot, ]); + const onSelectionChange = (nextSelection: ComposerEditorSelection) => { + setSelection(nextSelection); + }; const trigger = (() => { if (!enabled || selection.start !== selection.end) { diff --git a/apps/mobile/src/features/threads/use-worktree-setup.ts b/apps/mobile/src/features/threads/use-worktree-setup.ts index c92238b302..348aa86168 100644 --- a/apps/mobile/src/features/threads/use-worktree-setup.ts +++ b/apps/mobile/src/features/threads/use-worktree-setup.ts @@ -27,6 +27,7 @@ export function useWorktreeSetup(input: { ); const snapshot = resolveWorktreeSetupSnapshot(input.threadId, query.data, live); useEffect(() => { + // oxlint-disable-next-line react/set-state-in-effect -- Holds the last setup snapshot while the live subscription changes over. if (snapshot && snapshot !== live) setHeld({ key, snapshot }); }, [key, live, snapshot]); return { diff --git a/apps/mobile/src/features/usage/UsageLimitsSection.test.ts b/apps/mobile/src/features/usage/UsageLimitsSection.test.ts deleted file mode 100644 index 8db3f28837..0000000000 --- a/apps/mobile/src/features/usage/UsageLimitsSection.test.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { EnvironmentId } from "@infinitus/contracts"; -import { beforeEach, expect, it, vi } from "vite-plus/test"; - -const state = vi.hoisted(() => ({ - values: [] as unknown[], - cursor: 0, - presentations: new Map(), - refreshProviders: vi.fn(), - autoRefresh: async () => {}, - refreshingRef: { current: false }, -})); -vi.mock("../../state/session", () => ({ - useEnvironmentScope: () => true, - readEnvironmentScope: () => true, -})); -vi.mock("react", () => ({ - useState: (initial: unknown) => { - const index = state.cursor++; - if (!(index in state.values)) { - state.values[index] = typeof initial === "function" ? initial() : initial; - } - return [ - state.values[index], - (next: unknown) => { - state.values[index] = typeof next === "function" ? next(state.values[index]) : next; - }, - ]; - }, - useRef: () => state.refreshingRef, - useEffect: () => {}, - useEffectEvent: (callback: () => Promise) => { - state.autoRefresh = callback; - return callback; - }, -})); -vi.mock("@effect/atom-react", () => ({ useAtomValue: () => state.presentations })); -vi.mock("react-native", () => ({ Alert: {}, Pressable: "button", View: "div" })); -vi.mock("../../components/ui/AppText", () => ({ AppText: "span" })); -vi.mock("../../components/ProviderIcon", () => ({ ProviderIcon: () => null })); -vi.mock("./usageProviders", () => ({ useProviderColors: () => ({}) })); -vi.mock("../../state/presentation", () => ({ - environmentPresentations: { presentationsAtom: null }, -})); -vi.mock("../../state/server", () => ({ serverEnvironment: { refreshProviders: null } })); -vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => state.refreshProviders })); - -import { useRefreshLimits } from "./UsageLimitsSection"; -import { refreshUsageLimits } from "@infinitus/client-runtime/state/usage"; - -beforeEach(() => { - state.values = []; - state.cursor = 0; - state.refreshingRef.current = false; - state.refreshProviders.mockReset(); -}); - -it("keeps a newer environment failure when an older refresh finishes", async () => { - const a = EnvironmentId.make("mobile-limits-a"); - const b = EnvironmentId.make("mobile-limits-b"); - const pending = Promise.withResolvers<{ _tag: string }>(); - const read = () => { - state.cursor = 0; - return useRefreshLimits(); - }; - const presentation = (label: string) => ({ - connection: { phase: "connected" }, - entry: { target: { label } }, - }); - state.presentations = new Map([[a, presentation("A")]]); - state.refreshProviders.mockImplementation(({ environmentId }) => - environmentId === a ? pending.promise : Promise.resolve({ _tag: "Failure" }), - ); - const first = read().refresh(); - state.presentations = new Map([ - [a, presentation("A")], - [b, presentation("B")], - ]); - read(); - await state.autoRefresh(); - expect(read().failedLabels).toEqual(["B"]); - pending.resolve({ _tag: "Success" }); - await first; - expect(read().failedLabels).toEqual(["B"]); -}); - -it("does not let an older multi-environment batch clear a newer failure for the same environment", async () => { - const a = EnvironmentId.make("mobile-limits-race-a"); - const b = EnvironmentId.make("mobile-limits-race-b"); - const aFirst = Promise.withResolvers<{ _tag: string }>(); - const bFirst = Promise.withResolvers<{ _tag: string }>(); - const read = (selected: ReadonlySet | null = null) => { - state.cursor = 0; - return useRefreshLimits(selected); - }; - const presentation = (label: string) => ({ - connection: { phase: "connected" }, - entry: { target: { label } }, - }); - state.presentations = new Map([ - [a, presentation("A")], - [b, presentation("B")], - ]); - let aCalls = 0; - state.refreshProviders.mockImplementation(({ environmentId }) => - environmentId === b - ? bFirst.promise - : ++aCalls === 1 - ? aFirst.promise - : Promise.resolve({ _tag: "Failure" }), - ); - read(); - const older = state.autoRefresh(); - aFirst.resolve({ _tag: "Success" }); - await refreshUsageLimits(a, () => aFirst.promise); - const selected = new Set([a]); - await read(selected).refresh(); - expect(read(selected).failedLabels).toEqual(["A"]); - bFirst.resolve({ _tag: "Success" }); - await older; - expect(read(selected).failedLabels).toEqual(["A"]); -}); diff --git a/apps/mobile/src/features/usage/UsageLimitsSection.tsx b/apps/mobile/src/features/usage/UsageLimitsSection.tsx index dab8ffa1f0..6f6171f47f 100644 --- a/apps/mobile/src/features/usage/UsageLimitsSection.tsx +++ b/apps/mobile/src/features/usage/UsageLimitsSection.tsx @@ -1,5 +1,5 @@ import * as Dialog from "../../components/ui/Dialog"; -import { useAtomValue } from "@effect/atom-react"; +import { RegistryContext, useAtomMount, useAtomValue } from "@effect/atom-react"; import { AuthProvidersManageScope, type EnvironmentId, @@ -17,8 +17,8 @@ import { paceRemainingPercent, remainingPercent, } from "@infinitus/shared/usageLimits"; -import { type ReactNode, useEffect, useEffectEvent, useRef, useState } from "react"; -import { refreshUsageLimits } from "@infinitus/client-runtime/state/usage"; +import { type ReactNode, useContext, useRef, useState } from "react"; +import { Atom } from "effect/reactivity"; import { Linking, Pressable, View } from "react-native"; import { AppText as Text } from "../../components/ui/AppText"; @@ -27,6 +27,13 @@ import { environmentPresentations } from "../../state/presentation"; import { serverEnvironment } from "../../state/server"; import { readEnvironmentScope, useEnvironmentScope } from "../../state/session"; import { useAtomCommand } from "../../state/use-atom-command"; +import { useNowMinuteMs } from "../../lib/clock"; +import { + environmentIdsKey, + limitsAutoRefreshSetAtom, + limitsRefreshFailuresAtom, + refreshEnvironmentLimits, +} from "./usageLimitsRefresh"; import { usageProviderKind, useProviderColors } from "./usageProviders"; const PACE_LABEL = { ahead: "ahead of pace", on: "on pace", under: "under pace" } as const; @@ -334,79 +341,54 @@ export function ResetCredits(props: { ); } +const IDLE_ATOM = Atom.make(undefined); + export function useRefreshLimits( selectedEnvironmentIds: ReadonlySet | null = null, active = false, ) { + const registry = useContext(RegistryContext); const presentations = useAtomValue(environmentPresentations.presentationsAtom); - const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, { - reportFailure: false, - }); - const [now, setNow] = useState(() => Date.now()); + const now = useNowMinuteMs(); const [refreshing, setRefreshing] = useState(false); const refreshingRef = useRef(false); - const [failedEnvironments, setFailedEnvironments] = useState< - readonly { environmentId: EnvironmentId; label: string }[] - >([]); - const refresh = async (automatic = false, afterPending = false) => { - const connected = [...presentations].filter( - ([environmentId, presentation]) => - presentation.connection.phase === "connected" && - (selectedEnvironmentIds === null || selectedEnvironmentIds.has(environmentId)), + const selected = [...presentations].filter( + ([environmentId]) => + selectedEnvironmentIds === null || selectedEnvironmentIds.has(environmentId), + ); + const connectedIds = selected + .filter(([, presentation]) => presentation.connection.phase === "connected") + .map(([environmentId]) => environmentId); + const connectedKey = environmentIdsKey(connectedIds); + useAtomMount(active && connectedKey !== "" ? limitsAutoRefreshSetAtom(connectedKey) : IDLE_ATOM); + const failedIds = new Set( + useAtomValue(limitsRefreshFailuresAtom(environmentIdsKey(selected.map(([id]) => id)))), + ); + const failedLabels = selected + .filter(([environmentId]) => failedIds.has(environmentId)) + .map(([, presentation]) => presentation.entry.target.label); + + const refresh = (afterPending: boolean) => + Promise.all( + connectedIds.map((environmentId) => + refreshEnvironmentLimits(registry, environmentId, { afterPending }), + ), ); - await Promise.all( - connected.map(async ([environmentId, presentation]) => { - const result = await refreshUsageLimits( - environmentId, - () => refreshProviders({ environmentId, input: {} }), - automatic, - afterPending, - ); - if (result === undefined) return; - setFailedEnvironments((previous) => [ - ...previous.filter((failed) => failed.environmentId !== environmentId), - ...(result._tag === "Failure" - ? [{ environmentId, label: presentation.entry.target.label }] - : []), - ]); - }), - ).finally(() => setNow(Date.now())); - }; // Android RefreshControl needs a true-to-false cycle to stop, even with nothing to probe. const refreshManually = async () => { if (refreshingRef.current) return; refreshingRef.current = true; setRefreshing(true); - await refresh().finally(() => { + await refresh(false).finally(() => { refreshingRef.current = false; setRefreshing(false); }); }; - const connectedLimitsEnvironments = [...presentations] - .filter( - ([environmentId, presentation]) => - presentation.connection.phase === "connected" && - (selectedEnvironmentIds === null || selectedEnvironmentIds.has(environmentId)), - ) - .map(([environmentId]) => environmentId) - .sort() - .join(","); - const autoRefreshLimits = useEffectEvent(() => refresh(true)); - useEffect(() => { - if (active && connectedLimitsEnvironments) void autoRefreshLimits(); - }, [active, connectedLimitsEnvironments]); - - const failedLabels = failedEnvironments - .filter( - ({ environmentId }) => - selectedEnvironmentIds === null || selectedEnvironmentIds.has(environmentId), - ) - .map(({ label }) => label); return { now, refreshing, failedLabels, refresh: refreshManually, - refreshAfterEnable: () => refresh(false, true), + refreshAfterEnable: () => refresh(true), }; } diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx index 01b3f4a7de..d7614a7af7 100644 --- a/apps/mobile/src/features/usage/UsageRouteScreen.tsx +++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx @@ -32,19 +32,13 @@ import { formatUsd, makeWindow, } from "@infinitus/shared/usageFormat"; -import { useLayoutEffect, useRef, useState, type ReactNode } from "react"; +import { useRef, useState, type ReactNode } from "react"; import { Platform, Pressable, RefreshControl, View } from "react-native"; -import Animated, { - FadeIn, - ReduceMotion, - useAnimatedStyle, - useSharedValue, - withDelay, - withTiming, -} from "react-native-reanimated"; +import Animated, { FadeIn, ReduceMotion } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { SegmentedControl } from "../../components/ui/SegmentedControl"; +import { useReduceMotionEnabled } from "../../lib/appState"; import { AppText as Text } from "../../components/ui/AppText"; import { ProviderIcon } from "../../components/ProviderIcon"; import { cn } from "../../lib/cn"; @@ -407,15 +401,14 @@ function UsageUpdating({ readonly label: string | null; readonly children: ReactNode; }) { - const opacity = useSharedValue(1); - useLayoutEffect(() => { - opacity.set( - dimmed - ? withDelay(150, withTiming(0.5, { duration: 150, reduceMotion: ReduceMotion.System })) - : withTiming(1, { duration: 150, reduceMotion: ReduceMotion.System }), - ); - }, [opacity, dimmed]); - const dimStyle = useAnimatedStyle(() => ({ opacity: opacity.get() })); + const reduceMotion = useReduceMotionEnabled(); + // The delay keeps a quick refresh from flashing the content. + const dimStyle = { + opacity: dimmed ? 0.5 : 1, + transitionProperty: "opacity", + transitionDuration: reduceMotion ? 0 : 150, + transitionDelay: dimmed ? 150 : 0, + } as const; return ( diff --git a/apps/mobile/src/features/usage/usageLimitsRefresh.test.ts b/apps/mobile/src/features/usage/usageLimitsRefresh.test.ts new file mode 100644 index 0000000000..13319cc77a --- /dev/null +++ b/apps/mobile/src/features/usage/usageLimitsRefresh.test.ts @@ -0,0 +1,100 @@ +import { EnvironmentId } from "@infinitus/contracts"; +import { refreshUsageLimits } from "@infinitus/client-runtime/state/usage"; +import * as Cause from "effect/Cause"; +import { AsyncResult, AtomRegistry } from "effect/reactivity"; +import { expect, it, vi } from "vite-plus/test"; + +type RefreshResult = AsyncResult.AsyncResult; + +const state = vi.hoisted(() => ({ + refresh: (_environmentId: string): Promise => { + throw new Error("refresh not set up"); + }, +})); +vi.mock("../../state/server", () => ({ + serverEnvironment: { + refreshProviders: { + label: "refresh providers", + run: (_registry: unknown, input: { readonly environmentId: string }) => + state.refresh(input.environmentId), + }, + }, +})); + +import { + environmentIdsKey, + limitsRefreshFailuresAtom, + refreshEnvironmentLimits, +} from "./usageLimitsRefresh"; + +const success: RefreshResult = AsyncResult.success(undefined); +const failure: RefreshResult = AsyncResult.failure(Cause.fail("offline")); + +function setup(refresh: (environmentId: string) => Promise) { + state.refresh = refresh; + const registry = AtomRegistry.make(); + return { + registry, + failures: (ids: readonly EnvironmentId[]) => { + const atom = limitsRefreshFailuresAtom(environmentIdsKey(ids)); + registry.mount(atom); + return registry.get(atom); + }, + }; +} + +it("keeps a newer environment failure when an older refresh finishes", async () => { + const a = EnvironmentId.make("mobile-limits-a"); + const b = EnvironmentId.make("mobile-limits-b"); + const pending = Promise.withResolvers(); + const { registry, failures } = setup((environmentId) => + environmentId === a ? pending.promise : Promise.resolve(failure), + ); + failures([a, b]); + const first = refreshEnvironmentLimits(registry, a); + await refreshEnvironmentLimits(registry, b, { automatic: true }); + expect(failures([a, b])).toEqual([b]); + pending.resolve(success); + await first; + expect(failures([a, b])).toEqual([b]); +}); + +it("does not let an older refresh clear a newer failure for the same environment", async () => { + const a = EnvironmentId.make("mobile-limits-race-a"); + const b = EnvironmentId.make("mobile-limits-race-b"); + const aFirst = Promise.withResolvers(); + const bFirst = Promise.withResolvers(); + let aCalls = 0; + const { registry, failures } = setup((environmentId) => + environmentId === b + ? bFirst.promise + : ++aCalls === 1 + ? aFirst.promise + : Promise.resolve(failure), + ); + failures([a, b]); + const older = Promise.all([ + refreshEnvironmentLimits(registry, a, { automatic: true }), + refreshEnvironmentLimits(registry, b, { automatic: true }), + ]); + aFirst.resolve(success); + await refreshUsageLimits(a, () => aFirst.promise); + await refreshEnvironmentLimits(registry, a); + expect(failures([a])).toEqual([a]); + bFirst.resolve(success); + await older; + expect(failures([a, b])).toEqual([a]); +}); + +it("skips an automatic refresh inside the throttle window without touching the outcome", async () => { + const a = EnvironmentId.make("mobile-limits-throttle"); + const { registry, failures } = setup(() => Promise.resolve(failure)); + failures([a]); + await refreshEnvironmentLimits(registry, a); + expect(failures([a])).toEqual([a]); + state.refresh = () => Promise.resolve(success); + await refreshEnvironmentLimits(registry, a, { automatic: true }); + expect(failures([a])).toEqual([a]); + await refreshEnvironmentLimits(registry, a); + expect(failures([a])).toEqual([]); +}); diff --git a/apps/mobile/src/features/usage/usageLimitsRefresh.ts b/apps/mobile/src/features/usage/usageLimitsRefresh.ts new file mode 100644 index 0000000000..91fa5b8996 --- /dev/null +++ b/apps/mobile/src/features/usage/usageLimitsRefresh.ts @@ -0,0 +1,70 @@ +import { EnvironmentId } from "@infinitus/contracts"; +import { runAtomCommand } from "@infinitus/client-runtime/state/runtime"; +import { refreshUsageLimits } from "@infinitus/client-runtime/state/usage"; +import { Atom, type AtomRegistry } from "effect/reactivity"; + +import { serverEnvironment } from "../../state/server"; + +/** + * Whether the last finished limits refresh for an environment failed. Not kept + * alive: the limits view mounts it, and leaving the view forgets old failures. + */ +export const limitsRefreshFailedAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make(false).pipe(Atom.withLabel(`mobile:limits-refresh-failed:${environmentId}`)), +); + +export async function refreshEnvironmentLimits( + registry: AtomRegistry.AtomRegistry, + environmentId: EnvironmentId, + options: { readonly automatic?: boolean; readonly afterPending?: boolean } = {}, +): Promise { + // One refresh runs per environment at a time, so results land in the order + // they were started and the newest outcome always wins. + const result = await refreshUsageLimits( + environmentId, + () => + runAtomCommand( + registry, + serverEnvironment.refreshProviders, + { environmentId, input: {} }, + { reportFailure: false }, + ), + options.automatic, + options.afterPending, + ); + if (result === undefined) return; + registry.set(limitsRefreshFailedAtom(environmentId), result._tag === "Failure"); +} + +// Mounting starts an automatic refresh, which refreshUsageLimits throttles to +// one every few minutes per environment. +const limitsAutoRefreshAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make((get) => { + void refreshEnvironmentLimits(get.registry, environmentId, { automatic: true }); + }).pipe(Atom.withLabel(`mobile:limits-auto-refresh:${environmentId}`)), +); + +export function environmentIdsKey(environmentIds: Iterable): string { + return [...environmentIds].sort().join(","); +} + +function splitEnvironmentIds(key: string): EnvironmentId[] { + return key === "" ? [] : key.split(",").map((id) => EnvironmentId.make(id)); +} + +/** Keyed by a sorted, comma-joined id list so equal selections share one atom. */ +export const limitsAutoRefreshSetAtom = Atom.family((environmentIds: string) => + Atom.make((get) => { + for (const environmentId of splitEnvironmentIds(environmentIds)) { + get(limitsAutoRefreshAtom(environmentId)); + } + }), +); + +export const limitsRefreshFailuresAtom = Atom.family((environmentIds: string) => + Atom.make((get) => + splitEnvironmentIds(environmentIds).filter((environmentId) => + get(limitsRefreshFailedAtom(environmentId)), + ), + ), +); diff --git a/apps/mobile/src/features/voice-input/VoiceInputProvider.tsx b/apps/mobile/src/features/voice-input/VoiceInputProvider.tsx index ea810d21b3..605eebed7a 100644 --- a/apps/mobile/src/features/voice-input/VoiceInputProvider.tsx +++ b/apps/mobile/src/features/voice-input/VoiceInputProvider.tsx @@ -3,7 +3,6 @@ import { AudioModule, RecordingPresets, requestRecordingPermissionsAsync, - type RecorderState, type RecordingStatus, } from "expo-audio"; import { File } from "expo-file-system"; @@ -35,7 +34,7 @@ import { voiceInputBlocksSubmission, type VoiceInputState, } from "@infinitus/client-runtime/voice-input"; -import { createLazyVoiceRecorder, type LazyVoiceRecorder } from "./lazyVoiceRecorder"; +import { createLazyVoiceRecorder } from "./lazyVoiceRecorder"; import { normalizeVoiceInputDecibels, VOICE_WAVEFORM_SAMPLE_COUNT } from "./voiceInputMetering"; import { VoiceInputSession } from "./voiceInputSession"; import { @@ -93,17 +92,18 @@ function useVoiceInputRuntime() { const keepAwakeId = useId(); const keepAwakeSessionRef = useRef(0); const elapsedSecondsRef = useRef(0); - const audioLevelsRef = useRef(Array(VOICE_WAVEFORM_SAMPLE_COUNT).fill(0)); - const audioLevels = useSharedValue(audioLevelsRef.current); - const sessionRef = useRef(null); - const recorderRef = useRef | null>(null); + const [initialLevels] = useState(() => Array(VOICE_WAVEFORM_SAMPLE_COUNT).fill(0)); + const audioLevelsRef = useRef(initialLevels); + const audioLevels = useSharedValue(initialLevels); const audioToken = useRef | null>(null); - if (!sessionRef.current || !recorderRef.current) { + /* oxlint-disable react/refs -- The initializer captures the audio-session ref for later recording callbacks; it does not read it. */ + const [{ session, recorder }] = useState(() => { + let session: VoiceInputSession | null = null; const recorder = createLazyVoiceRecorder({ create: () => new AudioModule.AudioRecorder(VOICE_RECORDING_OPTIONS), onStatus: (status: RecordingStatus) => { - sessionRef.current?.controller.handleRecorderStatus({ + session?.controller.handleRecorderStatus({ isFinished: status.isFinished, hasError: status.hasError || status.mediaServicesDidReset === true, error: status.error, @@ -126,8 +126,7 @@ function useVoiceInputRuntime() { return preferredMicrophoneInput(inputs, order); }, }); - recorderRef.current = recorder; - sessionRef.current = new VoiceInputSession({ + session = new VoiceInputSession({ recorder, getTranscriber: getLocalVoiceTranscriber, requestPermission: async () => { @@ -152,15 +151,15 @@ function useVoiceInputRuntime() { onStateChange: (nextState) => setState({ state: nextState, - ownerKey: sessionRef.current?.ownerKey ?? null, - label: sessionRef.current?.label ?? null, + ownerKey: session?.ownerKey ?? null, + label: session?.label ?? null, }), }); - } + return { session, recorder }; + }); + /* oxlint-enable react/refs */ - const session = sessionRef.current; const controller = session.controller; - const recorder = recorderRef.current; useEffect(() => { const subscription = AppState.addEventListener("change", (nextState) => { diff --git a/apps/mobile/src/features/voice-input/useVoiceInputController.ts b/apps/mobile/src/features/voice-input/useVoiceInputController.ts index d7954c38b6..4d431344ad 100644 --- a/apps/mobile/src/features/voice-input/useVoiceInputController.ts +++ b/apps/mobile/src/features/voice-input/useVoiceInputController.ts @@ -24,6 +24,7 @@ export function useVoiceInputController(input: { const global = useGlobalVoiceInput(); const { setOwnerFocused, session } = global; const latestInput = useRef(input); + // oxlint-disable-next-line react/refs -- Transcription finishes asynchronously and must reach the composer's current selection setter, not the one captured at start. latestInput.current = input; const mounted = useRef(true); useEffect(() => { diff --git a/apps/mobile/src/features/workspace/WorkspaceToolbar.ios.tsx b/apps/mobile/src/features/workspace/WorkspaceToolbar.ios.tsx index 0ac48ac5e2..2278ee9ba7 100644 --- a/apps/mobile/src/features/workspace/WorkspaceToolbar.ios.tsx +++ b/apps/mobile/src/features/workspace/WorkspaceToolbar.ios.tsx @@ -119,6 +119,7 @@ export function WorkspaceToolbar(props: { if (!tabActive) return; const frame = requestAnimationFrame(restoreSearch); return () => cancelAnimationFrame(frame); + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Moving the search bar to the bottom toolbar restores its focus and query. }, [restoreSearch, tabActive, bottomSearch]); const composeButton = nativeToolbarCompose ? ( { it.each(["t3code://", "t3code:///", "t3code-dev://", "t3code-preview://"])( "ignores scheme-only URL %s", (url) => { - expect(shouldHandleAppLink(url)).toBe(false); + expect(shouldHandleAppLink(url, true)).toBe(false); }, ); @@ -15,17 +15,41 @@ describe("shouldHandleAppLink", () => { "t3code://pair?pairingUrl=x", "t3code-dev://settings/usage?tab=limits", ])("handles path-bearing URL %s", (url) => { - expect(shouldHandleAppLink(url)).toBe(true); + expect(shouldHandleAppLink(url, true)).toBe(true); }); it.each(["t3code://expo-development-client/?url=x", "t3code://expo-sharing/anything"])( "ignores lifecycle URL %s", (url) => { - expect(shouldHandleAppLink(url)).toBe(false); + expect(shouldHandleAppLink(url, true)).toBe(false); }, ); }); +describe("cloud-only deep links", () => { + it.each([ + "t3code://connect-onboarding", + "t3code-dev://connect-onboarding/?source=x", + "t3code://settings/waitlist", + "t3code://settings/auth", + "exp://192.168.1.2:8081/--/settings/auth", + ])("ignores %s without cloud config", (url) => { + expect(shouldHandleAppLink(url, false)).toBe(false); + expect(shouldHandleAppLink(url, true)).toBe(true); + }); + + it.each([ + "t3code://settings", + "t3code://settings/authors", + "t3code://pair?next=settings/auth", + "t3code://threads/env/thread/files/scripts/connect-onboarding", + "t3code://new/draft/files/settings/auth", + "exp://192.168.1.2:8081/--/threads/env/thread/files/settings/waitlist", + ])("still handles %s without cloud config", (url) => { + expect(shouldHandleAppLink(url, false)).toBe(true); + }); +}); + describe("draft deep links", () => { it("opens the old draft URL on the thread screen with its draft identity", () => { const draft = { projectId: "project-1", environmentId: "env-1", draftId: "draft-1" }; @@ -65,3 +89,28 @@ describe("draft deep links", () => { expect(restoreDraftLinkState(undefined, "/new/draft")).toBeUndefined(); }); }); + +describe("nested link routes", () => { + const settingsLink = { + routes: [ + { name: "Home" }, + { + name: "SettingsSheet", + state: { + routes: [ + { name: "Settings" }, + { name: "SettingsEnvironmentNew", params: { pairingUrl: "x", autoConnect: "1" } }, + ], + }, + }, + ], + }; + + it("finds a route the link opens inside a nested navigator", () => { + expect(findLinkedRoute(settingsLink, ["SettingsEnvironmentNew"])?.params).toEqual({ + pairingUrl: "x", + autoConnect: "1", + }); + expect(findLinkedRoute(settingsLink, ["ConnectionsNew"])).toBeUndefined(); + }); +}); diff --git a/apps/mobile/src/lib/appLinking.ts b/apps/mobile/src/lib/appLinking.ts index 08263bb94d..6f8e895397 100644 --- a/apps/mobile/src/lib/appLinking.ts +++ b/apps/mobile/src/lib/appLinking.ts @@ -6,14 +6,24 @@ import type { NavigationState, PartialState } from "@react-navigation/native"; * the launcher, not app navigation. Without this filter it falls through * to the NotFound wildcard route on every dev launch. */ -export function shouldHandleAppLink(url: string): boolean { +export function shouldHandleAppLink(url: string, cloudConfigured: boolean): boolean { return ( !url.includes("expo-development-client") && !url.includes("://expo-sharing") && - !/^t3code(-dev|-preview)?:\/*$/.test(url) + !/^t3code(-dev|-preview)?:\/*$/.test(url) && + (cloudConfigured || !CLOUD_ONLY_LINK_PATH.test(url.split(/[?#]/)[0] ?? "")) ); } +/** + * Screens that only exist with T3 Connect configured. A build without it has + * no entry points to them, so a link must not open one empty. Anchored to the + * app-relative path (after `exp://host/--/` in Expo Go) so a file route whose + * path merely ends in one of these names still opens. + */ +const CLOUD_ONLY_LINK_PATH = + /^[a-z][\w+.-]*:\/*(?:[^/]*\/--\/)?(?:connect-onboarding|settings\/(?:auth|waitlist))\/*$/i; + /** Keep the published draft URLs while using the root thread screen. */ export function restoreDraftLinkState( state: PartialState | undefined, @@ -29,3 +39,23 @@ export function restoreDraftLinkState( if (pathname !== "new/draft") routes.push(target); return { ...state, index: routes.length - 1, routes }; } + +type LinkState = PartialState; +type LinkRoute = LinkState["routes"][number]; + +function isLinkState(value: unknown): value is LinkState { + return typeof value === "object" && value !== null && Array.isArray((value as LinkState).routes); +} + +/** The first route named one of `names`, at any nesting depth of a parsed link. */ +export function findLinkedRoute( + state: LinkState | undefined, + names: ReadonlyArray, +): LinkRoute | undefined { + for (const route of state?.routes ?? []) { + if (names.includes(route.name)) return route; + const nested = isLinkState(route.state) ? findLinkedRoute(route.state, names) : undefined; + if (nested) return nested; + } + return undefined; +} diff --git a/apps/mobile/src/lib/clock.test.ts b/apps/mobile/src/lib/clock.test.ts new file mode 100644 index 0000000000..a72efe95e3 --- /dev/null +++ b/apps/mobile/src/lib/clock.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { minuteTickToEpochMs } from "./clock"; + +describe("minuteTickToEpochMs", () => { + it("reads the minute tick as UTC regardless of the device timezone", () => { + expect(minuteTickToEpochMs("2026-10-26T02:14")).toBe(Date.UTC(2026, 9, 26, 2, 14)); + }); +}); diff --git a/apps/mobile/src/lib/clock.ts b/apps/mobile/src/lib/clock.ts index b1fd237ae1..5cc2553e82 100644 --- a/apps/mobile/src/lib/clock.ts +++ b/apps/mobile/src/lib/clock.ts @@ -1,4 +1,4 @@ -import { deadlineStore } from "@infinitus/client-runtime/clock"; +import { deadlineStore, minuteTick } from "@infinitus/client-runtime/clock"; import { useSyncExternalStore } from "react"; /** Re-renders once when `deadlineMs` (epoch ms) is reached; `null` never passes. */ @@ -6,3 +6,18 @@ export function useDeadlinePassed(deadlineMs: number | null): boolean { const store = deadlineStore(deadlineMs); return useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot); } + +/** + * `minuteTick`'s UTC `YYYY-MM-DDTHH:mm` as epoch ms. The snapshot carries no + * offset, and `Date.parse` reads offset-less date-times as local time. + */ +export function minuteTickToEpochMs(minute: string): number { + return Date.parse(`${minute}:00Z`); +} + +/** Epoch ms floored to the minute, advancing each wall-clock minute. */ +export function useNowMinuteMs(): number { + return minuteTickToEpochMs( + useSyncExternalStore(minuteTick.subscribe, minuteTick.getSnapshot, minuteTick.getSnapshot), + ); +} diff --git a/apps/mobile/src/lib/composerContextClipboard.test.ts b/apps/mobile/src/lib/composerContextClipboard.test.ts index 18a23337f8..26bd2b3a5b 100644 --- a/apps/mobile/src/lib/composerContextClipboard.test.ts +++ b/apps/mobile/src/lib/composerContextClipboard.test.ts @@ -169,6 +169,24 @@ describe("mobile context clipboard imports", () => { expect(mocks.dispose).toHaveBeenCalledOnce(); }); + it("releases its files if cancelled after the last copy finishes", async () => { + const controller = new AbortController(); + mocks.dispose.mockImplementationOnce(() => controller.abort()); + const imageOnly = { + text: "![Checkout](t3-context://v1/image/image-source)", + fragment: encodeComposerContextFragment({ + version: 1, + source: { environmentId: EnvironmentId.make("source") }, + records: [image], + })!, + html: "", + }; + await expect(importComposerContextClipboard(imageOnly, 0, controller.signal)).rejects.toThrow( + "cancelled", + ); + expect(mocks.remove).toHaveBeenCalledWith("file:///owned.png"); + }); + it("can copy an image before its source upload completes", async () => { mocks.local.mockReturnValue({ id: "source-file", diff --git a/apps/mobile/src/lib/composerContextClipboard.ts b/apps/mobile/src/lib/composerContextClipboard.ts index 83e2135d56..9cf312dd23 100644 --- a/apps/mobile/src/lib/composerContextClipboard.ts +++ b/apps/mobile/src/lib/composerContextClipboard.ts @@ -109,6 +109,8 @@ export async function importComposerContextClipboard( failures.push(record.name); } } + // The last copy can be cancelled after its own check; a cancelled import must not return files nobody will release. + checkAborted(signal); return { text: imported.text, context: { version: 1 as const, records }, diff --git a/apps/mobile/src/lib/useMediaVideoPlayback.test.tsx b/apps/mobile/src/lib/useMediaVideoPlayback.test.tsx new file mode 100644 index 0000000000..6d16b98ede --- /dev/null +++ b/apps/mobile/src/lib/useMediaVideoPlayback.test.tsx @@ -0,0 +1,335 @@ +import { act, type ReactNode } from "react"; +import { createRoot } from "react-dom/client"; +import type { AppStateStatus } from "react-native"; +import { expect, it, vi } from "vite-plus/test"; +import { EnvironmentId } from "@infinitus/contracts"; + +const mocks = vi.hoisted(() => ({ + focused: true, + appState: "active" as AppStateStatus, + appStateListeners: new Set<(state: AppStateStatus) => void>(), + players: [] as ReturnType[], + refresh: vi.fn<() => Promise>(), + replace: vi.fn<() => Promise>(), + thumbnail: vi.fn<(key: string, source: unknown, signal: AbortSignal) => Promise>(), +})); +vi.mock("@react-navigation/native", () => ({ useIsFocused: () => mocks.focused })); +vi.mock("react-native", () => ({ + Platform: { OS: "ios" }, + StyleSheet: { absoluteFill: {} }, + View: ({ children }: { children?: ReactNode }) =>
{children}
, + Pressable: ({ + children, + onPress, + accessibilityLabel, + }: { + children?: ReactNode; + onPress?: () => void; + accessibilityLabel?: string; + }) => ( + + ), + AppState: { + get currentState() { + return mocks.appState; + }, + addEventListener: (_event: string, listener: (state: AppStateStatus) => void) => { + mocks.appStateListeners.add(listener); + return { remove: () => mocks.appStateListeners.delete(listener) }; + }, + }, +})); +vi.mock("expo", async () => { + const { useSyncExternalStore } = await import("react"); + return { + requireOptionalNativeModule: () => null, + useEvent: (player: ReturnType, event: string, initial: unknown) => { + const playing = useSyncExternalStore(player.subscribe, () => player.playing); + return event === "playingChange" ? { isPlaying: playing } : initial; + }, + }; +}); +vi.mock("expo-video", async () => { + const { useState } = await import("react"); + return { + useVideoPlayer: ( + _source: unknown, + initialize: (player: ReturnType) => void, + ) => + useState(() => { + const player = createPlayer(); + initialize(player); + mocks.players.push(player); + return player; + })[0], + VideoView: (props: { onFullscreenEnter: () => void; onFullscreenExit: () => void }) => ( +
+
+ ), + }; +}); +vi.mock("expo-audio", () => ({ + AudioModule: { setAudioSessionOwner: vi.fn() }, + setAudioModeAsync: vi.fn().mockResolvedValue(undefined), + setIsAudioActiveAsync: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("expo-image", () => ({ Image: () => null })); +vi.mock("../components/ui/AppActivityIndicator", () => ({ AppActivityIndicator: () => null })); +vi.mock("../components/ui/AppSymbol", () => ({ SymbolView: () => null })); +vi.mock("../components/ui/AppText", () => ({ + AppText: ({ children }: { children?: ReactNode }) => {children}, +})); +vi.mock("../components/MediaActionsMenu", () => ({ + MediaActionsMenu: ({ children }: { children?: ReactNode }) => children, +})); +vi.mock("../components/ui/NativePresentation", () => ({ + PresentationSource: ({ children }: { children?: ReactNode }) => children, +})); +vi.mock("../state/mediaActions", () => ({ + useMediaActions: () => ({ actions: [], sharing: false }), +})); +vi.mock("../state/assets", () => ({ + useAssetUrlState: () => ({ _tag: "Success", url: "https://media.example/clip.mp4" }), + useRefreshAssetUrl: () => mocks.refresh, +})); +vi.mock("./localAttachmentPreview", () => ({ loadLocalAttachmentPreview: vi.fn() })); +vi.mock("./videoThumbnails", () => ({ + cachedVideoThumbnail: () => null, + loadVideoThumbnail: mocks.thumbnail, +})); +vi.mock("../components/FilePreviewGallery", () => ({ + useFilePreviewGallery: () => (value: unknown) => value, +})); +vi.mock("../components/FilePreviewModal", () => ({ FilePreviewModal: () => null })); +vi.mock("../components/VideoPreviewModal", () => ({ VideoPreviewModal: () => null })); +vi.mock("../features/threads/ThreadMarkdownImage", () => ({ ThreadMarkdownImageView: () => null })); + +import { + ThreadMarkdownVideo, + ThreadMediaVisibleContext, +} from "../features/threads/ThreadMarkdownVideo"; +import { + PullRequestMedia, + PullRequestMediaContext, + PullRequestMediaVisibleContext, +} from "../features/pull-requests/PullRequestMedia"; + +function createPlayer() { + let playing = false; + const listeners = new Set<() => void>(); + const playingListeners = new Set<(event: { isPlaying: boolean }) => void>(); + const setPlaying = (next: boolean) => { + playing = next; + for (const listener of listeners) listener(); + for (const listener of playingListeners) listener({ isPlaying: next }); + }; + return { + currentTime: 0, + status: "readyToPlay", + get playing() { + return playing; + }, + play: vi.fn(() => setPlaying(true)), + pause: vi.fn(() => setPlaying(false)), + replaceAsync: vi.fn(() => mocks.replace()), + addListener(event: string, listener: (event: { isPlaying: boolean }) => void) { + if (event === "playingChange") playingListeners.add(listener); + return { remove: () => playingListeners.delete(listener) }; + }, + subscribe(listener: () => void) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }; +} + +function setup(kind: "thread" | "PR", compact = false) { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + mocks.focused = true; + mocks.appState = "active"; + mocks.players = []; + mocks.appStateListeners.clear(); + mocks.refresh.mockReset().mockResolvedValue("https://media.example/clip.mp4"); + mocks.replace.mockReset().mockResolvedValue(); + mocks.thumbnail.mockReset().mockResolvedValue(null); + const container = document.createElement("div"); + const root = createRoot(container); + const environmentId = EnvironmentId.make("test"); + return { + render(visible = true) { + act(() => + root.render( + kind === "thread" ? ( + + + + ) : ( + + + + + + ), + ), + ); + }, + async open() { + const button = container.querySelector("button"); + expect(button).not.toBeNull(); + await act(async () => button!.click()); + expect(mocks.players).toHaveLength(1); + return mocks.players[0]!; + }, + fullscreen(open: boolean) { + const button = container.querySelector( + `[aria-label="${open ? "Enter" : "Exit"} fullscreen"]`, + ); + expect(button).not.toBeNull(); + act(() => button!.click()); + }, + appState(state: AppStateStatus) { + mocks.appState = state; + act(() => { + for (const listener of mocks.appStateListeners) listener(state); + }); + }, + [Symbol.dispose]() { + act(() => root.unmount()); + }, + }; +} + +it.each(["thread", "PR"] as const)( + "retains the %s player and position while hidden and requires deliberate resume", + async (kind) => { + using screen = setup(kind); + screen.render(); + const player = await screen.open(); + expect(player.playing).toBe(true); + player.currentTime = 37; + screen.render(false); + expect(player.playing).toBe(false); + screen.render(true); + expect(mocks.players).toEqual([player]); + expect(player.replaceAsync).toHaveBeenCalledTimes(1); + expect(player.currentTime).toBe(37); + expect(player.playing).toBe(false); + }, +); + +it.each(["thread", "PR"] as const)( + "does not start a hidden %s video when signing or native loading finishes late", + async (kind) => { + using screen = setup(kind); + const signed = Promise.withResolvers(); + const replaced = Promise.withResolvers(); + mocks.refresh.mockReturnValue(signed.promise); + mocks.replace.mockReturnValue(replaced.promise); + screen.render(); + const player = await screen.open(); + screen.render(false); + await act(async () => signed.resolve("https://media.example/renewed.mp4")); + expect(player.replaceAsync).toHaveBeenCalledTimes(1); + expect(player.play).not.toHaveBeenCalled(); + await act(async () => replaced.resolve()); + expect(player.play).not.toHaveBeenCalled(); + screen.render(true); + expect(player.playing).toBe(false); + expect(player.replaceAsync).toHaveBeenCalledTimes(1); + }, +); + +it.each([false, true])( + "stops inactive PR thumbnail requests (compact: %s) and restarts on return", + async (compact) => { + using screen = setup("PR", compact); + screen.render(false); + expect(mocks.thumbnail).not.toHaveBeenCalled(); + await act(async () => screen.render(true)); + expect(mocks.thumbnail).toHaveBeenCalledTimes(1); + const signal = mocks.thumbnail.mock.calls[0]![2]; + expect(signal.aborted).toBe(false); + screen.render(false); + expect(signal.aborted).toBe(true); + expect(mocks.thumbnail).toHaveBeenCalledTimes(1); + await act(async () => screen.render(true)); + expect(mocks.thumbnail).toHaveBeenCalledTimes(2); + }, +); + +it("pauses on screen blur and app inactivity without resuming on return", async () => { + using screen = setup("thread"); + screen.render(); + const player = await screen.open(); + for (const reason of ["blur", "inactive", "background"] as const) { + act(() => player.play()); + player.currentTime = 37; + if (reason === "blur") { + mocks.focused = false; + screen.render(); + } else screen.appState(reason); + expect(player.playing, reason).toBe(false); + mocks.focused = true; + screen.appState("active"); + screen.render(); + expect(player.playing, reason).toBe(false); + expect(player.currentTime).toBe(37); + } + expect(player.replaceAsync).toHaveBeenCalledTimes(1); +}); + +it.each(["thread", "PR"] as const)( + "keeps the %s play request cancelled when loading finishes after returning", + async (kind) => { + using screen = setup(kind); + const signed = Promise.withResolvers(); + const replaced = Promise.withResolvers(); + mocks.refresh.mockReturnValue(signed.promise); + mocks.replace.mockReturnValue(replaced.promise); + screen.render(); + const player = await screen.open(); + screen.render(false); + screen.render(true); + await act(async () => signed.resolve("https://media.example/renewed.mp4")); + await act(async () => replaced.resolve()); + expect(player.replaceAsync).toHaveBeenCalledTimes(1); + expect(player.play).not.toHaveBeenCalled(); + act(() => player.play()); + expect(player.playing).toBe(true); + }, +); + +it("pauses when native fullscreen exits while the app remains in the background", async () => { + using screen = setup("thread"); + screen.render(); + const player = await screen.open(); + player.currentTime = 37; + screen.fullscreen(true); + screen.appState("background"); + expect(player.playing).toBe(true); + screen.fullscreen(false); + expect(player.playing).toBe(false); + screen.appState("active"); + expect(player.playing).toBe(false); + expect(player.currentTime).toBe(37); +}); diff --git a/apps/mobile/src/lib/useMediaVideoPlayback.ts b/apps/mobile/src/lib/useMediaVideoPlayback.ts new file mode 100644 index 0000000000..d0950cd3ef --- /dev/null +++ b/apps/mobile/src/lib/useMediaVideoPlayback.ts @@ -0,0 +1,56 @@ +import { useIsFocused } from "@react-navigation/native"; +import { useEvent } from "expo"; +import type { VideoPlayer } from "expo-video"; +import { useCallback, useEffect, useRef } from "react"; +import { AppState } from "react-native"; + +import { useFullscreenVideoOpen, useMediaAudioSessionHold } from "./videoAudioSession"; + +export function useMediaVideoPlayback( + player: VideoPlayer, + options: { readonly paused: boolean; readonly visible?: boolean }, + playback: { play(): Promise; pause(): void }, +) { + const focused = useIsFocused(); + const fullscreenVideoOpen = useFullscreenVideoOpen(); + const paused = options.paused || fullscreenVideoOpen || options.visible === false; + const active = useRef(focused && !paused && AppState.currentState === "active"); + const fullscreen = useRef(false); + const interruption = useRef(0); + const { isPlaying } = useEvent(player, "playingChange", { isPlaying: player.playing }); + useMediaAudioSessionHold(isPlaying); + + useEffect(() => { + active.current = focused && !paused && AppState.currentState === "active"; + if (!active.current) interruption.current += 1; + if (!focused || paused || (!active.current && !fullscreen.current)) playback.pause(); + // Native background handling distinguishes Android's fullscreen activity + // from leaving the app; React Native reports both as background. + const subscription = AppState.addEventListener("change", (state) => { + active.current = focused && !paused && state === "active"; + if (!active.current) interruption.current += 1; + if (state === "inactive" || (state === "background" && !fullscreen.current)) playback.pause(); + }); + return () => subscription.remove(); + }, [focused, playback, paused]); + + const createPlayRequest = useCallback(() => { + const requestedAt = interruption.current; + const requestedWhileActive = active.current; + // Returning to the content must not revive playback interrupted during loading. + return () => { + if (requestedWhileActive && active.current && requestedAt === interruption.current) + return playback.play(); + }; + }, [playback]); + + const onFullscreenEnter = useCallback(() => { + fullscreen.current = true; + }, []); + const onFullscreenExit = useCallback(() => { + fullscreen.current = false; + if (!active.current) playback.pause(); + }, [playback]); + + return { active, fullscreen, createPlayRequest, onFullscreenEnter, onFullscreenExit }; +} diff --git a/apps/mobile/src/native/NativeWorkspaceColumns.ios.tsx b/apps/mobile/src/native/NativeWorkspaceColumns.ios.tsx index f29d1677b7..de5d93b8cb 100644 --- a/apps/mobile/src/native/NativeWorkspaceColumns.ios.tsx +++ b/apps/mobile/src/native/NativeWorkspaceColumns.ios.tsx @@ -1,8 +1,10 @@ import { requireOptionalNativeModule } from "expo"; -import { useEffect, useState } from "react"; import { Platform } from "react-native"; +import { useDeadlinePassed } from "../lib/clock"; import type { NativeLayoutMetrics } from "../lib/reserved-regions"; -import { useNativeLayoutMetrics } from "./native-layout-metrics"; +import { useNativeLayoutMetrics, useNativeLayoutMetricsDeadline } from "./native-layout-metrics"; + +export { NATIVE_METRICS_TIMEOUT_MS } from "./native-layout-metrics"; const nativeControls = requireOptionalNativeModule<{ readonly supportsWorkspaceColumns?: boolean; @@ -19,24 +21,15 @@ export function nativeWorkspaceColumnsSupportedFor(metrics: NativeLayoutMetrics ); } -// Binaries without the native no-hinge fallback never report metrics on ordinary -// iPhones running iOS 27.1 or later. Use the compact navigator rather than staying blank. -export const NATIVE_METRICS_TIMEOUT_MS = 1000; - export function useNativeWorkspaceColumnsReady() { const metrics = useNativeLayoutMetrics(); - const [timedOut, setTimedOut] = useState(false); + const metricsDeadline = useNativeLayoutMetricsDeadline(); const waiting = !(Platform.OS === "ios" && Platform.isPad) && !!nativeControls?.ViewPrototypes?.T3NativeControls_LayoutMetrics && metrics === null; - - useEffect(() => { - if (!waiting) return; - const timeout = setTimeout(() => setTimedOut(true), NATIVE_METRICS_TIMEOUT_MS); - return () => clearTimeout(timeout); - }, [waiting]); - + // Without metrics by the deadline, use the compact navigator rather than staying blank. + const timedOut = useDeadlinePassed(waiting ? metricsDeadline : null); return !waiting || timedOut; } diff --git a/apps/mobile/src/native/StackHeader.tsx b/apps/mobile/src/native/StackHeader.tsx index 67b77a8c6b..e03e02d5e4 100644 --- a/apps/mobile/src/native/StackHeader.tsx +++ b/apps/mobile/src/native/StackHeader.tsx @@ -194,22 +194,26 @@ export function NativeStackScreenOptions(props: { const lastAppliedOptionsSignatureRef = useRef(undefined); const latestOptionFunctionsRef = useRef(new Map unknown>()); const optionFunctionWrappersRef = useRef(new Map unknown>()); - const normalizedOptions = useMemo(() => normalizeScreenOptions(props.options), [props.options]); - const stableOptions = normalizedOptions - ? (stabilizeOptionFunctions( - normalizedOptions, - "options", - latestOptionFunctionsRef.current, - optionFunctionWrappersRef.current, - ) as NativeStackNavigationOptions) - : undefined; + // Stabilizing records each factory's latest closure, so it runs in the commit + // phase: a render React discards must not leak its closures into the header. + // It runs before the tab check so wrappers already installed in an inactive + // tab's header still call the newest closures. useLayoutEffect(() => { + const normalizedOptions = normalizeScreenOptions(props.options); + const stableOptions = normalizedOptions + ? stabilizeOptionFunctions( + normalizedOptions, + "options", + latestOptionFunctionsRef.current, + optionFunctionWrappersRef.current, + ) + : undefined; if (!tabActive) { lastAppliedOptionsSignatureRef.current = undefined; return; } - if (!setOptions || !stableOptions) { + if (!setOptions || !normalizedOptions) { return; } const signature = optionsSignature([stableOptions, props.optionsVersion]); @@ -229,7 +233,7 @@ export function NativeStackScreenOptions(props: { optionFunctionWrappersRef.current, ) as NativeStackNavigationOptions, ); - }, [setOptions, normalizedOptions, props.optionsVersion, stableOptions, tabActive]); + }, [setOptions, props.options, props.optionsVersion, tabActive]); useEffect(() => { if (!navigation || !props.listeners) { diff --git a/apps/mobile/src/native/T3ComposerEditor.ios.tsx b/apps/mobile/src/native/T3ComposerEditor.ios.tsx index 1b8e65b3f7..0989a2f72c 100644 --- a/apps/mobile/src/native/T3ComposerEditor.ios.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.ios.tsx @@ -139,7 +139,19 @@ export function ComposerEditor({ const [, forceNativeEventRender] = useState(0); // Start history empty so restored drafts or recycled views receive a non-echo controlled payload. const nativeEventSnapshotsRef = useRef([]); - const confirmedTokensRef = useRef(collectComposerInlineTokens(props.value)); + // Tokens advance from the previous committed value; render-phase state keeps + // an abandoned render from leaking a trailing token into the next one. + const [confirmedTokens, setConfirmedTokens] = useState(() => ({ + value: props.value, + tokens: collectComposerInlineTokens(props.value), + })); + let tokens = confirmedTokens.tokens; + if (confirmedTokens.value !== props.value) { + tokens = collectComposerInlineTokens(props.value, { + preserveTrailingFrom: confirmedTokens.tokens, + }); + setConfirmedTokens({ value: props.value, tokens }); + } const bodyText = useScaledTextRole("body"); const theme = useUniwindTheme(); const fontFamily = FONT_FAMILIES.regular; @@ -158,10 +170,6 @@ export function ComposerEditor({ const skillLabels = useMemo(() => composerSkillLabels(skills), [skills]); const chipTextColor = createNativeComposerTheme(theme).text; const tokensJson = useMemo(() => { - const tokens = collectComposerInlineTokens(props.value, { - preserveTrailingFrom: confirmedTokensRef.current, - }); - confirmedTokensRef.current = tokens; return JSON.stringify( composerContextEditorTokens(props.value, tokens, skillLabels).map((token) => { const record = @@ -194,11 +202,12 @@ export function ComposerEditor({ }; }), ); - }, [props.value, props.context, skillLabels, theme, chipTextColor]); + }, [props.value, tokens, props.context, skillLabels, theme, chipTextColor]); // Every render resolves against the snapshot history, so a render whose // (value, selection) lags the acknowledged native state is stamped behind // the native revision and rejected by the editor instead of re-applying a // stale caret or stale text mid-typing. + /* oxlint-disable react/refs -- Native controlled edits must reconcile against the last acknowledged native event before sending the next payload. */ const controlledEventCount = resolveComposerControlledEventCount( props.value, selection ?? null, @@ -211,6 +220,7 @@ export function ComposerEditor({ mostRecentEventCount, nativeEventSnapshotsRef.current, ); + /* oxlint-enable react/refs */ const isNativeEcho = controlledEventCount === mostRecentEventCount && acknowledgesLatestNativeEvent; const controlledDocumentJson = JSON.stringify({ @@ -226,7 +236,7 @@ export function ComposerEditor({ nativeEventSnapshotsRef.current, mostRecentEventCount, ); - }, [acknowledgesLatestNativeEvent, mostRecentEventCount]); + }, [acknowledgesLatestNativeEvent, mostRecentEventCount, nativeEventSnapshotsRef]); const assumedValue = props.value; useEffect(() => { // Newer native events make this payload stale; do not record it as applied. @@ -236,7 +246,14 @@ export function ComposerEditor({ controlledEventCount, assumedValue, ); - }, [assumedValue, controlledEventCount, isNativeEcho, controlledDocumentJson]); + }, [ + assumedValue, + controlledEventCount, + isNativeEcho, + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Every changed native payload, including selection-only changes, must update the assumed native snapshot. + controlledDocumentJson, + nativeEventSnapshotsRef, + ]); const acceptNativeEvent = useCallback( (eventCount: number, value: string, nextSelection: ComposerEditorSelection) => { const acknowledgedEventCount = acknowledgeComposerNativeEvent( @@ -254,7 +271,7 @@ export function ComposerEditor({ }); return acknowledgedEventCount; }, - [], + [nativeEventSnapshotsRef], ); const themeJson = JSON.stringify(createNativeComposerTheme(theme)); const resolvedTextStyle = StyleSheet.flatten(textStyle) ?? {}; diff --git a/apps/mobile/src/native/T3ComposerEditor.native.tsx b/apps/mobile/src/native/T3ComposerEditor.native.tsx index a2378149e5..7b3ae97377 100644 --- a/apps/mobile/src/native/T3ComposerEditor.native.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.native.tsx @@ -137,8 +137,19 @@ export function ComposerEditor({ const [, forceNativeEventRender] = useState(0); // Start history empty so restored drafts or recycled views receive a non-echo controlled payload. const nativeEventSnapshotsRef = useRef([]); - const [initialConfirmedTokens] = useState(() => collectComposerInlineTokens(props.value)); - const confirmedTokensRef = useRef(initialConfirmedTokens); + // Tokens advance from the previous committed value; render-phase state keeps + // an abandoned render from leaking a trailing token into the next one. + const [confirmedTokens, setConfirmedTokens] = useState(() => ({ + value: props.value, + tokens: collectComposerInlineTokens(props.value), + })); + let tokens = confirmedTokens.tokens; + if (confirmedTokens.value !== props.value) { + tokens = collectComposerInlineTokens(props.value, { + preserveTrailingFrom: confirmedTokens.tokens, + }); + setConfirmedTokens({ value: props.value, tokens }); + } const theme = useUniwindTheme(); const handlePaste = useNativePaste((uris) => onPasteImages?.(uris)); @@ -156,10 +167,6 @@ export function ComposerEditor({ const skillLabels = useMemo(() => composerSkillLabels(skills), [skills]); const chipTextColor = createNativeComposerTheme(theme).text; const tokensJson = useMemo(() => { - const tokens = collectComposerInlineTokens(props.value, { - preserveTrailingFrom: confirmedTokensRef.current, - }); - confirmedTokensRef.current = tokens; return JSON.stringify( composerContextEditorTokens(props.value, tokens, skillLabels).map((token) => { const record = @@ -192,11 +199,12 @@ export function ComposerEditor({ }; }), ); - }, [props.value, props.context, skillLabels, theme, chipTextColor]); + }, [props.value, tokens, props.context, skillLabels, theme, chipTextColor]); // Every render resolves against the snapshot history, so a render whose // (value, selection) lags the acknowledged native state is stamped behind // the native revision and rejected by the editor instead of re-applying a // stale caret or stale text mid-typing. + /* oxlint-disable react/refs -- Native controlled edits must reconcile against the last acknowledged native event before sending the next payload. */ const controlledEventCount = resolveComposerControlledEventCount( props.value, selection ?? null, @@ -209,6 +217,7 @@ export function ComposerEditor({ mostRecentEventCount, nativeEventSnapshotsRef.current, ); + /* oxlint-enable react/refs */ const isNativeEcho = controlledEventCount === mostRecentEventCount && acknowledgesLatestNativeEvent; const controlledDocumentJson = JSON.stringify({ @@ -224,7 +233,7 @@ export function ComposerEditor({ nativeEventSnapshotsRef.current, mostRecentEventCount, ); - }, [acknowledgesLatestNativeEvent, mostRecentEventCount]); + }, [acknowledgesLatestNativeEvent, mostRecentEventCount, nativeEventSnapshotsRef]); const assumedValue = props.value; useEffect(() => { // Newer native events make this payload stale; do not record it as applied. @@ -234,7 +243,14 @@ export function ComposerEditor({ controlledEventCount, assumedValue, ); - }, [assumedValue, controlledEventCount, isNativeEcho, controlledDocumentJson]); + }, [ + assumedValue, + controlledEventCount, + isNativeEcho, + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Every changed native payload, including selection-only changes, must update the assumed native snapshot. + controlledDocumentJson, + nativeEventSnapshotsRef, + ]); const acceptNativeEvent = useCallback( (eventCount: number, value: string, nextSelection: ComposerEditorSelection) => { const acknowledgedEventCount = acknowledgeComposerNativeEvent( @@ -252,7 +268,7 @@ export function ComposerEditor({ }); return acknowledgedEventCount; }, - [], + [nativeEventSnapshotsRef], ); const { systemColorsActive } = useAppearancePreferences(); const themeJson = JSON.stringify({ diff --git a/apps/mobile/src/native/V5StackHeader.ios.tsx b/apps/mobile/src/native/V5StackHeader.ios.tsx index 7fc128f52e..969960b2c9 100644 --- a/apps/mobile/src/native/V5StackHeader.ios.tsx +++ b/apps/mobile/src/native/V5StackHeader.ios.tsx @@ -268,6 +268,8 @@ export function V5StackHeader(props: { : undefined; const searchOptions = options.headerSearchBarOptions; const autofocusRef = searchOptions?.ref ?? searchRef; + // iOS ignores the native autoFocus prop, and SearchBar's imperative handle changes every + // commit, so a ref callback would refocus on each render; focus once after the header mounts. useEffect(() => { if (!searchOptions?.autoFocus) return; const frame = requestAnimationFrame(() => autofocusRef.current?.focus()); diff --git a/apps/mobile/src/native/native-layout-metrics.tsx b/apps/mobile/src/native/native-layout-metrics.tsx index 3ace671cbf..cd254eabe9 100644 --- a/apps/mobile/src/native/native-layout-metrics.tsx +++ b/apps/mobile/src/native/native-layout-metrics.tsx @@ -11,6 +11,10 @@ const NativeLayoutContext = createContext(null); const DeviceTypeContext = createContext( deriveDeviceType({ isPad: IS_PAD, metrics: null }), ); +// Binaries without the native no-hinge fallback never report metrics on ordinary +// iPhones running iOS 27.1 or later; consumers stop waiting at this deadline. +export const NATIVE_METRICS_TIMEOUT_MS = 1000; +const NativeLayoutMetricsDeadlineContext = createContext(0); // Column reservations come from UIKit's layout, outside the resizing navigator. export const NativeColumnLayoutMetricsContext = createContext(null); @@ -38,6 +42,7 @@ export function currentNativeLayoutMetrics() { export function NativeLayoutMetricsProvider(props: { readonly children: ReactNode }) { const [metrics, setMetrics] = useState(null); + const [metricsDeadline] = useState(() => Date.now() + NATIVE_METRICS_TIMEOUT_MS); const publish = (next: NativeLayoutMetrics) => { latestMetrics = next; for (const settle of metricsWaiters) settle(next); @@ -45,12 +50,14 @@ export function NativeLayoutMetricsProvider(props: { readonly children: ReactNod }; const deviceType = deriveDeviceType({ isPad: IS_PAD, metrics }); return ( - - - - {props.children} - - + + + + + {props.children} + + + ); } @@ -58,6 +65,11 @@ export function useNativeLayoutMetrics() { return use(NativeLayoutContext); } +/** Epoch ms after which a consumer still waiting for the first metrics gives up. */ +export function useNativeLayoutMetricsDeadline() { + return use(NativeLayoutMetricsDeadlineContext); +} + export function useNativeColumnLayoutMetrics() { return use(NativeColumnLayoutMetricsContext); } diff --git a/apps/mobile/src/reactCompilerCoverage.test.ts b/apps/mobile/src/reactCompilerCoverage.test.ts index 7eb3b3c915..c644fb09a4 100644 --- a/apps/mobile/src/reactCompilerCoverage.test.ts +++ b/apps/mobile/src/reactCompilerCoverage.test.ts @@ -15,14 +15,12 @@ const metroConfig: Promise<{ transformer: { babelTransformerPath: string } }> = // Remove bailout entries and manual memoization when compilation starts; this check rejects stale entries. const KNOWN_BAILOUTS: Record = { "src/components/ComposerAttachmentStrip.tsx": "unsupported syntax", - "src/components/ui/AndroidAnchoredMenu.tsx": "ref read during render", "src/components/ui/Menu.tsx": "ref read during render", "src/features/diffs/nativeReviewDiffSurface.ts": "ref read during render", "src/features/home/thread-swipe-actions.tsx": "ref read during render", "src/features/threads/ThreadArrangementSheet.tsx": "ref read during render", "src/features/voice-input/VoiceInputProvider.tsx": "ref read during render", "src/features/voice-input/useVoiceInputController.ts": "ref read during render", - "src/native/StackHeader.tsx": "ref read during render", "src/native/T3ComposerEditor.ios.tsx": "ref read during render", "src/native/T3ComposerEditor.native.tsx": "ref read during render", }; diff --git a/apps/mobile/src/state/composerDraftOperations.test.ts b/apps/mobile/src/state/composerDraftOperations.test.ts new file mode 100644 index 0000000000..5e99f718a0 --- /dev/null +++ b/apps/mobile/src/state/composerDraftOperations.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + abortComposerDraftOperations, + startComposerDraftOperation, +} from "./composerDraftOperations"; + +describe("composer draft operations", () => { + it("aborts every running operation for the draft and leaves other drafts alone", () => { + const first = startComposerDraftOperation("thread-a"); + const second = startComposerDraftOperation("thread-a"); + const other = startComposerDraftOperation("thread-b"); + + abortComposerDraftOperations("thread-a"); + + expect(first.signal.aborted).toBe(true); + expect(second.signal.aborted).toBe(true); + expect(other.signal.aborted).toBe(false); + other.finish(); + }); + + it("does not abort an operation that finished before the draft switched", () => { + const finished = startComposerDraftOperation("thread-c"); + finished.finish(); + const running = startComposerDraftOperation("thread-c"); + + abortComposerDraftOperations("thread-c"); + + expect(finished.signal.aborted).toBe(false); + expect(running.signal.aborted).toBe(true); + }); + + it("keeps tracking an operation started after an abort when an older one finishes", () => { + const before = startComposerDraftOperation("thread-d"); + abortComposerDraftOperations("thread-d"); + const after = startComposerDraftOperation("thread-d"); + before.finish(); + abortComposerDraftOperations("thread-d"); + + expect(after.signal.aborted).toBe(true); + }); +}); diff --git a/apps/mobile/src/state/composerDraftOperations.ts b/apps/mobile/src/state/composerDraftOperations.ts new file mode 100644 index 0000000000..7093eea06f --- /dev/null +++ b/apps/mobile/src/state/composerDraftOperations.ts @@ -0,0 +1,39 @@ +const operationsByDraftKey = new Map>(); + +/** + * Async work that writes into one draft (a context import, a picker) starts + * here. Whoever discards that draft or moves the composer off it aborts the + * work, so a late result never lands in a draft the user has left. + */ +export function startComposerDraftOperation(draftKey: string): { + readonly signal: AbortSignal; + readonly finish: () => void; +} { + const controller = new AbortController(); + const operations = operationsByDraftKey.get(draftKey) ?? new Set(); + operations.add(controller); + operationsByDraftKey.set(draftKey, operations); + return { + signal: controller.signal, + finish: () => { + operations.delete(controller); + if (operations.size === 0 && operationsByDraftKey.get(draftKey) === operations) { + operationsByDraftKey.delete(draftKey); + } + }, + }; +} + +export function abortComposerDraftOperations(draftKey: string): void { + const operations = operationsByDraftKey.get(draftKey); + if (!operations) return; + operationsByDraftKey.delete(draftKey); + for (const controller of operations) controller.abort(); +} + +export function abortComposerDraftOperationsWhere(matches: (draftKey: string) => boolean): void { + // Deleting the current entry while iterating a Map is safe. + for (const draftKey of operationsByDraftKey.keys()) { + if (matches(draftKey)) abortComposerDraftOperations(draftKey); + } +} diff --git a/apps/mobile/src/state/device.ts b/apps/mobile/src/state/device.ts index 69ab9f79b3..7db8a148b3 100644 --- a/apps/mobile/src/state/device.ts +++ b/apps/mobile/src/state/device.ts @@ -7,32 +7,32 @@ import { Atom } from "effect/reactivity"; import { useMemo } from "react"; import { connectionAtomRuntime } from "../connection/runtime"; -import { appAtomRegistry } from "./atom-registry"; import { useEnvironmentQuery } from "./query"; import { environmentSession, usePreparedConnection } from "./session"; export const deviceEnvironment = createDeviceEnvironmentAtoms(connectionAtomRuntime); +// Hub tickets are single use, so each viewer resolves its own access: a new +// viewer (a reload, a device switch, a return to the screen) never reuses a +// ticket an earlier connection already spent. const deviceHubAccessAtom = Atom.family((environmentId: EnvironmentId) => - connectionAtomRuntime - .atom((get) => { - const prepared = Option.getOrNull( - get(environmentSession.preparedConnectionValueAtom(environmentId)), - ); - return prepared === null - ? Effect.never - : resolveDeviceHubAccess({ prepared, hubBasePath: "/api/device-hub" }); - }) - .pipe(Atom.setIdleTTL(60_000), Atom.withLabel(`mobile-device-hub-access:${environmentId}`)), + Atom.family((_viewer: string) => + connectionAtomRuntime + .atom((get) => { + const prepared = Option.getOrNull( + get(environmentSession.preparedConnectionValueAtom(environmentId)), + ); + return prepared === null + ? Effect.never + : resolveDeviceHubAccess({ prepared, hubBasePath: "/api/device-hub" }); + }) + .pipe(Atom.withLabel(`mobile-device-hub-access:${environmentId}`)), + ), ); -export function refreshDeviceHubAccess(environmentId: EnvironmentId) { - appAtomRegistry.refresh(deviceHubAccessAtom(environmentId)); -} - -export function useDeviceHubAccess(environmentId: EnvironmentId, hostId: string) { +export function useDeviceHubAccess(environmentId: EnvironmentId, hostId: string, viewer: string) { const prepared = usePreparedConnection(environmentId); - const query = useEnvironmentQuery(deviceHubAccessAtom(environmentId)); + const query = useEnvironmentQuery(deviceHubAccessAtom(environmentId)(viewer)); const access = useMemo( () => query.data && query.error === null && Option.isSome(prepared) diff --git a/apps/mobile/src/state/orchestration.ts b/apps/mobile/src/state/orchestration.ts index 344482fc69..fb0383a9d4 100644 --- a/apps/mobile/src/state/orchestration.ts +++ b/apps/mobile/src/state/orchestration.ts @@ -1,5 +1,8 @@ import { createOrchestrationEnvironmentAtoms } from "@infinitus/client-runtime/state/orchestration"; import { connectionAtomRuntime } from "../connection/runtime"; +import { recordReviewTurnDiffAnswer } from "./reviewTurnDiffs"; -export const orchestrationEnvironment = createOrchestrationEnvironmentAtoms(connectionAtomRuntime); +export const orchestrationEnvironment = createOrchestrationEnvironmentAtoms(connectionAtomRuntime, { + onCheckpointDiffAnswer: recordReviewTurnDiffAnswer, +}); diff --git a/apps/mobile/src/state/preview.ts b/apps/mobile/src/state/preview.ts index 5bd7662f5f..ca0a61a1a4 100644 --- a/apps/mobile/src/state/preview.ts +++ b/apps/mobile/src/state/preview.ts @@ -16,6 +16,7 @@ import { AsyncResult, Atom } from "effect/reactivity"; import { useMemo } from "react"; import { connectionAtomRuntime } from "../connection/runtime"; +import { appAtomRegistry } from "./atom-registry"; import { useEnvironmentQuery } from "./query"; import { environmentSession, usePreparedConnection } from "./session"; @@ -142,22 +143,40 @@ export function useThreadServerBrowserTabs(input: { return { tabs: sessions, loaded: tabs.listed }; } +// The earliest time the next access request may go out. +const previewStreamNotBeforeAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make(0).pipe(Atom.withLabel(`mobile-preview-stream-retry:${environmentId}`)), +); + const previewStreamAccessAtom = Atom.family((environmentId: EnvironmentId) => connectionAtomRuntime .atom((get) => { const prepared = Option.getOrNull( get(environmentSession.preparedConnectionValueAtom(environmentId)), ); - return prepared === null - ? Effect.never - : resolveDeviceHubAccess({ prepared, hubBasePath: PREVIEW_STREAM_BASE_PATH }); + const notBefore = get(previewStreamNotBeforeAtom(environmentId)); + if (prepared === null) return Effect.never; + const resolve = resolveDeviceHubAccess({ prepared, hubBasePath: PREVIEW_STREAM_BASE_PATH }); + // A recompute interrupts the wait, so only the newest retry resolves. + const wait = notBefore - Date.now(); + return wait > 0 ? Effect.andThen(Effect.sleep(wait), resolve) : resolve; }) .pipe(Atom.setIdleTTL(60_000), Atom.withLabel(`mobile-preview-stream-access:${environmentId}`)), ); +/** Resolves fresh stream access once `delayMs` has passed. */ +function retryPreviewStreamAccess(environmentId: EnvironmentId, delayMs: number) { + appAtomRegistry.set(previewStreamNotBeforeAtom(environmentId), Date.now() + delayMs); +} + export function usePreviewStreamAccess(environmentId: EnvironmentId) { const prepared = usePreparedConnection(environmentId); const query = useEnvironmentQuery(previewStreamAccessAtom(environmentId)); const access = query.data && query.error === null && Option.isSome(prepared) ? query.data : null; - return { access, error: query.error, refresh: query.refresh }; + return { + access, + error: query.error, + refresh: query.refresh, + retryAfter: (delayMs: number) => retryPreviewStreamAccess(environmentId, delayMs), + }; } diff --git a/apps/mobile/src/state/queued-run-edit.ts b/apps/mobile/src/state/queued-run-edit.ts index 0fa4aefc58..bb17cfc684 100644 --- a/apps/mobile/src/state/queued-run-edit.ts +++ b/apps/mobile/src/state/queued-run-edit.ts @@ -10,6 +10,7 @@ import { useAtomValue } from "@effect/atom-react"; import { uploadedComposerContext } from "../lib/composerContext"; import { appAtomRegistry } from "./atom-registry"; +import { abortComposerDraftOperations } from "./composerDraftOperations"; import { queuedEditDraftKey } from "./queued-edit-draft-key"; import { clearComposerDraft, @@ -59,6 +60,8 @@ export function beginQueuedRunEdit(threadKey: string, edit: QueuedRunEdit): void if (previous !== null && previous.runId !== edit.runId) { clearComposerDraft(queuedEditDraftKey(threadKey, previous.runId)); } + // The composer moves off the thread draft, so a paste or pick still running for it is dropped. + abortComposerDraftOperations(threadKey); const draftKey = queuedEditDraftKey(threadKey, edit.runId); clearComposerDraft(draftKey); setComposerDraftText(draftKey, edit.originalText); diff --git a/apps/mobile/src/state/review.ts b/apps/mobile/src/state/review.ts index 0f2537e39a..bc0c0fd59e 100644 --- a/apps/mobile/src/state/review.ts +++ b/apps/mobile/src/state/review.ts @@ -1,5 +1,31 @@ import { createReviewEnvironmentAtoms } from "@infinitus/client-runtime/state/review"; +import type { EnvironmentId, ReviewDiffPreviewSource } from "@infinitus/contracts"; +import * as Effect from "effect/Effect"; +import { Atom } from "effect/reactivity"; import { connectionAtomRuntime } from "../connection/runtime"; -export const reviewEnvironment = createReviewEnvironmentAtoms(connectionAtomRuntime); +const EMPTY_GIT_SECTIONS = Object.freeze>([]); + +// The last answered preview per worktree, so review keeps showing git sections while the live +// query is not mounted. useReviewSections still hides them unless the session grants file reads. +const lastGitSectionsFamily = Atom.family((key: string) => + Atom.make(EMPTY_GIT_SECTIONS).pipe( + Atom.keepAlive, + Atom.withLabel(`mobile:review:git-sections:${key}`), + ), +); + +export function lastReviewGitSectionsAtom(environmentId: EnvironmentId, cwd: string) { + return lastGitSectionsFamily(JSON.stringify([environmentId, cwd])); +} + +export const reviewEnvironment = createReviewEnvironmentAtoms(connectionAtomRuntime, { + onDiffPreviewAnswer: (target, value, registry) => + Effect.sync(() => + registry.set( + lastReviewGitSectionsAtom(target.environmentId, target.input.cwd), + value.sources, + ), + ), +}); diff --git a/apps/mobile/src/state/reviewTurnDiffs.test.ts b/apps/mobile/src/state/reviewTurnDiffs.test.ts new file mode 100644 index 0000000000..48c452d884 --- /dev/null +++ b/apps/mobile/src/state/reviewTurnDiffs.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "@effect/vitest"; +import { EnvironmentId, ThreadId } from "@infinitus/contracts"; +import * as Effect from "effect/Effect"; +import { AtomRegistry } from "effect/reactivity"; + +import { reviewTurnDiffsBySectionId } from "../features/review/reviewModel"; +import { lastReviewTurnDiffsAtom, recordReviewTurnDiffAnswer } from "./reviewTurnDiffs"; + +const environmentId = EnvironmentId.make("env-1"); +const threadId = ThreadId.make("thread-1"); + +const answer = ( + registry: AtomRegistry.AtomRegistry, + range: { fromTurnCount: number; toTurnCount: number; diff: string }, + ignoreWhitespace = false, +) => + recordReviewTurnDiffAnswer( + { environmentId, input: { ignoreWhitespace } }, + { threadId, ...range }, + registry, + ); + +describe("recordReviewTurnDiffAnswer", () => { + it.effect("keeps loaded turn diffs for review after their queries are gone", () => + Effect.gen(function* () { + const registry = AtomRegistry.make(); + yield* answer(registry, { fromTurnCount: 0, toTurnCount: 1, diff: "turn one" }); + yield* answer(registry, { fromTurnCount: 1, toTurnCount: 2, diff: "turn two" }); + + // A disconnected sheet mounts no turn query; every loaded turn still has its diff. + const cached = registry.get(lastReviewTurnDiffsAtom(environmentId, threadId)); + expect( + reviewTurnDiffsBySectionId( + [{ checkpointTurnCount: 1 }, { checkpointTurnCount: 2 }, { checkpointTurnCount: 3 }], + cached, + ), + ).toEqual({ "turn:1": "turn one", "turn:2": "turn two", "turn:3": undefined }); + registry.dispose(); + }), + ); + + it.effect("ignores ranges and whitespace-ignoring reads, which no review section shows", () => + Effect.gen(function* () { + const registry = AtomRegistry.make(); + yield* answer(registry, { fromTurnCount: 0, toTurnCount: 3, diff: "whole thread" }); + yield* answer(registry, { fromTurnCount: 1, toTurnCount: 2, diff: "no whitespace" }, true); + + expect(registry.get(lastReviewTurnDiffsAtom(environmentId, threadId))).toEqual({}); + registry.dispose(); + }), + ); +}); diff --git a/apps/mobile/src/state/reviewTurnDiffs.ts b/apps/mobile/src/state/reviewTurnDiffs.ts new file mode 100644 index 0000000000..e2baa6ee09 --- /dev/null +++ b/apps/mobile/src/state/reviewTurnDiffs.ts @@ -0,0 +1,38 @@ +import type { EnvironmentId, OrchestrationGetTurnDiffResult, ThreadId } from "@infinitus/contracts"; +import * as Effect from "effect/Effect"; +import { Atom, type AtomRegistry } from "effect/reactivity"; + +const EMPTY_TURN_DIFFS = Object.freeze>>({}); + +// The last answered diff of each single turn, keyed by its turn count, so review keeps showing +// turns it already loaded while the environment is disconnected and no turn query is mounted. +const lastTurnDiffsFamily = Atom.family((key: string) => + Atom.make(EMPTY_TURN_DIFFS).pipe( + Atom.keepAlive, + Atom.withLabel(`mobile:review:turn-diffs:${key}`), + ), +); + +export function lastReviewTurnDiffsAtom(environmentId: EnvironmentId, threadId: ThreadId) { + return lastTurnDiffsFamily(JSON.stringify([environmentId, threadId])); +} + +export const recordReviewTurnDiffAnswer = ( + target: { + readonly environmentId: EnvironmentId; + readonly input: { readonly ignoreWhitespace?: boolean }; + }, + value: OrchestrationGetTurnDiffResult, + registry: AtomRegistry.AtomRegistry, +) => + Effect.sync(() => { + // Review shows one turn with whitespace changes; other ranges and whitespace-ignoring + // reads are not what a review section displays. + if (target.input.ignoreWhitespace === true || value.fromTurnCount !== value.toTurnCount - 1) { + return; + } + const atom = lastReviewTurnDiffsAtom(target.environmentId, value.threadId); + const current = registry.get(atom); + if (current[value.toTurnCount] === value.diff) return; + registry.set(atom, { ...current, [value.toTurnCount]: value.diff }); + }); diff --git a/apps/mobile/src/state/thread-order.ts b/apps/mobile/src/state/thread-order.ts index 88ed737717..e17ab7231c 100644 --- a/apps/mobile/src/state/thread-order.ts +++ b/apps/mobile/src/state/thread-order.ts @@ -25,6 +25,7 @@ export function usePendingThreadOrder(nowMinute: string, snoozeWakeTick: number) // Clock-driven wakes can change membership without shell events; retire order holds and re-enable moves then. useEffect(() => { getPendingThreadOrder(); + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Clock ticks re-evaluate the external pending-order snapshot even without a shell event. }, [nowMinute, snoozeWakeTick]); return pending; } diff --git a/apps/mobile/src/state/use-composer-attachment-actions.ts b/apps/mobile/src/state/use-composer-attachment-actions.ts index 567d8cd7e6..302a571004 100644 --- a/apps/mobile/src/state/use-composer-attachment-actions.ts +++ b/apps/mobile/src/state/use-composer-attachment-actions.ts @@ -1,5 +1,5 @@ import * as Dialog from "../components/ui/Dialog"; -import { useLayoutEffect, useRef } from "react"; +import { useRef } from "react"; import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS, type ServerConfig } from "@infinitus/contracts"; import { clampFileAttachmentUploadBytes } from "@infinitus/client-runtime/state/attachments"; import { nextPastedTextFileName } from "@infinitus/client-runtime/text-paste"; @@ -19,28 +19,22 @@ import { getComposerDraftSnapshot, type ComposerDraftInsertion, } from "./use-composer-drafts"; +import { startComposerDraftOperation } from "./composerDraftOperations"; export function useComposerAttachmentActions(props: { readonly draftKey: string | null; readonly serverConfig: ServerConfig | null | undefined; readonly locked?: boolean; }) { - const currentKey = useRef(props.draftKey); const reservedNames = useRef(new Map>()); - useLayoutEffect(() => { - currentKey.current = props.draftKey; - return () => { - currentKey.current = null; - }; - }, [props.draftKey]); const capabilities = props.serverConfig?.environment.capabilities; const append = async ( key: string, + signal: AbortSignal, attachments: ReadonlyArray, insertion: ComposerDraftInsertion, ) => { - // A picker may finish after selecting another draft or sending this one. - if (currentKey.current !== key) { + if (signal.aborted) { await Promise.all( attachments.flatMap((item) => item.fileUri ? [removePersistedComposerAttachmentFile(item.fileUri)] : [], @@ -50,6 +44,11 @@ export function useComposerAttachmentActions(props: { } return appendComposerDraftAttachments(key, attachments, { appendReference: true, insertion }); }; + // A picker may finish after its draft was discarded or the composer moved off it. + const withDraftOperation = async (key: string, work: (signal: AbortSignal) => Promise) => { + const operation = startComposerDraftOperation(key); + await work(operation.signal).finally(operation.finish); + }; const report = (title: string, error: string | null | undefined, rejected: number) => { const problems = [ ...(error ? [error] : []), @@ -63,17 +62,19 @@ export function useComposerAttachmentActions(props: { const key = props.draftKey; if (!key || props.locked) return; const insertion = captureComposerDraftInsertion(key); - const result = await pickComposerMedia({ - existingCount: countComposerDraftAttachmentsAfterSelection(key, insertion), - maxVideoBytes: capabilities?.attachmentUploads - ? capabilities.fileAttachments?.maxUploadBytes - : undefined, + await withDraftOperation(key, async (signal) => { + const result = await pickComposerMedia({ + existingCount: countComposerDraftAttachmentsAfterSelection(key, insertion), + maxVideoBytes: capabilities?.attachmentUploads + ? capabilities.fileAttachments?.maxUploadBytes + : undefined, + }); + report( + "Could not attach photo or video", + result.error, + await append(key, signal, result.attachments, insertion), + ); }); - report( - "Could not attach photo or video", - result.error, - await append(key, result.attachments, insertion), - ); }; const onPickDraftFiles = async () => { const key = props.draftKey; @@ -84,25 +85,36 @@ export function useComposerAttachmentActions(props: { return; } const insertion = captureComposerDraftInsertion(key); - const result = await pickComposerFiles({ - existingCount: countComposerDraftAttachmentsAfterSelection(key, insertion), - maxBytes, + await withDraftOperation(key, async (signal) => { + const result = await pickComposerFiles({ + existingCount: countComposerDraftAttachmentsAfterSelection(key, insertion), + maxBytes, + }); + report( + "Could not attach file", + result.error, + await append(key, signal, result.files, insertion), + ); }); - report("Could not attach file", result.error, await append(key, result.files, insertion)); }; const onNativePasteImages = async (uris: ReadonlyArray) => { const key = props.draftKey; if (!key || props.locked || !uris.length) return; const insertion = captureComposerDraftInsertion(key); - try { - const images = await convertPastedImagesToAttachments({ - uris, - existingCount: countComposerDraftAttachmentsAfterSelection(key, insertion), - }); - report("Could not paste images", null, await append(key, images, insertion)); - } catch (error) { - Dialog.alert("Could not paste images", error instanceof Error ? error.message : "Try again."); - } + await withDraftOperation(key, async (signal) => { + try { + const images = await convertPastedImagesToAttachments({ + uris, + existingCount: countComposerDraftAttachmentsAfterSelection(key, insertion), + }); + report("Could not paste images", null, await append(key, signal, images, insertion)); + } catch (error) { + Dialog.alert( + "Could not paste images", + error instanceof Error ? error.message : "Try again.", + ); + } + }); }; const onNativePasteText = async (paste: ComposerTextPaste) => { const key = props.draftKey; @@ -116,21 +128,26 @@ export function useComposerAttachmentActions(props: { for (const attachment of getComposerDraftSnapshot(key).attachments) names.add(attachment.name); const name = nextPastedTextFileName([...names]); names.add(name); - try { - const attachment = await createPastedTextComposerAttachment({ - text: paste.text, - name, - maxBytes: clampFileAttachmentUploadBytes(maxBytes), - }); - const rejected = await append(key, [attachment], { text: paste.value, ...paste.selection }); - if (rejected) await removePersistedComposerAttachmentFile(attachment.fileUri); - report("Could not attach pasted text", null, rejected); - } catch (error) { - Dialog.alert( - "Could not attach pasted text", - error instanceof Error ? error.message : "Try again.", - ); - } + await withDraftOperation(key, async (signal) => { + try { + const attachment = await createPastedTextComposerAttachment({ + text: paste.text, + name, + maxBytes: clampFileAttachmentUploadBytes(maxBytes), + }); + const rejected = await append(key, signal, [attachment], { + text: paste.value, + ...paste.selection, + }); + if (rejected) await removePersistedComposerAttachmentFile(attachment.fileUri); + report("Could not attach pasted text", null, rejected); + } catch (error) { + Dialog.alert( + "Could not attach pasted text", + error instanceof Error ? error.message : "Try again.", + ); + } + }); }; return { onPickDraftMedia, onPickDraftFiles, onNativePasteImages, onNativePasteText }; } diff --git a/apps/mobile/src/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts index 75634c9e9b..03528fc9e0 100644 --- a/apps/mobile/src/state/use-composer-drafts.test.ts +++ b/apps/mobile/src/state/use-composer-drafts.test.ts @@ -171,6 +171,7 @@ import { formatComposerContextReference, } from "@infinitus/shared/composerContextReferences"; import { appAtomRegistry } from "./atom-registry"; +import { startComposerDraftOperation } from "./composerDraftOperations"; import { threadOutboxManager } from "./thread-outbox"; import { appendComposerDraftAttachments, @@ -2276,6 +2277,32 @@ describe("mobile composer drafts", () => { }); }); + it("aborts operations running for drafts an environment removal drops", async () => { + const environmentId = EnvironmentId.make("environment-removed"); + composerDraftFileMocks.setDocument({ schemaVersion: 1, drafts: {} }); + appAtomRegistry.set(composerDraftsAtom, { + "new-task:removed-draft": { + ...DRAFT, + project: { + environmentId, + projectId: ProjectId.make("project-removed"), + createdAt: "2026-09-05T00:00:00.000Z", + }, + }, + "environment-kept:thread-kept": DRAFT, + }); + const newTask = startComposerDraftOperation("new-task:removed-draft"); + const emptyThread = startComposerDraftOperation(`${environmentId}:thread-empty`); + const kept = startComposerDraftOperation("environment-kept:thread-kept"); + + await clearComposerDraftsEnvironment(environmentId); + + expect(newTask.signal.aborted).toBe(true); + expect(emptyThread.signal.aborted).toBe(true); + expect(kept.signal.aborted).toBe(false); + kept.finish(); + }); + it("lands a still-debounced draft write when flushed", async () => { const draftKey = "environment-1:thread-1"; setComposerDraftText(draftKey, "typed right before the restart"); diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts index 786c89e5bc..464fadcb5a 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -54,6 +54,10 @@ import { import type { DraftComposerAttachment, FileBackedComposerAttachment } from "../lib/composerImages"; import { SerializedAsyncQueue } from "../lib/serialized-async-queue"; import { appAtomRegistry } from "./atom-registry"; +import { + abortComposerDraftOperations, + abortComposerDraftOperationsWhere, +} from "./composerDraftOperations"; import { isNewTaskDraftKey, newTaskDraftKey, @@ -1804,6 +1808,7 @@ export function clearComposerDraft( draftKey: string, options?: { readonly deferAttachmentCleanup?: boolean }, ): void { + abortComposerDraftOperations(draftKey); const previousAttachments = getComposerDraftSnapshot(draftKey).attachments; updateComposerDrafts((current) => { if (!current[draftKey]) { @@ -1913,6 +1918,13 @@ export async function clearComposerDraftsEnvironment(environmentId: EnvironmentI const removedAttachments = Object.entries(current) .filter(([draftKey]) => next[draftKey] === undefined) .flatMap(([, draft]) => draft.attachments); + // Keys with no stored draft yet (an empty thread composer) can still have a pick running. + const environmentPrefix = `${environmentId}:`; + abortComposerDraftOperationsWhere( + (draftKey) => + draftKey.startsWith(environmentPrefix) || + (current[draftKey] !== undefined && next[draftKey] === undefined), + ); if (persistTimer !== null) { clearTimeout(persistTimer); diff --git a/apps/mobile/src/state/use-remote-environment-registry.ts b/apps/mobile/src/state/use-remote-environment-registry.ts index a5ad3dfbb3..5925c911a5 100644 --- a/apps/mobile/src/state/use-remote-environment-registry.ts +++ b/apps/mobile/src/state/use-remote-environment-registry.ts @@ -7,6 +7,7 @@ import * as Cause from "effect/Cause"; import { AsyncResult, Atom } from "effect/reactivity"; import { useCallback } from "react"; +import { hasCloudPublicConfig } from "../features/cloud/publicConfig"; import { useConnectionController } from "../features/connection/useConnectionController"; import { environmentPresentations } from "./presentation"; import { useWorkspaceConnectionState, useWorkspaceEnvironments } from "./workspace"; @@ -183,10 +184,22 @@ export function useRemoteConnections() { `Forget ${environment.environmentLabel} and its cached threads on this device.\n\nIt stays on your ${CONNECT_NAME} account and keeps its host space. Deregister it under ${PRODUCT_NAME} Account → ${CONNECT_NAME} to free it.`, [ { text: "Cancel", style: "cancel" }, +<<<<<<< HEAD { text: `Open ${PRODUCT_NAME} Account`, onPress: () => navigation.navigate("SettingsAuth"), }, +======= + // SettingsAuth renders nothing in builds without cloud config. + ...(hasCloudPublicConfig() + ? [ + { + text: "Open T3 Account", + onPress: () => navigation.navigate("SettingsAuth"), + }, + ] + : []), +>>>>>>> upstream-sync-cc0b86ddf-upstream-renamed remove, ], ); diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts index 8c51313b18..5961d0d9de 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -1377,6 +1377,7 @@ export function useThreadOutboxDrain(): void { operableEnvironments, projects, queuedMessagesByThreadKey, + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- retryTick re-runs the drain when a retry timer fires. retryTick, restoreQueuedMessage, scheduleQueuedMessageRetry, diff --git a/apps/mobile/src/state/use-vcs-action-state.ts b/apps/mobile/src/state/use-vcs-action-state.ts index 68ca2878b3..6d1bd32e6d 100644 --- a/apps/mobile/src/state/use-vcs-action-state.ts +++ b/apps/mobile/src/state/use-vcs-action-state.ts @@ -1,7 +1,8 @@ import { useAtomValue } from "@effect/atom-react"; +import { secondTick } from "@infinitus/client-runtime/clock"; import { type VcsActionState, type VcsActionTarget } from "@infinitus/client-runtime/state/vcs"; import { Atom } from "effect/reactivity"; -import { useEffect, useState } from "react"; +import { useSyncExternalStore } from "react"; import { appAtomRegistry } from "./atom-registry"; import { vcsActionManager } from "./vcs"; @@ -61,6 +62,8 @@ export interface GitActionProgress { readonly prUrl?: string; } +const subscribeNever = () => () => {}; + const EMPTY_PROGRESS: GitActionProgress = { phase: "idle", label: null, @@ -78,14 +81,11 @@ export function useGitActionProgress(target: VcsActionTarget): GitActionProgress const actionState = useVcsActionState(target); const { result } = useGitActionResultNotification(); - // Keep clock state; the compiler caches render-time Date.now() and would freeze the label. - const [nowMs, setNowMs] = useState(() => Date.now()); - useEffect(() => { - if (!actionState.isRunning) return; - setNowMs(Date.now()); - const interval = setInterval(() => setNowMs(Date.now()), 1000); - return () => clearInterval(interval); - }, [actionState.isRunning]); + // Only a running action shows elapsed time, so only it subscribes to the second tick. + const nowMs = useSyncExternalStore( + actionState.isRunning ? secondTick.subscribe : subscribeNever, + secondTick.getSnapshot, + ); if (actionState.isRunning) { const description = diff --git a/apps/mobile/vite.config.ts b/apps/mobile/vite.config.ts index 77fffb6cb6..c341dec4e2 100644 --- a/apps/mobile/vite.config.ts +++ b/apps/mobile/vite.config.ts @@ -1,15 +1,21 @@ -import { defineConfig } from "vite-plus"; +import * as NodeModule from "node:module"; +import * as NodeURL from "node:url"; +import { defineConfig, normalizePath } from "vite-plus"; import "vite-plus/test/config"; const reactTests = [ + "src/features/projects/AddProjectScreen.test.ts", + "src/lib/useMediaVideoPlayback.test.tsx", "src/features/pull-requests/usePullRequestEnvironmentFallback.test.tsx", "src/lib/filePreview.test.ts", "src/features/threads/thread-inspector-navigation.test.tsx", "src/features/diffs/nativeReviewDiffSurface.test.tsx", "src/features/pull-requests/usePullRequestDiff.test.tsx", "src/components/ui/Dialog.android.test.tsx", + "src/features/pull-requests/guide/useReviewGuide.test.tsx", "src/features/settings/SettingsServerControlsRouteScreen.test.tsx", "src/features/settings/components/BranchNamingSettings.test.tsx", + "src/features/threads/ThreadMarkdownImage.test.tsx", "src/features/threads/UsageLimitRecoveryCard.test.tsx", "src/native/NativeHeaderToolbar.test.tsx", "src/native/useRestingTopOffset.test.tsx", @@ -19,6 +25,16 @@ const reactTests = [ "src/features/terminal/useTerminalExitNavigation.test.tsx", ]; +const sourceRoot = `${normalizePath(NodeURL.fileURLToPath(new URL("./src", import.meta.url)))}/`; +const require = NodeModule.createRequire(import.meta.url); +const babel: { + transformAsync: ( + code: string, + options: Record, + ) => Promise<{ code?: string | null; map?: object | null } | null>; +} = require("@babel/core"); +const babelConfig = require("./babel.config.js")({ cache: () => {} }); + export default defineConfig({ test: { hookTimeout: 60_000, @@ -38,6 +54,44 @@ export default defineConfig({ }, { extends: true, + plugins: [ + { + name: "mobile-release-react-compiler", + enforce: "pre", + async transform(code, id) { + if ( + !normalizePath(id).startsWith(sourceRoot) || + !/\.tsx?$/.test(id) || + /\.test\.tsx?$/.test(id) + ) { + return; + } + const result = await babel.transformAsync(code, { + ...babelConfig, + presets: babelConfig.presets.map(([name, options]: [string, object]) => [ + require.resolve(name), + // Vite runs in Node; inline Babel helpers instead of Metro runtime imports. + { ...options, enableBabelRuntime: false }, + ]), + filename: id, + babelrc: false, + configFile: false, + sourceMaps: true, + caller: { + name: "metro", + bundler: "metro", + platform: "ios", + isDev: false, + isServer: false, + isNodeModule: false, + supportsStaticESM: true, + supportsReactCompiler: true, + }, + }); + if (result?.code) return { code: result.code, map: result.map }; + }, + }, + ], test: { name: "react", include: reactTests, diff --git a/apps/server/scripts/record-codex-app-server-replay-fixture.ts b/apps/server/scripts/record-codex-app-server-replay-fixture.ts index f0421abe45..35da481a11 100644 --- a/apps/server/scripts/record-codex-app-server-replay-fixture.ts +++ b/apps/server/scripts/record-codex-app-server-replay-fixture.ts @@ -1164,7 +1164,7 @@ function layerCodex({ recorder }: { readonly recorder: Recorder }) { const clientOptions: CodexClient.CodexAppServerClientOptions = { logIncoming: true, logOutgoing: true, - logger: (event) => { + logger: Effect.fn("logger")(function (event) { if (event.stage === "raw") { return Effect.void; } @@ -1215,7 +1215,7 @@ function layerCodex({ recorder }: { readonly recorder: Recorder }) { yield* updateVersion; yield* recorder.writeRecord(record); }).pipe(Effect.ignore); - }, + }), }; return Layer.effect( diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index c6d8d75cf3..29a50067c1 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -1416,8 +1416,10 @@ describe("AssetAccess", () => { }, ]); return Effect.gen(function* () { + const decodeAssetCreateUrlInput = Schema.decodeEffect(AssetCreateUrlInput); + // Every client and server release spells the resource this way on the wire. - const { resource } = yield* Schema.decodeEffect(AssetCreateUrlInput)({ + const { resource } = yield* decodeAssetCreateUrlInput({ resource: { _tag: "github-media", cwd: "/repo", url }, }); const issued = yield* issueAssetUrl({ resource }); @@ -1446,6 +1448,8 @@ describe("AssetAccess", () => { it.effect("refuses media no host keeps behind a credential, without echoing the URL", () => Effect.gen(function* () { + const encodeAssetAccessError = Schema.encodeEffect(Schema.fromJsonString(AssetAccessError)); + for (const url of [ "https://example.com/shot.png", "https://example.com/shot.png?token=private-media-token", @@ -1454,7 +1458,7 @@ describe("AssetAccess", () => { resource: { _tag: SOURCE_CONTROL_MEDIA_ASSET, cwd: "/repo", url }, }).pipe(Effect.flip); expect(error._tag).toBe("AssetGitHubMediaUrlValidationError"); - const encoded = yield* Schema.encodeEffect(Schema.fromJsonString(AssetAccessError))(error); + const encoded = yield* encodeAssetAccessError(error); expect(encoded).not.toContain(url); } }).pipe(Effect.provide(layerTest)), diff --git a/apps/server/src/assets/AttachmentUpload.ts b/apps/server/src/assets/AttachmentUpload.ts index 9375e9ae9d..4a7ad2c2f3 100644 --- a/apps/server/src/assets/AttachmentUpload.ts +++ b/apps/server/src/assets/AttachmentUpload.ts @@ -230,8 +230,9 @@ export const storeAttachmentUpload = Effect.fn("AttachmentUpload.store")(functio }), }).pipe( Effect.as(true), - Effect.catch((error) => - error.reason._tag === "AlreadyExists" ? Effect.succeed(false) : Effect.fail(error), + Effect.catchIf( + (error) => error.reason._tag === "AlreadyExists", + () => Effect.succeed(false), ), ); }), diff --git a/apps/server/src/auth/EnvironmentAuth.ts b/apps/server/src/auth/EnvironmentAuth.ts index 5075ef0201..751d8706c3 100644 --- a/apps/server/src/auth/EnvironmentAuth.ts +++ b/apps/server/src/auth/EnvironmentAuth.ts @@ -1374,8 +1374,9 @@ export const make = Effect.gen(function* () { ); return EnvironmentAuth.of({ - getDescriptor: () => - Effect.succeed(descriptor).pipe(Effect.withSpan("EnvironmentAuth.getDescriptor")), + getDescriptor: Effect.fn("EnvironmentAuth.getDescriptor")(function () { + return Effect.succeed(descriptor); + }), getSessionState, createBrowserSession, exchangeBootstrapCredentialForAccessToken, diff --git a/apps/server/src/auth/EnvironmentAuthPolicy.ts b/apps/server/src/auth/EnvironmentAuthPolicy.ts index 55523797be..52e1e58162 100644 --- a/apps/server/src/auth/EnvironmentAuthPolicy.ts +++ b/apps/server/src/auth/EnvironmentAuthPolicy.ts @@ -52,8 +52,9 @@ export const make = Effect.gen(function* () { }; return EnvironmentAuthPolicy.of({ - getDescriptor: () => - Effect.succeed(descriptor).pipe(Effect.withSpan("EnvironmentAuthPolicy.getDescriptor")), + getDescriptor: Effect.fn("EnvironmentAuthPolicy.getDescriptor")(function () { + return Effect.succeed(descriptor); + }), }); }); diff --git a/apps/server/src/auth/McpOAuth.ts b/apps/server/src/auth/McpOAuth.ts index cd41c6afe1..85c4c2a168 100644 --- a/apps/server/src/auth/McpOAuth.ts +++ b/apps/server/src/auth/McpOAuth.ts @@ -528,35 +528,38 @@ export const layerMcpClientAuthenticator = Layer.effect( const environment = yield* ServerEnvironment.ServerEnvironment; const environmentId = yield* environment.getEnvironmentId; return McpHttpServer.McpClientAuthenticator.of({ - authenticate: (request) => - environmentAuth.authenticateMcpClient(request).pipe( - Effect.flatMap((client) => - Clock.currentTimeMillis.pipe( - Effect.map((issuedAt): McpInvocationContext.McpInvocationScope => ({ - environmentId, - requestNamespace: `client:${client.sessionId}`, - thread: undefined, - client: { - sessionId: client.sessionId, - label: client.label, - access: client.access, - }, - capabilities: new Set([ - "orchestration", - "worktree", - "pull-requests", - ]), - issuedAt, - })), + authenticate: Effect.fn("McpHttpServer.McpClientAuthenticator.authenticate")( + function (request) { + return environmentAuth.authenticateMcpClient(request).pipe( + Effect.flatMap((client) => + Clock.currentTimeMillis.pipe( + Effect.map((issuedAt): McpInvocationContext.McpInvocationScope => ({ + environmentId, + requestNamespace: `client:${client.sessionId}`, + thread: undefined, + client: { + sessionId: client.sessionId, + label: client.label, + access: client.access, + }, + capabilities: new Set([ + "orchestration", + "worktree", + "pull-requests", + ]), + issuedAt, + })), + ), ), - ), - Effect.catch((error) => - (EnvironmentAuth.isServerAuthCredentialError(error) - ? Effect.void - : Effect.logWarning("MCP client authentication failed.", { cause: error }) - ).pipe(Effect.as(undefined)), - ), - ), + Effect.catch((error) => + (EnvironmentAuth.isServerAuthCredentialError(error) + ? Effect.void + : Effect.logWarning("MCP client authentication failed.", { cause: error }) + ).pipe(Effect.as(undefined)), + ), + ); + }, + ), }); }), ); diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index fd83899b54..ce1eb57eea 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -262,9 +262,10 @@ const SettingsUpdate = Schema.Struct({ patch: ServerSettingsPatch, providerInstanceMutation: Schema.optionalKey(ProviderInstanceMutation), }); +const decodeSettingsUpdate = Schema.decodeUnknownSync(SettingsUpdate); const requiredScopesForSettingsUpdate = (payload: unknown) => { - const input = Schema.decodeUnknownSync(SettingsUpdate)(payload); + const input = decodeSettingsUpdate(payload); const scopes = requiredScopesForServerSettingsPatch(input.patch); if (input.providerInstanceMutation === undefined) return scopes; return Object.values(input.patch).every((value) => value === undefined) @@ -273,6 +274,7 @@ const requiredScopesForSettingsUpdate = (payload: unknown) => { }; const decodeProjectMutation = Schema.decodeUnknownSync(ProjectMutation); +const decodeAssetCreateUrlInput = Schema.decodeUnknownSync(AssetCreateUrlInput); const requiredScopesForRpcCall = ( method: string, @@ -282,7 +284,7 @@ const requiredScopesForRpcCall = ( return [AuthEnvironmentMaintainScope, AuthDiagnosticsReadScope]; } if (method === WS_METHODS.assetsCreateUrl) { - const { resource } = Schema.decodeUnknownSync(AssetCreateUrlInput)(payload); + const { resource } = decodeAssetCreateUrlInput(payload); return [ resource._tag === "workspace-file" || resource._tag === "media-file" || diff --git a/apps/server/src/background/HostPowerMonitor.test.ts b/apps/server/src/background/HostPowerMonitor.test.ts index 1d074f6c05..7b28d956f4 100644 --- a/apps/server/src/background/HostPowerMonitor.test.ts +++ b/apps/server/src/background/HostPowerMonitor.test.ts @@ -1,6 +1,7 @@ import type { DesktopHostTelemetrySnapshot } from "@infinitus/contracts"; import { describe, expect, it } from "@effect/vitest"; import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; @@ -114,6 +115,7 @@ describe("HostPowerMonitor", () => { }, }; const desktopChanges = yield* PubSub.sliding(1); + const changeApplied = yield* Deferred.make(); const layerReceiver = DesktopTelemetryReceiver.layerTest({ latest: Effect.succeedSome(initial), changes: Stream.empty, @@ -122,7 +124,10 @@ describe("HostPowerMonitor", () => { yield* PubSub.publish(desktopChanges, updated); return { latest: Option.some(initial), - changes: Stream.fromSubscription(subscription), + changes: Stream.fromSubscription(subscription).pipe( + Stream.take(1), + Stream.ensuring(Deferred.succeed(changeApplied, undefined)), + ), }; }), }); @@ -130,13 +135,7 @@ describe("HostPowerMonitor", () => { yield* Effect.gen(function* () { const monitor = yield* HostPowerMonitor.HostPowerMonitor; - for ( - let attempt = 0; - attempt < 100 && (yield* monitor.snapshot).onBattery !== "true"; - attempt += 1 - ) { - yield* Effect.yieldNow; - } + yield* Deferred.await(changeApplied); expect((yield* monitor.snapshot).onBattery).toBe("true"); }).pipe(Effect.provide(layer)); }), diff --git a/apps/server/src/cloud/CliTokenManager.ts b/apps/server/src/cloud/CliTokenManager.ts index 0326fa109f..80665a676e 100644 --- a/apps/server/src/cloud/CliTokenManager.ts +++ b/apps/server/src/cloud/CliTokenManager.ts @@ -324,44 +324,44 @@ const exchangeToken = Effect.fn("cloud.cli_token.exchange")(function* ( Effect.flatMap((response) => readTokenResponse(response, params)), Effect.timeout(OAUTH_REQUEST_TIMEOUT), Effect.catchTags({ - HttpClientError: (cause) => - Effect.gen(function* () { - const reason = cause.reason; - if ( - params.grant_type !== "refresh_token" || - reason._tag !== "StatusCodeError" || - !isPermanentRefreshStatus(reason.response.status) - ) { - return yield* cause; - } - // Reading a rejection body shares the exchange budget without losing - // the known status when the body stalls or cannot be decoded. - const elapsed = Duration.toMillis( - Duration.nanos((yield* Clock.monotonicTimeNanos) - startedAt), - ); - const body = yield* reason.response.json.pipe( - Effect.timeout(Math.max(0, Duration.toMillis(OAUTH_REQUEST_TIMEOUT) - elapsed)), - Effect.mapError( - (cause) => - new CloudCliCredentialRefreshError({ - status: reason.response.status, - cause: new HttpClientError.HttpClientError({ - reason: new HttpClientError.StatusCodeError({ - request: reason.request, - response: reason.response, - cause, - }), + // The enclosing operation owns the span for this inline handler. + HttpClientError: Effect.fnUntraced(function* (cause) { + const reason = cause.reason; + if ( + params.grant_type !== "refresh_token" || + reason._tag !== "StatusCodeError" || + !isPermanentRefreshStatus(reason.response.status) + ) { + return yield* cause; + } + // Reading a rejection body shares the exchange budget without losing + // the known status when the body stalls or cannot be decoded. + const elapsed = Duration.toMillis( + Duration.nanos((yield* Clock.monotonicTimeNanos) - startedAt), + ); + const body = yield* reason.response.json.pipe( + Effect.timeout(Math.max(0, Duration.toMillis(OAUTH_REQUEST_TIMEOUT) - elapsed)), + Effect.mapError( + (cause) => + new CloudCliCredentialRefreshError({ + status: reason.response.status, + cause: new HttpClientError.HttpClientError({ + reason: new HttpClientError.StatusCodeError({ + request: reason.request, + response: reason.response, + cause, }), }), - ), - ); - const diagnostic = decodeOAuthRefreshErrorResponse(body); - return yield* new CloudCliCredentialRefreshError({ - cause, - status: reason.response.status, - ...(Option.isSome(diagnostic) ? { oauthErrorCode: diagnostic.value.error } : {}), - }); - }), + }), + ), + ); + const diagnostic = decodeOAuthRefreshErrorResponse(body); + return yield* new CloudCliCredentialRefreshError({ + cause, + status: reason.response.status, + ...(Option.isSome(diagnostic) ? { oauthErrorCode: diagnostic.value.error } : {}), + }); + }), }), ); }); diff --git a/apps/server/src/cloud/ManagedEndpointRuntime.ts b/apps/server/src/cloud/ManagedEndpointRuntime.ts index 9f8a1e048c..58831c8054 100644 --- a/apps/server/src/cloud/ManagedEndpointRuntime.ts +++ b/apps/server/src/cloud/ManagedEndpointRuntime.ts @@ -759,7 +759,9 @@ export const make = Effect.gen(function* () { inspectConnector, readinessChanged: Stream.fromQueue(readinessChanges), recoveryRequests: Stream.fromQueue(recoveryRequests), - requestRecovery: (config) => Queue.offer(recoveryRequests, config).pipe(Effect.asVoid), + requestRecovery: Effect.fn("CloudManagedEndpointRuntime.requestRecovery")(function (config) { + return Queue.offer(recoveryRequests, config).pipe(Effect.asVoid); + }), tunnelConnected: Stream.fromQueue(tunnelConnections), withLinkStateLock: linkStateSemaphore.withPermits(1), }); diff --git a/apps/server/src/cloud/selfUpdate.ts b/apps/server/src/cloud/selfUpdate.ts index a459f2df77..52771af337 100644 --- a/apps/server/src/cloud/selfUpdate.ts +++ b/apps/server/src/cloud/selfUpdate.ts @@ -454,8 +454,11 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () { return ServerSelfUpdate.of({ update, - commitDesktopUpdate: (requestId, onHandoffAccepted) => - desktopAppUpdate.commit(requestId, onHandoffAccepted), + commitDesktopUpdate: Effect.fn("ServerSelfUpdate.commitDesktopUpdate")( + function (requestId, onHandoffAccepted) { + return desktopAppUpdate.commit(requestId, onHandoffAccepted); + }, + ), }); }); diff --git a/apps/server/src/device/DeviceService.ts b/apps/server/src/device/DeviceService.ts index 6c14f274fa..a841c972a1 100644 --- a/apps/server/src/device/DeviceService.ts +++ b/apps/server/src/device/DeviceService.ts @@ -807,8 +807,8 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* return { ...DeviceService.of({ testHost, - updateTool: (tool) => - lifecycleLock.withPermit( + updateTool: Effect.fn("DeviceService.updateTool")(function (tool) { + return lifecycleLock.withPermit( Effect.gen(function* () { if (!installTool) return yield* new DeviceOperationError({ @@ -819,15 +819,17 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* yield* installTool(tool); return yield* inspect; }), - ), + ); + }), retryHost, inspect, - easAccount: () => - Effect.fail( + easAccount: Effect.fn("DeviceService.easAccount")(function () { + return Effect.fail( new DeviceEasAccountError({ reason: "EAS accounts are unavailable in this device service.", }), - ), + ); + }), agentCli: Effect.fail( new DeviceHostUnavailableError({ hostId: LOCAL_DEVICE_HOST_ID, diff --git a/apps/server/src/device/EasDeviceHost.ts b/apps/server/src/device/EasDeviceHost.ts index 7c4c3a8ee1..e94e559a88 100644 --- a/apps/server/src/device/EasDeviceHost.ts +++ b/apps/server/src/device/EasDeviceHost.ts @@ -863,8 +863,9 @@ export const make = Effect.fn("EasDeviceHost.make")(function* (config: EasDevice summary, platformAvailability, ensureReady, - ensureAgentReady: (onPhase) => - ensureReady(onPhase).pipe(Effect.map((value) => ({ ...value, agentDevice: null }))), + ensureAgentReady: Effect.fn("DeviceHost.ensureAgentReady")(function (onPhase) { + return ensureReady(onPhase).pipe(Effect.map((value) => ({ ...value, agentDevice: null }))); + }), current: Effect.sync(() => ready), stopAgent: Effect.void, // Unlike machines the user owns, every running session here is billed. diff --git a/apps/server/src/device/deviceToolVersions.json b/apps/server/src/device/deviceToolVersions.json index 83083ffbeb..34275ba3b6 100644 --- a/apps/server/src/device/deviceToolVersions.json +++ b/apps/server/src/device/deviceToolVersions.json @@ -1,4 +1,4 @@ { "agent-device": "0.21.31", - "expo-device-hub": "0.15.3" + "expo-device-hub": "0.19.0-canary-20261023-1357114" } diff --git a/apps/server/src/environment/DirectEndpoints.ts b/apps/server/src/environment/DirectEndpoints.ts index 132ed43ba9..483e0998f2 100644 --- a/apps/server/src/environment/DirectEndpoints.ts +++ b/apps/server/src/environment/DirectEndpoints.ts @@ -155,7 +155,11 @@ export const make = Effect.gen(function* () { return endpoints; }); - return DirectEndpoints.of({ resolve: () => resolve }); + return DirectEndpoints.of({ + resolve: Effect.fn("DirectEndpoints.resolve")(function () { + return resolve; + }), + }); }); export const layer = Layer.effect(DirectEndpoints, make); diff --git a/apps/server/src/environment/RemoteOpenTargets.ts b/apps/server/src/environment/RemoteOpenTargets.ts index bd46282a7f..2435316f20 100644 --- a/apps/server/src/environment/RemoteOpenTargets.ts +++ b/apps/server/src/environment/RemoteOpenTargets.ts @@ -78,7 +78,11 @@ export const make = Effect.gen(function* () { return targets; }); - return RemoteOpenTargets.of({ resolveTargets: () => resolveTargets }); + return RemoteOpenTargets.of({ + resolveTargets: Effect.fn("RemoteOpenTargets.resolveTargets")(function () { + return resolveTargets; + }), + }); }); export const layer = Layer.effect(RemoteOpenTargets, make); diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index cfb7f91f43..fe37015bea 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -300,8 +300,8 @@ export const make = Effect.gen(function* () { ensureGit(operation, input.cwd).pipe(Effect.andThen(run(input))); return GitWorkflowService.of({ - isRepository: (cwd) => - registry.detect({ cwd }).pipe( + isRepository: Effect.fn("GitWorkflowService.isRepository")(function (cwd) { + return registry.detect({ cwd }).pipe( Effect.map((handle) => handle?.kind === "git"), Effect.mapError( (cause) => @@ -312,9 +312,10 @@ export const make = Effect.gen(function* () { cause, }), ), - ), - hasCommit: (input) => - ensureGitCommand("GitWorkflowService.hasCommit", input.cwd).pipe( + ); + }), + hasCommit: Effect.fn("GitWorkflowService.hasCommit")(function (input) { + return ensureGitCommand("GitWorkflowService.hasCommit", input.cwd).pipe( Effect.andThen( git.execute({ operation: "GitWorkflowService.hasCommit", @@ -324,10 +325,11 @@ export const make = Effect.gen(function* () { }), ), Effect.map((result) => result.exitCode === 0), - ), - hasRefNamed: (input) => + ); + }), + hasRefNamed: Effect.fn("GitWorkflowService.hasRefNamed")(function (input) { // Pattern characters would widen the ref match. - /[*?[\\]/.test(input.refName) + return /[*?[\\]/.test(input.refName) ? Effect.succeed(false) : ensureGitCommand("GitWorkflowService.hasRefNamed", input.cwd).pipe( Effect.andThen( @@ -343,36 +345,41 @@ export const make = Effect.gen(function* () { }), ), Effect.map((result) => result.stdout.trim().length > 0), - ), - status: (input) => - detectGitRepositoryForStatus("GitWorkflowService.status", input.cwd).pipe( + ); + }), + status: Effect.fn("GitWorkflowService.status")(function (input) { + return detectGitRepositoryForStatus("GitWorkflowService.status", input.cwd).pipe( Effect.flatMap((isGitRepository) => isGitRepository ? gitManager.status(input) : Effect.succeed(nonRepositoryStatus()), ), - ), - localStatus: (input) => - detectGitRepositoryForStatus("GitWorkflowService.localStatus", input.cwd).pipe( + ); + }), + localStatus: Effect.fn("GitWorkflowService.localStatus")(function (input) { + return detectGitRepositoryForStatus("GitWorkflowService.localStatus", input.cwd).pipe( Effect.flatMap((isGitRepository) => isGitRepository ? gitManager.localStatus(input) : Effect.succeed(nonRepositoryLocalStatus()), ), - ), - remoteStatus: (input, options) => - detectGitRepositoryForStatus("GitWorkflowService.remoteStatus", input.cwd).pipe( + ); + }), + remoteStatus: Effect.fn("GitWorkflowService.remoteStatus")(function (input, options) { + return detectGitRepositoryForStatus("GitWorkflowService.remoteStatus", input.cwd).pipe( Effect.flatMap((isGitRepository) => isGitRepository ? gitManager.remoteStatus(input, options) : Effect.succeed(null), ), - ), + ); + }), invalidateLocalStatus: gitManager.invalidateLocalStatus, invalidateRemoteStatus: gitManager.invalidateRemoteStatus, invalidateStatus: gitManager.invalidateStatus, - pullCurrentBranch: (cwd) => - ensureGitCommand("GitWorkflowService.pullCurrentBranch", cwd).pipe( + pullCurrentBranch: Effect.fn("GitWorkflowService.pullCurrentBranch")(function (cwd) { + return ensureGitCommand("GitWorkflowService.pullCurrentBranch", cwd).pipe( Effect.andThen(git.pullCurrentBranch(cwd)), - ), - runStackedAction: (input, options) => - ensureGit("GitWorkflowService.runStackedAction", input.cwd).pipe( + ); + }), + runStackedAction: Effect.fn("GitWorkflowService.runStackedAction")(function (input, options) { + return ensureGit("GitWorkflowService.runStackedAction", input.cwd).pipe( Effect.flatMap((handle) => input.featureBranch ? withCheckoutLease( @@ -381,90 +388,115 @@ export const make = Effect.gen(function* () { ) : gitManager.runStackedAction(input, options), ), - ), + ); + }), resolvePullRequest: routeGitManager( "GitWorkflowService.resolvePullRequest", gitManager.resolvePullRequest, ), - preparePullRequestThread: (input) => - ensureGit("GitWorkflowService.preparePullRequestThread", input.cwd).pipe( - Effect.flatMap((handle) => - input.mode === "local" - ? withCheckoutLease( - handle.repository.rootPath, - gitManager.preparePullRequestThread(input), - ) - : gitManager.preparePullRequestThread(input), - ), - ), - listRefs: (input) => - detectGitRepositoryForCommand("GitWorkflowService.listRefs", input.cwd).pipe( + preparePullRequestThread: Effect.fn("GitWorkflowService.preparePullRequestThread")( + function (input) { + return ensureGit("GitWorkflowService.preparePullRequestThread", input.cwd).pipe( + Effect.flatMap((handle) => + input.mode === "local" + ? withCheckoutLease( + handle.repository.rootPath, + gitManager.preparePullRequestThread(input), + ) + : gitManager.preparePullRequestThread(input), + ), + ); + }, + ), + listRefs: Effect.fn("GitWorkflowService.listRefs")(function (input) { + return detectGitRepositoryForCommand("GitWorkflowService.listRefs", input.cwd).pipe( Effect.flatMap((isGitRepository) => isGitRepository ? git.listRefs(input) : Effect.succeed(nonRepositoryListRefs()), ), - ), - createWorktree: (input, options) => - ensureGitCommand("GitWorkflowService.createWorktree", input.cwd).pipe( + ); + }), + createWorktree: Effect.fn("GitWorkflowService.createWorktree")(function (input, options) { + return ensureGitCommand("GitWorkflowService.createWorktree", input.cwd).pipe( Effect.andThen(gitManager.createWorktree(input, options)), - ), - listLocalBranchNames: (cwd) => - ensureGitCommand("GitWorkflowService.listLocalBranchNames", cwd).pipe( + ); + }), + listLocalBranchNames: Effect.fn("GitWorkflowService.listLocalBranchNames")(function (cwd) { + return ensureGitCommand("GitWorkflowService.listLocalBranchNames", cwd).pipe( Effect.andThen(git.listLocalBranchNames(cwd)), - ), - fetchRemote: (input) => - ensureGitCommand("GitWorkflowService.fetchRemote", input.cwd).pipe( + ); + }), + fetchRemote: Effect.fn("GitWorkflowService.fetchRemote")(function (input) { + return ensureGitCommand("GitWorkflowService.fetchRemote", input.cwd).pipe( Effect.andThen(git.fetchRemote(input)), - ), - remoteExists: (input) => - ensureGitCommand("GitWorkflowService.remoteExists", input.cwd).pipe( + ); + }), + remoteExists: Effect.fn("GitWorkflowService.remoteExists")(function (input) { + return ensureGitCommand("GitWorkflowService.remoteExists", input.cwd).pipe( Effect.andThen(git.remoteExists(input)), - ), - remoteBranchExists: (input) => - ensureGitCommand("GitWorkflowService.remoteBranchExists", input.cwd).pipe( + ); + }), + remoteBranchExists: Effect.fn("GitWorkflowService.remoteBranchExists")(function (input) { + return ensureGitCommand("GitWorkflowService.remoteBranchExists", input.cwd).pipe( Effect.andThen(git.remoteBranchExists(input)), - ), - resolveRemoteTrackingCommit: (input) => - ensureGitCommand("GitWorkflowService.resolveRemoteTrackingCommit", input.cwd).pipe( - Effect.andThen(git.resolveRemoteTrackingCommit(input)), - ), - resolveWorktreeBaseRemote: (input) => - ensureGitCommand("GitWorkflowService.resolveWorktreeBaseRemote", input.cwd).pipe( - Effect.andThen(git.resolveWorktreeBaseRemote(input)), - ), - fetchWorktreeBaseCommit: (input) => - ensureGitCommand("GitWorkflowService.fetchWorktreeBaseCommit", input.cwd).pipe( - Effect.andThen(git.fetchWorktreeBaseCommit(input)), - ), - removeWorktree: (input) => - ensureGitCommand("GitWorkflowService.removeWorktree", input.cwd).pipe( + ); + }), + resolveRemoteTrackingCommit: Effect.fn("GitWorkflowService.resolveRemoteTrackingCommit")( + function (input) { + return ensureGitCommand("GitWorkflowService.resolveRemoteTrackingCommit", input.cwd).pipe( + Effect.andThen(git.resolveRemoteTrackingCommit(input)), + ); + }, + ), + resolveWorktreeBaseRemote: Effect.fn("GitWorkflowService.resolveWorktreeBaseRemote")( + function (input) { + return ensureGitCommand("GitWorkflowService.resolveWorktreeBaseRemote", input.cwd).pipe( + Effect.andThen(git.resolveWorktreeBaseRemote(input)), + ); + }, + ), + fetchWorktreeBaseCommit: Effect.fn("GitWorkflowService.fetchWorktreeBaseCommit")( + function (input) { + return ensureGitCommand("GitWorkflowService.fetchWorktreeBaseCommit", input.cwd).pipe( + Effect.andThen(git.fetchWorktreeBaseCommit(input)), + ); + }, + ), + removeWorktree: Effect.fn("GitWorkflowService.removeWorktree")(function (input) { + return ensureGitCommand("GitWorkflowService.removeWorktree", input.cwd).pipe( Effect.andThen(git.removeWorktree(input)), - ), - pruneWorktrees: (input) => - ensureGitCommand("GitWorkflowService.pruneWorktrees", input.cwd).pipe( + ); + }), + pruneWorktrees: Effect.fn("GitWorkflowService.pruneWorktrees")(function (input) { + return ensureGitCommand("GitWorkflowService.pruneWorktrees", input.cwd).pipe( Effect.andThen(git.pruneWorktrees(input)), - ), - deleteLocalBranch: (input) => - ensureGitCommand("GitWorkflowService.deleteLocalBranch", input.cwd).pipe( + ); + }), + deleteLocalBranch: Effect.fn("GitWorkflowService.deleteLocalBranch")(function (input) { + return ensureGitCommand("GitWorkflowService.deleteLocalBranch", input.cwd).pipe( Effect.andThen(git.deleteLocalBranch(input)), - ), - createRef: (input) => - ensureGitCommand("GitWorkflowService.createRef", input.cwd).pipe( + ); + }), + createRef: Effect.fn("GitWorkflowService.createRef")(function (input) { + return ensureGitCommand("GitWorkflowService.createRef", input.cwd).pipe( Effect.flatMap((handle) => input.switchRef ? withCheckoutLease(handle.repository.rootPath, git.createRef(input)) : git.createRef(input), ), - ), - switchRef: (input) => - ensureGitCommand("GitWorkflowService.switchRef", input.cwd).pipe( + ); + }), + switchRef: Effect.fn("GitWorkflowService.switchRef")(function (input) { + return ensureGitCommand("GitWorkflowService.switchRef", input.cwd).pipe( Effect.flatMap((handle) => withCheckoutLease(handle.repository.rootPath, Effect.scoped(git.switchRef(input))), ), - ), - renameBranch: (input) => - ensureGit("GitWorkflowService.renameBranch", input.cwd).pipe( + ); + }), + renameBranch: Effect.fn("GitWorkflowService.renameBranch")(function (input) { + return ensureGit("GitWorkflowService.renameBranch", input.cwd).pipe( Effect.andThen(git.renameBranch(input)), - ), + ); + }), }); }); diff --git a/apps/server/src/htmlRender/headlessChrome.ts b/apps/server/src/htmlRender/headlessChrome.ts index e233c4585c..104bb090fe 100644 --- a/apps/server/src/htmlRender/headlessChrome.ts +++ b/apps/server/src/htmlRender/headlessChrome.ts @@ -265,13 +265,14 @@ const launchBrowser = Effect.fnUntraced(function* (input: { textEncoder.encode('"}}\0'), ]).pipe(Effect.asVoid); - const receive = (raw: string) => { + // Incoming messages share the session span; frame acknowledgements are frequent. + const receive = Effect.fnUntraced(function* (raw: string) { const message = Option.getOrUndefined(decodeCdpMessage(raw)); if (message?.id !== undefined) { const waiter = pending.get(message.id); pending.delete(message.id); - if (!waiter) return Effect.void; - return message.error + if (!waiter) return yield* Effect.void; + return yield* message.error ? Deferred.fail( waiter.reply, new HtmlRenderBrowserError({ @@ -283,24 +284,24 @@ const launchBrowser = Effect.fnUntraced(function* (input: { } const sessionId = message?.sessionId; const page = sessionId === undefined ? undefined : pages.get(sessionId); - if (sessionId === undefined || !page || !message?.method) return Effect.void; + if (sessionId === undefined || !page || !message?.method) return yield* Effect.void; if (message.method === "Page.loadEventFired") { - return page.loaded ? Deferred.succeed(page.loaded, undefined) : Effect.void; + return yield* page.loaded ? Deferred.succeed(page.loaded, undefined) : Effect.void; } if (message.method === "Fetch.requestPaused") { const paused = Option.getOrUndefined(decodeRequestPaused(message.params)); - if (!paused) return Effect.void; + if (!paused) return yield* Effect.void; const body = page.body; const url = paused.request.url.split("#", 1)[0]!; if (url === PAGE_URL && body !== undefined) { - return fulfillPage(sessionId, paused.requestId, body); + return yield* fulfillPage(sessionId, paused.requestId, body); } // Subframes may show other sites covered by Local Network Access; the main frame stays on T3's page. const otherSiteFrame = paused.resourceType === "Document" && paused.frameId !== page.mainFrameId && !url.startsWith(`${PAGE_ORIGIN}/`); - return otherSiteFrame + return yield* otherSiteFrame ? post("Fetch.continueRequest", { requestId: paused.requestId }, sessionId) : post( "Fetch.failRequest", @@ -309,10 +310,10 @@ const launchBrowser = Effect.fnUntraced(function* (input: { ); } const consoleMessage = consoleMessageFromEvent(message.method, message.params); - if (!consoleMessage) return Effect.void; + if (!consoleMessage) return yield* Effect.void; if (page.consoleMessages.length >= MAX_CONSOLE_MESSAGES) { page.omittedConsoleMessages += 1; - return Effect.void; + return yield* Effect.void; } const text = consoleMessage.text.replaceAll(PAGE_URL, PAGE_NAME); page.consoleMessages.push({ @@ -320,8 +321,8 @@ const launchBrowser = Effect.fnUntraced(function* (input: { text: text.length > MAX_CONSOLE_TEXT_CHARS ? `${text.slice(0, MAX_CONSOLE_TEXT_CHARS)}…` : text, }); - return Effect.void; - }; + return yield* Effect.void; + }); // Startup aborts close the pipe; let stderr drain to recover the reason. const disconnect = Effect.gen(function* () { diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index 6666892a7c..8752f995be 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -401,7 +401,9 @@ const decodeAsAdvertised = >( const isPreviewAutomationError = Schema.is(PreviewAutomationError); -const previewSnapshotFailure = (cause: Cause.Cause) => { +const previewSnapshotFailure = Effect.fn("previewSnapshotFailure")(function ( + cause: Cause.Cause, +) { if (Cause.hasInterrupts(cause) || cause.reasons.some(Cause.isDieReason)) { return Effect.failCause(cause).pipe(Effect.orDie); } @@ -434,7 +436,7 @@ const previewSnapshotFailure = (cause: Cause.Cause) => { errorTag, failureCount: failures.length, }).pipe(Effect.as(result)); -}; +}); const registerPreviewSnapshot = Effect.fn("McpHttpServer.registerPreviewSnapshot")(function* () { const server = yield* McpServer.McpServer; @@ -904,8 +906,8 @@ const withToolAnalytics = ); const recordingServer = McpServer.McpServer.of({ ...server, - addTool: (options) => - server.addTool({ + addTool: Effect.fn("McpServer.addTool")(function (options) { + return server.addTool({ ...options, handle: /* Request instrumentation already owns the span; keep this helper in that context. */ @@ -927,7 +929,8 @@ const withToolAnalytics = }), ); }), - }), + }); + }), }); return yield* registration.pipe(Effect.provideService(McpServer.McpServer, recordingServer)); }); diff --git a/apps/server/src/mcp/PreviewAutomationBroker.ts b/apps/server/src/mcp/PreviewAutomationBroker.ts index 2a1925e563..dc8a9cef61 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.ts @@ -647,14 +647,14 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { input.updateCurrentTab === false ? timeoutMs : timeoutMs + HOST_RESPONSE_GRACE_MS; const result = yield* Deferred.await(deferred).pipe(Effect.timeoutOption(responseTimeoutMs)); return yield* Option.match(result, { - onNone: () => - Effect.gen(function* () { - // Never replay possibly applied actions; slow metadata reads must not evict the host. - if (input.updateCurrentTab !== false) { - yield* disconnect(connection.clientId, connection.queue, true); - } - return yield* new PreviewAutomationTimeoutError(requestContext); - }), + // The enclosing operation owns the span for this inline handler. + onNone: Effect.fnUntraced(function* () { + // Never replay possibly applied actions; slow metadata reads must not evict the host. + if (input.updateCurrentTab !== false) { + yield* disconnect(connection.clientId, connection.queue, true); + } + return yield* new PreviewAutomationTimeoutError(requestContext); + }), onSome: (value) => Effect.succeed(value as A), }); }); diff --git a/apps/server/src/mcpApps/McpAppModelContext.ts b/apps/server/src/mcpApps/McpAppModelContext.ts index 782d2edd80..ae06ddee13 100644 --- a/apps/server/src/mcpApps/McpAppModelContext.ts +++ b/apps/server/src/mcpApps/McpAppModelContext.ts @@ -91,5 +91,12 @@ export const layer = Layer.effect( export const layerEmpty = Layer.succeed( McpAppModelContext, - McpAppModelContext.of({ set: () => Effect.void, forThread: () => Effect.succeed([]) }), + McpAppModelContext.of({ + set: Effect.fn("McpAppModelContext.set")(function () { + return Effect.void; + }), + forThread: Effect.fn("McpAppModelContext.forThread")(function () { + return Effect.succeed([]); + }), + }), ); diff --git a/apps/server/src/observability/BrowserTraceCollector.ts b/apps/server/src/observability/BrowserTraceCollector.ts index d0e9722652..180c960a66 100644 --- a/apps/server/src/observability/BrowserTraceCollector.ts +++ b/apps/server/src/observability/BrowserTraceCollector.ts @@ -13,12 +13,13 @@ export class BrowserTraceCollector extends Context.Service< /** @public */ export const make = (sink: TraceSink): BrowserTraceCollector["Service"] => BrowserTraceCollector.of({ - record: (records) => - Effect.sync(() => { + record: Effect.fn("BrowserTraceCollector.record")(function (records) { + return Effect.sync(() => { for (const record of records) { sink.push(record); } - }), + }); + }), }); export const layer = (sink: TraceSink) => Layer.succeed(BrowserTraceCollector, make(sink)); diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index 6b06b3980b..f539f6f5e4 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -4170,6 +4170,7 @@ describe("AcpAdapterV2", () => { const responseWritten = yield* Deferred.make(); const releaseResponseAcknowledgement = yield* Deferred.make(); const responseLifecycle: Array = []; + const responseFailed = yield* Deferred.make(); const instanceId = ProviderInstanceId.make("acp-test-normal-close-held-response"); const adapter = yield* makeAcpAdapterV2({ instanceId, @@ -4192,6 +4193,7 @@ describe("AcpAdapterV2", () => { onNativeResponseLifecycle: (event) => Effect.sync(() => { responseLifecycle.push(event.type); + if (event.type === "failed") Deferred.doneUnsafe(responseFailed, Effect.void); }), }, }); @@ -4245,9 +4247,7 @@ describe("AcpAdapterV2", () => { yield* Deferred.await(responseWritten); const closeFiber = yield* Scope.close(sessionScope, Exit.void).pipe(Effect.forkScoped); - while (!responseLifecycle.includes("failed")) { - yield* Effect.yieldNow; - } + yield* Deferred.await(responseFailed); const responseExit = yield* Fiber.join(responseFiber); if (Exit.isSuccess(responseExit)) { assert.fail("normal close must fail a response whose transport acknowledgement is held"); @@ -6235,6 +6235,7 @@ describe("AcpAdapterV2", () => { type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; let sessionUpdateHandler: Parameters[0] | undefined; const promptGates: Array> = []; + const promptStarted = yield* Queue.unbounded(); const capturedMutation: { current: | ((mutation: { @@ -6305,6 +6306,7 @@ describe("AcpAdapterV2", () => { Effect.gen(function* () { const gate = yield* Deferred.make(); promptGates.push(gate); + yield* Queue.offer(promptStarted, undefined); return yield* Deferred.await(gate); }), }), @@ -6355,9 +6357,7 @@ describe("AcpAdapterV2", () => { makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 1 }), ) .pipe(Effect.forkScoped); - while (promptGates.length < 1) { - yield* Effect.yieldNow; - } + yield* Queue.take(promptStarted); assert.isDefined(sessionUpdateHandler, "session update handler must be wired"); const applyMutation = capturedMutation.current; if (applyMutation === null) { @@ -6490,9 +6490,7 @@ describe("AcpAdapterV2", () => { }), ) .pipe(Effect.forkScoped); - while (promptGates.length < 2) { - yield* Effect.yieldNow; - } + yield* Queue.take(promptStarted); if (sessionUpdateHandler === undefined || capturedMutation.current === null) { return yield* Effect.die("extensions and session handler must be wired for turn 2"); } diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 1706007730..c975e0fc0d 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -1029,12 +1029,9 @@ describe("ClaudeAdapterV2 approval cancellation", () => { decision: ProviderApprovalDecision; feedback?: string; }>(); - const controller = new AbortController(); - controller.abort(); - const result = yield* ClaudeAdapterV2.awaitClaudeApprovalDecision( decision, - controller.signal, + AbortSignal.abort(), ); assert.deepEqual(result, { decision: "cancel" }); @@ -1385,14 +1382,14 @@ describe("ClaudeAdapterV2 resume compaction", () => { Stream.runHead, Effect.forkScoped, ); - const controller = new AbortController(); + const signal = yield* Effect.abortSignal; const dialog = yield* Effect.promise(() => callback!( { dialogKind: "resume_return", payload: { sessionAgeMinutes: 90, estimatedTokens: 120000 }, }, - { signal: controller.signal, requestId: "dialog-resume-1" }, + { signal, requestId: "dialog-resume-1" }, ), ).pipe(Effect.forkScoped); const event = yield* Fiber.join(requestEvent); @@ -1408,8 +1405,6 @@ describe("ClaudeAdapterV2 resume compaction", () => { }); assert.deepEqual(yield* Fiber.join(dialog), { behavior: "completed", result: "compact" }); - const cancelledController = new AbortController(); - cancelledController.abort(); assert.deepEqual( yield* Effect.promise(() => callback!( @@ -1417,7 +1412,7 @@ describe("ClaudeAdapterV2 resume compaction", () => { dialogKind: "resume_return", payload: { sessionAgeMinutes: 90, estimatedTokens: 120000 }, }, - { signal: cancelledController.signal, requestId: "dialog-resume-2" }, + { signal: AbortSignal.abort(), requestId: "dialog-resume-2" }, ), ), { behavior: "cancelled" }, @@ -14801,6 +14796,55 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ), ); + + it.effect("keeps the CLI's goal confirmation out of the conversation", () => + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const condition = "all tests pass"; + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: { + ...harness.providerThread, + goal: { objective: condition, status: "active", checks: 1 }, + }, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("goal-clear-attempt"), + text: "/goal clear", + attachments: [], + }), + ); + yield* Queue.offer( + harness.sdkMessages, + syntheticFrame("goal-clear", `Goal cleared: ${condition}`), + ); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ + uuid: "goal-clear-result", + result: `Goal cleared: ${condition}\n`, + numTurns: 0, + }), + ); + yield* Queue.take(harness.terminalReceipts); + assert.isTrue( + harness.events.some( + (event) => + event.type === "provider_thread.updated" && event.providerThread.goal === null, + ), + ); + assert.isFalse( + harness.events.some( + (event) => event.type === "message.updated" && event.message.role === "assistant", + ), + ); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ); }); }); diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 5c0dd15169..4768db11d8 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -536,6 +536,15 @@ export class ClaudeAgentSdkQueryRunnerError extends Schema.TaggedError()( + "ClaudeMcpRegistrationTimeoutError", + {}, +) { + override get message(): string { + return "Claude timed out while registering its MCP tools. Retry the turn; if it keeps failing, check Claude and the server logs."; + } +} + export class ClaudeBackgroundWorkBlocksQueryReplacementError extends Schema.TaggedError()( "ClaudeBackgroundWorkBlocksQueryReplacementError", {}, @@ -605,6 +614,7 @@ export interface ClaudeAgentSdkSubagentLookupInput { } const isClaudeAgentSdkQueryRunnerError = Schema.is(ClaudeAgentSdkQueryRunnerError); +const isClaudeMcpRegistrationTimeoutError = Schema.is(ClaudeMcpRegistrationTimeoutError); const isProviderAdapterRuntimeRequestResponseError = Schema.is( ProviderAdapter.ProviderAdapterRuntimeRequestResponseError, ); @@ -910,249 +920,310 @@ export const layerQueryRunner: Layer.Layer< allocateSessionId: crypto .randomUUIDv4() .pipe(Effect.mapError((cause) => queryRunnerError(cause, "allocateSessionId"))), - open: Effect.fn("ClaudeAgentSdkQueryRunner.open")(function* ( - input: ClaudeAgentSdkQueryOpenInput, - ) { - const protocolLogger = makeClaudeAgentSdkProtocolLogger({ - nativeEventLogger, - threadId: input.threadId, - providerSessionId: input.providerSessionId, - }); - const logProtocolEvent = (event: ClaudeAgentSdkProtocolLogEvent) => - protocolLogger === undefined ? Effect.void : protocolLogger(event); - const promptQueue = yield* Queue.unbounded(); - const prompt = Stream.fromQueue(promptQueue).pipe( - Stream.catchCause((cause) => - Cause.hasInterruptsOnly(cause) ? Stream.empty : Stream.failCause(cause), - ), - Stream.toAsyncIterable, - ); - // Send MCP credentials over stdin to keep them out of SDK argv and inherited command environments. - const { mcpServers, ...queryOptions } = input.options; - // The SDK can prepend a scope wrapper to native binaries; script entries run through node. - const binaryPath = queryOptions.pathToClaudeCodeExecutable; - const launch = - binaryPath === undefined || - CLAUDE_SCRIPT_EXTENSIONS.some((ext) => binaryPath.endsWith(ext)) - ? undefined - : yield* agentScope.wrap({ - command: binaryPath, - args: [], - name: "claude", - threadId: input.threadId, - env: queryOptions.env, + open: Effect.fn("ClaudeAgentSdkQueryRunner.open")( + function* (input: ClaudeAgentSdkQueryOpenInput) { + const protocolLogger = makeClaudeAgentSdkProtocolLogger({ + nativeEventLogger, + threadId: input.threadId, + providerSessionId: input.providerSessionId, + }); + const logProtocolEvent = (event: ClaudeAgentSdkProtocolLogEvent) => + protocolLogger === undefined ? Effect.void : protocolLogger(event); + // A failed open shuts down its queue before retrying, so the old CLI cannot take the prompt. + const promptQueue = yield* Queue.unbounded(); + const prompt = Stream.fromQueue(promptQueue).pipe( + Stream.catchCause((cause) => + Cause.hasInterruptsOnly(cause) ? Stream.empty : Stream.failCause(cause), + ), + Stream.toAsyncIterable, + ); + // Send MCP credentials over stdin to keep them out of SDK argv and inherited command environments. + const { mcpServers, ...queryOptions } = input.options; + // The SDK can prepend a scope wrapper to native binaries; script entries run through node. + const binaryPath = queryOptions.pathToClaudeCodeExecutable; + const launch = + binaryPath === undefined || + CLAUDE_SCRIPT_EXTENSIONS.some((ext) => binaryPath.endsWith(ext)) + ? undefined + : yield* agentScope.wrap({ + command: binaryPath, + args: [], + name: "claude", + threadId: input.threadId, + env: queryOptions.env, + }); + const options = + launch === undefined || launch.args.length === 0 + ? queryOptions + : { + ...queryOptions, + pathToClaudeCodeExecutable: launch.command, + executableArgs: [...launch.args, ...(queryOptions.executableArgs ?? [])], + }; + const unit = launch?.unit; + const stopScope = + unit === undefined ? undefined : () => void runFork(agentScope.stop(unit)); + const spawnProcess = + options.spawnClaudeCodeProcess ?? + ((spawnOptions: ClaudeSpawnOptions) => { + if (platform !== "win32") return spawnClaudeProcessGroup(spawnOptions, stopScope); + const child = NodeChildProcess.spawn(spawnOptions.command, spawnOptions.args, { + cwd: spawnOptions.cwd, + env: spawnOptions.env, + signal: spawnOptions.signal, + windowsHide: true, + stdio: ["pipe", "pipe", "pipe"], }); - const options = - launch === undefined || launch.args.length === 0 - ? queryOptions - : { - ...queryOptions, - pathToClaudeCodeExecutable: launch.command, - executableArgs: [...launch.args, ...(queryOptions.executableArgs ?? [])], - }; - const unit = launch?.unit; - const stopScope = - unit === undefined ? undefined : () => void runFork(agentScope.stop(unit)); - // Windows has no process groups, so there the SDK spawns the CLI itself. - const spawnClaudeCodeProcess = - options.spawnClaudeCodeProcess ?? - (platform === "win32" - ? undefined - : (spawnOptions: ClaudeSpawnOptions) => - spawnClaudeProcessGroup(spawnOptions, stopScope)); - const queryRuntime = yield* Effect.uninterruptibleMask((restore) => - Effect.try({ - try: () => - query({ - prompt, - options: - spawnClaudeCodeProcess === undefined - ? options - : { ...options, spawnClaudeCodeProcess }, - }), - catch: (cause) => queryRunnerError(cause, "query"), - }).pipe( - Effect.tap((queryRuntime) => - restore( - Effect.gen(function* () { - if (mcpServers !== undefined) { - // Errors and mcp_status can echo headers; log only server names. - const failed = yield* Effect.tryPromise(() => - queryRuntime.setMcpServers(mcpServers), - ).pipe( - Effect.map((result) => Object.keys(result.errors)), - Effect.orElseSucceed(() => Object.keys(mcpServers)), - Effect.timeoutOrElse({ - duration: CLAUDE_MCP_REGISTRATION_TIMEOUT, - orElse: () => - Effect.fail( - queryRunnerError( - "Claude Code did not answer the MCP server registration.", - "setMcpServers", + child.stderr + .setEncoding("utf8") + .on("data", (chunk: string) => options.stderr?.(chunk)); + return child; + }); + let spawnedProcess: ClaudeSpawnedProcess | undefined; + let processExited = false; + let processExit: Promise | undefined; + const spawnClaudeCodeProcess = (spawnOptions: ClaudeSpawnOptions) => { + const child = spawnProcess(spawnOptions); + spawnedProcess = child; + processExited = child.exitCode !== null || child.signalCode != null; + processExit = new Promise((resolve) => { + if (processExited) resolve(); + else + child.once("exit", () => { + processExited = true; + resolve(); + }); + }); + return child; + }; + const queryRuntime = yield* Effect.uninterruptibleMask((restore) => + Effect.try({ + try: () => + query({ + prompt, + options: { ...options, spawnClaudeCodeProcess }, + }), + catch: (cause) => queryRunnerError(cause, "query"), + }).pipe( + Effect.tap((queryRuntime) => + restore( + Effect.gen(function* () { + if (mcpServers !== undefined) { + // Errors and mcp_status can echo headers; log only server names. + const failed = yield* Effect.tryPromise(() => + queryRuntime.setMcpServers(mcpServers), + ).pipe( + Effect.map((result) => Object.keys(result.errors)), + Effect.orElseSucceed(() => Object.keys(mcpServers)), + Effect.timeoutOrElse({ + duration: CLAUDE_MCP_REGISTRATION_TIMEOUT, + orElse: () => + Effect.logWarning("orchestration-v2.claude-mcp-registration-timeout", { + threadId: input.threadId, + providerSessionId: input.providerSessionId, + }).pipe( + Effect.andThen( + Effect.fail( + queryRunnerError( + new ClaudeMcpRegistrationTimeoutError(), + "setMcpServers", + ), + ), + ), ), - ), - }), - ); - if (failed.length > 0) { - yield* Effect.logWarning("orchestration-v2.claude-mcp-servers-unavailable", { - threadId: input.threadId, - servers: failed, - }); + }), + ); + if (failed.length > 0) { + yield* Effect.logWarning( + "orchestration-v2.claude-mcp-servers-unavailable", + { + threadId: input.threadId, + servers: failed, + }, + ); + } } - } - yield* logProtocolEvent({ - direction: "outgoing", - stage: "decoded", - payload: { - type: "query.open", - options: loggedClaudeQueryOptions(input.options), - }, - }); - }), - ).pipe( - Effect.onError(() => - Queue.shutdown(promptQueue).pipe( - Effect.andThen(closeClaudeQuery(queryRuntime)), - Effect.ignore, + yield* logProtocolEvent({ + direction: "outgoing", + stage: "decoded", + payload: { + type: "query.open", + options: loggedClaudeQueryOptions(input.options), + }, + }); + }), + ).pipe( + Effect.onError(() => + Queue.shutdown(promptQueue).pipe( + // SDK disposal can return before process exit; await the owned child's exit before retrying. + Effect.andThen( + Effect.tryPromise({ + try: async () => { + if (spawnedProcess !== undefined && !processExited) { + spawnedProcess.kill("SIGKILL"); + } + await processExit; + }, + catch: (cause) => queryRunnerError(cause, "close"), + }).pipe(Effect.timeout("10 seconds")), + ), + Effect.ensuring( + Effect.tryPromise({ + try: () => queryRuntime[Symbol.asyncDispose](), + catch: (cause) => queryRunnerError(cause, "close"), + }).pipe(Effect.orDie), + ), + Effect.orDie, + ), ), ), ), ), - ), - ); + ); - return { - messages: Stream.fromAsyncIterable(claudeQueryMessages(queryRuntime), (cause) => - queryRunnerError(cause, "fromAsyncIterable"), - ).pipe( - Stream.tap((message) => - logProtocolEvent({ - direction: "incoming", - stage: "decoded", - payload: message, - }), - ), - ), - offer: (message, onQueued) => - Effect.uninterruptibleMask((restore) => - restore(Queue.offer(promptQueue, message)).pipe( - Effect.tap(() => Effect.sync(() => onQueued?.())), - ), + return { + messages: Stream.fromAsyncIterable(claudeQueryMessages(queryRuntime), (cause) => + queryRunnerError(cause, "fromAsyncIterable"), ).pipe( - Effect.asVoid, - Effect.tap(() => + Stream.tap((message) => logProtocolEvent({ - direction: "outgoing", + direction: "incoming", stage: "decoded", - payload: { - type: "prompt.offer", - message, - }, + payload: message, }), ), ), - setModel: (model) => - Effect.tryPromise({ - try: () => queryRuntime.setModel(model), - catch: (cause) => queryRunnerError(cause, "setModel"), - }).pipe( - Effect.tap(() => - logProtocolEvent({ - direction: "outgoing", - stage: "decoded", - payload: { - type: "query.set_model", - model, - }, - }), + offer: (message, onQueued) => + Effect.uninterruptibleMask((restore) => + restore(Queue.offer(promptQueue, message)).pipe( + Effect.tap(() => Effect.sync(() => onQueued?.())), + ), + ).pipe( + Effect.asVoid, + Effect.tap(() => + logProtocolEvent({ + direction: "outgoing", + stage: "decoded", + payload: { + type: "prompt.offer", + message, + }, + }), + ), ), - ), - applyFlagSettings: (settings) => - Effect.tryPromise({ - try: () => queryRuntime.applyFlagSettings(settings), - catch: (cause) => queryRunnerError(cause, "applyFlagSettings"), + setModel: (model) => + Effect.tryPromise({ + try: () => queryRuntime.setModel(model), + catch: (cause) => queryRunnerError(cause, "setModel"), + }).pipe( + Effect.tap(() => + logProtocolEvent({ + direction: "outgoing", + stage: "decoded", + payload: { + type: "query.set_model", + model, + }, + }), + ), + ), + applyFlagSettings: (settings) => + Effect.tryPromise({ + try: () => queryRuntime.applyFlagSettings(settings), + catch: (cause) => queryRunnerError(cause, "applyFlagSettings"), + }).pipe( + Effect.tap(() => + logProtocolEvent({ + direction: "outgoing", + stage: "decoded", + payload: { + type: "query.apply_flag_settings", + settings, + }, + }), + ), + ), + setPermissionMode: (mode) => + Effect.tryPromise({ + try: () => queryRuntime.setPermissionMode(mode), + catch: (cause) => queryRunnerError(cause, "setPermissionMode"), + }).pipe( + Effect.tap(() => + logProtocolEvent({ + direction: "outgoing", + stage: "decoded", + payload: { + type: "query.set_permission_mode", + mode, + }, + }), + ), + ), + stopTask: (taskId) => + Effect.tryPromise({ + try: () => queryRuntime.stopTask(taskId), + catch: (cause) => queryRunnerError(cause, "stopTask"), + }).pipe( + Effect.tap(() => + logProtocolEvent({ + direction: "outgoing", + stage: "decoded", + payload: { type: "query.stop_task", taskId }, + }), + ), + ), + backgroundTasks: (toolUseId) => + Effect.tryPromise({ + try: () => queryRuntime.backgroundTasks(toolUseId), + catch: (cause) => queryRunnerError(cause, "backgroundTasks"), + }).pipe( + Effect.tap(() => + logProtocolEvent({ + direction: "outgoing", + stage: "decoded", + payload: { type: "query.background_tasks", toolUseId }, + }), + ), + ), + interrupt: Effect.tryPromise({ + try: () => queryRuntime.interrupt(), + catch: (cause) => queryRunnerError(cause, "interrupt"), }).pipe( Effect.tap(() => logProtocolEvent({ direction: "outgoing", stage: "decoded", payload: { - type: "query.apply_flag_settings", - settings, + type: "query.interrupt", }, }), ), ), - setPermissionMode: (mode) => - Effect.tryPromise({ - try: () => queryRuntime.setPermissionMode(mode), - catch: (cause) => queryRunnerError(cause, "setPermissionMode"), - }).pipe( + close: Queue.shutdown(promptQueue).pipe( + Effect.andThen(closeClaudeQuery(queryRuntime)), Effect.tap(() => logProtocolEvent({ direction: "outgoing", stage: "decoded", payload: { - type: "query.set_permission_mode", - mode, + type: "query.close", }, }), ), ), - stopTask: (taskId) => - Effect.tryPromise({ - try: () => queryRuntime.stopTask(taskId), - catch: (cause) => queryRunnerError(cause, "stopTask"), - }).pipe( - Effect.tap(() => - logProtocolEvent({ - direction: "outgoing", - stage: "decoded", - payload: { type: "query.stop_task", taskId }, - }), - ), - ), - backgroundTasks: (toolUseId) => - Effect.tryPromise({ - try: () => queryRuntime.backgroundTasks(toolUseId), - catch: (cause) => queryRunnerError(cause, "backgroundTasks"), - }).pipe( - Effect.tap(() => - logProtocolEvent({ - direction: "outgoing", - stage: "decoded", - payload: { type: "query.background_tasks", toolUseId }, - }), - ), - ), - interrupt: Effect.tryPromise({ - try: () => queryRuntime.interrupt(), - catch: (cause) => queryRunnerError(cause, "interrupt"), - }).pipe( - Effect.tap(() => - logProtocolEvent({ - direction: "outgoing", - stage: "decoded", - payload: { - type: "query.interrupt", - }, - }), - ), - ), - close: Queue.shutdown(promptQueue).pipe( - Effect.andThen(closeClaudeQuery(queryRuntime)), - Effect.tap(() => - logProtocolEvent({ - direction: "outgoing", - stage: "decoded", - payload: { - type: "query.close", - }, - }), - ), + } satisfies ClaudeAgentSdkQuerySession; + }, + // Fresh IDs may already have a metadata-only transcript; only resumed opens can reuse their ID. + (effect, input) => + effect.pipe( + Effect.retry({ + times: 1, + while: (error) => + input.options.resume !== undefined && + isClaudeMcpRegistrationTimeoutError(error.cause), + }), ), - } satisfies ClaudeAgentSdkQuerySession; - }), + ), forkSession: Effect.fn("ClaudeAgentSdkQueryRunner.forkSession")(function* ( input: ClaudeAgentSdkSessionForkInput, ) { @@ -1662,6 +1733,19 @@ function nextClaudeGoal( }; } +/** The CLI confirms a goal change as a synthetic reply; the goal row shows that change instead. */ +function isClaudeGoalAcknowledgement(message: SDKMessage): boolean { + if ( + message.type !== "assistant" || + message.parent_tool_use_id !== null || + message.message.model !== "" + ) { + return false; + } + const text = textFromClaudeContent(message.message.content).trim(); + return text.startsWith(CLAUDE_GOAL_SET_PREFIX) || text.startsWith("Goal cleared: "); +} + const providerGoalsEqual = Schema.toEquivalence(Schema.NullOr(OrchestrationV2ProviderGoal)); function normalizeClaudeResultText(text: string): string { @@ -4353,8 +4437,12 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( return ProviderAdapter.ProviderAdapterV2.of({ instanceId: adapterOptions.instanceId, driver: CLAUDE_PROVIDER, - getCapabilities: () => Effect.succeed(ClaudeProviderCapabilitiesV2), - planSelectionTransition: () => Effect.succeed(turnScopedSelectionTransition()), + getCapabilities: Effect.fn("ClaudeAdapterV2.getCapabilities")(function () { + return Effect.succeed(ClaudeProviderCapabilitiesV2); + }), + planSelectionTransition: Effect.fn("ClaudeAdapterV2.planSelectionTransition")(function () { + return Effect.succeed(turnScopedSelectionTransition()); + }), openSession: Effect.fn("ClaudeAdapterV2.openSession")( function* (input: ProviderAdapter.ProviderAdapterV2OpenSessionInput) { const sessionScope = yield* Effect.scope; @@ -8874,6 +8962,13 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }); return; } + if (isClaudeGoalAcknowledgement(message)) { + // The CLI repeats a local command's output as the turn's result text. + if (assistantText !== null) { + context.assistant.noticeTexts.add(normalizeClaudeResultText(assistantText.text)); + } + return; + } // Claude Code echoes /compact's stdout ("Not enough messages to compact.") as a // synthetic assistant message. The compaction item reports that outcome; it is not a // reply. @@ -8954,7 +9049,8 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( context.assistant.fallbackText.length === 0 && resultText !== null && resultText.text.length > 0 && - !context.assistant.noticeTexts.has(resultText.text) + !context.assistant.noticeTexts.has(resultText.text) && + !context.assistant.noticeTexts.has(normalizeClaudeResultText(resultText.text)) ) { context.assistant.fallbackText = resultText.text; context.assistant.fallbackNativeItemId = resultText.nativeItemId; diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAgentSdkQueryRunner.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAgentSdkQueryRunner.test.ts index 01a1993578..105f98abf5 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAgentSdkQueryRunner.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAgentSdkQueryRunner.test.ts @@ -1,25 +1,32 @@ // @effect-diagnostics nodeBuiltinImport:off -- the fake CLI and its leftover child are real processes. import * as ClaudeSdk from "@anthropic-ai/claude-agent-sdk"; import * as NodeChildProcess from "node:child_process"; +import * as NodeEvents from "node:events"; import * as NodeFS from "node:fs"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; +import * as NodeStream from "node:stream"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import { ProviderSessionId, ThreadId } from "@infinitus/contracts"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; 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 Path from "effect/Path"; +import * as TestClock from "effect/testing/TestClock"; import { vi } from "vite-plus/test"; import * as HostProcess from "@infinitus/shared/HostProcess"; import { AgentScope } from "@infinitus/shared/AgentScope"; import { ProviderEventLoggers } from "@infinitus/provider-core/server/ProviderEventLoggers"; +import { makeProviderFailure } from "@infinitus/provider-core/server/failure"; import { signalProcessGroup } from "@infinitus/provider-core/server/processGroup"; import { ClaudeAgentSdkQueryRunner, + type ClaudeAgentSdkQueryOpenInput, layerQueryRunner, spawnClaudeProcessGroup, } from "./ClaudeAdapterV2.ts"; @@ -112,6 +119,268 @@ const realQueryRunner = layerQueryRunner.pipe( Layer.provide(NodeServices.layer), ); +const resumedMcpQueryInput = { + threadId: ThreadId.make("thread-restart-registration"), + providerSessionId: ProviderSessionId.make("session-restart-registration"), + options: { + resume: FORK_SESSION_ID, + model: "claude-sonnet-4-6", + tools: [], + permissionMode: "default", + mcpServers: { + "t3-code": { + type: "http", + url: "http://localhost:3773/mcp", + headers: { Authorization: "Bearer test-mcp-credential" }, + }, + }, + }, +} satisfies ClaudeAgentSdkQueryOpenInput; + +it.effect("reopens a stalled MCP registration before delivering the resumed prompt", () => + Effect.scoped( + Effect.gen(function* () { + const registrationStarted = Promise.withResolvers(); + const disposalStarted = Promise.withResolvers(); + const finishDisposal = Promise.withResolvers(); + const prompts: AsyncIterable[] = []; + const firstClose = vi.fn(); + const secondClose = vi.fn(); + const firstRegistration = vi.fn(() => { + registrationStarted.resolve(); + return new Promise(() => {}); + }); + const secondRegistration = vi.fn(async () => ({ + added: ["t3-code"], + removed: [], + errors: {}, + })); + const querySpy = vi.spyOn(ClaudeSdk, "query").mockImplementation(({ prompt }) => { + assert.notTypeOf(prompt, "string"); + if (typeof prompt === "string") throw new Error("Expected streaming input"); + prompts.push(prompt); + return { + setMcpServers: prompts.length === 1 ? firstRegistration : secondRegistration, + close: prompts.length === 1 ? firstClose : secondClose, + [Symbol.asyncDispose]: async () => { + firstClose(); + disposalStarted.resolve(); + await finishDisposal.promise; + }, + } as unknown as ClaudeSdk.Query; + }); + yield* Effect.addFinalizer(() => Effect.sync(() => querySpy.mockRestore())); + const runner = yield* ClaudeAgentSdkQueryRunner; + const opening = yield* runner.open(resumedMcpQueryInput).pipe(Effect.forkChild); + yield* Effect.promise(() => registrationStarted.promise); + yield* TestClock.adjust("90 seconds"); + yield* Effect.promise(() => disposalStarted.promise); + assert.equal(querySpy.mock.calls.length, 1); + finishDisposal.resolve(); + const session = yield* Fiber.join(opening); + yield* Effect.addFinalizer(() => session.close.pipe(Effect.orDie)); + + assert.equal(firstClose.mock.calls.length, 1); + assert.equal(secondClose.mock.calls.length, 0); + assert.equal(querySpy.mock.calls.length, 2); + for (const [call] of querySpy.mock.calls) { + assert.equal(call.options?.resume, FORK_SESSION_ID); + assert.isUndefined(call.options?.mcpServers); + } + assert.deepEqual(secondRegistration.mock.calls, [[resumedMcpQueryInput.options.mcpServers]]); + const message = { + type: "user", + message: { role: "user", content: "Continue where you left off." }, + parent_tool_use_id: null, + session_id: FORK_SESSION_ID, + } satisfies ClaudeSdk.SDKUserMessage; + yield* session.offer(message); + assert.isTrue((yield* Effect.promise(() => prompts[0]![Symbol.asyncIterator]().next())).done); + const delivered = yield* Effect.promise(() => prompts[1]![Symbol.asyncIterator]().next()); + assert.deepEqual(delivered.value, message); + assert.isFalse(delivered.done); + }).pipe(Effect.provide(realQueryRunner)), + ), +); + +it.effect.each([true, false])("only retries after confirmed process exit: %s", (exits) => + Effect.scoped( + Effect.gen(function* () { + const registrationStarted = Promise.withResolvers(); + const cleanupProgress = Promise.withResolvers<"kill" | "retry">(); + const events = new NodeEvents.EventEmitter(); + const stdin = new NodeStream.PassThrough(); + const stdout = new NodeStream.PassThrough(); + const child = { + stdin, + stdout, + killed: false, + exitCode: null, + kill: vi.fn(() => { + cleanupProgress.resolve("kill"); + return true; + }), + on: events.on.bind(events), + once: events.once.bind(events), + off: events.off.bind(events), + } satisfies ClaudeSdk.SpawnedProcess; + const signal = yield* Effect.abortSignal; + const disposed = vi.fn(async () => {}); + const querySpy = vi.spyOn(ClaudeSdk, "query").mockImplementation(({ options }) => { + const first = querySpy.mock.calls.length === 1; + if (first) { + options?.spawnClaudeCodeProcess?.({ command: "claude", args: [], env: {}, signal }); + } else { + cleanupProgress.resolve("retry"); + } + return { + setMcpServers: () => { + registrationStarted.resolve(); + return first + ? new Promise(() => {}) + : Promise.resolve({ added: ["t3-code"], removed: [], errors: {} }); + }, + close: vi.fn(), + [Symbol.asyncDispose]: disposed, + } as unknown as ClaudeSdk.Query; + }); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + querySpy.mockRestore(); + stdin.destroy(); + stdout.destroy(); + }), + ); + const runner = yield* ClaudeAgentSdkQueryRunner; + const opening = yield* runner + .open({ + ...resumedMcpQueryInput, + options: { ...resumedMcpQueryInput.options, spawnClaudeCodeProcess: () => child }, + }) + .pipe(Effect.exit, Effect.forkChild); + yield* Effect.promise(() => registrationStarted.promise); + yield* TestClock.adjust("90 seconds"); + + assert.equal(yield* Effect.promise(() => cleanupProgress.promise), "kill"); + assert.equal(querySpy.mock.calls.length, 1); + assert.deepEqual(child.kill.mock.calls, [["SIGKILL"]]); + if (exits) events.emit("exit", null, "SIGKILL"); + else yield* TestClock.adjust("10 seconds"); + const result = yield* Fiber.join(opening); + assert.equal(Exit.isSuccess(result), exits); + if (Exit.isSuccess(result)) yield* result.value.close; + assert.equal(querySpy.mock.calls.length, exits ? 2 : 1); + assert.equal(disposed.mock.calls.length, 1); + }).pipe(Effect.provide(realQueryRunner)), + ), +); + +it.effect("stops retrying MCP registration after two timeouts and reports the cause", () => + Effect.scoped( + Effect.gen(function* () { + const firstRegistration = Promise.withResolvers(); + const secondRegistration = Promise.withResolvers(); + const disposed = vi.fn(async () => {}); + const querySpy = vi.spyOn(ClaudeSdk, "query").mockImplementation(() => { + const started = querySpy.mock.calls.length === 1 ? firstRegistration : secondRegistration; + return { + setMcpServers: () => { + started.resolve(); + return new Promise(() => {}); + }, + [Symbol.asyncDispose]: disposed, + } as unknown as ClaudeSdk.Query; + }); + yield* Effect.addFinalizer(() => Effect.sync(() => querySpy.mockRestore())); + const runner = yield* ClaudeAgentSdkQueryRunner; + const opening = yield* runner + .open(resumedMcpQueryInput) + .pipe(Effect.result, Effect.forkChild); + yield* Effect.promise(() => firstRegistration.promise); + yield* TestClock.adjust("90 seconds"); + yield* Effect.promise(() => secondRegistration.promise); + yield* TestClock.adjust("90 seconds"); + const result = yield* Fiber.join(opening); + + assert.equal(result._tag, "Failure"); + if (result._tag !== "Failure") return; + assert.equal(querySpy.mock.calls.length, 2); + assert.equal(disposed.mock.calls.length, 2); + assert.equal( + makeProviderFailure({ + cause: { _tag: "ProviderAdapterTurnStartError", cause: result.failure }, + }).message, + "Claude timed out while registering its MCP tools. Retry the turn; if it keeps failing, check Claude and the server logs.", + ); + }).pipe(Effect.provide(realQueryRunner)), + ), +); + +it.effect("cancels a stalled registration without retrying or retaining its prompt queue", () => + Effect.scoped( + Effect.gen(function* () { + const registrationStarted = Promise.withResolvers(); + const disposed = vi.fn(async () => {}); + const querySpy = vi.spyOn(ClaudeSdk, "query").mockImplementation( + () => + ({ + setMcpServers: () => { + registrationStarted.resolve(); + return new Promise(() => {}); + }, + [Symbol.asyncDispose]: disposed, + }) as unknown as ClaudeSdk.Query, + ); + yield* Effect.addFinalizer(() => Effect.sync(() => querySpy.mockRestore())); + const runner = yield* ClaudeAgentSdkQueryRunner; + const opening = yield* runner.open(resumedMcpQueryInput).pipe(Effect.forkChild); + yield* Effect.promise(() => registrationStarted.promise); + yield* Fiber.interrupt(opening); + + assert.equal(querySpy.mock.calls.length, 1); + assert.equal(disposed.mock.calls.length, 1); + const prompt = querySpy.mock.calls[0]![0].prompt; + if (typeof prompt === "string") throw new Error("Expected streaming input"); + assert.isTrue((yield* Effect.promise(() => prompt[Symbol.asyncIterator]().next())).done); + }).pipe(Effect.provide(realQueryRunner)), + ), +); + +it.effect("does not reuse a fresh session ID after MCP registration times out", () => + Effect.scoped( + Effect.gen(function* () { + const registrationStarted = Promise.withResolvers(); + const disposed = vi.fn(async () => {}); + const querySpy = vi.spyOn(ClaudeSdk, "query").mockImplementation( + () => + ({ + setMcpServers: () => { + registrationStarted.resolve(); + return new Promise(() => {}); + }, + [Symbol.asyncDispose]: disposed, + }) as unknown as ClaudeSdk.Query, + ); + yield* Effect.addFinalizer(() => Effect.sync(() => querySpy.mockRestore())); + const runner = yield* ClaudeAgentSdkQueryRunner; + const { resume, ...options } = resumedMcpQueryInput.options; + const opening = yield* runner + .open({ + ...resumedMcpQueryInput, + options: { ...options, sessionId: resume }, + }) + .pipe(Effect.result, Effect.forkChild); + yield* Effect.promise(() => registrationStarted.promise); + yield* TestClock.adjust("90 seconds"); + const result = yield* Fiber.join(opening); + + assert.equal(result._tag, "Failure"); + assert.equal(querySpy.mock.calls.length, 1); + assert.equal(disposed.mock.calls.length, 1); + }).pipe(Effect.provide(realQueryRunner)), + ), +); + function fakeClaudeCli(script: string): string { const dir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-claude-cli-")); const cli = NodePath.join(dir, "claude"); @@ -196,6 +465,7 @@ describe.skipIf(HostProcess.Platform.defaultValue() === "win32")("Claude CLI pro args: [], cwd: dir, env: process.env, + // @effect-diagnostics-next-line abortControllerInEffect:off - This crash fixture closes pipes itself; aborting on test teardown raises an unrelated process error. signal: new AbortController().signal, }) as NodeChildProcess.ChildProcess; yield* Effect.addFinalizer(() => diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index fb6f8cd326..8db38dc122 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -5959,6 +5959,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { nativeThreadId, nativeTurnId: "goal-turn-1", prompt: objective, + sentPrompt: `Start working toward this goal: ${objective}`, }).slice(5), 5, ), @@ -6023,6 +6024,8 @@ describe("CodexAdapterV2 post-settle continuation", () => { [providerTurnId("goal-turn-2"), RunAttemptId.make("goal-attempt"), 2], ], ); + // The goal is set paused until its first turn starts; only the result shows. + assert.notInclude(providerGoals(harness.events), "paused"); assert.equal(providerGoals(harness.events).at(-1), "complete"); }).pipe( Effect.scoped, @@ -6205,6 +6208,233 @@ describe("CodexAdapterV2 post-settle continuation", () => { ), ); + it.effect("pauses a goal Codex does not continue before its run settles", () => + Effect.gen(function* () { + const prompt = "Keep going"; + const transcript = makeCodexReplayTranscript({ + scenario: "goal-continuation-expired", + entries: [ + ...codexReplayPreamble({ nativeThreadId, nativeTurnId: "goal-turn", prompt }), + notification("goal active", "thread/goal/updated", { + threadId: nativeThreadId, + turnId: "goal-turn", + goal: codexGoal("active", 10), + }), + notification("turn done", "turn/completed", { + threadId: nativeThreadId, + turn: makeCodexReplayTurn({ id: "goal-turn", status: "completed" }), + }), + notification("goal accounted", "thread/goal/updated", { + threadId: nativeThreadId, + turnId: null, + goal: codexGoal("active", 11), + }), + ...request( + 4, + "thread/goal/set", + { threadId: nativeThreadId, status: "paused" }, + { goal: codexGoal("paused", 11) }, + ), + ], + }); + const harness = yield* makeCodexReplayHarness(transcript); + yield* harness.runtime.startTurn( + makeCodexTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("goal-expired-attempt"), + text: prompt, + }), + ); + yield* harness.until(() => + harness.events.some( + (event) => + event.type === "provider_thread.updated" && + event.providerThread.goal?.tokensUsed === 11, + ), + ); + assert.lengthOf(harness.terminalEvents(), 0); + yield* TestClock.adjust("5 seconds"); + yield* harness.firstTerminal; + + assert.deepEqual(providerGoals(harness.events), ["active", "active", "paused"]); + assert.deepEqual( + harness.terminalEvents().map((event) => event.status), + ["completed"], + ); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ); + + it.effect("stops a turn Codex starts while the expiry pause is in flight", () => + Effect.gen(function* () { + const prompt = "Keep going"; + const transcript = makeCodexReplayTranscript({ + scenario: "goal-continuation-during-expiry", + entries: [ + ...codexReplayPreamble({ nativeThreadId, nativeTurnId: "goal-turn", prompt }), + notification("goal active", "thread/goal/updated", { + threadId: nativeThreadId, + turnId: "goal-turn", + goal: codexGoal("active", 10), + }), + notification("turn done", "turn/completed", { + threadId: nativeThreadId, + turn: makeCodexReplayTurn({ id: "goal-turn", status: "completed" }), + }), + notification("goal accounted", "thread/goal/updated", { + threadId: nativeThreadId, + turnId: null, + goal: codexGoal("active", 11), + }), + { + type: "expect_outbound", + label: "pause", + frame: { + id: 4, + method: "thread/goal/set", + params: { threadId: nativeThreadId, status: "paused" }, + }, + }, + notification("goal paused", "thread/goal/updated", { + threadId: nativeThreadId, + turnId: null, + goal: codexGoal("paused", 11), + }), + notification("late continuation", "turn/started", { + threadId: nativeThreadId, + turn: makeCodexReplayTurn({ id: "goal-turn-2", status: "inProgress" }), + }), + ...request( + 5, + "thread/goal/set", + { threadId: nativeThreadId, status: "paused" }, + { goal: codexGoal("paused", 11) }, + ), + ...request( + 6, + "turn/interrupt", + { threadId: nativeThreadId, turnId: "goal-turn-2" }, + {}, + ), + { + type: "emit_inbound", + label: "paused", + frame: { id: 4, result: { goal: codexGoal("paused", 11) } }, + }, + ], + }); + const harness = yield* makeCodexReplayHarness(transcript); + yield* harness.runtime.startTurn( + makeCodexTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("goal-race-attempt"), + text: prompt, + }), + ); + yield* harness.until(() => + harness.events.some( + (event) => + event.type === "provider_thread.updated" && + event.providerThread.goal?.tokensUsed === 11, + ), + ); + yield* TestClock.adjust("5 seconds"); + yield* harness.firstTerminal; + + assert.deepEqual( + harness.terminalEvents().map((event) => [event.providerTurnId, event.status]), + [ + [ + (yield* IdAllocator.IdAllocatorV2).derive.providerTurn({ + driver: CodexAdapterV2.CODEX_DRIVER_KIND, + nativeTurnId: "goal-turn", + }), + "completed", + ], + ], + ); + assert.equal(providerGoals(harness.events).at(-1), "paused"); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ); + + it.effect("settles the run when Codex never answers the expiry pause", () => + Effect.gen(function* () { + const prompt = "Keep going"; + const transcript = makeCodexReplayTranscript({ + scenario: "goal-expiry-pause-unanswered", + entries: [ + ...codexReplayPreamble({ nativeThreadId, nativeTurnId: "goal-turn", prompt }), + notification("goal active", "thread/goal/updated", { + threadId: nativeThreadId, + turnId: "goal-turn", + goal: codexGoal("active", 10), + }), + notification("turn done", "turn/completed", { + threadId: nativeThreadId, + turn: makeCodexReplayTurn({ id: "goal-turn", status: "completed" }), + }), + notification("goal accounted", "thread/goal/updated", { + threadId: nativeThreadId, + turnId: null, + goal: codexGoal("active", 11), + }), + { + type: "expect_outbound", + label: "pause", + frame: { + id: 4, + method: "thread/goal/set", + params: { threadId: nativeThreadId, status: "paused" }, + }, + }, + ], + }); + const harness = yield* makeCodexReplayHarness(transcript); + yield* harness.runtime.startTurn( + makeCodexTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("goal-unanswered-attempt"), + text: prompt, + }), + ); + yield* harness.until(() => + harness.events.some( + (event) => + event.type === "provider_thread.updated" && + event.providerThread.goal?.tokensUsed === 11, + ), + ); + yield* TestClock.adjust("5 seconds"); + yield* TestClock.adjust("10 seconds"); + yield* harness.firstTerminal; + + assert.deepEqual( + harness.terminalEvents().map((event) => event.status), + ["completed"], + ); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ); + it.effect("pauses an active goal before Stop interrupts its turn", () => Effect.gen(function* () { const prompt = "Keep going"; diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 08887c1897..0e700dcd6e 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -31,7 +31,9 @@ import { CodexSettings, defaultInstanceIdForDriver, isOrchestrationV2WorkActive, + type OrchestrationV2GoalCommand, OrchestrationV2ProviderGoal, + parseGoalCommand, ProviderDriverKind, type ProviderSetupError, } from "@infinitus/contracts"; @@ -882,32 +884,6 @@ const isTerminalProviderTurn = (turn: OrchestrationV2ProviderTurn): boolean => turn.status === "failed" || turn.status === "cancelled"); -export type CodexGoalCommand = - | { readonly type: "show" } - | { readonly type: "clear" } - | { readonly type: "pause" } - | { readonly type: "resume" } - | { readonly type: "set"; readonly objective: string }; - -function parseCodexGoalCommand(text: string): CodexGoalCommand | null { - const match = /^\/goal(?:\s+([\s\S]*))?$/u.exec(text.trim()); - if (match === null) return null; - const argument = (match[1] ?? "").trim(); - switch (argument.toLowerCase()) { - case "": - case "edit": - return { type: "show" }; - case "clear": - return { type: "clear" }; - case "pause": - return { type: "pause" }; - case "resume": - return { type: "resume" }; - default: - return { type: "set", objective: argument }; - } -} - type CodexThreadGoal = CodexSchema.V2ThreadGoalUpdatedNotification["goal"]; const CODEX_GOAL_STATUSES = { @@ -1634,8 +1610,12 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( return ProviderAdapter.ProviderAdapterV2.of({ instanceId: adapterOptions.instanceId, driver: CODEX_PROVIDER, - getCapabilities: () => Effect.succeed(CodexProviderCapabilitiesV2), - planSelectionTransition: () => Effect.succeed(turnScopedSelectionTransition()), + getCapabilities: Effect.fn("CodexAdapterV2.getCapabilities")(function () { + return Effect.succeed(CodexProviderCapabilitiesV2); + }), + planSelectionTransition: Effect.fn("CodexAdapterV2.planSelectionTransition")(function () { + return Effect.succeed(turnScopedSelectionTransition()); + }), openSession: Effect.fn("CodexAdapterV2.openSession")( function* (input) { const scope = yield* Scope.Scope; @@ -1845,9 +1825,17 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( const emitProviderEvent = (event: ProviderAdapter.ProviderAdapterV2Event) => Queue.offer(events, event).pipe(Effect.asVoid); + // Setting a goal passes through cleared and paused states; publish only the result. + const goalUpdatesHeld = new Set(); const emitGoalUpdate = Effect.fnUntraced(function* (nativeThreadId: string) { const providerThread = rootProviderThreads.get(nativeThreadId); - if (providerThread === undefined || !goalsByNativeThread.has(nativeThreadId)) return; + if ( + providerThread === undefined || + !goalsByNativeThread.has(nativeThreadId) || + goalUpdatesHeld.has(nativeThreadId) + ) { + return; + } const goal = goalsByNativeThread.get(nativeThreadId) ?? null; if (providerGoalsEqual(providerThread.goal ?? null, goal)) return; const updated = { ...providerThread, goal, updatedAt: yield* DateTime.now }; @@ -1868,6 +1856,12 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( yield* emitGoalUpdate(nativeThreadId); }); + const releaseGoalUpdates = (nativeThreadId: string) => + Effect.suspend(() => { + goalUpdatesHeld.delete(nativeThreadId); + return emitGoalUpdate(nativeThreadId); + }); + // Only new model output proves recovery; local item/completed can arrive during upstream retries. const completeProviderRetry = Effect.fn("CodexAdapterV2.completeProviderRetry")(function* ( context: ActiveCodexTurnContext, @@ -2912,15 +2906,15 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( : null, ), Effect.orElseSucceed(() => null), - Effect.flatMap((response) => - response === null - ? client.raw - .request("thread/resume", { - threadId: input.nativeThreadId, - excludeTurns: true, - }) - .pipe(Effect.flatMap(decodeCodexChildModel)) - : Effect.succeed(response), + Effect.filterOrElse( + (response) => !(response === null), + (_response) => + client.raw + .request("thread/resume", { + threadId: input.nativeThreadId, + excludeTurns: true, + }) + .pipe(Effect.flatMap(decodeCodexChildModel)), ), Effect.timeout("5 seconds"), Effect.flatMap((response) => @@ -5691,10 +5685,12 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( next: yield* Deferred.make(), }); yield* Effect.sleep(CODEX_GOAL_CONTINUATION_GRACE).pipe( + Effect.andThen(pauseUncontinuedGoal(nativeThreadId, event)), Effect.andThen( turnTerminalizationPermit.withPermits(1)(releaseGoalHold(nativeThreadId, event)), ), - Effect.forkIn(scope), + // The grace window starts when the turn completes, not when the fiber is scheduled. + Effect.forkIn(scope, { startImmediately: true }), ); return; } @@ -5702,6 +5698,51 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( }, ); + // A goal must not read as active once its run ends. Claiming the hold first + // makes a turn Codex starts during the pause an orphan, which is stopped. + const pauseUncontinuedGoal = Effect.fnUntraced(function* ( + nativeThreadId: string, + expected: CodexRootTerminalEvent, + ) { + const hold = goalHolds.get(nativeThreadId); + if ( + hold?.event !== expected || + goalStops.has(nativeThreadId) || + goalsByNativeThread.get(nativeThreadId)?.status !== "active" + ) { + return; + } + goalHolds.delete(nativeThreadId); + // Keeps a turn Codex starts before settlement an orphan once the goal reads paused. + goalStops.set(nativeThreadId, (goalStops.get(nativeThreadId) ?? 0) + 1); + yield* Effect.logWarning("orchestration-v2.codex-goal-continuation-expired", { + nativeThreadId, + }); + yield* client + .request("thread/goal/set", { threadId: nativeThreadId, status: "paused" }) + .pipe( + // The hold is already claimed, so an unanswered pause must not keep the run open. + Effect.timeout("10 seconds"), + Effect.flatMap(({ goal }) => { + goalsByNativeThread.set(nativeThreadId, providerGoalFromCodex(goal)); + return emitGoalUpdate(nativeThreadId); + }), + Effect.catch((cause) => + Effect.logWarning("orchestration-v2.codex-goal-pause-failed", { cause }), + ), + Effect.ensuring( + turnTerminalizationPermit.withPermits(1)(settleGoalHold(hold, "completed")), + ), + Effect.ensuring( + Effect.sync(() => { + const stops = (goalStops.get(nativeThreadId) ?? 1) - 1; + if (stops > 0) goalStops.set(nativeThreadId, stops); + else goalStops.delete(nativeThreadId); + }), + ), + ); + }); + /** Hold the terminalization permit and remove the goalHolds entry before calling. */ const settleGoalHold = Effect.fnUntraced(function* ( hold: CodexGoalHold, @@ -6080,9 +6121,10 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( }), ); + /** Settles a /goal turn that never reaches Codex. State changes reply through the goal row. */ const completeGoalCommandTurn = Effect.fnUntraced(function* ( turnInput: ProviderAdapter.ProviderAdapterV2TurnInput, - reply: string, + reply: string | null, ) { const now = yield* DateTime.now; const nativeId = `goal-command:${turnInput.attemptId}`; @@ -6127,26 +6169,28 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( providerTurn, }); yield* rememberRootProviderThread(turnInput.providerThread); - const artifacts = yield* buildAgentMessageArtifacts( - context, - { id: nativeId, text: reply }, - true, - ); - yield* emitProviderEvent({ - type: "node.updated", - driver: CODEX_PROVIDER, - node: artifacts.node, - }); - yield* emitProviderEvent({ - type: "message.updated", - driver: CODEX_PROVIDER, - message: artifacts.message, - }); - yield* emitProviderEvent({ - type: "turn_item.updated", - driver: CODEX_PROVIDER, - turnItem: artifacts.turnItem, - }); + if (reply !== null) { + const artifacts = yield* buildAgentMessageArtifacts( + context, + { id: nativeId, text: reply }, + true, + ); + yield* emitProviderEvent({ + type: "node.updated", + driver: CODEX_PROVIDER, + node: artifacts.node, + }); + yield* emitProviderEvent({ + type: "message.updated", + driver: CODEX_PROVIDER, + message: artifacts.message, + }); + yield* emitProviderEvent({ + type: "turn_item.updated", + driver: CODEX_PROVIDER, + turnItem: artifacts.turnItem, + }); + } yield* emitProviderEvent({ type: "provider_turn.updated", driver: CODEX_PROVIDER, @@ -6304,7 +6348,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( const runGoalCommand = Effect.fn("CodexAdapterV2.runGoalCommand")( function* ( turnInput: ProviderAdapter.ProviderAdapterV2TurnInput, - command: CodexGoalCommand, + command: OrchestrationV2GoalCommand, ) { const threadId = yield* getNativeThreadId(turnInput.providerThread); rootProviderThreads.set(threadId, turnInput.providerThread); @@ -6326,10 +6370,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( if (command.type === "clear") { const { cleared } = yield* client.request("thread/goal/clear", { threadId }); goalsByNativeThread.set(threadId, null); - return yield* completeGoalCommandTurn( - turnInput, - cleared ? "Goal cleared." : "No goal to clear.", - ); + return yield* completeGoalCommandTurn(turnInput, cleared ? null : "No goal is set."); } if (current === null && command.type !== "set") { return yield* completeGoalCommandTurn(turnInput, "No goal is set."); @@ -6340,12 +6381,10 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( status: "paused", }); goalsByNativeThread.set(threadId, providerGoalFromCodex(goal)); - return yield* completeGoalCommandTurn( - turnInput, - "Goal paused. Send /goal resume to continue.", - ); + return yield* completeGoalCommandTurn(turnInput, null); } if (command.type === "set") { + goalUpdatesHeld.add(threadId); if (current !== null) yield* client.request("thread/goal/clear", { threadId }); // Keep paused until our turn starts so Codex cannot start one first. const { goal } = yield* client.request("thread/goal/set", { @@ -6359,12 +6398,16 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( const activation = { stopped: false }; goalActivations.set(threadId, activation); yield* Effect.gen(function* () { + // A bare objective reads like a statement, so the model echoes it back. yield* startNativeTurn( turnInput, command.type === "set" ? yield* toCodexInput({ ...turnInput, - message: { ...turnInput.message, text: command.objective }, + message: { + ...turnInput.message, + text: `Start working toward this goal: ${command.objective}`, + }, }) : [], ); @@ -6398,6 +6441,14 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( }, (effect, turnInput) => effect.pipe( + Effect.ensuring( + Effect.suspend(() => { + const threadId = turnInput.providerThread.nativeThreadRef?.nativeId; + return threadId == null || !goalUpdatesHeld.has(threadId) + ? Effect.void + : releaseGoalUpdates(threadId); + }), + ), Effect.mapError( (cause) => new ProviderAdapter.ProviderAdapterTurnStartError({ @@ -6686,9 +6737,8 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( ), startTurn: (turnInput) => { const goalCommand = - turnInput.message.attachments.length === 0 && turnInput.restartContinuationOfRunId === undefined - ? parseCodexGoalCommand(turnInput.message.text) + ? parseGoalCommand(turnInput.message) : null; if (goalCommand !== null) return runGoalCommand(turnInput, goalCommand); return Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/CheckpointCaptureService.ts b/apps/server/src/orchestration-v2/CheckpointCaptureService.ts index 85675687c6..1a1e198cdf 100644 --- a/apps/server/src/orchestration-v2/CheckpointCaptureService.ts +++ b/apps/server/src/orchestration-v2/CheckpointCaptureService.ts @@ -232,14 +232,15 @@ export const layer: Layer.Layer< }); return CheckpointCaptureServiceV2.of({ - execute: (input) => - execute(input).pipe( + execute: Effect.fn("CheckpointCaptureServiceV2.execute")(function (input) { + return execute(input).pipe( Effect.mapError((cause) => isCheckpointCaptureExecutionError(cause) ? cause : new CheckpointCaptureExecutionError({ ...input, cause }), ), - ), + ); + }), }); }), ); diff --git a/apps/server/src/orchestration-v2/CheckpointRestoreSafety.ts b/apps/server/src/orchestration-v2/CheckpointRestoreSafety.ts index 78d7d8493d..7901a952da 100644 --- a/apps/server/src/orchestration-v2/CheckpointRestoreSafety.ts +++ b/apps/server/src/orchestration-v2/CheckpointRestoreSafety.ts @@ -37,15 +37,12 @@ export const isCheckpointRestoreIsolated = Effect.fn("orchestrationV2.isCheckpoi if (!shared && worktreePath !== null) { // Storage cleanup may have removed the worktree; rollback recreates it. const resolve = (target: string) => - fileSystem - .realPath(target) - .pipe( - Effect.catch((error) => - error.reason._tag === "NotFound" - ? Effect.succeed(path.resolve(target)) - : Effect.fail(error), - ), - ); + fileSystem.realPath(target).pipe( + Effect.catchIf( + (error) => error.reason._tag === "NotFound", + (_error) => Effect.succeed(path.resolve(target)), + ), + ); const cwd = yield* resolve(scope.cwd); const worktreeCwd = yield* resolve(worktreePath); shared = cwd !== worktreeCwd; @@ -77,13 +74,12 @@ export const isCheckpointRestoreIsolated = Effect.fn("orchestrationV2.isCheckpoi for (const candidate of paths) { if (checkedPaths.has(candidate)) continue; checkedPaths.add(candidate); - const otherCwd = yield* fileSystem - .realPath(candidate) - .pipe( - Effect.catch((error) => - error.reason._tag === "NotFound" ? Effect.succeed(null) : Effect.fail(error), - ), - ); + const otherCwd = yield* fileSystem.realPath(candidate).pipe( + Effect.catchIf( + (error) => error.reason._tag === "NotFound", + (_error) => Effect.succeed(null), + ), + ); if (otherCwd !== null && (contains(cwd, otherCwd) || contains(otherCwd, cwd))) { shared = true; break; diff --git a/apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts b/apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts index b16ce51036..6580f1877b 100644 --- a/apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts +++ b/apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts @@ -139,6 +139,7 @@ it.effect.each(deliveries)( const sdkMessages = yield* Queue.unbounded(); const offers: SDKUserMessage[] = []; const nativeQueue: SDKUserMessage[] = []; + // @effect-diagnostics-next-line abortControllerInEffect:off - The fake SDK interrupts its batch independently of the test fiber. const batchAbort = new AbortController(); const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({ instanceId: modelSelection.instanceId, diff --git a/apps/server/src/orchestration-v2/CommandOutputHub.test.ts b/apps/server/src/orchestration-v2/CommandOutputHub.test.ts index c1ce02469f..74ee3503f1 100644 --- a/apps/server/src/orchestration-v2/CommandOutputHub.test.ts +++ b/apps/server/src/orchestration-v2/CommandOutputHub.test.ts @@ -163,8 +163,9 @@ const subscribeFrames = (threadId: ThreadId) => const takeFrame = (frames: Queue.Queue) => Queue.take(frames).pipe( - Effect.flatMap((frame) => - frame === "ended" ? Effect.die("command output stream ended early") : Effect.succeed(frame), + Effect.filterOrElse( + (frame) => !(frame === "ended"), + (_frame) => Effect.die("command output stream ended early"), ), ); diff --git a/apps/server/src/orchestration-v2/CommandPolicy.ts b/apps/server/src/orchestration-v2/CommandPolicy.ts index 415ce4d3a5..445036f795 100644 --- a/apps/server/src/orchestration-v2/CommandPolicy.ts +++ b/apps/server/src/orchestration-v2/CommandPolicy.ts @@ -4,6 +4,8 @@ import { ModelSelection, type OrchestrationV2Command, OrchestrationV2ProviderCapabilities, + type OrchestrationV2GoalCommand, + type OrchestrationV2ProviderGoalStatus, type OrchestrationV2Run, OrchestrationV2ThreadProjection, ProviderInstanceId, @@ -128,6 +130,26 @@ export function isNativeMaintenanceCommand(message: { ); } +/** + * A goal command sent into active work stops that work when it starts or + * replaces goal work, or ends a goal that is driving the run. + */ +export function goalCommandStopsActiveRun( + command: OrchestrationV2GoalCommand, + goalStatus: OrchestrationV2ProviderGoalStatus | null, +): boolean { + switch (command.type) { + case "set": + case "resume": + return true; + case "clear": + case "pause": + return goalStatus === "active"; + case "show": + return false; + } +} + export function resolveMessageDispatchIntent( projection: OrchestrationV2ThreadProjection, requestedMode: MessageDispatchMode, diff --git a/apps/server/src/orchestration-v2/EffectWorker.ts b/apps/server/src/orchestration-v2/EffectWorker.ts index 1d9484308a..840a0f8345 100644 --- a/apps/server/src/orchestration-v2/EffectWorker.ts +++ b/apps/server/src/orchestration-v2/EffectWorker.ts @@ -95,7 +95,7 @@ export const layerExecutor: Layer.Layer< const threads = yield* ThreadManagementService.ThreadManagementService; const settings = yield* ServerSettings.ServerSettingsService; return OrchestrationEffectExecutorV2.of({ - execute: (effect, options) => { + execute: Effect.fn("OrchestrationEffectExecutorV2.execute")(function (effect, options) { const willRetry = options?.willRetry ?? false; switch (effect.request.type) { case "provider-runtime.continue": { @@ -177,14 +177,14 @@ export const layerExecutor: Layer.Layer< cause, }), ), - Effect.catch((cause) => - request.subagent === undefined && - isNonRetryableProviderTurnControlFailure( - request.type, - Cause.pretty(Cause.fail(cause)), - ) - ? Effect.void - : Effect.fail(cause), + Effect.catchIf( + (cause) => + request.subagent === undefined && + isNonRetryableProviderTurnControlFailure( + request.type, + Cause.pretty(Cause.fail(cause)), + ), + () => Effect.void, ), Effect.andThen((outcome) => request.subagent !== undefined && outcome === undefined @@ -557,7 +557,7 @@ export const layerExecutor: Layer.Layer< ), ); } - }, + }), }); }), ); diff --git a/apps/server/src/orchestration-v2/EventStore.ts b/apps/server/src/orchestration-v2/EventStore.ts index 0b51a39422..7e6b01f5d6 100644 --- a/apps/server/src/orchestration-v2/EventStore.ts +++ b/apps/server/src/orchestration-v2/EventStore.ts @@ -107,8 +107,8 @@ const layerBase: Layer.Layer - applicationEvents.appendAgentEvents(input).pipe( + append: Effect.fn("EventStoreV2.append")(function (input) { + return applicationEvents.appendAgentEvents(input).pipe( Effect.mapError( (cause) => new EventStoreAppendEventsError({ @@ -116,20 +116,22 @@ const layerBase: Layer.Layer - applicationEvents + ); + }), + appendProjectEvent: Effect.fn("EventStoreV2.appendProjectEvent")(function (event) { + return applicationEvents .appendProjectEvent(event) .pipe( Effect.mapError((cause) => new EventStoreAppendEventsError({ eventCount: 1, cause })), - ), + ); + }), read, readByCommandId: ({ commandId }) => applicationEvents .readAgentEvents({ commandId }) .pipe(Stream.mapError((cause) => new EventStoreReadEventsError({ cause }))), - latestSequence: (input) => - applicationEvents.latestAgentSequence(input?.threadId).pipe( + latestSequence: Effect.fn("EventStoreV2.latestSequence")(function (input) { + return applicationEvents.latestAgentSequence(input?.threadId).pipe( Effect.mapError( (cause) => new EventStoreReadEventsError({ @@ -137,7 +139,8 @@ const layerBase: Layer.Layer new EventStoreReadEventsError({ cause })), ), diff --git a/apps/server/src/orchestration-v2/FoundationPersistence.test.ts b/apps/server/src/orchestration-v2/FoundationPersistence.test.ts index d486d0240c..6ccf5acf09 100644 --- a/apps/server/src/orchestration-v2/FoundationPersistence.test.ts +++ b/apps/server/src/orchestration-v2/FoundationPersistence.test.ts @@ -737,6 +737,7 @@ it.layer(layerTest)("orchestration V2 foundation persistence", (it) => { }); let afterSequence = created[0]!.sequence; + // oxlint-disable-next-line t3code/no-polling-loop-in-tests -- Fixed-count writes exercise the catch-up/live handoff; each iteration awaits its event rather than polling. for (let index = 1; index <= 32; index += 1) { const nextEvent = { id: EventId.make(`event:foundation-stream-race:${index}`), @@ -2426,6 +2427,8 @@ it.layer(layerTest)("orchestration V2 foundation persistence", (it) => { const releaseA1 = yield* Deferred.make(); const releaseA2 = yield* Deferred.make(); const releaseB1 = yield* Deferred.make(); + const settled = yield* Deferred.make(); + const completed = yield* Ref.make(0); const gates = new Map([ [effectA1, { started: startedA1, release: releaseA1 }], [effectA2, { started: startedA2, release: releaseA2 }], @@ -2447,7 +2450,24 @@ it.layer(layerTest)("orchestration V2 foundation persistence", (it) => { workerId: "concurrency-worker", }).pipe( Layer.provide( - Layer.merge(Layer.succeed(EffectOutbox.EffectOutboxV2, outbox), layerExecutor), + Layer.merge( + Layer.succeed(EffectOutbox.EffectOutboxV2, { + ...outbox, + succeed: (input) => + outbox + .succeed(input) + .pipe( + Effect.tap(() => + Ref.updateAndGet(completed, (count) => count + 1).pipe( + Effect.flatMap((count) => + count === 3 ? Deferred.succeed(settled, undefined) : Effect.void, + ), + ), + ), + ), + }), + layerExecutor, + ), ), ); @@ -2493,13 +2513,12 @@ it.layer(layerTest)("orchestration V2 foundation persistence", (it) => { yield* Deferred.succeed(releaseB1, undefined); yield* Deferred.await(startedA2); yield* Deferred.succeed(releaseA2, undefined); - let settled = false; - while (!settled) { - settled = (yield* outbox.listByCommandId(commandId)).every( + yield* Deferred.await(settled); + assert.isTrue( + (yield* outbox.listByCommandId(commandId)).every( (effect) => effect.status === "succeeded", - ); - if (!settled) yield* Effect.yieldNow; - } + ), + ); }).pipe(Effect.provide(layerWorker), Effect.scoped); }), ); diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 7851186d44..9f2ebb5970 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -56,6 +56,7 @@ import { type OrchestrationV2ThreadProjection, type OrchestrationV2TurnItem, latestProviderTurnForAttempt, + parseGoalCommand, orchestrationV2RunWorkStartedAt, ProviderInstanceId, ProviderInteractionMode, @@ -94,6 +95,7 @@ import { import { CheckpointServiceV2 } from "./CheckpointService.ts"; import { CommandPolicyV2, + goalCommandStopsActiveRun, isNativeMaintenanceCommand, resolveMessageDispatchIntent, } from "./CommandPolicy.ts"; @@ -453,13 +455,6 @@ function wakeWorkStartedAt( return previous === undefined ? {} : { workStartedAt: orchestrationV2RunWorkStartedAt(previous) }; } -function isGoalCommand(message: { - readonly text: string; - readonly attachments: ReadonlyArray; -}): boolean { - return message.attachments.length === 0 && /^\/goal(?:\s|$)/u.test(message.text.trim()); -} - const threadPullRequestLinksEqual = Schema.toEquivalence(Schema.NullOr(ThreadLinkedPullRequest)); function commandThreadId(command: OrchestrationV2ServerCommand): ThreadId { @@ -622,10 +617,12 @@ function isBlockingRun(run: OrchestrationV2Run): boolean { } /** Waiting runs only drain post-terminal output; their agent turn is over and still needs a wake. */ +function isLiveRun(run: OrchestrationV2Run): boolean { + return run.status === "preparing" || run.status === "starting" || run.status === "running"; +} + function hasLiveRun(projection: Pick): boolean { - return projection.runs.some( - (run) => run.status === "preparing" || run.status === "starting" || run.status === "running", - ); + return projection.runs.some(isLiveRun); } function withPullRequestWatch( @@ -987,6 +984,35 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const threadForkService = yield* ThreadForkServiceV2; const threadDispatch = yield* ThreadCommandExecutor.ThreadCommandExecutor; + /** The driver when this provider runs `/goal` natively, else undefined. */ + const goalDriverFor = Effect.fn("Orchestrator.goalDriverFor")(function* ( + command: OrchestrationV2ServerCommand, + providerInstanceId: ProviderInstanceId, + ) { + const { driver } = yield* providerAdapters + .get(providerInstanceId) + .pipe(mapDispatchError(command)); + return driver === "codex" || driver === "claudeAgent" ? driver : undefined; + }); + + /** Claude Code reads `/goal pause` and `/goal resume` as new goals named "pause" and "resume". */ + const ensureGoalCommandSupported = Effect.fn("Orchestrator.ensureGoalCommandSupported")( + function* ( + command: OrchestrationV2ServerCommand, + message: { readonly text: string; readonly attachments: ReadonlyArray }, + providerInstanceId: ProviderInstanceId, + ) { + const goalCommand = parseGoalCommand(message); + if (goalCommand?.type !== "pause" && goalCommand?.type !== "resume") return; + if ((yield* goalDriverFor(command, providerInstanceId)) !== "claudeAgent") return; + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: "Claude goals cannot be paused or resumed. Send /goal clear to remove the goal.", + }); + }, + ); + const mapDispatchError = (command: OrchestrationV2ServerCommand) => (effect: Effect.Effect): Effect.Effect => @@ -4282,7 +4308,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio : "Signing out must run as a separate turn. Queue it or wait for the active turn to finish.", }); } - if (isGoalCommand(input)) { + if (parseGoalCommand(input) !== null) { return yield* new OrchestratorDispatchError({ commandId: input.command.commandId, commandType: input.command.type, @@ -5206,6 +5232,45 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ) { dispatchMode = { type: "queue_after_active" }; } + // Codex and Claude own /goal; elsewhere it is ordinary text that cannot steer. + const parsedGoalCommand = parseGoalCommand(command); + const goalCommand = + parsedGoalCommand === null || + (yield* goalDriverFor(command, modelSelection.instanceId)) === undefined + ? null + : parsedGoalCommand; + let goalStopRunId: RunId | undefined; + if (goalCommand !== null) { + yield* ensureGoalCommandSupported(command, command, modelSelection.instanceId); + // A goal command never joins a running turn. One that starts or ends goal + // work stops the run and goes first in the queue; the rest wait. A send + // that asked to be queued (no delivery intent) only queues, and a run + // that is only finishing or running background work is never stopped. + const resolvedMode = dispatchMode; + const target = + resolvedMode.type === "steer_active" || resolvedMode.type === "restart_active" + ? projection.runs.find((run) => run.id === resolvedMode.targetRunId) + : resolvedMode.type === "queue_after_active" && command.deliveryIntent !== undefined + ? projection.runs.findLast(isLiveRun) + : undefined; + if (target !== undefined) { + const goal = projection.providerThreads.find( + (candidate) => candidate.id === target.providerThreadId, + )?.goal; + const targetMessage = projection.messages.find( + (message) => message.id === target.userMessageId, + ); + if ( + isLiveRun(target) && + goalCommandStopsActiveRun(goalCommand, goal?.status ?? null) && + (targetMessage === undefined || !isNativeMaintenanceCommand(targetMessage)) && + !(yield* stopReachedRun(command, command.threadId, target.id)) + ) { + goalStopRunId = target.id; + } + dispatchMode = { type: "queue_after_active" }; + } + } if ( command.notification !== undefined && (command.createdBy !== "agent" || @@ -5566,12 +5631,14 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ? { queueHeld: true } : {}), queuePosition: - Math.max( - 0, - ...projection.runs - .filter((candidate) => candidate.status === "queued") - .map((candidate) => candidate.queuePosition ?? candidate.ordinal), - ) + 1, + goalStopRunId !== undefined + ? 1 + : Math.max( + 0, + ...projection.runs + .filter((candidate) => candidate.status === "queued") + .map((candidate) => candidate.queuePosition ?? candidate.ordinal), + ) + 1, requestedAt: now, startedAt: null, completedAt: null, @@ -5724,6 +5791,17 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }, }); } + if (goalStopRunId !== undefined) { + return yield* stopRunForGoalCommand({ + command, + events, + effects, + projection: yield* getProjectionWithPendingEvents(command.threadId, events), + queuedRunId: runId, + targetRunId: goalStopRunId, + now, + }); + } return; } const pendingForkTransfer = pendingForkTransferForThread(projection); @@ -8289,6 +8367,46 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const now = yield* DateTime.now; const emitEvent = emit(events, command); + const parsedGoalCommand = parseGoalCommand(queuedMessage); + const goalCommand = + parsedGoalCommand === null || + (yield* goalDriverFor(command, queuedRun.providerInstanceId)) === undefined + ? null + : parsedGoalCommand; + if (goalCommand !== null) { + yield* ensureGoalCommandSupported(command, queuedMessage, queuedRun.providerInstanceId); + const target = projection.runs.find((run) => run.id === command.targetRunId); + const goal = projection.providerThreads.find( + (candidate) => candidate.id === target?.providerThreadId, + )?.goal; + const targetMessage = projection.messages.find( + (message) => message.id === target?.userMessageId, + ); + yield* emitEvent({ + type: "run.updated", + threadId: command.threadId, + runId: queuedRun.id, + nodeId: queuedRootNode.id, + providerInstanceId: queuedRun.providerInstanceId, + occurredAt: now, + payload: { ...queuedRun, queuePosition: 1 }, + }); + const stops = + target !== undefined && + isLiveRun(target) && + goalCommandStopsActiveRun(goalCommand, goal?.status ?? null) && + (targetMessage === undefined || !isNativeMaintenanceCommand(targetMessage)) && + !(yield* stopReachedRun(command, command.threadId, target.id)); + return yield* stopRunForGoalCommand({ + command, + events, + effects, + projection, + queuedRunId: queuedRun.id, + targetRunId: stops ? target.id : undefined, + now, + }); + } yield* emitEvent({ type: "run.updated", threadId: command.threadId, @@ -8599,6 +8717,11 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio cause: "A guest's message cannot be edited. Cancel it instead.", }); } + yield* ensureGoalCommandSupported( + command, + { text: command.text, attachments: command.attachments ?? queuedMessage.attachments }, + queuedRun.providerInstanceId, + ); const queuedTurnItem = projection.turnItems.find( (candidate) => candidate.type === "user_message" && candidate.messageId === queuedMessage.id, ); @@ -10390,6 +10513,73 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ), ); + /** Puts a queued goal command first and, given a target, stops the run in its way. */ + const stopRunForGoalCommand = Effect.fn("Orchestrator.stopRunForGoalCommand")(function* (input: { + readonly command: Extract< + OrchestrationV2Command, + { readonly type: "message.dispatch" | "queued-message.promote-to-steer" } + >; + readonly events: Ref.Ref>; + readonly effects: Ref.Ref>; + readonly projection: Pick; + readonly queuedRunId: RunId; + readonly targetRunId: RunId | undefined; + readonly now: DateTime.Utc; + }) { + const { command, events, effects, projection, now, targetRunId } = input; + const others = queuedRunsInDeliveryOrder(projection).filter( + (run) => run.id !== input.queuedRunId && !isAutomaticCompletionRun(projection, run), + ); + for (const [index, run] of others.entries()) { + const queuePosition = index + 2; + if (run.queuePosition === queuePosition) continue; + yield* emit( + events, + command, + )({ + type: "run.updated", + threadId: command.threadId, + runId: run.id, + ...(run.rootNodeId === null ? {} : { nodeId: run.rootNodeId }), + providerInstanceId: run.providerInstanceId, + occurredAt: now, + payload: { ...run, queuePosition }, + }); + } + if (targetRunId === undefined) return undefined; + const interruptEvents = yield* Ref.make>([]); + const interruptEffects = yield* Ref.make>([]); + const interrupted = yield* Effect.exit( + dispatchRunInterrupt( + { + type: "run.interrupt", + commandId: command.commandId, + threadId: command.threadId, + runId: targetRunId, + reason: "Stopped to apply /goal", + }, + interruptEvents, + interruptEffects, + ), + ); + // A failed interrupt may have written part of its events, so they are kept apart. + // The goal command then waits for the turn to end. + if (Exit.isFailure(interrupted)) { + yield* Effect.logWarning("goal command could not stop the running turn", { + threadId: command.threadId, + runId: targetRunId, + cause: interrupted.cause, + }); + return undefined; + } + const written = yield* Ref.get(interruptEvents); + const enqueued = yield* Ref.get(interruptEffects); + yield* Ref.update(events, (existing) => [...existing, ...written]); + yield* Ref.update(effects, (existing) => [...existing, ...enqueued]); + // Like Stop: a run stopped before its provider started also abandons that start. + return interrupted.value; + }); + const dispatchThreadStop = Effect.fn("Orchestrator.dispatchThreadStop")(function* ( command: Extract, events: Ref.Ref>, @@ -11728,7 +11918,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio text: guestSend.text, context: { version: 1 as const, records: [], guest: guestSend.guest }, }; - yield* dispatchMessage(dispatched, events, effects); + cancelUnsettledEffects = yield* dispatchMessage(dispatched, events, effects); yield* recordSubagentFollowup(dispatched, events).pipe(mapDispatchError(command)); break; } @@ -11788,7 +11978,11 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio cancelUnsettledEffects = yield* dispatchThreadStop(command, events, effects); break; case "queued-message.promote-to-steer": - yield* dispatchQueuedMessagePromoteToSteer(command, events, effects); + cancelUnsettledEffects = yield* dispatchQueuedMessagePromoteToSteer( + command, + events, + effects, + ); break; case "queued-run.start": case "queue.resume": { @@ -12000,21 +12194,20 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio } const plan = yield* dispatchOnce(command).pipe( - Effect.flatMap((planned) => - planned.events.length > 0 || - command.type === "thread.background-work.settle" || - command.type === "thread.stop" || - command.type === "subagent.stop" || - // A second Stop sharing, or one after the share already ended, is not an error. - command.type === "thread.guest-share.close" - ? Effect.succeed(planned) - : Effect.fail( - new OrchestratorDispatchError({ - commandId: command.commandId, - commandType: command.type, - cause: "Command produced no domain events.", - }), - ), + Effect.filterOrFail( + (planned) => + planned.events.length > 0 || + command.type === "thread.background-work.settle" || + command.type === "thread.stop" || + command.type === "subagent.stop" || + // Closing a share again, or after it ended, is not an error. + command.type === "thread.guest-share.close", + (_planned) => + new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: "Command produced no domain events.", + }), ), Effect.catch((cause) => Effect.gen(function* () { @@ -12418,13 +12611,17 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio recoverDelegatedTasks, recoverDelegatedTask, delegatedTaskResultPending, - restartContinuationPending: (run) => - awaitsRestartContinuation(run).pipe(Effect.orElseSucceed(() => false)), - awaitRestartContinuation: (run) => - effectOutbox.awaitSettled(`effect:restart-continuation:${run.id}`).pipe(Effect.ignore), + restartContinuationPending: Effect.fn("OrchestratorV2.restartContinuationPending")( + function (run) { + return awaitsRestartContinuation(run).pipe(Effect.orElseSucceed(() => false)); + }, + ), + awaitRestartContinuation: Effect.fn("OrchestratorV2.awaitRestartContinuation")(function (run) { + return effectOutbox.awaitSettled(`effect:restart-continuation:${run.id}`).pipe(Effect.ignore); + }), dispatch: dispatchWithReceipt, - autoArmLimitRecovery: (input) => - threadDispatch.withLock( + autoArmLimitRecovery: Effect.fn("OrchestratorV2.autoArmLimitRecovery")(function (input) { + return threadDispatch.withLock( input.threadId, Effect.gen(function* () { const thread = yield* projectionStore @@ -12442,7 +12639,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio return; yield* dispatchWithReceiptEffect({ type: "thread.metadata.update", ...input }); }), - ), + ); + }), searchThreadStream: (input) => projectionStore .searchThreadStream(input) @@ -12451,77 +12649,108 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio (cause) => new OrchestratorProjectionError({ threadId: input.threadId, cause }), ), ), - searchThread: (input) => - projectionStore + searchThread: Effect.fn("OrchestratorV2.searchThread")(function (input) { + return projectionStore .searchThread(input) .pipe( Effect.mapError( (cause) => new OrchestratorProjectionError({ threadId: input.threadId, cause }), ), - ), - getThreadHistoryPage: (threadId, cursor, throughEntryId, conversationOnly) => - projectionStore - .getThreadHistoryPage(threadId, cursor, throughEntryId, conversationOnly) - .pipe(Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause }))), - getTimelinePage: (threadId, options) => - projectionStore + ); + }), + getThreadHistoryPage: Effect.fn("OrchestratorV2.getThreadHistoryPage")( + function (threadId, cursor, throughEntryId, conversationOnly) { + return projectionStore + .getThreadHistoryPage(threadId, cursor, throughEntryId, conversationOnly) + .pipe(Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause }))); + }, + ), + getTimelinePage: Effect.fn("OrchestratorV2.getTimelinePage")(function (threadId, options) { + return projectionStore .getTimelinePage(threadId, options) - .pipe(Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause }))), - getMessageCount: (threadId) => - projectionStore + .pipe(Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause }))); + }), + getMessageCount: Effect.fn("OrchestratorV2.getMessageCount")(function (threadId) { + return projectionStore .getMessageCount(threadId) - .pipe(Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause }))), - getTurnItem: (input) => - projectionStore + .pipe(Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause }))); + }), + getTurnItem: Effect.fn("OrchestratorV2.getTurnItem")(function (input) { + return projectionStore .getTurnItem(input) .pipe( Effect.mapError( (cause) => new OrchestratorProjectionError({ threadId: input.threadId, cause }), ), - ), - getThreadRecords: (threadId, fields, filter) => - projectionStore - .getThreadRecords(threadId, fields, filter) - .pipe(Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause }))), - getThreadProjection: (threadId) => - projectionStore + ); + }), + getThreadRecords: Effect.fn("OrchestratorV2.getThreadRecords")( + function (threadId, fields, filter) { + return projectionStore + .getThreadRecords(threadId, fields, filter) + .pipe(Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause }))); + }, + ), + getThreadProjection: Effect.fn("OrchestratorV2.getThreadProjection")(function (threadId) { + return projectionStore .getThreadProjection(threadId) - .pipe(Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause }))), - getCheckpointContext: (threadId) => - projectionStore + .pipe(Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause }))); + }), + getCheckpointContext: Effect.fn("OrchestratorV2.getCheckpointContext")(function (threadId) { + return projectionStore .getCheckpointContext(threadId) - .pipe(Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause }))), - getThreadSnapshot: (threadId) => - projectionStore + .pipe(Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause }))); + }), + getThreadSnapshot: Effect.fn("OrchestratorV2.getThreadSnapshot")(function (threadId) { + return projectionStore .getThreadSnapshot(threadId) - .pipe(Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause }))), - getThreadSnapshotWindow: (threadId, options) => - projectionStore - .getThreadSnapshotWindow(threadId, options) - .pipe(Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause }))), - getShellSnapshot: (options) => - projectionStore.getShellSnapshot(options).pipe(Effect.mapError(shellProjectionError)), - listActiveSubagentThreadIds: () => - projectionStore.listActiveSubagentThreadIds().pipe(Effect.mapError(shellProjectionError)), - listOlderSettledThreads: (query) => - projectionStore.listOlderSettledThreads(query).pipe(Effect.mapError(shellProjectionError)), - readShellSnapshot: (options) => - projectionStore + .pipe(Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause }))); + }), + getThreadSnapshotWindow: Effect.fn("OrchestratorV2.getThreadSnapshotWindow")( + function (threadId, options) { + return projectionStore + .getThreadSnapshotWindow(threadId, options) + .pipe(Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause }))); + }, + ), + getShellSnapshot: Effect.fn("OrchestratorV2.getShellSnapshot")(function (options) { + return projectionStore.getShellSnapshot(options).pipe(Effect.mapError(shellProjectionError)); + }), + listActiveSubagentThreadIds: Effect.fn("OrchestratorV2.listActiveSubagentThreadIds")( + function () { + return projectionStore + .listActiveSubagentThreadIds() + .pipe(Effect.mapError(shellProjectionError)); + }, + ), + listOlderSettledThreads: Effect.fn("OrchestratorV2.listOlderSettledThreads")(function (query) { + return projectionStore + .listOlderSettledThreads(query) + .pipe(Effect.mapError(shellProjectionError)); + }), + readShellSnapshot: Effect.fn("OrchestratorV2.readShellSnapshot")(function (options) { + return projectionStore .readShellSnapshot(options) .pipe( Effect.mapError(shellProjectionError), Effect.map(Effect.mapError(shellProjectionError)), - ), - getThreadShell: (threadId) => - projectionStore + ); + }), + getThreadShell: Effect.fn("OrchestratorV2.getThreadShell")(function (threadId) { + return projectionStore .getThreadShell(threadId) - .pipe(Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause }))), - findGuestShareThreadId: (shareId) => - projectionStore.findGuestShareThreadId(shareId).pipe(Effect.mapError(shellProjectionError)), - getThreadEventSequence: (threadId) => - eventSink + .pipe(Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause }))); + }), + findGuestShareThreadId: Effect.fn("OrchestratorV2.findGuestShareThreadId")(function (shareId) { + return projectionStore + .findGuestShareThreadId(shareId) + .pipe(Effect.mapError(shellProjectionError)); + }), + getThreadEventSequence: Effect.fn("OrchestratorV2.getThreadEventSequence")(function (threadId) { + return eventSink .latestSequence({ threadId }) - .pipe(Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause }))), + .pipe(Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause }))); + }), streamStoredEvents: eventSink.stream().pipe( Stream.mapError( (cause) => diff --git a/apps/server/src/orchestration-v2/ProjectStore.ts b/apps/server/src/orchestration-v2/ProjectStore.ts index e2e16d2861..54ab5034d8 100644 --- a/apps/server/src/orchestration-v2/ProjectStore.ts +++ b/apps/server/src/orchestration-v2/ProjectStore.ts @@ -266,11 +266,14 @@ export const make = Effect.gen(function* () { get, list, findActiveByWorkspaceRoot, - getShell: (projectId) => get(projectId).pipe(Effect.map(Option.map(toShell))), - listShells: (options) => - list(options?.projectIds === undefined ? undefined : { projectIds: options.projectIds }).pipe( - Effect.map((rows) => rows.map(toShell)), - ), + getShell: Effect.fn("ProjectStoreV2.getShell")(function (projectId) { + return get(projectId).pipe(Effect.map(Option.map(toShell))); + }), + listShells: Effect.fn("ProjectStoreV2.listShells")(function (options) { + return list( + options?.projectIds === undefined ? undefined : { projectIds: options.projectIds }, + ).pipe(Effect.map((rows) => rows.map(toShell))); + }), }); }); diff --git a/apps/server/src/orchestration-v2/ProviderAdapterRegistry.ts b/apps/server/src/orchestration-v2/ProviderAdapterRegistry.ts index a3673442d2..f9c579d886 100644 --- a/apps/server/src/orchestration-v2/ProviderAdapterRegistry.ts +++ b/apps/server/src/orchestration-v2/ProviderAdapterRegistry.ts @@ -75,8 +75,8 @@ export const layerFromProviderInstanceRegistry: Layer.Layer< Effect.gen(function* () { const instances = yield* ProviderInstanceRegistry.ProviderInstanceRegistry; return ProviderAdapterRegistryV2.of({ - get: (instanceId) => - instances.getInstance(instanceId).pipe( + get: Effect.fn("ProviderAdapterRegistryV2.get")(function (instanceId) { + return instances.getInstance(instanceId).pipe( Effect.flatMap((instance) => { if (instance === undefined) return new ProviderAdapterRegistryLookupError({ instanceId }); @@ -131,11 +131,13 @@ export const layerFromProviderInstanceRegistry: Layer.Layer< }, } satisfies ProviderAdapter.ProviderAdapterV2["Service"]); }), - ), - list: () => - instances.listInstances.pipe( + ); + }), + list: Effect.fn("ProviderAdapterRegistryV2.list")(function () { + return instances.listInstances.pipe( Effect.map((available) => available.map((instance) => instance.instanceId)), - ), + ); + }), getMetadata: Effect.fn("ProviderAdapterRegistry.getMetadata")(function* (instanceId) { const instance = yield* instances.getInstance(instanceId); if (instance === undefined) { diff --git a/apps/server/src/orchestration-v2/ProviderEventIngestor.ts b/apps/server/src/orchestration-v2/ProviderEventIngestor.ts index 51609eb16c..54c4820bd1 100644 --- a/apps/server/src/orchestration-v2/ProviderEventIngestor.ts +++ b/apps/server/src/orchestration-v2/ProviderEventIngestor.ts @@ -120,8 +120,11 @@ export const layerAnalytics = Layer.effect( Effect.gen(function* () { const analytics = yield* AnalyticsService.AnalyticsService; return { - record: (properties: Readonly>) => - analytics.record("provider.turn.completed", properties), + record: Effect.fn("ProviderTurnAnalytics.record")(function ( + properties: Readonly>, + ) { + return analytics.record("provider.turn.completed", properties); + }), }; }), ); @@ -867,6 +870,7 @@ export const layer: Layer.Layer< ), ), ), + // @effect-diagnostics-next-line flatMapConditionalToFilterOrFail:off - The predicate narrows the captured event for the subagent update. Effect.flatMap((storedEvents) => storedEvents.length === 0 || input.event.type !== "subagent.updated" ? Effect.succeed(storedEvents) diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts index 98d15e3c53..f7db872a08 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts @@ -9,6 +9,8 @@ import { type OrchestrationV2Run, type OrchestrationV2RunAttempt, type OrchestrationV2TurnItem, + isCompactionCommandMessage, + parseGoalCommand, RunId, ThreadId, } from "@infinitus/contracts"; @@ -286,11 +288,12 @@ export const layer: Layer.Layer< source.status === "interrupted" || (source.status === "completed" && handoff.delivery === undefined && + // Compaction and /goal turns defer history to the next turn. projection.messages.some( (message) => message.id === source.userMessageId && - message.attachments.length === 0 && - message.text.trim().toLowerCase() === "/compact", + (isCompactionCommandMessage(message) || + parseGoalCommand(message) !== null), ))), ))), ); @@ -509,14 +512,14 @@ export const layer: Layer.Layer< path: worktreePath, }), ), - Effect.catchCause((cause) => - Cause.hasInterruptsOnly(cause) - ? Effect.failCause(cause) - : Effect.logWarning("provider turn start failed to recreate worktree", { - threadId: projection.thread.id, - worktreePath, - cause: Cause.pretty(cause), - }), + Effect.catchCauseIf( + (cause) => !Cause.hasInterruptsOnly(cause), + (cause) => + Effect.logWarning("provider turn start failed to recreate worktree", { + threadId: projection.thread.id, + worktreePath, + cause: Cause.pretty(cause), + }), ), ); } @@ -1040,6 +1043,9 @@ export const layer: Layer.Layer< text: message.text, records: message.context?.records ?? [], }); + // Codex and Claude parse /goal from the prompt, so it goes verbatim and history waits a turn. + const goalDriver = session.driver === "codex" || session.driver === "claudeAgent"; + const goalCommand = goalDriver && parseGoalCommand(message) !== null; // The provider turn marks cancellation work delivered; restart continuations use their own text or resume natively. const noteContinuation = isRestartNoteContinuation( run, @@ -1054,8 +1060,8 @@ export const layer: Layer.Layer< projection.messages .filter( (candidate) => - candidate.attachments.length === 0 && - candidate.text.trim().toLowerCase() === "/compact", + isCompactionCommandMessage(candidate) || + (goalDriver && parseGoalCommand(candidate) !== null), ) .map((candidate) => candidate.id), ), @@ -1203,7 +1209,7 @@ export const layer: Layer.Layer< ]; const delivery = yield* deliverContextHandoffs({ handoffs: [...effectiveHandoffs, ...retryHandoff], - deferInline: compact, + deferInline: compact || goalCommand, providerThread: runningProviderThread, budget: Effect.gen(function* () { return handoffBudget({ @@ -1257,7 +1263,10 @@ export const layer: Layer.Layer< ...(noteContinuation ? promptedInput : turnInput), message: { ...turnInput.message, - text: context === "" ? userText : `${context}\n\nUser message:\n${userText}`, + text: + context === "" || goalCommand + ? userText + : `${context}\n\nUser message:\n${userText}`, }, }).pipe( // Clear pending handoff markers for refused prompts so the next turn retains the native session. diff --git a/apps/server/src/orchestration-v2/ResourceCleanupService.ts b/apps/server/src/orchestration-v2/ResourceCleanupService.ts index c82cf4fce9..c322d61813 100644 --- a/apps/server/src/orchestration-v2/ResourceCleanupService.ts +++ b/apps/server/src/orchestration-v2/ResourceCleanupService.ts @@ -47,33 +47,44 @@ export const layer = Layer.effect( const config = yield* ServerConfig.ServerConfig; const scheduledTasks = yield* ScheduledTaskService.ScheduledTaskService; return { - cleanupScheduledTasks: (threadId: ThreadId) => - scheduledTasks + cleanupScheduledTasks: Effect.fn("ResourceCleanupService.cleanupScheduledTasks")(function ( + threadId: ThreadId, + ) { + return scheduledTasks .deleteForThread(threadId) .pipe( Effect.mapError( (cause) => new ResourceCleanupError({ operation: "scheduled-tasks", threadId, cause }), ), - ), - cleanupTerminals: (threadId: string) => - terminals + ); + }), + cleanupTerminals: Effect.fn("ResourceCleanupService.cleanupTerminals")(function ( + threadId: string, + ) { + return terminals .close({ threadId, deleteHistory: true }) .pipe( Effect.mapError( (cause) => new ResourceCleanupError({ operation: "terminal", threadId, cause }), ), - ), - cleanupPreviews: (threadId: string) => - previews + ); + }), + cleanupPreviews: Effect.fn("ResourceCleanupService.cleanupPreviews")(function ( + threadId: string, + ) { + return previews .close({ threadId: ThreadId.make(threadId) }) .pipe( Effect.mapError( (cause) => new ResourceCleanupError({ operation: "preview", threadId, cause }), ), - ), - cleanupAttachments: (attachmentIds: ReadonlyArray) => - Effect.forEach( + ); + }), + cleanupAttachments: Effect.fn("ResourceCleanupService.cleanupAttachments")(function ( + attachmentIds: ReadonlyArray, + ) { + return Effect.forEach( attachmentIds, (attachmentId) => { const extension = parseAttachmentFileExtension(attachmentId); @@ -99,7 +110,8 @@ export const layer = Layer.effect( ); }, { discard: true, concurrency: 4 }, - ), + ); + }), }; }), ); diff --git a/apps/server/src/orchestration-v2/RunExecutionService.ts b/apps/server/src/orchestration-v2/RunExecutionService.ts index 61c8b77ec5..dfc90c901f 100644 --- a/apps/server/src/orchestration-v2/RunExecutionService.ts +++ b/apps/server/src/orchestration-v2/RunExecutionService.ts @@ -937,13 +937,13 @@ export const layer: Layer.Layer< ordinalWithinScope: Math.max(0, input.run.ordinal - 1), }) .pipe( - Effect.catchCause((cause) => - Cause.hasInterruptsOnly(cause) - ? Effect.failCause(cause) - : Effect.logWarning( - "orchestration V2 checkpoint baseline capture failed; starting provider without a baseline", - { runId: input.run.id }, - ), + Effect.catchCauseIf( + (cause) => !Cause.hasInterruptsOnly(cause), + (_cause) => + Effect.logWarning( + "orchestration V2 checkpoint baseline capture failed; starting provider without a baseline", + { runId: input.run.id }, + ), ), ); if ( diff --git a/apps/server/src/orchestration-v2/RuntimePolicy.ts b/apps/server/src/orchestration-v2/RuntimePolicy.ts index 0b6b032cc2..eab51ad24e 100644 --- a/apps/server/src/orchestration-v2/RuntimePolicy.ts +++ b/apps/server/src/orchestration-v2/RuntimePolicy.ts @@ -51,12 +51,13 @@ export class RuntimePolicyV2 extends Context.Service = Layer.succeed(RuntimePolicyV2, { - resolve: (input) => - Effect.succeed({ + resolve: Effect.fn("RuntimePolicyV2.resolve")(function (input) { + return Effect.succeed({ runtimeMode: input.thread.runtimeMode, interactionMode: input.thread.interactionMode, cwd: input.thread.worktreePath, - }), + }); + }), }); function providerRuntimeMode( diff --git a/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts b/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts index 78eaef3e84..64ce9540db 100644 --- a/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts +++ b/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts @@ -547,7 +547,7 @@ const nextTurnSelectionHarness = Effect.fn("nextTurnSelectionHarness")(function* yield* Fiber.join(running); return threadId; }); - return { events, started, steered, layer, startFirstTurn }; + return { cwd, events, started, steered, layer, startFirstTurn }; }); it.effect("sends a selected held message once and preserves the remaining queue order", () => @@ -1048,3 +1048,218 @@ it.effect("queues a plain send behind the stopped queue and releases its hold", }), ), ); + +const goalHarness = Effect.fn("goalHarness")(function* (name: string) { + const harness = yield* nextTurnSelectionHarness(name); + const send = (id: string, text: string, delivery: "queue" | "auto") => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(id), + threadId: ThreadId.make(`thread:${name}`), + messageId: MessageId.make(`message:${id}`), + text, + attachments: [], + modelSelection: runSelection, + ...(delivery === "queue" + ? { dispatchMode: { type: "queue_after_active" } } + : { dispatchMode: { type: "start_immediately" }, deliveryIntent: "auto" }), + createdBy: "user", + creationSource: "web", + }); + }); + const read = (threadId: ThreadId) => + Effect.gen(function* () { + const projection = yield* (yield* Orchestrator.OrchestratorV2).getThreadProjection(threadId); + const textOf = (run: (typeof projection.runs)[number]) => + projection.messages.find((message) => message.id === run.userMessageId)?.text; + return { + queue: projection.runs + .filter((run) => run.status === "queued") + .toSorted((left, right) => (left.queuePosition ?? 0) - (right.queuePosition ?? 0)) + .map(textOf), + started: harness.started.map((turn) => turn.message.text), + stopRequested: projection.turnItems.some( + (item) => + item.type === "run_interrupt_request" && item.runId === harness.started[0]?.runId, + ), + }; + }); + return { ...harness, send, read }; +}); + +it.effect("stops the running turn and starts a goal sent into it next", () => + Effect.scoped( + Effect.gen(function* () { + const { steered, layer, startFirstTurn, send, read } = yield* goalHarness("goal-steer"); + yield* Effect.gen(function* () { + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const threadId = yield* startFirstTurn; + yield* send("queued", "queued", "queue"); + yield* send("goal", "/goal all tests pass", "auto"); + yield* worker.drain(); + + assert.deepEqual(steered, []); + assert.deepEqual(yield* read(threadId), { + queue: ["queued"], + started: ["first", "/goal all tests pass"], + stopRequested: true, + }); + }).pipe(Effect.provide(layer)); + }), + ), +); + +it.effect("queues a goal clear without stopping work when no goal drives it", () => + Effect.scoped( + Effect.gen(function* () { + const { steered, layer, startFirstTurn, send, read } = yield* goalHarness("goal-clear"); + yield* Effect.gen(function* () { + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const threadId = yield* startFirstTurn; + yield* send("queued", "queued", "queue"); + yield* send("clear", "/goal clear", "auto"); + yield* worker.drain(); + + assert.deepEqual(steered, []); + assert.deepEqual(yield* read(threadId), { + queue: ["queued", "/goal clear"], + started: ["first"], + stopRequested: false, + }); + }).pipe(Effect.provide(layer)); + }), + ), +); + +it.effect("promotes a queued goal by stopping the running turn", () => + Effect.scoped( + Effect.gen(function* () { + const { started, steered, layer, startFirstTurn, send, read } = + yield* goalHarness("goal-promote"); + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const threadId = yield* startFirstTurn; + yield* send("queued", "queued", "queue"); + yield* send("goal", "/goal ship it", "queue"); + const goalRun = (yield* orchestrator.getThreadProjection(threadId)).runs.find( + (run) => run.userMessageId === MessageId.make("message:goal"), + )!; + yield* orchestrator.dispatch({ + type: "queued-message.promote-to-steer", + commandId: CommandId.make("promote"), + threadId, + queuedRunId: goalRun.id, + targetRunId: started[0]!.runId, + }); + yield* worker.drain(); + + assert.deepEqual(steered, []); + assert.deepEqual(yield* read(threadId), { + queue: ["queued"], + started: ["first", "/goal ship it"], + stopRequested: true, + }); + }).pipe(Effect.provide(layer)); + }), + ), +); + +it.effect("stops a turn that is still starting when a goal is sent into it", () => + Effect.scoped( + Effect.gen(function* () { + const { cwd, layer, send, started } = yield* goalHarness("goal-during-start"); + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const threadId = ThreadId.make("thread:goal-during-start"); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("create"), + threadId, + projectId: ProjectId.make("project:goal-during-start"), + title: "Goal during start", + modelSelection: runSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: cwd, + createdBy: "user", + creationSource: "web", + }); + // The stopped run's end starts the queue, which then needs one more drain. + const goalStarting = yield* orchestrator.streamDomainEvents.pipe( + Stream.filter( + (event) => event.type === "run.updated" && event.payload.status === "starting", + ), + Stream.take(1), + Stream.runDrain, + Effect.forkScoped({ startImmediately: true }), + ); + yield* send("first", "first", "auto"); + yield* send("goal", "/goal all tests pass", "auto"); + yield* worker.drain(); + yield* Fiber.join(goalStarting); + yield* worker.drain(); + + const projection = yield* orchestrator.getThreadProjection(threadId); + const statusOf = (text: string) => + projection.runs.find( + (run) => + projection.messages.find((message) => message.id === run.userMessageId)?.text === + text, + )?.status; + assert.equal(statusOf("first"), "interrupted"); + assert.deepEqual( + started.map((turn) => turn.message.text), + ["/goal all tests pass"], + ); + }).pipe(Effect.provide(layer)); + }), + ), +); + +it.effect("stops the running turn when a goal clear ends the goal driving it", () => + Effect.scoped( + Effect.gen(function* () { + const { events, layer, startFirstTurn, send, read, steered } = + yield* goalHarness("goal-clear-active"); + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const threadId = yield* startFirstTurn; + const providerThread = (yield* orchestrator.getThreadProjection(threadId)) + .providerThreads[0]!; + const goalSeen = yield* orchestrator.streamDomainEvents.pipe( + Stream.filter( + (event) => + event.type === "provider-thread.updated" && event.payload.goal?.status === "active", + ), + Stream.take(1), + Stream.runDrain, + Effect.forkScoped({ startImmediately: true }), + ); + yield* Queue.offer(events, { + type: "provider_thread.updated", + driver, + providerThread: { + ...providerThread, + goal: { objective: "all tests pass", status: "active" }, + }, + }); + yield* Fiber.join(goalSeen); + yield* send("clear", "/goal clear", "auto"); + yield* worker.drain(); + + assert.deepEqual(steered, []); + assert.deepEqual(yield* read(threadId), { + queue: [], + started: ["first", "/goal clear"], + stopRequested: true, + }); + }).pipe(Effect.provide(layer)); + }), + ), +); diff --git a/apps/server/src/orchestration-v2/ThreadLifecycleService.ts b/apps/server/src/orchestration-v2/ThreadLifecycleService.ts index 179a39513c..a90e4b8628 100644 --- a/apps/server/src/orchestration-v2/ThreadLifecycleService.ts +++ b/apps/server/src/orchestration-v2/ThreadLifecycleService.ts @@ -88,54 +88,61 @@ const make = Effect.gen(function* () { ); return ThreadLifecycleService.of({ - archive: (input) => - dispatch("archive", input.threadId, { + archive: Effect.fn("ThreadLifecycleService.archive")(function (input) { + return dispatch("archive", input.threadId, { type: "thread.archive", commandId: input.commandId, threadId: input.threadId, - }), - unarchive: (input) => - dispatch("unarchive", input.threadId, { + }); + }), + unarchive: Effect.fn("ThreadLifecycleService.unarchive")(function (input) { + return dispatch("unarchive", input.threadId, { type: "thread.unarchive", commandId: input.commandId, threadId: input.threadId, - }), - delete: (input) => - dispatch("delete", input.threadId, { + }); + }), + delete: Effect.fn("ThreadLifecycleService.delete")(function (input) { + return dispatch("delete", input.threadId, { type: "thread.delete", commandId: input.commandId, threadId: input.threadId, - }), - updateMetadata: (input) => - dispatch("update-metadata", input.threadId, { + }); + }), + updateMetadata: Effect.fn("ThreadLifecycleService.updateMetadata")(function (input) { + return dispatch("update-metadata", input.threadId, { type: "thread.metadata.update", commandId: input.commandId, threadId: input.threadId, ...(input.title === undefined ? {} : { title: input.title }), ...(input.branch === undefined ? {} : { branch: input.branch }), ...(input.worktreePath === undefined ? {} : { worktreePath: input.worktreePath }), - }), - setRuntimeMode: (input) => - dispatch("set-runtime-mode", input.threadId, { + }); + }), + setRuntimeMode: Effect.fn("ThreadLifecycleService.setRuntimeMode")(function (input) { + return dispatch("set-runtime-mode", input.threadId, { type: "thread.runtime-mode.set", commandId: input.commandId, threadId: input.threadId, runtimeMode: input.runtimeMode, - }), - setInteractionMode: (input) => - dispatch("set-interaction-mode", input.threadId, { + }); + }), + setInteractionMode: Effect.fn("ThreadLifecycleService.setInteractionMode")(function (input) { + return dispatch("set-interaction-mode", input.threadId, { type: "thread.interaction-mode.set", commandId: input.commandId, threadId: input.threadId, interactionMode: input.interactionMode, - }), - setModelSelection: (input) => - dispatch("set-model-selection", input.threadId, { + }); + }), + setModelSelection: Effect.fn("ThreadLifecycleService.setModelSelection")(function (input) { + return dispatch("set-model-selection", input.threadId, { type: "thread.model-selection.set", commandId: input.commandId, threadId: input.threadId, modelSelection: input.modelSelection, - }), + }); + }), }); }); diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index e4c8a96910..875f35965d 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -707,15 +707,14 @@ const make = Effect.gen(function* () { cause, }), ), - Effect.flatMap((projection) => - projection.thread.projectId === input.projectId && projection.thread.deletedAt === null - ? Effect.succeed(projection) - : Effect.fail( - new ThreadManagementThreadNotFoundError({ - projectId: input.projectId, - threadId: input.threadId, - }), - ), + Effect.filterOrFail( + (projection) => + projection.thread.projectId === input.projectId && projection.thread.deletedAt === null, + (_projection) => + new ThreadManagementThreadNotFoundError({ + projectId: input.projectId, + threadId: input.threadId, + }), ), ); @@ -734,15 +733,14 @@ const make = Effect.gen(function* () { cause, }), ), - Effect.flatMap((projection) => - projection.thread.projectId === input.projectId && projection.thread.deletedAt === null - ? Effect.succeed(projection) - : Effect.fail( - new ThreadManagementThreadNotFoundError({ - projectId: input.projectId, - threadId: input.threadId, - }), - ), + Effect.filterOrFail( + (projection) => + projection.thread.projectId === input.projectId && projection.thread.deletedAt === null, + (_projection) => + new ThreadManagementThreadNotFoundError({ + projectId: input.projectId, + threadId: input.threadId, + }), ), ); @@ -1206,34 +1204,46 @@ const make = Effect.gen(function* () { Effect.as(orchestrator.searchThreadStream(input)), ), ), - searchThread: (input) => - ensureProjectionTranscript(input.threadId).pipe( + searchThread: Effect.fn("ThreadManagementService.searchThread")(function (input) { + return ensureProjectionTranscript(input.threadId).pipe( Effect.andThen(orchestrator.searchThread(input)), - ), + ); + }), dispatch, - getThreadHistoryPage: (threadId, cursor, throughEntryId, conversationOnly) => - ensureProjectionTranscript(threadId).pipe( - Effect.andThen( - orchestrator.getThreadHistoryPage(threadId, cursor, throughEntryId, conversationOnly), - ), - ), - getTimelinePage: (threadId, options) => - ensureProjectionTranscript(threadId).pipe( - Effect.andThen(orchestrator.getTimelinePage(threadId, options)), - ), - getMessageCount: (threadId) => - ensureProjectionTranscript(threadId).pipe( + getThreadHistoryPage: Effect.fn("ThreadManagementService.getThreadHistoryPage")( + function (threadId, cursor, throughEntryId, conversationOnly) { + return ensureProjectionTranscript(threadId).pipe( + Effect.andThen( + orchestrator.getThreadHistoryPage(threadId, cursor, throughEntryId, conversationOnly), + ), + ); + }, + ), + getTimelinePage: Effect.fn("ThreadManagementService.getTimelinePage")( + function (threadId, options) { + return ensureProjectionTranscript(threadId).pipe( + Effect.andThen(orchestrator.getTimelinePage(threadId, options)), + ); + }, + ), + getMessageCount: Effect.fn("ThreadManagementService.getMessageCount")(function (threadId) { + return ensureProjectionTranscript(threadId).pipe( Effect.andThen(orchestrator.getMessageCount(threadId)), - ), - getTurnItem: (input) => - ensureProjectionTranscript(input.threadId).pipe( + ); + }), + getTurnItem: Effect.fn("ThreadManagementService.getTurnItem")(function (input) { + return ensureProjectionTranscript(input.threadId).pipe( Effect.andThen(orchestrator.getTurnItem(input)), Effect.map((item) => ({ item: item === null ? null : projectTurnItemForDetail(item) })), - ), - getThreadRecords: (threadId, fields, filter) => - ensureProjectionTranscript(threadId).pipe( - Effect.andThen(orchestrator.getThreadRecords(threadId, fields, filter)), - ), + ); + }), + getThreadRecords: Effect.fn("ThreadManagementService.getThreadRecords")( + function (threadId, fields, filter) { + return ensureProjectionTranscript(threadId).pipe( + Effect.andThen(orchestrator.getThreadRecords(threadId, fields, filter)), + ); + }, + ), getThreadProjection, getCheckpointContext, getThreadSnapshot, @@ -1272,7 +1282,9 @@ const layerLegacyV1ThreadImporterNoop = Layer.succeed( LegacyV1ThreadImporter.LegacyV1ThreadImporter.of({ pendingThreadCount: Effect.succeed(0), reconcileShells: Effect.succeed({ importedThreadCount: 0, importedMessageCount: 0 }), - ensureTranscript: () => Effect.succeed({ importedThreadCount: 0, importedMessageCount: 0 }), + ensureTranscript: Effect.fn("LegacyV1ThreadImporter.ensureTranscript")(function () { + return Effect.succeed({ importedThreadCount: 0, importedMessageCount: 0 }); + }), importPendingTranscripts: Effect.succeed({ importedThreadCount: 0, importedMessageCount: 0 }), }), ); diff --git a/apps/server/src/orchestration-v2/ThreadPullRequestService.ts b/apps/server/src/orchestration-v2/ThreadPullRequestService.ts index 06104dd876..bea190e5ca 100644 --- a/apps/server/src/orchestration-v2/ThreadPullRequestService.ts +++ b/apps/server/src/orchestration-v2/ThreadPullRequestService.ts @@ -318,13 +318,13 @@ export const make = Effect.gen(function* () { }); pendingBackfill.delete(thread.id); }).pipe( - Effect.catchCause((cause) => - Cause.hasInterruptsOnly(cause) - ? Effect.failCause(cause) - : Effect.logWarning("thread pull request update failed", { - threadId: thread.id, - cause: Cause.pretty(cause), - }).pipe(Effect.tap(() => Effect.sync(() => failBackfill([thread])))), + Effect.catchCauseIf( + (cause) => !Cause.hasInterruptsOnly(cause), + (cause) => + Effect.logWarning("thread pull request update failed", { + threadId: thread.id, + cause: Cause.pretty(cause), + }).pipe(Effect.tap(() => Effect.sync(() => failBackfill([thread])))), ), ), { discard: true }, diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.ts index cbc04d59fc..2586aeade8 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.ts @@ -352,13 +352,13 @@ export const make = Effect.gen(function* () { }, (effect, thread) => effect.pipe( - Effect.catchCause((cause) => - Cause.hasInterruptsOnly(cause) - ? Effect.failCause(cause) - : Effect.logWarning("automatic thread settlement skipped", { - threadId: thread.id, - cause: Cause.pretty(cause), - }).pipe(Effect.as(null)), + Effect.catchCauseIf( + (cause) => !Cause.hasInterruptsOnly(cause), + (cause) => + Effect.logWarning("automatic thread settlement skipped", { + threadId: thread.id, + cause: Cause.pretty(cause), + }).pipe(Effect.as(null)), ), ), ); @@ -509,13 +509,13 @@ export const make = Effect.gen(function* () { discard: true, }); }).pipe( - Effect.catchCause((cause) => - Cause.hasInterruptsOnly(cause) - ? Effect.failCause(cause) - : Effect.logWarning("automatic thread settlement skipped", { - threadIds: group.map((thread) => thread.id), - cause: Cause.pretty(cause), - }), + Effect.catchCauseIf( + (cause) => !Cause.hasInterruptsOnly(cause), + (cause) => + Effect.logWarning("automatic thread settlement skipped", { + threadIds: group.map((thread) => thread.id), + cause: Cause.pretty(cause), + }), ), ), { concurrency: 8, discard: true }, @@ -527,12 +527,12 @@ export const make = Effect.gen(function* () { threadId?: ThreadId, ) => sweep(mergedPullRequest, threadId).pipe( - Effect.catchCause((cause) => - Cause.hasInterruptsOnly(cause) - ? Effect.failCause(cause) - : Effect.logWarning("automatic thread settlement sweep failed", { - cause: Cause.pretty(cause), - }), + Effect.catchCauseIf( + (cause) => !Cause.hasInterruptsOnly(cause), + (cause) => + Effect.logWarning("automatic thread settlement sweep failed", { + cause: Cause.pretty(cause), + }), ), ); // A thread that just stopped must not wait behind a full sweep or another thread's host @@ -592,13 +592,13 @@ export const make = Effect.gen(function* () { }, (effect, threadId) => effect.pipe( - Effect.catchCause((cause) => - Cause.hasInterruptsOnly(cause) - ? Effect.failCause(cause) - : Effect.logWarning("cleaning up a settled thread failed", { - threadId, - cause: Cause.pretty(cause), - }), + Effect.catchCauseIf( + (cause) => !Cause.hasInterruptsOnly(cause), + (cause) => + Effect.logWarning("cleaning up a settled thread failed", { + threadId, + cause: Cause.pretty(cause), + }), ), ), ); diff --git a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts index 8c0a12ce2f..261978f895 100644 --- a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts +++ b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts @@ -90,15 +90,15 @@ const make = Effect.gen(function* () { thread: OrchestrationV2AppThread, ) { let title: string | undefined; - let history: Array = []; + const history: Array> = []; let forkedFrom = thread.forkedFrom; while (forkedFrom?.type === "run") { const source = yield* messagesThroughRun(forkedFrom.threadId, forkedFrom.runId); - history = [...source.messages, ...history]; + history.push(source.messages); title ??= source.thread.title; forkedFrom = source.thread.forkedFrom; } - const context = formatThreadTitleContext(history); + const context = formatThreadTitleContext(history.reverse().flat()); return title === undefined || context.message.length === 0 ? undefined : { title, history: context.message }; diff --git a/apps/server/src/orchestration-v2/TurnItemPositionStore.ts b/apps/server/src/orchestration-v2/TurnItemPositionStore.ts index 0f498bfadf..ff66fca3ff 100644 --- a/apps/server/src/orchestration-v2/TurnItemPositionStore.ts +++ b/apps/server/src/orchestration-v2/TurnItemPositionStore.ts @@ -103,13 +103,15 @@ export const layer: Layer.Layer - allocate({ + // Per-event normalization shares the enclosing orchestration span. + normalize: Effect.fnUntraced(function* (item, runOrdinal) { + return yield* allocate({ threadId: item.threadId, turnItemId: item.id, runId: item.runId, ...(runOrdinal === undefined ? {} : { runOrdinal }), - }).pipe(Effect.map((ordinal) => (item.ordinal === ordinal ? item : { ...item, ordinal }))), + }).pipe(Effect.map((ordinal) => (item.ordinal === ordinal ? item : { ...item, ordinal }))); + }), }); }), ); diff --git a/apps/server/src/orchestration-v2/testkit/CommandOutputRecorder.ts b/apps/server/src/orchestration-v2/testkit/CommandOutputRecorder.ts index 954c6e34a9..e6fc55d85d 100644 --- a/apps/server/src/orchestration-v2/testkit/CommandOutputRecorder.ts +++ b/apps/server/src/orchestration-v2/testkit/CommandOutputRecorder.ts @@ -34,14 +34,15 @@ export const layer: Layer.Layer< CommandOutputHub.CommandOutputHub, CommandOutputHub.CommandOutputHub.of({ ...hub, - append: (input) => - Effect.sync(() => { + append: Effect.fn("CommandOutputHub.append")(function (input) { + return Effect.sync(() => { const previous = recorded.get(input.itemId); recorded.set(input.itemId, { chunks: (previous?.chunks ?? 0) + 1, output: appendTerminalOutput(previous?.output ?? EMPTY_TERMINAL_OUTPUT, input.chunk), }); - }).pipe(Effect.andThen(hub.append(input))), + }).pipe(Effect.andThen(hub.append(input))); + }), }), ).pipe( Context.add(CommandOutputRecorder, { diff --git a/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts b/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts index edec5a282d..207aa251d4 100644 --- a/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts +++ b/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts @@ -279,19 +279,18 @@ export function runOrchestratorV2Scenario( const capturedShellSnapshots = new Map(); let anonymousBackgroundDispatchIndex = 0; - const awaitDispatch = (key: string) => - Effect.gen(function* () { - const fiber = backgroundDispatches.get(key); - if (!fiber) { - return yield* new OrchestratorV2ScenarioStepError({ - scenario: scenario.name, - step: `await:${key}`, - }); - } - const events = yield* Fiber.join(fiber); - backgroundDispatches.delete(key); - storedEventGroups.push(events); - }); + const awaitDispatch = Effect.fn("awaitDispatch")(function* (key: string) { + const fiber = backgroundDispatches.get(key); + if (!fiber) { + return yield* new OrchestratorV2ScenarioStepError({ + scenario: scenario.name, + step: `await:${key}`, + }); + } + const events = yield* Fiber.join(fiber); + backgroundDispatches.delete(key); + storedEventGroups.push(events); + }); const waitForPendingRuntimeRequest = ( threadId: ThreadId, diff --git a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts index f10fc3d73e..d776951c30 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts +++ b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts @@ -445,7 +445,9 @@ export function layerWithRegistry( const layerThreadTitleRegenerationTest = Layer.succeed( ThreadTitleRegenerationService.ThreadTitleRegenerationService, ThreadTitleRegenerationService.ThreadTitleRegenerationService.of({ - execute: () => Effect.void, + execute: Effect.fn("ThreadTitleRegenerationService.execute")(function () { + return Effect.void; + }), }), ); const layerOrchestratorProvided = Orchestrator.layer.pipe( diff --git a/apps/server/src/persistence/PullRequestFilesViewed.ts b/apps/server/src/persistence/PullRequestFilesViewed.ts index f3f63d9e7b..481009fa03 100644 --- a/apps/server/src/persistence/PullRequestFilesViewed.ts +++ b/apps/server/src/persistence/PullRequestFilesViewed.ts @@ -84,18 +84,19 @@ const make = Effect.gen(function* () { }); return PullRequestFilesViewedRepository.of({ - list: (input) => - listRows(input).pipe( + list: Effect.fn("PullRequestFilesViewedRepository.list")(function (input) { + return listRows(input).pipe( NodeSqliteClient.readOnly, Effect.map((rows) => ({ files: rows.slice(0, MAX_FILES_VIEWED_ROWS), truncated: rows.length > MAX_FILES_VIEWED_ROWS, })), Effect.mapError(toSqlOrDecodeError("listPullRequestFilesViewed", "PullRequestFileViewed")), - ), + ); + }), - set: (input) => - sql + set: Effect.fn("PullRequestFilesViewedRepository.set")(function (input) { + return sql .withTransaction( Effect.forEach( input.files, @@ -141,7 +142,8 @@ const make = Effect.gen(function* () { Effect.mapError( (cause) => new PersistenceSqlError({ operation: "setPullRequestFilesViewed", cause }), ), - ), + ); + }), }); }); diff --git a/apps/server/src/persistence/PullRequestReviewGuides.ts b/apps/server/src/persistence/PullRequestReviewGuides.ts index d6d53cf9ad..abed04b7e8 100644 --- a/apps/server/src/persistence/PullRequestReviewGuides.ts +++ b/apps/server/src/persistence/PullRequestReviewGuides.ts @@ -51,8 +51,8 @@ const make = Effect.gen(function* () { const encodeGuide = Schema.encodeEffect(Schema.fromJsonString(PullRequestReviewGuide)); return PullRequestReviewGuideRepository.of({ - get: (key) => - findGuide(key).pipe( + get: Effect.fn("PullRequestReviewGuideRepository.get")(function (key) { + return findGuide(key).pipe( NodeSqliteClient.readOnly, Effect.map(Option.map((row) => row.guide)), Effect.mapError((cause) => @@ -60,9 +60,10 @@ const make = Effect.gen(function* () { ? PersistenceDecodeError.fromSchemaError("PullRequestReviewGuide", cause) : new PersistenceSqlError({ operation: "getPullRequestReviewGuide", cause }), ), - ), - put: (key, guide) => - encodeGuide(guide).pipe( + ); + }), + put: Effect.fn("PullRequestReviewGuideRepository.put")(function (key, guide) { + return encodeGuide(guide).pipe( Effect.mapError((cause) => PersistenceDecodeError.fromSchemaError("PullRequestReviewGuide", cause), ), @@ -78,7 +79,8 @@ const make = Effect.gen(function* () { ), ), Effect.asVoid, - ), + ); + }), }); }); diff --git a/apps/server/src/persistence/initializeV2Database.ts b/apps/server/src/persistence/initializeV2Database.ts index 7f63bb9c4c..755907b507 100644 --- a/apps/server/src/persistence/initializeV2Database.ts +++ b/apps/server/src/persistence/initializeV2Database.ts @@ -39,13 +39,12 @@ export const initializeV2Database = Effect.fn("initializeV2Database")(function* } }); // Publish only a complete snapshot, without replacing an existing V2 database. - yield* fs - .link(snapshotPath, destinationPath) - .pipe( - Effect.catch((error) => - error.reason._tag === "AlreadyExists" ? Effect.void : Effect.fail(error), - ), - ); + yield* fs.link(snapshotPath, destinationPath).pipe( + Effect.catchIf( + (error) => error.reason._tag === "AlreadyExists", + (_error) => Effect.void, + ), + ); }).pipe( Effect.scoped, Effect.mapError((cause) => new V2DatabaseImportError({ sourcePath, destinationPath, cause })), diff --git a/apps/server/src/preview/DesktopBrowserChannel.ts b/apps/server/src/preview/DesktopBrowserChannel.ts index 0be211e69a..bb40efe89e 100644 --- a/apps/server/src/preview/DesktopBrowserChannel.ts +++ b/apps/server/src/preview/DesktopBrowserChannel.ts @@ -66,13 +66,23 @@ const make = Effect.gen(function* () { if (inputFd === undefined || controlFd === undefined) { return DesktopBrowserChannel.of({ available: false, - awaitAttached: () => Effect.succeed(false), + awaitAttached: Effect.fn("DesktopBrowserChannel.awaitAttached")(function () { + return Effect.succeed(false); + }), detached: Stream.empty, attached: Stream.empty, - isAttached: () => Effect.succeed(false), - endpoint: () => Effect.die("No desktop app is attached to this server."), - pointer: () => Effect.void, - turn: () => Effect.void, + isAttached: Effect.fn("DesktopBrowserChannel.isAttached")(function () { + return Effect.succeed(false); + }), + endpoint: Effect.fn("DesktopBrowserChannel.endpoint")(function () { + return Effect.die("No desktop app is attached to this server."); + }), + turn: Effect.fn("DesktopBrowserChannel.turn")(function () { + return Effect.void; + }), + pointer: Effect.fn("DesktopBrowserChannel.pointer")(function () { + return Effect.void; + }), }); } @@ -185,8 +195,8 @@ const make = Effect.gen(function* () { return DesktopBrowserChannel.of({ available: true, - awaitAttached: (key, timeout) => - Effect.scoped( + awaitAttached: Effect.fn("DesktopBrowserChannel.awaitAttached")(function (key, timeout) { + return Effect.scoped( Effect.gen(function* () { const subscription = yield* PubSub.subscribe(changes); if (attachedTabs.has(keyOf(key))) return true; @@ -198,7 +208,8 @@ const make = Effect.gen(function* () { Effect.map((result) => Option.getOrElse(result, () => false)), ); }), - ), + ); + }), detached: Stream.fromPubSub(changes).pipe( Stream.filter((change) => !change.attached), Stream.map((change) => change.key), @@ -207,10 +218,16 @@ const make = Effect.gen(function* () { Stream.filter((change) => change.attached), Stream.map((change) => change.key), ), - isAttached: (key) => Effect.sync(() => attachedTabs.has(keyOf(key))), + isAttached: Effect.fn("DesktopBrowserChannel.isAttached")(function (key) { + return Effect.sync(() => attachedTabs.has(keyOf(key))); + }), endpoint, - pointer: (key, pointer) => command({ type: "pointer", ...key, ...pointer }), - turn: (threadId, active) => command({ type: "turn", threadId, active }), + turn: Effect.fn("DesktopBrowserChannel.turn")(function (threadId, active) { + return command({ type: "turn", threadId, active }); + }), + pointer: Effect.fn("DesktopBrowserChannel.pointer")(function (key, pointer) { + return command({ type: "pointer", ...key, ...pointer }); + }), }); }); diff --git a/apps/server/src/preview/PreviewAgentActivity.ts b/apps/server/src/preview/PreviewAgentActivity.ts index e3395b2e13..eb81b67d73 100644 --- a/apps/server/src/preview/PreviewAgentActivity.ts +++ b/apps/server/src/preview/PreviewAgentActivity.ts @@ -50,7 +50,9 @@ const make = Effect.gen(function* () { return PreviewAgentActivity.of({ begin, - active: (threadId) => Effect.sync(() => turns.has(threadId)), + active: Effect.fn("PreviewAgentActivity.active")(function (threadId) { + return Effect.sync(() => turns.has(threadId)); + }), changes: PubSub.subscribe(changes), }); }); diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index 9a54b35cd5..e1c6f255f5 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -97,6 +97,8 @@ function makeSession(frame?: ReturnType) { // A snapshot sizes its screenshot from the PNG header; viewers get JPEG frames as-is. return { data: (input as { format: string }).format === "png" ? PNG_HEADER : "ZnJhbWU=" }; } + // Snapshots shrink their screenshot in the page; the header stands in for the result. + if (method === "Runtime.callFunctionOn") return { result: { value: PNG_HEADER } }; if (method === "Page.getFrameTree") return { frameTree: { frame: { id: "main" } } }; if (method === "Page.createIsolatedWorld") { return { diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index 8b20f1efd3..9a600204d3 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -458,6 +458,7 @@ const make = Effect.gen(function* () { const desktopChannel = yield* DesktopBrowserChannel.DesktopBrowserChannel; const agentTurns = yield* (yield* PreviewAgentActivity.PreviewAgentActivity).changes; const runFork = Effect.runForkWith(yield* Effect.context()); + const runPromise = Effect.runPromiseWith(yield* Effect.context()); const launchServices = yield* Effect.context(); const setupCommand = yield* resolveRootCliCommand(PreviewBrowserHost.SETUP_SUBCOMMAND); @@ -472,7 +473,7 @@ const make = Effect.gen(function* () { const contexts = new ServerBrowserContexts({ profilesDir: NodePath.join(config.stateDir, "server-browser", "profiles"), - executable: () => Effect.runPromise(previewBrowser.executable), + executable: () => runPromise(previewBrowser.executable), // Playwright's launch error drops Chrome's own output; its sandbox note survives. diagnose: (executable, cause) => Effect.runPromiseWith(launchServices)( @@ -680,7 +681,7 @@ const make = Effect.gen(function* () { const desktopRenders = (snapshot: PreviewSessionSnapshot) => desktopChannel.available - ? Effect.runPromise( + ? runPromise( desktopChannel.awaitAttached( { threadId: snapshot.threadId, tabId: snapshot.tabId }, DESKTOP_ATTACH_TIMEOUT, @@ -689,9 +690,9 @@ const make = Effect.gen(function* () { : Promise.resolve(false); const connectDesktop = async (snapshot: PreviewSessionSnapshot) => { - const scope = await Effect.runPromise(Scope.make()); + const scope = await runPromise(Scope.make()); try { - const endpoint = await Effect.runPromise( + const endpoint = await runPromise( desktopChannel .endpoint({ threadId: snapshot.threadId, tabId: snapshot.tabId }) .pipe(Scope.provide(scope)), @@ -701,11 +702,11 @@ const make = Effect.gen(function* () { page: connected.page, close: async () => { await connected.browser.close().catch(constVoid); - await Effect.runPromise(Scope.close(scope, Exit.void)); + await runPromise(Scope.close(scope, Exit.void)); }, }; } catch (cause) { - await Effect.runPromise(Scope.close(scope, Exit.void)); + await runPromise(Scope.close(scope, Exit.void)); throw cause; } }; @@ -1187,7 +1188,7 @@ const make = Effect.gen(function* () { const controller = [...opener.viewers].find( (viewer) => viewer.id === opener.control.controller, ); - await Effect.runPromise( + await runPromise( manager.open({ threadId: opener.threadId, ...(/^https?:/i.test(url) ? { url } : {}), @@ -1469,10 +1470,9 @@ const make = Effect.gen(function* () { try { await encoder.evaluate(ServerBrowserPage.recordingEncoderScript(recordingFrameRate)); // Seed idle pages before returning so an immediate stop has a frame to encode. - const firstFrame = await ServerBrowserPage.captureViewport(tab.page, tab.cdp, { + const firstFrame = await ServerBrowserPage.captureViewport(tab.cdp, { format: "jpeg", quality: RECORDING_SCREENCAST.quality, - scale: 1, }); await encoder.evaluate( ([data, width]) => @@ -1524,7 +1524,6 @@ const make = Effect.gen(function* () { return started; }; - // Scaled captures repaint every screencast; pause them to avoid leaking that frame. const withScreencastsPaused = (tab: ServerTab, capture: () => Promise): Promise => withCaptureLock(tab, async () => { tab.capturing += 1; @@ -1680,7 +1679,7 @@ const make = Effect.gen(function* () { const tab = request.tabId === undefined ? latestThreadTab(request.threadId, request.agentSessionId) - : await Effect.runPromise( + : await runPromise( findTab(request.threadId, request.tabId).pipe(Effect.orElseSucceed(constVoid)), ); if (!tab) { @@ -1744,7 +1743,7 @@ const make = Effect.gen(function* () { const found = reuse && request.tabId !== undefined - ? await Effect.runPromise( + ? await runPromise( findTab(request.threadId, request.tabId).pipe( Effect.catchTags({ ServerBrowserTabNotFoundError: () => Effect.void, @@ -1753,7 +1752,7 @@ const make = Effect.gen(function* () { ) : undefined; if (reuse && request.tabIdExplicit && !found) { - const { sessions } = await Effect.runPromise( + const { sessions } = await runPromise( manager.list({ threadId: ThreadId.make(request.threadId) }), ); const desktop = sessions.some( @@ -1780,7 +1779,7 @@ const make = Effect.gen(function* () { const tab = existing ?? (await ensureTab( - await Effect.runPromise( + await runPromise( manager.open({ threadId: request.threadId, ...(url ? { url } : {}), @@ -1811,7 +1810,7 @@ const make = Effect.gen(function* () { } const reveal = open.open ?? open.show; if (reveal !== false) { - await Effect.runPromise( + await runPromise( manager.requestReveal({ threadId: tab.threadId, tabId: tab.tabId, @@ -1875,7 +1874,7 @@ const make = Effect.gen(function* () { if (tab.control.controller !== null) throw new BrowserControlInterrupted("A human controls this tab.", "humanControl"); void tab.control.close().catch(constVoid); - await Effect.runPromise(manager.close({ threadId: tab.threadId, tabId: tab.tabId })); + await runPromise(manager.close({ threadId: tab.threadId, tabId: tab.tabId })); dropTab(tab, false); return {}; } @@ -1942,7 +1941,7 @@ const make = Effect.gen(function* () { } case "resize": { const setting = resolvePreviewViewport(input as PreviewAutomationResizeInput); - await Effect.runPromise( + await runPromise( manager.resize({ threadId: tab.threadId, tabId: tab.tabId, viewport: setting }), ); await applySetting(tab, setting); @@ -1954,7 +1953,7 @@ const make = Effect.gen(function* () { } case "setColorScheme": { const { colorScheme } = input as PreviewAutomationSetColorSchemeInput; - const snapshot = await Effect.runPromise( + const snapshot = await runPromise( manager.adjust({ threadId: tab.threadId, tabId: tab.tabId, colorScheme }), ); await applyRendering(tab, snapshot); @@ -2200,7 +2199,7 @@ const make = Effect.gen(function* () { } case "viewport": { const setting = decodeViewportSetting(message.setting); - await Effect.runPromise( + await runPromise( manager.resize({ threadId: tab.threadId, tabId: tab.tabId, viewport: setting }), ); await applySetting(tab, setting); @@ -2335,14 +2334,9 @@ const make = Effect.gen(function* () { } broadcastControl(tab); pushFileChooser(tab); - // Full scale: a scaled capture would flash in every other viewer. const pushStill = async () => { const data = await withCaptureLock(tab, () => - ServerBrowserPage.captureViewport(tab.page, session, { - format: "jpeg", - quality, - scale: 1, - }), + ServerBrowserPage.captureViewport(session, { format: "jpeg", quality }), ).catch(() => null); if (data) viewer.push({ _tag: "frame", data: Buffer.from(data, "base64"), ack: Effect.void }); }; diff --git a/apps/server/src/preview/ServerBrowserPage.test.ts b/apps/server/src/preview/ServerBrowserPage.test.ts index b419d48e36..31f180bcc9 100644 --- a/apps/server/src/preview/ServerBrowserPage.test.ts +++ b/apps/server/src/preview/ServerBrowserPage.test.ts @@ -245,6 +245,109 @@ describe("server browser element refs", () => { }); }); +describe("server browser snapshot screenshot", () => { + let browser: Browser; + let context: BrowserContext; + + beforeAll(async () => { + // ServerBrowserContexts launches with these, and the capture scale depends on them. + browser = await chromium.launch({ + headless: true, + args: ["--disable-gpu", "--force-device-scale-factor=2"], + }); + }); + afterAll(async () => { + await browser?.close(); + }); + beforeEach(async () => { + context = await browser.newContext({ + viewport: { width: 1280, height: 800 }, + deviceScaleFactor: 2, + }); + }); + afterEach(async () => { + await context.close(); + }); + + // Blue on the left half and red on the right, so a mirrored left half shows up on the right. + const halves = (overlay: string) => ` + +
+
+
`; + + const screenshotOf = async (html: string) => { + const page = await context.newPage(); + await page.setContent(html); + const { screenshot } = await ServerBrowserPage.snapshot({ + page, + cdp: await context.newCDPSession(page), + renderScale: 2, + consoleEntries: [], + networkEntries: [], + actionTimeline: [], + }); + // The server has no DOM types, so the decoder runs as page source. + const points = [ + [0.15, 0.75], + [0.85, 0.25], + [0.85, 0.75], + ]; + const pixels = (await page.evaluate(`(async () => { + const bytes = Uint8Array.from(atob(${JSON.stringify(screenshot.data)}), (char) => char.charCodeAt(0)); + const bitmap = await createImageBitmap(new Blob([bytes], { type: "image/png" })); + const canvas = document.createElement("canvas"); + canvas.width = bitmap.width; + canvas.height = bitmap.height; + const context = canvas.getContext("2d"); + context.drawImage(bitmap, 0, 0); + return ${JSON.stringify(points)}.map(([x, y]) => + Array.from(context.getImageData(x * bitmap.width, y * bitmap.height, 1, 1).data), + ); + })()`)) as Array<[number, number, number, number]>; + return { screenshot, pixels }; + }; + const colorOf = ([red, green, blue]: readonly [number, number, number, number]) => + blue - red > 100 ? "blue" : red - blue > 100 ? "red" : `rgb(${red}, ${green}, ${blue})`; + + it("captures a blurred backdrop-filter page as rendered, not mirrored", async () => { + const { screenshot, pixels } = await screenshotOf(halves("backdrop-filter: blur(4px);")); + + expect({ + size: [screenshot.width, screenshot.height], + colors: pixels.map(colorOf), + }).toEqual({ size: [1280, 800], colors: ["blue", "red", "red"] }); + }); + + it("captures a plain page at the shrunk size with its content in place", async () => { + const { screenshot, pixels } = await screenshotOf(halves("")); + + expect({ + size: [screenshot.width, screenshot.height], + colors: pixels.map(colorOf), + }).toEqual({ size: [1280, 800], colors: ["blue", "red", "red"] }); + }); + + it("shrinks the screenshot of an XML document, whose createElement makes no canvas", async () => { + const page = await context.newPage(); + await page.goto("data:text/xml,
"); + const { screenshot } = await ServerBrowserPage.snapshot({ + page, + cdp: await context.newCDPSession(page), + renderScale: 2, + consoleEntries: [], + networkEntries: [], + actionTimeline: [], + }); + + expect([screenshot.width, screenshot.height]).toEqual([1280, 800]); + }); +}); + describe("server browser drag", () => { it("fails the drag, not the server, when it times out while the cursor is still moving", async () => { const timedOut = new Error("locator.dragTo: Timeout 30000ms exceeded."); diff --git a/apps/server/src/preview/ServerBrowserPage.ts b/apps/server/src/preview/ServerBrowserPage.ts index 2b70c78142..38b55d2676 100644 --- a/apps/server/src/preview/ServerBrowserPage.ts +++ b/apps/server/src/preview/ServerBrowserPage.ts @@ -410,8 +410,6 @@ const pngSize = (base64: string) => { return { width: header.readUInt32BE(16), height: header.readUInt32BE(20) }; }; -type Viewport = { readonly width: number; readonly height: number }; - /** * A page's display ratio and CSS viewport, read in an isolated world since page script can * reassign `devicePixelRatio`, `innerWidth`, and `innerHeight` in its own, and its page zoom. @@ -438,13 +436,7 @@ const readPageMetrics = async (cdp: CDPSession) => { readonly width: number; readonly height: number; }; - return { - ratio, - width, - height, - zoom, - offset: { x: cssVisualViewport.pageX * zoom, y: cssVisualViewport.pageY * zoom }, - }; + return { ratio, width, height, zoom }; } } throw new Error( @@ -452,41 +444,56 @@ const readPageMetrics = async (cdp: CDPSession) => { ); }; -// Scaled captures repaint live screencasts, so callers pause them. Clips use document offsets. +// Never pass a clip scale: Chromium mirrors the top-left of the page into a `backdrop-filter` +// in any capture it shrinks. export const captureViewport = async ( - page: Page, cdp: CDPSession, - options: { - readonly format: "png" | "jpeg"; - readonly quality?: number; - readonly scale: number; - readonly viewport?: Viewport; - readonly offset?: { readonly x: number; readonly y: number } | undefined; - }, + options: { readonly format: "png" | "jpeg"; readonly quality?: number }, ) => { - let clip; - if (options.scale < 1) { - const viewport = options.viewport ?? page.viewportSize() ?? { width: 1280, height: 800 }; - let offset = options.offset; - if (!offset) { - const { cssVisualViewport } = await cdp.send("Page.getLayoutMetrics"); - const zoom = cssVisualViewport.zoom ?? 1; - offset = { x: cssVisualViewport.pageX * zoom, y: cssVisualViewport.pageY * zoom }; - } - clip = { - ...offset, - ...viewport, - scale: options.scale, - }; - } const { data } = await cdp.send("Page.captureScreenshot", { format: options.format, ...(options.quality === undefined ? {} : { quality: options.quality }), - ...(clip ? { clip } : {}), }); return data; }; +/** Runs in the page's own isolated world, as the server has no image library. */ +const SHRINK_PNG = `async function (png, width, height) { + const bytes = Uint8Array.from(atob(png), (char) => char.charCodeAt(0)); + const bitmap = await createImageBitmap(new Blob([bytes], { type: "image/png" })); + // Namespaced, as an XML document's createElement makes a plain element with no getContext. + const canvas = document.createElementNS("http://www.w3.org/1999/xhtml", "canvas"); + canvas.width = width; + canvas.height = height; + const context = canvas.getContext("2d"); + context.imageSmoothingQuality = "high"; + context.drawImage(bitmap, 0, 0, width, height); + bitmap.close(); + return canvas.toDataURL("image/png").slice("data:image/png;base64,".length); +}`; + +const shrinkPng = async (cdp: CDPSession, png: string, scale: number) => { + const size = pngSize(png); + const { frameTree } = await cdp.send("Page.getFrameTree"); + const { executionContextId } = await cdp.send("Page.createIsolatedWorld", { + frameId: frameTree.frame.id, + worldName: "t3-preview-screenshot", + }); + const { result, exceptionDetails } = await cdp.send("Runtime.callFunctionOn", { + functionDeclaration: SHRINK_PNG, + executionContextId, + arguments: [ + { value: png }, + { value: Math.round(size.width * scale) }, + { value: Math.round(size.height * scale) }, + ], + awaitPromise: true, + returnByValue: true, + }); + if (exceptionDetails) throw new Error("The snapshot could not shrink its screenshot."); + return result.value as string; +}; + type SnapshotInput = { readonly page: Page; readonly cdp: CDPSession; @@ -497,17 +504,16 @@ type SnapshotInput = { readonly actionTimeline: PreviewAutomationSnapshot["actionTimeline"]; }; -/** The scale a page renders at and its viewport in device-independent pixels, a clip's units. */ +/** The scale a page renders at and its viewport in device-independent pixels. */ const renderingOf = async (input: SnapshotInput) => { if (input.renderScale !== "display") { const viewport = input.page.viewportSize() ?? { width: 1280, height: 800 }; - return { renderScale: input.renderScale, viewport, offset: undefined }; + return { renderScale: input.renderScale, viewport }; } // The display ratio a desktop page reports includes its page zoom, and its sizes are CSS pixels. const page = await readPageMetrics(input.cdp); return { renderScale: page.ratio / page.zoom, - offset: page.offset, viewport: { width: Math.round(page.width * page.zoom), height: Math.round(page.height * page.zoom), @@ -519,7 +525,7 @@ const capture = async ( input: SnapshotInput, deadline: number, ): Promise => { - const { renderScale, viewport, offset } = await renderingOf(input); + const { renderScale, viewport } = await renderingOf(input); // A late metrics reply must not begin a capture after viewers have resumed. if (Date.now() >= deadline) throw tooBusy(); const scale = Math.min(1, MAX_SCREENSHOT_WIDTH / (viewport.width * renderScale)); @@ -536,7 +542,9 @@ const capture = async ( "url" | "title" | "loading" | "visibleText" | "interactiveElements" > >, - captureViewport(input.page, input.cdp, { format: "png", scale, viewport, offset }), + captureViewport(input.cdp, { format: "png" }).then((png) => + scale < 1 ? shrinkPng(input.cdp, png, scale) : png, + ), ]); void pageAndScreenshot.catch(constVoid); // Leaves time for the second read; a frame that never loads holds the tree until then. diff --git a/apps/server/src/preview/ServerBrowserStream.ts b/apps/server/src/preview/ServerBrowserStream.ts index c06a342c0c..a34282134c 100644 --- a/apps/server/src/preview/ServerBrowserStream.ts +++ b/apps/server/src/preview/ServerBrowserStream.ts @@ -176,12 +176,13 @@ const makeHandler = Effect.fnUntraced(function* (browser: ServerBrowser.ServerBr } }), ); - const receive = (chunk: Uint8Array | string) => { + // Incoming messages share the session span; frame acknowledgements are frequent. + const receive = Effect.fnUntraced(function* (chunk: Uint8Array | string) { const message = parseMessage(chunk); // Ownership serializes actions in the service. Keep reading so dialog // replies and takeover can unblock an action already waiting on the page. if (!isAck(message)) - return canOperate + return yield* canOperate ? viewer.input(message).pipe(Effect.forkScoped, Effect.asVoid) : Effect.void; const ack = inFlight; @@ -189,11 +190,11 @@ const makeHandler = Effect.fnUntraced(function* (browser: ServerBrowser.ServerBr inFlight = null; pending = null; // Forked: Chromium acks are paced and must not hold up input. - return Effect.andThen( + return yield* Effect.andThen( ack ? Effect.forkScoped(ack) : Effect.void, next ? sendFrame(next) : Effect.void, ); - }; + }); const receiveInput = reader.pull.pipe( Effect.flatMap((chunks) => Effect.forEach(chunks, receive, { discard: true })), ); diff --git a/apps/server/src/process/externalLauncher.ts b/apps/server/src/process/externalLauncher.ts index 80bb45fdd4..4dc8a889ac 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts @@ -817,20 +817,29 @@ export const make = Effect.gen(function* () { }).pipe(Effect.uninterruptible); const cachedAvailableEditors = Effect.flatMap(acquireEditorDiscovery, Deferred.await); + // Forwarders retain the underlying launcher and discovery spans. return ExternalLauncher.of({ - resolveAvailableEditors: () => cachedAvailableEditors, - resolveFileManagerRevealKind: () => - provideCommandResolutionServices(resolveFileManagerRevealKind()).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - ), - launchBrowser: (target) => - launchBrowser(target).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - ), - launchEditor: (input) => - provideCommandResolutionServices(launchEditor(input)).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - ), + resolveAvailableEditors: Effect.fnUntraced(function* () { + return yield* cachedAvailableEditors; + }), + resolveFileManagerRevealKind: Effect.fnUntraced( + function* () { + return yield* provideCommandResolutionServices(resolveFileManagerRevealKind()); + }, + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ), + launchBrowser: Effect.fnUntraced( + function* (target) { + return yield* launchBrowser(target); + }, + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ), + launchEditor: Effect.fnUntraced( + function* (input) { + return yield* provideCommandResolutionServices(launchEditor(input)); + }, + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ), }); }); diff --git a/apps/server/src/project/ProjectCloneTracker.ts b/apps/server/src/project/ProjectCloneTracker.ts index bc4a22f74a..ea9fdaaea4 100644 --- a/apps/server/src/project/ProjectCloneTracker.ts +++ b/apps/server/src/project/ProjectCloneTracker.ts @@ -417,11 +417,20 @@ export const make = Effect.gen(function* () { { bufferSize: 1, strategy: "sliding" }, ); + // Forwarders retain the underlying operation spans. return ProjectCloneTracker.of({ - start: (input, hooks) => locked(start(input, hooks)), - cancel: (projectId) => locked(cancel(projectId)), - retry: (projectId) => locked(retry(projectId)), - discard: (projectId) => locked(discard(projectId)), + start: Effect.fnUntraced(function* (input, hooks) { + return yield* locked(start(input, hooks)); + }), + cancel: Effect.fnUntraced(function* (projectId) { + return yield* locked(cancel(projectId)); + }), + retry: Effect.fnUntraced(function* (projectId) { + return yield* locked(retry(projectId)); + }), + discard: Effect.fnUntraced(function* (projectId) { + return yield* locked(discard(projectId)); + }), get, stream, }); diff --git a/apps/server/src/project/ProjectEnrichmentService.test.ts b/apps/server/src/project/ProjectEnrichmentService.test.ts index 9dff765c23..3ec2b07c0d 100644 --- a/apps/server/src/project/ProjectEnrichmentService.test.ts +++ b/apps/server/src/project/ProjectEnrichmentService.test.ts @@ -25,12 +25,16 @@ const waitForAvailable = Effect.fn("ProjectEnrichmentServiceTest.waitForAvailabl workspaceRoot: string, predicate: (value: ProjectEnrichment.ProjectEnrichment) => boolean, ) { - for (let attempt = 0; attempt < 100; attempt += 1) { - const available = yield* service.peek(workspaceRoot); - if (predicate(available)) return available; - yield* Effect.yieldNow; - } - return yield* Effect.die(`Project metadata for ${workspaceRoot} was not resolved in time.`); + return yield* Effect.scoped( + Effect.gen(function* () { + const changes = yield* service.subscribeChanges; + for (;;) { + const available = yield* service.peek(workspaceRoot); + if (predicate(available)) return available; + yield* PubSub.take(changes); + } + }), + ); }); const layer = ( diff --git a/apps/server/src/project/ProjectService.test.ts b/apps/server/src/project/ProjectService.test.ts index 2e087222ed..c6524399ba 100644 --- a/apps/server/src/project/ProjectService.test.ts +++ b/apps/server/src/project/ProjectService.test.ts @@ -1,3 +1,6 @@ +import * as Context from "effect/Context"; +import * as Queue from "effect/Queue"; +import * as Tracer from "effect/Tracer"; import { assert, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { CommandId, type Project, ProjectId, ProviderInstanceId } from "@infinitus/contracts"; @@ -56,6 +59,35 @@ const layerMetadata = Layer.merge( }), ); +class EnrichmentCompletions extends Context.Service>()( + "t3/project/ProjectService.test/EnrichmentCompletions", +) {} + +const layerEnrichmentCompletions = Layer.effectContext( + Effect.gen(function* () { + const completions = yield* Queue.unbounded(); + const tracer = Tracer.make({ + span: (options) => { + const span = new Tracer.NativeSpan(options); + const end = span.end.bind(span); + span.end = (...args) => { + end(...args); + if ( + span.name === "ProjectEnrichmentService.resolveRepositoryIdentity" || + span.name === "ProjectEnrichmentService.resolveFavicon" + ) { + Queue.offerUnsafe(completions, undefined); + } + }; + return span; + }, + }); + return Context.make(EnrichmentCompletions, completions).pipe( + Context.add(Tracer.Tracer, tracer), + ); + }), +); + const layerTestFor = ( projectMetadataLayer: Layer.Layer< | ProjectFaviconResolver.ProjectFaviconResolver @@ -70,6 +102,7 @@ const layerTestFor = ( Layer.provideMerge(ServerSettings.layerTest()), Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "project-service-test-" })), Layer.provide(NodeServices.layer), + Layer.provideMerge(layerEnrichmentCompletions), ); const layerTest = layerTestFor(layerMetadata); @@ -96,12 +129,16 @@ const waitForProject = Effect.fn("ProjectServiceTest.waitForProject")(function* projectId: ProjectId, predicate: (project: Project) => boolean, ) { - for (let attempt = 0; attempt < 100; attempt += 1) { - const project = Option.getOrThrow(yield* service.getById(projectId)); - if (predicate(project)) return project; - yield* Effect.yieldNow; - } - return yield* Effect.die(`Project ${projectId} was not enriched in time.`); + return yield* Effect.scoped( + Effect.gen(function* () { + const completions = yield* EnrichmentCompletions; + for (;;) { + const project = Option.getOrThrow(yield* service.getById(projectId)); + if (predicate(project)) return project; + yield* Queue.take(completions); + } + }), + ); }); it.layer(layerTest)("ProjectService", (it) => { diff --git a/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts b/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts index 547d73690a..602c17a9e5 100644 --- a/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts +++ b/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts @@ -13,6 +13,8 @@ import { vi.mock("@anthropic-ai/claude-agent-sdk", { spy: true }); +const decodeClaudeSettings = Schema.decodeEffect(ClaudeSettings); + it("drops strict MCP mode, and only that, next to an administrator's managed-mcp.json", () => { const options = buildClaudeCapabilitiesProbeQueryOptions({ executablePath: "/usr/bin/claude", @@ -31,7 +33,7 @@ it("drops strict MCP mode, and only that, next to an administrator's managed-mcp it.effect("reports a deleted worktree's scan as finished, not pending", () => Effect.gen(function* () { const snapshot = yield* probeClaudeWorkspaceSnapshot( - yield* Schema.decodeEffect(ClaudeSettings)({}), + yield* decodeClaudeSettings({}), { skills: [], slashCommands: [] } as unknown as ServerProvider, "/nonexistent/t3-deleted-worktree", { HOME: "/nonexistent/t3-home" }, diff --git a/apps/server/src/provider/CodexChatGptHandoff.ts b/apps/server/src/provider/CodexChatGptHandoff.ts index fcd2e20aa3..e55ed2774b 100644 --- a/apps/server/src/provider/CodexChatGptHandoff.ts +++ b/apps/server/src/provider/CodexChatGptHandoff.ts @@ -18,17 +18,25 @@ export function subscribeChatGptHandoff( const bytes = new Map(); yield* Effect.addFinalizer(() => Effect.sync(() => bytes.clear())); const store = ServerSecretStore.ServerSecretStore.of({ - get: (key) => Effect.sync(() => Option.fromUndefinedOr(bytes.get(key))), - set: (key, value) => - Effect.sync(() => { + get: Effect.fn("ServerSecretStore.get")(function (key) { + return Effect.sync(() => Option.fromUndefinedOr(bytes.get(key))); + }), + set: Effect.fn("ServerSecretStore.set")(function (key, value) { + return Effect.sync(() => { bytes.set(key, value); - }), - remove: (key) => - Effect.sync(() => { + }); + }), + remove: Effect.fn("ServerSecretStore.remove")(function (key) { + return Effect.sync(() => { bytes.delete(key); - }), - create: () => Effect.die("Handoff does not create persistent secrets."), - getOrCreateRandom: () => Effect.die("Handoff uses the destination environment identity."), + }); + }), + create: Effect.fn("ServerSecretStore.create")(function () { + return Effect.die("Handoff does not create persistent secrets."); + }), + getOrCreateRandom: Effect.fn("ServerSecretStore.getOrCreateRandom")(function () { + return Effect.die("Handoff uses the destination environment identity."); + }), }); const auth = yield* makeCodexChatGptAuth({ ...endpoints, diff --git a/apps/server/src/provider/ModelManifest.ts b/apps/server/src/provider/ModelManifest.ts index 278d482eeb..4124bb7a9c 100644 --- a/apps/server/src/provider/ModelManifest.ts +++ b/apps/server/src/provider/ModelManifest.ts @@ -267,10 +267,11 @@ export const layerModelCatalog = Layer.effect( Effect.gen(function* () { const manifest = yield* ModelManifest; return ModelCatalog.ModelCatalog.of({ - current: (driverKind) => - manifest.current.pipe( + current: Effect.fn("ModelCatalog.current")(function (driverKind) { + return manifest.current.pipe( Effect.map((data) => resolveProviderCatalog(data, driverKind) ?? undefined), - ), + ); + }), bundled: (driverKind) => resolveProviderCatalog(BUNDLED_MODEL_MANIFEST, driverKind) ?? undefined, refreshInBackground: manifest.refreshInBackground, diff --git a/apps/server/src/provider/ProviderAuthService.ts b/apps/server/src/provider/ProviderAuthService.ts index ed97b3a147..3d427e5a67 100644 --- a/apps/server/src/provider/ProviderAuthService.ts +++ b/apps/server/src/provider/ProviderAuthService.ts @@ -206,8 +206,8 @@ export const makeProviderAuthService = Effect.gen(function* () { }); return yield* auth.reconnectProfile(input.methodId); }), - importProfile: (input) => - credentialChanges.withPermit( + importProfile: Effect.fn("ProviderAuthService.importProfile")(function (input) { + return credentialChanges.withPermit( Effect.gen(function* () { const auth = yield* getController(input.instanceId, "import"); yield* checkSharedBinding(input.instanceId, "start", auth); @@ -222,7 +222,8 @@ export const makeProviderAuthService = Effect.gen(function* () { stopSessions(input.instanceId, auth.credentialBinding), ); }), - ), + ); + }), start: Effect.fn("ProviderAuthService.start")(function* (input, ownerSessionId) { return yield* credentialChanges.withPermit( Effect.gen(function* () { diff --git a/apps/server/src/provider/ProviderHostLive.ts b/apps/server/src/provider/ProviderHostLive.ts index 237da7b8ad..bd8001a8f9 100644 --- a/apps/server/src/provider/ProviderHostLive.ts +++ b/apps/server/src/provider/ProviderHostLive.ts @@ -39,12 +39,15 @@ export const layer = Layer.effect( shouldRunBackgroundWork: backgroundPolicy.shouldRunScopeWork, resolveAttachmentPath: (attachment) => resolveAttachmentPath({ attachmentsDir: config.attachmentsDir, attachment }), - resolveExtractedTextPath: (path) => - DocumentText.resolveExtractedTextPath(path).pipe( - Effect.provideService(FileSystem.FileSystem, fileSystem), - ), - credentials: (namespace, bindingId) => - ProviderCredentialStore.make(namespace, bindingId).pipe( + // DocumentText owns the path-resolution span. + resolveExtractedTextPath: Effect.fnUntraced( + function* (path) { + return yield* DocumentText.resolveExtractedTextPath(path); + }, + Effect.provideService(FileSystem.FileSystem, fileSystem), + ), + credentials: Effect.fn("ProviderHost.credentials")(function (namespace, bindingId) { + return ProviderCredentialStore.make(namespace, bindingId).pipe( Effect.map((store) => ({ binding: store.binding, get: store.get.pipe( @@ -66,7 +69,8 @@ export const layer = Layer.effect( })), Effect.provideService(ServerSecretStore.ServerSecretStore, secrets), Effect.provideService(Crypto.Crypto, crypto), - ), + ); + }), }); }), ); diff --git a/apps/server/src/provider/acp/AntigravityProtocol.ts b/apps/server/src/provider/acp/AntigravityProtocol.ts index a94dbdf213..61222bf2ef 100644 --- a/apps/server/src/provider/acp/AntigravityProtocol.ts +++ b/apps/server/src/provider/acp/AntigravityProtocol.ts @@ -228,6 +228,7 @@ function sanitizeToolValue(value: unknown, budget: ToolPayloadBudget, depth: num export function sanitizeAntigravityToolPayload(payload: unknown): unknown { return sanitizeToolValue(payload, { nodes: 512, text: 64_000 }, 0); } +const isJsonRecord = Schema.is(Schema.Record(Schema.String, Schema.Json)); export function normalizeAntigravitySessionUpdate( notification: EffectAcpSchema.SessionNotification, @@ -256,9 +257,7 @@ export function normalizeAntigravitySessionUpdate( ? { rawOutput: sanitizeAntigravityToolPayload(update.rawOutput) } : {}), ...(update.content !== undefined ? { content: content ?? [] } : {}), - ...(update._meta !== undefined - ? { _meta: Schema.is(Schema.Record(Schema.String, Schema.Json))(meta) ? meta : null } - : {}), + ...(update._meta !== undefined ? { _meta: isJsonRecord(meta) ? meta : null } : {}), }, }; } diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index 533437957a..55d4709d36 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -2954,8 +2954,9 @@ export const make = Effect.gen(function* () { ]); // A replacement reader can join a lookup still finishing its previous reader's cancellation. return Cache.get(listCache, key).pipe( - Effect.catchCause((cause) => - Cause.hasInterruptsOnly(cause) ? Cache.get(listCache, key) : Effect.failCause(cause), + Effect.catchCauseIf( + (cause) => Cause.hasInterruptsOnly(cause), + (_cause) => Cache.get(listCache, key), ), ); }; @@ -3385,8 +3386,9 @@ export const make = Effect.gen(function* () { if (missing.size === 0) return { stats: held }; const key = statsBatchKey(missing.values()); const { result, at } = yield* Cache.get(listStatsCache, key).pipe( - Effect.catchCause((cause) => - Cause.hasInterruptsOnly(cause) ? Cache.get(listStatsCache, key) : Effect.failCause(cause), + Effect.catchCauseIf( + (cause) => Cause.hasInterruptsOnly(cause), + (_cause) => Cache.get(listStatsCache, key), ), ); for (const [key, ref] of missing) { @@ -3621,10 +3623,13 @@ export const make = Effect.gen(function* () { withRoutingCredential, list, filterOptions, - listStats: (input) => - Effect.forEach(input.refs, (ref) => canonicalRef(ref).pipe(Effect.option)).pipe( - Effect.flatMap((refs) => listStats({ ...input, refs: refs.flatMap(Option.toArray) })), - ), + // The local operation owns the span; this wrapper only canonicalizes refs. + listStats: Effect.fnUntraced(function* (input) { + const refs = yield* Effect.forEach(input.refs, (ref) => + canonicalRef(ref).pipe(Effect.option), + ); + return yield* listStats({ ...input, refs: refs.flatMap(Option.toArray) }); + }), summary: credentialCached(summary), stack: credentialCached(stack), subscribeMerges: PubSub.subscribe(mergedPullRequests).pipe( diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index 84a017cb06..a9e74b3975 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -846,7 +846,9 @@ export const make = Effect.gen(function* () { return AgentAwarenessRelay.of({ publishThread, drain: worker.drain, - requestCatchUp: () => Queue.offer(catchUpRequests, undefined).pipe(Effect.asVoid), + requestCatchUp: Effect.fn("AgentAwarenessRelay.requestCatchUp")(function () { + return Queue.offer(catchUpRequests, undefined).pipe(Effect.asVoid); + }), start, }); }); diff --git a/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts b/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts index 38472b5699..16e1dc754a 100644 --- a/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts +++ b/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts @@ -637,16 +637,25 @@ export const make = Effect.fn("resourceTelemetry.desktopTelemetryReceiver.make") health: Ref.get(health), subscribeHealth: subscribeBeforeSnapshotWithoutMutex(healthChanges, Ref.get(health)), setDiagnosticsDemand, - requestDesktopUpdate: (requestId) => - sendControlMessage({ - version: 1, - type: "requestDesktopUpdate", - requestId, - }), - commitDesktopUpdate: (requestId) => - sendControlMessage({ version: 1, type: "commitDesktopUpdate", requestId }), - cancelDesktopUpdate: (requestId) => - sendControlMessage({ version: 1, type: "cancelDesktopUpdate", requestId }), + requestDesktopUpdate: Effect.fn("DesktopTelemetryReceiver.requestDesktopUpdate")( + function (requestId) { + return sendControlMessage({ + version: 1, + type: "requestDesktopUpdate", + requestId, + }); + }, + ), + commitDesktopUpdate: Effect.fn("DesktopTelemetryReceiver.commitDesktopUpdate")( + function (requestId) { + return sendControlMessage({ version: 1, type: "commitDesktopUpdate", requestId }); + }, + ), + cancelDesktopUpdate: Effect.fn("DesktopTelemetryReceiver.cancelDesktopUpdate")( + function (requestId) { + return sendControlMessage({ version: 1, type: "cancelDesktopUpdate", requestId }); + }, + ), desktopUpdates: Effect.gen(function* () { const subscription = yield* PubSub.subscribe(updateReportChanges); const initial = yield* Ref.get(latestUpdateReport); @@ -694,10 +703,18 @@ export const layerTest = ( changes: Stream.empty, })), ), - setDiagnosticsDemand: () => Effect.void, - requestDesktopUpdate: () => Effect.void, - commitDesktopUpdate: () => Effect.void, - cancelDesktopUpdate: () => Effect.void, + setDiagnosticsDemand: Effect.fn("DesktopTelemetryReceiver.setDiagnosticsDemand")(function () { + return Effect.void; + }), + requestDesktopUpdate: Effect.fn("DesktopTelemetryReceiver.requestDesktopUpdate")(function () { + return Effect.void; + }), + commitDesktopUpdate: Effect.fn("DesktopTelemetryReceiver.commitDesktopUpdate")(function () { + return Effect.void; + }), + cancelDesktopUpdate: Effect.fn("DesktopTelemetryReceiver.cancelDesktopUpdate")(function () { + return Effect.void; + }), desktopUpdates: overrides.desktopUpdates ?? Effect.succeed({ diff --git a/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts b/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts index 8af9a0bf94..c4acea920f 100644 --- a/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts +++ b/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts @@ -1088,14 +1088,19 @@ export const layerTest = ( processTree: true, }), snapshots: Stream.empty, - readHistory: () => - Effect.fail( + readHistory: Effect.fn("NativeTelemetryClient.readHistory")(function () { + return Effect.fail( new NativeTelemetryUnavailable({ reason: "No resource monitor history was configured for this test.", }), - ), - setExternalProcesses: () => Effect.void, - setHostPowerState: () => Effect.void, + ); + }), + setExternalProcesses: Effect.fn("NativeTelemetryClient.setExternalProcesses")(function () { + return Effect.void; + }), + setHostPowerState: Effect.fn("NativeTelemetryClient.setHostPowerState")(function () { + return Effect.void; + }), sampleNow: Effect.fail( new NativeTelemetryUnavailable({ reason: "No resource monitor sample was configured for this test.", diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.webhook.test.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.webhook.test.ts index 860c751006..1522be9350 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.webhook.test.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.webhook.test.ts @@ -503,6 +503,20 @@ it.effect.each(queuedDeliveryCases)( ({ reason, apply }) => Effect.gen(function* () { const gate = yield* Deferred.make(); + const deliveryReleased = yield* Deferred.make(); + const tracer = Tracer.make({ + span: (options) => { + const span = new Tracer.NativeSpan(options); + const end = span.end.bind(span); + span.end = (...args) => { + end(...args); + if (span.name === "ScheduledTaskService.releaseFailedDelivery") { + Deferred.doneUnsafe(deliveryReleased, Effect.void); + } + }; + return span; + }, + }); yield* withService( ({ service, launches }) => Effect.gen(function* () { @@ -513,23 +527,17 @@ it.effect.each(queuedDeliveryCases)( yield* apply(service, task.id); yield* Deferred.succeed(gate, undefined); const deliveryId = queued._tag === "accepted" ? queued.deliveryId : undefined; - let delivery = (yield* service.getWebhookDelivery({ + yield* Deferred.await(deliveryReleased); + const delivery = (yield* service.getWebhookDelivery({ id: task.id, deliveryId: deliveryId!, })).delivery; - while (delivery.outcome === "accepted") { - yield* Effect.yieldNow; - delivery = (yield* service.getWebhookDelivery({ - id: task.id, - deliveryId: deliveryId!, - })).delivery; - } assert.equal(delivery.outcome, "dispatch_failed"); assert.equal(delivery.error, reason); assert.equal(yield* Queue.size(launches), 0); }), { gate }, - ); + ).pipe(Effect.withTracer(tracer)); }), ); diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 91bed839c8..c9bd818b78 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -1737,10 +1737,14 @@ it.layer(NodeServices.layer)("server settings", (it) => { runOnWorktreeCreate: false, }; const model = createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.5"); - const modelJson = yield* Schema.encodeEffect(Schema.fromJsonString(ModelSelection))(model); - const scriptsJson = yield* Schema.encodeEffect( + const encodeModelSelection = Schema.encodeEffect(Schema.fromJsonString(ModelSelection)); + + const modelJson = yield* encodeModelSelection(model); + const encodeProjectScripts = Schema.encodeEffect( Schema.fromJsonString(Schema.Array(ProjectScript)), - )([script]); + ); + + const scriptsJson = yield* encodeProjectScripts([script]); for (const [projectId, modelColumn, envMode, autoPull, scripts] of [ [legacyProject, modelJson, "worktree", 1, scriptsJson], [scriptedProject, null, null, 0, scriptsJson], diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts index ac6e95a595..f2270332eb 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts @@ -71,6 +71,7 @@ export class SourceControlProviderRegistry extends Context.Service< function unsupportedProvider( kind: SourceControlProviderKind, ): SourceControlProvider.SourceControlProvider["Service"] { + // Binding context does not create a second provider-operation span. return SourceControlProvider.SourceControlProvider.of({ kind, // Unknown hosts have no readable change requests; return none to avoid poll warnings on every refresh. @@ -195,37 +196,45 @@ function bindProviderContext( return SourceControlProvider.SourceControlProvider.of({ ...provider, - listChangeRequests: (input) => - provider.listChangeRequests({ + listChangeRequests: Effect.fnUntraced(function* (input) { + return yield* provider.listChangeRequests({ ...input, context: input.context ?? context, - }), - getChangeRequest: (input) => - provider.getChangeRequest({ + }); + }), + getChangeRequest: Effect.fnUntraced(function* (input) { + return yield* provider.getChangeRequest({ ...input, context: input.context ?? context, - }), - createChangeRequest: (input) => - provider.createChangeRequest({ + }); + }), + createChangeRequest: Effect.fnUntraced(function* (input) { + return yield* provider.createChangeRequest({ ...input, context: input.context ?? context, - }), - getRepositoryCloneUrls: (input) => - provider.getRepositoryCloneUrls({ + }); + }), + getRepositoryCloneUrls: Effect.fnUntraced(function* (input) { + return yield* provider.getRepositoryCloneUrls({ ...input, context: input.context ?? context, - }), - createRepository: (input) => provider.createRepository(input), - getDefaultBranch: (input) => - provider.getDefaultBranch({ + }); + }), + createRepository: Effect.fnUntraced(function* (input) { + return yield* provider.createRepository(input); + }), + getDefaultBranch: Effect.fnUntraced(function* (input) { + return yield* provider.getDefaultBranch({ ...input, context: input.context ?? context, - }), - checkoutChangeRequest: (input) => - provider.checkoutChangeRequest({ + }); + }), + checkoutChangeRequest: Effect.fnUntraced(function* (input) { + return yield* provider.checkoutChangeRequest({ ...input, context: input.context ?? context, - }), + }); + }), }); } @@ -336,7 +345,9 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit }, get, resolveHandle, - resolve: (input) => resolveHandle(input).pipe(Effect.map((handle) => handle.provider)), + resolve: Effect.fn("SourceControlProviderRegistry.resolve")(function (input) { + return resolveHandle(input).pipe(Effect.map((handle) => handle.provider)); + }), discover: Effect.forEach( discoverySpecs, (spec) => diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.ts index 380ed3bc4a..799dbd758a 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.ts @@ -382,6 +382,7 @@ export const make = Effect.gen(function* () { options, ).pipe( Effect.as(prepared.remoteUrl), + // @effect-diagnostics-next-line catchConditionalRefailToCatchIf:off - The condition also narrows httpsUrl for the fallback closure. Effect.catch((sshError) => httpsUrl === null || !isSshRejection(sshError) ? Effect.fail(sshError) @@ -520,29 +521,39 @@ export const make = Effect.gen(function* () { }, ); + // Forwarders retain the underlying operation spans. return SourceControlRepositoryService.of({ - lookupRepository: (input) => - lookupRepository(input).pipe(mapRepositoryError("lookupRepository", input.provider)), - prepareClone: (input) => - prepareClone(input).pipe( + lookupRepository: Effect.fnUntraced(function* (input) { + return yield* lookupRepository(input).pipe( + mapRepositoryError("lookupRepository", input.provider), + ); + }), + prepareClone: Effect.fnUntraced(function* (input) { + return yield* prepareClone(input).pipe( mapRepositoryError( "cloneRepository", input.provider ?? SourceControlProviderKind.make("unknown"), ), - ), - cloneRepository: (input, options) => - cloneRepository(input, options).pipe( + ); + }), + cloneRepository: Effect.fnUntraced(function* (input, options) { + return yield* cloneRepository(input, options).pipe( mapRepositoryError( "cloneRepository", input.provider ?? SourceControlProviderKind.make("unknown"), ), - ), - discardClone: (destinationPath) => - discardClone(destinationPath).pipe( + ); + }), + discardClone: Effect.fnUntraced(function* (destinationPath) { + return yield* discardClone(destinationPath).pipe( mapRepositoryError("discardClone", SourceControlProviderKind.make("unknown")), - ), - publishRepository: (input) => - publishRepository(input).pipe(mapRepositoryError("publishRepository", input.provider)), + ); + }), + publishRepository: Effect.fnUntraced(function* (input) { + return yield* publishRepository(input).pipe( + mapRepositoryError("publishRepository", input.provider), + ); + }), }); }); diff --git a/apps/server/src/telemetry/AnalyticsService.ts b/apps/server/src/telemetry/AnalyticsService.ts index 71d801b982..88a68f5d4d 100644 --- a/apps/server/src/telemetry/AnalyticsService.ts +++ b/apps/server/src/telemetry/AnalyticsService.ts @@ -95,9 +95,13 @@ export class AnalyticsService extends Context.Service< static readonly layerTest = Layer.succeed( AnalyticsService, AnalyticsService.of({ - record: () => Effect.void, + record: Effect.fn("AnalyticsService.record")(function () { + return Effect.void; + }), flush: Effect.void, - recordOwners: () => Effect.void, + recordOwners: Effect.fn("AnalyticsService.recordOwners")(function () { + return Effect.void; + }), }), ); } diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts index b1ba5d5f3f..94c5a37d7c 100644 --- a/apps/server/src/textGeneration/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -57,24 +57,30 @@ export const make = Effect.gen(function* () { const registry = yield* ProviderInstanceRegistry.ProviderInstanceRegistry; const sourceControl = yield* SourceControlProviderRegistry.SourceControlProviderRegistry; return TextGeneration.of({ - generateCommitMessage: (input) => - resolveInstance(registry, "generateCommitMessage", input.modelSelection.instanceId).pipe( - Effect.flatMap((textGeneration) => textGeneration.generateCommitMessage(input)), - ), - generatePrContent: (input) => - resolveInstance(registry, "generatePrContent", input.modelSelection.instanceId).pipe( + generateCommitMessage: Effect.fn("TextGeneration.generateCommitMessage")(function (input) { + return resolveInstance( + registry, + "generateCommitMessage", + input.modelSelection.instanceId, + ).pipe(Effect.flatMap((textGeneration) => textGeneration.generateCommitMessage(input))); + }), + generatePrContent: Effect.fn("TextGeneration.generatePrContent")(function (input) { + return resolveInstance(registry, "generatePrContent", input.modelSelection.instanceId).pipe( Effect.flatMap((textGeneration) => textGeneration.generatePrContent(input)), - ), - generateBranchName: (input) => - resolveInstance(registry, "generateBranchName", input.modelSelection.instanceId).pipe( + ); + }), + generateBranchName: Effect.fn("TextGeneration.generateBranchName")(function (input) { + return resolveInstance(registry, "generateBranchName", input.modelSelection.instanceId).pipe( Effect.flatMap((textGeneration) => textGeneration.generateBranchName(input)), - ), - generateReviewGuide: (input) => - resolveInstance(registry, "generateReviewGuide", input.modelSelection.instanceId).pipe( + ); + }), + generateReviewGuide: Effect.fn("TextGeneration.generateReviewGuide")(function (input) { + return resolveInstance(registry, "generateReviewGuide", input.modelSelection.instanceId).pipe( Effect.flatMap((textGeneration) => textGeneration.generateReviewGuide(input)), - ), - generateThreadTitle: (input) => - resolveInstance(registry, "generateThreadTitle", input.modelSelection.instanceId).pipe( + ); + }), + generateThreadTitle: Effect.fn("TextGeneration.generateThreadTitle")(function (input) { + return resolveInstance(registry, "generateThreadTitle", input.modelSelection.instanceId).pipe( Effect.flatMap((textGeneration) => Effect.gen(function* () { const linkedContext = @@ -88,7 +94,8 @@ export const make = Effect.gen(function* () { return yield* textGeneration.generateThreadTitle({ ...input, linkedContext }); }), ), - ), + ); + }), }); }); diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index 30db45cd01..4aab315e4c 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -2614,6 +2614,7 @@ describe("UsageService", () => { ); let orphanedAt: number | undefined; + // oxlint-disable-next-line t3code/no-polling-loop-in-tests -- Fixed-count scheduler interruption matrix; each iteration exercises a different interruption point. for (let interruptAt = 1; interruptAt <= 31; interruptAt += 1) { const tasks: Array<() => void> = []; const dispatcher: Scheduler.SchedulerDispatcher = { diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index bd11d64407..dc96eef068 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -1783,9 +1783,11 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { baseRef: initialBranch, }); const branch = preview.sources.find((source) => source.kind === "branch-range")!; + const decodeReviewDiffPreviewInput = Schema.decodeEffect(ReviewDiffPreviewInput); + for (const path of ["renamed.md", "[literal].txt", " leading.txt", "mode-only.sh"]) { const stat = branch.files!.find((file) => file.path === path)!; - const request = yield* Schema.decodeEffect(ReviewDiffPreviewInput)({ + const request = yield* decodeReviewDiffPreviewInput({ cwd, baseRef: initialBranch, file: { path, previousPath: stat.previousPath, sourceKind: "branch-range" }, diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 3719906a8b..bfb924d21c 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -4017,8 +4017,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }, ); return run.pipe( - Effect.flatMap((result) => - result.exitCode !== 0 && isRefLockFailure(result.stderr) ? run : Effect.succeed(result), + Effect.filterOrElse( + (result) => !(result.exitCode !== 0 && isRefLockFailure(result.stderr)), + (_result) => run, ), ); }; @@ -4563,6 +4564,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ), ); + // Forwarders retain the underlying Git operation spans. return GitVcsDriver.GitVcsDriver.of({ execute, status, @@ -4570,11 +4572,15 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* statusDetailsLocal, statusDetailsRemote, prepareCommitContext, - commit: (cwd, subject, body, options) => - withListRefsInvalidation(cwd, commit(cwd, subject, body, options)), - pushCurrentBranch: (cwd, fallbackBranch, options) => - withListRefsInvalidation(cwd, pushCurrentBranch(cwd, fallbackBranch, options)), - pullCurrentBranch: (cwd) => withListRefsInvalidation(cwd, pullCurrentBranch(cwd)), + commit: Effect.fnUntraced(function* (cwd, subject, body, options) { + return yield* withListRefsInvalidation(cwd, commit(cwd, subject, body, options)); + }), + pushCurrentBranch: Effect.fnUntraced(function* (cwd, fallbackBranch, options) { + return yield* withListRefsInvalidation(cwd, pushCurrentBranch(cwd, fallbackBranch, options)); + }), + pullCurrentBranch: Effect.fnUntraced(function* (cwd) { + return yield* withListRefsInvalidation(cwd, pullCurrentBranch(cwd)); + }), readRangeContext, getReviewDiffPreview, getReviewDiffFileContents, @@ -4582,18 +4588,24 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* readBranchBase, resolveBranchName, listRefs, - createWorktree: (input, options) => - withListRefsInvalidation(input.cwd, createWorktree(input, options)), - fetchRefIntoBranch: (input) => withListRefsInvalidation(input.cwd, fetchRefIntoBranch(input)), + createWorktree: Effect.fnUntraced(function* (input, options) { + return yield* withListRefsInvalidation(input.cwd, createWorktree(input, options)); + }), + fetchRefIntoBranch: Effect.fnUntraced(function* (input) { + return yield* withListRefsInvalidation(input.cwd, fetchRefIntoBranch(input)); + }), fetchRefCommit, resolveCommit, - refreshCheckedOutBranch: (input) => - withListRefsInvalidation(input.cwd, refreshCheckedOutBranch(input)), - ensureRemote: (input) => withListRefsInvalidation(input.cwd, ensureRemote(input)), + refreshCheckedOutBranch: Effect.fnUntraced(function* (input) { + return yield* withListRefsInvalidation(input.cwd, refreshCheckedOutBranch(input)); + }), + ensureRemote: Effect.fnUntraced(function* (input) { + return yield* withListRefsInvalidation(input.cwd, ensureRemote(input)); + }), resolvePrimaryRemoteName, resolveDefaultBranchName, - fetchRemote: (input) => - withListRefsInvalidation( + fetchRemote: Effect.fnUntraced(function* (input) { + return yield* withListRefsInvalidation( input.cwd, fetchRemote(input).pipe( Effect.flatMap((fetched) => @@ -4602,23 +4614,42 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* : fetchRemote({ cwd: input.cwd, remoteName: input.remoteName }).pipe(Effect.asVoid), ), ), - ), + ); + }), remoteExists, remoteBranchExists, resolveRemoteTrackingCommit, resolveWorktreeBaseRemote, - fetchWorktreeBaseCommit: (input) => - withListRefsInvalidation(input.cwd, fetchWorktreeBaseCommit(input)), - fetchRemoteBranch: (input) => withListRefsInvalidation(input.cwd, fetchRemoteBranch(input)), - fetchRemoteTrackingBranch: (input) => - withListRefsInvalidation(input.cwd, fetchRemoteTrackingBranch(input)), - setBranchUpstream: (input) => withListRefsInvalidation(input.cwd, setBranchUpstream(input)), - removeWorktree: (input) => withListRefsInvalidation(input.cwd, removeWorktree(input)), - pruneWorktrees: (input) => withListRefsInvalidation(input.cwd, pruneWorktrees(input)), - deleteLocalBranch: (input) => withListRefsInvalidation(input.cwd, deleteLocalBranch(input)), - renameBranch: (input) => withListRefsInvalidation(input.cwd, renameBranch(input)), - createRef: (input) => withListRefsInvalidation(input.cwd, createRef(input)), - switchRef: (input) => withListRefsInvalidation(input.cwd, switchRef(input)), + fetchWorktreeBaseCommit: Effect.fnUntraced(function* (input) { + return yield* withListRefsInvalidation(input.cwd, fetchWorktreeBaseCommit(input)); + }), + fetchRemoteBranch: Effect.fnUntraced(function* (input) { + return yield* withListRefsInvalidation(input.cwd, fetchRemoteBranch(input)); + }), + fetchRemoteTrackingBranch: Effect.fnUntraced(function* (input) { + return yield* withListRefsInvalidation(input.cwd, fetchRemoteTrackingBranch(input)); + }), + setBranchUpstream: Effect.fnUntraced(function* (input) { + return yield* withListRefsInvalidation(input.cwd, setBranchUpstream(input)); + }), + removeWorktree: Effect.fnUntraced(function* (input) { + return yield* withListRefsInvalidation(input.cwd, removeWorktree(input)); + }), + pruneWorktrees: Effect.fnUntraced(function* (input) { + return yield* withListRefsInvalidation(input.cwd, pruneWorktrees(input)); + }), + deleteLocalBranch: Effect.fnUntraced(function* (input) { + return yield* withListRefsInvalidation(input.cwd, deleteLocalBranch(input)); + }), + renameBranch: Effect.fnUntraced(function* (input) { + return yield* withListRefsInvalidation(input.cwd, renameBranch(input)); + }), + createRef: Effect.fnUntraced(function* (input) { + return yield* withListRefsInvalidation(input.cwd, createRef(input)); + }), + switchRef: Effect.fnUntraced(function* (input) { + return yield* withListRefsInvalidation(input.cwd, switchRef(input)); + }), initRepo: initRepoWithListRefsInvalidation, listLocalBranchNames, listWorktreePaths, diff --git a/apps/web/index.html b/apps/web/index.html index 6c01298b62..13518deb6e 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -439,29 +439,234 @@ object-fit: contain; } + /* App CSS locks body and #root scrolling once main loads, so the error screen scrolls itself. */ + #boot-shell:has(> #boot-error) { + height: 100%; + overflow-x: hidden; + overflow-y: auto; + } + #boot-error { - display: grid; - justify-items: center; - gap: 16px; - max-width: 600px; - padding: 24px; - text-align: center; + --boot-error-fg: #262626; + --boot-error-bg: #ffffff; + --boot-error-line: color-mix(in srgb, currentColor 12%, transparent); + display: flex; + flex-direction: column; + align-items: flex-start; + align-self: flex-start; + width: min(400px, calc(100% - 48px)); + padding: clamp(24px, 22vh, 220px) 0 24px; + font-size: 13px; + line-height: 20px; } - #boot-error p { + html.dark #boot-error { + --boot-error-fg: #f5f5f5; + --boot-error-bg: #0a0a0a; + } + + html[data-theme-selected="true"] #boot-error { + --boot-error-fg: var(--boot-foreground); + --boot-error-bg: var(--boot-background); + } + + #boot-error h1 { margin: 0; + font-size: 15px; + font-weight: 600; + line-height: 22px; + } + + #boot-error p { + margin: 4px 0 0; + opacity: 0.7; + overflow-wrap: anywhere; + text-wrap: pretty; + } + + #boot-error code, + #boot-error pre { + font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; + font-size: 12px; overflow-wrap: anywhere; } + #boot-error a { + color: inherit; + text-decoration-line: underline; + text-decoration-color: color-mix(in srgb, currentColor 40%, transparent); + text-underline-offset: 3px; + } + + #boot-error a:hover { + text-decoration-color: currentColor; + } + #boot-error button { - padding: 8px 16px; - border: 1px solid currentColor; - border-radius: 4px; + height: 32px; + padding: 0 14px; + border: 1px solid var(--boot-error-line); + border-radius: 6px; background: transparent; color: inherit; font: inherit; + font-weight: 500; cursor: pointer; } + + #boot-error button[aria-disabled="true"] { + opacity: 0.6; + cursor: default; + } + + #boot-error :is(button, a, summary, pre):focus-visible { + outline: 2px solid var(--boot-error-fg); + outline-offset: 2px; + } + + #boot-error-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 16px; + } + + #boot-error #boot-error-reload { + border-color: var(--boot-error-fg); + background: var(--boot-error-fg); + color: var(--boot-error-bg); + } + + #boot-error-reload:hover { + opacity: 0.9; + } + + #boot-error #boot-error-update-note { + margin-top: 8px; + font-size: 12px; + line-height: 18px; + } + + #boot-error details { + width: 100%; + margin-top: 32px; + } + + #boot-error summary { + display: inline-flex; + align-items: center; + gap: 8px; + border-radius: 4px; + list-style: none; + opacity: 0.7; + cursor: pointer; + } + + #boot-error summary::-webkit-details-marker { + display: none; + } + + #boot-error summary::before { + content: ""; + width: 5px; + height: 5px; + border-right: 1.5px solid currentColor; + border-bottom: 1.5px solid currentColor; + transform: rotate(-45deg); + } + + #boot-error details[open] summary::before { + transform: rotate(45deg); + } + + #boot-error summary:hover, + #boot-error details[open] summary { + opacity: 1; + } + + #boot-error details > :not(summary) { + display: grid; + gap: 8px; + margin-top: 20px; + } + + #boot-error details > a { + display: block; + width: fit-content; + } + + #boot-error details p { + margin: 0; + font-size: 12px; + line-height: 18px; + } + + #boot-error-command { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 4px 4px 10px; + border: 1px solid var(--boot-error-line); + border-radius: 6px; + } + + #boot-error-command code { + flex: 1; + min-width: 0; + opacity: 0.8; + } + + #boot-error .boot-error-icon { + display: grid; + flex: none; + place-items: center; + min-width: 24px; + height: 24px; + padding: 0 4px; + border: 0; + border-radius: 4px; + font-size: 12px; + font-weight: 400; + opacity: 0.7; + } + + #boot-error .boot-error-icon:hover { + opacity: 1; + background: color-mix(in srgb, currentColor 8%, transparent); + } + + #boot-error-details { + max-height: 200px; + margin: 0; + padding: 10px 12px; + overflow: auto; + border: 1px solid var(--boot-error-line); + border-radius: 6px; + font-size: 11px; + line-height: 16px; + white-space: pre-wrap; + opacity: 0.8; + } + + #boot-error-report { + display: flex; + align-items: center; + gap: 16px; + } + + #boot-error-report button { + height: 28px; + padding: 0 10px; + } + + .boot-error-sr { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + } T3 Code (Beta) diff --git a/apps/web/src/appInit.tsx b/apps/web/src/appInit.tsx index 36d988f969..719347c7c1 100644 --- a/apps/web/src/appInit.tsx +++ b/apps/web/src/appInit.tsx @@ -1,21 +1,39 @@ +import { scopedThreadKey, scopeThreadRef } from "@infinitus/client-runtime/environment"; import type { ClientSettings } from "@infinitus/contracts/settings"; import { createBrowserHistory, createHashHistory } from "@tanstack/react-router"; import { applyAppearanceContrast } from "./appearanceContrast"; import { applyAppearanceFontVariables } from "./appearanceFonts"; +import { retainThreadOverlaysForThread } from "./components/chat/threadOverlayRetention"; +import { startBackgroundThreadAnnouncements } from "./components/BackgroundThreadAnnouncer"; +import { startSshPasswordPromptQueue } from "./components/desktop/SshPasswordPromptDialog"; +import { startHostFreezeToasts } from "./components/HostFreezeToastCoordinator"; +import { startLegacyThreadMigrationToast } from "./components/LegacyThreadMigrationToast"; +import { startPermissionUpdateNotices } from "./components/PermissionUpdateNotice"; import { startAgentOpenedPreviews } from "./components/preview/agentOpenedPreviews"; +import { startMainAppLocationTracking } from "./components/sidebar/mainAppLocation"; +import { startDesktopUpdateLaunchNotice } from "./components/sidebar/updateEverythingRun"; +import { startThreadNotifications } from "./components/ThreadNotificationCoordinator"; import { APP_BASE_NAME, APP_DISPLAY_NAME, APP_STAGE_LABEL } from "./branding"; import { resolveServerBackedAppDisplayName } from "./branding.logic"; import { isElectron } from "./env"; -import { getClientSettings, subscribeClientSettings } from "./hooks/useSettings"; +import { useComposerDraftStore } from "./composerDraftStore"; +import { startDefaultThemeAdoption } from "./hooks/useDefaultTheme"; +import { + getClientSettings, + startModelPreferencesMigration, + subscribeClientSettings, +} from "./hooks/useSettings"; +import { startThreadVisitedMigration } from "./hooks/useThreadVisitedMigration"; import { syncDocumentElectronPlatformClasses, syncDocumentWindowControlsOverlayClass, } from "./lib/windowControlsOverlay"; -import { prepareProviderAuthDelivery } from "./providerAuthDelivery"; +import { startProviderAuthDelivery } from "./providerAuthDelivery"; import { getRouter, type AppRouter } from "./router"; import { appAtomRegistry } from "./rpc/atomRegistry"; import { primaryServerConfigAtom } from "./state/server"; +import { resolveThreadRouteTarget } from "./threadRoutes"; /** Writes appearance settings onto the document, touching only the groups that changed. */ function startClientAppearance(): void { @@ -84,13 +102,31 @@ function startDocumentTitle(): void { ); } +/** Chat overlays belong to the thread they were opened in, so leaving its route drops them. */ +function startThreadOverlayRetention(router: AppRouter): void { + router.subscribe("onResolved", () => { + const target = resolveThreadRouteTarget(router.state.matches.at(-1)?.params ?? {}); + const draftSession = + target?.kind === "draft" + ? useComposerDraftStore.getState().getDraftSession(target.draftId) + : null; + const threadRef = + target?.kind === "server" + ? target.threadRef + : draftSession + ? scopeThreadRef(draftSession.environmentId, draftSession.threadId) + : null; + retainThreadOverlaysForThread(threadRef ? scopedThreadKey(threadRef) : null); + }); +} + /** * Starts every app-lifetime service once, before the first render, and returns the router. * They run for the life of the page, so their disposers are dropped here. */ export function startAppServices(): AppRouter { // Ahead of the history: the provider auth code must leave the address before anything reads it. - prepareProviderAuthDelivery(); + startProviderAuthDelivery(); // Electron loads the app from a file-backed shell, so hash history avoids path resolution issues. const router = getRouter(isElectron ? createHashHistory() : createBrowserHistory()); @@ -102,7 +138,20 @@ export function startAppServices(): AppRouter { startClientAppearance(); startDocumentTitle(); + startDefaultThemeAdoption(); + startModelPreferencesMigration(); startAgentOpenedPreviews(); + startThreadOverlayRetention(router); + startMainAppLocationTracking(router); + startBackgroundThreadAnnouncements(appAtomRegistry, router); + startThreadVisitedMigration(appAtomRegistry); + startSshPasswordPromptQueue(); + + startHostFreezeToasts(); + startLegacyThreadMigrationToast(); + startPermissionUpdateNotices(router); + startThreadNotifications(appAtomRegistry, router); + if (isElectron) startDesktopUpdateLaunchNotice(); return router; } diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 3c9d098519..5688307f20 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -39,10 +39,9 @@ import { } from "../panelAnimations"; import { BackgroundThreadAnnouncer } from "./BackgroundThreadAnnouncer"; import LegacyThreadSidebar from "./LegacySidebar"; -import { useThreadVisitedMigration } from "../hooks/useThreadVisitedMigration"; import ThreadSidebar from "./Sidebar"; import { SidebarBrandWidthProbe } from "./sidebar/SidebarChrome"; -import { isAppModalPage, MainAppLocationTracker } from "./sidebar/mainAppLocation"; +import { isAppModalPage } from "./sidebar/mainAppLocation"; import type { SidebarRouteReveal } from "./sidebar/routeReveal"; import { useSidebarStageBackdropVariant } from "./SidebarStageBackdrop"; import { @@ -172,6 +171,28 @@ export function registerNavigationHistoryCommands(router: AnyRouter): () => void ); } +// Escape leaves the pull requests page when no scope (a selection, a dialog, +// a drag) claimed it, so it observes after every other scope. +function registerPullRequestsEscapeCommand(router: AnyRouter): () => void { + return registerCommands( + {}, + { + observe: (event, consumed) => { + if (consumed || event.repeat || event.isComposing || event.key !== "Escape") return; + if (router.state.location.pathname !== "/pull-requests") return; + event.preventDefault(); + const activeElement = document.activeElement; + if (activeElement instanceof HTMLElement) activeElement.blur(); + if (router.state.location.state.__TSR_index !== 0) { + window.history.back(); + return; + } + void router.navigate({ to: "/" }); + }, + }, + ); +} + const MODIFIER_KEYS = new Set(["Alt", "Control", "Meta", "Shift"]); // A held key undoes once. Module state: undoing an archive navigates, and the @@ -252,6 +273,7 @@ function registerAppShellCommands(router: AnyRouter, shellElement: Element): () const disposers = [ registerThreadUndoCommands(router, readSidebarVisible), registerNavigationHistoryCommands(router), + registerPullRequestsEscapeCommand(router), registerMenuAction("open-settings", () => { if (!/^\/settings(\/|$)/.test(router.state.location.pathname)) void router.navigate({ to: "/settings" }); @@ -360,7 +382,6 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { })); const { active: panelAnimationsActive, durationMs: panelAnimationDurationMs } = usePanelAnimationSettings(); - useThreadVisitedMigration(); const pathname = useLocation({ select: (location) => location.pathname }); useHistoryNavigation(pathname); const panelAnimationsSuppressed = usePanelNavigationSuppression(pathname); @@ -431,7 +452,6 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { {children} - ); diff --git a/apps/web/src/components/BackgroundThreadAnnouncer.tsx b/apps/web/src/components/BackgroundThreadAnnouncer.tsx index 78625d29bc..54d351a4ec 100644 --- a/apps/web/src/components/BackgroundThreadAnnouncer.tsx +++ b/apps/web/src/components/BackgroundThreadAnnouncer.tsx @@ -1,14 +1,13 @@ -import { useAtomValue } from "@effect/atom-react"; import { scopeThreadRef, scopedThreadKey } from "@infinitus/client-runtime/environment"; import { effectiveSnoozed } from "@infinitus/client-runtime/state/thread-settled"; -import { useEffect, useRef } from "react"; +import type { AnyRouter } from "@tanstack/react-router"; +import type { AtomRegistry } from "effect/reactivity"; import { useComposerDraftStore } from "../composerDraftStore"; -import { useLiveRegion } from "../hooks/useLiveRegion"; -import { useThreadRouteTarget } from "../hooks/useThreadRouteTarget"; -import { useQuietThreadShells } from "../state/entities"; +import { createLiveRegion } from "../hooks/useLiveRegion"; import { liveShellEnvironmentIdsAtom } from "../state/shell"; -import { resolveActiveThreadRouteRef } from "../threadRoutes"; +import { environmentThreadShells } from "../state/threads"; +import { resolveActiveThreadRouteRef, resolveThreadRouteTarget } from "../threadRoutes"; import { type BackgroundThreadMemory, type BackgroundThreadSnapshot, @@ -16,23 +15,30 @@ import { resolveBackgroundThreadRequest, } from "./backgroundThreadAnnouncement"; -/** - * Speaks status changes for threads other than the open one. It lives outside - * the sidebar because a collapsed sidebar is inert, which silences its rows. - */ -export function BackgroundThreadAnnouncer() { - const threads = useQuietThreadShells(); - const liveEnvironmentIds = useAtomValue(liveShellEnvironmentIdsAtom); - const routeTarget = useThreadRouteTarget(); - const routeDraftThread = useComposerDraftStore((store) => - routeTarget?.kind === "draft" ? store.getDraftSession(routeTarget.draftId) : null, - ); - const openThreadRef = resolveActiveThreadRouteRef(routeTarget, routeDraftThread); - const openThreadKey = openThreadRef === null ? null : scopedThreadKey(openThreadRef); - const { regionRef, announce } = useLiveRegion(); - const memoryRef = useRef(new Map()); +const backgroundThreadLiveRegion = createLiveRegion(); +const attachBackgroundThreadRegion = backgroundThreadLiveRegion.attach; + +function readOpenThreadKey(router: AnyRouter): string | null { + const target = resolveThreadRouteTarget(router.state.matches.at(-1)?.params ?? {}); + const draft = + target?.kind === "draft" + ? useComposerDraftStore.getState().getDraftSession(target.draftId) + : null; + const ref = resolveActiveThreadRouteRef(target, draft); + return ref === null ? null : scopedThreadKey(ref); +} + +/** Speaks status changes for threads other than the open one, for the life of the app. */ +export function startBackgroundThreadAnnouncements( + registry: AtomRegistry.AtomRegistry, + router: AnyRouter, +): () => void { + let memory: BackgroundThreadMemory = new Map(); + let openThreadKey = readOpenThreadKey(router); - useEffect(() => { + const update = () => { + const threads = registry.get(environmentThreadShells.quietThreadShellsAtom); + const liveEnvironmentIds = registry.get(liveShellEnvironmentIdsAtom); const now = new Date().toISOString(); const snapshots: BackgroundThreadSnapshot[] = []; for (const thread of threads) { @@ -50,15 +56,36 @@ export function BackgroundThreadAnnouncer() { snoozed: effectiveSnoozed(thread, { now }), }); } - const { memory, announcement } = nextBackgroundThreadAnnouncement( - memoryRef.current, - snapshots, - openThreadKey, - ); - memoryRef.current = memory; - if (announcement !== null) announce(announcement); - }, [threads, liveEnvironmentIds, openThreadKey, announce]); + const next = nextBackgroundThreadAnnouncement(memory, snapshots, openThreadKey); + memory = next.memory; + if (next.announcement !== null) backgroundThreadLiveRegion.announce(next.announcement); + }; + // The draft store changes on every keystroke; only a new open thread matters here. + const updateOpenThread = () => { + const next = readOpenThreadKey(router); + if (next === openThreadKey) return; + openThreadKey = next; + update(); + }; + + const unsubscribes = [ + registry.subscribe(environmentThreadShells.quietThreadShellsAtom, update), + registry.subscribe(liveShellEnvironmentIdsAtom, update), + router.subscribe("onResolved", updateOpenThread), + useComposerDraftStore.subscribe(updateOpenThread), + ]; + update(); + return () => { + for (const unsubscribe of unsubscribes) unsubscribe(); + }; +} + +/** + * The region the background announcements are spoken through. It lives outside + * the sidebar because a collapsed sidebar is inert, which silences its rows. + */ +export function BackgroundThreadAnnouncer() { // No role="status": Orca 50 maps it to a status bar in Chromium and drops its updates. - return
; + return
; } diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index a2e7fdee1b..8bec326fc8 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -114,7 +114,7 @@ type ThreadStructureRow = ThreadStructure["visibleTurnItems"][number]; export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project"; export const MAX_HIDDEN_MOUNTED_TERMINAL_THREADS = 10; -const ENVIRONMENT_RECONNECT_WARNING_GRACE_MS = 2_000; +export const ENVIRONMENT_RECONNECT_WARNING_GRACE_MS = 2_000; export function agentControlledBrowserCloseConfirmation( surfaces: readonly RightPanelSurface[], @@ -470,18 +470,6 @@ export function resolveDraftPromotionNavigationTarget(input: { return runStarted || startupStopped || messagePersisted ? input.serverThreadRef : null; } -export function scheduleEnvironmentReconnectWarning(showWarning: () => void): () => void { - const timeoutId = globalThis.setTimeout(showWarning, ENVIRONMENT_RECONNECT_WARNING_GRACE_MS); - return () => globalThis.clearTimeout(timeoutId); -} - -export function hasEnvironmentReconnectWarningGraceElapsed( - activeEnvironmentId: EnvironmentId | null, - elapsedEnvironmentId: EnvironmentId | null, -): boolean { - return activeEnvironmentId !== null && activeEnvironmentId === elapsedEnvironmentId; -} - export function startNewThreadForProject( projectRef: ScopedProjectRef | null, handleNewThread: (projectRef: ScopedProjectRef) => Promise, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 36b344b67f..aecdd4b600 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -259,6 +259,7 @@ import { RevertConfirmHost, } from "./chat/ChatOverlayHosts"; import type { AssistantCitationRequest } from "./chat/AssistantCitationSource"; +import { useCitedHistory } from "./chat/citationProcedure"; import type { MessagesTimelineHistoryControls } from "./chat/MessagesTimeline"; import { ThreadTimeline } from "./chat/ThreadTimeline"; import { ProviderSubagentBar } from "./chat/ProviderSubagentBar"; @@ -571,6 +572,7 @@ export default function ChatView(props: ChatViewProps) { ? { citation, key: citationLocation.key ?? citationLocation.href } : null; })() as AssistantCitationRequest | null; + const citationHistory = useCitedHistory(citationRequest); const { resolvedTheme } = useTheme(); const composerRuntimeMode = useComposerDraftStore( (store) => store.getComposerDraft(composerDraftTarget)?.runtimeMode ?? null, @@ -3222,7 +3224,7 @@ export default function ChatView(props: ChatViewProps) { readyMarkdownCwd={gitCwd} readyWorkspaceRoot={activeWorkspaceRoot ?? null} citationRequest={paintOnlyDisplayedTimeline ? null : citationRequest} - citationHistoryLoading={threadDetailLoading} + citationHistory={citationHistory} {...(!paintOnlyDisplayedTimeline ? { onCiteAssistantText: citeAssistantTextStable, diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index 235c8088bf..eabd8673b3 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -809,8 +809,8 @@ export function buildPaletteRemoteSourceReadiness( : { ...entry, hint: entry.hint.replace( - "Open Source Control settings", - "Open Settings -> Source Control", + "Open source control settings", + "Open Settings -> Source control", ), }; }; diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 794713dfc9..02aef65a85 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -1121,7 +1121,7 @@ function OpenCommandPaletteDialog(props: { } /> - {disabledHint ?? "Open Settings -> Source Control to configure this provider."} + {disabledHint ?? "Open Settings -> Source control to configure this provider."} @@ -2178,6 +2178,7 @@ function OpenCommandPaletteDialog(props: { input: { projectId, title: inferProjectTitleFromPath(destinationPath), + // oxlint-disable-next-line react/purity -- runs in an event handler (oxc#27309) createdAt: new Date().toISOString(), remoteUrl: addProjectCloneFlow.remoteUrl, destinationPath, diff --git a/apps/web/src/components/CommandPaletteContent.browser.test.tsx b/apps/web/src/components/CommandPaletteContent.browser.test.tsx new file mode 100644 index 0000000000..abeef942c0 --- /dev/null +++ b/apps/web/src/components/CommandPaletteContent.browser.test.tsx @@ -0,0 +1,47 @@ +import { useState } from "react"; +import { describe, expect, it } from "vite-plus/test"; +import { page, userEvent } from "vite-plus/test/browser"; + +import { render } from "../../test/render"; +import { CommandPaletteContent } from "./CommandPaletteContent"; +import { CommandDialog, CommandDialogPopup, CommandItem, CommandList } from "./ui/command"; + +function SubmenuPalette() { + const [open, setOpen] = useState(false); + return ( + <> + + + + + Back + + ), + }} + > + + {(item: string) => {item}} + + + + + + ); +} + +describe("CommandPaletteContent", () => { + it("focuses the search input, not the Back button, when it opens into a submenu", async () => { + await using _screen = await render(); + + await userEvent.click(page.getByRole("button", { name: "Open" })); + + await expect.element(page.getByPlaceholder("Search projects")).toHaveFocus(); + }); +}); diff --git a/apps/web/src/components/CommandPaletteThreadItems.tsx b/apps/web/src/components/CommandPaletteThreadItems.tsx index aaf8cfdcf2..7a06d789a4 100644 --- a/apps/web/src/components/CommandPaletteThreadItems.tsx +++ b/apps/web/src/components/CommandPaletteThreadItems.tsx @@ -164,6 +164,7 @@ export function useCommandPaletteThreadItems(input: { const isOpen = activeThread?.environmentId === thread.environmentId && activeThread.id === thread.id; if (threadSearchQuery.trim().length > 0 && !threadSearchQuery.startsWith(">") && !isOpen) { + // oxlint-disable-next-line react/purity -- runs in an event handler (oxc#27309) claimThreadOpen(thread.environmentId, thread.id, Date.now()); } if ( diff --git a/apps/web/src/components/ConfirmDialogHost.tsx b/apps/web/src/components/ConfirmDialogHost.tsx index 6fa7602bf6..a00f7206b9 100644 --- a/apps/web/src/components/ConfirmDialogHost.tsx +++ b/apps/web/src/components/ConfirmDialogHost.tsx @@ -1,11 +1,14 @@ +<<<<<<< HEAD import { useEffect, useRef, useSyncExternalStore } from "react"; +======= +import { useSyncExternalStore } from "react"; +>>>>>>> upstream-sync-cc0b86ddf-upstream-renamed import { completeConfirmDialogClose, readConfirmDialogState, - registerConfirmDialogHost, respondToConfirmDialog, - subscribeConfirmDialog, + subscribeConfirmDialogHost, } from "../confirmDialog"; import { AlertDialog, @@ -53,13 +56,11 @@ function resolveConfirmDialogCopy(message: string): ConfirmationCopy { export function ConfirmDialogHost() { const state = useSyncExternalStore( - subscribeConfirmDialog, + subscribeConfirmDialogHost, readConfirmDialogState, readConfirmDialogState, ); - useEffect(() => registerConfirmDialogHost(), []); - const copy = resolveConfirmDialogCopy(state.status === "idle" ? "" : state.message); const confirmVariant = state.status === "idle" ? "default" : state.variant; const onCancel = () => respondToConfirmDialog(false); diff --git a/apps/web/src/components/CustomSnoozeDialog.tsx b/apps/web/src/components/CustomSnoozeDialog.tsx index 8e8e0918f6..fbd7daa66d 100644 --- a/apps/web/src/components/CustomSnoozeDialog.tsx +++ b/apps/web/src/components/CustomSnoozeDialog.tsx @@ -10,6 +10,7 @@ import { Button } from "./ui/button"; import { CalendarIcon } from "lucide-react"; import { Calendar } from "./ui/calendar"; import { weekStartsOn } from "../timestampFormat"; +import { useNowMinute } from "../hooks/useNowMinute"; import { Popover, PopoverTrigger, PopoverPopup } from "./ui/popover"; import { Input } from "./ui/input"; import { Label } from "./ui/label"; @@ -47,6 +48,12 @@ function finish(choice: SnoozeChoice | null) { request?.resolve(choice); } +function startOfLocalDay(ms: number) { + const day = new Date(ms); + day.setHours(0, 0, 0, 0); + return day; +} + export function CustomSnoozeDialogHost() { const request = useRequest((state) => state.request); useEffect(() => () => finish(null), []); @@ -56,6 +63,9 @@ export function CustomSnoozeDialogHost() { function CustomSnoozeDialog() { const id = useId(); const [initial] = useState(() => new Date(Date.now() + 3_600_000)); + const nowMinute = useNowMinute(); + // Follows the clock so a dialog left open past midnight stops offering yesterday. + const today = startOfLocalDay(Date.parse(`${nowMinute}Z`)); const [mode, setMode] = useState("date"); const [date, setDate] = useState(initial); const [calendarOpen, setCalendarOpen] = useState(false); @@ -137,7 +147,7 @@ function CustomSnoozeDialog() { selected={date} defaultMonth={date} {...(weekStartsOn === undefined ? {} : { weekStartsOn })} - disabled={{ before: new Date(new Date().setHours(0, 0, 0, 0)) }} + disabled={{ before: today }} onSelect={(selected) => { setDate(selected); setCalendarOpen(false); diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 15f86945b9..8195eb223a 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -175,7 +175,9 @@ function DiffFileHeaderSuffix({ }) { return ( <> - + + + {hasStat ? ( onRetry(filePath)} /> ) : null} @@ -661,7 +663,7 @@ export default function DiffPanel({ const headerRow = ( <> -
+
} @@ -717,7 +719,9 @@ export default function DiffPanel({ + // Too narrow for both refs, the New design keeps the target readable and drops + // the head, which is the thread's own branch. + } > @@ -729,6 +733,18 @@ export default function DiffPanel({ {`${selectedGitSource.headRef ?? "HEAD"} → ${selectedGitSource.baseRef}`} + + + } + > + vs + + + {`${selectedGitSource.headRef ?? "HEAD"} → ${selectedGitSource.baseRef}`} + + } - className="min-w-9 max-w-48 shrink" + className="min-w-9 max-w-48 shrink redesign:shrink-0" aria-label={`Change comparison target. Currently ${selectedGitSource.baseRef}`} > {selectedGitSource.baseRef} diff --git a/apps/web/src/components/DiffWorkerPoolProvider.tsx b/apps/web/src/components/DiffWorkerPoolProvider.tsx index e311886086..a9e89b62d1 100644 --- a/apps/web/src/components/DiffWorkerPoolProvider.tsx +++ b/apps/web/src/components/DiffWorkerPoolProvider.tsx @@ -3,13 +3,14 @@ import { WorkerPoolManager } from "@pierre/diffs/worker"; import DiffsWorker from "@pierre/diffs/worker/worker.js?worker"; import * as Schema from "effect/Schema"; import { useEffect, useState, useSyncExternalStore, type ReactNode } from "react"; +import { subscribeClientSettings } from "../hooks/useSettings"; import { getRenderedAppearance, subscribeRenderedAppearance } from "../hooks/useTheme"; import { resolveDiffThemeName } from "../lib/diffRendering"; import { PREFERRED_HIGHLIGHTER } from "../lib/syntaxHighlighting"; export class DiffWorkerError extends Schema.TaggedError()("DiffWorkerError", { operation: Schema.Literals(["create-worker", "get-render-options", "set-render-options"]), - themeName: Schema.Literals(["pierre-light", "pierre-dark"]), + themeName: Schema.Literals(["pierre-light", "pierre-dark", "t3-light"]), cause: Schema.Defect(), }) { override get message(): string { @@ -72,7 +73,12 @@ function createDiffWorkerPool(poolSize: number) { ); return { pool, - unsubscribeTheme: subscribeRenderedAppearance(() => applyDiffWorkerTheme(pool)), + unsubscribeTheme: (() => { + const apply = () => applyDiffWorkerTheme(pool); + // The New design picks the light syntax palette, so a settings change can switch it too. + const unsubscribes = [subscribeRenderedAppearance(apply), subscribeClientSettings(apply)]; + return () => unsubscribes.forEach((unsubscribe) => unsubscribe()); + })(), consumers: 0, idleTimer: undefined, }; diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 7181388352..32ae18a123 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -205,7 +205,7 @@ function getPublishProviderReadiness(input: { if (!discovered) { return { ready: false, - hint: "Provider status unavailable. Open Settings -> Source Control and rescan.", + hint: "Provider status unavailable. Open Settings -> Source control and rescan.", }; } if (discovered.status !== "available") { @@ -216,7 +216,7 @@ function getPublishProviderReadiness(input: { ready: false, hint: Option.getOrNull(discovered.auth.detail) ?? - `${discovered.label} is not authenticated. Open Settings -> Source Control for setup guidance.`, + `${discovered.label} is not authenticated. Open Settings -> Source control for setup guidance.`, }; } return { ready: true, hint: null }; @@ -673,7 +673,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { /> {readiness.hint ?? - "Open Settings -> Source Control to configure this provider."} + "Open Settings -> Source control to configure this provider."}
@@ -1153,11 +1153,11 @@ export default function GitActionsControl({ : null; const gitActionProgress = resolveGitActionProgressPresentation(vcsActionState); const redesign = useClientSettings((s) => s.redesignEnabled); - // A fresh draft with nothing to commit, push, or diff hides those rows until there is work. + // A fresh draft or a thread with no turns hides commit, push, and diff rows until there is work. const quietEmptyDraft = redesign && isPanel && - draftId !== undefined && + (draftId !== undefined || activeServerThreadShell?.latestRun === null) && gitActionProgress === null && !gitStatusError && quickAction.disabled && diff --git a/apps/web/src/components/HostFreezeToastCoordinator.ts b/apps/web/src/components/HostFreezeToastCoordinator.ts new file mode 100644 index 0000000000..366688c4dc --- /dev/null +++ b/apps/web/src/components/HostFreezeToastCoordinator.ts @@ -0,0 +1,33 @@ +import { hostFreezeNotice } from "@infinitus/client-runtime/connection"; + +import { appAtomRegistry } from "../rpc/atomRegistry"; +import { environmentSummaries } from "../state/presentation"; +import { environmentServerConfigsAtom } from "../state/server"; +import { toastManager } from "./ui/toast"; + +/** Tells the user why a host dropped, once per stall, when it reports one on reconnect. */ +export function startHostFreezeToasts(): () => void { + const shown = new Set(); + return appAtomRegistry.subscribe( + environmentServerConfigsAtom, + (configs) => { + for (const [environmentId, config] of configs) { + const stall = config.lastEventLoopStall; + if (stall === undefined) continue; + const key = `${environmentId}:${stall.detectedAt}`; + if (shown.has(key)) continue; + shown.add(key); + const label = + appAtomRegistry + .get(environmentSummaries.identitiesAtom) + .find((identity) => identity.environmentId === environmentId)?.label ?? + config.environment.label; + const notice = hostFreezeNotice(label, stall); + if (notice !== null) { + toastManager.add({ type: "warning", ...notice }); + } + } + }, + { immediate: true }, + ); +} diff --git a/apps/web/src/components/HostFreezeToastCoordinator.tsx b/apps/web/src/components/HostFreezeToastCoordinator.tsx deleted file mode 100644 index b5dde5f14f..0000000000 --- a/apps/web/src/components/HostFreezeToastCoordinator.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { useAtomValue } from "@effect/atom-react"; -import { hostFreezeNotice } from "@infinitus/client-runtime/connection"; -import { useEffect, useRef } from "react"; - -import { environmentSummaries } from "../state/presentation"; -import { environmentServerConfigsAtom } from "../state/server"; -import { toastManager } from "./ui/toast"; - -/** Tells the user why a host dropped, once per stall, when it reports one on reconnect. */ -export function HostFreezeToastCoordinator() { - const configs = useAtomValue(environmentServerConfigsAtom); - const identities = useAtomValue(environmentSummaries.identitiesAtom); - const shownRef = useRef(new Set()); - - useEffect(() => { - for (const [environmentId, config] of configs) { - const stall = config.lastEventLoopStall; - if (stall === undefined) continue; - const key = `${environmentId}:${stall.detectedAt}`; - if (shownRef.current.has(key)) continue; - shownRef.current.add(key); - const label = - identities.find((identity) => identity.environmentId === environmentId)?.label ?? - config.environment.label; - const notice = hostFreezeNotice(label, stall); - if (notice !== null) { - toastManager.add({ type: "warning", ...notice }); - } - } - }, [configs, identities]); - - return null; -} diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index 43e03a8ed7..3ef3617e0b 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -24,7 +24,15 @@ import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; import { ProjectFavicon } from "./ProjectFavicon"; import { useAtomValue } from "@effect/atom-react"; import { autoAnimate } from "@formkit/auto-animate"; -import React, { useEffect, useEffectEvent, memo, useRef, useState } from "react"; +import React, { + createContext, + useEffect, + useEffectEvent, + memo, + useContext, + useRef, + useState, +} from "react"; import { cn } from "~/lib/utils"; import { useShallow } from "zustand/react/shallow"; import { @@ -199,7 +207,8 @@ import { shouldClearThreadSelectionOnMouseDown, sortProjectsForSidebar, useSidebarRowSubscriptionLease, - useThreadJumpHintVisibility, + setFileDragActive, + useThreadJumpHintsVisible, ThreadStatusPill, } from "./Sidebar.logic"; import { sortThreads } from "../lib/threadSort"; @@ -227,11 +236,7 @@ import { } from "../sidebarProjectGrouping"; import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons"; import { useThreadRouteTarget } from "../hooks/useThreadRouteTarget"; -import { - completeSidebarRouteReveal, - pendingSidebarRouteReveal, - type SidebarRouteReveal, -} from "./sidebar/routeReveal"; +import { type SidebarRouteReveal, useActiveRowReveal } from "./sidebar/routeReveal"; const SIDEBAR_SORT_LABELS: Record = { updated_at: "Last user message", created_at: "Created at", @@ -383,6 +388,11 @@ function checkTaskPermission(environmentId: EnvironmentId): boolean { return false; } +// The active row reports itself, so a route change scrolls to it once it mounts. +const RevealActiveRowContext = createContext<(row: HTMLElement | null, threadKey: string) => void>( + () => {}, +); + const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowProps) { const { orderedProjectThreadKeys, @@ -415,12 +425,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP const canOperatePreview = useEnvironmentScope(thread.environmentId, AuthPreviewOperateScope); const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); const [isFileDragOver, setIsFileDragOver] = useState(false); - useEffect(() => { - if (!isFileDragOver) return; - const clearFileDrag = () => setIsFileDragOver(false); - window.addEventListener("dragend", clearFileDrag); - return () => window.removeEventListener("dragend", clearFileDrag); - }, [isFileDragOver]); + const revealActiveRow = useContext(RevealActiveRowContext); const { leaseLiveStatus, rowRef } = useSidebarRowSubscriptionLease(isActive); const localLastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); const lastVisitedAt = resolveThreadLastVisitedAt(thread.lastVisitedAt, localLastVisitedAt); @@ -466,7 +471,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP // Hooks must precede this ref's plain uses for the compiler to cache it. const threadRef = scopeThreadRef(thread.environmentId, thread.id); const fileDropHandlers = makeWorkspaceFileDropHandlers({ - setDragActive: setIsFileDragOver, + setDragActive: (active) => setFileDragActive(setIsFileDragOver, active), addFiles: (files) => { onFileDropThreads(threadRef, files); }, @@ -669,6 +674,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP onBlurCapture={handleBlurCapture} >
revealActiveRow(node, threadKey) : undefined} role="button" tabIndex={0} data-active={isActive} @@ -3259,6 +3265,13 @@ function useLegacySidebarThreads(input: { }; } +function clearThreadSelectionOnOutsideMouseDown(event: globalThis.MouseEvent) { + const selection = useThreadSelectionStore.getState(); + if (!selection.hasSelection()) return; + const target = event.target instanceof HTMLElement ? event.target : null; + if (shouldClearThreadSelectionOnMouseDown(target)) selection.clearSelection(); +} + export default function LegacySidebar({ routeReveal }: { routeReveal: SidebarRouteReveal }) { const reorderProjects = useUiStateStore((store) => store.reorderProjects); const navigate = useNavigate(); @@ -3283,7 +3296,6 @@ export default function LegacySidebar({ routeReveal }: { routeReveal: SidebarRou const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState< ReadonlySet >(() => new Set()); - const { showThreadJumpHints, updateThreadJumpHintsVisibility } = useThreadJumpHintVisibility(); const dragInProgressRef = useRef(false); const suppressProjectClickAfterDragRef = useRef(false); const suppressProjectClickForContextMenuRef = useRef(false); @@ -3323,25 +3335,7 @@ export default function LegacySidebar({ routeReveal }: { routeReveal: SidebarRou sidebarThreadPreviewCount, expandedThreadListsByProject, }); - // History and the command palette can open a thread scrolled out of view, - // and a remount starts the list at the top, so each route change and each - // mount scroll to the open thread once. Its row can mount after the route - // does, once its environment's shells arrive, so the reveal waits for it. - const mountRevealOwed = useRef(true); - const routeRowRendered = - routeThreadKey !== null && visibleSidebarThreadKeys.includes(routeThreadKey); - const revealRouteRow = useStableCallback(() => { - const pendingKey = pendingSidebarRouteReveal(routeReveal, routeThreadKey); - if (pendingKey === null && (!mountRevealOwed.current || routeThreadKey === null)) return; - const activeRow = document.querySelector( - '[data-sidebar="content"] [data-thread-item] [data-active="true"]', - ); - if (!activeRow) return; - completeSidebarRouteReveal(routeReveal); - mountRevealOwed.current = false; - activeRow.scrollIntoView({ block: "nearest" }); - }); - useEffect(revealRouteRow, [revealRouteRow, routeThreadKey, routeRowRendered]); + const revealActiveRow = useActiveRowReveal(routeReveal, routeThreadKey); const newThreadShortcutLabelOptions = { platform, context: { @@ -3438,15 +3432,12 @@ export default function LegacySidebar({ routeReveal }: { routeReveal: SidebarRou context: sidebarShortcutContext, }, ); + const showThreadJumpHints = useThreadJumpHintsVisible(shouldShowThreadJumpHintsNow); const visibleThreadJumpLabelByKey = showThreadJumpHints ? threadJumpLabelByKey : EMPTY_THREAD_JUMP_LABELS; const orderedSidebarThreadKeys = visibleSidebarThreadKeys; - useEffect(() => { - updateThreadJumpHintsVisibility(shouldShowThreadJumpHintsNow); - }, [shouldShowThreadJumpHintsNow, updateThreadJumpHintsVisibility]); - const onWindowKeyDown = useEffectEvent((event: globalThis.KeyboardEvent) => { const shortcutContext = { terminalFocus: isTerminalFocused(), @@ -3508,20 +3499,11 @@ export default function LegacySidebar({ routeReveal }: { routeReveal: SidebarRou window.removeEventListener("keydown", handleWindowKeyDown); }; }, []); - + // A window listener whose lifetime is the sidebar's: no element owns outside clicks. useEffect(() => { - const onMouseDown = (event: globalThis.MouseEvent) => { - if (!useThreadSelectionStore.getState().hasSelection()) return; - const target = event.target instanceof HTMLElement ? event.target : null; - if (!shouldClearThreadSelectionOnMouseDown(target)) return; - clearSelection(); - }; - - window.addEventListener("mousedown", onMouseDown); - return () => { - window.removeEventListener("mousedown", onMouseDown); - }; - }, [clearSelection]); + window.addEventListener("mousedown", clearThreadSelectionOnOutsideMouseDown); + return () => window.removeEventListener("mousedown", clearThreadSelectionOnOutsideMouseDown); + }, []); const desktopUpdateButtonDisabled = isDesktopUpdateButtonDisabled(desktopUpdateState); const desktopUpdateButtonAction = desktopUpdateState @@ -3647,7 +3629,7 @@ export default function LegacySidebar({ routeReveal }: { routeReveal: SidebarRou }; return ( - <> + {prewarmedSidebarThreadRefs.map((threadRef) => ( ))} @@ -3692,7 +3674,7 @@ export default function LegacySidebar({ routeReveal }: { routeReveal: SidebarRou attachProjectListAutoAnimateRef={attachProjectListAutoAnimateRef} projectsLength={projectsLength} /> - - + + ); } diff --git a/apps/web/src/components/LegacyThreadMigrationToast.ts b/apps/web/src/components/LegacyThreadMigrationToast.ts new file mode 100644 index 0000000000..3e1e1f7e43 --- /dev/null +++ b/apps/web/src/components/LegacyThreadMigrationToast.ts @@ -0,0 +1,39 @@ +import { appAtomRegistry } from "../rpc/atomRegistry"; +import { primaryServerLegacyThreadMigrationAtom } from "../state/server"; +import { toastManager } from "./ui/toast"; + +type MigrationToastId = ReturnType; + +/** A loading toast for as long as the primary server reports a legacy thread migration running. */ +export function startLegacyThreadMigrationToast(): () => void { + let toastId: MigrationToastId | null = null; + + const unsubscribe = appAtomRegistry.subscribe( + primaryServerLegacyThreadMigrationAtom, + (migration) => { + if (migration?.status === "running") { + if (toastId !== null) return; + toastId = toastManager.add({ + type: "loading", + title: "Restoring your threads…", + description: `Migrating ${migration.totalThreadCount.toLocaleString()} ${ + migration.totalThreadCount === 1 ? "thread" : "threads" + } from the previous version. You can keep working while this finishes.`, + timeout: 0, + }); + return; + } + + if (toastId !== null) { + toastManager.close(toastId); + toastId = null; + } + }, + { immediate: true }, + ); + + return () => { + unsubscribe(); + if (toastId !== null) toastManager.close(toastId); + }; +} diff --git a/apps/web/src/components/LegacyThreadMigrationToast.tsx b/apps/web/src/components/LegacyThreadMigrationToast.tsx deleted file mode 100644 index 720e11c429..0000000000 --- a/apps/web/src/components/LegacyThreadMigrationToast.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { useAtomValue } from "@effect/atom-react"; -import { useEffect, useRef } from "react"; - -import { primaryServerLegacyThreadMigrationAtom } from "../state/server"; -import { toastManager } from "./ui/toast"; - -type MigrationToastId = ReturnType; - -export function LegacyThreadMigrationToast() { - const migration = useAtomValue(primaryServerLegacyThreadMigrationAtom); - const toastIdRef = useRef(null); - - useEffect(() => { - if (migration?.status === "running") { - if (toastIdRef.current !== null) { - return; - } - toastIdRef.current = toastManager.add({ - type: "loading", - title: "Restoring your threads…", - description: `Migrating ${migration.totalThreadCount.toLocaleString()} ${ - migration.totalThreadCount === 1 ? "thread" : "threads" - } from the previous version. You can keep working while this finishes.`, - timeout: 0, - }); - return; - } - - if (toastIdRef.current !== null) { - toastManager.close(toastIdRef.current); - toastIdRef.current = null; - } - }, [migration]); - - useEffect( - () => () => { - if (toastIdRef.current !== null) { - toastManager.close(toastIdRef.current); - } - }, - [], - ); - - return null; -} diff --git a/apps/web/src/components/PermissionUpdateNotice.ts b/apps/web/src/components/PermissionUpdateNotice.ts new file mode 100644 index 0000000000..e68d2ef039 --- /dev/null +++ b/apps/web/src/components/PermissionUpdateNotice.ts @@ -0,0 +1,83 @@ +import { sessionHasLegacyPermissions, type EnvironmentId } from "@infinitus/contracts"; +import type { AnyRouter } from "@tanstack/react-router"; +import * as Schema from "effect/Schema"; +import { Atom } from "effect/reactivity"; + +import { getLocalStorageItem, setLocalStorageItem } from "../hooks/useLocalStorage"; +import { appAtomRegistry } from "../rpc/atomRegistry"; +import { environmentSummaries } from "../state/presentation"; +import { environmentSession } from "../state/session"; +import { toastManager } from "./ui/toast"; + +interface LegacyPermissionEnvironment { + readonly environmentId: EnvironmentId; + readonly label: string; +} + +/** Environments whose settled session still carries pre-scope permissions. */ +const legacyPermissionEnvironmentsAtom = Atom.make((get) => + get(environmentSummaries.identitiesAtom).filter(({ environmentId }) => { + const session = get(environmentSession.sessionStateAtom(environmentId)); + return ( + session._tag === "Success" && !session.waiting && sessionHasLegacyPermissions(session.value) + ); + }), +).pipe(Atom.withLabel("web-legacy-permission-environments")); + +const dismissalKey = (environmentId: EnvironmentId) => + `t3code:permission-update:v1:${environmentId}`; + +function showPermissionNotice( + router: AnyRouter, + { environmentId, label }: LegacyPermissionEnvironment, +) { + const key = dismissalKey(environmentId); + const persistDismissal = () => { + try { + setLocalStorageItem(key, true, Schema.Boolean); + } catch {} + }; + const dismiss = () => { + persistDismissal(); + toastManager.close(id); + }; + const id = toastManager.add({ + title: `Permissions have changed for ${label}`, + description: + "This connection still uses the old permissions, so some actions may no longer be available. Pair again using a new link with the permissions you need.", + timeout: 0, + onClose: persistDismissal, + actionProps: { + children: "Open Connections", + onClick: () => { + dismiss(); + void router.navigate({ to: "/settings/connections" }); + }, + }, + data: { + actionLayout: "stacked-end", + secondaryActionProps: { children: "Dismiss", onClick: dismiss }, + secondaryActionVariant: "ghost", + }, + }); +} + +/** Asks once per environment, until dismissed, to re-pair a connection that predates scoped permissions. */ +export function startPermissionUpdateNotices(router: AnyRouter): () => void { + const shown = new Set(); + return appAtomRegistry.subscribe( + legacyPermissionEnvironmentsAtom, + (environments) => { + for (const environment of environments) { + if (shown.has(environment.environmentId)) continue; + try { + if (getLocalStorageItem(dismissalKey(environment.environmentId), Schema.Boolean)) + continue; + } catch {} + shown.add(environment.environmentId); + showPermissionNotice(router, environment); + } + }, + { immediate: true }, + ); +} diff --git a/apps/web/src/components/PermissionUpdateNotice.tsx b/apps/web/src/components/PermissionUpdateNotice.tsx deleted file mode 100644 index 38c57c0164..0000000000 --- a/apps/web/src/components/PermissionUpdateNotice.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { useAtomValue } from "@effect/atom-react"; -import { sessionHasLegacyPermissions, type EnvironmentId } from "@infinitus/contracts"; -import { useNavigate } from "@tanstack/react-router"; -import * as Schema from "effect/Schema"; -import { useEffect } from "react"; - -import { getLocalStorageItem, setLocalStorageItem } from "../hooks/useLocalStorage"; -import { useEnvironments } from "../state/environments"; -import { environmentSession } from "../state/session"; -import { toastManager } from "./ui/toast"; - -// Keep an active toast across remounts, including Strict Mode effect replay. -const shown = new Set(); - -function EnvironmentPermissionNotice({ - environmentId, - label, -}: { - environmentId: EnvironmentId; - label: string; -}) { - const session = useAtomValue(environmentSession.sessionStateAtom(environmentId)); - const navigate = useNavigate(); - useEffect(() => { - if ( - session._tag !== "Success" || - session.waiting || - !sessionHasLegacyPermissions(session.value) - ) - return; - if (shown.has(environmentId)) return; - const key = `t3code:permission-update:v1:${environmentId}`; - try { - if (getLocalStorageItem(key, Schema.Boolean)) return; - } catch {} - shown.add(environmentId); - const persistDismissal = () => { - try { - setLocalStorageItem(key, true, Schema.Boolean); - } catch {} - }; - const dismiss = () => { - persistDismissal(); - toastManager.close(id); - }; - const id = toastManager.add({ - title: `Permissions have changed for ${label}`, - description: - "This connection still uses the old permissions, so some actions may no longer be available. Pair again using a new link with the permissions you need.", - timeout: 0, - onClose: persistDismissal, - actionProps: { - children: "Open Connections", - onClick: () => { - dismiss(); - void navigate({ to: "/settings/connections" }); - }, - }, - data: { - actionLayout: "stacked-end", - secondaryActionProps: { children: "Dismiss", onClick: dismiss }, - secondaryActionVariant: "ghost", - }, - }); - }, [environmentId, label, navigate, session]); - return null; -} - -export function PermissionUpdateNotice() { - const { environments } = useEnvironments(); - return environments.map(({ environmentId, label }) => ( - - )); -} diff --git a/apps/web/src/components/QuitHoldOverlay.tsx b/apps/web/src/components/QuitHoldOverlay.tsx index 25d6a745de..819455b68f 100644 --- a/apps/web/src/components/QuitHoldOverlay.tsx +++ b/apps/web/src/components/QuitHoldOverlay.tsx @@ -1,35 +1,55 @@ -import { useEffect, useState } from "react"; +import { useSyncExternalStore } from "react"; import { isMacPlatform } from "../lib/utils"; const HOLD_HINT_LINGER_MS = 1200; +type QuitHintMode = "hold" | "double-click"; + +let visibleMode: QuitHintMode | null = null; +let pressedMode: QuitHintMode = "hold"; +let hideTimer: number | undefined; +const listeners = new Set<() => void>(); +let stopBridge: (() => void) | null = null; + +function setVisibleMode(next: QuitHintMode | null) { + if (next === visibleMode) return; + visibleMode = next; + for (const listener of listeners) listener(); +} + +function subscribeQuitHint(listener: () => void): () => void { + listeners.add(listener); + if (listeners.size === 1) { + stopBridge = + window.desktopBridge?.onQuitShortcut?.((hint) => { + window.clearTimeout(hideTimer); + if (hint.state === "down") { + pressedMode = hint.mode; + setVisibleMode(hint.mode); + return; + } + if (pressedMode === "double-click") { + setVisibleMode(null); + return; + } + hideTimer = window.setTimeout(() => setVisibleMode(null), HOLD_HINT_LINGER_MS); + }) ?? null; + } + return () => { + listeners.delete(listener); + if (listeners.size > 0) return; + window.clearTimeout(hideTimer); + stopBridge?.(); + stopBridge = null; + visibleMode = null; + }; +} + +const readQuitHint = () => visibleMode; + export function QuitHoldOverlay() { - const [visibleMode, setVisibleMode] = useState<"hold" | "double-click" | null>(null); - - useEffect(() => { - const subscribe = window.desktopBridge?.onQuitShortcut; - if (!subscribe) return; - let hideTimer: number | undefined; - let pressedMode: "hold" | "double-click" = "hold"; - const unsubscribe = subscribe((hint) => { - window.clearTimeout(hideTimer); - if (hint.state === "down") { - pressedMode = hint.mode; - setVisibleMode(hint.mode); - return; - } - if (pressedMode === "double-click") { - setVisibleMode(null); - return; - } - hideTimer = window.setTimeout(() => setVisibleMode(null), HOLD_HINT_LINGER_MS); - }); - return () => { - window.clearTimeout(hideTimer); - unsubscribe(); - }; - }, []); + const visibleMode = useSyncExternalStore(subscribeQuitHint, readQuitHint, readQuitHint); if (!visibleMode) return null; const shortcut = isMacPlatform(navigator.platform) ? "⌘Q" : "Ctrl+Q"; diff --git a/apps/web/src/components/Sidebar.logic.browser.test.tsx b/apps/web/src/components/Sidebar.logic.browser.test.tsx new file mode 100644 index 0000000000..c7c5e6bcb2 --- /dev/null +++ b/apps/web/src/components/Sidebar.logic.browser.test.tsx @@ -0,0 +1,33 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +import { render } from "../../test/render"; +import { useSnoozeWakeClock } from "./Sidebar.logic"; + +const MOUNTED_AT_MS = Date.parse("2026-10-24T08:00:00.000Z"); + +function WakeClock(props: { readonly threads: ReadonlyArray<{ readonly snoozedUntil: string }> }) { + return {useSnoozeWakeClock(props.threads)}; +} + +describe("useSnoozeWakeClock", () => { + it("settles in one step when many distinct wakes passed during a sleep", async () => { + using now = vi.spyOn(Date, "now").mockReturnValue(MOUNTED_AT_MS); + // More distinct wakes than React's 25 render-phase re-render limit. + const threads = Array.from({ length: 40 }, (_, index) => ({ + snoozedUntil: new Date(MOUNTED_AT_MS + 1 + index * 60_000).toISOString(), + })); + await using screen = await render(, { + // A bare hook host; nothing here links or navigates. + router: false, + }); + await expect.element(screen.getByRole("status")).toHaveTextContent(String(MOUNTED_AT_MS)); + + // The machine resumes after every wake has passed; the first wake's timer fires late. + const resumedAtMs = MOUNTED_AT_MS + 24 * 60 * 60_000; + now.mockReturnValue(resumedAtMs); + + // The clock lands on the last elapsed wake, so every woken thread has left the shelf. + const lastWakeMs = MOUNTED_AT_MS + 1 + 39 * 60_000; + await expect.element(screen.getByRole("status")).toHaveTextContent(String(lastWakeMs)); + }); +}); diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index e353404be3..a0ad110ce1 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -50,7 +50,11 @@ import { type Thread, } from "../types"; import { makeThreadFixture, type ThreadFixtureOverrides } from "../test-fixtures"; -import { completeSidebarRouteReveal, pendingSidebarRouteReveal } from "./sidebar/routeReveal"; +import { + completeSidebarRouteReveal, + pendingSidebarRouteReveal, + resolveSidebarRouteReveal, +} from "./sidebar/routeReveal"; const localEnvironmentId = EnvironmentId.make("environment-local"); @@ -1341,3 +1345,63 @@ describe("pendingSidebarRouteReveal", () => { expect(pendingSidebarRouteReveal(reveal, "env:a1")).toBe("env:a1"); }); }); + +describe("resolveSidebarRouteReveal", () => { + const scopedProjectKeys = new Set(["environment-test:project-scoped"]); + const hidden = makeThreadFixture({ projectId: ProjectId.make("project-other") }); + const hiddenKey = "environment-test:thread-test"; + const findThread = (key: string) => (key === hiddenKey ? hidden : undefined); + + it("widens the scope for a newly opened thread the scope hides", () => { + expect( + resolveSidebarRouteReveal({ + reveal: { observedKey: "environment-test:other", pendingKey: null }, + routeKey: hiddenKey, + isSearching: false, + scopedProjectKeys, + findThread, + }), + ).toEqual({ pendingKey: hiddenKey, widensScope: true }); + }); + + it("never widens for a thread whose reveal already completed", () => { + // A remount's once-per-mount scroll must keep the scope the user chose. + expect( + resolveSidebarRouteReveal({ + reveal: { observedKey: hiddenKey, pendingKey: null }, + routeKey: hiddenKey, + isSearching: false, + scopedProjectKeys, + findThread, + }), + ).toEqual({ pendingKey: null, widensScope: false }); + }); + + it("widens again for a thread reopened before the thread between them listed its row", () => { + // The user scoped away from the open thread, then went to a thread whose row + // never mounted, then came back. + const reveal = { observedKey: hiddenKey, pendingKey: null }; + const resolve = (routeKey: string) => + resolveSidebarRouteReveal({ + reveal, + routeKey, + isSearching: false, + scopedProjectKeys, + findThread, + }); + resolve("environment-test:unlisted"); + expect(resolve(hiddenKey)).toEqual({ pendingKey: hiddenKey, widensScope: true }); + }); + + it("holds a pending reveal without widening while searching", () => { + expect( + resolveSidebarRouteReveal({ + reveal: { observedKey: hiddenKey, pendingKey: hiddenKey }, + routeKey: hiddenKey, + isSearching: true, + scopedProjectKeys, + findThread, + }), + ).toEqual({ pendingKey: hiddenKey, widensScope: false }); + }); +}); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index a8c1947c13..321ba83444 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -38,6 +38,8 @@ import type { SidebarThreadSummary, Thread } from "../types"; import { normalizeSearchText } from "../lib/utils"; import { isLatestRunSettled } from "../session-logic"; import { observeIntersection } from "../lib/observeIntersection"; +import { deadlineAfter, deadlineStore } from "@infinitus/client-runtime/clock"; +import { useDeadlinePassed } from "../hooks/useDeadline"; export function shouldNavigateAfterThreadPark(input: { readonly threadKey: string; @@ -96,6 +98,61 @@ export function useSidebarRowSubscriptionLease(isActive: boolean): { }; } +const fileDragClears = new Set<(active: false) => void>(); +function clearFileDrags() { + const clears = [...fileDragClears]; + fileDragClears.clear(); + for (const clear of clears) clear(false); +} + +/** + * A file drag that ends outside every drop target (Escape, or a drop elsewhere) + * sends no dragleave to the row it last entered, so the window's dragend clears it. + */ +export function setFileDragActive(setActive: (active: boolean) => void, active: boolean) { + setActive(active); + if (typeof window === "undefined") return; + if (!active) { + if (fileDragClears.delete(setActive) && fileDragClears.size === 0) { + window.removeEventListener("dragend", clearFileDrags); + } + return; + } + if (fileDragClears.size === 0) window.addEventListener("dragend", clearFileDrags, { once: true }); + fileDragClears.add(setActive); +} + +/** + * A clock that advances exactly when a snooze wake time passes, so a woken + * thread leaves the shelf on time without a per-second clock. + */ +export function useSnoozeWakeClock( + threads: ReadonlyArray<{ readonly snoozedUntil?: string | null | undefined }>, +): number { + const [clockMs, setClockMs] = React.useState(() => Date.now()); + const wakeTimes: number[] = []; + let nextWakeAt: number | null = null; + for (const thread of threads) { + const wakeAt = thread.snoozedUntil == null ? Number.NaN : Date.parse(thread.snoozedUntil); + if (!(wakeAt > clockMs)) continue; + wakeTimes.push(wakeAt); + if (nextWakeAt === null || wakeAt < nextWakeAt) nextWakeAt = wakeAt; + } + const woke = useDeadlinePassed(nextWakeAt); + // Advancing to the latest wake that has passed skips every wake that elapsed + // meanwhile, e.g. across a laptop sleep. Stepping one wake at a time would + // re-render once per elapsed wake and hit React's limit. + if (woke && nextWakeAt !== null) { + let nextClockMs = nextWakeAt; + for (const wakeAt of wakeTimes) { + if (wakeAt > nextClockMs && deadlineStore(wakeAt).getSnapshot()) nextClockMs = wakeAt; + } + setClockMs(nextClockMs); + return nextClockMs; + } + return clockMs; +} + export function useRetainedValue(key: string | null, value: T | null): T | null { const [retained, setRetained] = React.useState<{ readonly key: string; @@ -614,89 +671,16 @@ type ThreadStatusInput = Pick< blockedOnThreadWait?: boolean | undefined; }; -export interface ThreadJumpHintVisibilityController { - sync: (shouldShow: boolean) => void; - dispose: () => void; -} - -function createThreadJumpHintVisibilityController(input: { - delayMs: number; - onVisibilityChange: (visible: boolean) => void; - setTimeoutFn?: typeof globalThis.setTimeout; - clearTimeoutFn?: typeof globalThis.clearTimeout; -}): ThreadJumpHintVisibilityController { - const setTimeoutFn = input.setTimeoutFn ?? globalThis.setTimeout; - const clearTimeoutFn = input.clearTimeoutFn ?? globalThis.clearTimeout; - let isVisible = false; - let timeoutId: NodeJS.Timeout | null = null; - - const clearPendingShow = () => { - if (timeoutId === null) { - return; - } - clearTimeoutFn(timeoutId); - timeoutId = null; - }; - - return { - sync: (shouldShow) => { - if (!shouldShow) { - clearPendingShow(); - if (isVisible) { - isVisible = false; - input.onVisibilityChange(false); - } - return; - } - - if (isVisible || timeoutId !== null) { - return; - } - - timeoutId = setTimeoutFn(() => { - timeoutId = null; - isVisible = true; - input.onVisibilityChange(true); - }, input.delayMs); - }, - dispose: () => { - clearPendingShow(); - }, - }; -} - -export function useThreadJumpHintVisibility(): { - showThreadJumpHints: boolean; - updateThreadJumpHintsVisibility: (shouldShow: boolean) => void; -} { - const [showThreadJumpHints, setShowThreadJumpHints] = React.useState(false); - const controllerRef = React.useRef(null); - - React.useEffect(() => { - const controller = createThreadJumpHintVisibilityController({ - delayMs: THREAD_JUMP_HINT_SHOW_DELAY_MS, - onVisibilityChange: (visible) => { - setShowThreadJumpHints(visible); - }, - setTimeoutFn: window.setTimeout.bind(window), - clearTimeoutFn: window.clearTimeout.bind(window), - }); - controllerRef.current = controller; - - return () => { - controller.dispose(); - controllerRef.current = null; - }; - }, []); - - const updateThreadJumpHintsVisibility = (shouldShow: boolean) => { - controllerRef.current?.sync(shouldShow); - }; - - return { - showThreadJumpHints, - updateThreadJumpHintsVisibility, - }; +/** + * Thread jump hints appear once the jump modifier has been held for a moment, + * so a quick shortcut never flashes them, and hide as soon as it is released. + */ +export function useThreadJumpHintsVisible(shouldShow: boolean): boolean { + const [showAt, setShowAt] = React.useState(null); + let nextShowAt = shouldShow ? showAt : null; + if (shouldShow && showAt === null) nextShowAt = deadlineAfter(THREAD_JUMP_HINT_SHOW_DELAY_MS); + if (nextShowAt !== showAt) setShowAt(nextShowAt); + return useDeadlinePassed(nextShowAt); } export function resolveThreadLastVisitedAt( diff --git a/apps/web/src/components/Sidebar.pointer.ts b/apps/web/src/components/Sidebar.pointer.ts index 282e8728a5..5a0ce373c0 100644 --- a/apps/web/src/components/Sidebar.pointer.ts +++ b/apps/web/src/components/Sidebar.pointer.ts @@ -1,13 +1,7 @@ -import { useLayoutEffect, type PointerEvent as ReactPointerEvent } from "react"; +import { type PointerEvent as ReactPointerEvent } from "react"; import { type SensorProps } from "@dnd-kit/core"; import { getOwnerDocument, getWindow } from "@dnd-kit/utilities"; -// Search unmounts the drag context while its owning Sidebar remains mounted. -export function SidebarDragLifecycle({ onUnmount }: { onUnmount: () => void }) { - useLayoutEffect(() => onUnmount, [onUnmount]); - return null; -} - type Options = { distance: number; onAttach: (sensor: SidebarPointerSensor) => void; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index d6663ca22a..9ab5e05bdf 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -59,7 +59,7 @@ import { } from "@infinitus/contracts"; import type { OlderSettledScope } from "@infinitus/client-runtime/state/threads"; -import { FolderIcon, ListFilterIcon, PlusIcon, SettingsIcon } from "lucide-react"; +import { ChevronDownIcon, FolderIcon, ListFilterIcon, PlusIcon, SettingsIcon } from "lucide-react"; import { useEffect, useLayoutEffect, @@ -71,7 +71,7 @@ import { type MouseEvent as ReactMouseEvent, type ReactNode, } from "react"; -import { useRouter } from "@tanstack/react-router"; +import { type AnyRouter, useRouter } from "@tanstack/react-router"; import { isAtomCommandInterrupted, @@ -138,11 +138,15 @@ import { threadEnvironment } from "../state/threads"; import { useOlderSettledShelf, useOlderSettledShelfGrowth } from "../state/olderSettledShelf"; import { useThreadSearch } from "../state/queries"; import { useOrchestrationCommand } from "../state/use-orchestration-command"; -import { readEnvironmentScope } from "../state/session"; +import { readEnvironmentScope, useEnvironmentScope } from "../state/session"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { canShareWithGuestAtom } from "./chat/guestShareAvailability"; import { openGuestShareDialog } from "./chat/guestShareDialogState"; -import { buildThreadRouteParams, resolveActiveThreadRouteRef } from "../threadRoutes"; +import { + buildThreadRouteParams, + resolveActiveThreadRouteRef, + resolveThreadRouteTarget, +} from "../threadRoutes"; import type { SidebarThreadSummary } from "../types"; import { isScratchProject } from "@infinitus/client-runtime/state/projects"; import { cn } from "~/lib/utils"; @@ -179,7 +183,8 @@ import { sortPinnedThreadsForSidebar, sortThreadsForSidebar, sortWorkingThreadsBySend, - useThreadJumpHintVisibility, + useSnoozeWakeClock, + useThreadJumpHintsVisible, type SidebarListItem, type SidebarSection, resolveSidebarRowThreadId, @@ -189,16 +194,16 @@ import { createSidebarSortingStrategy, restrictBelowSidebarLabel, } from "./Sidebar.drag"; -import { SidebarDragLifecycle, SidebarPointerSensor } from "./Sidebar.pointer"; +import { SidebarPointerSensor } from "./Sidebar.pointer"; import { createSidebarListMotion } from "./Sidebar.motion"; import { resolveSnoozePresets, snoozeWakeLabel, type SnoozePreset } from "./Sidebar.snooze"; import { ProjectFavicon } from "./ProjectFavicon"; import { deriveProviderEntriesByEnvironment } from "../providerInstances"; import { useStableCallback } from "../hooks/useStableCallback"; import { - completeSidebarRouteReveal, - pendingSidebarRouteReveal, + resolveSidebarRouteReveal, type SidebarRouteReveal, + useActiveRowReveal, } from "./sidebar/routeReveal"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { Button } from "./ui/button"; @@ -367,24 +372,17 @@ function useSidebarThreads(input: { readonly scopedProjectKeys: ReadonlySet | null; readonly serverConfigs: ReadonlyMap; readonly optimisticDrop: SidebarOptimisticDrop | null; - readonly routeThreadRef: ScopedThreadRef | null; + readonly threads: readonly EnvironmentThreadShell[]; + readonly routeRowThreadKey: string | null; }) { - const { projectScopeKey, scopedProjectKeys, serverConfigs, optimisticDrop, routeThreadRef } = - input; - const threads = useQuietThreadShells(); - const routeRowThreadKey = - routeThreadRef === null - ? null - : scopedThreadKey( - scopeThreadRef( - routeThreadRef.environmentId, - resolveSidebarRowThreadId( - routeThreadRef.threadId, - routeThreadRef.environmentId, - threads, - ), - ), - ); + const { + projectScopeKey, + scopedProjectKeys, + serverConfigs, + optimisticDrop, + threads, + routeRowThreadKey, + } = input; const workingShelfEnabled = useClientSettings((s) => s.sidebarWorkingShelfEnabled); const inboxReturnsVersion = useSyncExternalStore( inboxReturns.subscribe, @@ -392,12 +390,7 @@ function useSidebarThreads(input: { ); const nowMinute = useNowMinute(); // Minute-quantized clocks would delay second-precise snooze wakes by up to a minute. - const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0); - const [nextSnoozeWakeAt, setNextSnoozeWakeAt] = useState(null); - useEffect( - () => scheduleSnoozeWake(nextSnoozeWakeAt, () => bumpSnoozeWakeTick((tick) => tick + 1)), - [nextSnoozeWakeAt], - ); + const snoozeWakeClock = useSnoozeWakeClock(threads); const [settledShelfExpanded, setSettledShelfExpanded] = useLocalStorage( SETTLED_SHELF_EXPANDED_KEY, false, @@ -429,10 +422,8 @@ function useSidebarThreads(input: { serverConfigs, optimisticDrop, workingShelfEnabled, - clockKey: `${nowMinute}:${snoozeWakeTick}:${inboxReturnsVersion}`, + clockKey: `${nowMinute}:${snoozeWakeClock}:${inboxReturnsVersion}`, }); - const partitionNextWakeAt = partition.snoozedThreads[0]?.snoozedUntil ?? null; - if (partitionNextWakeAt !== nextSnoozeWakeAt) setNextSnoozeWakeAt(partitionNextWakeAt); const { pinnedThreads, activeThreads, workingThreads, snoozedThreads, settledThreads } = partition; @@ -486,7 +477,6 @@ function useSidebarThreads(input: { for (const thread of list) sectionByThreadKey.set(threadShellKey(thread), section); } return { - routeRowThreadKey, ...partition, threads, workingShelfEnabled, @@ -756,15 +746,6 @@ function useSidebarLookups() { }; } -function scheduleSnoozeWake(wakeAt: string | null, onWake: () => void): (() => void) | undefined { - const wakeAtMs = wakeAt === null ? Number.NaN : Date.parse(wakeAt); - if (Number.isNaN(wakeAtMs)) return undefined; - // Delays beyond signed 32-bit range fire immediately and loop for far-future snoozes. - const delayMs = Math.min(Math.max(0, wakeAtMs - Date.now()) + 50, 2_147_483_647); - const id = window.setTimeout(onWake, delayMs); - return () => window.clearTimeout(id); -} - function canOperateThreads( threads: ReadonlyArray>, ): boolean { @@ -789,6 +770,19 @@ const SIDEBAR_DRAG_DISTANCE = 6; const SIDEBAR_DRAG_LABEL_HEIGHT = 24; +const threadSearchResultId = (index: number) => `sidebar-thread-search-result-${index}`; + +// Continuations that outlive a render read the open thread from the router. +function readRouteThreadKey(router: AnyRouter): string | null { + const target = resolveThreadRouteTarget(router.state.matches.at(-1)?.params ?? {}); + const draft = + target?.kind === "draft" + ? useComposerDraftStore.getState().getDraftSession(target.draftId) + : null; + const ref = resolveActiveThreadRouteRef(target, draft); + return ref === null ? null : scopedThreadKey(ref); +} + export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReveal }) { const { projects, @@ -882,6 +876,8 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve }, }); const newThreadContext = useHandleNewThread(); + // Row callbacks capture only the handler; the rest of the context changes with the route. + const { handleNewThread } = newThreadContext; const primaryEnvironmentId = usePrimaryEnvironmentId(); const clearSelection = useThreadSelectionStore((s) => s.clearSelection); const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor); @@ -913,10 +909,20 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve const [threadSearchQuery, setThreadSearchQuery] = useState(""); const [searchHighlightKey, setSearchHighlightKey] = useState(null); const searchEnvironmentIds = useConnectedEnvironmentIds(); - const handleNewThreadRef = useRef(newThreadContext.handleNewThread); const queuePendingFileDrop = useSidebarPendingFileDropStore((s) => s.queuePendingFileDrop); const clearPendingFileDrop = useSidebarPendingFileDropStore((s) => s.clearPendingFileDrop); - const [renamingThreadKey, setRenamingThreadKey] = useState(null); + const [renamingThreadRef, setRenamingThreadRef] = useState(null); + const canOperateRenamingThread = useEnvironmentScope( + renamingThreadRef?.environmentId ?? null, + AuthOrchestrationOperateScope, + ); + // Losing operate access cancels the rename without committing the title, so + // the editor does not return and take focus when access comes back. + if (renamingThreadRef !== null && !canOperateRenamingThread) setRenamingThreadRef(null); + const renamingThreadKey = + renamingThreadRef !== null && canOperateRenamingThread + ? scopedThreadKey(renamingThreadRef) + : null; const [renamingTitle, setRenamingTitle] = useState(""); const settlingThreadKeysRef = useRef(new Set()); const threadListRef = useRef(null); @@ -943,18 +949,70 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve const openAddProjectCommandPalette = () => openCommandPalette({ open: "add-project" }); const routeThreadRef = resolveActiveThreadRouteRef(routeTarget, routeDraftThread); const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null; - const routeThreadKeyRef = useRef(routeThreadKey); // A selection made under one scope can hold threads the next scope hides. const setProjectScopeKey = (key: string | null) => { clearSelection(); setPersistedProjectScopeKey(key); }; - const projectScopeKey = resolveSidebarProjectScopeKey({ + // A scope whose project is gone reads as all projects once every snapshot has + // loaded. The stored key is left alone, so the scope returns with its project. + const chosenProjectScopeKey = resolveSidebarProjectScopeKey({ groups: projectGroups, key: persistedProjectScopeKey, settings: projectGroupingSettings, + canClearMissingScope: allProjectSnapshotsReady && allProjectConfigsReady, + }); + const chosenProjectGroup = + chosenProjectScopeKey === null + ? null + : (projectGroups.find((project) => project.projectKey === chosenProjectScopeKey) ?? null); + const chosenScopeProjectKeys = + chosenProjectGroup === null + ? null + : new Set( + chosenProjectGroup.memberProjectRefs.map( + (projectRef) => `${projectRef.environmentId}:${projectRef.projectId}`, + ), + ); + const threads = useQuietThreadShells(); + const routeRowThreadKey = + routeThreadRef === null + ? null + : scopedThreadKey( + scopeThreadRef( + routeThreadRef.environmentId, + resolveSidebarRowThreadId( + routeThreadRef.threadId, + routeThreadRef.environmentId, + threads, + ), + ), + ); + const isSearchingThreads = threadSearchQuery.trim().length > 0; + + // History, the palette and links can open a thread the scope hides or the list + // scrolled away; a remount starts at the top, so each mount scrolls to the open + // thread once. Render decides the widening, so the first frame of the new route + // already lists the row; the active row's ref callback scrolls to it. + const { widensScope: revealWidensScope } = resolveSidebarRouteReveal({ + reveal: routeReveal, + routeKey: routeRowThreadKey, + isSearching: isSearchingThreads, + scopedProjectKeys: chosenScopeProjectKeys, + findThread: (key) => threads.find((candidate) => threadShellKey(candidate) === key), }); + const projectScopeKey = revealWidensScope ? null : chosenProjectScopeKey; + // The widened scope outlives the reveal, so it is written back to the persisted + // scope. Route changes arrive from history, links and the palette, and only this + // component holds the project groups that decide a widening, so no single + // navigation handler can make the write. + useEffect(() => { + if (!revealWidensScope) return; + clearSelection(); + setPersistedProjectScopeKey(null); + }, [clearSelection, revealWidensScope, setPersistedProjectScopeKey]); + const revealActiveRow = useActiveRowReveal(routeReveal, routeRowThreadKey); const showProjectEnvironments = projectGroupsSpanEnvironments(projectGroups); const scratchMachineLabelFor = ( thread: Pick, @@ -978,18 +1036,8 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve matches: (item, query) => projectScopeFilter.contains(item, query, (candidate) => candidate.label), }); - const scopedProjectGroup = - projectScopeKey === null - ? null - : (projectGroups.find((project) => project.projectKey === projectScopeKey) ?? null); - const scopedProjectKeys = - scopedProjectGroup === null - ? null - : new Set( - scopedProjectGroup.memberProjectRefs.map( - (projectRef) => `${projectRef.environmentId}:${projectRef.projectId}`, - ), - ); + const scopedProjectGroup = revealWidensScope ? null : chosenProjectGroup; + const scopedProjectKeys = revealWidensScope ? null : chosenScopeProjectKeys; const threadSearch = useThreadSearch( searchEnvironmentIds, threadSearchQuery, @@ -1001,26 +1049,6 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve ), }, ); - useEffect(() => { - const nextKey = resolveSidebarProjectScopeKey({ - groups: projectGroups, - key: persistedProjectScopeKey, - settings: projectGroupingSettings, - canClearMissingScope: allProjectSnapshotsReady && allProjectConfigsReady, - }); - if (nextKey !== persistedProjectScopeKey) { - clearSelection(); - setPersistedProjectScopeKey(nextKey); - } - }, [ - allProjectConfigsReady, - allProjectSnapshotsReady, - clearSelection, - persistedProjectScopeKey, - projectGroups, - projectGroupingSettings, - setPersistedProjectScopeKey, - ]); const routeDraftIdForRows = routeTarget?.kind === "draft" ? routeTarget.draftId : null; const visibleDraftSessionCount = useComposerDraftStore((store) => { let count = 0; @@ -1074,7 +1102,6 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve ); const { - threads, workingShelfEnabled, pinnedThreads, draggableThreadKeys, @@ -1109,53 +1136,14 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve activeKeys, pinnedKeysById, activeKeysById, - routeRowThreadKey, } = useSidebarThreads({ projectScopeKey, scopedProjectKeys, serverConfigs, optimisticDrop, - routeThreadRef, - }); - - const isSearchingThreads = threadSearchQuery.trim().length > 0; - - // History, the command palette, and links can open a thread the project - // scope hides or the list has scrolled away. A remount starts the list at - // the top, so each mount also scrolls to the open thread once, without - // widening the scope. - const mountRevealOwed = useRef(true); - const revealRouteRow = useStableCallback(() => { - const pendingKey = pendingSidebarRouteReveal(routeReveal, routeRowThreadKey); - // Search results replace the thread list; reveal once the list returns. - if (isSearchingThreads) return; - const revealKey = pendingKey ?? (mountRevealOwed.current ? routeRowThreadKey : null); - if (revealKey === null) return; - const thread = threads.find((candidate) => threadShellKey(candidate) === revealKey); - if (thread === undefined) return; - if ( - pendingKey !== null && - scopedProjectKeys !== null && - filterSidebarV2VisibleThreads([thread], scopedProjectKeys).length === 0 && - filterSidebarV2VisibleThreads([thread], null).length === 1 - ) { - // The row renders under the widened scope; scroll on that pass. - setProjectScopeKey(null); - return; - } - const activeRow = document.querySelector('[data-sidebar="content"] [aria-current="page"]'); - if (!activeRow) return; - completeSidebarRouteReveal(routeReveal); - mountRevealOwed.current = false; - activeRow.scrollIntoView({ block: "nearest" }); - }); - useEffect(revealRouteRow, [ - revealRouteRow, - routeRowThreadKey, threads, - projectScopeKey, - isSearchingThreads, - ]); + routeRowThreadKey, + }); // Ranked across environments as the palette ranks them, so one // environment's list does not sit above another's better matches; rows @@ -1179,6 +1167,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve return thread ? threadSearchSubject(thread, null) : undefined; }, foldSearchQuery(threadSearchQuery), + // oxlint-disable-next-line react/purity -- Recency ranking samples the clock when the search matches change; the compiler recomputes it only then. Date.now(), // A list filter, as on mobile: a pick would move rows unexplained. { picks: false }, @@ -1224,15 +1213,10 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve typeof index === "function" ? index(activeSearchResultIndex) : index ] ?? null, ); - const activeSearchResultKey = isSearchingThreads - ? (threadSearchResultKeys[activeSearchResultIndex] ?? null) - : null; - useEffect(() => { - if (activeSearchResultKey === null) return; - document - .querySelector(`[data-search-result-key="${globalThis.CSS.escape(activeSearchResultKey)}"]`) - ?.scrollIntoView({ block: "nearest" }); - }, [activeSearchResultKey]); + const moveSearchHighlight = (step: 1 | -1) => { + const count = threadSearchResults.length; + setActiveSearchResultIndex((activeSearchResultIndex + step + count) % count); + }; const orderedThreadKeysRef = useRef(orderedThreadKeys); // Read changing maps/sets through refs so streamed events preserve row callbacks. @@ -1250,8 +1234,6 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve } return mapping; })(); - const { showThreadJumpHints: showJumpHints, updateThreadJumpHintsVisibility } = - useThreadJumpHintVisibility(); const navigateToThread = (threadRef: ScopedThreadRef) => { if (useThreadSelectionStore.getState().selectedThreadKeys.size > 0) { @@ -1323,14 +1305,12 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve if (threadSearchResults.length === 0) return; if (event.key === "ArrowDown") { event.preventDefault(); - setActiveSearchResultIndex((index) => (index + 1) % threadSearchResults.length); + moveSearchHighlight(1); return; } if (event.key === "ArrowUp") { event.preventDefault(); - setActiveSearchResultIndex( - (index) => (index - 1 + threadSearchResults.length) % threadSearchResults.length, - ); + moveSearchHighlight(-1); return; } if (event.key === "Enter") { @@ -1342,15 +1322,15 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve const startThreadRename = (threadRef: ScopedThreadRef, title: string) => { if (!readEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope)) return; - setRenamingThreadKey(scopedThreadKey(threadRef)); + setRenamingThreadRef(threadRef); setRenamingTitle(title); }; - const cancelThreadRename = () => setRenamingThreadKey(null); + const cancelThreadRename = () => setRenamingThreadRef(null); const commitThreadRename = (threadRef: ScopedThreadRef, title: string, originalTitle: string) => { void (async () => { if (!readEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope)) return; const trimmed = title.trim(); - setRenamingThreadKey(null); + setRenamingThreadRef(null); if (trimmed.length === 0) { toastManager.add({ type: "warning", title: "Thread title cannot be empty" }); return; @@ -1399,7 +1379,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve threadKey: string, coParkingKeys?: ReadonlySet, ): (() => void) | null => { - if (routeThreadKeyRef.current !== threadKey) return null; + if (readRouteThreadKey(router) !== threadKey) return null; const shell = threadByKeyRef.current.get(threadKey); const orderedKeys = orderedThreadKeysRef.current; const settledKeys = settledThreadKeysRef.current; @@ -1415,8 +1395,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve return nextThread ? () => navigateToThread(scopeThreadRef(nextThread.environmentId, nextThread.id)) : shell - ? () => - void handleNewThreadRef.current(scopeProjectRef(shell.environmentId, shell.projectId)) + ? () => void handleNewThread(scopeProjectRef(shell.environmentId, shell.projectId)) : () => void router.navigate({ to: "/" }); }; @@ -1447,7 +1426,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve if ( shouldNavigateAfterThreadPark({ threadKey, - currentThreadKey: routeThreadKeyRef.current, + currentThreadKey: readRouteThreadKey(router), action: "settle", now: new Date().toISOString(), thread: readThreadShell(threadRef), @@ -1503,11 +1482,20 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve restrictBelowPins, restrictToFirstScrollableAncestor, ]; - const attachListMotionRef = (node: HTMLUListElement | null) => { + const attachListMotionRef = (node: HTMLUListElement) => { threadListRef.current = node; - listMotionRef.current?.dispose(); - listMotionRef.current = node === null ? null : createSidebarListMotion(node); - listMotionRef.current?.update(false); + const motion = createSidebarListMotion(node); + listMotionRef.current = motion; + motion.update(false); + return () => { + // Search replaces this list while the Sidebar stays mounted, and a drag + // whose rows are gone can never drop. The compiler memoizes this callback + // with no deps (it reads only refs), so the cleanup runs only on unmount. + dragSensorRef.current?.cancel(); + motion.dispose(); + if (listMotionRef.current === motion) listMotionRef.current = null; + if (threadListRef.current === node) threadListRef.current = null; + }; }; const isContextDrag = dragState?.contextDrag === true || dragState?.activeSection === "working"; @@ -1574,11 +1562,9 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve const dndSensors = useSensors(useSensor(SidebarPointerSensor, pointerSensorOptions)); const sectionByThreadKeyRef = useRef(sectionByThreadKey); useLayoutEffect(() => { - routeThreadKeyRef.current = routeThreadKey; projectGroupsRef.current = projectGroups; orderedThreadKeysRef.current = orderedThreadKeys; threadByKeyRef.current = threadByKey; - handleNewThreadRef.current = newThreadContext.handleNewThread; settledThreadKeysRef.current = settledThreadKeys; snoozedThreadKeysRef.current = snoozedThreadKeys; sectionByThreadKeyRef.current = sectionByThreadKey; @@ -1947,7 +1933,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve settled && shouldNavigateAfterThreadPark({ threadKey: activeKey, - currentThreadKey: routeThreadKeyRef.current, + currentThreadKey: readRouteThreadKey(router), action: "settle", now: new Date().toISOString(), thread: readThreadShell(threadRef), @@ -2018,7 +2004,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve if ( shouldNavigateAfterThreadPark({ threadKey, - currentThreadKey: routeThreadKeyRef.current, + currentThreadKey: readRouteThreadKey(router), action: "snooze", now: new Date().toISOString(), thread: readThreadShell(threadRef), @@ -2458,7 +2444,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve return; case "new-thread-on-branch": { const result = await settlePromise(() => - handleNewThreadRef.current(scopeProjectRef(thread.environmentId, thread.projectId), { + handleNewThread(scopeProjectRef(thread.environmentId, thread.projectId), { branch: thread.branch, worktreePath: thread.worktreePath, envMode: thread.worktreePath ? "worktree" : "local", @@ -2697,9 +2683,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve }, }, ); - useEffect(() => { - updateThreadJumpHintsVisibility(shouldShowJumpHintsNow); - }, [shouldShowJumpHintsNow, updateThreadJumpHintsVisibility]); + const showJumpHints = useThreadJumpHintsVisible(shouldShowJumpHintsNow); const handleNewThreadClick = (event?: ReactMouseEvent) => { if (shouldCreateNewThreadInCurrentProject(event?.shiftKey ?? false, projectGroups.length)) { @@ -2708,7 +2692,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve activeDraftThread: newThreadContext.activeDraftThread, activeThread: newThreadContext.activeThread ?? undefined, defaultProjectRef: newThreadContext.defaultProjectRef, - handleNewThread: newThreadContext.handleNewThread, + handleNewThread, }); return; } @@ -2928,7 +2912,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve } isHighlighted={activeSearchResultIndex === index} isRouteActive={routeRowThreadKey === threadKey} - resultId={`sidebar-thread-search-result-${index}`} + resultId={threadSearchResultId(index)} searchMatch={ threadSearchMatchByKey.get( threadSearchMatchKey({ @@ -2972,7 +2956,6 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve onDragOver={handleThreadDragOver} onDragEnd={handleThreadDragEnd} > -
    - + {/* The New design keeps plus for "add" and uses a chevron to reveal more. */} + + {olderSettledShelf.loading ? "Loading" : olderSettledShelf.failed @@ -3279,7 +3275,9 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve ) : null} - + ); } diff --git a/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.tsx b/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts similarity index 80% rename from apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.tsx rename to apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts index da329ecdbc..4feb874365 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts @@ -1,12 +1,12 @@ -import { RegistryContext } from "@effect/atom-react"; import { WORKING_EXIT_GRACE_MS } from "@infinitus/client-runtime/state/thread-inbox"; +import type { AnyRouter } from "@tanstack/react-router"; import { EnvironmentId } from "@infinitus/contracts"; import * as DateTime from "effect/DateTime"; import * as Option from "effect/Option"; import { Atom, AtomRegistry } from "effect/reactivity"; import { expect, it, vi } from "vite-plus/test"; -import { fakeTimers, render, stubGlobal, type Screen } from "../../test/render"; +import { fakeTimers, stubGlobal } from "../../test/render"; const state = vi.hoisted(() => ({ mode: "notifications", @@ -16,12 +16,7 @@ const state = vi.hoisted(() => ({ navigate: vi.fn(), sound: vi.fn(), badge: vi.fn(), - environmentIds: ["one", "two"], -})); -vi.mock(import("@tanstack/react-router"), async (original) => ({ - ...(await original()), - useNavigate: (() => state.navigate) as never, - useParams: (() => ({})) as never, + settingsListeners: new Set<() => void>(), })); vi.mock(import("./ui/toast"), async (original) => { const actual = await original(); @@ -37,16 +32,29 @@ vi.mock(import("../state/shell"), async (original) => { }, }; }); -vi.mock(import("../state/environments"), async (original) => ({ - ...(await original()), - useEnvironmentIds: (() => state.environmentIds) as never, -})); +vi.mock(import("../state/presentation"), async (original) => { + const actual = await original(); + return { + ...actual, + environmentSummaries: { + ...actual.environmentSummaries, + // Read lazily: the factory runs before this module's own bindings exist. + get environmentIdsAtom() { + return environmentIdsAtom as never; + }, + }, + }; +}); vi.mock(import("../hooks/useSettings"), async (original) => ({ ...(await original()), - useClientSettings: (( - select: (settings: { notificationMode: string; inAppNotificationsEnabled: boolean }) => unknown, - ) => select({ notificationMode: state.mode, inAppNotificationsEnabled: state.inApp })) as never, - getClientSettings: (() => ({ notificationMode: state.mode })) as never, + subscribeClientSettings: ((listener: () => void) => { + state.settingsListeners.add(listener); + return () => state.settingsListeners.delete(listener); + }) as never, + getClientSettings: (() => ({ + notificationMode: state.mode, + inAppNotificationsEnabled: state.inApp, + })) as never, })); vi.mock(import("../threadNotifications"), async (original) => ({ ...(await original()), @@ -55,10 +63,11 @@ vi.mock(import("../threadNotifications"), async (original) => ({ setNotificationBadge: state.badge, })); -import { ThreadNotificationCoordinator } from "./ThreadNotificationCoordinator"; +import { startThreadNotifications } from "./ThreadNotificationCoordinator"; let registry: AtomRegistry.AtomRegistry; const shellAtom = Atom.family((_id: string) => Atom.make(null)); +const environmentIdsAtom = Atom.make>([]); state.shells = { set: (id, value) => registry.set(shellAtom(id), value) }; class TestNotification extends EventTarget { @@ -155,8 +164,8 @@ function shell(overrides: Partial = {}, ...others: Partial testRegistry.dispose()); state.mode = "notifications"; state.inApp = false; - state.environmentIds = ["one", "two"]; + state.settingsListeners.clear(); + registry.set(environmentIdsAtom, ["one", "two"]); state.shells.set("one", shell()); state.shells.set("two", shell()); focused = false; @@ -180,19 +190,18 @@ function setup() { .spyOn(document, "visibilityState", "get") .mockImplementation(() => visibility as DocumentVisibilityState), ); - const ui = () => ( - - - - ); - let screen: Screen | undefined; + const router = { + state: { matches: [{ params: {} }] }, + navigate: state.navigate, + } as unknown as AnyRouter; return { - ui, - async render() { - if (screen) await screen.rerender(ui()); - else screen = stack.use(await render(ui())); + start: () => { + stack.defer(startThreadNotifications(testRegistry, router)); + }, + setEnvironmentIds: (ids: ReadonlyArray) => testRegistry.set(environmentIdsAtom, ids), + changeSettings: () => { + for (const listener of state.settingsListeners) listener(); }, - unmount: () => screen!.unmount(), [Symbol.asyncDispose]: () => stack.disposeAsync(), }; } @@ -201,9 +210,8 @@ it.each(["hasPendingApprovals", "hasPendingUserInput"] as const)( "badges %s and clears when notifications are disabled", async (flag) => { await using app = setup(); - await app.render(); + app.start(); state.shells.set("one", shell({ [flag]: true })); - await app.render(); expect(state.badge).toHaveBeenLastCalledWith(1); const notification = TestNotification.sent[0]!; notification.dispatchEvent(new Event("click")); @@ -212,7 +220,7 @@ it.each(["hasPendingApprovals", "hasPendingUserInput"] as const)( params: { environmentId: EnvironmentId.make("one"), threadId: "thread" }, }); state.mode = "sound"; - await app.render(); + app.changeSettings(); expect(state.badge).toHaveBeenLastCalledWith(0); expect(notification.close).toHaveBeenCalled(); }, @@ -220,20 +228,17 @@ it.each(["hasPendingApprovals", "hasPendingUserInput"] as const)( it("holds a completion through a continuation gap and notifies once it settles", async () => { await using app = setup(); - await app.render(); + app.start(); using _timers = fakeTimers(); const finished = (turnId: string, completedAt: string) => ({ latestTurn: { turnId, state: "completed", completedAt }, }); state.shells.set("one", shell(finished("turn-1", "2026-09-13T07:00:01.000Z"))); - await app.render(); state.shells.set( "one", shell({ latestTurn: { turnId: "turn-2", state: "running", completedAt: null } }), ); - await app.render(); state.shells.set("one", shell(finished("turn-2", "2026-09-13T07:00:02.000Z"))); - await app.render(); expect(TestNotification.sent).toEqual([]); await vi.advanceTimersByTimeAsync(WORKING_EXIT_GRACE_MS - 1); expect(TestNotification.sent).toEqual([]); diff --git a/apps/web/src/components/ThreadNotificationCoordinator.tsx b/apps/web/src/components/ThreadNotificationCoordinator.tsx index 67c8bca8e3..4d243b3dd2 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.tsx @@ -4,22 +4,26 @@ import { presentThreadShell, } from "@infinitus/client-runtime/state/models"; import { pendingSubagentRequestsByThread } from "@infinitus/client-runtime/state/threads"; -import { useAtomValue } from "@effect/atom-react"; -import { useNavigate, useParams } from "@tanstack/react-router"; +import type { AnyRouter } from "@tanstack/react-router"; import type { EnvironmentId, OrchestrationV2ThreadShell, ThreadId } from "@infinitus/contracts"; +import type { ClientSettings } from "@infinitus/contracts/settings"; import * as Option from "effect/Option"; +import type { AtomRegistry } from "effect/reactivity"; import { CircleAlertIcon, CircleCheckIcon, MessageCircleQuestionIcon, ShieldQuestionIcon, } from "lucide-react"; -import { useEffect, useEffectEvent, useRef } from "react"; +<<<<<<< HEAD import { getClientSettings, useClientSettings } from "../hooks/useSettings"; import { quietForViewer } from "../lib/infinitusNotifications.logic"; +======= +import { getClientSettings, subscribeClientSettings } from "../hooks/useSettings"; +>>>>>>> upstream-sync-cc0b86ddf-upstream-renamed import { isWindowsPlatform } from "../lib/utils"; -import { useEnvironmentIds } from "../state/environments"; +import { environmentSummaries } from "../state/presentation"; import { environmentShell } from "../state/shell"; import { hasDesktopNotifications, @@ -37,70 +41,6 @@ import { } from "./ThreadNotificationCoordinator.logic"; import { toastManager } from "./ui/toast"; -export function ThreadNotificationCoordinator() { - const environmentIds = useEnvironmentIds(); - const mode = useClientSettings((settings) => settings.notificationMode); - const inAppNotificationsEnabled = useClientSettings( - (settings) => settings.inAppNotificationsEnabled, - ); - const pending = useRef( - new Map(), - ); - const onNotification = (environmentId: EnvironmentId, notification: Notification) => { - pending.current.get(notification.tag)?.notification.close(); - pending.current.set(notification.tag, { environmentId, notification }); - setNotificationBadge(pending.current.size); - }; - - useEffect(() => { - const activeIds = new Set(environmentIds); - const count = pending.current.size; - for (const [tag, entry] of pending.current) { - if (activeIds.has(entry.environmentId)) continue; - entry.notification.close(); - pending.current.delete(tag); - } - if (count !== pending.current.size) setNotificationBadge(pending.current.size); - }, [environmentIds]); - - useEffect(() => { - const clear = () => { - for (const { notification } of pending.current.values()) notification.close(); - pending.current.clear(); - setNotificationBadge(0); - }; - clear(); - if (!hasDesktopNotifications(mode)) return; - const unsubscribe = window.desktopBridge?.onNotificationBadgeClear?.(clear); - window.addEventListener("focus", clear); - return () => { - unsubscribe?.(); - window.removeEventListener("focus", clear); - clear(); - }; - }, [mode]); - - useEffect(() => { - if (!hasNotificationSound(mode)) return; - document.addEventListener("pointerdown", unlockNotificationAudio); - document.addEventListener("keydown", unlockNotificationAudio); - return () => { - document.removeEventListener("pointerdown", unlockNotificationAudio); - document.removeEventListener("keydown", unlockNotificationAudio); - }; - }, [mode]); - - if (mode === "off" && !inAppNotificationsEnabled) return null; - - return environmentIds.map((environmentId) => ( - - )); -} - interface NotificationState extends ThreadNotificationObservation { readonly raw: OrchestrationV2ThreadShell; readonly subagentRequest: PendingSubagentRequest | null; @@ -108,6 +48,7 @@ interface NotificationState extends ThreadNotificationObservation { readonly status: SidebarThreadStatus; } +<<<<<<< HEAD function EnvironmentNotifications({ environmentId, onNotification, @@ -141,6 +82,48 @@ function EnvironmentNotifications({ // no bell) while the window has focus. Upstream silences only its banner // and toast for it, and still rings. if (quietForViewer(viewedKey, `${environmentId}:${thread.id}`, document)) return; +======= +interface WatchedEnvironment { + unsubscribe: () => void; + previous: Map; + readonly gate: ReturnType>; +} + +/** + * Turns thread status transitions in every connected environment into sounds, in-app toasts, + * and desktop notifications, and keeps the dock badge in step with the unread ones. + */ +export function startThreadNotifications( + registry: AtomRegistry.AtomRegistry, + router: AnyRouter, +): () => void { + const pending = new Map(); + const watched = new Map(); + + const trackNotification = (environmentId: EnvironmentId, notification: Notification) => { + pending.get(notification.tag)?.notification.close(); + pending.set(notification.tag, { environmentId, notification }); + setNotificationBadge(pending.size); + }; + const clearPending = () => { + for (const { notification } of pending.values()) notification.close(); + pending.clear(); + setNotificationBadge(0); + }; + + // Completions fire from a timer, so read settings, focus and route when presenting. + const present = ( + environmentId: EnvironmentId, + state: NotificationState, + kind: ThreadNotificationKind, + ) => { + const { thread, status } = state; + const { notificationMode: mode, inAppNotificationsEnabled } = getClientSettings(); + const active = (router.state.matches.at(-1)?.params ?? {}) as { + environmentId?: string; + threadId?: string; + }; +>>>>>>> upstream-sync-cc0b86ddf-upstream-renamed const title = kind === "completion" ? "Thread completed" @@ -162,7 +145,7 @@ function EnvironmentNotifications({ inAppNotificationsEnabled && document.visibilityState === "visible" && document.hasFocus() && - (activeEnvironmentId !== environmentId || activeThreadId !== thread.id) + (active.environmentId !== environmentId || active.threadId !== thread.id) ) { const toastId = toastManager.add({ type: kind === "completion" ? "success" : status === "failed" ? "error" : "warning", @@ -185,7 +168,7 @@ function EnvironmentNotifications({ children: "Open thread", onClick: () => { toastManager.close(toastId); - void navigate({ + void router.navigate({ to: "/$environmentId/$threadId", params: { environmentId, threadId: thread.id }, }); @@ -209,12 +192,12 @@ function EnvironmentNotifications({ // Windows drops clicks after a toast moves into Notification Center. requireInteraction: kind === "completion" && isWindowsPlatform(navigator.platform), }); - onNotification(environmentId, notification); + trackNotification(environmentId, notification); notification.addEventListener("click", () => { notification.close(); window.focus(); void window.desktopBridge?.revealWindow?.().catch(() => undefined); - void navigate({ + void router.navigate({ to: "/$environmentId/$threadId", params: { environmentId, threadId: thread.id }, }); @@ -222,35 +205,20 @@ function EnvironmentNotifications({ } catch { // Some browsers expose Notification but reject desktop presentation. } - }); - const gate = useRef>>(null); - useEffect(() => { - const created = createThreadNotificationGate((threadId, kind) => - present(threadId, kind), - ); - gate.current = created; - return () => { - created.clear(); - gate.current = null; - // A new gate has no pending completions, so forget the states they compared against. - previous.current.clear(); - }; - }, []); + }; - useEffect(() => { - const pendingCompletions = gate.current; - if (!pendingCompletions) return; - if (threads === null) { - pendingCompletions.clear(); - previous.current.clear(); - return; - } + const observeThreads = ( + environmentId: EnvironmentId, + entry: WatchedEnvironment, + threads: ReadonlyArray, + ) => { + const previous = entry.previous; const next = new Map(); const subagentRequests = pendingSubagentRequestsByThread(threads); const changed: Array<[NotificationState | undefined, NotificationState]> = []; for (const rawThread of threads) { if (rawThread.lineage.relationshipToParent === "subagent") continue; - const prior = previous.current.get(rawThread.id); + const prior = previous.get(rawThread.id); const subagentRequest = subagentRequests.get(rawThread.id) ?? null; if (prior?.raw === rawThread && prior.subagentRequest === subagentRequest) { next.set(rawThread.id, prior); @@ -281,15 +249,125 @@ function EnvironmentNotifications({ next.set(thread.id, state); changed.push([prior, state]); } - previous.current = next; - pendingCompletions.retain(new Set(next.keys())); + entry.previous = next; + entry.gate.retain(new Set(next.keys())); for (const [prior, state] of changed) { - pendingCompletions.observe(state.thread.id, prior, state, { + entry.gate.observe(state.thread.id, prior, state, { archived: state.thread.archivedAt !== null, quiet: state.raw.latestRunStopRequested === true, }); } - }, [environmentId, threads]); + }; + + const watchEnvironment = (environmentId: EnvironmentId): WatchedEnvironment => { + const entry: WatchedEnvironment = { + previous: new Map(), + unsubscribe: () => {}, + gate: createThreadNotificationGate((threadId, kind) => { + const state = entry.previous.get(threadId); + if (state) present(environmentId, state, kind); + }), + }; + const unsubscribeShell = registry.subscribe( + environmentShell.stateValueAtom(environmentId), + (shell) => { + const threads = + shell.status === "live" && Option.isSome(shell.snapshot) + ? shell.snapshot.value.threads + : null; + if (threads !== null) { + observeThreads(environmentId, entry, threads); + return; + } + entry.gate.clear(); + entry.previous = new Map(); + }, + { immediate: true }, + ); + entry.unsubscribe = () => { + unsubscribeShell(); + entry.gate.clear(); + }; + return entry; + }; + + const syncEnvironments = () => { + const { notificationMode, inAppNotificationsEnabled } = getClientSettings(); + const enabled = notificationMode !== "off" || inAppNotificationsEnabled; + const environmentIds = enabled ? registry.get(environmentSummaries.environmentIdsAtom) : []; + const activeIds = new Set(environmentIds); + for (const [environmentId, entry] of watched) { + if (activeIds.has(environmentId)) continue; + entry.unsubscribe(); + watched.delete(environmentId); + } + for (const environmentId of environmentIds) { + if (!watched.has(environmentId)) watched.set(environmentId, watchEnvironment(environmentId)); + } + }; + + const pruneRemovedEnvironments = () => { + const activeIds = new Set(registry.get(environmentSummaries.environmentIdsAtom)); + const count = pending.size; + for (const [tag, entry] of pending) { + if (activeIds.has(entry.environmentId)) continue; + entry.notification.close(); + pending.delete(tag); + } + if (count !== pending.size) setNotificationBadge(pending.size); + }; + + // Listeners that only exist for some notification modes, rebuilt when the mode changes. + let appliedMode: ClientSettings["notificationMode"] | null = null; + let stopModeListeners = () => {}; + const applyMode = () => { + const nextMode = getClientSettings().notificationMode; + if (nextMode === appliedMode) return; + appliedMode = nextMode; + stopModeListeners(); + clearPending(); + const stops: Array<() => void> = []; + if (hasDesktopNotifications(nextMode)) { + const unsubscribeBadgeClear = window.desktopBridge?.onNotificationBadgeClear?.(clearPending); + window.addEventListener("focus", clearPending); + stops.push(() => { + unsubscribeBadgeClear?.(); + window.removeEventListener("focus", clearPending); + clearPending(); + }); + } + if (hasNotificationSound(nextMode)) { + document.addEventListener("pointerdown", unlockNotificationAudio); + document.addEventListener("keydown", unlockNotificationAudio); + stops.push(() => { + document.removeEventListener("pointerdown", unlockNotificationAudio); + document.removeEventListener("keydown", unlockNotificationAudio); + }); + } + stopModeListeners = () => { + for (const stop of stops) stop(); + }; + }; - return null; + const onSettings = () => { + applyMode(); + syncEnvironments(); + }; + const unsubscribeSettings = subscribeClientSettings(onSettings); + const unsubscribeEnvironmentIds = registry.subscribe( + environmentSummaries.environmentIdsAtom, + () => { + pruneRemovedEnvironments(); + syncEnvironments(); + }, + ); + onSettings(); + + return () => { + unsubscribeSettings(); + unsubscribeEnvironmentIds(); + for (const entry of watched.values()) entry.unsubscribe(); + watched.clear(); + stopModeListeners(); + }; } diff --git a/apps/web/src/components/chat/ChatCanvas.tsx b/apps/web/src/components/chat/ChatCanvas.tsx index 783d86a8ee..365d78d0d6 100644 --- a/apps/web/src/components/chat/ChatCanvas.tsx +++ b/apps/web/src/components/chat/ChatCanvas.tsx @@ -3,6 +3,7 @@ import { ChatCanvasContext } from "./ChatCanvasContext"; import { resolveChatCanvasLayout, type ChatCanvasPreview } from "./chatCanvasLayout"; import type { PreviewMiniPlayerObstacles } from "../preview/previewMiniPlayerLayout"; import { observeResize } from "../../lib/observeResize"; +import { useClientSettings } from "~/hooks/useSettings"; export function ChatCanvas({ composerOverlayElement, @@ -93,6 +94,7 @@ export function ChatCanvas({ if (timelineElement) observed.push(timelineElement); return observeResize(observed, measure); }, [composerOverlayElement, timelineElement, detailsCardTopInset]); + const redesign = useClientSettings((s) => s.redesignEnabled); const container = { width: measurements.width, height: measurements.height }; const findBar = detailsCardTopInset > 0 @@ -111,6 +113,7 @@ export function ChatCanvas({ preview, detailsCard, findBar, + centerBesideDetails: redesign, }), previewKey: preview?.key ?? null, reportPreview, diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index f5a174914d..df0ce64abe 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -1017,8 +1017,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const refreshWorkspaceSnapshotOnTyping = useProviderWorkspaceSnapshotRefresh({ environmentId, gitCwd, - selectedProviderEntry, - selectedProviderStatus: selectedProviderStatus, + instanceId: selectedProviderEntry?.instanceId, }); const selectedPromptEffort = composerProviderState.promptEffort; @@ -2304,7 +2303,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) stashQueue, isStashMenuOpen, setIsStashMenuOpen, - stashPulse, + stashPulseKey, restoreStashEntryStable, deleteStashEntryStable, toggleStashMenuStable, @@ -2312,26 +2311,25 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) environmentId, composerDraftTarget, promptRef, - composerImagesRef, - composerFilesRef, - composerTerminalContextsRef, - keybindings, - terminalOpen, + readAttachmentDraft: () => ({ + images: composerImagesRef.current, + files: composerFilesRef.current, + }), + readTerminalContexts: () => composerTerminalContextsRef.current, + routeThreadRef, projectSelectionRequired, - isRevertingCheckpoint, pendingUserInputs, activePendingProgress, prompt, composerMenuOpen, - isComposerModelPickerOpen, isComposerCollapsedMobile, composerPreviewAnnotations, composerReviewComments, composerThreadContexts, - attachmentTargetKeyRef, - composerDraftTargetKeyRef, + attachmentTargetKey, + isComposerDraftTargetOpen: (key) => composerDraftTargetKeyRef.current === key, composerEditorRef, - isRevertingCheckpointRef, + isRevertingCheckpoint: () => isRevertingCheckpointRef.current, pendingThreadImportsRef, setComposerCursor, setComposerTrigger, @@ -3612,8 +3610,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)