From 504e1cb760244df969b8a813ce0e861f1bcd747d Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Sun, 26 Jul 2026 12:25:48 +0200 Subject: [PATCH 01/16] feat(tim): import tim-smart/t3code#4 Add custom "Open with" applications Source: https://github.com/tim-smart/t3code/pull/4 Source head: 8c4bdfbc5b57f6b600233244d330f9efa41dc498 Source commits: 08e1a4fb949585c3c441d6d00455fe904f72cd7b,cd43a401c6c148f1fe26cff72104ac527ea189f3,a8370e7502c552ebb064436e42e1c00f86f0946b,8c4bdfbc5b57f6b600233244d330f9efa41dc498 Imported: complete product delta from the source PR. --- .../src/backend/DesktopBackendPool.test.ts | 9 +- .../src/electron/ElectronDialog.test.ts | 95 +- apps/desktop/src/electron/ElectronDialog.ts | 254 +++-- .../src/electron/MacApplicationIcon.test.ts | 60 + .../src/electron/MacApplicationIcon.ts | 125 ++ apps/desktop/src/ipc/DesktopIpcHandlers.ts | 8 + apps/desktop/src/ipc/channels.ts | 3 + apps/desktop/src/ipc/methods/openWith.test.ts | 43 + apps/desktop/src/ipc/methods/openWith.ts | 47 + apps/desktop/src/main.ts | 7 +- apps/desktop/src/preload.ts | 4 + .../settings/DesktopClientSettings.test.ts | 13 +- .../desktop/src/shell/DesktopOpenWith.test.ts | 224 ++++ apps/desktop/src/shell/DesktopOpenWith.ts | 323 ++++++ .../src/window/DesktopApplicationMenu.test.ts | 1 + apps/web/src/components/ChatView.tsx | 3 +- .../src/components/chat/ChatHeader.test.ts | 8 +- apps/web/src/components/chat/ChatHeader.tsx | 8 +- apps/web/src/components/chat/OpenInPicker.tsx | 1007 +++++++++++++---- .../KeybindingsSettings.logic.test.ts | 1 + .../settings/KeybindingsSettings.logic.ts | 1 + apps/web/src/editorPreferences.ts | 6 +- apps/web/src/localApi.test.ts | 30 + apps/web/src/localApi.ts | 12 + apps/web/src/openWith.test.ts | 98 ++ apps/web/src/openWith.ts | 101 ++ packages/contracts/src/index.ts | 1 + packages/contracts/src/ipc.ts | 11 + packages/contracts/src/openWith.test.ts | 77 ++ packages/contracts/src/openWith.ts | 179 +++ packages/contracts/src/settings.test.ts | 6 + packages/contracts/src/settings.ts | 7 + 32 files changed, 2414 insertions(+), 358 deletions(-) create mode 100644 apps/desktop/src/electron/MacApplicationIcon.test.ts create mode 100644 apps/desktop/src/electron/MacApplicationIcon.ts create mode 100644 apps/desktop/src/ipc/methods/openWith.test.ts create mode 100644 apps/desktop/src/ipc/methods/openWith.ts create mode 100644 apps/desktop/src/shell/DesktopOpenWith.test.ts create mode 100644 apps/desktop/src/shell/DesktopOpenWith.ts create mode 100644 apps/web/src/openWith.test.ts create mode 100644 apps/web/src/openWith.ts create mode 100644 packages/contracts/src/openWith.test.ts create mode 100644 packages/contracts/src/openWith.ts diff --git a/apps/desktop/src/backend/DesktopBackendPool.test.ts b/apps/desktop/src/backend/DesktopBackendPool.test.ts index fa0811d5df7..9aaac689c34 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.test.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.test.ts @@ -11,6 +11,7 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import * as DesktopObservability from "../app/DesktopObservability.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as ElectronDialog from "../electron/ElectronDialog.ts"; +import * as MacApplicationIcon from "../electron/MacApplicationIcon.ts"; import * as DesktopWindow from "../window/DesktopWindow.ts"; import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts"; import * as DesktopBackendPool from "./DesktopBackendPool.ts"; @@ -66,7 +67,13 @@ function makePoolLayer( resolveWsl: () => Effect.die("unexpected WSL config resolve"), } satisfies DesktopBackendConfiguration.DesktopBackendConfiguration["Service"]), DesktopAppSettings.layerTest(), - ElectronDialog.layer, + ElectronDialog.layer.pipe( + Layer.provide( + Layer.succeed(MacApplicationIcon.MacApplicationIcon, { + resolveDataUrl: () => Effect.die("unexpected application icon resolution"), + } satisfies MacApplicationIcon.MacApplicationIcon["Service"]), + ), + ), Layer.succeed(DesktopWindow.DesktopWindow, { createMain: Effect.die("unexpected window create"), ensureMain: Effect.die("unexpected window ensure"), diff --git a/apps/desktop/src/electron/ElectronDialog.test.ts b/apps/desktop/src/electron/ElectronDialog.test.ts index 388b3fd2c15..d54d82e1ca2 100644 --- a/apps/desktop/src/electron/ElectronDialog.test.ts +++ b/apps/desktop/src/electron/ElectronDialog.test.ts @@ -1,17 +1,22 @@ import { assert, describe, it } from "@effect/vitest"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import type { BrowserWindow } from "electron"; import { beforeEach, vi } from "vite-plus/test"; import * as ElectronDialog from "./ElectronDialog.ts"; +import * as MacApplicationIcon from "./MacApplicationIcon.ts"; -const { showMessageBoxMock, showOpenDialogMock, showErrorBoxMock } = vi.hoisted(() => ({ - showMessageBoxMock: vi.fn(), - showOpenDialogMock: vi.fn(), - showErrorBoxMock: vi.fn(), -})); +const { showMessageBoxMock, showOpenDialogMock, showErrorBoxMock, resolveDataUrlMock } = vi.hoisted( + () => ({ + showMessageBoxMock: vi.fn(), + showOpenDialogMock: vi.fn(), + showErrorBoxMock: vi.fn(), + resolveDataUrlMock: vi.fn(), + }), +); vi.mock("electron", () => ({ dialog: { @@ -21,13 +26,79 @@ vi.mock("electron", () => ({ }, })); +const applicationIconLayer = Layer.succeed(MacApplicationIcon.MacApplicationIcon, { + resolveDataUrl: (applicationPath) => + Effect.tryPromise({ + try: () => resolveDataUrlMock(applicationPath), + catch: (cause) => + new MacApplicationIcon.MacApplicationIconResolutionError({ applicationPath, cause }), + }), +} satisfies MacApplicationIcon.MacApplicationIcon["Service"]); +const dialogLayer = ElectronDialog.layer.pipe(Layer.provide(applicationIconLayer)); + describe("ElectronDialog", () => { beforeEach(() => { showMessageBoxMock.mockReset(); showOpenDialogMock.mockReset(); showErrorBoxMock.mockReset(); + resolveDataUrlMock.mockReset(); }); + it.effect("selects a macOS application and resolves its system icon", () => + Effect.gen(function* () { + const owner = { id: 12 } as BrowserWindow; + showOpenDialogMock.mockResolvedValue({ + canceled: false, + filePaths: ["/Applications/Terminal.app"], + }); + resolveDataUrlMock.mockResolvedValue("data:image/png;base64,icon"); + const dialog = yield* ElectronDialog.ElectronDialog; + + const result = yield* dialog.pickApplication({ owner: Option.some(owner) }); + + assert.deepEqual(Option.getOrNull(result), { + applicationPath: "/Applications/Terminal.app", + suggestedName: "Terminal", + iconDataUrl: "data:image/png;base64,icon", + }); + assert.deepEqual(showOpenDialogMock.mock.calls[0], [ + owner, + { + defaultPath: "/Applications", + properties: ["openFile"], + filters: [{ name: "Applications", extensions: ["app"] }], + }, + ]); + assert.deepEqual(resolveDataUrlMock.mock.calls[0], ["/Applications/Terminal.app"]); + }).pipe(Effect.provide(dialogLayer)), + ); + + it.effect("returns none when application selection is cancelled", () => + Effect.gen(function* () { + showOpenDialogMock.mockResolvedValue({ canceled: true, filePaths: [] }); + const dialog = yield* ElectronDialog.ElectronDialog; + assert.isTrue(Option.isNone(yield* dialog.pickApplication({ owner: Option.none() }))); + assert.equal(resolveDataUrlMock.mock.calls.length, 0); + }).pipe(Effect.provide(dialogLayer)), + ); + + it.effect("keeps a valid selection when icon extraction fails", () => + Effect.gen(function* () { + showOpenDialogMock.mockResolvedValue({ + canceled: false, + filePaths: ["/Users/test/Applications/My Tool.app"], + }); + resolveDataUrlMock.mockRejectedValue(new Error("icon unavailable")); + const dialog = yield* ElectronDialog.ElectronDialog; + + assert.deepEqual(Option.getOrNull(yield* dialog.pickApplication({ owner: Option.none() })), { + applicationPath: "/Users/test/Applications/My Tool.app", + suggestedName: "My Tool", + iconDataUrl: null, + }); + }).pipe(Effect.provide(dialogLayer)), + ); + it.effect("returns false without opening a confirm dialog for empty messages", () => Effect.gen(function* () { const dialog = yield* ElectronDialog.ElectronDialog; @@ -39,7 +110,7 @@ describe("ElectronDialog", () => { assert.isFalse(result); assert.equal(showMessageBoxMock.mock.calls.length, 0); - }).pipe(Effect.provide(ElectronDialog.layer)), + }).pipe(Effect.provide(dialogLayer)), ); it.effect("opens a confirm dialog for the owner window", () => @@ -65,7 +136,7 @@ describe("ElectronDialog", () => { message: "Delete worktree?", }, ]); - }).pipe(Effect.provide(ElectronDialog.layer)), + }).pipe(Effect.provide(dialogLayer)), ); it.effect("opens an app-level confirm dialog when there is no owner window", () => @@ -89,7 +160,7 @@ describe("ElectronDialog", () => { message: "Delete worktree?", }, ]); - }).pipe(Effect.provide(ElectronDialog.layer)), + }).pipe(Effect.provide(dialogLayer)), ); it.effect("preserves folder picker request context and cause", () => @@ -114,7 +185,7 @@ describe("ElectronDialog", () => { assert.include(error.message, "window 7"); assert.include(error.message, "/workspace"); assert.notInclude(error.message, cause.message); - }).pipe(Effect.provide(ElectronDialog.layer)), + }).pipe(Effect.provide(dialogLayer)), ); it.effect("preserves confirmation request context and cause", () => @@ -139,7 +210,7 @@ describe("ElectronDialog", () => { assert.include(error.message, "window 9"); assert.notInclude(error.message, "Confirm removal?"); assert.notInclude(error.message, cause.message); - }).pipe(Effect.provide(ElectronDialog.layer)), + }).pipe(Effect.provide(dialogLayer)), ); it.effect("preserves message box request context and cause", () => @@ -176,7 +247,7 @@ describe("ElectronDialog", () => { assert.notInclude(error.message, "Cancel"); assert.notInclude(error.message, "Discard"); assert.notInclude(error.message, cause.message); - }).pipe(Effect.provide(ElectronDialog.layer)), + }).pipe(Effect.provide(dialogLayer)), ); it.effect("preserves error box request context and cause in the defect", () => @@ -201,6 +272,6 @@ describe("ElectronDialog", () => { assert.notInclude(error.message, "Startup failed"); assert.notInclude(error.message, "Could not start."); assert.notInclude(error.message, cause.message); - }).pipe(Effect.provide(ElectronDialog.layer)), + }).pipe(Effect.provide(dialogLayer)), ); }); diff --git a/apps/desktop/src/electron/ElectronDialog.ts b/apps/desktop/src/electron/ElectronDialog.ts index be633971bea..83d53d9dab9 100644 --- a/apps/desktop/src/electron/ElectronDialog.ts +++ b/apps/desktop/src/electron/ElectronDialog.ts @@ -1,10 +1,15 @@ +// @effect-diagnostics nodeBuiltinImport:off - Electron's native picker returns host paths. import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; +import * as NodePath from "node:path"; import * as Electron from "electron"; +import type { DesktopApplicationSelection } from "@t3tools/contracts"; + +import * as MacApplicationIcon from "./MacApplicationIcon.ts"; const CONFIRM_BUTTON_INDEX = 1; @@ -23,6 +28,19 @@ export class ElectronDialogPickFolderError extends Schema.TaggedErrorClass()( + "ElectronDialogPickApplicationError", + { + ownerWindowId: Schema.NullOr(Schema.Number), + selectedPath: Schema.NullOr(Schema.String), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Failed to select a macOS application."; + } +} + export class ElectronDialogConfirmError extends Schema.TaggedErrorClass()( "ElectronDialogConfirmError", { @@ -69,6 +87,7 @@ export class ElectronDialogShowErrorBoxError extends Schema.TaggedErrorClass; } +export interface ElectronDialogPickApplicationInput { + readonly owner: Option.Option; +} + export interface ElectronDialogConfirmInput { readonly owner: Option.Option; readonly message: string; @@ -92,6 +115,12 @@ export class ElectronDialog extends Context.Service< readonly pickFolder: ( input: ElectronDialogPickFolderInput, ) => Effect.Effect, ElectronDialogPickFolderError>; + readonly pickApplication: ( + input: ElectronDialogPickApplicationInput, + ) => Effect.Effect< + Option.Option, + ElectronDialogPickApplicationError + >; readonly confirm: ( input: ElectronDialogConfirmInput, ) => Effect.Effect; @@ -102,97 +131,148 @@ export class ElectronDialog extends Context.Service< } >()("@t3tools/desktop/electron/ElectronDialog") {} -export const make = ElectronDialog.of({ - pickFolder: Effect.fn("desktop.electron.dialog.pickFolder")(function* (input) { - const ownerWindowId = Option.match(input.owner, { - onNone: () => null, - onSome: (owner) => owner.id, - }); - const defaultPath = Option.getOrNull(input.defaultPath); - const openDialogOptions: Electron.OpenDialogOptions = Option.match(input.defaultPath, { - onNone: () => ({ - properties: ["openDirectory", "createDirectory"], - }), - onSome: (defaultPath) => ({ - properties: ["openDirectory", "createDirectory"], - defaultPath, - }), - }); - const result = yield* Effect.tryPromise({ - try: () => - Option.match(input.owner, { - onNone: () => Electron.dialog.showOpenDialog(openDialogOptions), - onSome: (owner) => Electron.dialog.showOpenDialog(owner, openDialogOptions), +export const make = Effect.gen(function* () { + const applicationIcon = yield* MacApplicationIcon.MacApplicationIcon; + + return ElectronDialog.of({ + pickFolder: Effect.fn("desktop.electron.dialog.pickFolder")(function* (input) { + const ownerWindowId = Option.match(input.owner, { + onNone: () => null, + onSome: (owner) => owner.id, + }); + const defaultPath = Option.getOrNull(input.defaultPath); + const openDialogOptions: Electron.OpenDialogOptions = Option.match(input.defaultPath, { + onNone: () => ({ + properties: ["openDirectory", "createDirectory"], }), - catch: (cause) => - new ElectronDialogPickFolderError({ - ownerWindowId, + onSome: (defaultPath) => ({ + properties: ["openDirectory", "createDirectory"], defaultPath, - cause, - }), - }); - - if (result.canceled) { - return Option.none(); - } - return Option.fromNullishOr(result.filePaths[0]); - }), - confirm: Effect.fn("desktop.electron.dialog.confirm")(function* (input) { - const normalizedMessage = input.message.trim(); - if (normalizedMessage.length === 0) { - return false; - } - - const options = { - type: "question" as const, - buttons: ["No", "Yes"], - defaultId: 0, - cancelId: 0, - noLink: true, - message: normalizedMessage, - }; - const ownerWindowId = Option.match(input.owner, { - onNone: () => null, - onSome: (owner) => owner.id, - }); - const result = yield* Effect.tryPromise({ - try: () => - Option.match(input.owner, { - onNone: () => Electron.dialog.showMessageBox(options), - onSome: (owner) => Electron.dialog.showMessageBox(owner, options), }), - catch: (cause) => - new ElectronDialogConfirmError({ + }); + const result = yield* Effect.tryPromise({ + try: () => + Option.match(input.owner, { + onNone: () => Electron.dialog.showOpenDialog(openDialogOptions), + onSome: (owner) => Electron.dialog.showOpenDialog(owner, openDialogOptions), + }), + catch: (cause) => + new ElectronDialogPickFolderError({ + ownerWindowId, + defaultPath, + cause, + }), + }); + + if (result.canceled) { + return Option.none(); + } + return Option.fromNullishOr(result.filePaths[0]); + }), + pickApplication: Effect.fn("desktop.electron.dialog.pickApplication")(function* (input) { + const ownerWindowId = Option.match(input.owner, { + onNone: () => null, + onSome: (owner) => owner.id, + }); + const options: Electron.OpenDialogOptions = { + defaultPath: "/Applications", + properties: ["openFile"], + filters: [{ name: "Applications", extensions: ["app"] }], + }; + const result = yield* Effect.tryPromise({ + try: () => + Option.match(input.owner, { + onNone: () => Electron.dialog.showOpenDialog(options), + onSome: (owner) => Electron.dialog.showOpenDialog(owner, options), + }), + catch: (cause) => + new ElectronDialogPickApplicationError({ + ownerWindowId, + selectedPath: null, + cause, + }), + }); + if (result.canceled) return Option.none(); + + const applicationPath = result.filePaths[0]; + if ( + applicationPath === undefined || + !NodePath.isAbsolute(applicationPath) || + !applicationPath.toLowerCase().endsWith(".app") + ) { + return yield* new ElectronDialogPickApplicationError({ ownerWindowId, - promptLength: normalizedMessage.length, - cause, - }), - }); - return result.response === CONFIRM_BUTTON_INDEX; - }), - showMessageBox: (options) => - Effect.tryPromise({ - try: () => Electron.dialog.showMessageBox(options), - catch: (cause) => - new ElectronDialogShowMessageBoxError({ - type: options.type ?? null, - titleLength: options.title?.length ?? null, - messageLength: options.message.length, - detailLength: options.detail?.length ?? null, - buttonCount: options.buttons?.length ?? 0, - cause, - }), + selectedPath: applicationPath ?? null, + cause: new Error("The selected path is not an absolute .app bundle."), + }); + } + + const iconDataUrl = yield* applicationIcon + .resolveDataUrl(applicationPath) + .pipe(Effect.orElseSucceed(() => null)); + return Option.some({ + applicationPath, + suggestedName: NodePath.basename(applicationPath, NodePath.extname(applicationPath)), + iconDataUrl, + }); }), - showErrorBox: (title, content) => - Effect.try({ - try: () => Electron.dialog.showErrorBox(title, content), - catch: (cause) => - new ElectronDialogShowErrorBoxError({ - titleLength: title.length, - contentLength: content.length, - cause, - }), - }).pipe(Effect.orDie), + confirm: Effect.fn("desktop.electron.dialog.confirm")(function* (input) { + const normalizedMessage = input.message.trim(); + if (normalizedMessage.length === 0) { + return false; + } + + const options = { + type: "question" as const, + buttons: ["No", "Yes"], + defaultId: 0, + cancelId: 0, + noLink: true, + message: normalizedMessage, + }; + const ownerWindowId = Option.match(input.owner, { + onNone: () => null, + onSome: (owner) => owner.id, + }); + const result = yield* Effect.tryPromise({ + try: () => + Option.match(input.owner, { + onNone: () => Electron.dialog.showMessageBox(options), + onSome: (owner) => Electron.dialog.showMessageBox(owner, options), + }), + catch: (cause) => + new ElectronDialogConfirmError({ + ownerWindowId, + promptLength: normalizedMessage.length, + cause, + }), + }); + return result.response === CONFIRM_BUTTON_INDEX; + }), + showMessageBox: (options) => + Effect.tryPromise({ + try: () => Electron.dialog.showMessageBox(options), + catch: (cause) => + new ElectronDialogShowMessageBoxError({ + type: options.type ?? null, + titleLength: options.title?.length ?? null, + messageLength: options.message.length, + detailLength: options.detail?.length ?? null, + buttonCount: options.buttons?.length ?? 0, + cause, + }), + }), + showErrorBox: (title, content) => + Effect.try({ + try: () => Electron.dialog.showErrorBox(title, content), + catch: (cause) => + new ElectronDialogShowErrorBoxError({ + titleLength: title.length, + contentLength: content.length, + cause, + }), + }).pipe(Effect.orDie), + }); }); -export const layer = Layer.succeed(ElectronDialog, make); +export const layer = Layer.effect(ElectronDialog, make); diff --git a/apps/desktop/src/electron/MacApplicationIcon.test.ts b/apps/desktop/src/electron/MacApplicationIcon.test.ts new file mode 100644 index 00000000000..85f168b0e62 --- /dev/null +++ b/apps/desktop/src/electron/MacApplicationIcon.test.ts @@ -0,0 +1,60 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { beforeEach, vi } from "vite-plus/test"; + +import * as MacApplicationIcon from "./MacApplicationIcon.ts"; + +const { createFromBufferMock, getFileIconMock } = vi.hoisted(() => ({ + createFromBufferMock: vi.fn(), + getFileIconMock: vi.fn(), +})); + +vi.mock("electron", () => ({ + app: { getFileIcon: getFileIconMock }, + nativeImage: { createFromBuffer: createFromBufferMock }, +})); + +const applicationIconLayer = MacApplicationIcon.layer.pipe(Layer.provide(NodeServices.layer)); + +const provideLayer = (effect: Effect.Effect) => + effect.pipe(Effect.provide(applicationIconLayer)); + +describe("MacApplicationIcon", () => { + beforeEach(() => { + createFromBufferMock.mockReset(); + getFileIconMock.mockReset(); + }); + + it.effect("falls back to Electron's system icon lookup", () => + provideLayer( + Effect.gen(function* () { + getFileIconMock.mockResolvedValue({ toDataURL: () => "data:image/png;base64,icon" }); + const applicationIcon = yield* MacApplicationIcon.MacApplicationIcon; + + assert.equal( + yield* applicationIcon.resolveDataUrl("/missing/Fixture.app"), + "data:image/png;base64,icon", + ); + assert.deepEqual(getFileIconMock.mock.calls[0], [ + "/missing/Fixture.app", + { size: "large" }, + ]); + }), + ), + ); + + it.effect("reports system icon lookup failures with the application path", () => + provideLayer( + Effect.gen(function* () { + getFileIconMock.mockRejectedValue(new Error("icon unavailable")); + const applicationIcon = yield* MacApplicationIcon.MacApplicationIcon; + + const error = yield* Effect.flip(applicationIcon.resolveDataUrl("/missing/Fixture.app")); + assert.equal(error._tag, "MacApplicationIconResolutionError"); + assert.equal(error.applicationPath, "/missing/Fixture.app"); + }), + ), + ); +}); diff --git a/apps/desktop/src/electron/MacApplicationIcon.ts b/apps/desktop/src/electron/MacApplicationIcon.ts new file mode 100644 index 00000000000..48f096a2e71 --- /dev/null +++ b/apps/desktop/src/electron/MacApplicationIcon.ts @@ -0,0 +1,125 @@ +import * as Electron from "electron"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as ChildProcess from "effect/unstable/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; + +export class MacApplicationIconResolutionError extends Schema.TaggedErrorClass()( + "MacApplicationIconResolutionError", + { + applicationPath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to resolve the macOS application icon for ${this.applicationPath}.`; + } +} + +export class MacApplicationIcon extends Context.Service< + MacApplicationIcon, + { + readonly resolveDataUrl: ( + applicationPath: string, + ) => Effect.Effect; + } +>()("@t3tools/desktop/electron/MacApplicationIcon") {} + +export const make = Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const pathService = yield* Path.Path; + const fs = yield* FileSystem.FileSystem; + + const resolveBundleIconPath = Effect.fn( + "desktop.electron.macApplicationIcon.resolveBundleIconPath", + )(function* (applicationPath: string) { + const infoPlistPath = pathService.join(applicationPath, "Contents", "Info.plist"); + const configuredName = yield* spawner + .string( + ChildProcess.make( + "/usr/bin/plutil", + ["-extract", "CFBundleIconFile", "raw", infoPlistPath], + { stdin: "ignore", stdout: "pipe", stderr: "pipe" }, + ), + ) + .pipe(Effect.map((output) => output.trim())); + if ( + configuredName.length === 0 || + pathService.basename(configuredName) !== configuredName || + configuredName === "." || + configuredName === ".." + ) { + return Option.none(); + } + const iconName = pathService.extname(configuredName) + ? configuredName + : `${configuredName}.icns`; + const iconPath = pathService.join(applicationPath, "Contents", "Resources", iconName); + const stat = yield* fs.stat(iconPath); + return stat.type === "File" ? Option.some(iconPath) : Option.none(); + }); + + const convertIcnsToDataUrl = Effect.fn( + "desktop.electron.macApplicationIcon.convertIcnsToDataUrl", + )(function* (applicationPath: string, iconPath: string) { + return yield* Effect.gen(function* () { + const temporaryDirectory = yield* fs.makeTempDirectoryScoped({ + prefix: "t3code-open-with-icon-", + }); + const pngPath = pathService.join(temporaryDirectory, "icon.png"); + yield* spawner.string( + ChildProcess.make("/usr/bin/sips", ["-s", "format", "png", iconPath, "--out", pngPath], { + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }), + ); + const png = yield* fs.readFile(pngPath); + const image = yield* Effect.try({ + try: () => Electron.nativeImage.createFromBuffer(Buffer.from(png)), + catch: (cause) => new MacApplicationIconResolutionError({ applicationPath, cause }), + }); + if (image.isEmpty()) { + return yield* new MacApplicationIconResolutionError({ + applicationPath, + cause: new Error("The converted application icon is empty."), + }); + } + return image.toDataURL(); + }).pipe(Effect.scoped); + }); + + const resolveDataUrl = Effect.fn("desktop.electron.macApplicationIcon.resolveDataUrl")(function* ( + applicationPath: string, + ) { + // Electron can return a generic bundle icon, so prefer the app's declared ICNS asset. + const iconPath = yield* resolveBundleIconPath(applicationPath).pipe( + Effect.orElseSucceed(() => Option.none()), + ); + if (Option.isSome(iconPath)) { + const bundleIcon = yield* convertIcnsToDataUrl(applicationPath, iconPath.value).pipe( + Effect.map(Option.some), + Effect.orElseSucceed(() => Option.none()), + ); + if (Option.isSome(bundleIcon)) return bundleIcon.value; + } + + const image = yield* Effect.tryPromise({ + try: () => Electron.app.getFileIcon(applicationPath, { size: "large" }), + catch: (cause) => new MacApplicationIconResolutionError({ applicationPath, cause }), + }); + return yield* Effect.try({ + try: () => image.toDataURL(), + catch: (cause) => new MacApplicationIconResolutionError({ applicationPath, cause }), + }); + }); + + return MacApplicationIcon.of({ resolveDataUrl }); +}); + +export const layer = Layer.effect(MacApplicationIcon, make); diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index e478d0c6eff..402e9d75a23 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -42,6 +42,11 @@ import { showContextMenu, } from "./methods/window.ts"; import * as PreviewIpc from "./methods/preview.ts"; +import { + openWith, + pickOpenWithApplication, + resolveOpenWithPresentations, +} from "./methods/openWith.ts"; import { getWslState, setWslBackendEnabled, setWslDistro, setWslOnly } from "./methods/wsl.ts"; export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers")(function* () { @@ -83,6 +88,9 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(setTheme); yield* ipc.handle(showContextMenu); yield* ipc.handle(openExternal); + yield* ipc.handle(pickOpenWithApplication); + yield* ipc.handle(resolveOpenWithPresentations); + yield* ipc.handle(openWith); yield* ipc.handle(getUpdateState); yield* ipc.handle(setUpdateChannel); yield* ipc.handle(downloadUpdate); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 95c725130e5..459fd1982ce 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -3,6 +3,9 @@ export const CONFIRM_CHANNEL = "desktop:confirm"; export const SET_THEME_CHANNEL = "desktop:set-theme"; export const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; +export const PICK_OPEN_WITH_APPLICATION_CHANNEL = "desktop:pick-open-with-application"; +export const RESOLVE_OPEN_WITH_PRESENTATIONS_CHANNEL = "desktop:resolve-open-with-presentations"; +export const OPEN_WITH_CHANNEL = "desktop:open-with"; export const MENU_ACTION_CHANNEL = "desktop:menu-action"; export const GET_WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:get-window-fullscreen-state"; export const WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:window-fullscreen-state"; diff --git a/apps/desktop/src/ipc/methods/openWith.test.ts b/apps/desktop/src/ipc/methods/openWith.test.ts new file mode 100644 index 00000000000..9e606199ebd --- /dev/null +++ b/apps/desktop/src/ipc/methods/openWith.test.ts @@ -0,0 +1,43 @@ +import { assert, describe, it } from "@effect/vitest"; +import { OpenWithEntryId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; + +import * as DesktopOpenWith from "../../shell/DesktopOpenWith.ts"; +import { openWith, resolveOpenWithPresentations } from "./openWith.ts"; + +describe("Open With IPC methods", () => { + it.effect("encodes presentations and delegates launch inputs", () => + Effect.gen(function* () { + const opened = yield* Ref.make(null); + const entryId = OpenWithEntryId.make("terminal"); + const layer = Layer.succeed( + DesktopOpenWith.DesktopOpenWith, + DesktopOpenWith.DesktopOpenWith.of({ + resolvePresentations: Effect.succeed([ + { entryId, available: true, iconDataUrl: "data:image/png;base64,abc" }, + ]), + open: (input) => Ref.set(opened, input), + }), + ); + + assert.deepEqual( + yield* resolveOpenWithPresentations.handler(undefined).pipe(Effect.provide(layer)), + [{ entryId: "terminal", available: true, iconDataUrl: "data:image/png;base64,abc" }], + ); + yield* openWith + .handler({ + environmentId: "primary", + entryId: "terminal", + directory: "/tmp/project", + }) + .pipe(Effect.provide(layer)); + assert.deepEqual(yield* Ref.get(opened), { + environmentId: "primary", + entryId: "terminal", + directory: "/tmp/project", + }); + }), + ); +}); diff --git a/apps/desktop/src/ipc/methods/openWith.ts b/apps/desktop/src/ipc/methods/openWith.ts new file mode 100644 index 00000000000..eb19e0ec7e3 --- /dev/null +++ b/apps/desktop/src/ipc/methods/openWith.ts @@ -0,0 +1,47 @@ +import { + DesktopApplicationSelection, + DesktopOpenWithInput, + OpenWithEntryPresentation, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import * as ElectronDialog from "../../electron/ElectronDialog.ts"; +import * as ElectronWindow from "../../electron/ElectronWindow.ts"; +import * as DesktopOpenWith from "../../shell/DesktopOpenWith.ts"; +import * as IpcChannels from "../channels.ts"; +import * as DesktopIpc from "../DesktopIpc.ts"; + +export const pickOpenWithApplication = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.PICK_OPEN_WITH_APPLICATION_CHANNEL, + payload: Schema.Void, + result: Schema.NullOr(DesktopApplicationSelection), + handler: Effect.fn("desktop.ipc.openWith.pickApplication")(function* () { + const dialog = yield* ElectronDialog.ElectronDialog; + const electronWindow = yield* ElectronWindow.ElectronWindow; + return Option.getOrNull( + yield* dialog.pickApplication({ owner: yield* electronWindow.focusedMainOrFirst }), + ); + }), +}); + +export const resolveOpenWithPresentations = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.RESOLVE_OPEN_WITH_PRESENTATIONS_CHANNEL, + payload: Schema.Void, + result: Schema.Array(OpenWithEntryPresentation), + handler: Effect.fn("desktop.ipc.openWith.resolvePresentations")(function* () { + const openWith = yield* DesktopOpenWith.DesktopOpenWith; + return yield* openWith.resolvePresentations; + }), +}); + +export const openWith = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.OPEN_WITH_CHANNEL, + payload: DesktopOpenWithInput, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.openWith.open")(function* (input) { + const openWith = yield* DesktopOpenWith.DesktopOpenWith; + yield* openWith.open(input); + }), +}); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 9795f04e8ae..26e5d3ac412 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -23,6 +23,7 @@ import serverPackageJson from "../../server/package.json" with { type: "json" }; import * as DesktopIpc from "./ipc/DesktopIpc.ts"; import * as ElectronApp from "./electron/ElectronApp.ts"; import * as ElectronDialog from "./electron/ElectronDialog.ts"; +import * as MacApplicationIcon from "./electron/MacApplicationIcon.ts"; import * as ElectronMenu from "./electron/ElectronMenu.ts"; import * as ElectronProtocol from "./electron/ElectronProtocol.ts"; import * as ElectronSafeStorage from "./electron/ElectronSafeStorage.ts"; @@ -49,6 +50,7 @@ import * as DesktopClientSettings from "./settings/DesktopClientSettings.ts"; import * as DesktopSavedEnvironments from "./settings/DesktopSavedEnvironments.ts"; import * as DesktopAppSettings from "./settings/DesktopAppSettings.ts"; import * as DesktopShellEnvironment from "./shell/DesktopShellEnvironment.ts"; +import * as DesktopOpenWith from "./shell/DesktopOpenWith.ts"; import * as DesktopSshEnvironment from "./ssh/DesktopSshEnvironment.ts"; import * as DesktopSshPasswordPrompts from "./ssh/DesktopSshPasswordPrompts.ts"; import * as DesktopState from "./app/DesktopState.ts"; @@ -120,7 +122,7 @@ const electronLayer = Layer.mergeAll( ElectronUpdater.layer, ElectronWindow.layer, DesktopIpc.layer(Electron.ipcMain), -); +).pipe(Layer.provideMerge(MacApplicationIcon.layer)); const desktopFoundationLayer = Layer.mergeAll( DesktopState.layer, @@ -178,6 +180,7 @@ const desktopApplicationLayer = Layer.mergeAll( DesktopLifecycle.layer, DesktopApplicationMenu.layer, DesktopShellEnvironment.layer, + DesktopOpenWith.layer, desktopSshLayer, ).pipe( Layer.provideMerge(DesktopUpdates.layer), @@ -195,10 +198,10 @@ const desktopRuntimeLayer = desktopClerkLayer.pipe( Layer.flatMap((clerkContext) => desktopApplicationLayer.pipe( Layer.provideMerge(Layer.succeedContext(clerkContext)), - Layer.provideMerge(NodeServices.layer), Layer.provideMerge(NodeHttpClient.layerUndici), Layer.provideMerge(NetService.layer), Layer.provideMerge(electronLayer), + Layer.provideMerge(NodeServices.layer), ), ), ); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 228114fd1d1..3a12ae1f39e 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -105,6 +105,10 @@ contextBridge.exposeInMainWorld("desktopBridge", { ...(position === undefined ? {} : { position }), }), openExternal: (url: string) => ipcRenderer.invoke(IpcChannels.OPEN_EXTERNAL_CHANNEL, url), + pickOpenWithApplication: () => ipcRenderer.invoke(IpcChannels.PICK_OPEN_WITH_APPLICATION_CHANNEL), + resolveOpenWithPresentations: () => + ipcRenderer.invoke(IpcChannels.RESOLVE_OPEN_WITH_PRESENTATIONS_CHANNEL), + openWith: (input) => ipcRenderer.invoke(IpcChannels.OPEN_WITH_CHANNEL, input), onMenuAction: (listener) => { const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => { if (typeof action !== "string") return; diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 8f50aa8f882..bc1a06e7c34 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -1,6 +1,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; -import { ClientSettingsSchema, type ClientSettings } from "@t3tools/contracts"; +import { ClientSettingsSchema, OpenWithEntryId, type ClientSettings } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -20,6 +20,17 @@ const clientSettings: ClientSettings = { diffIgnoreWhitespace: true, favorites: [], glassOpacity: 80, + openWithEntries: [ + { + id: OpenWithEntryId.make("terminal"), + name: "Terminal", + kind: "terminal", + invocation: { type: "mac-application", applicationPath: "/Applications/Terminal.app" }, + directoryMode: "open-target", + arguments: [], + }, + ], + preferredOpenWith: { type: "custom", id: OpenWithEntryId.make("terminal") }, providerModelPreferences: {}, sidebarAutoSettleAfterDays: 3, sidebarProjectGroupingMode: "repository_path", diff --git a/apps/desktop/src/shell/DesktopOpenWith.test.ts b/apps/desktop/src/shell/DesktopOpenWith.test.ts new file mode 100644 index 00000000000..55dbafb8fbd --- /dev/null +++ b/apps/desktop/src/shell/DesktopOpenWith.test.ts @@ -0,0 +1,224 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import { + DEFAULT_CLIENT_SETTINGS, + EnvironmentId, + OpenWithEntry, + OpenWithEntryId, + PRIMARY_LOCAL_ENVIRONMENT_ID, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import * as DesktopConfig from "../app/DesktopConfig.ts"; +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as MacApplicationIcon from "../electron/MacApplicationIcon.ts"; +import * as DesktopClientSettings from "../settings/DesktopClientSettings.ts"; +import * as DesktopOpenWith from "./DesktopOpenWith.ts"; + +const decodeEntry = Schema.decodeUnknownSync(OpenWithEntry); + +const configuredEntries = [ + decodeEntry({ + id: "echo", + name: "Echo", + kind: "other", + invocation: { type: "command", executable: "/bin/echo" }, + directoryMode: "open-target", + arguments: [], + }), + decodeEntry({ + id: "missing", + name: "Missing", + kind: "other", + invocation: { type: "command", executable: "/definitely/missing/t3-open-with" }, + directoryMode: "open-target", + arguments: [], + }), +] as const; + +const environmentLayer = DesktopEnvironment.layer({ + dirname: "/repo/apps/desktop/src", + homeDirectory: "/tmp", + platform: "darwin", + processArch: "arm64", + appVersion: "1.0.0", + appPath: "/repo", + isPackaged: true, + resourcesPath: "/repo/resources", + runningUnderArm64Translation: false, +}).pipe( + Layer.provide( + Layer.mergeAll( + NodeServices.layer, + DesktopConfig.layerTest({ T3CODE_HOME: "/tmp/t3-open-with" }), + ), + ), +); + +const openWithLayer = DesktopOpenWith.layer.pipe( + Layer.provideMerge( + Layer.succeed(MacApplicationIcon.MacApplicationIcon, { + resolveDataUrl: () => Effect.die("unexpected application icon resolution"), + } satisfies MacApplicationIcon.MacApplicationIcon["Service"]), + ), + Layer.provideMerge( + DesktopClientSettings.layerTest( + Option.some({ + ...DEFAULT_CLIENT_SETTINGS, + openWithEntries: configuredEntries, + }), + ), + ), + Layer.provideMerge(environmentLayer), + Layer.provideMerge(NodeServices.layer), +); + +describe("DesktopOpenWith launch resolution", () => { + it.effect("appends the directory for open-target command entries", () => + Effect.gen(function* () { + const launch = yield* DesktopOpenWith.resolveOpenWithLaunch( + decodeEntry({ + id: "echo", + name: "Echo", + kind: "other", + invocation: { type: "command", executable: "/bin/echo" }, + directoryMode: "open-target", + arguments: ["--flag"], + }), + "/tmp/work tree", + "darwin", + ); + assert.deepEqual(launch, { + command: "/bin/echo", + args: ["--flag", "/tmp/work tree"], + shell: false, + cwd: null, + }); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("sets cwd without adding a directory argument in working-directory mode", () => + Effect.gen(function* () { + const launch = yield* DesktopOpenWith.resolveOpenWithLaunch( + decodeEntry({ + id: "echo", + name: "Echo", + kind: "other", + invocation: { type: "command", executable: "/bin/echo" }, + directoryMode: "working-directory", + arguments: ["one argument"], + }), + "/tmp/work tree", + "darwin", + ); + assert.deepEqual(launch, { + command: "/bin/echo", + args: ["one argument"], + shell: false, + cwd: "/tmp/work tree", + }); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("expands placeholders within independent argument rows without shell parsing", () => + Effect.gen(function* () { + const launch = yield* DesktopOpenWith.resolveOpenWithLaunch( + decodeEntry({ + id: "echo", + name: "Echo", + kind: "other", + invocation: { type: "command", executable: "/bin/echo" }, + directoryMode: "custom-arguments", + arguments: ["prefix={directory}=suffix", "literal value"], + }), + "/tmp/work tree", + "darwin", + ); + assert.deepEqual(launch.args, ["prefix=/tmp/work tree=suffix", "literal value"]); + assert.isFalse(launch.shell ?? false); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("resolves CFBundleExecutable and reports missing bundle executables", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const base = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-open-with-test-" }); + const applicationPath = path.join(base, "Fixture.app"); + const contentsPath = path.join(applicationPath, "Contents"); + const executableDirectory = path.join(contentsPath, "MacOS"); + yield* fileSystem.makeDirectory(executableDirectory, { recursive: true }); + yield* fileSystem.writeFileString( + path.join(contentsPath, "Info.plist"), + ` + CFBundleExecutableFixture`, + ); + const executablePath = path.join(executableDirectory, "Fixture"); + yield* fileSystem.writeFileString(executablePath, "#!/bin/sh\n"); + + assert.equal( + yield* DesktopOpenWith.resolveMacBundleExecutable(applicationPath), + executablePath, + ); + yield* fileSystem.remove(executablePath); + const error = yield* Effect.flip(DesktopOpenWith.resolveMacBundleExecutable(applicationPath)); + assert.equal(error.reason, "missing-executable"); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); + + it.effect("reports available and missing command presentations", () => + Effect.gen(function* () { + const openWith = yield* DesktopOpenWith.DesktopOpenWith; + const presentations = yield* openWith.resolvePresentations; + assert.deepEqual(presentations, [ + { entryId: configuredEntries[0].id, available: true, iconDataUrl: null }, + { + entryId: configuredEntries[1].id, + available: false, + iconDataUrl: null, + unavailableReason: "Executable not found: /definitely/missing/t3-open-with", + }, + ]); + }).pipe(Effect.provide(openWithLayer)), + ); + + it.effect("rejects secondary environments, invalid targets, and unknown entry ids", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-open-target-" }); + const openWith = yield* DesktopOpenWith.DesktopOpenWith; + + const remoteError = yield* Effect.flip( + openWith.open({ + environmentId: EnvironmentId.make("ssh:test"), + entryId: configuredEntries[0].id, + directory, + }), + ); + assert.equal(remoteError._tag, "OpenWithEnvironmentError"); + + const relativeError = yield* Effect.flip( + openWith.open({ + environmentId: EnvironmentId.make(PRIMARY_LOCAL_ENVIRONMENT_ID), + entryId: configuredEntries[0].id, + directory: "relative/path", + }), + ); + assert.equal(relativeError._tag, "OpenWithInvalidTargetError"); + + const unknownError = yield* Effect.flip( + openWith.open({ + environmentId: EnvironmentId.make(PRIMARY_LOCAL_ENVIRONMENT_ID), + entryId: OpenWithEntryId.make("unknown"), + directory, + }), + ); + assert.equal(unknownError._tag, "OpenWithMissingEntryError"); + }).pipe(Effect.provide(openWithLayer), Effect.scoped), + ); +}); diff --git a/apps/desktop/src/shell/DesktopOpenWith.ts b/apps/desktop/src/shell/DesktopOpenWith.ts new file mode 100644 index 00000000000..464d71fce48 --- /dev/null +++ b/apps/desktop/src/shell/DesktopOpenWith.ts @@ -0,0 +1,323 @@ +// @effect-diagnostics nodeBuiltinImport:off - macOS bundle paths use the host path grammar. +import { + OpenWithBundleResolutionError, + OpenWithEnvironmentError, + OpenWithInvalidTargetError, + OpenWithMissingEntryError, + OpenWithSpawnError, + OpenWithUnavailableApplicationError, + PRIMARY_LOCAL_ENVIRONMENT_ID, + type DesktopOpenWithInput, + type OpenWithEntry, + type OpenWithEntryPresentation, + type OpenWithLaunchError, +} from "@t3tools/contracts"; +import { resolveCommandPath, resolveSpawnCommand } from "@t3tools/shared/shell"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as NodePath from "node:path"; +import * as ChildProcess from "effect/unstable/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; + +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as MacApplicationIcon from "../electron/MacApplicationIcon.ts"; +import * as DesktopClientSettings from "../settings/DesktopClientSettings.ts"; + +interface ResolvedLaunch { + readonly command: string; + readonly args: readonly string[]; + readonly cwd: string | null; + readonly shell?: boolean; +} + +const statPath = (path: string) => + FileSystem.FileSystem.pipe( + Effect.flatMap((fileSystem) => fileSystem.stat(path)), + Effect.map(Option.some), + Effect.orElseSucceed(() => Option.none()), + ); + +const applicationUnavailableReason = (entry: OpenWithEntry): string => + entry.invocation.type === "mac-application" + ? `Application not found: ${entry.invocation.applicationPath}` + : `Executable not found: ${entry.invocation.executable}`; + +const isMacApplicationPath = (value: string): boolean => + NodePath.isAbsolute(value) && value.toLowerCase().endsWith(".app"); + +const commandExists = Effect.fn("desktop.openWith.commandExists")(function* (command: string) { + return yield* resolveCommandPath(command).pipe( + Effect.as(true), + Effect.catchTag("CommandResolutionError", () => Effect.succeed(false)), + ); +}); + +const entryIsAvailable = Effect.fn("desktop.openWith.entryIsAvailable")(function* ( + entry: OpenWithEntry, + platform: NodeJS.Platform, +) { + if (entry.invocation.type === "command") { + return yield* commandExists(entry.invocation.executable); + } + if (platform !== "darwin" || !isMacApplicationPath(entry.invocation.applicationPath)) { + return false; + } + const stat = yield* statPath(entry.invocation.applicationPath); + return Option.isSome(stat) && stat.value.type === "Directory"; +}); + +export const resolveMacBundleExecutable = Effect.fn("desktop.openWith.resolveMacBundleExecutable")( + function* (applicationPath: string) { + if (!isMacApplicationPath(applicationPath)) { + return yield* new OpenWithBundleResolutionError({ + applicationPath, + reason: "invalid-application-path", + }); + } + const infoPlistPath = NodePath.join(applicationPath, "Contents", "Info.plist"); + const plistStat = yield* statPath(infoPlistPath); + if (Option.isNone(plistStat) || plistStat.value.type !== "File") { + return yield* new OpenWithBundleResolutionError({ + applicationPath, + reason: "missing-info-plist", + }); + } + + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const executableName = yield* spawner + .string( + ChildProcess.make( + "/usr/bin/plutil", + ["-extract", "CFBundleExecutable", "raw", "-o", "-", infoPlistPath], + { stdin: "ignore", stdout: "pipe", stderr: "pipe" }, + ), + ) + .pipe( + Effect.map((output) => output.trim()), + Effect.mapError( + (cause) => + new OpenWithBundleResolutionError({ + applicationPath, + reason: "malformed-info-plist", + cause, + }), + ), + ); + if ( + executableName.length === 0 || + executableName.includes("/") || + executableName.includes("\\") + ) { + return yield* new OpenWithBundleResolutionError({ + applicationPath, + reason: "malformed-info-plist", + }); + } + const executablePath = NodePath.join(applicationPath, "Contents", "MacOS", executableName); + const executableStat = yield* statPath(executablePath); + if (Option.isNone(executableStat) || executableStat.value.type !== "File") { + return yield* new OpenWithBundleResolutionError({ + applicationPath, + reason: "missing-executable", + }); + } + return executablePath; + }, +); + +const expandDirectoryArguments = (args: readonly string[], directory: string): string[] => + args.map((argument) => argument.replaceAll("{directory}", directory)); + +export const resolveOpenWithLaunch = Effect.fn("desktop.openWith.resolveLaunch")(function* ( + entry: OpenWithEntry, + directory: string, + platform: NodeJS.Platform, +): Effect.fn.Return< + ResolvedLaunch, + OpenWithLaunchError, + FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner +> { + if (!(yield* entryIsAvailable(entry, platform))) { + return yield* new OpenWithUnavailableApplicationError({ + entryId: entry.id, + executable: + entry.invocation.type === "mac-application" + ? entry.invocation.applicationPath + : entry.invocation.executable, + }); + } + + if (entry.directoryMode === "open-target") { + if (entry.invocation.type === "mac-application") { + return { + command: "/usr/bin/open", + args: ["-a", entry.invocation.applicationPath, directory], + cwd: null, + }; + } + const command = yield* resolveSpawnCommand(entry.invocation.executable, [ + ...entry.arguments, + directory, + ]); + return { ...command, cwd: null }; + } + + if (entry.directoryMode === "working-directory") { + if (entry.invocation.type === "mac-application") { + return { + command: yield* resolveMacBundleExecutable(entry.invocation.applicationPath), + args: [...entry.arguments], + cwd: directory, + }; + } + const command = yield* resolveSpawnCommand(entry.invocation.executable, entry.arguments); + return { ...command, cwd: directory }; + } + + if (!entry.arguments.some((argument) => argument.includes("{directory}"))) { + return yield* new OpenWithUnavailableApplicationError({ + entryId: entry.id, + executable: "Custom arguments must include {directory}", + }); + } + const args = expandDirectoryArguments(entry.arguments, directory); + if (entry.invocation.type === "mac-application") { + return { + command: yield* resolveMacBundleExecutable(entry.invocation.applicationPath), + args, + cwd: null, + }; + } + const command = yield* resolveSpawnCommand(entry.invocation.executable, args); + return { ...command, cwd: null }; +}); + +const spawnDetached = ( + entry: OpenWithEntry, + launch: ResolvedLaunch & { readonly shell?: boolean }, +) => + ChildProcessSpawner.ChildProcessSpawner.pipe( + Effect.flatMap((spawner) => + spawner.spawn( + ChildProcess.make(launch.command, launch.args, { + ...(launch.cwd === null ? {} : { cwd: launch.cwd }), + detached: true, + shell: launch.shell ?? false, + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + }), + ), + ), + Effect.flatMap((handle) => handle.unref), + Effect.asVoid, + Effect.scoped, + Effect.mapError( + (cause) => + new OpenWithSpawnError({ + entryId: entry.id, + command: launch.command, + args: [...launch.args], + cwd: launch.cwd, + cause, + }), + ), + ); + +export class DesktopOpenWith extends Context.Service< + DesktopOpenWith, + { + readonly resolvePresentations: Effect.Effect; + readonly open: (input: DesktopOpenWithInput) => Effect.Effect; + } +>()("@t3tools/desktop/shell/DesktopOpenWith") {} + +export const make = Effect.gen(function* () { + const clientSettings = yield* DesktopClientSettings.DesktopClientSettings; + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const applicationIcon = yield* MacApplicationIcon.MacApplicationIcon; + + const providePlatformServices = ( + effect: Effect.Effect< + A, + E, + FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner + >, + ): Effect.Effect => + effect.pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + + const resolvePresentations = Effect.gen(function* () { + const settings = yield* clientSettings.get; + if (Option.isNone(settings)) return []; + return yield* Effect.forEach(settings.value.openWithEntries, (entry) => + Effect.gen(function* () { + const available = yield* providePlatformServices( + entryIsAvailable(entry, environment.platform), + ); + const iconDataUrl = + available && entry.invocation.type === "mac-application" + ? yield* applicationIcon + .resolveDataUrl(entry.invocation.applicationPath) + .pipe(Effect.orElseSucceed(() => null)) + : null; + return { + entryId: entry.id, + available, + iconDataUrl, + ...(available ? {} : { unavailableReason: applicationUnavailableReason(entry) }), + } satisfies OpenWithEntryPresentation; + }), + ); + }).pipe(Effect.withSpan("desktop.openWith.resolvePresentations")); + + const open = Effect.fn("desktop.openWith.open")(function* (input: DesktopOpenWithInput) { + if (input.environmentId !== PRIMARY_LOCAL_ENVIRONMENT_ID) { + return yield* new OpenWithEnvironmentError({ environmentId: input.environmentId }); + } + if (!NodePath.isAbsolute(input.directory)) { + return yield* new OpenWithInvalidTargetError({ + directory: input.directory, + reason: "relative", + }); + } + const targetStat = yield* providePlatformServices(statPath(input.directory)); + if (Option.isNone(targetStat)) { + return yield* new OpenWithInvalidTargetError({ + directory: input.directory, + reason: "missing", + }); + } + if (targetStat.value.type !== "Directory") { + return yield* new OpenWithInvalidTargetError({ + directory: input.directory, + reason: "not-directory", + }); + } + const settings = yield* clientSettings.get; + const entry = Option.isSome(settings) + ? settings.value.openWithEntries.find((candidate) => candidate.id === input.entryId) + : undefined; + if (entry === undefined) { + return yield* new OpenWithMissingEntryError({ entryId: input.entryId }); + } + const launch = yield* providePlatformServices( + resolveOpenWithLaunch(entry, input.directory, environment.platform), + ); + yield* providePlatformServices(spawnDetached(entry, launch)); + }); + + return DesktopOpenWith.of({ resolvePresentations, open }); +}); + +export const layer = Layer.effect(DesktopOpenWith, make); diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index 168846466ed..149caa79747 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -50,6 +50,7 @@ const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, { const electronDialogLayer = Layer.succeed(ElectronDialog.ElectronDialog, { pickFolder: () => Effect.succeed(Option.none()), + pickApplication: () => Effect.succeed(Option.none()), confirm: () => Effect.succeed(false), showMessageBox: () => Effect.succeed({ response: 0, checkboxChecked: false }), showErrorBox: () => Effect.void, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index ab1256cddb3..8d1a789512e 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2331,7 +2331,8 @@ function ChatViewContent(props: ChatViewProps) { }), ); const keybindings = useAtomValue(primaryServerKeybindingsAtom); - const availableEditors = useAtomValue(primaryServerAvailableEditorsAtom); + const activeServerConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId)); + const availableEditors = activeServerConfig?.availableEditors ?? []; // Prefer an instance-id match so a custom Codex instance (e.g. // `codex_personal`) surfaces its own status/message in the banner rather // than the default Codex's. Falls back to first-match-by-kind when no diff --git a/apps/web/src/components/chat/ChatHeader.test.ts b/apps/web/src/components/chat/ChatHeader.test.ts index d716092fc3e..36508296203 100644 --- a/apps/web/src/components/chat/ChatHeader.test.ts +++ b/apps/web/src/components/chat/ChatHeader.test.ts @@ -16,24 +16,24 @@ describe("shouldShowOpenInPicker", () => { ).toBe(true); }); - it("hides the picker when hosted static mode has no primary environment", () => { + it("keeps built-in applications visible when hosted static mode has no primary environment", () => { expect( shouldShowOpenInPicker({ activeProjectName: "codething-mvp", activeThreadEnvironmentId: EnvironmentId.make("environment-remote"), primaryEnvironmentId: null, }), - ).toBe(false); + ).toBe(true); }); - it("hides the picker for remote environments", () => { + it("keeps built-in applications visible for remote environments", () => { expect( shouldShowOpenInPicker({ activeProjectName: "codething-mvp", activeThreadEnvironmentId: EnvironmentId.make("environment-remote"), primaryEnvironmentId, }), - ).toBe(false); + ).toBe(true); }); it("hides the picker when there is no active project", () => { diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index 3a7d57859a8..c0bb8417e54 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -48,11 +48,7 @@ export function shouldShowOpenInPicker(input: { readonly activeThreadEnvironmentId: EnvironmentId; readonly primaryEnvironmentId: EnvironmentId | null; }): boolean { - return ( - Boolean(input.activeProjectName) && - input.primaryEnvironmentId !== null && - input.activeThreadEnvironmentId === input.primaryEnvironmentId - ); + return Boolean(input.activeProjectName); } export const ChatHeader = memo(function ChatHeader({ @@ -82,7 +78,7 @@ export const ChatHeader = memo(function ChatHeader({ const showOpenInPicker = shouldShowOpenInPicker({ activeProjectName, activeThreadEnvironmentId, - primaryEnvironmentId, + primaryEnvironmentId: null, }); return (
diff --git a/apps/web/src/components/chat/OpenInPicker.tsx b/apps/web/src/components/chat/OpenInPicker.tsx index de5a2f7cfff..2c635a0a0f0 100644 --- a/apps/web/src/components/chat/OpenInPicker.tsx +++ b/apps/web/src/components/chat/OpenInPicker.tsx @@ -1,11 +1,68 @@ -import { EditorId, type EnvironmentId, type ResolvedKeybindingsConfig } from "@t3tools/contracts"; -import { memo, useCallback, useEffect, useMemo } from "react"; +import { + EditorId, + EnvironmentId, + OpenWithEntry as OpenWithEntrySchema, + PRIMARY_LOCAL_ENVIRONMENT_ID, + type OpenWithDirectoryMode, + type OpenWithEntry, + type OpenWithEntryKind, + type OpenWithEntryPresentation, + type OpenWithEntryRef, + type ResolvedKeybindingsConfig, +} from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import { + AppWindowIcon, + ChevronDownIcon, + Code2Icon, + FolderClosedIcon, + PlusIcon, + SettingsIcon, + SquareTerminalIcon, + Trash2Icon, + TriangleAlertIcon, +} from "lucide-react"; +import { memo, useCallback, useEffect, useId, useMemo, useState, type FormEvent } from "react"; + +import { readLegacyPreferredEditor } from "../../editorPreferences"; import { isOpenFavoriteEditorShortcut, shortcutLabelForCommand } from "../../keybindings"; -import { usePreferredEditor } from "../../editorPreferences"; -import { ChevronDownIcon, FolderClosedIcon } from "lucide-react"; +import { + mergeOpenWithOptions, + nextOpenWithEntryId, + refForOpenWithOption, + resolveEffectiveOpenWith, + type OpenWithOption, +} from "../../openWith"; +import { useClientSettings, useUpdateClientSettings } from "../../hooks/useSettings"; +import { ensureLocalApi } from "../../localApi"; +import { usePrimaryEnvironmentId } from "../../state/environments"; +import { shellEnvironment } from "../../state/shell"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { + AlertDialog, + AlertDialogClose, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogPopup, + AlertDialogTitle, +} from "../ui/alert-dialog"; import { Button } from "../ui/button"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "../ui/dialog"; import { Group, GroupSeparator } from "../ui/group"; -import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "../ui/menu"; +import { Input } from "../ui/input"; +import { Label } from "../ui/label"; +import { Menu, MenuItem, MenuPopup, MenuSeparator, MenuShortcut, MenuTrigger } from "../ui/menu"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; +import { stackedThreadToast, toastManager } from "../ui/toast"; import { AntigravityIcon, CursorIcon, @@ -31,156 +88,137 @@ import { RustRoverIcon, WebStormIcon, } from "../JetBrainsIcons"; -import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils"; -import { shellEnvironment } from "~/state/shell"; -import { useAtomCommand } from "~/state/use-atom-command"; - -type OpenInOption = { - label: string; - Icon: Icon; - value: EditorId; - kind: "brand" | "generic"; +import { cn, isMacPlatform, isWindowsPlatform, randomUUID } from "~/lib/utils"; + +type BuiltinPresentation = { + readonly label: string; + readonly Icon: Icon; + readonly value: EditorId; + readonly kind: "brand" | "generic"; }; -const resolveOptions = (platform: string, availableEditors: ReadonlyArray) => { - const baseOptions: ReadonlyArray = [ - { - label: "Cursor", - Icon: CursorIcon, - value: "cursor", - kind: "brand", - }, - { - label: "Trae", - Icon: TraeIcon, - value: "trae", - kind: "brand", - }, - { - label: "Kiro", - Icon: KiroIcon, - value: "kiro", - kind: "brand", - }, - { - label: "VS Code", - Icon: VisualStudioCode, - value: "vscode", - kind: "brand", - }, - { - label: "VS Code Insiders", - Icon: VisualStudioCodeInsiders, - value: "vscode-insiders", - kind: "brand", - }, - { - label: "VSCodium", - Icon: VSCodium, - value: "vscodium", - kind: "brand", - }, - { - label: "Zed", - Icon: Zed, - value: "zed", - kind: "brand", - }, - { - label: "Antigravity", - Icon: AntigravityIcon, - value: "antigravity", - kind: "brand", - }, - { - label: "IntelliJ IDEA", - Icon: IntelliJIdeaIcon, - value: "idea", - kind: "brand", - }, - { - label: "Aqua", - Icon: AquaIcon, - value: "aqua", - kind: "brand", - }, - { - label: "CLion", - Icon: CLionIcon, - value: "clion", - kind: "brand", - }, - { - label: "DataGrip", - Icon: DataGripIcon, - value: "datagrip", - kind: "brand", - }, - { - label: "DataSpell", - Icon: DataSpellIcon, - value: "dataspell", - kind: "brand", - }, - { - label: "GoLand", - Icon: GoLandIcon, - value: "goland", - kind: "brand", - }, - { - label: "PhpStorm", - Icon: PhpStormIcon, - value: "phpstorm", - kind: "brand", - }, - { - label: "PyCharm", - Icon: PyCharmIcon, - value: "pycharm", - kind: "brand", - }, - { - label: "Rider", - Icon: RiderIcon, - value: "rider", - kind: "brand", - }, - { - label: "RubyMine", - Icon: RubyMineIcon, - value: "rubymine", - kind: "brand", - }, - { - label: "RustRover", - Icon: RustRoverIcon, - value: "rustrover", - kind: "brand", - }, - { - label: "WebStorm", - Icon: WebStormIcon, - value: "webstorm", - kind: "brand", - }, - { - label: isMacPlatform(platform) - ? "Finder" - : isWindowsPlatform(platform) - ? "Explorer" - : "Files", - Icon: FolderClosedIcon, - value: "file-manager", - kind: "generic", - }, - ]; - const availableEditorSet = new Set(availableEditors); - return baseOptions.filter((option) => availableEditorSet.has(option.value)); +const BUILTIN_PRESENTATIONS: readonly BuiltinPresentation[] = [ + { label: "Cursor", Icon: CursorIcon, value: "cursor", kind: "brand" }, + { label: "Trae", Icon: TraeIcon, value: "trae", kind: "brand" }, + { label: "Kiro", Icon: KiroIcon, value: "kiro", kind: "brand" }, + { label: "VS Code", Icon: VisualStudioCode, value: "vscode", kind: "brand" }, + { + label: "VS Code Insiders", + Icon: VisualStudioCodeInsiders, + value: "vscode-insiders", + kind: "brand", + }, + { label: "VSCodium", Icon: VSCodium, value: "vscodium", kind: "brand" }, + { label: "Zed", Icon: Zed, value: "zed", kind: "brand" }, + { label: "Antigravity", Icon: AntigravityIcon, value: "antigravity", kind: "brand" }, + { label: "IntelliJ IDEA", Icon: IntelliJIdeaIcon, value: "idea", kind: "brand" }, + { label: "Aqua", Icon: AquaIcon, value: "aqua", kind: "brand" }, + { label: "CLion", Icon: CLionIcon, value: "clion", kind: "brand" }, + { label: "DataGrip", Icon: DataGripIcon, value: "datagrip", kind: "brand" }, + { label: "DataSpell", Icon: DataSpellIcon, value: "dataspell", kind: "brand" }, + { label: "GoLand", Icon: GoLandIcon, value: "goland", kind: "brand" }, + { label: "PhpStorm", Icon: PhpStormIcon, value: "phpstorm", kind: "brand" }, + { label: "PyCharm", Icon: PyCharmIcon, value: "pycharm", kind: "brand" }, + { label: "Rider", Icon: RiderIcon, value: "rider", kind: "brand" }, + { label: "RubyMine", Icon: RubyMineIcon, value: "rubymine", kind: "brand" }, + { label: "RustRover", Icon: RustRoverIcon, value: "rustrover", kind: "brand" }, + { label: "WebStorm", Icon: WebStormIcon, value: "webstorm", kind: "brand" }, + { label: "File Manager", Icon: FolderClosedIcon, value: "file-manager", kind: "generic" }, +]; + +const DESKTOP_PRIMARY_ENVIRONMENT_ID = EnvironmentId.make(PRIMARY_LOCAL_ENVIRONMENT_ID); + +const builtinPresentationById = new Map(BUILTIN_PRESENTATIONS.map((entry) => [entry.value, entry])); +const decodeOpenWithEntry = Schema.decodeUnknownSync(OpenWithEntrySchema); + +type ArgumentRow = { readonly id: string; readonly value: string }; +const makeArgumentRow = (value = ""): ArgumentRow => ({ id: randomUUID(), value }); + +const OPEN_WITH_KIND_LABELS: Record = { + editor: "Editor", + terminal: "Terminal", + "file-manager": "File manager", + other: "Other", +}; + +const DIRECTORY_MODE_LABELS: Record = { + "open-target": "Open target", + "working-directory": "Working directory", + "custom-arguments": "Custom arguments", }; -function getOpenInIconClass(kind: OpenInOption["kind"]) { - return cn(kind === "brand" ? "text-foreground opacity-100" : "text-muted-foreground"); +function categoryIcon(kind: OpenWithEntryKind): Icon { + if (kind === "editor") return Code2Icon; + if (kind === "terminal") return SquareTerminalIcon; + if (kind === "file-manager") return FolderClosedIcon; + return AppWindowIcon; +} + +function CustomIcon({ + entry, + presentation, + className, +}: { + entry: OpenWithEntry; + presentation: OpenWithEntryPresentation | null; + className?: string; +}) { + if (presentation?.iconDataUrl) { + return ; + } + const FallbackIcon = categoryIcon(entry.kind); + return
+
+ + + } + > + + + + {boardShortcutLabel ? `Board (${boardShortcutLabel})` : "Board"} + + +
; +}) { + if (thread.interactionMode !== "plan") { + return null; + } + + return ( + + + } + > + + + Plan-only thread + + ); +} + +/** + * Renders in the status pill slot with the same visual language as + * `ThreadStatusLabel`. Settled-ness is context-dependent (auto-settle window, + * PR state, server capability), so callers decide when to render this instead + * of the indicator re-deriving it from the thread. + */ +export function ThreadSettledIndicator({ thread }: { thread: Pick }) { + return ( + + + } + > + + Settled + + Settled + + ); +} + +/** + * The sidebar-v2 status indicator: colored label text, with an icon only for + * the working and done states. Shared by the v2 sidebar rows and board cards + * so both surfaces speak the same status language. + */ +export function ThreadStatusV2Indicator({ + status, + className, +}: { + status: SidebarV2TopStatus; + className?: string; +}) { + return ( + + {status.icon === "working" ? ( + + ) : status.icon === "done" ? ( + + ) : null} + {status.label} + + ); +} + export function ThreadStatusLabel({ status, compact = false, diff --git a/apps/web/src/components/board/Board.logic.test.ts b/apps/web/src/components/board/Board.logic.test.ts new file mode 100644 index 00000000000..b6b5445fd23 --- /dev/null +++ b/apps/web/src/components/board/Board.logic.test.ts @@ -0,0 +1,607 @@ +import { EnvironmentId, ProjectId, type VcsStatusResult } from "@t3tools/contracts"; +import { scopeProjectRef } from "@t3tools/client-runtime/environment"; +import { describe, expect, it } from "vite-plus/test"; +import { + BOARD_ARCHIVE_DROPPABLE_ID, + BOARD_SETTLED_COLUMN_DROPPABLE_ID, + BOARD_TRASH_DROPPABLE_ID, + BOARD_UNSETTLE_DROPPABLE_ID, + boardWorktreeGroupDragId, + boardWorktreeKey, + buildBoardColumns, + buildBoardProjectFilterPredicate, + countBoardColumnThreads, + deriveBoardColumn, + parseBoardWorktreeGroupDragId, + resolveBoardDropIntent, + sliceBoardSettledItems, + sortBoardThreads, + type BoardColumnItem, + type BoardColumnInput, +} from "./Board.logic"; + +const localEnvironmentId = EnvironmentId.make("environment-local"); +const remoteEnvironmentId = EnvironmentId.make("environment-remote"); + +function columnThreadIds( + items: readonly BoardColumnItem[], +): string[] { + return items.flatMap((item) => + item.kind === "thread" ? [item.thread.id] : item.threads.map((thread) => thread.id), + ); +} + +function makeGitStatus(overrides: Partial = {}): VcsStatusResult { + return { + isRepo: true, + hasPrimaryRemote: true, + isDefaultRef: false, + refName: "feature/board", + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + pr: null, + ...overrides, + }; +} + +function makePr(state: "open" | "closed" | "merged"): NonNullable { + return { + number: 42, + title: "Board view", + url: "https://github.com/example/repo/pull/42", + baseRef: "main", + headRef: "feature/board", + state, + }; +} + +function makeColumnInput(overrides: Partial = {}): BoardColumnInput { + return { + threadStatusLabel: null, + interactionMode: "default", + isSettled: false, + latestTurnCompletedAt: null, + readySessionUpdatedAt: null, + lastVisitedAt: null, + threadBranch: "feature/board", + hasDedicatedWorktree: false, + hasWorkingThreadForWorktree: false, + gitStatus: makeGitStatus(), + ...overrides, + }; +} + +describe("deriveBoardColumn", () => { + it("puts attention status pills in review ahead of working or merged lifecycle state", () => { + const gitStatus = makeGitStatus({ pr: makePr("merged") }); + expect( + deriveBoardColumn(makeColumnInput({ threadStatusLabel: "Pending Approval", gitStatus })), + ).toBe("review"); + expect( + deriveBoardColumn(makeColumnInput({ threadStatusLabel: "Awaiting Input", gitStatus })), + ).toBe("review"); + expect( + deriveBoardColumn( + makeColumnInput({ + interactionMode: "plan", + threadStatusLabel: "Plan Ready", + gitStatus, + }), + ), + ).toBe("review"); + expect(deriveBoardColumn(makeColumnInput({ threadStatusLabel: "Completed", gitStatus }))).toBe( + "review", + ); + }); + + it("puts working and connecting status pills in working, even with a merged PR", () => { + const gitStatus = makeGitStatus({ pr: makePr("merged") }); + expect(deriveBoardColumn(makeColumnInput({ threadStatusLabel: "Working", gitStatus }))).toBe( + "working", + ); + expect(deriveBoardColumn(makeColumnInput({ threadStatusLabel: "Connecting", gitStatus }))).toBe( + "working", + ); + }); + + it("defaults to review while git status is unloaded or the cwd is not a repo", () => { + expect(deriveBoardColumn(makeColumnInput({ gitStatus: null }))).toBe("review"); + expect( + deriveBoardColumn(makeColumnInput({ gitStatus: makeGitStatus({ isRepo: false }) })), + ).toBe("review"); + }); + + it("ignores git status from a shared cwd checked out on a different branch", () => { + const gitStatus = makeGitStatus({ + refName: "someone-elses-branch", + hasWorkingTreeChanges: true, + pr: makePr("open"), + }); + expect(deriveBoardColumn(makeColumnInput({ gitStatus }))).toBe("review"); + }); + + it("applies git status from a dedicated worktree regardless of ref name", () => { + const gitStatus = makeGitStatus({ refName: "detached-head", hasWorkingTreeChanges: true }); + expect(deriveBoardColumn(makeColumnInput({ hasDedicatedWorktree: true, gitStatus }))).toBe( + "review", + ); + }); + + it("puts a branch ahead of upstream in review", () => { + expect( + deriveBoardColumn(makeColumnInput({ gitStatus: makeGitStatus({ aheadCount: 2 }) })), + ).toBe("review"); + }); + + it("puts a never-pushed branch ahead of (or with unknown distance to) the default in review", () => { + expect( + deriveBoardColumn( + makeColumnInput({ + gitStatus: makeGitStatus({ hasUpstream: false, aheadOfDefaultCount: 3 }), + }), + ), + ).toBe("review"); + expect( + deriveBoardColumn(makeColumnInput({ gitStatus: makeGitStatus({ hasUpstream: false }) })), + ).toBe("review"); + }); + + it("puts a clean fully pushed feature branch without a PR in published", () => { + expect(deriveBoardColumn(makeColumnInput())).toBe("published"); + }); + + it("puts an open PR with unpublished work in review", () => { + const gitStatus = makeGitStatus({ + hasWorkingTreeChanges: true, + pr: makePr("open"), + }); + expect(deriveBoardColumn(makeColumnInput({ gitStatus }))).toBe("review"); + }); + + it("does not move a sibling thread to review for a dirty worktree that is still working", () => { + const gitStatus = makeGitStatus({ + hasWorkingTreeChanges: true, + pr: makePr("open"), + }); + expect( + deriveBoardColumn( + makeColumnInput({ + hasDedicatedWorktree: true, + hasWorkingThreadForWorktree: true, + gitStatus, + }), + ), + ).toBe("published"); + }); + + it("still moves locally-ahead siblings to review while their worktree is working", () => { + expect( + deriveBoardColumn( + makeColumnInput({ + hasDedicatedWorktree: true, + hasWorkingThreadForWorktree: true, + gitStatus: makeGitStatus({ aheadCount: 1, pr: makePr("open") }), + }), + ), + ).toBe("review"); + }); + + it("puts a clean open PR in published", () => { + expect( + deriveBoardColumn(makeColumnInput({ gitStatus: makeGitStatus({ pr: makePr("open") }) })), + ).toBe("published"); + }); + + it("keeps an unsettled merged PR in published instead of guessing settled", () => { + expect( + deriveBoardColumn(makeColumnInput({ gitStatus: makeGitStatus({ pr: makePr("merged") }) })), + ).toBe("published"); + }); + + it("lets the settled flag win regardless of git or completion state", () => { + expect(deriveBoardColumn(makeColumnInput({ isSettled: true, gitStatus: null }))).toBe( + "settled", + ); + expect( + deriveBoardColumn( + makeColumnInput({ + isSettled: true, + gitStatus: makeGitStatus({ hasWorkingTreeChanges: true, aheadCount: 1 }), + }), + ), + ).toBe("settled"); + expect( + deriveBoardColumn(makeColumnInput({ isSettled: true, threadStatusLabel: "Completed" })), + ).toBe("settled"); + }); + + it("puts an unseen turn completion in review regardless of git state", () => { + const unseen = { + latestTurnCompletedAt: "2026-07-22T10:00:00.000Z", + lastVisitedAt: "2026-07-22T09:00:00.000Z", + }; + expect(deriveBoardColumn(makeColumnInput({ ...unseen, gitStatus: null }))).toBe("review"); + expect( + deriveBoardColumn( + makeColumnInput({ ...unseen, gitStatus: makeGitStatus({ pr: makePr("merged") }) }), + ), + ).toBe("review"); + }); + + it("keeps a working status pill in working even with an unseen completion", () => { + expect( + deriveBoardColumn( + makeColumnInput({ + threadStatusLabel: "Working", + latestTurnCompletedAt: "2026-07-22T10:00:00.000Z", + lastVisitedAt: "2026-07-22T09:00:00.000Z", + }), + ), + ).toBe("working"); + }); + + it("keeps a visited ready session in review when its latest turn summary is unavailable", () => { + expect( + deriveBoardColumn( + makeColumnInput({ + latestTurnCompletedAt: null, + readySessionUpdatedAt: "2026-07-22T03:45:04.819Z", + lastVisitedAt: "2026-07-22T03:45:04.819Z", + gitStatus: null, + }), + ), + ).toBe("review"); + }); + + it("lets in-flight git work outrank a seen completion", () => { + const seen = { + latestTurnCompletedAt: "2026-07-22T09:00:00.000Z", + lastVisitedAt: "2026-07-22T10:00:00.000Z", + }; + expect( + deriveBoardColumn( + makeColumnInput({ ...seen, gitStatus: makeGitStatus({ hasWorkingTreeChanges: true }) }), + ), + ).toBe("review"); + expect(deriveBoardColumn(makeColumnInput({ ...seen }))).toBe("published"); + }); + + it("keeps a plan-only thread's column off its worktree's git state", () => { + // This git state would classify as published; a plan-mode thread does not + // own it (the implementation thread does), so the plan thread stays in + // review until settled. + const badgelessPlan = { + interactionMode: "plan" as const, + threadStatusLabel: null, + hasDedicatedWorktree: true, + gitStatus: makeGitStatus({ pr: makePr("open") }), + }; + expect( + deriveBoardColumn(makeColumnInput({ ...badgelessPlan, interactionMode: "default" })), + ).toBe("published"); + expect(deriveBoardColumn(makeColumnInput(badgelessPlan))).toBe("review"); + expect(deriveBoardColumn(makeColumnInput({ ...badgelessPlan, isSettled: true }))).toBe( + "settled", + ); + }); + + it("puts a clean default branch in review", () => { + const gitStatus = makeGitStatus({ refName: "main", isDefaultRef: true }); + expect(deriveBoardColumn(makeColumnInput({ threadBranch: "main", gitStatus }))).toBe("review"); + }); +}); + +describe("sortBoardThreads", () => { + const byUpdatedAt = (thread: { updatedAt: string }) => thread.updatedAt; + + it("orders by the selected timestamp descending", () => { + const sorted = sortBoardThreads( + [ + { id: "thread-1", updatedAt: "2026-07-20T10:00:00.000Z" }, + { id: "thread-2", updatedAt: "2026-07-21T10:00:00.000Z" }, + { id: "thread-3", updatedAt: "2026-07-19T10:00:00.000Z" }, + ], + byUpdatedAt, + ); + expect(sorted.map((thread) => thread.id)).toEqual(["thread-2", "thread-1", "thread-3"]); + }); + + it("breaks timestamp ties by thread id", () => { + const sorted = sortBoardThreads( + [ + { id: "thread-b", updatedAt: "2026-07-20T10:00:00.000Z" }, + { id: "thread-a", updatedAt: "2026-07-20T10:00:00.000Z" }, + ], + byUpdatedAt, + ); + expect(sorted.map((thread) => thread.id)).toEqual(["thread-a", "thread-b"]); + }); + + it("sorts invalid timestamps last", () => { + const sorted = sortBoardThreads( + [ + { id: "thread-1", updatedAt: "not-a-date" }, + { id: "thread-2", updatedAt: "2026-07-20T10:00:00.000Z" }, + ], + byUpdatedAt, + ); + expect(sorted.map((thread) => thread.id)).toEqual(["thread-2", "thread-1"]); + }); +}); + +describe("buildBoardColumns", () => { + it("sorts working threads by active session start (falling back to update time) and other columns by update time", () => { + const threads = [ + { + id: "thread-review-old", + updatedAt: "2026-07-18T10:00:00.000Z", + workingStartedAt: null, + }, + { + id: "thread-review-new", + updatedAt: "2026-07-21T10:00:00.000Z", + workingStartedAt: null, + }, + { + id: "thread-working-old", + updatedAt: "2026-07-22T10:00:00.000Z", + workingStartedAt: "2026-07-19T10:00:00.000Z", + }, + { + id: "thread-working-new", + updatedAt: "2026-07-20T10:00:00.000Z", + workingStartedAt: "2026-07-21T10:00:00.000Z", + }, + { + id: "thread-working-fallback", + updatedAt: "2026-07-23T10:00:00.000Z", + workingStartedAt: null, + }, + ]; + const columns = buildBoardColumns( + threads, + (thread) => (thread.id.startsWith("thread-working") ? "working" : "review"), + (thread) => thread.workingStartedAt, + ); + expect(columnThreadIds(columns.review)).toEqual(["thread-review-new", "thread-review-old"]); + expect(columnThreadIds(columns.working)).toEqual([ + "thread-working-fallback", + "thread-working-new", + "thread-working-old", + ]); + expect(columns.published).toEqual([]); + expect(columns.settled).toEqual([]); + }); + + it("hosts shared groups in their earliest column and orders members by actual column then time", () => { + const threads = [ + { + id: "group-review-old", + updatedAt: "2026-07-19T10:00:00.000Z", + workingStartedAt: null, + column: "review" as const, + groupKey: "shared-worktree", + }, + { + id: "group-working-old", + updatedAt: "2026-07-24T10:00:00.000Z", + workingStartedAt: "2026-07-20T10:00:00.000Z", + column: "working" as const, + groupKey: "shared-worktree", + }, + { + id: "group-published", + updatedAt: "2026-07-25T10:00:00.000Z", + workingStartedAt: null, + column: "published" as const, + groupKey: "shared-worktree", + }, + { + id: "group-review-new", + updatedAt: "2026-07-23T10:00:00.000Z", + workingStartedAt: null, + column: "review" as const, + groupKey: "shared-worktree", + }, + { + id: "group-working-new", + updatedAt: "2026-07-18T10:00:00.000Z", + workingStartedAt: "2026-07-22T10:00:00.000Z", + column: "working" as const, + groupKey: "shared-worktree", + }, + ]; + const columns = buildBoardColumns( + threads, + (thread) => thread.column, + (thread) => thread.workingStartedAt, + (thread) => thread.groupKey, + ); + + expect(columns.working).toEqual([ + { + kind: "worktreeGroup", + worktreeKey: "shared-worktree", + threads: [threads[4], threads[1], threads[3], threads[0], threads[2]], + }, + ]); + expect(columns.review).toEqual([]); + expect(columns.published).toEqual([]); + expect(columns.settled).toEqual([]); + }); + + it("uses the earliest represented column rather than moving every group to working", () => { + const threads = [ + { + id: "standalone-working", + updatedAt: "2026-07-22T10:00:00.000Z", + column: "working" as const, + groupKey: null, + }, + { + id: "group-settled", + updatedAt: "2026-07-23T10:00:00.000Z", + column: "settled" as const, + groupKey: "later-group", + }, + { + id: "group-review", + updatedAt: "2026-07-21T10:00:00.000Z", + column: "review" as const, + groupKey: "later-group", + }, + ]; + const columns = buildBoardColumns( + threads, + (thread) => thread.column, + () => null, + (thread) => thread.groupKey, + ); + + expect(columns.working).toEqual([{ kind: "thread", thread: threads[0] }]); + expect(columns.review).toEqual([ + { + kind: "worktreeGroup", + worktreeKey: "later-group", + threads: [threads[2], threads[1]], + }, + ]); + expect(columns.settled).toEqual([]); + }); +}); + +describe("countBoardColumnThreads", () => { + it("counts a worktree group as its member count", () => { + const items: BoardColumnItem<{ readonly id: string }>[] = [ + { kind: "thread", thread: { id: "thread-1" } }, + { + kind: "worktreeGroup", + worktreeKey: "shared-worktree", + threads: [{ id: "thread-2" }, { id: "thread-3" }], + }, + ]; + expect(countBoardColumnThreads(items)).toBe(3); + expect(countBoardColumnThreads([])).toBe(0); + }); +}); + +describe("sliceBoardSettledItems", () => { + const thread = (id: string): BoardColumnItem<{ readonly id: string }> => ({ + kind: "thread", + thread: { id }, + }); + const group = ( + worktreeKey: string, + ...ids: string[] + ): BoardColumnItem<{ readonly id: string }> => ({ + kind: "worktreeGroup", + worktreeKey, + threads: ids.map((id) => ({ id })), + }); + + it("returns the same array with no hidden count when the total fits the limit", () => { + const items = [thread("thread-1"), group("shared-worktree", "thread-2", "thread-3")]; + const result = sliceBoardSettledItems(items, 3); + expect(result.visibleItems).toBe(items); + expect(result.hiddenThreadCount).toBe(0); + }); + + it("slices plain thread items at the limit", () => { + const items = [thread("thread-1"), thread("thread-2"), thread("thread-3")]; + const result = sliceBoardSettledItems(items, 2); + expect(columnThreadIds(result.visibleItems)).toEqual(["thread-1", "thread-2"]); + expect(result.hiddenThreadCount).toBe(1); + }); + + it("includes a group straddling the limit whole and counts its members", () => { + const items = [ + thread("thread-1"), + group("shared-worktree", "thread-2", "thread-3", "thread-4"), + thread("thread-5"), + ]; + const result = sliceBoardSettledItems(items, 2); + expect(result.visibleItems).toEqual([items[0], items[1]]); + expect(result.hiddenThreadCount).toBe(1); + }); + + it("returns no visible items for a zero limit with non-empty input", () => { + const items = [thread("thread-1"), thread("thread-2")]; + const result = sliceBoardSettledItems(items, 0); + expect(result.visibleItems).toEqual([]); + expect(result.hiddenThreadCount).toBe(2); + }); +}); + +describe("buildBoardProjectFilterPredicate", () => { + const projectId = ProjectId.make("project-1"); + const otherProjectId = ProjectId.make("project-2"); + const snapshots = [ + { + projectKey: "logical-project-1", + memberProjectRefs: [ + scopeProjectRef(localEnvironmentId, projectId), + scopeProjectRef(remoteEnvironmentId, projectId), + ], + }, + ]; + + it("matches everything when no project is selected or the stored key no longer resolves", () => { + const noSelection = buildBoardProjectFilterPredicate({ selectedProjectKey: null, snapshots }); + expect(noSelection({ environmentId: localEnvironmentId, projectId: otherProjectId })).toBe( + true, + ); + const staleSelection = buildBoardProjectFilterPredicate({ + selectedProjectKey: "removed-project", + snapshots, + }); + expect(staleSelection({ environmentId: localEnvironmentId, projectId: otherProjectId })).toBe( + true, + ); + }); + + it("matches threads from any member project of the selected group", () => { + const predicate = buildBoardProjectFilterPredicate({ + selectedProjectKey: "logical-project-1", + snapshots, + }); + expect(predicate({ environmentId: localEnvironmentId, projectId })).toBe(true); + expect(predicate({ environmentId: remoteEnvironmentId, projectId })).toBe(true); + expect(predicate({ environmentId: localEnvironmentId, projectId: otherProjectId })).toBe(false); + }); +}); + +describe("boardWorktreeKey", () => { + it("returns null without a dedicated worktree", () => { + expect(boardWorktreeKey({ environmentId: localEnvironmentId, worktreePath: null })).toBeNull(); + expect(boardWorktreeKey({ environmentId: localEnvironmentId, worktreePath: " " })).toBeNull(); + }); +}); + +describe("resolveBoardDropIntent", () => { + it("maps the drop-zone droppables to their intents and everything else to null", () => { + expect(resolveBoardDropIntent(BOARD_ARCHIVE_DROPPABLE_ID)).toBe("archive"); + expect(resolveBoardDropIntent(BOARD_TRASH_DROPPABLE_ID)).toBe("trash"); + expect(resolveBoardDropIntent(BOARD_UNSETTLE_DROPPABLE_ID)).toBe("unsettle"); + expect(resolveBoardDropIntent(BOARD_SETTLED_COLUMN_DROPPABLE_ID)).toBe("settle"); + expect(resolveBoardDropIntent("board-column-review")).toBeNull(); + expect(resolveBoardDropIntent(null)).toBeNull(); + }); +}); + +describe("parseBoardWorktreeGroupDragId", () => { + it("round-trips the worktree key through the group drag id and rejects thread drag ids", () => { + const worktreeKey = boardWorktreeKey({ + environmentId: localEnvironmentId, + worktreePath: "/wt", + }); + expect(worktreeKey).not.toBeNull(); + const dragId = boardWorktreeGroupDragId(worktreeKey ?? ""); + + expect(dragId).not.toBe(worktreeKey); + expect(parseBoardWorktreeGroupDragId(dragId)).toBe(worktreeKey); + expect(parseBoardWorktreeGroupDragId("environment-local thread-1")).toBeNull(); + }); +}); diff --git a/apps/web/src/components/board/Board.logic.ts b/apps/web/src/components/board/Board.logic.ts new file mode 100644 index 00000000000..8414edbc3a4 --- /dev/null +++ b/apps/web/src/components/board/Board.logic.ts @@ -0,0 +1,385 @@ +import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; +import type { + EnvironmentId, + ProjectId, + ProviderInteractionMode, + ScopedProjectRef, + VcsStatusResult, +} from "@t3tools/contracts"; +import { toSortableTimestamp } from "../../lib/threadSort"; +import { isCompletionUnseen, type ThreadStatusPill } from "../Sidebar.logic"; +import { resolveThreadPr } from "../ThreadStatusIndicators"; + +export type BoardColumnId = "working" | "review" | "published" | "settled"; + +export const BOARD_COLUMN_IDS: readonly BoardColumnId[] = [ + "working", + "review", + "published", + "settled", +]; + +export const BOARD_COLUMN_LABELS: Record = { + working: "Working", + review: "Review", + published: "Published", + settled: "Settled", +}; + +export const BOARD_TRASH_DROPPABLE_ID = "board-trash"; +export const BOARD_ARCHIVE_DROPPABLE_ID = "board-archive"; +export const BOARD_UNSETTLE_DROPPABLE_ID = "board-unsettle"; +export const BOARD_SETTLED_COLUMN_DROPPABLE_ID = "board-column-settled"; + +export type BoardDropIntent = "archive" | "trash" | "settle" | "unsettle"; + +/** Drag-overlay feedback per drop intent, shared by the card and group overlays. */ +export const BOARD_DROP_INTENT_OVERLAY_CLASSES: Record = { + archive: "scale-90 border-amber-500 opacity-60", + trash: "scale-90 border-destructive opacity-60", + settle: "scale-90 border-primary opacity-60", + unsettle: "scale-90 border-emerald-500 opacity-60", +}; + +/** + * Intent implied by the droppable currently under the pointer, or null when + * the drag is over neither zone. Drives feedback on the dragged card itself — + * the card usually covers the drop zone, hiding the zone's own highlight. + */ +export function resolveBoardDropIntent( + droppableId: string | number | null | undefined, +): BoardDropIntent | null { + if (droppableId === BOARD_ARCHIVE_DROPPABLE_ID) return "archive"; + if (droppableId === BOARD_TRASH_DROPPABLE_ID) return "trash"; + if (droppableId === BOARD_UNSETTLE_DROPPABLE_ID) return "unsettle"; + if (droppableId === BOARD_SETTLED_COLUMN_DROPPABLE_ID) return "settle"; + return null; +} + +const BOARD_WORKTREE_GROUP_DRAG_PREFIX = "board-worktree-group\u0000"; + +/** Draggable id for a whole worktree group; drops act on every member thread. */ +export function boardWorktreeGroupDragId(worktreeKey: string): string { + return `${BOARD_WORKTREE_GROUP_DRAG_PREFIX}${worktreeKey}`; +} + +/** Worktree key encoded in a group draggable id, or null for thread drags. */ +export function parseBoardWorktreeGroupDragId( + dragId: string | number | null | undefined, +): string | null { + return typeof dragId === "string" && dragId.startsWith(BOARD_WORKTREE_GROUP_DRAG_PREFIX) + ? dragId.slice(BOARD_WORKTREE_GROUP_DRAG_PREFIX.length) + : null; +} + +export interface BoardColumnInput { + threadStatusLabel: ThreadStatusPill["label"] | null; + interactionMode: ProviderInteractionMode; + isSettled: boolean; + latestTurnCompletedAt: string | null; + readySessionUpdatedAt: string | null; + lastVisitedAt: string | null; + threadBranch: string | null; + hasDedicatedWorktree: boolean; + hasWorkingThreadForWorktree: boolean; + gitStatus: VcsStatusResult | null; +} + +/** + * Whether the thread completed after the user's last visit. Falls back to the + * ready-session timestamp for providers whose shell cannot retain a + * latest-turn summary; both sources follow the sidebar's `isCompletionUnseen` + * rules. + */ +export function hasUnseenBoardCompletion( + input: Pick< + BoardColumnInput, + "latestTurnCompletedAt" | "readySessionUpdatedAt" | "lastVisitedAt" + >, +): boolean { + return isCompletionUnseen( + input.latestTurnCompletedAt ?? input.readySessionUpdatedAt, + input.lastVisitedAt, + ); +} + +/** + * Cache key for the board's aggregated VCS status map. Matches the dedupe + * granularity of the underlying subscription family: one entry per unique + * (environmentId, cwd) pair. + */ +export function boardGitKey(environmentId: EnvironmentId, cwd: string): string { + return `${environmentId}\u0000${cwd}`; +} + +/** + * Git status a thread may be attributed at all, or null. Threads sharing the + * project-root cwd must not inherit another branch's state, so without a + * dedicated worktree the checked-out ref has to match the thread's branch. + */ +export function resolveAppliedBoardGitStatus( + input: Pick, +): VcsStatusResult | null { + if (input.gitStatus === null || !input.gitStatus.isRepo) { + return null; + } + if (input.hasDedicatedWorktree) { + return input.gitStatus; + } + return input.threadBranch !== null && input.gitStatus.refName === input.threadBranch + ? input.gitStatus + : null; +} + +/** + * Lifecycle column for a thread. The server-backed settled flag is + * authoritative — safe to check first because `effectiveSettled` is never + * true for running or blocked threads, and it keeps a just-settled card from + * bouncing back to review on an unseen completion pill. Attention states win + * over the remaining lifecycle states: a thread blocked on the user + * (question/permission prompt) or holding an unseen completion sits in + * "review" regardless of git state. An actionable ready plan is also always + * reviewable. Git-driven columns still only move a card rightward as statuses + * stream in: unknown/unattributable git state falls through instead of + * guessing. + */ +export function deriveBoardColumn(input: BoardColumnInput): BoardColumnId { + if (input.isSettled) { + return "settled"; + } + + switch (input.threadStatusLabel) { + case "Pending Approval": + case "Awaiting Input": + case "Plan Ready": + case "Completed": + return "review"; + case "Working": + case "Connecting": + return "working"; + case null: + break; + default: { + const exhaustiveStatusLabel: never = input.threadStatusLabel; + return exhaustiveStatusLabel; + } + } + + if (hasUnseenBoardCompletion(input)) { + return "review"; + } + + // A plan-mode thread does not own worktree changes made by the separate + // implementation thread that consumed its plan. Keep its column tied to + // its own attention/completion state instead of the shared worktree. + const gitStatus = input.interactionMode === "plan" ? null : resolveAppliedBoardGitStatus(input); + if (gitStatus !== null) { + const hasUnpublishedWork = + (gitStatus.hasWorkingTreeChanges && !input.hasWorkingThreadForWorktree) || + gitStatus.aheadCount > 0 || + (!gitStatus.hasUpstream && (gitStatus.aheadOfDefaultCount ?? 0) > 0); + if (hasUnpublishedWork) { + return "review"; + } + + // A merged PR is not special-cased here: it settles the thread through + // `effectiveSettled` upstream. When that is unavailable (pinned active, + // server without the settlement capability) the branch classifies by its + // git state alone, since it could not be moved out of Settled anyway. + const pr = resolveThreadPr(input); + const isCleanPushedFeatureBranch = + gitStatus.aheadCount === 0 && gitStatus.hasUpstream && !gitStatus.isDefaultRef; + if (pr?.state === "open" || isCleanPushedFeatureBranch) { + return "published"; + } + } + + return "review"; +} + +export interface BoardSortableThread { + readonly id: string; + readonly updatedAt: string; +} + +/** Sorts board threads newest-first by the timestamp selected for the column. */ +export function sortBoardThreads( + threads: readonly T[], + getSortTimestamp: (thread: T) => string | null, +): T[] { + return [...threads].sort((left, right) => { + const leftTimestamp = + toSortableTimestamp(getSortTimestamp(left) ?? undefined) ?? Number.NEGATIVE_INFINITY; + const rightTimestamp = + toSortableTimestamp(getSortTimestamp(right) ?? undefined) ?? Number.NEGATIVE_INFINITY; + if (leftTimestamp !== rightTimestamp) { + return rightTimestamp > leftTimestamp ? 1 : -1; + } + return left.id < right.id ? -1 : left.id > right.id ? 1 : 0; + }); +} + +export type BoardColumnItem = + | { readonly kind: "thread"; readonly thread: T } + | { + readonly kind: "worktreeGroup"; + readonly worktreeKey: string; + readonly threads: readonly T[]; + }; + +/** + * Builds board items in lifecycle order. A shared group is emitted on its + * first encounter, which is its earliest column and most recent member there. + * Its members are already ordered by actual column, then that column's time. + */ +export function buildBoardColumns( + threads: readonly T[], + getColumn: (thread: T) => BoardColumnId, + getWorkingStartedAt: (thread: T) => string | null, + getGroupKey: (thread: T) => string | null = () => null, +): Record[]> { + const threadsByColumn: Record = { + working: [], + review: [], + published: [], + settled: [], + }; + for (const thread of threads) { + threadsByColumn[getColumn(thread)].push(thread); + } + for (const columnId of BOARD_COLUMN_IDS) { + threadsByColumn[columnId] = sortBoardThreads( + threadsByColumn[columnId], + columnId === "working" + ? (thread) => getWorkingStartedAt(thread) ?? thread.updatedAt + : (thread) => thread.updatedAt, + ); + } + + const groupMembersByKey = new Map(); + for (const columnId of BOARD_COLUMN_IDS) { + for (const thread of threadsByColumn[columnId]) { + const groupKey = getGroupKey(thread); + if (groupKey === null) { + continue; + } + const members = groupMembersByKey.get(groupKey); + if (members) { + members.push(thread); + } else { + groupMembersByKey.set(groupKey, [thread]); + } + } + } + + const columns: Record[]> = { + working: [], + review: [], + published: [], + settled: [], + }; + const emittedGroupKeys = new Set(); + for (const columnId of BOARD_COLUMN_IDS) { + for (const thread of threadsByColumn[columnId]) { + const groupKey = getGroupKey(thread); + const groupMembers = groupKey === null ? undefined : groupMembersByKey.get(groupKey); + if (groupKey === null || groupMembers === undefined || groupMembers.length < 2) { + columns[columnId].push({ kind: "thread", thread }); + continue; + } + if (emittedGroupKeys.has(groupKey)) { + continue; + } + emittedGroupKeys.add(groupKey); + columns[columnId].push({ + kind: "worktreeGroup", + worktreeKey: groupKey, + threads: groupMembers, + }); + } + } + return columns; +} + +/** Total threads across column items; a worktree group counts each member. */ +export function countBoardColumnThreads(items: readonly BoardColumnItem[]): number { + return items.reduce( + (count, item) => count + (item.kind === "thread" ? 1 : item.threads.length), + 0, + ); +} + +/** + * Settled-tail slice for the board column. The limit counts threads (a + * worktree group counts as its member count) so paging matches the sidebar's + * thread-based tail; a group straddling the limit is included whole since a + * group card cannot render partially. + */ +export function sliceBoardSettledItems( + items: readonly BoardColumnItem[], + limit: number, +): { visibleItems: readonly BoardColumnItem[]; hiddenThreadCount: number } { + const total = countBoardColumnThreads(items); + if (total <= limit) { + return { visibleItems: items, hiddenThreadCount: 0 }; + } + const visibleItems: BoardColumnItem[] = []; + let shown = 0; + for (const item of items) { + if (shown >= limit) break; + visibleItems.push(item); + shown += item.kind === "thread" ? 1 : item.threads.length; + } + return { visibleItems, hiddenThreadCount: total - shown }; +} + +export interface BoardWorktreeThread { + readonly environmentId: EnvironmentId; + readonly worktreePath: string | null; +} + +/** + * Identity of a thread's dedicated worktree for board grouping, or null when + * the thread runs in the shared project checkout. Only dedicated worktrees + * group: threads in the project root are unrelated lines of work even though + * they share a checkout. + */ +export function boardWorktreeKey(thread: BoardWorktreeThread): string | null { + const worktreePath = thread.worktreePath?.trim(); + if (!worktreePath) { + return null; + } + return `${thread.environmentId}\u0000${worktreePath}`; +} + +export interface BoardProjectFilterThread { + readonly environmentId: EnvironmentId; + readonly projectId: ProjectId; +} + +/** + * Predicate matching threads against a selected sidebar project group. + * Membership is by scoped project ref so locally+remotely-open copies of the + * same repository stay one entry, matching the sidebar's grouping. An + * unresolvable stored key (project removed, grouping changed) matches + * everything, i.e. falls back to "All projects". + */ +export function buildBoardProjectFilterPredicate(input: { + selectedProjectKey: string | null; + snapshots: ReadonlyArray<{ + readonly projectKey: string; + readonly memberProjectRefs: readonly ScopedProjectRef[]; + }>; +}): (thread: BoardProjectFilterThread) => boolean { + const selectedSnapshot = + input.selectedProjectKey === null + ? null + : (input.snapshots.find((snapshot) => snapshot.projectKey === input.selectedProjectKey) ?? + null); + if (selectedSnapshot === null) { + return () => true; + } + const memberKeys = new Set(selectedSnapshot.memberProjectRefs.map(scopedProjectKey)); + return (thread) => + memberKeys.has(scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId))); +} diff --git a/apps/web/src/components/board/BoardCard.test.tsx b/apps/web/src/components/board/BoardCard.test.tsx new file mode 100644 index 00000000000..5ed33b8c54e --- /dev/null +++ b/apps/web/src/components/board/BoardCard.test.tsx @@ -0,0 +1,200 @@ +import { DndContext } from "@dnd-kit/core"; +import { + DEFAULT_RUNTIME_MODE, + EnvironmentId, + ProjectId, + ProviderInstanceId, + ThreadId, + type VcsStatusResult, +} from "@t3tools/contracts"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vite-plus/test"; + +import type { SidebarThreadSummary } from "../../types"; +import { BoardCard, BoardCardDragOverlay } from "./BoardCard"; + +const environmentId = EnvironmentId.make("environment-local"); + +function makeThread(overrides: Partial = {}): SidebarThreadSummary { + return { + id: ThreadId.make("thread-1"), + environmentId, + projectId: ProjectId.make("project-1"), + title: "Fix board keyboard semantics", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: DEFAULT_RUNTIME_MODE, + interactionMode: "default", + session: null, + createdAt: "2026-07-22T09:00:00.000Z", + updatedAt: "2026-07-22T10:00:00.000Z", + archivedAt: null, + latestTurn: null, + latestUserMessageAt: "2026-07-22T09:30:00.000Z", + branch: "feature/board", + worktreePath: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + settledOverride: null, + settledAt: null, + ...overrides, + }; +} + +function makeGitStatus(): VcsStatusResult { + return { + isRepo: true, + hasPrimaryRemote: true, + isDefaultRef: false, + refName: "feature/board", + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + pr: { + number: 42, + title: "Board controls", + url: "https://github.com/example/repo/pull/42", + baseRef: "main", + headRef: "feature/board", + state: "open", + }, + }; +} + +function renderCard(thread: SidebarThreadSummary = makeThread(), isSettled = false): string { + return renderToStaticMarkup( + + {}} + onShowContextMenu={() => {}} + dragClickGuard={{ + startDrag: () => {}, + finishDrag: () => {}, + consumeSuppressedClick: () => false, + dispose: () => {}, + }} + /> + , + ); +} + +describe("BoardCard", () => { + it("uses a noninteractive card root and a native open-thread button", () => { + const markup = renderCard(); + const rootTag = markup.match(/]*data-testid="board-card-thread-1"[^>]*>/)?.[0]; + + expect(rootTag).toBeDefined(); + expect(rootTag).not.toContain('role="button"'); + expect(rootTag).not.toContain('tabindex="0"'); + expect(markup).toContain('", openButtonStart); + const prButtonStart = markup.indexOf(' + ) : ( + + #{pr.number} + + ) + ) : null} + {dirtyFileCount > 0 ? ( + + {dirtyFileCount} {dirtyFileCount === 1 ? "file" : "files"} + + ) : null} + {aheadCount > 0 ? ( + ↑{aheadCount} + ) : null} + {gitStatusPending ? ( + + ) : null} + + + {driverKind ? ( + + } + > + + + {modelLabel} + + ) : null} + +
+ + ); +} + +export const BoardCard = memo(function BoardCard({ + thread, + project, + gitStatus, + gitStatusPending, + isSettled, + onOpenThread, + onShowContextMenu, + dragClickGuard, +}: BoardCardProps) { + const threadRef = scopeThreadRef(thread.environmentId, thread.id); + const { attributes, listeners, setNodeRef, isDragging } = useDraggable({ + id: scopedThreadKey(threadRef), + data: { threadRef }, + }); + + const openThread = () => { + if (dragClickGuard.consumeSuppressedClick()) { + return; + } + onOpenThread(threadRef); + }; + + const showContextMenu = (event: MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + onShowContextMenu(thread, { x: event.clientX, y: event.clientY }); + }; + + return ( +
+ { + event.stopPropagation(); + openThread(); + }} + /> +
+ ); +}); + +/** Non-interactive clone rendered inside the DragOverlay while dragging. */ +export function BoardCardDragOverlay({ + thread, + project, + gitStatus, + gitStatusPending, + isSettled, + dropIntent = null, +}: Pick & { + dropIntent?: BoardDropIntent | null; +}) { + return ( + + ); +} diff --git a/apps/web/src/components/board/BoardColumn.tsx b/apps/web/src/components/board/BoardColumn.tsx new file mode 100644 index 00000000000..88d2ef79ea9 --- /dev/null +++ b/apps/web/src/components/board/BoardColumn.tsx @@ -0,0 +1,68 @@ +import { useDroppable } from "@dnd-kit/core"; +import type { ReactNode } from "react"; + +import { cn } from "../../lib/utils"; +import { ScrollArea } from "../ui/scroll-area"; +import { BOARD_COLUMN_LABELS, type BoardColumnId } from "./Board.logic"; + +/** Small rounded count pill used by column headers and worktree groups. */ +export function BoardCountPill({ count, className }: { count: number; className?: string }) { + return ( + + {count} + + ); +} + +export function BoardColumn({ + columnId, + count, + children, +}: { + columnId: BoardColumnId; + count: number; + children: ReactNode; +}) { + // Only the settled column accepts drops (dropping a card there settles the + // thread); the droppable ids are per-column so the intent resolver only + // matches the settled one. + const { isOver, setNodeRef } = useDroppable({ + id: `board-column-${columnId}`, + disabled: columnId !== "settled", + }); + + return ( +
+
+ {BOARD_COLUMN_LABELS[columnId]} + +
+ {/* Horizontal touch pans must chain to the board's scroll container; + the viewport's default overscroll containment would swallow them. */} + +
+ {count === 0 ? ( +
+ No threads +
+ ) : ( + children + )} +
+
+
+ ); +} diff --git a/apps/web/src/components/board/BoardDragClickGuard.test.ts b/apps/web/src/components/board/BoardDragClickGuard.test.ts new file mode 100644 index 00000000000..b35ef0580d0 --- /dev/null +++ b/apps/web/src/components/board/BoardDragClickGuard.test.ts @@ -0,0 +1,48 @@ +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +import { createBoardDragClickGuard } from "./BoardDragClickGuard"; + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("createBoardDragClickGuard", () => { + it("does not suppress ordinary clicks", () => { + const guard = createBoardDragClickGuard(); + + expect(guard.consumeSuppressedClick()).toBe(false); + }); + + it("suppresses the click generated when a drag finishes", () => { + const guard = createBoardDragClickGuard(); + + guard.startDrag(); + guard.finishDrag(); + + expect(guard.consumeSuppressedClick()).toBe(true); + expect(guard.consumeSuppressedClick()).toBe(false); + }); + + it("releases suppression when a finished drag produces no click", () => { + vi.useFakeTimers(); + const guard = createBoardDragClickGuard(); + + guard.startDrag(); + guard.finishDrag(); + vi.runOnlyPendingTimers(); + + expect(guard.consumeSuppressedClick()).toBe(false); + }); + + it("does not let an earlier reset clear suppression for a newer drag", () => { + vi.useFakeTimers(); + const guard = createBoardDragClickGuard(); + + guard.startDrag(); + guard.finishDrag(); + guard.startDrag(); + vi.runOnlyPendingTimers(); + + expect(guard.consumeSuppressedClick()).toBe(true); + }); +}); diff --git a/apps/web/src/components/board/BoardDragClickGuard.ts b/apps/web/src/components/board/BoardDragClickGuard.ts new file mode 100644 index 00000000000..cd99579db08 --- /dev/null +++ b/apps/web/src/components/board/BoardDragClickGuard.ts @@ -0,0 +1,47 @@ +export interface BoardDragClickGuard { + startDrag: () => void; + finishDrag: () => void; + consumeSuppressedClick: () => boolean; + dispose: () => void; +} + +/** + * Prevents the click synthesized by the pointer release that finishes a drag + * from opening a thread. If that release produces no click, the suppression + * expires on the next task instead of swallowing a later, intentional click. + */ +export function createBoardDragClickGuard(): BoardDragClickGuard { + let suppressClick = false; + let resetTimer: ReturnType | null = null; + + const clearResetTimer = () => { + if (resetTimer !== null) { + clearTimeout(resetTimer); + resetTimer = null; + } + }; + + return { + startDrag() { + clearResetTimer(); + suppressClick = true; + }, + finishDrag() { + clearResetTimer(); + resetTimer = setTimeout(() => { + suppressClick = false; + resetTimer = null; + }, 0); + }, + consumeSuppressedClick() { + if (!suppressClick) { + return false; + } + suppressClick = false; + return true; + }, + dispose() { + clearResetTimer(); + }, + }; +} diff --git a/apps/web/src/components/board/BoardDropZones.tsx b/apps/web/src/components/board/BoardDropZones.tsx new file mode 100644 index 00000000000..5c613567749 --- /dev/null +++ b/apps/web/src/components/board/BoardDropZones.tsx @@ -0,0 +1,78 @@ +import { useDroppable } from "@dnd-kit/core"; +import { ArchiveIcon, ArchiveRestoreIcon, Trash2Icon, type LucideIcon } from "lucide-react"; + +import { cn } from "../../lib/utils"; +import { + BOARD_ARCHIVE_DROPPABLE_ID, + BOARD_TRASH_DROPPABLE_ID, + BOARD_UNSETTLE_DROPPABLE_ID, +} from "./Board.logic"; + +function BoardDropZone({ + droppableId, + testId, + label, + icon: Icon, + activeClass, +}: { + droppableId: string; + testId: string; + label: string; + icon: LucideIcon; + activeClass: string; +}) { + const { isOver, setNodeRef } = useDroppable({ id: droppableId }); + + return ( +
+ + {label} +
+ ); +} + +/** + * Floating droppables shown while a drag is active: archive and delete are + * always available, and restore (un-settle) appears when the drag includes a + * settled thread. Columns are derived state, so settling happens by dropping + * on the Settled column itself rather than a zone here. + */ +export function BoardDropZones({ showRestoreZone }: { showRestoreZone: boolean }) { + return ( + // w-max: shrink-to-fit against left:50% would otherwise cap the width at + // half the board and wrap the labels on narrow viewports. +
+ {showRestoreZone ? ( + + ) : null} + + +
+ ); +} diff --git a/apps/web/src/components/board/BoardScroll.test.ts b/apps/web/src/components/board/BoardScroll.test.ts new file mode 100644 index 00000000000..2a8ad4744e7 --- /dev/null +++ b/apps/web/src/components/board/BoardScroll.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { scrollBoardFromWheel, shouldScrollColumnFromWheel } from "./BoardScroll"; + +function makeContainer(overrides: Partial = {}): MockScrollContainer { + return { + clientWidth: 500, + scrollLeft: 200, + scrollWidth: 1_500, + ...overrides, + }; +} + +interface MockScrollContainer { + clientWidth: number; + scrollLeft: number; + scrollWidth: number; +} + +describe("shouldScrollColumnFromWheel", () => { + const verticalWheel = { deltaX: 0, deltaY: 50 }; + + it("keeps vertical wheel movement in a column that can scroll in that direction", () => { + expect( + shouldScrollColumnFromWheel( + { clientHeight: 500, scrollHeight: 1_000, scrollTop: 200 }, + verticalWheel, + ), + ).toBe(true); + expect( + shouldScrollColumnFromWheel( + { clientHeight: 500, scrollHeight: 1_000, scrollTop: 200 }, + { deltaX: 0, deltaY: -50 }, + ), + ).toBe(true); + }); + + it("releases vertical movement to the board at the matching column edge", () => { + expect( + shouldScrollColumnFromWheel( + { clientHeight: 500, scrollHeight: 1_000, scrollTop: 500 }, + verticalWheel, + ), + ).toBe(false); + expect( + shouldScrollColumnFromWheel( + { clientHeight: 500, scrollHeight: 1_000, scrollTop: 0 }, + { deltaX: 0, deltaY: -50 }, + ), + ).toBe(false); + }); + + it("releases horizontal gestures and non-overflowing columns to the board", () => { + expect( + shouldScrollColumnFromWheel( + { clientHeight: 500, scrollHeight: 1_000, scrollTop: 200 }, + { deltaX: 50, deltaY: 10 }, + ), + ).toBe(false); + expect( + shouldScrollColumnFromWheel( + { clientHeight: 500, scrollHeight: 500, scrollTop: 0 }, + verticalWheel, + ), + ).toBe(false); + }); +}); + +describe("scrollBoardFromWheel", () => { + it("maps vertical wheel movement to horizontal board movement", () => { + const container = makeContainer(); + + expect( + scrollBoardFromWheel(container, { + ctrlKey: false, + deltaMode: 0, + deltaX: 0, + deltaY: 80, + }), + ).toBe(true); + expect(container.scrollLeft).toBe(280); + + scrollBoardFromWheel(container, { + ctrlKey: false, + deltaMode: 0, + deltaX: 0, + deltaY: -50, + }); + expect(container.scrollLeft).toBe(230); + + // Horizontal trackpad movement wins when it is the dominant axis. + scrollBoardFromWheel(container, { + ctrlKey: false, + deltaMode: 0, + deltaX: 60, + deltaY: 10, + }); + expect(container.scrollLeft).toBe(290); + }); + + it("normalizes line and page wheel deltas", () => { + const lineContainer = makeContainer(); + const pageContainer = makeContainer(); + + scrollBoardFromWheel(lineContainer, { + ctrlKey: false, + deltaMode: 1, + deltaX: 0, + deltaY: 2, + }); + scrollBoardFromWheel(pageContainer, { + ctrlKey: false, + deltaMode: 2, + deltaX: 0, + deltaY: 1, + }); + + expect(lineContainer.scrollLeft).toBe(232); + expect(pageContainer.scrollLeft).toBe(700); + }); + + it("clamps movement at both ends while retaining ownership of board scrolling", () => { + const rightEdge = makeContainer({ scrollLeft: 990 }); + const leftEdge = makeContainer({ scrollLeft: 10 }); + + expect( + scrollBoardFromWheel(rightEdge, { + ctrlKey: false, + deltaMode: 0, + deltaX: 0, + deltaY: 100, + }), + ).toBe(true); + expect(rightEdge.scrollLeft).toBe(1_000); + + expect( + scrollBoardFromWheel(leftEdge, { + ctrlKey: false, + deltaMode: 0, + deltaX: 0, + deltaY: -100, + }), + ).toBe(true); + expect(leftEdge.scrollLeft).toBe(0); + }); + + it("leaves pinch zoom and non-overflowing boards untouched", () => { + const zoomContainer = makeContainer(); + const fittingContainer = makeContainer({ clientWidth: 1_500 }); + const wheel = { ctrlKey: false, deltaMode: 0, deltaX: 0, deltaY: 100 }; + + expect(scrollBoardFromWheel(zoomContainer, { ...wheel, ctrlKey: true })).toBe(false); + expect(zoomContainer.scrollLeft).toBe(200); + expect(scrollBoardFromWheel(fittingContainer, wheel)).toBe(false); + expect(fittingContainer.scrollLeft).toBe(200); + }); +}); diff --git a/apps/web/src/components/board/BoardScroll.ts b/apps/web/src/components/board/BoardScroll.ts new file mode 100644 index 00000000000..ed607d99793 --- /dev/null +++ b/apps/web/src/components/board/BoardScroll.ts @@ -0,0 +1,84 @@ +const PIXELS_PER_WHEEL_LINE = 16; + +interface BoardScrollContainer { + readonly clientWidth: number; + scrollLeft: number; + readonly scrollWidth: number; +} + +interface BoardWheelInput { + readonly ctrlKey: boolean; + readonly deltaMode: number; + readonly deltaX: number; + readonly deltaY: number; +} + +interface ColumnScrollContainer { + readonly clientHeight: number; + readonly scrollHeight: number; + readonly scrollTop: number; +} + +interface WheelAxes { + readonly deltaX: number; + readonly deltaY: number; +} + +/** Returns true when a vertical gesture can still move an overflowing column. */ +export function shouldScrollColumnFromWheel( + container: ColumnScrollContainer, + event: WheelAxes, +): boolean { + if ( + !Number.isFinite(event.deltaY) || + event.deltaY === 0 || + Math.abs(event.deltaX) >= Math.abs(event.deltaY) + ) { + return false; + } + + const maxScrollTop = Math.max(0, container.scrollHeight - container.clientHeight); + if (maxScrollTop === 0) { + return false; + } + + return event.deltaY < 0 ? container.scrollTop > 0 : container.scrollTop < maxScrollTop; +} + +/** + * Redirects the wheel's dominant axis into the board's native horizontal + * scroll position. Handling horizontal input here as well keeps nested column + * scroll areas from swallowing trackpad gestures before they reach the board. + */ +export function scrollBoardFromWheel( + container: BoardScrollContainer, + event: BoardWheelInput, +): boolean { + if (event.ctrlKey) { + // Ctrl+wheel is commonly a trackpad pinch gesture. Leave browser zoom alone. + return false; + } + + const maxScrollLeft = Math.max(0, container.scrollWidth - container.clientWidth); + if (maxScrollLeft === 0) { + return false; + } + + const dominantDelta = + Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY; + if (!Number.isFinite(dominantDelta) || dominantDelta === 0) { + return false; + } + + const multiplier = + event.deltaMode === 1 + ? PIXELS_PER_WHEEL_LINE + : event.deltaMode === 2 + ? container.clientWidth + : 1; + container.scrollLeft = Math.min( + maxScrollLeft, + Math.max(0, container.scrollLeft + dominantDelta * multiplier), + ); + return true; +} diff --git a/apps/web/src/components/board/BoardView.tsx b/apps/web/src/components/board/BoardView.tsx new file mode 100644 index 00000000000..a06a82c4d56 --- /dev/null +++ b/apps/web/src/components/board/BoardView.tsx @@ -0,0 +1,998 @@ +import { + DndContext, + DragOverlay, + MouseSensor, + pointerWithin, + TouchSensor, + useSensor, + useSensors, + type DragEndEvent, + type DragOverEvent, + type DragStartEvent, +} from "@dnd-kit/core"; +import { + scopedProjectKey, + scopedThreadKey, + scopeProjectRef, + scopeThreadRef, +} from "@t3tools/client-runtime/environment"; +import { + isAtomCommandInterrupted, + settlePromise, + squashAtomCommandFailure, + type AtomCommandResult, +} from "@t3tools/client-runtime/state/runtime"; +import { canSnooze, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled"; +import type { ScopedThreadRef, VcsStatusResult } from "@t3tools/contracts"; +import { useNavigate } from "@tanstack/react-router"; +import * as Schema from "effect/Schema"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { isDesktopLocalConnectionTarget } from "../../connection/desktopLocal"; +import { isElectron } from "../../env"; +import { useNewThreadHandler } from "../../hooks/useHandleNewThread"; +import { useLocalStorage } from "../../hooks/useLocalStorage"; +import { useNowMinute } from "../../hooks/useNowMinute"; +import { useClientSettings } from "../../hooks/useSettings"; +import { useThreadActions } from "../../hooks/useThreadActions"; +import { cn } from "../../lib/utils"; +import { readLocalApi } from "../../localApi"; +import { selectProjectGroupingSettings } from "../../logicalProject"; +import { + buildSidebarProjectSnapshots, + type SidebarProjectSnapshot, +} from "../../sidebarProjectGrouping"; +import { + useAllEnvironmentShellsBootstrapped, + useProjects, + useServerConfigs, + useThreadShells, +} from "../../state/entities"; +import { useEnvironments, usePrimaryEnvironmentId } from "../../state/environments"; +import { buildThreadRouteParams } from "../../threadRoutes"; +import type { Project, SidebarThreadSummary } from "../../types"; +import { useUiStateStore } from "../../uiStateStore"; +import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "../../workspaceTitlebar"; +import { ProjectFavicon, ProjectFaviconFallback } from "../ProjectFavicon"; +import { + SETTLED_TAIL_INITIAL_COUNT, + SETTLED_TAIL_PAGE_COUNT, + archiveSelectedThreadEntries, + buildSidebarV2ThreadContextMenuItems, + isThreadSettledForDisplay, + resolveThreadStatusPill, +} from "../Sidebar.logic"; +import { resolveSnoozePresets, snoozeWakeDescription } from "../Sidebar.snooze"; +import { resolveThreadPr } from "../ThreadStatusIndicators"; +import { Button } from "../ui/button"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "../ui/dialog"; +import { Input } from "../ui/input"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; +import { SidebarInset } from "../ui/sidebar"; +import { Spinner } from "../ui/spinner"; +import { stackedThreadToast, toastManager } from "../ui/toast"; +import { + BOARD_COLUMN_IDS, + boardGitKey, + boardWorktreeKey, + buildBoardColumns, + buildBoardProjectFilterPredicate, + countBoardColumnThreads, + deriveBoardColumn, + parseBoardWorktreeGroupDragId, + resolveBoardDropIntent, + sliceBoardSettledItems, + type BoardDropIntent, +} from "./Board.logic"; +import { BoardCard, BoardCardDragOverlay } from "./BoardCard"; +import { BoardColumn } from "./BoardColumn"; +import { createBoardDragClickGuard } from "./BoardDragClickGuard"; +import { BoardDropZones } from "./BoardDropZones"; +import { scrollBoardFromWheel, shouldScrollColumnFromWheel } from "./BoardScroll"; +import { BoardWorktreeGroup, BoardWorktreeGroupDragOverlay } from "./BoardWorktreeGroup"; +import { useBoardVcsStatuses, type BoardVcsTarget } from "./useBoardVcsStatuses"; + +const BOARD_PROJECT_FILTER_STORAGE_KEY = "t3code:board:project-filter:v1"; +const BOARD_PROJECT_FILTER_ALL = "all"; +const BoardProjectFilterSchema = Schema.NullOr(Schema.String); + +interface BoardThreadGitContext { + readonly project: Project | null; + readonly gitStatus: VcsStatusResult | null; + readonly gitStatusPending: boolean; +} + +/** Error toast for a failed thread action; interruptions and successes are silent. */ +function reportThreadActionFailure(result: AtomCommandResult, title: string) { + if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) { + return; + } + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title, + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); +} + +export function BoardView() { + const bootstrapped = useAllEnvironmentShellsBootstrapped(); + + return ( + +
+ {bootstrapped ? ( + + ) : ( +
+ +
+ )} +
+
+ ); +} + +function BoardContent() { + const projects = useProjects(); + const threadShells = useThreadShells(); + const { environments } = useEnvironments(); + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); + const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays); + const serverConfigs = useServerConfigs(); + const { + archiveThread, + confirmAndDeleteThread, + confirmAndDeleteThreads, + renameThread, + settleThread, + snoozeThread, + unsettleThread, + unsnoozeThread, + } = useThreadActions(); + const handleNewThread = useNewThreadHandler(); + const navigate = useNavigate(); + const boardScrollRef = useRef(null); + const markThreadUnread = useUiStateStore((state) => state.markThreadUnread); + const [threadRenameTarget, setThreadRenameTarget] = useState(null); + const [threadRenameTitle, setThreadRenameTitle] = useState(""); + + useEffect(() => { + const scrollContainer = boardScrollRef.current; + if (scrollContainer === null) { + return; + } + + const handleWheel = (event: WheelEvent) => { + const columnScrollViewport = + event.target instanceof Element + ? event.target.closest( + '[data-testid^="board-column-"] [data-slot="scroll-area-viewport"]', + ) + : null; + if ( + columnScrollViewport instanceof HTMLElement && + shouldScrollColumnFromWheel(columnScrollViewport, event) + ) { + return; + } + + if (scrollBoardFromWheel(scrollContainer, event)) { + event.preventDefault(); + } + }; + + // React's delegated wheel events can be passive. This listener must be + // non-passive so gestures released by nested columns can move the board. + scrollContainer.addEventListener("wheel", handleWheel, { passive: false }); + return () => scrollContainer.removeEventListener("wheel", handleWheel); + }, []); + + const [storedProjectFilter, setStoredProjectFilter] = useLocalStorage( + BOARD_PROJECT_FILTER_STORAGE_KEY, + null, + BoardProjectFilterSchema, + ); + + const environmentLabelById = useMemo( + () => + new Map( + environments.map((environment) => [environment.environmentId, environment.label] as const), + ), + [environments], + ); + const desktopLocalEnvironmentIds = useMemo( + () => + new Set( + environments + .filter((environment) => isDesktopLocalConnectionTarget(environment.entry.target)) + .map((environment) => environment.environmentId), + ), + [environments], + ); + const projectSnapshots = useMemo( + () => + buildSidebarProjectSnapshots({ + projects, + settings: projectGroupingSettings, + primaryEnvironmentId, + resolveEnvironmentLabel: (environmentId) => environmentLabelById.get(environmentId) ?? null, + isDesktopLocalEnvironment: (environmentId) => desktopLocalEnvironmentIds.has(environmentId), + }), + [ + desktopLocalEnvironmentIds, + environmentLabelById, + primaryEnvironmentId, + projectGroupingSettings, + projects, + ], + ); + + const projectByKey = useMemo( + () => + new Map( + projects.map( + (project) => + [ + scopedProjectKey(scopeProjectRef(project.environmentId, project.id)), + project, + ] as const, + ), + ), + [projects], + ); + + const threads = useMemo( + () => threadShells.filter((thread) => thread.archivedAt === null), + [threadShells], + ); + const filterPredicate = useMemo( + () => + buildBoardProjectFilterPredicate({ + selectedProjectKey: storedProjectFilter, + snapshots: projectSnapshots, + }), + [projectSnapshots, storedProjectFilter], + ); + const filteredThreads = useMemo( + () => threads.filter(filterPredicate), + [filterPredicate, threads], + ); + + const resolveThreadGitCwd = useCallback( + (thread: SidebarThreadSummary): string | null => { + if (thread.branch == null) { + return null; + } + const project = projectByKey.get( + scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)), + ); + return thread.worktreePath ?? project?.workspaceRoot ?? null; + }, + [projectByKey], + ); + + const vcsTargets = useMemo( + () => + filteredThreads.flatMap((thread) => { + const cwd = resolveThreadGitCwd(thread); + return cwd === null ? [] : [{ environmentId: thread.environmentId, cwd }]; + }), + [filteredThreads, resolveThreadGitCwd], + ); + const gitStatuses = useBoardVcsStatuses(vcsTargets); + + const getThreadGitContext = useCallback( + (thread: SidebarThreadSummary): BoardThreadGitContext => { + const project = + projectByKey.get( + scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)), + ) ?? null; + const cwd = resolveThreadGitCwd(thread); + const gitStatus = + cwd === null ? null : (gitStatuses.get(boardGitKey(thread.environmentId, cwd)) ?? null); + return { + project, + gitStatus, + gitStatusPending: cwd !== null && gitStatus === null, + }; + }, + [gitStatuses, projectByKey, resolveThreadGitCwd], + ); + + const nowMinute = useNowMinute(); + + const previousSettledThreadKeys = useRef>(new Set()); + const settledThreadKeys = useMemo(() => { + const now = `${nowMinute}:00.000Z`; + const keys = new Set(); + for (const thread of filteredThreads) { + const changeRequestState = + resolveThreadPr({ + threadBranch: thread.branch, + hasDedicatedWorktree: thread.worktreePath != null, + gitStatus: getThreadGitContext(thread).gitStatus, + })?.state ?? null; + if ( + isThreadSettledForDisplay(thread, { + serverConfigs, + now, + autoSettleAfterDays, + changeRequestState, + }) + ) { + keys.add(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))); + } + } + // Column building and the drag handlers key on this set; keep the previous + // identity when the contents did not change so the minute tick and + // git-status churn don't cascade a full board re-render. + const previous = previousSettledThreadKeys.current; + if (previous.size === keys.size && [...keys].every((key) => previous.has(key))) { + return previous; + } + previousSettledThreadKeys.current = keys; + return keys; + }, [autoSettleAfterDays, filteredThreads, getThreadGitContext, nowMinute, serverConfigs]); + // The context-menu handler reads these through refs: depending on the live + // identities would hand every BoardCard a fresh callback prop on each + // git-status or shell event and defeat the cards' memoization. + const settledThreadKeysRef = useRef(settledThreadKeys); + settledThreadKeysRef.current = settledThreadKeys; + const serverConfigsRef = useRef(serverConfigs); + serverConfigsRef.current = serverConfigs; + + const threadLastVisitedAtById = useUiStateStore((state) => state.threadLastVisitedAtById); + const threadStatusLabelByKey = useMemo( + () => + new Map( + threads.map((thread) => { + const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + const threadStatusLabel = resolveThreadStatusPill({ + thread: { + ...thread, + lastVisitedAt: threadLastVisitedAtById[threadKey], + }, + })?.label; + return [threadKey, threadStatusLabel ?? null] as const; + }), + ), + [threadLastVisitedAtById, threads], + ); + const workingWorktreeKeys = useMemo(() => { + const keys = new Set(); + for (const thread of threads) { + const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + const statusLabel = threadStatusLabelByKey.get(threadKey); + if (statusLabel !== "Working" && statusLabel !== "Connecting") { + continue; + } + const cwd = resolveThreadGitCwd(thread); + if (cwd !== null) { + keys.add(boardGitKey(thread.environmentId, cwd)); + } + } + return keys; + }, [resolveThreadGitCwd, threadStatusLabelByKey, threads]); + const columns = useMemo( + () => + buildBoardColumns( + filteredThreads, + (thread) => { + const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + const lastVisitedAt = threadLastVisitedAtById[threadKey]; + const cwd = resolveThreadGitCwd(thread); + + return deriveBoardColumn({ + threadStatusLabel: threadStatusLabelByKey.get(threadKey) ?? null, + interactionMode: thread.interactionMode, + isSettled: settledThreadKeys.has(threadKey), + latestTurnCompletedAt: thread.latestTurn?.completedAt ?? null, + readySessionUpdatedAt: + thread.latestTurn === null && thread.session?.status === "ready" + ? thread.session.updatedAt + : null, + lastVisitedAt: lastVisitedAt ?? null, + threadBranch: thread.branch, + hasDedicatedWorktree: thread.worktreePath != null, + hasWorkingThreadForWorktree: + cwd !== null && workingWorktreeKeys.has(boardGitKey(thread.environmentId, cwd)), + gitStatus: getThreadGitContext(thread).gitStatus, + }); + }, + (thread) => + thread.session?.status === "running" && + thread.latestTurn?.turnId === thread.session.activeTurnId + ? (thread.latestTurn.startedAt ?? thread.latestTurn.requestedAt) + : null, + boardWorktreeKey, + ), + [ + filteredThreads, + getThreadGitContext, + resolveThreadGitCwd, + settledThreadKeys, + threadLastVisitedAtById, + threadStatusLabelByKey, + workingWorktreeKeys, + ], + ); + + // Settled tail renders in pages, mirroring SidebarV2: expansion resets when + // the project filter changes so a scope flip never inherits a deep page. + const [settledVisibleCount, setSettledVisibleCount] = useState(SETTLED_TAIL_INITIAL_COUNT); + const settledResetKey = storedProjectFilter ?? "all"; + const lastSettledResetKeyRef = useRef(settledResetKey); + if (lastSettledResetKeyRef.current !== settledResetKey) { + lastSettledResetKeyRef.current = settledResetKey; + setSettledVisibleCount(SETTLED_TAIL_INITIAL_COUNT); + } + const settledTail = useMemo( + () => sliceBoardSettledItems(columns.settled, settledVisibleCount), + [columns, settledVisibleCount], + ); + const showMoreSettled = useCallback( + () => setSettledVisibleCount((count) => count + SETTLED_TAIL_PAGE_COUNT), + [], + ); + + const dragClickGuard = useMemo(() => createBoardDragClickGuard(), []); + useEffect(() => () => dragClickGuard.dispose(), [dragClickGuard]); + const [activeDragId, setActiveDragId] = useState(null); + const [dropIntent, setDropIntent] = useState(null); + const activeThread = useMemo( + () => + activeDragId === null || parseBoardWorktreeGroupDragId(activeDragId) !== null + ? null + : (filteredThreads.find( + (thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === activeDragId, + ) ?? null), + [activeDragId, filteredThreads], + ); + const activeWorktreeGroup = useMemo(() => { + const worktreeKey = parseBoardWorktreeGroupDragId(activeDragId); + if (worktreeKey === null) { + return null; + } + for (const columnId of BOARD_COLUMN_IDS) { + for (const item of columns[columnId]) { + if (item.kind === "worktreeGroup" && item.worktreeKey === worktreeKey) { + return item; + } + } + } + return null; + }, [activeDragId, columns]); + const activeDragIncludesSettledThread = useMemo(() => { + if (activeDragId === null) { + return false; + } + if (activeWorktreeGroup !== null) { + return activeWorktreeGroup.threads.some((thread) => + settledThreadKeys.has(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))), + ); + } + return settledThreadKeys.has(activeDragId); + }, [activeDragId, activeWorktreeGroup, settledThreadKeys]); + + // Touch drags require a long-press so swipe gestures keep scrolling the + // board; a distance-only constraint would claim every swipe as a drag. + const dndSensors = useSensors( + useSensor(MouseSensor, { activationConstraint: { distance: 6 } }), + useSensor(TouchSensor, { activationConstraint: { delay: 250, tolerance: 8 } }), + ); + + const handleDragStart = useCallback( + (event: DragStartEvent) => { + dragClickGuard.startDrag(); + setActiveDragId(String(event.active.id)); + }, + [dragClickGuard], + ); + + const handleDragOver = useCallback((event: DragOverEvent) => { + setDropIntent(resolveBoardDropIntent(event.over?.id)); + }, []); + + const handleDragCancel = useCallback(() => { + dragClickGuard.finishDrag(); + setActiveDragId(null); + setDropIntent(null); + }, [dragClickGuard]); + + const handleDragEnd = useCallback( + (event: DragEndEvent) => { + dragClickGuard.finishDrag(); + setActiveDragId(null); + setDropIntent(null); + const dragData = event.active.data.current as + | { + threadRef?: ScopedThreadRef; + worktreeGroupThreadRefs?: readonly ScopedThreadRef[]; + } + | undefined; + const threadRefs = + dragData?.worktreeGroupThreadRefs ?? (dragData?.threadRef ? [dragData.threadRef] : []); + if (threadRefs.length === 0) { + return; + } + const intent = resolveBoardDropIntent(event.over?.id); + if (intent === null) { + return; + } + + if (intent === "settle" || intent === "unsettle") { + // Dropping onto a state a card is already in is a no-op: the restore + // zone only shows for settled drags, and a group can hold a mix. + const wantSettled = intent === "settle"; + const refs = threadRefs.filter( + (threadRef) => settledThreadKeys.has(scopedThreadKey(threadRef)) !== wantSettled, + ); + if (refs.length === 0) { + return; + } + const action = wantSettled ? settleThread : unsettleThread; + const failureTitle = `Failed to ${wantSettled ? "settle" : "restore"} ${ + refs.length === 1 ? "thread" : "threads" + }`; + // Success is silent: the card moves when the shell update streams in. + void Promise.all(refs.map((threadRef) => action(threadRef))).then((results) => { + const failure = results.find( + (result) => result._tag === "Failure" && !isAtomCommandInterrupted(result), + ); + if (failure) { + reportThreadActionFailure(failure, failureTitle); + } + }); + return; + } + + if (intent === "trash") { + void confirmAndDeleteThreads(threadRefs).then((result) => { + reportThreadActionFailure( + result, + threadRefs.length === 1 ? "Failed to delete thread" : "Failed to delete threads", + ); + }); + return; + } + + void archiveSelectedThreadEntries({ + entries: threadRefs.map((threadRef) => ({ + threadKey: scopedThreadKey(threadRef), + threadRef, + })), + archive: ({ threadRef }, onArchived) => archiveThread(threadRef, { onArchived }), + }).then((outcome) => { + for (const failure of outcome.followupFailures) { + reportThreadActionFailure(failure, "Thread archived, but navigation failed"); + } + if (outcome.mutationFailure) { + reportThreadActionFailure( + outcome.mutationFailure, + threadRefs.length === 1 ? "Failed to archive thread" : "Failed to archive threads", + ); + } + }); + }, + [ + archiveThread, + confirmAndDeleteThreads, + dragClickGuard, + settledThreadKeys, + settleThread, + unsettleThread, + ], + ); + + const handleOpenThread = useCallback( + (threadRef: ScopedThreadRef) => { + void navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(threadRef), + }); + }, + [navigate], + ); + + const closeThreadRenameDialog = useCallback(() => { + setThreadRenameTarget(null); + setThreadRenameTitle(""); + }, []); + + const submitThreadRename = useCallback(async () => { + if (threadRenameTarget === null) { + return; + } + const committed = await renameThread( + scopeThreadRef(threadRenameTarget.environmentId, threadRenameTarget.id), + threadRenameTitle, + threadRenameTarget.title, + ); + if (committed) { + closeThreadRenameDialog(); + } + }, [closeThreadRenameDialog, renameThread, threadRenameTarget, threadRenameTitle]); + + const handleThreadContextMenu = useCallback( + async (thread: SidebarThreadSummary, position: { x: number; y: number }) => { + const api = readLocalApi(); + if (!api) { + return; + } + + const threadRef = scopeThreadRef(thread.environmentId, thread.id); + const threadKey = scopedThreadKey(threadRef); + const supportsSettlement = + serverConfigsRef.current.get(thread.environmentId)?.environment.capabilities + .threadSettlement === true; + const supportsSnooze = + serverConfigsRef.current.get(thread.environmentId)?.environment.capabilities + .threadSnooze === true; + const isSettled = settledThreadKeysRef.current.has(threadKey); + // Snooze classification uses a real clock (wake times are + // second-precise) and presets resolve at menu-open time — both same + // as the sidebar. + const preciseNow = new Date().toISOString(); + const isSnoozed = supportsSnooze && effectiveSnoozed(thread, { now: preciseNow }); + const snoozePresets = resolveSnoozePresets(new Date()); + const clicked = await api.contextMenu.show( + buildSidebarV2ThreadContextMenuItems({ + branch: thread.branch, + supportsSettlement, + isSettled, + supportsSnooze, + isSnoozed, + canSnoozeNow: canSnooze(thread, { now: preciseNow }), + snoozePresets, + }), + position, + ); + + if (clicked?.startsWith("snooze:")) { + const preset = snoozePresets.find((candidate) => `snooze:${candidate.id}` === clicked); + if (!preset) { + return; + } + const result = await snoozeThread(threadRef, preset.snoozedUntil); + if (result._tag === "Failure") { + reportThreadActionFailure(result, "Failed to snooze thread"); + return; + } + // A snoozed card has no visible change on the board, so the toast is + // the only confirmation — and Undo the escape hatch for a mis-click. + toastManager.add( + stackedThreadToast({ + type: "success", + title: `Snoozed until ${snoozeWakeDescription(preset.snoozedUntil, new Date())}`, + timeout: 5_000, + actionProps: { + children: "Undo", + onClick: () => { + void unsnoozeThread(threadRef).then((undoResult) => { + reportThreadActionFailure(undoResult, "Failed to wake thread"); + }); + }, + }, + }), + ); + return; + } + if (clicked === "unsnooze") { + reportThreadActionFailure(await unsnoozeThread(threadRef), "Failed to wake thread"); + return; + } + if (clicked === "new-thread-on-branch") { + // Explicit branch carry-over: reuse the thread's worktree when it + // has one, otherwise its branch on the local checkout. + await handleNewThread(scopeProjectRef(thread.environmentId, thread.projectId), { + branch: thread.branch, + worktreePath: thread.worktreePath, + envMode: thread.worktreePath ? "worktree" : "local", + startFromOrigin: false, + }); + return; + } + if (clicked === "settle" || clicked === "unsettle") { + const result = + clicked === "settle" ? await settleThread(threadRef) : await unsettleThread(threadRef); + reportThreadActionFailure( + result, + clicked === "settle" ? "Failed to settle thread" : "Failed to un-settle thread", + ); + return; + } + + if (clicked === "rename") { + setThreadRenameTarget(thread); + setThreadRenameTitle(thread.title); + return; + } + if (clicked === "mark-unread") { + markThreadUnread(threadKey, thread.latestTurn?.completedAt); + return; + } + if (clicked !== "delete") { + return; + } + + reportThreadActionFailure(await confirmAndDeleteThread(threadRef), "Failed to delete thread"); + }, + [ + confirmAndDeleteThread, + handleNewThread, + markThreadUnread, + settleThread, + snoozeThread, + unsettleThread, + unsnoozeThread, + ], + ); + + const showThreadContextMenu = useCallback( + (thread: SidebarThreadSummary, position: { x: number; y: number }) => { + void settlePromise(() => handleThreadContextMenu(thread, position)).then((result) => { + if (result._tag === "Success") { + return; + } + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Thread action failed", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + }); + }, + [handleThreadContextMenu], + ); + + const projectFilterItems = useMemo( + () => [ + { value: BOARD_PROJECT_FILTER_ALL, label: "All projects" }, + ...projectSnapshots.map((snapshot) => ({ + value: snapshot.projectKey, + label: snapshot.displayName, + })), + ], + [projectSnapshots], + ); + const selectedFilterValue = + storedProjectFilter !== null && + projectSnapshots.some((snapshot) => snapshot.projectKey === storedProjectFilter) + ? storedProjectFilter + : BOARD_PROJECT_FILTER_ALL; + const selectedFilterSnapshot = + selectedFilterValue === BOARD_PROJECT_FILTER_ALL + ? null + : (projectSnapshots.find((snapshot) => snapshot.projectKey === selectedFilterValue) ?? null); + + return ( + <> + {/* .workspace-topbar pins the header to --workspace-topbar-height so the + floating sidebar toggle (absolutely positioned in that same band) + stays vertically aligned with the title at every breakpoint. */} +
+ Board +
+ +
+
+ +
+
+
+ {BOARD_COLUMN_IDS.map((columnId) => { + const items = columnId === "settled" ? settledTail.visibleItems : columns[columnId]; + return ( + + {items.map((item) => { + const renderCard = (thread: SidebarThreadSummary) => { + const gitContext = getThreadGitContext(thread); + const threadKey = scopedThreadKey( + scopeThreadRef(thread.environmentId, thread.id), + ); + return ( + + ); + }; + if (item.kind === "thread") { + return renderCard(item.thread); + } + // buildBoardColumns only emits groups with >= 2 members. + const mostRecentThread = item.threads[0]!; + return ( + + scopeThreadRef(thread.environmentId, thread.id), + )} + worktreePath={mostRecentThread.worktreePath ?? ""} + branch={mostRecentThread.branch} + mostRecentCard={renderCard(mostRecentThread)} + dragClickGuard={dragClickGuard} + > + {item.threads.slice(1).map(renderCard)} + + ); + })} + {columnId === "settled" && settledTail.hiddenThreadCount > 0 ? ( + + ) : null} + + ); + })} +
+
+ {activeDragId !== null ? ( + + ) : null} +
+ + {activeWorktreeGroup !== null ? ( + + ) : activeThread !== null ? ( + + ) : null} + +
+ { + if (!open) { + closeThreadRenameDialog(); + } + }} + > + + + Rename thread + Update the title shown for this thread. + + +
+ Thread title + setThreadRenameTitle(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + void submitThreadRename(); + } + }} + /> +
+
+ + + + +
+
+ + ); +} diff --git a/apps/web/src/components/board/BoardWorktreeGroup.test.tsx b/apps/web/src/components/board/BoardWorktreeGroup.test.tsx new file mode 100644 index 00000000000..a1f5f1b67cf --- /dev/null +++ b/apps/web/src/components/board/BoardWorktreeGroup.test.tsx @@ -0,0 +1,100 @@ +import { DndContext } from "@dnd-kit/core"; +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import type { ReactNode } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vite-plus/test"; + +import type { BoardDragClickGuard } from "./BoardDragClickGuard"; +import { BoardWorktreeGroup, BoardWorktreeGroupDragOverlay } from "./BoardWorktreeGroup"; + +const environmentId = EnvironmentId.make("environment-local"); + +const noopDragClickGuard: BoardDragClickGuard = { + startDrag: () => {}, + finishDrag: () => {}, + consumeSuppressedClick: () => false, + dispose: () => {}, +}; + +function renderGroup({ + branch = "t3code/session-dashboard-board", + mostRecentCard =
, + children =
, +}: { + branch?: string | null; + mostRecentCard?: ReactNode; + children?: ReactNode; +} = {}): string { + return renderToStaticMarkup( + + + {children} + + , + ); +} + +describe("BoardWorktreeGroup", () => { + it("starts collapsed showing only the most recent card below the toggle", () => { + const markup = renderGroup(); + + expect(markup).toContain('aria-expanded="false"'); + expect(markup).toContain(' +
+ {mostRecentCard} + {expanded ? children : null} +
+ + ); +} + +/** Non-interactive header clone rendered inside the DragOverlay while dragging a group. */ +export function BoardWorktreeGroupDragOverlay({ + worktreePath, + branch, + threadCount, + dropIntent = null, +}: { + worktreePath: string; + branch: string | null; + threadCount: number; + dropIntent?: BoardDropIntent | null; +}) { + const displayLabel = resolveWorktreeGroupLabel(worktreePath, branch); + + return ( + + ); +} diff --git a/apps/web/src/components/board/useBoardVcsStatuses.ts b/apps/web/src/components/board/useBoardVcsStatuses.ts new file mode 100644 index 00000000000..b774880b651 --- /dev/null +++ b/apps/web/src/components/board/useBoardVcsStatuses.ts @@ -0,0 +1,78 @@ +import { useAtomValue } from "@effect/atom-react"; +import type { EnvironmentId, VcsStatusResult } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; +import { useMemo, useRef } from "react"; + +import { vcsEnvironment } from "../../state/vcs"; +import { boardGitKey } from "./Board.logic"; + +export interface BoardVcsTarget { + readonly environmentId: EnvironmentId; + readonly cwd: string; +} + +const EMPTY_STATUSES_ATOM = Atom.make( + (): ReadonlyMap => new Map(), +).pipe(Atom.withLabel("web:board-vcs-statuses:empty")); + +/** + * Aggregated VCS status subscription for the board: one derived atom over the + * per-cwd status subscription family, read with a single useAtomValue. The + * family dedupes identical (environmentId, cwd) keys into one WS subscription + * and keeps entries warm for 5 minutes after last use, so filter toggles + * don't churn subscriptions. Entries are `null` until the first snapshot + * streams in. + */ +export function useBoardVcsStatuses( + targets: ReadonlyArray, +): ReadonlyMap { + // Thread-shell churn hands this hook a fresh input array on every update; + // dedupe into a sorted, identity-stable list so the derived atom below is + // only rebuilt when the (environmentId, cwd) set actually changes. + const previousTargetsRef = useRef>([]); + const dedupedTargets = useMemo(() => { + const byKey = new Map(); + for (const target of targets) { + byKey.set(boardGitKey(target.environmentId, target.cwd), target); + } + const next = [...byKey.entries()].sort(([left], [right]) => + left < right ? -1 : left > right ? 1 : 0, + ); + const previous = previousTargetsRef.current; + if ( + previous.length === next.length && + previous.every(([key], index) => key === next[index]![0]) + ) { + return previous; + } + previousTargetsRef.current = next; + return next; + }, [targets]); + + const statusesAtom = useMemo(() => { + if (dedupedTargets.length === 0) { + return EMPTY_STATUSES_ATOM; + } + return Atom.make( + (get): ReadonlyMap => + new Map( + dedupedTargets.map(([key, target]) => [ + key, + Option.getOrNull( + AsyncResult.value( + get( + vcsEnvironment.status({ + environmentId: target.environmentId, + input: { cwd: target.cwd }, + }), + ), + ), + ), + ]), + ), + ).pipe(Atom.withLabel(`web:board-vcs-statuses:${dedupedTargets.length}`)); + }, [dedupedTargets]); + + return useAtomValue(statusesAtom); +} diff --git a/apps/web/src/components/ui/scroll-area.tsx b/apps/web/src/components/ui/scroll-area.tsx index d6f178a1f54..e36ddee1fff 100644 --- a/apps/web/src/components/ui/scroll-area.tsx +++ b/apps/web/src/components/ui/scroll-area.tsx @@ -11,12 +11,14 @@ function ScrollArea({ scrollbarGutter = false, hideScrollbars = false, chainVerticalScroll = false, + chainHorizontalScroll = false, ...props }: ScrollAreaPrimitive.Root.Props & { scrollFade?: boolean; scrollbarGutter?: boolean; hideScrollbars?: boolean; chainVerticalScroll?: boolean; + chainHorizontalScroll?: boolean; }) { return ( { it("keeps the blocked thread context with the fixed message", () => { const error = new ThreadArchiveBlockedError({ - environmentId: EnvironmentId.make("environment-1"), + environmentId, threadId: ThreadId.make("thread-1"), }); @@ -17,3 +20,45 @@ describe("ThreadArchiveBlockedError", () => { expect(error.message).toBe("Cannot archive a running thread."); }); }); + +describe("deleteThreadTargetsSequentially", () => { + const targets = [ + { environmentId, threadId: ThreadId.make("thread-1") }, + { environmentId, threadId: ThreadId.make("thread-2") }, + { environmentId, threadId: ThreadId.make("thread-3") }, + ] as const; + + it("makes shared-worktree cleanup eligible only for the final target", async () => { + const cleanupEligibleTargets: string[] = []; + + const result = await deleteThreadTargetsSequentially(targets, async (target, opts) => { + if (opts.deletedThreadKeys.size === targets.length - 1) { + cleanupEligibleTargets.push(scopedThreadKey(target)); + } + return { _tag: "Success" } as const; + }); + + expect(result).toBeNull(); + expect(cleanupEligibleTargets).toEqual([scopedThreadKey(targets[2])]); + }); + + it("does not discount a target whose deletion failed", async () => { + const deletedKeySnapshots: string[][] = []; + const failure = { _tag: "Failure", reason: "delete failed" } as const; + const deleteTarget = vi.fn( + async ( + target: (typeof targets)[number], + opts: { deletedThreadKeys: ReadonlySet }, + ) => { + deletedKeySnapshots.push([...opts.deletedThreadKeys]); + return target === targets[1] ? failure : ({ _tag: "Success" } as const); + }, + ); + + const result = await deleteThreadTargetsSequentially(targets, deleteTarget); + + expect(result).toBe(failure); + expect(deleteTarget).toHaveBeenCalledTimes(2); + expect(deletedKeySnapshots).toEqual([[], [scopedThreadKey(targets[0])]]); + }); +}); diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 91a3779a057..6b7afedbb96 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -1,9 +1,14 @@ import { parseScopedThreadKey, + scopedThreadKey, scopeProjectRef, scopeThreadRef, } from "@t3tools/client-runtime/environment"; -import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; +import { + isAtomCommandInterrupted, + settlePromise, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; import { canSettle, canSnooze } from "@t3tools/client-runtime/state/thread-settled"; import { EnvironmentId, type ScopedThreadRef, ThreadId } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; @@ -94,6 +99,33 @@ export class ThreadSnoozeBlockedError extends Schema.TaggedErrorClass( + targets: readonly ScopedThreadRef[], + deleteTarget: ( + target: ScopedThreadRef, + opts: { deletedThreadKeys: ReadonlySet }, + ) => Promise, +): Promise | null> { + const deletedThreadKeys = new Set(); + for (const target of targets) { + const result = await deleteTarget(target, { deletedThreadKeys }); + if (result._tag === "Failure") { + return result as Extract; + } + deletedThreadKeys.add(scopedThreadKey(target)); + } + return null; +} + export function useThreadActions() { const closeTerminal = useAtomCommand(terminalEnvironment.close); const archiveThreadMutation = useAtomCommand(threadEnvironment.archive, { @@ -117,6 +149,9 @@ export function useThreadActions() { const unsnoozeThreadMutation = useAtomCommand(threadEnvironment.unsnooze, { reportFailure: false, }); + const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { + reportFailure: false, + }); const stopThreadSession = useAtomCommand(threadEnvironment.stopSession); const removeWorktree = useAtomCommand(vcsEnvironment.removeWorktree, { reportFailure: false, @@ -527,6 +562,41 @@ export function useThreadActions() { [unsnoozeThreadMutation], ); + // Shared rename commit: trims, warns on an empty title, and reports + // failures. Returns true when the title is committed (or was unchanged) so + // dialog-style callers know they can close. + const renameThread = useCallback( + async (target: ScopedThreadRef, title: string, originalTitle: string): Promise => { + const trimmed = title.trim(); + if (trimmed.length === 0) { + toastManager.add({ type: "warning", title: "Thread title cannot be empty" }); + return false; + } + if (trimmed === originalTitle) { + return true; + } + const result = await updateThreadMetadata({ + environmentId: target.environmentId, + input: { threadId: target.threadId, title: trimmed }, + }); + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to rename thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + return false; + } + return true; + }, + [updateThreadMetadata], + ); + const confirmAndDeleteThread = useCallback( async (target: ScopedThreadRef) => { const localApi = readLocalApi(); @@ -555,12 +625,51 @@ export function useThreadActions() { [confirmThreadDelete, deleteThread, resolveThreadTarget], ); + const confirmAndDeleteThreads = useCallback( + async (targets: readonly ScopedThreadRef[]) => { + const [firstTarget] = targets; + if (firstTarget === undefined) { + return AsyncResult.success(undefined); + } + if (targets.length === 1) { + return confirmAndDeleteThread(firstTarget); + } + + const localApi = readLocalApi(); + if (confirmThreadDelete && localApi) { + const confirmationResult = await settlePromise(() => + localApi.dialogs.confirm( + [ + `Delete ${targets.length} threads?`, + "This permanently clears conversation history for these threads.", + ].join("\n"), + ), + ); + if (confirmationResult._tag === "Failure") { + return confirmationResult; + } + if (!confirmationResult.value) { + return AsyncResult.success(undefined); + } + } + + const failure = await deleteThreadTargetsSequentially(targets, deleteThread); + if (failure) { + return failure; + } + return AsyncResult.success(undefined); + }, + [confirmAndDeleteThread, confirmThreadDelete, deleteThread], + ); + return useMemo( () => ({ archiveThread, unarchiveThread, deleteThread, confirmAndDeleteThread, + confirmAndDeleteThreads, + renameThread, settleThread, unsettleThread, snoozeThread, @@ -569,7 +678,9 @@ export function useThreadActions() { [ archiveThread, confirmAndDeleteThread, + confirmAndDeleteThreads, deleteThread, + renameThread, settleThread, snoozeThread, unarchiveThread, diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index c0d326edd55..d832cc19711 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -118,6 +118,7 @@ const DEFAULT_BINDINGS = compile([ command: "commandPalette.toggle", whenAst: whenNot(whenIdentifier("terminalFocus")), }, + { shortcut: modShortcut("t"), command: "board.open" }, { shortcut: modShortcut("m", { shiftKey: true }), command: "modelPicker.toggle", @@ -458,6 +459,15 @@ describe("model picker navigation helpers", () => { }); describe("chat/editor shortcuts", () => { + it("resolves the board shortcut", () => { + assert.strictEqual( + resolveShortcutCommand(event({ key: "t", metaKey: true }), DEFAULT_BINDINGS, { + platform: "MacIntel", + }), + "board.open", + ); + }); + it("matches chat.new shortcut", () => { assert.isTrue( isChatNewShortcut(event({ key: "o", metaKey: true, shiftKey: true }), DEFAULT_BINDINGS, { diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 563d1b43755..609c3b79641 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -23,6 +23,7 @@ import { Route as SettingsConnectionsRouteImport } from './routes/settings.conne import { Route as SettingsBetaRouteImport } from './routes/settings.beta' import { Route as SettingsArchivedRouteImport } from './routes/settings.archived' import { Route as ConnectCallbackRouteImport } from './routes/connect_.callback' +import { Route as ChatBoardRouteImport } from './routes/_chat.board' import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId' import { Route as ChatEnvironmentIdThreadIdRouteImport } from './routes/_chat.$environmentId.$threadId' @@ -95,6 +96,11 @@ const ConnectCallbackRoute = ConnectCallbackRouteImport.update({ path: '/connect/callback', getParentRoute: () => rootRouteImport, } as any) +const ChatBoardRoute = ChatBoardRouteImport.update({ + id: '/board', + path: '/board', + getParentRoute: () => ChatRoute, +} as any) const ChatDraftDraftIdRoute = ChatDraftDraftIdRouteImport.update({ id: '/draft/$draftId', path: '/draft/$draftId', @@ -112,6 +118,7 @@ export interface FileRoutesByFullPath { '/connect': typeof ConnectRoute '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren + '/board': typeof ChatBoardRoute '/connect/callback': typeof ConnectCallbackRoute '/settings/archived': typeof SettingsArchivedRoute '/settings/beta': typeof SettingsBetaRoute @@ -128,6 +135,7 @@ export interface FileRoutesByTo { '/connect': typeof ConnectRoute '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren + '/board': typeof ChatBoardRoute '/connect/callback': typeof ConnectCallbackRoute '/settings/archived': typeof SettingsArchivedRoute '/settings/beta': typeof SettingsBetaRoute @@ -147,6 +155,7 @@ export interface FileRoutesById { '/connect': typeof ConnectRoute '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren + '/_chat/board': typeof ChatBoardRoute '/connect_/callback': typeof ConnectCallbackRoute '/settings/archived': typeof SettingsArchivedRoute '/settings/beta': typeof SettingsBetaRoute @@ -167,6 +176,7 @@ export interface FileRouteTypes { | '/connect' | '/pair' | '/settings' + | '/board' | '/connect/callback' | '/settings/archived' | '/settings/beta' @@ -183,6 +193,7 @@ export interface FileRouteTypes { | '/connect' | '/pair' | '/settings' + | '/board' | '/connect/callback' | '/settings/archived' | '/settings/beta' @@ -201,6 +212,7 @@ export interface FileRouteTypes { | '/connect' | '/pair' | '/settings' + | '/_chat/board' | '/connect_/callback' | '/settings/archived' | '/settings/beta' @@ -323,6 +335,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ConnectCallbackRouteImport parentRoute: typeof rootRouteImport } + '/_chat/board': { + id: '/_chat/board' + path: '/board' + fullPath: '/board' + preLoaderRoute: typeof ChatBoardRouteImport + parentRoute: typeof ChatRoute + } '/_chat/draft/$draftId': { id: '/_chat/draft/$draftId' path: '/draft/$draftId' @@ -341,12 +360,14 @@ declare module '@tanstack/react-router' { } interface ChatRouteChildren { + ChatBoardRoute: typeof ChatBoardRoute ChatIndexRoute: typeof ChatIndexRoute ChatEnvironmentIdThreadIdRoute: typeof ChatEnvironmentIdThreadIdRoute ChatDraftDraftIdRoute: typeof ChatDraftDraftIdRoute } const ChatRouteChildren: ChatRouteChildren = { + ChatBoardRoute: ChatBoardRoute, ChatIndexRoute: ChatIndexRoute, ChatEnvironmentIdThreadIdRoute: ChatEnvironmentIdThreadIdRoute, ChatDraftDraftIdRoute: ChatDraftDraftIdRoute, diff --git a/apps/web/src/routes/_chat.board.tsx b/apps/web/src/routes/_chat.board.tsx new file mode 100644 index 00000000000..39824af9745 --- /dev/null +++ b/apps/web/src/routes/_chat.board.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { BoardView } from "../components/board/BoardView"; + +export const Route = createFileRoute("/_chat/board")({ + component: BoardView, +}); diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index c7cff9943cd..09dcf982904 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -63,6 +63,7 @@ const STATIC_KEYBINDING_COMMANDS = [ "preview.zoomOut", "preview.resetZoom", "commandPalette.toggle", + "board.open", "chat.new", "chat.newLocal", "editor.openFavorite", diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index b6bdd7b4783..4b1fea98160 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -35,6 +35,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+-", command: "preview.zoomOut", when: "previewFocus" }, { key: "mod+0", command: "preview.resetZoom", when: "previewFocus" }, { key: "mod+k", command: "commandPalette.toggle", when: "!terminalFocus" }, + { key: "mod+t", command: "board.open" }, { key: "mod+n", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" }, From 4f34f943a9f1d8bb808f5be31aa48b632a168175 Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Sun, 26 Jul 2026 12:25:56 +0200 Subject: [PATCH 06/16] feat(tim): import tim-smart/t3code#9 Recover interrupted provider turns after server restarts Source: https://github.com/tim-smart/t3code/pull/9 Source head: b181832560177250b90bbfe07b0882c9e5b93493 Source commits: 7f69028a25be21f1882ecba14b62f387ad60cf2a,1d52bce1376766d804ef884d7d50b8b6d1b48cf7,b181832560177250b90bbfe07b0882c9e5b93493 Imported: complete product delta from the source PR. --- .../OrchestrationEngineHarness.integration.ts | 1 + apps/server/src/observability/Metrics.ts | 4 + .../Layers/ProviderCommandReactor.test.ts | 714 +++++++++++++++++- .../Layers/ProviderCommandReactor.ts | 489 +++++++++++- .../src/persistence/Layers/ProjectionTurns.ts | 28 + .../persistence/Services/ProjectionTurns.ts | 9 + .../src/provider/Layers/CodexAdapter.test.ts | 44 ++ .../src/provider/Layers/CodexAdapter.ts | 2 +- .../provider/Layers/ProviderService.test.ts | 104 ++- .../src/provider/Layers/ProviderService.ts | 146 +++- .../provider/ProviderRestartRecovery.test.ts | 73 ++ .../src/provider/ProviderRestartRecovery.ts | 103 +++ 12 files changed, 1647 insertions(+), 70 deletions(-) create mode 100644 apps/server/src/provider/ProviderRestartRecovery.test.ts create mode 100644 apps/server/src/provider/ProviderRestartRecovery.ts diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index ebc4f984b86..dc4a3f8df13 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -304,6 +304,7 @@ export const makeOrchestrationIntegrationHarness = ( ProjectionPendingApprovalRepositoryLive, checkpointStoreLayer, providerLayer, + providerSessionDirectoryLayer, RuntimeReceiptBusTest, ); const serverSettingsLayer = ServerSettingsService.layerTest(); diff --git a/apps/server/src/observability/Metrics.ts b/apps/server/src/observability/Metrics.ts index 886833d6e2c..a09179cbab2 100644 --- a/apps/server/src/observability/Metrics.ts +++ b/apps/server/src/observability/Metrics.ts @@ -50,6 +50,10 @@ export const providerTurnsTotal = Metric.counter("t3_provider_turns_total", { description: "Total provider turn lifecycle operations.", }); +export const providerTurnRecoveriesTotal = Metric.counter("t3_provider_turn_recoveries_total", { + description: "Total provider turn restart-recovery candidates and outcomes.", +}); + export const providerTurnDuration = Metric.timer("t3_provider_turn_duration", { description: "Provider turn request duration.", }); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index c49646b7a4b..dca0d2188f4 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -26,6 +26,7 @@ import * as Deferred from "effect/Deferred"; import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import * as ManagedRuntime from "effect/ManagedRuntime"; +import * as Option from "effect/Option"; import * as PubSub from "effect/PubSub"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; @@ -37,11 +38,15 @@ import { TextGenerationError } from "@t3tools/contracts"; import { ProviderAdapterRequestError } from "../../provider/Errors.ts"; import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts"; import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts"; -import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/ProjectionTurns.ts"; +import { ProjectionTurnRepository } from "../../persistence/Services/ProjectionTurns.ts"; +import { makeSqlitePersistenceLive } from "../../persistence/Layers/Sqlite.ts"; import { ProviderService, type ProviderServiceShape, } from "../../provider/Services/ProviderService.ts"; +import { ProviderSessionDirectory } from "../../provider/Services/ProviderSessionDirectory.ts"; +import type { ProviderRuntimeBindingWithMetadata } from "../../provider/Services/ProviderSessionDirectory.ts"; import { makeProviderRegistryLayer } from "../../provider/testUtils/providerRegistryMock.ts"; import { TextGeneration, type TextGenerationShape } from "../../textGeneration/TextGeneration.ts"; import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts"; @@ -51,8 +56,10 @@ import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQu import { providerErrorLabel, providerErrorLabelFromInstanceHint, + RESTART_RECOVERY_CONTINUATION_INSTRUCTION, ProviderCommandReactorLive, } from "./ProviderCommandReactor.ts"; +import { makeProviderRestartRecoveryMarker } from "../../provider/ProviderRestartRecovery.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; @@ -91,7 +98,10 @@ async function waitFor( describe("ProviderCommandReactor", () => { let runtime: ManagedRuntime.ManagedRuntime< - OrchestrationEngineService | ProviderCommandReactor | ProjectionSnapshotQuery, + | OrchestrationEngineService + | ProviderCommandReactor + | ProjectionSnapshotQuery + | ProjectionTurnRepository, unknown > | null = null; let scope: Scope.Closeable | null = null; @@ -149,13 +159,21 @@ describe("ProviderCommandReactor", () => { readonly startSessionEffect?: ( session: ProviderSession, ) => Effect.Effect; + readonly deferReactorStart?: boolean; + readonly providerBindings?: ReadonlyArray; + readonly providerBindingsMap?: Map; + readonly skipDefaultSetup?: boolean; + readonly providerInstanceEnabled?: boolean; }) { const now = "2026-01-01T00:00:00.000Z"; const baseDir = input?.baseDir ?? NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3code-reactor-")); createdBaseDirs.add(baseDir); - const { stateDir } = deriveServerPathsSync(baseDir, undefined); + const { stateDir, dbPath } = deriveServerPathsSync(baseDir, undefined); createdStateDirs.add(stateDir); + const persistenceLayer = makeSqlitePersistenceLive(dbPath).pipe( + Layer.provide(NodeServices.layer), + ); const runtimeEventPubSub = Effect.runSync(PubSub.unbounded()); let nextSessionIndex = 1; const runtimeSessions: Array = []; @@ -302,6 +320,30 @@ describe("ProviderCommandReactor", () => { : {}), }, ]; + const providerBindings = + input?.providerBindingsMap ?? + new Map((input?.providerBindings ?? []).map((binding) => [binding.threadId, binding])); + const directoryUpsert = vi.fn( + (binding: Parameters[0]) => + Effect.sync(() => { + const existing = providerBindings.get(binding.threadId); + providerBindings.set(binding.threadId, { + ...existing, + ...binding, + providerInstanceId: binding.providerInstanceId, + lastSeenAt: existing?.lastSeenAt ?? now, + runtimePayload: + existing?.runtimePayload && + typeof existing.runtimePayload === "object" && + !Array.isArray(existing.runtimePayload) && + binding.runtimePayload && + typeof binding.runtimePayload === "object" && + !Array.isArray(binding.runtimePayload) + ? { ...existing.runtimePayload, ...binding.runtimePayload } + : (binding.runtimePayload ?? existing?.runtimePayload ?? null), + } as ProviderRuntimeBindingWithMetadata); + }), + ); const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never; const service: ProviderServiceShape = { @@ -325,7 +367,7 @@ describe("ProviderCommandReactor", () => { instanceId, driverKind, displayName: undefined, - enabled: true, + enabled: input?.providerInstanceEnabled ?? true, continuationIdentity: { driverKind, continuationKey: @@ -347,16 +389,26 @@ describe("ProviderCommandReactor", () => { Layer.provide(OrchestrationEventStoreLive), Layer.provide(OrchestrationCommandReceiptRepositoryLive), Layer.provide(RepositoryIdentityResolver.layer), - Layer.provide(SqlitePersistenceMemory), + Layer.provide(persistenceLayer), ); const projectionSnapshotLayer = OrchestrationProjectionSnapshotQueryLive.pipe( Layer.provide(RepositoryIdentityResolver.layer), - Layer.provide(SqlitePersistenceMemory), + Layer.provide(persistenceLayer), ); const layer = ProviderCommandReactorLive.pipe( Layer.provideMerge(orchestrationLayer), Layer.provideMerge(projectionSnapshotLayer), Layer.provideMerge(Layer.succeed(ProviderService, service)), + Layer.provideMerge( + Layer.succeed(ProviderSessionDirectory, { + upsert: directoryUpsert, + getProvider: () => Effect.die("getProvider should not be called in this test"), + getBinding: (threadId) => + Effect.succeed(Option.fromNullishOr(providerBindings.get(threadId))), + listThreadIds: () => Effect.succeed(Array.from(providerBindings.keys())), + listBindings: () => Effect.succeed(Array.from(providerBindings.values())), + }), + ), Layer.provideMerge(makeProviderRegistryLayer(providerSnapshots as never)), Layer.provideMerge( Layer.mock(GitWorkflowService.GitWorkflowService)({ @@ -381,46 +433,61 @@ describe("ProviderCommandReactor", () => { Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(ServerConfig.layerTest(process.cwd(), baseDir)), Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(persistenceLayer), + Layer.provideMerge(ProjectionTurnRepositoryLive.pipe(Layer.provide(persistenceLayer))), ); runtime = ManagedRuntime.make(layer); const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService)); const snapshotQuery = await runtime.runPromise(Effect.service(ProjectionSnapshotQuery)); const reactor = await runtime.runPromise(Effect.service(ProviderCommandReactor)); + const turnRepository = await runtime.runPromise(Effect.service(ProjectionTurnRepository)); scope = await Effect.runPromise(Scope.make("sequential")); - await Effect.runPromise(reactor.start().pipe(Scope.provide(scope))); + let reactorStarted = false; + const startReactor = async () => { + if (reactorStarted) return; + reactorStarted = true; + await Effect.runPromise(reactor.start().pipe(Scope.provide(scope!))); + }; + if (input?.deferReactorStart !== true) { + await startReactor(); + } const drain = () => Effect.runPromise(reactor.drain); - await Effect.runPromise( - engine.dispatch({ - type: "project.create", - commandId: CommandId.make("cmd-project-create"), - projectId: asProjectId("project-1"), - title: "Provider Project", - workspaceRoot: "/tmp/provider-project", - defaultModelSelection: modelSelection, - createdAt: now, - }), - ); - await Effect.runPromise( - engine.dispatch({ - type: "thread.create", - commandId: CommandId.make("cmd-thread-create"), - threadId: ThreadId.make("thread-1"), - projectId: asProjectId("project-1"), - title: "Thread", - modelSelection: modelSelection, - interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, - runtimeMode: "approval-required", - branch: null, - worktreePath: null, - createdAt: now, - }), - ); + if (input?.skipDefaultSetup !== true) { + await Effect.runPromise( + engine.dispatch({ + type: "project.create", + commandId: CommandId.make("cmd-project-create"), + projectId: asProjectId("project-1"), + title: "Provider Project", + workspaceRoot: "/tmp/provider-project", + defaultModelSelection: modelSelection, + createdAt: now, + }), + ); + await Effect.runPromise( + engine.dispatch({ + type: "thread.create", + commandId: CommandId.make("cmd-thread-create"), + threadId: ThreadId.make("thread-1"), + projectId: asProjectId("project-1"), + title: "Thread", + modelSelection: modelSelection, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + branch: null, + worktreePath: null, + createdAt: now, + }), + ); + } return { engine, readModel: () => Effect.runPromise(snapshotQuery.getSnapshot()), + readTurns: (threadId: ThreadId) => + Effect.runPromise(turnRepository.listByThreadId({ threadId })), startSession, sendTurn, interruptTurn, @@ -432,8 +499,11 @@ describe("ProviderCommandReactor", () => { generateBranchName, generateThreadTitle, runtimeSessions, + directoryUpsert, + providerBindings, stateDir, drain, + startReactor, }; } @@ -586,6 +656,584 @@ describe("ProviderCommandReactor", () => { expect(thread?.session?.lastError).toBeNull(); }), ); + it("replays a persisted pending turn start exactly once on startup", async () => { + const harness = await createHarness({ deferReactorStart: true }); + const now = "2026-01-01T00:00:00.000Z"; + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.interaction-mode.set", + commandId: CommandId.make("cmd-plan-before-pending"), + threadId: ThreadId.make("thread-1"), + interactionMode: "plan", + createdAt: now, + }), + ); + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-pending-before-restart"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("pending-user-message"), + role: "user", + text: "resume this exact pending request", + attachments: [], + }, + interactionMode: "plan", + runtimeMode: "approval-required", + createdAt: now, + }), + ); + expect(harness.sendTurn).not.toHaveBeenCalled(); + + await harness.startReactor(); + await harness.drain(); + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({ + threadId: ThreadId.make("thread-1"), + input: "resume this exact pending request", + interactionMode: "plan", + }); + await harness.startReactor(); + await harness.drain(); + expect(harness.sendTurn).toHaveBeenCalledTimes(1); + }); + + it("continues an interrupted running turn without replaying its user message", async () => { + const modelSelection: ModelSelection = { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-recovery", + }; + const threadId = ThreadId.make("thread-1"); + const harness = await createHarness({ + deferReactorStart: true, + threadModelSelection: modelSelection, + providerBindings: [ + { + threadId, + provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + adapterKey: "codex", + runtimeMode: "full-access", + status: "stopped", + resumeCursor: { threadId: "provider-thread-resume" }, + runtimePayload: { + cwd: "/tmp/persisted-recovery-cwd", + modelSelection, + interactionMode: "plan", + activeTurnId: null, + restartRecovery: makeProviderRestartRecoveryMarker({ + interruptedProviderTurnId: asTurnId("provider-turn-before-restart"), + shutdownAt: "2026-01-01T00:00:01.000Z", + }), + }, + lastSeenAt: "2026-01-01T00:00:01.000Z", + }, + ], + }); + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-running-before-restart"), + threadId, + message: { + messageId: asMessageId("running-user-message"), + role: "user", + text: "the original request must not be replayed", + attachments: [], + }, + modelSelection, + interactionMode: "plan", + runtimeMode: "full-access", + createdAt: "2026-01-01T00:00:00.000Z", + }), + ); + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("server:running-before-restart"), + threadId, + session: { + threadId, + status: "running", + providerName: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + runtimeMode: "full-access", + activeTurnId: asTurnId("orchestration-turn-before-restart"), + lastError: null, + updatedAt: "2026-01-01T00:00:00.500Z", + }, + createdAt: "2026-01-01T00:00:00.500Z", + }), + ); + + await harness.startReactor(); + await harness.drain(); + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + + expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ + cwd: "/tmp/persisted-recovery-cwd", + modelSelection, + resumeCursor: { threadId: "provider-thread-resume" }, + runtimeMode: "full-access", + providerInstanceId: ProviderInstanceId.make("codex"), + }); + expect(harness.sendTurn.mock.calls[0]?.[0]).toEqual({ + threadId, + input: RESTART_RECOVERY_CONTINUATION_INSTRUCTION, + attachments: [], + modelSelection, + interactionMode: "plan", + }); + const readModel = await harness.readModel(); + const thread = readModel.threads.find((entry) => entry.id === threadId); + const oldTurn = (await harness.readTurns(threadId)).find( + (turn) => turn.turnId === asTurnId("orchestration-turn-before-restart"), + ); + expect(oldTurn?.state).toBe("interrupted"); + expect(thread?.messages.map((message) => message.text)).toEqual([ + "the original request must not be replayed", + ]); + expect(harness.providerBindings.get(threadId)?.runtimePayload).toMatchObject({ + restartRecovery: null, + interactionMode: "plan", + }); + }); + + it("does not resume settled turns from stale recovery bindings", async () => { + const modelSelection: ModelSelection = { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }; + const markerThreadId = ThreadId.make("thread-1"); + const legacyThreadId = ThreadId.make("thread-settled-legacy"); + const markerTurnId = asTurnId("turn-settled-marker"); + const legacyTurnId = asTurnId("turn-settled-legacy"); + const harness = await createHarness({ + deferReactorStart: true, + providerBindings: [ + { + threadId: markerThreadId, + provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + runtimeMode: "full-access", + status: "stopped", + resumeCursor: { threadId: "provider-thread-marker" }, + runtimePayload: { + modelSelection, + restartRecovery: makeProviderRestartRecoveryMarker({ + interruptedProviderTurnId: markerTurnId, + shutdownAt: "2026-01-01T00:00:02.000Z", + }), + }, + lastSeenAt: "2026-01-01T00:00:02.000Z", + }, + { + threadId: legacyThreadId, + provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + runtimeMode: "full-access", + status: "running", + resumeCursor: { threadId: "provider-thread-legacy" }, + runtimePayload: { + modelSelection, + activeTurnId: legacyTurnId, + }, + lastSeenAt: "2026-01-01T00:00:02.000Z", + }, + ], + }); + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.create", + commandId: CommandId.make("cmd-create-settled-legacy-thread"), + threadId: legacyThreadId, + projectId: asProjectId("project-1"), + title: "Settled legacy thread", + modelSelection, + interactionMode: "default", + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt: "2026-01-01T00:00:00.000Z", + }), + ); + + const settleTurn = async (threadId: ThreadId, turnId: TurnId, suffix: string) => { + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make(`cmd-settled-turn-${suffix}`), + threadId, + message: { + messageId: asMessageId(`message-settled-${suffix}`), + role: "user", + text: "already finished", + attachments: [], + }, + modelSelection, + interactionMode: "default", + runtimeMode: "full-access", + createdAt: "2026-01-01T00:00:00.500Z", + }), + ); + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make(`server:settled-running-${suffix}`), + threadId, + session: { + threadId, + status: "running", + providerName: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + runtimeMode: "full-access", + activeTurnId: turnId, + lastError: null, + updatedAt: "2026-01-01T00:00:01.000Z", + }, + createdAt: "2026-01-01T00:00:01.000Z", + }), + ); + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make(`server:settled-ready-${suffix}`), + threadId, + session: { + threadId, + status: "ready", + providerName: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: "2026-01-01T00:00:01.500Z", + }, + createdAt: "2026-01-01T00:00:01.500Z", + }), + ); + }; + + await settleTurn(markerThreadId, markerTurnId, "marker"); + await settleTurn(legacyThreadId, legacyTurnId, "legacy"); + expect( + (await harness.readTurns(markerThreadId)).find((turn) => turn.turnId === markerTurnId)?.state, + ).toBe("completed"); + expect( + (await harness.readTurns(legacyThreadId)).find((turn) => turn.turnId === legacyTurnId)?.state, + ).toBe("completed"); + + await harness.startReactor(); + await harness.drain(); + + expect(harness.startSession).not.toHaveBeenCalled(); + expect(harness.sendTurn).not.toHaveBeenCalled(); + for (const threadId of [markerThreadId, legacyThreadId]) { + expect(harness.providerBindings.get(threadId)).toMatchObject({ + status: "stopped", + runtimePayload: { + activeTurnId: null, + restartRecovery: null, + lastRuntimeEvent: "provider.restartRecovery.skipped", + }, + }); + } + }); + + it("recovers across temporary-SQLite runtimes and does not repeat a completed recovery", async () => { + const baseDir = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3code-reactor-two-runtime-"), + ); + const threadId = ThreadId.make("thread-1"); + const modelSelection: ModelSelection = { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }; + const persistedBindings = new Map(); + const first = await createHarness({ baseDir, providerBindingsMap: persistedBindings }); + await Effect.runPromise( + first.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-two-runtime-original-turn"), + threadId, + message: { + messageId: asMessageId("two-runtime-user-message"), + role: "user", + text: "finish this after the server restarts", + attachments: [], + }, + modelSelection, + interactionMode: "default", + runtimeMode: "approval-required", + createdAt: "2026-01-01T00:00:00.000Z", + }), + ); + await waitFor(() => first.sendTurn.mock.calls.length === 1); + await Effect.runPromise( + first.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("server:two-runtime-running"), + threadId, + session: { + threadId, + status: "running", + providerName: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + runtimeMode: "approval-required", + activeTurnId: asTurnId("orchestration-turn-two-runtime"), + lastError: null, + updatedAt: "2026-01-01T00:00:01.000Z", + }, + createdAt: "2026-01-01T00:00:01.000Z", + }), + ); + await Effect.runPromise( + first.directoryUpsert({ + threadId, + provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + runtimeMode: "approval-required", + status: "stopped", + resumeCursor: { threadId: "durable-provider-thread" }, + runtimePayload: { + cwd: "/tmp/provider-project", + modelSelection, + interactionMode: "default", + restartRecovery: makeProviderRestartRecoveryMarker({ + interruptedProviderTurnId: asTurnId("provider-turn-two-runtime"), + shutdownAt: "2026-01-01T00:00:02.000Z", + }), + }, + }), + ); + + await Effect.runPromise(Scope.close(scope!, Exit.void)); + scope = null; + await runtime!.dispose(); + runtime = null; + + const second = await createHarness({ + baseDir, + providerBindingsMap: persistedBindings, + skipDefaultSetup: true, + }); + await second.drain(); + await waitFor(() => second.sendTurn.mock.calls.length === 1); + expect(second.startSession.mock.calls[0]?.[1]).toMatchObject({ + resumeCursor: { threadId: "durable-provider-thread" }, + cwd: "/tmp/provider-project", + modelSelection, + runtimeMode: "approval-required", + }); + expect(second.sendTurn.mock.calls[0]?.[0]).toMatchObject({ + input: RESTART_RECOVERY_CONTINUATION_INSTRUCTION, + }); + const secondReadModel = await second.readModel(); + expect( + secondReadModel.threads + .find((thread) => thread.id === threadId) + ?.messages.map((message) => message.text), + ).toEqual(["finish this after the server restarts"]); + + await Effect.runPromise( + second.directoryUpsert({ + threadId, + provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + runtimeMode: "approval-required", + status: "running", + runtimePayload: { activeTurnId: null, restartRecovery: null }, + }), + ); + await Effect.runPromise( + second.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("server:two-runtime-recovery-complete"), + threadId, + session: { + threadId, + status: "ready", + providerName: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: "2026-01-01T00:00:03.000Z", + }, + createdAt: "2026-01-01T00:00:03.000Z", + }), + ); + + await Effect.runPromise(Scope.close(scope!, Exit.void)); + scope = null; + await runtime!.dispose(); + runtime = null; + + const third = await createHarness({ + baseDir, + providerBindingsMap: persistedBindings, + skipDefaultSetup: true, + }); + await third.drain(); + expect(third.sendTurn).not.toHaveBeenCalled(); + }); + + it("isolates restart recovery failures and continues unrelated threads", async () => { + const modelSelection: ModelSelection = { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }; + const failedThreadId = ThreadId.make("thread-1"); + const healthyThreadId = ThreadId.make("thread-2"); + const marker = makeProviderRestartRecoveryMarker({ + interruptedProviderTurnId: asTurnId("provider-turn-before-restart"), + shutdownAt: "2026-01-01T00:00:01.000Z", + }); + const makeBinding = ( + threadId: ThreadId, + resumeCursor: unknown | null, + ): ProviderRuntimeBindingWithMetadata => ({ + threadId, + provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + adapterKey: "codex", + runtimeMode: "approval-required", + status: "stopped", + resumeCursor, + runtimePayload: { + cwd: "/tmp/provider-project", + modelSelection, + interactionMode: "default", + restartRecovery: marker, + }, + lastSeenAt: "2026-01-01T00:00:01.000Z", + }); + const harness = await createHarness({ + deferReactorStart: true, + providerBindings: [ + makeBinding(failedThreadId, null), + makeBinding(healthyThreadId, { threadId: "healthy-provider-thread" }), + ], + }); + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.create", + commandId: CommandId.make("cmd-create-recovery-thread-2"), + threadId: healthyThreadId, + projectId: asProjectId("project-1"), + title: "Healthy recovery", + modelSelection, + interactionMode: "default", + runtimeMode: "approval-required", + branch: null, + worktreePath: null, + createdAt: "2026-01-01T00:00:00.000Z", + }), + ); + + await harness.startReactor(); + await harness.drain(); + expect(harness.sendTurn).toHaveBeenCalledTimes(1); + expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({ + threadId: healthyThreadId, + input: RESTART_RECOVERY_CONTINUATION_INSTRUCTION, + }); + + const readModel = await harness.readModel(); + const failedThread = readModel.threads.find((thread) => thread.id === failedThreadId); + expect(failedThread?.session?.status).toBe("error"); + expect( + failedThread?.activities.some( + (activity) => activity.kind === "provider.turn.recovery.failed", + ), + ).toBe(true); + }); + + it("surfaces a disabled provider instance without starting recovery work", async () => { + const threadId = ThreadId.make("thread-1"); + const modelSelection: ModelSelection = { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }; + const harness = await createHarness({ + deferReactorStart: true, + providerInstanceEnabled: false, + providerBindings: [ + { + threadId, + provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + runtimeMode: "approval-required", + status: "stopped", + resumeCursor: { threadId: "disabled-provider-thread" }, + runtimePayload: { + modelSelection, + restartRecovery: makeProviderRestartRecoveryMarker({ + interruptedProviderTurnId: asTurnId("disabled-provider-turn"), + shutdownAt: "2026-01-01T00:00:01.000Z", + }), + }, + lastSeenAt: "2026-01-01T00:00:01.000Z", + }, + ], + }); + + await harness.startReactor(); + await harness.drain(); + expect(harness.startSession).not.toHaveBeenCalled(); + expect(harness.sendTurn).not.toHaveBeenCalled(); + const thread = (await harness.readModel()).threads.find((entry) => entry.id === threadId); + expect(thread?.session?.status).toBe("error"); + expect( + thread?.activities.find((activity) => activity.kind === "provider.turn.recovery.failed") + ?.payload, + ).toMatchObject({ detail: expect.stringContaining("disabled") }); + }); + + it("skips archived recovery candidates", async () => { + const threadId = ThreadId.make("thread-1"); + const modelSelection: ModelSelection = { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }; + const harness = await createHarness({ + deferReactorStart: true, + providerBindings: [ + { + threadId, + provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + runtimeMode: "approval-required", + status: "stopped", + resumeCursor: { threadId: "archived-provider-thread" }, + runtimePayload: { + modelSelection, + restartRecovery: makeProviderRestartRecoveryMarker({ + interruptedProviderTurnId: asTurnId("archived-provider-turn"), + shutdownAt: "2026-01-01T00:00:01.000Z", + }), + }, + lastSeenAt: "2026-01-01T00:00:01.000Z", + }, + ], + }); + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.archive", + commandId: CommandId.make("cmd-archive-before-recovery"), + threadId, + }), + ); + + await harness.startReactor(); + await harness.drain(); + expect(harness.startSession).not.toHaveBeenCalled(); + expect(harness.sendTurn).not.toHaveBeenCalled(); + expect(harness.providerBindings.get(threadId)?.runtimePayload).toMatchObject({ + restartRecovery: expect.objectContaining({ version: 1 }), + }); + }); it("generates a thread title on the first turn", async () => { const harness = await createHarness(); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index b6bff8c766a..28bbdaadfe4 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -9,6 +9,7 @@ import { type OrchestrationSession, ThreadId, type ProviderSession, + type ProviderInteractionMode, type RuntimeMode, type TurnId, } from "@t3tools/contracts"; @@ -16,6 +17,8 @@ import { isTemporaryWorktreeBranch, WORKTREE_BRANCH_PREFIX } from "@t3tools/shar import * as Cache from "effect/Cache"; import * as Cause from "effect/Cause"; import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Equal from "effect/Equal"; @@ -26,12 +29,29 @@ import * as Stream from "effect/Stream"; import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts"; -import { increment, orchestrationEventsProcessedTotal } from "../../observability/Metrics.ts"; +import { + increment, + orchestrationEventsProcessedTotal, + providerTurnRecoveriesTotal, +} from "../../observability/Metrics.ts"; import { ProviderAdapterRequestError } from "../../provider/Errors.ts"; import type { ProviderServiceError } from "../../provider/Errors.ts"; +import { + readPersistedProviderCwd, + readPersistedProviderInteractionMode, + readPersistedProviderModelSelection, + readProviderRestartRecoveryCandidate, + type ProviderRestartRecoveryCandidate, +} from "../../provider/ProviderRestartRecovery.ts"; import { TextGeneration } from "../../textGeneration/TextGeneration.ts"; import { ProviderService } from "../../provider/Services/ProviderService.ts"; import { ProviderRegistry } from "../../provider/Services/ProviderRegistry.ts"; +import { + ProviderSessionDirectory, + type ProviderRuntimeBindingWithMetadata, +} from "../../provider/Services/ProviderSessionDirectory.ts"; +import { ProjectionTurnRepository } from "../../persistence/Services/ProjectionTurns.ts"; +import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/ProjectionTurns.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; import { @@ -87,6 +107,10 @@ const HANDLED_TURN_START_KEY_MAX = 10_000; const HANDLED_TURN_START_KEY_TTL = Duration.minutes(30); const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; const DEFAULT_THREAD_TITLE = "New thread"; +const STARTUP_RECOVERY_CONCURRENCY = 4; + +export const RESTART_RECOVERY_CONTINUATION_INSTRUCTION = + "The server restarted while you were working. Inspect the conversation and current workspace state, verify which side effects from the interrupted turn already happened, and continue the unfinished work safely. Do not repeat completed work or assume an earlier tool call failed merely because its response is absent."; export function providerErrorLabel(value: string | undefined): string { const normalized = value?.trim(); @@ -190,7 +214,9 @@ const make = Effect.gen(function* () { const crypto = yield* Crypto.Crypto; const orchestrationEngine = yield* OrchestrationEngineService; const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; + const projectionTurnRepository = yield* ProjectionTurnRepository; const providerService = yield* ProviderService; + const providerSessionDirectory = yield* ProviderSessionDirectory; const providerRegistry = yield* ProviderRegistry; const gitWorkflow = yield* GitWorkflowService; const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; @@ -204,6 +230,9 @@ const make = Effect.gen(function* () { timeToLive: HANDLED_TURN_START_KEY_TTL, lookup: () => Effect.succeed(true), }); + const startupReconciliationDone = yield* Deferred.make(); + const recoveredThreadIds = new Set(); + const interruptedRecoveryThreadIds = new Set(); const hasHandledTurnStartRecently = (key: string) => Cache.getOption(handledTurnStartKeys, key).pipe( @@ -218,6 +247,7 @@ const make = Effect.gen(function* () { readonly threadId: ThreadId; readonly kind: | "provider.turn.start.failed" + | "provider.turn.recovery.failed" | "provider.turn.interrupt.failed" | "provider.approval.respond.failed" | "provider.user-input.respond.failed" @@ -1026,6 +1056,319 @@ const make = Effect.gen(function* () { }); }); + const setRecoveryFailureState = Effect.fn("setRecoveryFailureState")(function* (input: { + readonly binding: ProviderRuntimeBindingWithMetadata; + readonly detail: string; + readonly createdAt: string; + }) { + const thread = yield* resolveThread(input.binding.threadId); + if (!thread) return; + yield* setThreadSession({ + threadId: thread.id, + session: { + threadId: thread.id, + status: "error", + providerName: input.binding.provider, + ...(input.binding.providerInstanceId !== undefined + ? { providerInstanceId: input.binding.providerInstanceId } + : {}), + runtimeMode: input.binding.runtimeMode ?? thread.runtimeMode, + activeTurnId: null, + lastError: input.detail, + updatedAt: input.createdAt, + }, + createdAt: input.createdAt, + }); + }); + + const recoverInterruptedTurn = Effect.fn("recoverInterruptedTurn")(function* (input: { + readonly binding: ProviderRuntimeBindingWithMetadata; + readonly candidate: ProviderRestartRecoveryCandidate; + }) { + const { binding, candidate } = input; + if (recoveredThreadIds.has(binding.threadId)) { + yield* increment(providerTurnRecoveriesTotal, { + outcome: "skipped", + reason: "duplicate-in-boot", + provider: binding.provider, + }); + return; + } + recoveredThreadIds.add(binding.threadId); + + const thread = yield* resolveThread(binding.threadId); + if (!thread) { + yield* Effect.logInfo("provider turn restart recovery skipped", { + threadId: binding.threadId, + provider: binding.provider, + reason: "thread-missing-archived-or-deleted", + }); + yield* increment(providerTurnRecoveriesTotal, { + outcome: "skipped", + reason: "inactive-thread", + provider: binding.provider, + }); + return; + } + + const createdAt = DateTime.formatIso(yield* DateTime.now); + const projectedTurns = yield* projectionTurnRepository.listByThreadId({ + threadId: binding.threadId, + }); + const projectedCandidateTurn = + candidate.interruptedProviderTurnId === null + ? undefined + : projectedTurns.find((turn) => turn.turnId === candidate.interruptedProviderTurnId); + const latestProjectedTurn = projectedTurns.findLast((turn) => turn.turnId !== null); + const projectedRecoveryTurn = projectedCandidateTurn ?? latestProjectedTurn; + if (projectedRecoveryTurn?.state === "completed" || projectedRecoveryTurn?.state === "error") { + yield* providerSessionDirectory + .upsert({ + threadId: binding.threadId, + provider: binding.provider, + ...(binding.providerInstanceId !== undefined + ? { providerInstanceId: binding.providerInstanceId } + : {}), + ...(binding.runtimeMode !== undefined ? { runtimeMode: binding.runtimeMode } : {}), + status: "stopped", + runtimePayload: { + activeTurnId: null, + restartRecovery: null, + lastRuntimeEvent: "provider.restartRecovery.skipped", + lastRuntimeEventAt: createdAt, + }, + }) + .pipe( + Effect.catchCause((cause) => + Effect.logWarning("stale provider restart recovery intent was not cleared", { + threadId: binding.threadId, + cause: Cause.pretty(cause), + }), + ), + ); + yield* Effect.logInfo("provider turn restart recovery skipped", { + threadId: binding.threadId, + provider: binding.provider, + reason: "projected-turn-settled", + projectedTurnId: projectedRecoveryTurn.turnId, + projectedTurnState: projectedRecoveryTurn.state, + }); + yield* increment(providerTurnRecoveriesTotal, { + outcome: "skipped", + reason: "projected-turn-settled", + provider: binding.provider, + }); + return; + } + interruptedRecoveryThreadIds.add(thread.id); + + const recover = Effect.gen(function* () { + const runtimeMode = binding.runtimeMode ?? thread.runtimeMode; + // This lifecycle transition settles the concrete old projection row as + // interrupted before validation or replacement work can proceed. + yield* setThreadSession({ + threadId: thread.id, + session: { + threadId: thread.id, + status: "interrupted", + providerName: binding.provider, + ...(binding.providerInstanceId !== undefined + ? { providerInstanceId: binding.providerInstanceId } + : {}), + runtimeMode, + activeTurnId: null, + lastError: null, + updatedAt: createdAt, + }, + createdAt, + }); + + const providerInstanceId = binding.providerInstanceId; + if (providerInstanceId === undefined) { + return yield* new ProviderAdapterRequestError({ + provider: binding.provider, + method: "provider.turn.restart-recovery", + detail: `Persisted provider binding for thread '${binding.threadId}' has no provider instance id.`, + }); + } + if (binding.resumeCursor === null || binding.resumeCursor === undefined) { + return yield* new ProviderAdapterRequestError({ + provider: binding.provider, + method: "provider.turn.restart-recovery", + detail: `Cannot recover thread '${binding.threadId}' because no provider resume cursor is persisted.`, + }); + } + + const instanceInfo = yield* providerService.getInstanceInfo(providerInstanceId); + if (!instanceInfo.enabled) { + return yield* new ProviderAdapterRequestError({ + provider: binding.provider, + method: "provider.turn.restart-recovery", + detail: `Provider instance '${providerInstanceId}' is disabled in T3 Code settings.`, + }); + } + if (instanceInfo.driverKind !== binding.provider) { + return yield* new ProviderAdapterRequestError({ + provider: binding.provider, + method: "provider.turn.restart-recovery", + detail: `Persisted provider instance '${providerInstanceId}' now uses driver '${instanceInfo.driverKind}', not '${binding.provider}'.`, + }); + } + + const persistedModelSelection = readPersistedProviderModelSelection(binding.runtimePayload); + const modelSelection = persistedModelSelection ?? thread.modelSelection; + if (modelSelection.instanceId !== providerInstanceId) { + return yield* new ProviderAdapterRequestError({ + provider: binding.provider, + method: "provider.turn.restart-recovery", + detail: `Persisted model selection references provider instance '${modelSelection.instanceId}', but the recoverable session belongs to '${providerInstanceId}'.`, + }); + } + const interactionMode: ProviderInteractionMode = + readPersistedProviderInteractionMode(binding.runtimePayload) ?? thread.interactionMode; + const project = yield* resolveProject(thread.projectId); + const cwd = + readPersistedProviderCwd(binding.runtimePayload) ?? + resolveThreadWorkspaceCwd({ thread, projects: project ? [project] : [] }); + + const session = yield* providerService.startSession(thread.id, { + threadId: thread.id, + provider: binding.provider, + providerInstanceId, + ...(cwd !== undefined ? { cwd } : {}), + modelSelection, + resumeCursor: binding.resumeCursor, + runtimeMode, + }); + yield* setThreadSession({ + threadId: thread.id, + session: { + threadId: thread.id, + status: mapProviderSessionStatusToOrchestrationStatus(session.status), + providerName: session.provider, + providerInstanceId, + runtimeMode, + activeTurnId: null, + lastError: session.lastError ?? null, + updatedAt: session.updatedAt, + }, + createdAt, + }); + + const replacement = yield* providerService.sendTurn({ + threadId: thread.id, + input: RESTART_RECOVERY_CONTINUATION_INSTRUCTION, + attachments: [], + modelSelection, + interactionMode, + }); + + // ProviderService clears this in the accepted sendTurn transaction. The + // explicit write keeps the reconciliation invariant local and obvious. + yield* providerSessionDirectory.upsert({ + threadId: thread.id, + provider: binding.provider, + providerInstanceId, + runtimeMode, + status: "running", + ...(replacement.resumeCursor !== undefined + ? { resumeCursor: replacement.resumeCursor } + : {}), + runtimePayload: { + activeTurnId: replacement.turnId, + modelSelection, + interactionMode, + restartRecovery: null, + lastRuntimeEvent: "provider.restartRecovery.accepted", + lastRuntimeEventAt: DateTime.formatIso(yield* DateTime.now), + }, + }); + + yield* Effect.logInfo("provider turn restart recovery accepted", { + threadId: thread.id, + provider: binding.provider, + providerInstanceId, + interruptedProviderTurnId: candidate.interruptedProviderTurnId, + replacementProviderTurnId: replacement.turnId, + recoverySource: candidate.source, + }); + yield* increment(providerTurnRecoveriesTotal, { + outcome: "continued", + source: candidate.source, + provider: binding.provider, + }); + }); + + yield* recover.pipe( + Effect.catchCause((cause) => { + const detail = formatFailureDetail(cause); + const persistFailureState = + binding.providerInstanceId === undefined + ? Effect.void + : providerSessionDirectory + .upsert({ + threadId: binding.threadId, + provider: binding.provider, + providerInstanceId: binding.providerInstanceId, + ...(binding.runtimeMode !== undefined + ? { runtimeMode: binding.runtimeMode } + : {}), + status: "error", + runtimePayload: { + activeTurnId: null, + lastError: detail, + lastRuntimeEvent: "provider.restartRecovery.failed", + lastRuntimeEventAt: createdAt, + }, + }) + .pipe( + Effect.catchCause((persistenceCause) => + Effect.logWarning("provider restart recovery failure state was not persisted", { + threadId: binding.threadId, + cause: Cause.pretty(persistenceCause), + }), + ), + ); + return persistFailureState.pipe( + Effect.andThen(setRecoveryFailureState({ binding, detail, createdAt })), + Effect.andThen( + appendProviderFailureActivity({ + threadId: binding.threadId, + kind: "provider.turn.recovery.failed", + summary: "Provider turn recovery failed", + detail, + turnId: thread.latestTurn?.turnId ?? null, + createdAt, + }), + ), + Effect.andThen( + Effect.logWarning("provider turn restart recovery failed", { + threadId: binding.threadId, + provider: binding.provider, + providerInstanceId: binding.providerInstanceId, + recoverySource: candidate.source, + cause: Cause.pretty(cause), + }), + ), + Effect.andThen( + increment(providerTurnRecoveriesTotal, { + outcome: "failed", + source: candidate.source, + provider: binding.provider, + }), + ), + Effect.catchCause((reportingCause) => + Effect.logWarning("provider turn restart recovery failure reporting failed", { + threadId: binding.threadId, + cause: Cause.pretty(reportingCause), + originalCause: Cause.pretty(cause), + }), + ), + ); + }), + ); + }); + const processDomainEvent = Effect.fn("processDomainEvent")(function* ( event: ProviderIntentEvent, ) { @@ -1084,6 +1427,135 @@ const make = Effect.gen(function* () { const worker = yield* makeDrainableWorker(processDomainEventSafely); + const reconcileStartup = Effect.fn("reconcileStartup")(function* () { + const bindings = yield* providerSessionDirectory.listBindings().pipe( + Effect.catchCause((cause) => + Effect.logWarning("provider restart recovery failed to list persisted bindings", { + cause: Cause.pretty(cause), + }).pipe(Effect.as([] as ReadonlyArray)), + ), + ); + const recoveryCandidates = bindings.flatMap((binding) => { + const candidate = readProviderRestartRecoveryCandidate({ + runtimePayload: binding.runtimePayload, + status: binding.status, + lastSeenAt: binding.lastSeenAt, + }); + return candidate === undefined ? [] : [{ binding, candidate }]; + }); + const pendingTurnStarts = yield* projectionTurnRepository.listPendingTurnStarts().pipe( + Effect.catchCause((cause) => + Effect.logWarning("provider restart recovery failed to list pending turn starts", { + cause: Cause.pretty(cause), + }).pipe(Effect.as([])), + ), + ); + + yield* Effect.logInfo("provider restart reconciliation candidates loaded", { + interruptedTurnCandidates: recoveryCandidates.length, + pendingTurnStartCandidates: pendingTurnStarts.length, + }); + if (recoveryCandidates.length > 0) { + yield* increment( + providerTurnRecoveriesTotal, + { outcome: "candidate", recoveryKind: "interrupted-turn" }, + recoveryCandidates.length, + ); + } + + yield* Effect.forEach(recoveryCandidates, recoverInterruptedTurn, { + concurrency: STARTUP_RECOVERY_CONCURRENCY, + discard: true, + }); + + const pendingWithoutInterruptedRecovery = pendingTurnStarts.filter( + (pending) => !interruptedRecoveryThreadIds.has(pending.threadId), + ); + if (pendingWithoutInterruptedRecovery.length === 0) return; + + const persistedEvents = yield* Stream.runCollect( + orchestrationEngine.readEvents(0, Number.MAX_SAFE_INTEGER), + ).pipe( + Effect.map((events) => Array.from(events)), + Effect.catchCause((cause) => + Effect.logWarning("provider restart recovery failed to read persisted turn starts", { + cause: Cause.pretty(cause), + }).pipe(Effect.as([] as ReadonlyArray)), + ), + ); + const turnStartEventsByPendingKey = new Map< + string, + Extract + >(); + for (const event of persistedEvents) { + if (event.type !== "thread.turn-start-requested") continue; + turnStartEventsByPendingKey.set( + `${event.payload.threadId}\u0000${event.payload.messageId}\u0000${event.payload.createdAt}`, + event, + ); + } + + yield* Effect.forEach( + pendingWithoutInterruptedRecovery, + (pending) => + Effect.gen(function* () { + const thread = yield* resolveThread(pending.threadId); + if (!thread) { + yield* Effect.logInfo("pending provider turn start reconciliation skipped", { + threadId: pending.threadId, + messageId: pending.messageId, + reason: "thread-missing-archived-or-deleted", + }); + yield* increment(providerTurnRecoveriesTotal, { + outcome: "skipped", + recoveryKind: "pending-start", + reason: "inactive-thread", + }); + return; + } + const event = turnStartEventsByPendingKey.get( + `${pending.threadId}\u0000${pending.messageId}\u0000${pending.requestedAt}`, + ); + if (event === undefined) { + yield* appendProviderFailureActivity({ + threadId: pending.threadId, + kind: "provider.turn.start.failed", + summary: "Provider turn start recovery failed", + detail: `Persisted turn start event for user message '${pending.messageId}' could not be found.`, + turnId: null, + createdAt: DateTime.formatIso(yield* DateTime.now), + }); + yield* increment(providerTurnRecoveriesTotal, { + outcome: "failed", + recoveryKind: "pending-start", + reason: "event-missing", + }); + return; + } + + yield* worker.enqueue(event); + yield* Effect.logInfo("pending provider turn start replay enqueued", { + threadId: pending.threadId, + messageId: pending.messageId, + eventId: event.eventId, + }); + yield* increment(providerTurnRecoveriesTotal, { + outcome: "replayed", + recoveryKind: "pending-start", + }); + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("pending provider turn start reconciliation failed", { + threadId: pending.threadId, + messageId: pending.messageId, + cause: Cause.pretty(cause), + }), + ), + ), + { concurrency: STARTUP_RECOVERY_CONCURRENCY, discard: true }, + ); + }); + const start: ProviderCommandReactorShape["start"] = Effect.fn("start")(function* () { const processEvent = Effect.fn("processEvent")(function* (event: OrchestrationEvent) { if ( @@ -1101,12 +1573,23 @@ const make = Effect.gen(function* () { yield* Effect.forkScoped( Stream.runForEach(orchestrationEngine.streamDomainEvents, processEvent), ); + yield* reconcileStartup().pipe( + Effect.catchCause((cause) => + Effect.logWarning("provider restart reconciliation failed", { + cause: Cause.pretty(cause), + }), + ), + Effect.ensuring(Deferred.succeed(startupReconciliationDone, undefined).pipe(Effect.ignore)), + Effect.forkScoped, + ); }); return { start, - drain: worker.drain, + drain: Deferred.await(startupReconciliationDone).pipe(Effect.andThen(worker.drain)), } satisfies ProviderCommandReactorShape; }); -export const ProviderCommandReactorLive = Layer.effect(ProviderCommandReactor, make); +export const ProviderCommandReactorLive = Layer.effect(ProviderCommandReactor, make).pipe( + Layer.provide(ProjectionTurnRepositoryLive), +); diff --git a/apps/server/src/persistence/Layers/ProjectionTurns.ts b/apps/server/src/persistence/Layers/ProjectionTurns.ts index bd57a4eaa30..bc59d3ed45f 100644 --- a/apps/server/src/persistence/Layers/ProjectionTurns.ts +++ b/apps/server/src/persistence/Layers/ProjectionTurns.ts @@ -169,6 +169,26 @@ const makeProjectionTurnRepository = Effect.gen(function* () { `, }); + const listPendingProjectionTurns = SqlSchema.findAll({ + Request: Schema.Void, + Result: ProjectionPendingTurnStart, + execute: () => + sql` + SELECT + thread_id AS "threadId", + pending_message_id AS "messageId", + source_proposed_plan_thread_id AS "sourceProposedPlanThreadId", + source_proposed_plan_id AS "sourceProposedPlanId", + requested_at AS "requestedAt" + FROM projection_turns + WHERE turn_id IS NULL + AND state = 'pending' + AND pending_message_id IS NOT NULL + AND checkpoint_turn_count IS NULL + ORDER BY requested_at ASC, thread_id ASC + `, + }); + const listProjectionTurnsByThread = SqlSchema.findAll({ Request: ListProjectionTurnsByThreadInput, Result: ProjectionTurnDbRowSchema, @@ -288,6 +308,13 @@ const makeProjectionTurnRepository = Effect.gen(function* () { ), ); + const listPendingTurnStarts: ProjectionTurnRepositoryShape["listPendingTurnStarts"] = () => + listPendingProjectionTurns(undefined).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionTurnRepository.listPendingTurnStarts:query"), + ), + ); + const deletePendingTurnStartByThreadId: ProjectionTurnRepositoryShape["deletePendingTurnStartByThreadId"] = (input) => clearPendingProjectionTurnsByThread(input).pipe( @@ -341,6 +368,7 @@ const makeProjectionTurnRepository = Effect.gen(function* () { upsertByTurnId, replacePendingTurnStart, getPendingTurnStartByThreadId, + listPendingTurnStarts, deletePendingTurnStartByThreadId, listByThreadId, getByTurnId, diff --git a/apps/server/src/persistence/Services/ProjectionTurns.ts b/apps/server/src/persistence/Services/ProjectionTurns.ts index f3d5d5e4706..faf70bce24a 100644 --- a/apps/server/src/persistence/Services/ProjectionTurns.ts +++ b/apps/server/src/persistence/Services/ProjectionTurns.ts @@ -128,6 +128,15 @@ export interface ProjectionTurnRepositoryShape { input: GetProjectionPendingTurnStartInput, ) => Effect.Effect, ProjectionRepositoryError>; + /** + * Lists every pending-start placeholder across active reconciliation scope. + * Callers must still resolve the owning thread and discard archived/deleted rows. + */ + readonly listPendingTurnStarts: () => Effect.Effect< + ReadonlyArray, + ProjectionRepositoryError + >; + /** * Deletes only pending-start placeholder rows (`turnId = null`) for a thread and leaves concrete turn rows untouched. */ diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 39f6250d921..e6046e35e97 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -585,6 +585,50 @@ function startLifecycleRuntime() { } lifecycleLayer("CodexAdapterLive lifecycle", (it) => { + it.effect("keeps forwarding events after the start-session caller exits", () => + Effect.gen(function* () { + const adapter = yield* CodexAdapter; + const threadId = asThreadId("thread-short-lived-start-caller"); + const startCaller = yield* adapter + .startSession({ + provider: ProviderDriverKind.make("codex"), + threadId, + runtimeMode: "full-access", + }) + .pipe(Effect.forkChild); + yield* Fiber.join(startCaller); + + const runtime = lifecycleRuntimeFactory.lastRuntime; + NodeAssert.ok(runtime); + const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); + + yield* runtime.emit({ + id: asEventId("evt-after-start-caller-exited"), + kind: "notification", + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", + method: "turn/started", + threadId, + turnId: asTurnId("turn-after-start-caller-exited"), + payload: { + threadId: "provider-thread-1", + turn: { + id: "turn-after-start-caller-exited", + status: "inProgress", + items: [], + error: null, + }, + }, + } satisfies ProviderEvent); + + const firstEvent = yield* Fiber.join(firstEventFiber).pipe(Effect.timeout("1 second")); + NodeAssert.equal(firstEvent._tag, "Some"); + if (firstEvent._tag !== "Some") return; + NodeAssert.equal(firstEvent.value.type, "turn.started"); + NodeAssert.equal(firstEvent.value.turnId, "turn-after-start-caller-exited"); + }), + ); + it.effect("maps completed agent message items to canonical item.completed events", () => Effect.gen(function* () { const { adapter, runtime } = yield* startLifecycleRuntime(); diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 130eace6151..9a46a8243ce 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -1485,7 +1485,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( } yield* Queue.offerAll(runtimeEventQueue, runtimeEvents); }), - ).pipe(Effect.forkChild); + ).pipe(Effect.forkIn(sessionScope)); const started = yield* runtime.start().pipe( Effect.mapError( diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index ccbbce1759f..1fb1cd92c7a 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -58,6 +58,7 @@ import { import * as ServerSettings from "../../serverSettings.ts"; import * as AnalyticsService from "../../telemetry/AnalyticsService.ts"; import { makeAdapterRegistryMock } from "../testUtils/providerAdapterRegistryMock.ts"; +import { readProviderRestartRecoveryMarker } from "../ProviderRestartRecovery.ts"; const defaultServerSettingsLayer = ServerSettings.ServerSettingsService.layerTest(); @@ -366,6 +367,94 @@ it.effect("ProviderServiceLive catches stopAll failures during shutdown", () => }), ); +it.effect("graceful shutdown preserves recovery intent only for working sessions", () => + Effect.gen(function* () { + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-recovery-")); + const dbPath = NodePath.join(tempDir, "runtime.sqlite"); + const persistenceLayer = makeSqlitePersistenceLive(dbPath); + const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe( + Layer.provide(persistenceLayer), + ); + const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)); + const codex = makeFakeCodexAdapter(); + const providerLayer = makeProviderServiceLive().pipe( + Layer.provide( + Layer.succeed( + ProviderAdapterRegistry.ProviderAdapterRegistry, + makeAdapterRegistryMock({ [CODEX_DRIVER]: codex.adapter }), + ), + ), + Layer.provide(directoryLayer), + Layer.provide(defaultServerSettingsLayer), + Layer.provide(AnalyticsService.layerTest), + Layer.provide( + Layer.succeed( + ProviderEventLoggers.ProviderEventLoggers, + ProviderEventLoggers.NoOpProviderEventLoggers, + ), + ), + ); + const scope = yield* Scope.make(); + const services = yield* Layer.build( + Layer.mergeAll(providerLayer, runtimeRepositoryLayer, directoryLayer), + ).pipe(Scope.provide(scope)); + const provider = yield* ProviderService.ProviderService.pipe(Effect.provide(services)); + + const runningThreadId = asThreadId("thread-running-on-shutdown"); + const connectingThreadId = asThreadId("thread-connecting-on-shutdown"); + const readyThreadId = asThreadId("thread-ready-on-shutdown"); + const stoppedThreadId = asThreadId("thread-explicitly-stopped"); + for (const threadId of [runningThreadId, connectingThreadId, readyThreadId, stoppedThreadId]) { + yield* provider.startSession(threadId, { + threadId, + provider: CODEX_DRIVER, + providerInstanceId: codexInstanceId, + runtimeMode: "full-access", + }); + } + yield* provider.sendTurn({ + threadId: runningThreadId, + input: "keep working", + interactionMode: "plan", + }); + codex.updateSession(runningThreadId, (session) => ({ + ...session, + status: "running", + activeTurnId: asTurnId("provider-turn-running"), + })); + codex.updateSession(connectingThreadId, (session) => ({ + ...session, + status: "connecting", + })); + + yield* provider.stopSession({ threadId: stoppedThreadId }); + yield* Scope.close(scope, Exit.void); + + const rows = yield* Effect.gen(function* () { + const repository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; + return yield* repository.list(); + }).pipe(Effect.provide(runtimeRepositoryLayer)); + const byThreadId = new Map(rows.map((row) => [row.threadId, row])); + + const runningMarker = readProviderRestartRecoveryMarker( + byThreadId.get(runningThreadId)?.runtimePayload, + ); + assert.equal(runningMarker?.interruptedProviderTurnId, asTurnId("provider-turn-running")); + assert.isDefined( + readProviderRestartRecoveryMarker(byThreadId.get(connectingThreadId)?.runtimePayload), + ); + assert.isUndefined( + readProviderRestartRecoveryMarker(byThreadId.get(readyThreadId)?.runtimePayload), + ); + assert.isUndefined( + readProviderRestartRecoveryMarker(byThreadId.get(stoppedThreadId)?.runtimePayload), + ); + assert.equal(byThreadId.get(runningThreadId)?.status, "stopped"); + + NodeFS.rmSync(tempDir, { recursive: true, force: true }); + }).pipe(Effect.provide(NodeServices.layer)), +); + it.effect("ProviderServiceLive rejects new sessions for disabled providers", () => Effect.gen(function* () { const codex = makeFakeCodexAdapter(); @@ -1499,6 +1588,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { threadId: asThreadId("thread-1"), runtimeMode: "full-access", }); + yield* provider.sendTurn({ threadId: session.threadId, input: "hello" }); const eventsRef = yield* Ref.make>([]); const consumer = yield* Stream.runForEach(provider.streamEvents, (event) => @@ -1512,7 +1602,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { provider: ProviderDriverKind.make("codex"), createdAt: "2026-01-01T00:00:00.000Z", threadId: session.threadId, - turnId: asTurnId("turn-1"), + turnId: asTurnId("turn-thread-1"), status: "completed", }; @@ -1533,6 +1623,18 @@ fanout.layer("ProviderServiceLive fanout", (it) => { ), true, ); + const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; + const persistedRuntime = yield* runtimeRepository.getByThreadId({ + threadId: session.threadId, + }); + assert.equal(Option.isSome(persistedRuntime), true); + if (Option.isSome(persistedRuntime)) { + assert.equal(persistedRuntime.value.status, "running"); + assert.deepInclude(persistedRuntime.value.runtimePayload, { + activeTurnId: null, + lastRuntimeEvent: "turn.completed", + }); + } }), ); diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 2eaaeb8ce3c..461a1eb9ff7 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -10,7 +10,6 @@ * @module ProviderServiceLive */ import { - ModelSelection, NonNegativeInt, ThreadId, ProviderInterruptTurnInput, @@ -55,7 +54,12 @@ import * as ProviderEventLoggers from "./ProviderEventLoggers.ts"; import * as AnalyticsService from "../../telemetry/AnalyticsService.ts"; import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; import * as McpSessionRegistry from "../../mcp/McpSessionRegistry.ts"; -const isModelSelection = Schema.is(ModelSelection); +import { + makeProviderRestartRecoveryMarker, + readPersistedProviderActiveTurnId, + readPersistedProviderCwd, + readPersistedProviderModelSelection, +} from "../ProviderRestartRecovery.ts"; /** * Hook for tests that want to override the canonical event logger pulled @@ -123,8 +127,10 @@ function toRuntimePayloadFromSession( session: ProviderSession, extra?: { readonly modelSelection?: unknown; + readonly interactionMode?: unknown; readonly lastRuntimeEvent?: string; readonly lastRuntimeEventAt?: string; + readonly restartRecovery?: unknown; }, ): Record { return { @@ -133,35 +139,15 @@ function toRuntimePayloadFromSession( activeTurnId: session.activeTurnId ?? null, lastError: session.lastError ?? null, ...(extra?.modelSelection !== undefined ? { modelSelection: extra.modelSelection } : {}), + ...(extra?.interactionMode !== undefined ? { interactionMode: extra.interactionMode } : {}), ...(extra?.lastRuntimeEvent !== undefined ? { lastRuntimeEvent: extra.lastRuntimeEvent } : {}), ...(extra?.lastRuntimeEventAt !== undefined ? { lastRuntimeEventAt: extra.lastRuntimeEventAt } : {}), + ...(extra?.restartRecovery !== undefined ? { restartRecovery: extra.restartRecovery } : {}), }; } -function readPersistedModelSelection( - runtimePayload: ProviderSessionDirectory.ProviderRuntimeBinding["runtimePayload"], -): ModelSelection | undefined { - if (!runtimePayload || typeof runtimePayload !== "object" || Array.isArray(runtimePayload)) { - return undefined; - } - const raw = "modelSelection" in runtimePayload ? runtimePayload.modelSelection : undefined; - return isModelSelection(raw) ? raw : undefined; -} - -function readPersistedCwd( - runtimePayload: ProviderSessionDirectory.ProviderRuntimeBinding["runtimePayload"], -): string | undefined { - if (!runtimePayload || typeof runtimePayload !== "object" || Array.isArray(runtimePayload)) { - return undefined; - } - const rawCwd = "cwd" in runtimePayload ? runtimePayload.cwd : undefined; - if (typeof rawCwd !== "string") return undefined; - const trimmed = rawCwd.trim(); - return trimmed.length > 0 ? trimmed : undefined; -} - const dieOnMissingBindingInstanceId = ( operation: string, payload: { @@ -238,6 +224,73 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( Effect.asVoid, ); + const persistRuntimeEventState = Effect.fn("persistRuntimeEventState")(function* ( + event: ProviderRuntimeEvent, + ) { + const binding = Option.getOrUndefined(yield* directory.getBinding(event.threadId)); + if (!binding || event.providerInstanceId === undefined) return; + if (binding.providerInstanceId !== event.providerInstanceId) { + yield* Effect.logWarning("provider runtime event ignored for stale persisted instance", { + threadId: event.threadId, + eventType: event.type, + eventProviderInstanceId: event.providerInstanceId, + bindingProviderInstanceId: binding.providerInstanceId, + }); + return; + } + + const persistedActiveTurnId = readPersistedProviderActiveTurnId(binding.runtimePayload); + const lifecycle = (() => { + switch (event.type) { + case "turn.started": + return event.turnId === undefined + ? { status: "running" as const } + : { status: "running" as const, activeTurnId: event.turnId }; + case "turn.completed": + case "turn.aborted": + if ( + persistedActiveTurnId !== undefined && + (event.turnId === undefined || event.turnId !== persistedActiveTurnId) + ) { + return undefined; + } + return { status: "running" as const, activeTurnId: null }; + case "session.exited": + return { status: "stopped" as const, activeTurnId: null }; + case "session.state.changed": + switch (event.payload.state) { + case "starting": + return { status: "starting" as const }; + case "error": + return { status: "error" as const, activeTurnId: null }; + case "stopped": + return { status: "stopped" as const, activeTurnId: null }; + case "ready": + case "waiting": + return { status: "running" as const, activeTurnId: null }; + case "running": + return { status: "running" as const }; + } + default: + return undefined; + } + })(); + if (lifecycle === undefined) return; + + yield* directory.upsert({ + threadId: event.threadId, + provider: binding.provider, + providerInstanceId: event.providerInstanceId, + ...(binding.runtimeMode !== undefined ? { runtimeMode: binding.runtimeMode } : {}), + status: lifecycle.status, + runtimePayload: { + ...(lifecycle.activeTurnId !== undefined ? { activeTurnId: lifecycle.activeTurnId } : {}), + lastRuntimeEvent: event.type, + lastRuntimeEventAt: event.createdAt, + }, + }); + }); + const requireBindingInstanceId = ( operation: string, payload: { @@ -261,8 +314,10 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( threadId: ThreadId, extra?: { readonly modelSelection?: unknown; + readonly interactionMode?: unknown; readonly lastRuntimeEvent?: string; readonly lastRuntimeEventAt?: string; + readonly restartRecovery?: unknown; }, ) => Effect.gen(function* () { @@ -293,7 +348,20 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( increment(providerRuntimeEventsTotal, { provider: canonicalEvent.provider, eventType: canonicalEvent.type, - }).pipe(Effect.andThen(publishRuntimeEvent(canonicalEvent))), + }).pipe( + Effect.andThen( + persistRuntimeEventState(canonicalEvent).pipe( + Effect.catchCause((cause) => + Effect.logWarning("failed to persist provider runtime lifecycle event", { + threadId: canonicalEvent.threadId, + eventType: canonicalEvent.type, + cause, + }), + ), + ), + ), + Effect.andThen(publishRuntimeEvent(canonicalEvent)), + ), ), ); @@ -394,8 +462,10 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( ); } - const persistedCwd = readPersistedCwd(input.binding.runtimePayload); - const persistedModelSelection = readPersistedModelSelection(input.binding.runtimePayload); + const persistedCwd = readPersistedProviderCwd(input.binding.runtimePayload); + const persistedModelSelection = readPersistedProviderModelSelection( + input.binding.runtimePayload, + ); yield* prepareMcpSession(input.binding.threadId, bindingInstanceId); const resumed = yield* adapter @@ -568,7 +638,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( const effectiveCwd = input.cwd ?? (persistedBinding?.providerInstanceId === resolvedInstanceId - ? readPersistedCwd(persistedBinding.runtimePayload) + ? readPersistedProviderCwd(persistedBinding.runtimePayload) : undefined); yield* Effect.annotateCurrentSpan({ "provider.kind": resolvedProvider, @@ -688,7 +758,11 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( ...(turn.resumeCursor !== undefined ? { resumeCursor: turn.resumeCursor } : {}), runtimePayload: { ...(input.modelSelection !== undefined ? { modelSelection: input.modelSelection } : {}), + ...(input.interactionMode !== undefined + ? { interactionMode: input.interactionMode } + : {}), activeTurnId: turn.turnId, + restartRecovery: null, lastRuntimeEvent: "provider.sendTurn", lastRuntimeEventAt: yield* nowIso, }, @@ -857,6 +931,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( status: "stopped", runtimePayload: { activeTurnId: null, + restartRecovery: null, }, }); yield* analytics.record("provider.session.stopped", { @@ -1022,12 +1097,19 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( ), ).pipe(Effect.map((sessionsByAdapter) => sessionsByAdapter.flatMap((sessions) => sessions))); yield* Effect.forEach(activeSessions, (session) => - Effect.flatMap(nowIso, (lastRuntimeEventAt) => - upsertSessionBinding(session, session.threadId, { + Effect.flatMap(nowIso, (lastRuntimeEventAt) => { + const wasWorking = session.status === "connecting" || session.status === "running"; + return upsertSessionBinding(session, session.threadId, { lastRuntimeEvent: "provider.stopAll", lastRuntimeEventAt, - }), - ), + restartRecovery: wasWorking + ? makeProviderRestartRecoveryMarker({ + interruptedProviderTurnId: session.activeTurnId, + shutdownAt: lastRuntimeEventAt, + }) + : null, + }); + }), ).pipe(Effect.asVoid); yield* Effect.forEach(currentAdapters, ([, adapter]) => adapter.stopAll()).pipe(Effect.asVoid); yield* McpSessionRegistry.revokeAllActiveMcpCredentials(); diff --git a/apps/server/src/provider/ProviderRestartRecovery.test.ts b/apps/server/src/provider/ProviderRestartRecovery.test.ts new file mode 100644 index 00000000000..c7952f547f2 --- /dev/null +++ b/apps/server/src/provider/ProviderRestartRecovery.test.ts @@ -0,0 +1,73 @@ +import { ModelSelection, ProviderInstanceId, TurnId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + makeProviderRestartRecoveryMarker, + readPersistedProviderCwd, + readPersistedProviderInteractionMode, + readPersistedProviderModelSelection, + readProviderRestartRecoveryCandidate, +} from "./ProviderRestartRecovery.ts"; + +describe("ProviderRestartRecovery", () => { + it("reads typed recovery metadata and persisted restart settings", () => { + const modelSelection: ModelSelection = { + instanceId: ProviderInstanceId.make("codex-work"), + model: "gpt-5.4", + options: [{ id: "reasoningEffort", value: "high" }], + }; + const marker = makeProviderRestartRecoveryMarker({ + interruptedProviderTurnId: TurnId.make("turn-interrupted"), + shutdownAt: "2026-07-22T00:00:00.000Z", + }); + const runtimePayload = { + cwd: " /tmp/project ", + modelSelection, + interactionMode: "plan", + restartRecovery: marker, + }; + + expect( + readProviderRestartRecoveryCandidate({ + runtimePayload, + status: "stopped", + lastSeenAt: "2026-07-22T00:00:01.000Z", + }), + ).toEqual({ ...marker, source: "marker" }); + expect(readPersistedProviderCwd(runtimePayload)).toBe("/tmp/project"); + expect(readPersistedProviderModelSelection(runtimePayload)).toEqual(modelSelection); + expect(readPersistedProviderInteractionMode(runtimePayload)).toBe("plan"); + }); + + it("recognizes crash-style legacy running rows with an active turn", () => { + expect( + readProviderRestartRecoveryCandidate({ + runtimePayload: { activeTurnId: TurnId.make("turn-before-crash") }, + status: "running", + lastSeenAt: "2026-07-22T00:00:00.000Z", + }), + ).toEqual({ + version: 1, + interruptedProviderTurnId: TurnId.make("turn-before-crash"), + shutdownAt: "2026-07-22T00:00:00.000Z", + source: "legacy-active-turn", + }); + }); + + it("does not recover idle, stopped, or malformed legacy rows", () => { + expect( + readProviderRestartRecoveryCandidate({ + runtimePayload: { activeTurnId: TurnId.make("turn-ready") }, + status: "stopped", + lastSeenAt: "2026-07-22T00:00:00.000Z", + }), + ).toBeUndefined(); + expect( + readProviderRestartRecoveryCandidate({ + runtimePayload: { activeTurnId: null }, + status: "running", + lastSeenAt: "2026-07-22T00:00:00.000Z", + }), + ).toBeUndefined(); + }); +}); diff --git a/apps/server/src/provider/ProviderRestartRecovery.ts b/apps/server/src/provider/ProviderRestartRecovery.ts new file mode 100644 index 00000000000..9aa79efe582 --- /dev/null +++ b/apps/server/src/provider/ProviderRestartRecovery.ts @@ -0,0 +1,103 @@ +import { + IsoDateTime, + ModelSelection, + ProviderInteractionMode, + TurnId, + type ProviderSessionRuntimeStatus, +} from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; + +export const PROVIDER_RESTART_RECOVERY_PAYLOAD_KEY = "restartRecovery"; + +export const ProviderRestartRecoveryMarker = Schema.Struct({ + version: Schema.Literal(1), + interruptedProviderTurnId: Schema.NullOr(TurnId), + shutdownAt: IsoDateTime, +}); +export type ProviderRestartRecoveryMarker = typeof ProviderRestartRecoveryMarker.Type; + +export interface ProviderRestartRecoveryCandidate extends ProviderRestartRecoveryMarker { + readonly source: "marker" | "legacy-active-turn"; +} + +const isProviderRestartRecoveryMarker = Schema.is(ProviderRestartRecoveryMarker); +const isModelSelection = Schema.is(ModelSelection); +const isProviderInteractionMode = Schema.is(ProviderInteractionMode); +const isTurnId = Schema.is(TurnId); + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +export function makeProviderRestartRecoveryMarker(input: { + readonly interruptedProviderTurnId: TurnId | null | undefined; + readonly shutdownAt: string; +}): ProviderRestartRecoveryMarker { + return { + version: 1, + interruptedProviderTurnId: input.interruptedProviderTurnId ?? null, + shutdownAt: IsoDateTime.make(input.shutdownAt), + }; +} + +export function readProviderRestartRecoveryMarker( + runtimePayload: unknown, +): ProviderRestartRecoveryMarker | undefined { + if (!isRecord(runtimePayload)) return undefined; + const marker = runtimePayload[PROVIDER_RESTART_RECOVERY_PAYLOAD_KEY]; + return isProviderRestartRecoveryMarker(marker) ? marker : undefined; +} + +export function readProviderRestartRecoveryCandidate(input: { + readonly runtimePayload: unknown; + readonly status: ProviderSessionRuntimeStatus | undefined; + readonly lastSeenAt: string; +}): ProviderRestartRecoveryCandidate | undefined { + const marker = readProviderRestartRecoveryMarker(input.runtimePayload); + if (marker !== undefined) { + return { ...marker, source: "marker" }; + } + if (input.status !== "starting" && input.status !== "running") { + return undefined; + } + if (!isRecord(input.runtimePayload)) return undefined; + const activeTurnId = input.runtimePayload.activeTurnId; + if (!isTurnId(activeTurnId)) return undefined; + return { + version: 1, + interruptedProviderTurnId: activeTurnId, + shutdownAt: IsoDateTime.make(input.lastSeenAt), + source: "legacy-active-turn", + }; +} + +export function readPersistedProviderCwd(runtimePayload: unknown): string | undefined { + if (!isRecord(runtimePayload)) return undefined; + const cwd = runtimePayload.cwd; + if (typeof cwd !== "string") return undefined; + const trimmed = cwd.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +export function readPersistedProviderModelSelection( + runtimePayload: unknown, +): ModelSelection | undefined { + if (!isRecord(runtimePayload)) return undefined; + return isModelSelection(runtimePayload.modelSelection) + ? runtimePayload.modelSelection + : undefined; +} + +export function readPersistedProviderInteractionMode( + runtimePayload: unknown, +): ProviderInteractionMode | undefined { + if (!isRecord(runtimePayload)) return undefined; + return isProviderInteractionMode(runtimePayload.interactionMode) + ? runtimePayload.interactionMode + : undefined; +} + +export function readPersistedProviderActiveTurnId(runtimePayload: unknown): TurnId | undefined { + if (!isRecord(runtimePayload)) return undefined; + return isTurnId(runtimePayload.activeTurnId) ? runtimePayload.activeTurnId : undefined; +} From b71b63305b0d2e06bf72901776033229b4164e2b Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Sun, 26 Jul 2026 12:25:58 +0200 Subject: [PATCH 07/16] feat(tim): import tim-smart/t3code#10 Avoid repeated thread snapshot loads during subscription retries Source: https://github.com/tim-smart/t3code/pull/10 Source head: c8c9eadb9de3026706bc3a403ca05b12d0da8dd5 Source commits: c8c9eadb9de3026706bc3a403ca05b12d0da8dd5 Imported: complete product delta from the source PR. --- .../src/state/threads-sync.test.ts | 20 +++++++++++++++++++ packages/client-runtime/src/state/threads.ts | 19 ++++++++++++++---- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/packages/client-runtime/src/state/threads-sync.test.ts b/packages/client-runtime/src/state/threads-sync.test.ts index 8d0e4d0a35c..ab2b8898be8 100644 --- a/packages/client-runtime/src/state/threads-sync.test.ts +++ b/packages/client-runtime/src/state/threads-sync.test.ts @@ -418,6 +418,26 @@ describe("EnvironmentThreads", () => { }), ); + it.effect("does not reload a missing HTTP snapshot when the socket subscription retries", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + yield* Queue.offer(harness.inputs, new Error("Thread was not found")); + yield* awaitThreadState(harness.observed, (value) => Option.isSome(value.error)); + + expect(yield* Ref.get(harness.loaderCalls)).toBe(1); + expect(yield* Ref.get(harness.subscriptionCount)).toBe(1); + + yield* TestClock.adjust("250 millis"); + for (let attempt = 0; attempt < 100; attempt += 1) { + if ((yield* Ref.get(harness.subscriptionCount)) >= 2) break; + yield* Effect.yieldNow; + } + + expect(yield* Ref.get(harness.subscriptionCount)).toBe(2); + expect(yield* Ref.get(harness.loaderCalls)).toBe(1); + }), + ); + it.effect("ignores replayed thread events at or below the snapshot sequence", () => Effect.gen(function* () { const harness = yield* makeHarness({ cached: BASE_THREAD }); diff --git a/packages/client-runtime/src/state/threads.ts b/packages/client-runtime/src/state/threads.ts index 196229cc8b1..97635a95fb5 100644 --- a/packages/client-runtime/src/state/threads.ts +++ b/packages/client-runtime/src/state/threads.ts @@ -80,6 +80,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make Option.match(cached, { onNone: () => 0, onSome: (snapshot) => snapshot.snapshotSequence }), ); const awaitingCompletion = yield* Ref.make(false); + const httpSnapshotLoadAttempted = yield* Ref.make(false); const persistence = yield* Queue.sliding(1); const persist = Effect.fn("EnvironmentThreadState.persist")(function* ( @@ -267,10 +268,20 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make }), ), ); - const httpSnapshot = yield* snapshotLoader.load(prepared, threadId); - if (Option.isSome(httpSnapshot)) { - yield* applyItem({ kind: "snapshot", snapshot: httpSnapshot.value }); - current = yield* SubscriptionRef.get(state); + // The socket subscription may retry an expected domain failure (for + // example, while a newly-created thread is still being projected). + // Do not repeat the HTTP fallback on each socket retry: a missing + // snapshot otherwise produces a new 404 every 250ms. + const alreadyAttemptedHttpSnapshotLoad = yield* Ref.getAndSet( + httpSnapshotLoadAttempted, + true, + ); + if (!alreadyAttemptedHttpSnapshotLoad) { + const httpSnapshot = yield* snapshotLoader.load(prepared, threadId); + if (Option.isSome(httpSnapshot)) { + yield* applyItem({ kind: "snapshot", snapshot: httpSnapshot.value }); + current = yield* SubscriptionRef.get(state); + } } } From f12d5d3601d5e9ef5d13bdafe301efaf98a02c07 Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Sun, 26 Jul 2026 12:26:00 +0200 Subject: [PATCH 08/16] feat(tim): import tim-smart/t3code#11 Add image upload button to compact chat composer Source: https://github.com/tim-smart/t3code/pull/11 Source head: 1ff63f9b9c418ef56a46c6422d22c01de97581a8 Source commits: 1ff63f9b9c418ef56a46c6422d22c01de97581a8 Imported: complete product delta from the source PR. --- apps/web/src/components/chat/ChatComposer.tsx | 52 ++++++++++++++----- 1 file changed, 40 insertions(+), 12 deletions(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 9d7275686bf..816bca7b49c 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -159,6 +159,7 @@ import { CircleAlertIcon, ListTodoIcon, PencilRulerIcon, + PlusIcon, type LucideIcon, LockIcon, LockOpenIcon, @@ -982,6 +983,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const mobileComposerExpandReleaseFrameRef = useRef(null); const mobileComposerExpandInFlightRef = useRef(false); const dragDepthRef = useRef(0); + const imageFileInputRef = useRef(null); // ------------------------------------------------------------------ // Derived: composer send state @@ -1938,6 +1940,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) removeComposerImageFromDraft(imageId); }; + const onImageFileInputChange = (event: React.ChangeEvent) => { + const files = Array.from(event.target.files ?? []); + event.target.value = ""; + addComposerImages(files); + }; + // ------------------------------------------------------------------ // Callbacks: paste / drag // ------------------------------------------------------------------ @@ -2697,18 +2705,38 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) )} {isComposerFooterCompact ? ( - + <> + + + + ) : ( <> {providerTraitsPicker ? ( From 89aa909e9c186acd80a17a73c1d5de5f0f74d6b1 Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Sun, 26 Jul 2026 12:26:01 +0200 Subject: [PATCH 09/16] feat(tim): import tim-smart/t3code#12 Truncate mobile branch toolbar controls Source: https://github.com/tim-smart/t3code/pull/12 Source head: 1b7d44428472511bc98d8f936654359ce2536901 Source commits: 1b7d44428472511bc98d8f936654359ce2536901 Imported: complete product delta from the source PR. --- apps/web/src/components/BranchToolbar.tsx | 4 ++-- apps/web/src/components/BranchToolbarBranchSelector.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index e703427d4b6..d0d0f308633 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -125,7 +125,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ if (isLocked) { return ( - + {triggerContent} ); @@ -135,7 +135,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ } - className="min-w-0 max-w-[48%] flex-1 justify-start text-muted-foreground/70 hover:text-foreground/80 md:hidden" + className="min-w-0 max-w-[48%] justify-start text-muted-foreground/70 hover:text-foreground/80 md:hidden" > {triggerContent} diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 763de56d8c4..cccc444b4f2 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -731,7 +731,7 @@ export function BranchToolbarBranchSelector({ > } - className="min-w-0 max-w-full text-muted-foreground/70 hover:text-foreground/80" + className="min-w-0 max-w-full shrink text-muted-foreground/70 hover:text-foreground/80" disabled={isInitialBranchesLoadPending || isBranchActionPending} > From 66fba462bf0f51e5d2fe5a3c7bffc2d3869df6eb Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Sun, 26 Jul 2026 12:26:03 +0200 Subject: [PATCH 10/16] feat(tim): import tim-smart/t3code#13 Clean up worktrees when archiving threads Source: https://github.com/tim-smart/t3code/pull/13 Source head: a23f42d6ac671ea36b8db5d03934c089a31be448 Source commits: 4a194707ed134f993502ac5fdf36a8425f1769cd,1b6688aa5b641010cb2e9dad23d36d87257403ad,9ed32aa3923fb674380564b1ffcb3268290069b9,a23f42d6ac671ea36b8db5d03934c089a31be448 Imported: complete product delta from the source PR. --- .../src/features/home/threadActionMessages.ts | 30 + .../src/features/home/useThreadListActions.ts | 114 ++- .../home/worktreeCleanupPrompt.test.ts | 102 +++ .../features/home/worktreeCleanupPrompt.ts | 49 ++ .../checkpointing/CheckpointDiffQuery.test.ts | 5 + .../Layers/OrchestrationEngine.test.ts | 1 + .../Layers/ProjectionSnapshotQuery.test.ts | 124 ++++ .../Layers/ProjectionSnapshotQuery.ts | 47 ++ .../Layers/ProviderCommandReactor.test.ts | 87 ++- .../Layers/ProviderCommandReactor.ts | 28 +- .../Layers/WorktreeLifecycle.test.ts | 681 ++++++++++++++++++ .../orchestration/Layers/WorktreeLifecycle.ts | 395 ++++++++++ .../Services/ProjectionSnapshotQuery.ts | 18 + .../Services/WorktreeLifecycle.ts | 73 ++ .../Layers/ProjectionRepositories.test.ts | 81 +++ .../persistence/Layers/ProjectionThreads.ts | 26 + .../persistence/Services/ProjectionThreads.ts | 21 + .../project/ProjectSetupScriptRunner.test.ts | 1 + .../Layers/ProviderSessionReaper.test.ts | 1 + apps/server/src/server.test.ts | 67 +- apps/server/src/server.ts | 3 +- apps/server/src/serverRuntimeStartup.test.ts | 4 + apps/server/src/vcs/GitVcsDriverCore.ts | 2 +- apps/server/src/ws.ts | 37 +- apps/web/src/hooks/useThreadActions.ts | 93 ++- packages/client-runtime/package.json | 4 + packages/client-runtime/src/state/vcs.ts | 18 +- .../src/state/vcsCommandScheduler.ts | 8 + .../src/state/worktreeCleanup.test.ts | 192 +++++ .../src/state/worktreeCleanup.ts | 84 +++ packages/contracts/src/git.ts | 54 ++ packages/contracts/src/rpc.ts | 21 + plan.md | 193 +++++ 33 files changed, 2571 insertions(+), 93 deletions(-) create mode 100644 apps/mobile/src/features/home/threadActionMessages.ts create mode 100644 apps/mobile/src/features/home/worktreeCleanupPrompt.test.ts create mode 100644 apps/mobile/src/features/home/worktreeCleanupPrompt.ts create mode 100644 apps/server/src/orchestration/Layers/WorktreeLifecycle.test.ts create mode 100644 apps/server/src/orchestration/Layers/WorktreeLifecycle.ts create mode 100644 apps/server/src/orchestration/Services/WorktreeLifecycle.ts create mode 100644 packages/client-runtime/src/state/worktreeCleanup.test.ts create mode 100644 packages/client-runtime/src/state/worktreeCleanup.ts create mode 100644 plan.md diff --git a/apps/mobile/src/features/home/threadActionMessages.ts b/apps/mobile/src/features/home/threadActionMessages.ts new file mode 100644 index 00000000000..680fc376c16 --- /dev/null +++ b/apps/mobile/src/features/home/threadActionMessages.ts @@ -0,0 +1,30 @@ +import * as Cause from "effect/Cause"; + +export type ThreadListAction = "archive" | "unarchive" | "delete" | "settle" | "unsettle"; + +const ACTION_VERBS: Record = { + archive: "archived", + unarchive: "unarchived", + delete: "deleted", + settle: "settled", + unsettle: "un-settled", +}; + +export function actionFailureMessage( + action: ThreadListAction, + cause: Cause.Cause, +): string { + const error = Cause.squash(cause); + if (error instanceof Error && error.message.trim().length > 0) { + return error.message; + } + return `The thread could not be ${ACTION_VERBS[action]}.`; +} + +export function actionFailureTitle(action: ThreadListAction): string { + if (action === "archive") return "Could not archive thread"; + if (action === "unarchive") return "Could not unarchive thread"; + if (action === "settle") return "Could not settle thread"; + if (action === "unsettle") return "Could not un-settle thread"; + return "Could not delete thread"; +} diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index e200eb7acde..7da189a5269 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -1,5 +1,6 @@ import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import { canSettle } from "@t3tools/client-runtime/state/thread-settled"; +import { runArchiveWithWorktreeCleanup } from "@t3tools/client-runtime/state/worktreeCleanup"; import * as Cause from "effect/Cause"; import * as Haptics from "expo-haptics"; import { useCallback, useRef } from "react"; @@ -11,7 +12,14 @@ import { refreshArchivedThreadsForEnvironment } from "../archive/useArchivedThre import { appAtomRegistry } from "../../state/atom-registry"; import { environmentServerConfigsAtom } from "../../state/server"; import { threadEnvironment } from "../../state/threads"; +import { vcsEnvironment } from "../../state/vcs"; import { useAtomCommand } from "../../state/use-atom-command"; +import { + actionFailureMessage, + actionFailureTitle, + type ThreadListAction, +} from "./threadActionMessages"; +import { presentWorktreeCleanupConfirmation } from "./worktreeCleanupPrompt"; /** Version skew: never send settle/unsettle to a server that predates them (capability defaults false on decode for older servers). */ @@ -22,36 +30,10 @@ function environmentSupportsSettlement(environmentId: EnvironmentThreadShell["en ); } -type ThreadListAction = "archive" | "unarchive" | "delete" | "settle" | "unsettle"; - -const ACTION_VERBS: Record = { - archive: "archived", - unarchive: "unarchived", - delete: "deleted", - settle: "settled", - unsettle: "un-settled", -}; - -function actionFailureMessage(action: ThreadListAction, cause: Cause.Cause): string { - const error = Cause.squash(cause); - if (error instanceof Error && error.message.trim().length > 0) { - return error.message; - } - return `The thread could not be ${ACTION_VERBS[action]}.`; -} - function selectionHaptic(): void { void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); } -function actionFailureTitle(action: ThreadListAction): string { - if (action === "archive") return "Could not archive thread"; - if (action === "unarchive") return "Could not unarchive thread"; - if (action === "settle") return "Could not settle thread"; - if (action === "unsettle") return "Could not un-settle thread"; - return "Could not delete thread"; -} - /** Resolves to true iff the action was dispatched and succeeded. */ function useThreadActionExecutor( onCompleted?: (action: ThreadListAction, thread: EnvironmentThreadShell) => void, @@ -195,12 +177,88 @@ export function useThreadListActions(): { readonly unsettleThread: (thread: EnvironmentThreadShell) => Promise; } { const executeAction = useThreadActionExecutor(); + const previewWorktreeCleanup = useAtomCommand(vcsEnvironment.previewWorktreeCleanup, { + reportFailure: false, + }); + const cleanupThreadWorktree = useAtomCommand(vcsEnvironment.cleanupThreadWorktree, { + reportFailure: false, + }); const archiveThread = useCallback( (thread: EnvironmentThreadShell) => { - void executeAction("archive", thread); + void runArchiveWithWorktreeCleanup({ + // Server-authoritative preview; a failure (old server, transient + // error) degrades to a plain archive without a prompt. A thread + // mid-turn keeps the original archive guard: executeAction re-checks + // and surfaces the alert, so it must not be prompted for cleanup. + previewCandidate: async () => { + if (thread.session?.status === "running" && thread.session.activeTurnId != null) { + return null; + } + const preview = await previewWorktreeCleanup({ + environmentId: thread.environmentId, + input: { threadId: thread.id }, + }); + return preview._tag === "Success" ? preview.value.candidate : null; + }, + confirmRemoval: ({ displayWorktreePath }) => + presentWorktreeCleanupConfirmation({ + isIos: process.env.EXPO_OS === "ios", + displayWorktreePath, + presentAlert: (buttons) => { + Alert.alert(buttons.title, buttons.message, [ + { text: "Keep", style: "cancel", onPress: buttons.onKeep }, + { text: "Remove", style: "destructive", onPress: buttons.onRemove }, + ]); + }, + presentConfirmDialog: (buttons) => { + showConfirmDialog({ + title: buttons.title, + message: buttons.message, + cancelText: "Keep", + confirmText: "Remove", + destructive: true, + onConfirm: buttons.onRemove, + onCancel: buttons.onKeep, + }); + }, + }), + archive: () => executeAction("archive", thread), + isArchiveSuccess: (archived) => archived, + cleanup: async () => { + const result = await cleanupThreadWorktree({ + environmentId: thread.environmentId, + input: { threadId: thread.id }, + }); + if (result._tag === "Failure") { + const error = Cause.squash(result.cause); + return { + kind: "failed", + message: + error instanceof Error && error.message.trim().length > 0 + ? error.message + : "The worktree could not be removed.", + } as const; + } + return { kind: "done", status: result.value.status } as const; + }, + // Cleanup problems are nonfatal: the archive itself already + // succeeded. + onCleanupFailed: (displayWorktreePath, message) => { + Alert.alert( + "Thread archived, but worktree removal failed", + `Could not remove ${displayWorktreePath}. ${message}`, + ); + }, + onCleanupRetained: (displayWorktreePath) => { + Alert.alert( + "Worktree kept", + `${displayWorktreePath} is still used by another active thread.`, + ); + }, + }); }, - [executeAction], + [cleanupThreadWorktree, executeAction, previewWorktreeCleanup], ); const settleThread = useCallback( async (thread: EnvironmentThreadShell) => (await executeAction("settle", thread)) === true, diff --git a/apps/mobile/src/features/home/worktreeCleanupPrompt.test.ts b/apps/mobile/src/features/home/worktreeCleanupPrompt.test.ts new file mode 100644 index 00000000000..502dd92b8f9 --- /dev/null +++ b/apps/mobile/src/features/home/worktreeCleanupPrompt.test.ts @@ -0,0 +1,102 @@ +import { WorktreeLifecycleError } from "@t3tools/contracts"; +import { ThreadId } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { actionFailureMessage, actionFailureTitle } from "./threadActionMessages"; +import { + buildWorktreeCleanupPrompt, + presentWorktreeCleanupConfirmation, +} from "./worktreeCleanupPrompt"; + +describe("presentWorktreeCleanupConfirmation", () => { + it("uses the native alert on iOS and never the confirm dialog", async () => { + const presentAlert = vi.fn( + (buttons: { readonly onKeep: () => void; readonly onRemove: () => void }) => { + buttons.onRemove(); + }, + ); + const presentConfirmDialog = vi.fn(); + + const confirmation = await presentWorktreeCleanupConfirmation({ + isIos: true, + displayWorktreePath: "feature-1", + presentAlert, + presentConfirmDialog, + }); + + expect(confirmation).toEqual({ kind: "confirmed" }); + expect(presentAlert).toHaveBeenCalledTimes(1); + expect(presentConfirmDialog).not.toHaveBeenCalled(); + }); + + it("uses the confirm dialog host elsewhere", async () => { + const presentAlert = vi.fn(); + const presentConfirmDialog = vi.fn( + (buttons: { readonly onKeep: () => void; readonly onRemove: () => void }) => { + buttons.onKeep(); + }, + ); + + const confirmation = await presentWorktreeCleanupConfirmation({ + isIos: false, + displayWorktreePath: "feature-1", + presentAlert, + presentConfirmDialog, + }); + + expect(confirmation).toEqual({ kind: "declined" }); + expect(presentAlert).not.toHaveBeenCalled(); + expect(presentConfirmDialog).toHaveBeenCalledTimes(1); + }); + + it("resolves declined for Keep and confirmed for Remove", async () => { + const keep = presentWorktreeCleanupConfirmation({ + isIos: true, + displayWorktreePath: "feature-1", + presentAlert: (buttons) => { + buttons.onKeep(); + }, + presentConfirmDialog: () => {}, + }); + await expect(keep).resolves.toEqual({ kind: "declined" }); + + const remove = presentWorktreeCleanupConfirmation({ + isIos: false, + displayWorktreePath: "feature-1", + presentAlert: () => {}, + presentConfirmDialog: (buttons) => { + buttons.onRemove(); + }, + }); + await expect(remove).resolves.toEqual({ kind: "confirmed" }); + }); + + it("names the worktree in the prompt copy", () => { + const prompt = buildWorktreeCleanupPrompt("feature-1"); + expect(prompt.title).toBe("Remove worktree?"); + expect(prompt.message).toContain("feature-1"); + expect(prompt.message).toContain("branch is kept"); + }); +}); + +describe("thread action failure messages", () => { + it("surfaces unarchive restoration errors with the server-provided detail", () => { + const restorationError = new WorktreeLifecycleError({ + operation: "restore", + threadId: ThreadId.make("thread-1"), + detail: + "Failed to recreate the worktree at /wt/feature-1 from branch 'feature-1': branch missing. The thread stays archived.", + }); + expect(actionFailureTitle("unarchive")).toBe("Could not unarchive thread"); + expect(actionFailureMessage("unarchive", Cause.fail(restorationError))).toContain( + "Failed to recreate the worktree", + ); + }); + + it("falls back to a generic message when the cause has no message", () => { + expect(actionFailureMessage("unarchive", Cause.fail(new Error("")))).toBe( + "The thread could not be unarchived.", + ); + }); +}); diff --git a/apps/mobile/src/features/home/worktreeCleanupPrompt.ts b/apps/mobile/src/features/home/worktreeCleanupPrompt.ts new file mode 100644 index 00000000000..3c331e619ff --- /dev/null +++ b/apps/mobile/src/features/home/worktreeCleanupPrompt.ts @@ -0,0 +1,49 @@ +import type { WorktreeCleanupConfirmation } from "@t3tools/client-runtime/state/worktreeCleanup"; + +export interface WorktreeCleanupPromptButtons { + readonly title: string; + readonly message: string; + readonly onKeep: () => void; + readonly onRemove: () => void; +} + +export function buildWorktreeCleanupPrompt(displayWorktreePath: string): { + readonly title: string; + readonly message: string; +} { + return { + title: "Remove worktree?", + message: `This thread is the last active one linked to the worktree “${displayWorktreePath}”. Remove it when archiving? The branch is kept.`, + }; +} + +/** + * Presents the archive-time cleanup confirmation through the platform's + * surface: the native alert on iOS, the in-app confirm dialog elsewhere. + * Keep archives without cleanup; Remove archives and cleans up. + */ +export function presentWorktreeCleanupConfirmation(input: { + readonly isIos: boolean; + readonly displayWorktreePath: string; + readonly presentAlert: (buttons: WorktreeCleanupPromptButtons) => void; + readonly presentConfirmDialog: (buttons: WorktreeCleanupPromptButtons) => void; +}): Promise> { + const { title, message } = buildWorktreeCleanupPrompt(input.displayWorktreePath); + return new Promise((resolve) => { + const buttons: WorktreeCleanupPromptButtons = { + title, + message, + onKeep: () => { + resolve({ kind: "declined" }); + }, + onRemove: () => { + resolve({ kind: "confirmed" }); + }, + }; + if (input.isIos) { + input.presentAlert(buttons); + return; + } + input.presentConfirmDialog(buttons); + }); +} diff --git a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts index 8e0e5fb74d5..bb124d03c98 100644 --- a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts +++ b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts @@ -106,6 +106,7 @@ describe("CheckpointDiffQuery.layer", () => { }); }), getThreadShellById: () => Effect.succeed(Option.none()), + getSessionStopContextById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), getThreadDetailSnapshot: () => Effect.succeed(Option.none()), }), @@ -199,6 +200,7 @@ describe("CheckpointDiffQuery.layer", () => { getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)), getFullThreadDiffContext: () => Effect.die("unused"), getThreadShellById: () => Effect.succeed(Option.none()), + getSessionStopContextById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), getThreadDetailSnapshot: () => Effect.succeed(Option.none()), }), @@ -282,6 +284,7 @@ describe("CheckpointDiffQuery.layer", () => { getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)), getFullThreadDiffContext: () => Effect.die("unused"), getThreadShellById: () => Effect.succeed(Option.none()), + getSessionStopContextById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), getThreadDetailSnapshot: () => Effect.succeed(Option.none()), }), @@ -350,6 +353,7 @@ describe("CheckpointDiffQuery.layer", () => { getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)), getFullThreadDiffContext: () => Effect.die("unused"), getThreadShellById: () => Effect.succeed(Option.none()), + getSessionStopContextById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), getThreadDetailSnapshot: () => Effect.succeed(Option.none()), }), @@ -403,6 +407,7 @@ describe("CheckpointDiffQuery.layer", () => { getThreadCheckpointContext: () => Effect.succeed(Option.none()), getFullThreadDiffContext: () => Effect.succeed(Option.none()), getThreadShellById: () => Effect.succeed(Option.none()), + getSessionStopContextById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), getThreadDetailSnapshot: () => Effect.succeed(Option.none()), }), diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 731002ea783..5623128c16d 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -201,6 +201,7 @@ describe("OrchestrationEngine", () => { getThreadCheckpointContext: () => Effect.succeed(Option.none()), getFullThreadDiffContext: () => Effect.succeed(Option.none()), getThreadShellById: () => Effect.succeed(Option.none()), + getSessionStopContextById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), getThreadDetailSnapshot: () => Effect.succeed(Option.none()), }), diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index d4a24a209ad..fbb8aa5082b 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -570,6 +570,130 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { }), ); + it.effect("resolves session stop context for archived threads but not deleted ones", () => + Effect.gen(function* () { + const snapshotQuery = yield* ProjectionSnapshotQuery; + const sql = yield* SqlClient.SqlClient; + + yield* sql`DELETE FROM projection_threads`; + yield* sql`DELETE FROM projection_thread_sessions`; + + yield* sql` + INSERT INTO projection_threads ( + thread_id, + project_id, + title, + model_selection_json, + runtime_mode, + interaction_mode, + branch, + worktree_path, + latest_turn_id, + latest_user_message_at, + pending_approval_count, + pending_user_input_count, + has_actionable_proposed_plan, + created_at, + updated_at, + archived_at, + deleted_at + ) + VALUES + ( + 'thread-archived-session', + 'project-stop-test', + 'Archived Thread', + '{"provider":"codex","model":"gpt-5-codex"}', + 'full-access', + 'default', + NULL, + NULL, + NULL, + NULL, + 0, + 0, + 0, + '2026-04-07T00:00:00.000Z', + '2026-04-07T00:00:01.000Z', + '2026-04-07T00:00:02.000Z', + NULL + ), + ( + 'thread-deleted-session', + 'project-stop-test', + 'Deleted Thread', + '{"provider":"codex","model":"gpt-5-codex"}', + 'full-access', + 'default', + NULL, + NULL, + NULL, + NULL, + 0, + 0, + 0, + '2026-04-07T00:00:00.000Z', + '2026-04-07T00:00:01.000Z', + NULL, + '2026-04-07T00:00:03.000Z' + ) + `; + + yield* sql` + INSERT INTO projection_thread_sessions ( + thread_id, + status, + provider_name, + provider_session_id, + provider_thread_id, + runtime_mode, + active_turn_id, + last_error, + updated_at + ) + VALUES ( + 'thread-archived-session', + 'running', + 'codex', + 'provider-session-stop', + 'provider-thread-stop', + 'full-access', + NULL, + NULL, + '2026-04-07T00:00:04.000Z' + ) + `; + + // The archived, nondeleted thread must resolve so the archive flow's + // session stop can still find it. + const archivedContext = yield* snapshotQuery.getSessionStopContextById( + ThreadId.make("thread-archived-session"), + ); + assert.isTrue(archivedContext._tag === "Some"); + if (archivedContext._tag === "Some") { + assert.equal(archivedContext.value.threadId, ThreadId.make("thread-archived-session")); + assert.equal(archivedContext.value.session?.status, "running"); + assert.equal(archivedContext.value.session?.providerName, "codex"); + } + + const deletedContext = yield* snapshotQuery.getSessionStopContextById( + ThreadId.make("thread-deleted-session"), + ); + assert.isTrue(deletedContext._tag === "None"); + + const archivedWithoutSession = yield* sql` + DELETE FROM projection_thread_sessions + `.pipe( + Effect.flatMap(() => + snapshotQuery.getSessionStopContextById(ThreadId.make("thread-archived-session")), + ), + ); + assert.isTrue( + archivedWithoutSession._tag === "Some" && archivedWithoutSession.value.session === null, + ); + }), + ); + it.effect("keeps settled threads in the shell snapshot with non-null settlement fields", () => Effect.gen(function* () { const snapshotQuery = yield* ProjectionSnapshotQuery; diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 3d05bef4bdf..0cd3853bfb6 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -54,6 +54,7 @@ import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts"; import { ProjectionSnapshotQuery, type ProjectionFullThreadDiffContext, + type ProjectionSessionStopContext, type ProjectionSnapshotCounts, type ProjectionThreadCheckpointContext, type ProjectionSnapshotQueryShape, @@ -748,6 +749,20 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); + const getNondeletedThreadIdRowById = SqlSchema.findOneOption({ + Request: ThreadIdLookupInput, + Result: ProjectionThreadIdLookupRowSchema, + execute: ({ threadId }) => + sql` + SELECT + thread_id AS "threadId" + FROM projection_threads + WHERE thread_id = ${threadId} + AND deleted_at IS NULL + LIMIT 1 + `, + }); + const getActiveThreadRowById = SqlSchema.findOneOption({ Request: ThreadIdLookupInput, Result: ProjectionThreadDbRowSchema, @@ -1873,6 +1888,37 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { }); }); + const getSessionStopContextById: ProjectionSnapshotQueryShape["getSessionStopContextById"] = ( + threadId, + ) => + Effect.gen(function* () { + const threadRow = yield* getNondeletedThreadIdRowById({ threadId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getSessionStopContextById:getThread:query", + "ProjectionSnapshotQuery.getSessionStopContextById:getThread:decodeRow", + ), + ), + ); + if (Option.isNone(threadRow)) { + return Option.none(); + } + + const sessionRow = yield* getThreadSessionRowByThread({ threadId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getSessionStopContextById:getSession:query", + "ProjectionSnapshotQuery.getSessionStopContextById:getSession:decodeRow", + ), + ), + ); + + return Option.some({ + threadId: threadRow.value.threadId, + session: Option.isSome(sessionRow) ? mapSessionRow(sessionRow.value) : null, + }); + }); + const getThreadShellById: ProjectionSnapshotQueryShape["getThreadShellById"] = (threadId) => Effect.gen(function* () { const [threadRow, latestTurnRow, sessionRow] = yield* Effect.all([ @@ -2115,6 +2161,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { getFirstActiveThreadIdByProjectId, getThreadCheckpointContext, getFullThreadDiffContext, + getSessionStopContextById, getThreadShellById, getThreadDetailById, getThreadDetailSnapshot, diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index dca0d2188f4..516df2d85fc 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -2372,7 +2372,7 @@ describe("ProviderCommandReactor", () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; - await Effect.runPromise( + await runtime!.runPromise( harness.engine.dispatch({ type: "thread.session.set", commandId: CommandId.make("cmd-session-set"), @@ -2390,7 +2390,7 @@ describe("ProviderCommandReactor", () => { }), ); - await Effect.runPromise( + await runtime!.runPromise( harness.engine.dispatch({ type: "thread.turn.interrupt", commandId: CommandId.make("cmd-turn-interrupt"), @@ -2410,7 +2410,7 @@ describe("ProviderCommandReactor", () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; - await Effect.runPromise( + await runtime!.runPromise( harness.engine.dispatch({ type: "thread.session.set", commandId: CommandId.make("cmd-session-set-stale"), @@ -2428,7 +2428,7 @@ describe("ProviderCommandReactor", () => { }), ); - await Effect.runPromise( + await runtime!.runPromise( harness.engine.dispatch({ type: "thread.turn.start", commandId: CommandId.make("cmd-turn-start-stale"), @@ -2465,7 +2465,7 @@ describe("ProviderCommandReactor", () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; - await Effect.runPromise( + await runtime!.runPromise( harness.engine.dispatch({ type: "thread.session.set", commandId: CommandId.make("cmd-session-set-missing-instance"), @@ -2493,7 +2493,7 @@ describe("ProviderCommandReactor", () => { updatedAt: now, }); - await Effect.runPromise( + await runtime!.runPromise( harness.engine.dispatch({ type: "thread.turn.start", commandId: CommandId.make("cmd-turn-start-missing-instance"), @@ -2536,7 +2536,7 @@ describe("ProviderCommandReactor", () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; - await Effect.runPromise( + await runtime!.runPromise( harness.engine.dispatch({ type: "thread.session.set", commandId: CommandId.make("cmd-session-set-for-approval"), @@ -2554,7 +2554,7 @@ describe("ProviderCommandReactor", () => { }), ); - await Effect.runPromise( + await runtime!.runPromise( harness.engine.dispatch({ type: "thread.approval.respond", commandId: CommandId.make("cmd-approval-respond"), @@ -2577,7 +2577,7 @@ describe("ProviderCommandReactor", () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; - await Effect.runPromise( + await runtime!.runPromise( harness.engine.dispatch({ type: "thread.session.set", commandId: CommandId.make("cmd-session-set-for-user-input"), @@ -2595,7 +2595,7 @@ describe("ProviderCommandReactor", () => { }), ); - await Effect.runPromise( + await runtime!.runPromise( harness.engine.dispatch({ type: "thread.user-input.respond", commandId: CommandId.make("cmd-user-input-respond"), @@ -2631,7 +2631,7 @@ describe("ProviderCommandReactor", () => { ), ); - await Effect.runPromise( + await runtime!.runPromise( harness.engine.dispatch({ type: "thread.session.set", commandId: CommandId.make("cmd-session-set-for-approval-error"), @@ -2649,7 +2649,7 @@ describe("ProviderCommandReactor", () => { }), ); - await Effect.runPromise( + await runtime!.runPromise( harness.engine.dispatch({ type: "thread.activity.append", commandId: CommandId.make("cmd-approval-requested"), @@ -2670,7 +2670,7 @@ describe("ProviderCommandReactor", () => { }), ); - await Effect.runPromise( + await runtime!.runPromise( harness.engine.dispatch({ type: "thread.approval.respond", commandId: CommandId.make("cmd-approval-respond-stale"), @@ -2726,7 +2726,7 @@ describe("ProviderCommandReactor", () => { ), ); - await Effect.runPromise( + await runtime!.runPromise( harness.engine.dispatch({ type: "thread.session.set", commandId: CommandId.make("cmd-session-set-for-user-input-error"), @@ -2744,7 +2744,7 @@ describe("ProviderCommandReactor", () => { }), ); - await Effect.runPromise( + await runtime!.runPromise( harness.engine.dispatch({ type: "thread.activity.append", commandId: CommandId.make("cmd-user-input-requested"), @@ -2777,7 +2777,7 @@ describe("ProviderCommandReactor", () => { }), ); - await Effect.runPromise( + await runtime!.runPromise( harness.engine.dispatch({ type: "thread.user-input.respond", commandId: CommandId.make("cmd-user-input-respond-stale"), @@ -2826,7 +2826,7 @@ describe("ProviderCommandReactor", () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; - await Effect.runPromise( + await runtime!.runPromise( harness.engine.dispatch({ type: "thread.session.set", commandId: CommandId.make("cmd-session-set-for-stop"), @@ -2845,7 +2845,7 @@ describe("ProviderCommandReactor", () => { }), ); - await Effect.runPromise( + await runtime!.runPromise( harness.engine.dispatch({ type: "thread.session.stop", commandId: CommandId.make("cmd-session-stop"), @@ -2863,4 +2863,55 @@ describe("ProviderCommandReactor", () => { expect(thread?.session?.providerInstanceId).toBe(ProviderInstanceId.make("codex_work")); expect(thread?.session?.activeTurnId).toBeNull(); }); + + effectIt.effect( + "stops the provider session when the stop is requested after the thread was archived", + () => + Effect.gen(function* () { + const harness = yield* Effect.promise(() => createHarness()); + const now = "2026-01-01T00:00:00.000Z"; + + yield* harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-session-set-for-archive-stop"), + threadId: ThreadId.make("thread-1"), + session: { + threadId: ThreadId.make("thread-1"), + status: "running", + providerName: "codex", + providerInstanceId: ProviderInstanceId.make("codex_work"), + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + createdAt: now, + }); + + // Mirrors the archive flow: the archive commits first, so the + // reactor resolves the stop against an already-archived thread. + yield* harness.engine.dispatch({ + type: "thread.archive", + commandId: CommandId.make("cmd-archive-before-stop"), + threadId: ThreadId.make("thread-1"), + }); + yield* harness.engine.dispatch({ + type: "thread.session.stop", + commandId: CommandId.make("cmd-session-stop-after-archive"), + threadId: ThreadId.make("thread-1"), + createdAt: now, + }); + + yield* Effect.promise(() => waitFor(() => harness.stopSession.mock.calls.length === 1)); + expect(harness.stopSession.mock.calls[0]?.[0]).toMatchObject({ + threadId: ThreadId.make("thread-1"), + }); + yield* Effect.promise(() => harness.drain()); + const readModel = yield* Effect.promise(() => harness.readModel()); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + expect(thread?.archivedAt).not.toBeNull(); + expect(thread?.session?.status).toBe("stopped"); + expect(thread?.session?.activeTurnId).toBeNull(); + }), + ); }); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 28bbdaadfe4..b6588cbea37 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -1028,28 +1028,34 @@ const make = Effect.gen(function* () { const processSessionStopRequested = Effect.fn("processSessionStopRequested")(function* ( event: Extract, ) { - const thread = yield* resolveThread(event.payload.threadId); - if (!thread) { + // Session stops are resolved through an archived-inclusive context query: + // the archive flow dispatches the stop after the thread disappears from + // the active-only shell/detail queries, so resolving through those would + // silently leak the provider session. + const context = yield* projectionSnapshotQuery + .getSessionStopContextById(event.payload.threadId) + .pipe(Effect.map(Option.getOrUndefined)); + if (!context) { return; } const now = event.payload.createdAt; - if (thread.session && thread.session.status !== "stopped") { - yield* providerService.stopSession({ threadId: thread.id }); + if (context.session && context.session.status !== "stopped") { + yield* providerService.stopSession({ threadId: context.threadId }); } yield* setThreadSession({ - threadId: thread.id, + threadId: context.threadId, session: { - threadId: thread.id, + threadId: context.threadId, status: "stopped", - providerName: thread.session?.providerName ?? null, - ...(thread.session?.providerInstanceId !== undefined - ? { providerInstanceId: thread.session.providerInstanceId } + providerName: context.session?.providerName ?? null, + ...(context.session?.providerInstanceId !== undefined + ? { providerInstanceId: context.session.providerInstanceId } : {}), - runtimeMode: thread.session?.runtimeMode ?? DEFAULT_RUNTIME_MODE, + runtimeMode: context.session?.runtimeMode ?? DEFAULT_RUNTIME_MODE, activeTurnId: null, - lastError: thread.session?.lastError ?? null, + lastError: context.session?.lastError ?? null, updatedAt: now, }, createdAt: now, diff --git a/apps/server/src/orchestration/Layers/WorktreeLifecycle.test.ts b/apps/server/src/orchestration/Layers/WorktreeLifecycle.test.ts new file mode 100644 index 00000000000..a6f69d45a6a --- /dev/null +++ b/apps/server/src/orchestration/Layers/WorktreeLifecycle.test.ts @@ -0,0 +1,681 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { + DEFAULT_PROVIDER_INTERACTION_MODE, + ProjectId, + ProviderInstanceId, + ThreadId, + WorktreeLifecycleError, + type ModelSelection, +} from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import type * as Scope from "effect/Scope"; + +import { ServerConfig } from "../../config.ts"; +import { GitWorkflowService } from "../../git/GitWorkflowService.ts"; +import { ProjectionThreadRepositoryLive } from "../../persistence/Layers/ProjectionThreads.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { + ProjectionThreadRepository, + type ProjectionThread, +} from "../../persistence/Services/ProjectionThreads.ts"; +import { ProjectSetupScriptRunner } from "../../project/ProjectSetupScriptRunner.ts"; +import { ProviderService } from "../../provider/Services/ProviderService.ts"; +import * as TerminalManager from "../../terminal/Manager.ts"; +import * as GitVcsDriver from "../../vcs/GitVcsDriver.ts"; +import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; +import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; +import { WorktreeLifecycle } from "../Services/WorktreeLifecycle.ts"; +import { WorktreeLifecycleLive } from "./WorktreeLifecycle.ts"; + +const isWorktreeLifecycleError = Schema.is(WorktreeLifecycleError); + +const now = "2026-03-01T00:00:00.000Z"; +const projectId = ProjectId.make("project-1"); +const modelSelection: ModelSelection = { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", +}; + +interface HarnessRefs { + workspaceRoot: string; + readonly stopSessionCalls: Array; + readonly terminalCloseCalls: Array; + readonly setupScriptCalls: Array<{ readonly threadId: string; readonly worktreePath: string }>; + removeWorktreeStarted: Deferred.Deferred | null; + removeWorktreeRelease: Deferred.Deferred | null; +} + +const makeRefs = (): HarnessRefs => ({ + workspaceRoot: "", + stopSessionCalls: [], + terminalCloseCalls: [], + setupScriptCalls: [], + removeWorktreeStarted: null, + removeWorktreeRelease: null, +}); + +const emptyVcsStatus = { + isRepo: true, + hasPrimaryRemote: false, + isDefaultRef: false, + refName: null, + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + hasUpstream: false, + aheadCount: 0, + behindCount: 0, + pr: null, +}; + +const gitWorkflowFromDriver = (refs: HarnessRefs) => + Layer.unwrap( + Effect.gen(function* () { + const driver = yield* GitVcsDriver.GitVcsDriver; + return Layer.mock(GitWorkflowService)({ + createWorktree: (input) => driver.createWorktree(input), + removeWorktree: (input) => + Effect.gen(function* () { + if (refs.removeWorktreeStarted) { + yield* Deferred.succeed(refs.removeWorktreeStarted, undefined); + } + if (refs.removeWorktreeRelease) { + yield* Deferred.await(refs.removeWorktreeRelease); + } + yield* driver.removeWorktree(input); + }), + }); + }), + ); + +const makeTestLayer = (refs: HarnessRefs) => + WorktreeLifecycleLive.pipe( + Layer.provideMerge(ProjectionThreadRepositoryLive), + Layer.provideMerge( + Layer.mock(ProjectionSnapshotQuery)({ + getProjectShellById: (id) => + Effect.sync(() => + id === projectId && refs.workspaceRoot.length > 0 + ? Option.some({ + id: projectId, + title: "Project 1", + workspaceRoot: refs.workspaceRoot, + repositoryIdentity: null, + defaultModelSelection: modelSelection, + scripts: [], + createdAt: now, + updatedAt: now, + }) + : Option.none(), + ), + }), + ), + Layer.provideMerge( + Layer.mock(ProviderService)({ + stopSession: ({ threadId }) => + Effect.sync(() => { + refs.stopSessionCalls.push(threadId); + }), + }), + ), + Layer.provideMerge( + Layer.mock(TerminalManager.TerminalManager)({ + close: (input) => + Effect.sync(() => { + refs.terminalCloseCalls.push(input.threadId); + }), + }), + ), + Layer.provideMerge( + Layer.mock(VcsStatusBroadcaster)({ + refreshStatus: () => Effect.succeed(emptyVcsStatus), + }), + ), + Layer.provideMerge( + Layer.mock(ProjectSetupScriptRunner)({ + runForThread: (input) => + Effect.sync(() => { + refs.setupScriptCalls.push({ + threadId: input.threadId, + worktreePath: input.worktreePath, + }); + return { status: "no-script" } as const; + }), + }), + ), + Layer.provideMerge(gitWorkflowFromDriver(refs)), + Layer.provideMerge( + GitVcsDriver.layer.pipe( + Layer.provide( + ServerConfig.layerTest(process.cwd(), { prefix: "t3-worktree-lifecycle-config-" }), + ), + ), + ), + Layer.provideMerge(SqlitePersistenceMemory), + Layer.provideMerge(NodeServices.layer), + ); + +const git = (cwd: string, args: ReadonlyArray) => + Effect.gen(function* () { + const driver = yield* GitVcsDriver.GitVcsDriver; + const result = yield* driver.execute({ + operation: "WorktreeLifecycle.test.git", + cwd, + args, + timeoutMs: 10_000, + }); + return result.stdout.trim(); + }); + +/** Creates a real repo with an initial commit plus a feature-branch worktree. */ +const setupRepoWithWorktree = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const driver = yield* GitVcsDriver.GitVcsDriver; + + const repoDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-worktree-lifecycle-repo-", + }); + yield* driver.initRepo({ cwd: repoDir }); + yield* git(repoDir, ["config", "user.email", "test@test.com"]); + yield* git(repoDir, ["config", "user.name", "Test"]); + yield* fileSystem.writeFileString(pathService.join(repoDir, "README.md"), "# test\n"); + yield* git(repoDir, ["add", "."]); + yield* git(repoDir, ["-c", "commit.gpgsign=false", "commit", "-m", "initial commit"]); + const initialBranch = yield* git(repoDir, ["branch", "--show-current"]); + + const worktreesDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-worktree-lifecycle-wt-", + }); + const worktreePath = pathService.join(worktreesDir, "feature-1"); + yield* driver.createWorktree({ + cwd: repoDir, + refName: initialBranch, + newRefName: "feature-1", + path: worktreePath, + }); + + return { repoDir, worktreePath, initialBranch, branch: "feature-1" }; +}); + +const makeThreadRow = (input: { + readonly threadId: string; + readonly branch: string | null; + readonly worktreePath: string | null; + readonly archivedAt?: string | null; + readonly deletedAt?: string | null; +}): ProjectionThread => ({ + threadId: ThreadId.make(input.threadId), + projectId, + title: `Thread ${input.threadId}`, + modelSelection, + runtimeMode: "full-access", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + branch: input.branch, + worktreePath: input.worktreePath, + latestTurnId: null, + createdAt: now, + updatedAt: now, + archivedAt: input.archivedAt ?? null, + settledOverride: null, + settledAt: null, + snoozedUntil: null, + snoozedAt: null, + latestUserMessageAt: null, + pendingApprovalCount: 0, + pendingUserInputCount: 0, + hasActionableProposedPlan: 0, + deletedAt: input.deletedAt ?? null, +}); + +const seedThreads = (rows: ReadonlyArray) => + Effect.gen(function* () { + const repository = yield* ProjectionThreadRepository; + yield* Effect.forEach(rows, (row) => repository.upsert(row), { discard: true }); + }); + +const runWithHarness = ( + refs: HarnessRefs, + body: Effect.Effect< + A, + E, + | WorktreeLifecycle + | ProjectionThreadRepository + | GitVcsDriver.GitVcsDriver + | FileSystem.FileSystem + | Path.Path + | Scope.Scope + >, +) => Effect.scoped(body).pipe(Effect.provide(makeTestLayer(refs))); + +it.effect("preview returns a candidate for the only active worktree thread", () => { + const refs = makeRefs(); + return runWithHarness( + refs, + Effect.gen(function* () { + const lifecycle = yield* WorktreeLifecycle; + const repo = yield* setupRepoWithWorktree; + refs.workspaceRoot = repo.repoDir; + yield* seedThreads([ + makeThreadRow({ threadId: "t1", branch: repo.branch, worktreePath: repo.worktreePath }), + ]); + + const preview = yield* lifecycle.previewCleanup({ threadId: ThreadId.make("t1") }); + assert.deepStrictEqual(preview.candidate, { + worktreePath: repo.worktreePath, + branch: repo.branch, + }); + }), + ); +}); + +it.effect("preview returns no candidate when another active thread shares the path", () => { + const refs = makeRefs(); + return runWithHarness( + refs, + Effect.gen(function* () { + const lifecycle = yield* WorktreeLifecycle; + const repo = yield* setupRepoWithWorktree; + refs.workspaceRoot = repo.repoDir; + yield* seedThreads([ + makeThreadRow({ threadId: "t1", branch: repo.branch, worktreePath: repo.worktreePath }), + makeThreadRow({ threadId: "t2", branch: repo.branch, worktreePath: repo.worktreePath }), + ]); + + const preview = yield* lifecycle.previewCleanup({ threadId: ThreadId.make("t1") }); + assert.isNull(preview.candidate); + }), + ); +}); + +it.effect("archived and deleted siblings do not prevent a candidate", () => { + const refs = makeRefs(); + return runWithHarness( + refs, + Effect.gen(function* () { + const lifecycle = yield* WorktreeLifecycle; + const repo = yield* setupRepoWithWorktree; + refs.workspaceRoot = repo.repoDir; + yield* seedThreads([ + makeThreadRow({ threadId: "t1", branch: repo.branch, worktreePath: repo.worktreePath }), + makeThreadRow({ + threadId: "t2", + branch: repo.branch, + worktreePath: repo.worktreePath, + archivedAt: now, + }), + makeThreadRow({ + threadId: "t3", + branch: repo.branch, + worktreePath: repo.worktreePath, + deletedAt: now, + }), + ]); + + const preview = yield* lifecycle.previewCleanup({ threadId: ThreadId.make("t1") }); + assert.deepStrictEqual(preview.candidate, { + worktreePath: repo.worktreePath, + branch: repo.branch, + }); + }), + ); +}); + +it.effect("preview treats different normalized spellings of the path as one worktree", () => { + const refs = makeRefs(); + return runWithHarness( + refs, + Effect.gen(function* () { + const lifecycle = yield* WorktreeLifecycle; + const repo = yield* setupRepoWithWorktree; + refs.workspaceRoot = repo.repoDir; + yield* seedThreads([ + makeThreadRow({ threadId: "t1", branch: repo.branch, worktreePath: repo.worktreePath }), + // Same worktree spelled with a trailing separator. + makeThreadRow({ + threadId: "t2", + branch: repo.branch, + worktreePath: `${repo.worktreePath}/`, + }), + ]); + + const preview = yield* lifecycle.previewCleanup({ threadId: ThreadId.make("t1") }); + assert.isNull(preview.candidate); + }), + ); +}); + +it.effect("preview returns no candidate without a retained branch", () => { + const refs = makeRefs(); + return runWithHarness( + refs, + Effect.gen(function* () { + const lifecycle = yield* WorktreeLifecycle; + const repo = yield* setupRepoWithWorktree; + refs.workspaceRoot = repo.repoDir; + yield* seedThreads([ + makeThreadRow({ threadId: "t1", branch: null, worktreePath: repo.worktreePath }), + ]); + + const preview = yield* lifecycle.previewCleanup({ threadId: ThreadId.make("t1") }); + assert.isNull(preview.candidate); + }), + ); +}); + +it.effect( + "cleanup force-removes a dirty worktree, preserves the branch, and stops archived runtimes", + () => { + const refs = makeRefs(); + return runWithHarness( + refs, + Effect.gen(function* () { + const lifecycle = yield* WorktreeLifecycle; + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const repo = yield* setupRepoWithWorktree; + refs.workspaceRoot = repo.repoDir; + yield* seedThreads([ + makeThreadRow({ + threadId: "t1", + branch: repo.branch, + worktreePath: repo.worktreePath, + archivedAt: now, + }), + makeThreadRow({ + threadId: "t2", + branch: repo.branch, + worktreePath: repo.worktreePath, + archivedAt: now, + }), + ]); + // Make the worktree dirty so a non-forced removal would fail. + yield* fileSystem.writeFileString( + pathService.join(repo.worktreePath, "dirty.txt"), + "uncommitted\n", + ); + + const result = yield* lifecycle.cleanupThreadWorktree({ threadId: ThreadId.make("t1") }); + assert.strictEqual(result.status, "removed"); + assert.strictEqual(yield* fileSystem.exists(repo.worktreePath), false); + // The branch survives removal so the worktree stays restorable. + const branches = yield* git(repo.repoDir, ["branch", "--list", repo.branch]); + assert.include(branches, repo.branch); + // Every archived reference had its runtime stopped. + assert.sameMembers(refs.stopSessionCalls, ["t1", "t2"]); + assert.sameMembers(refs.terminalCloseCalls, ["t1", "t2"]); + }), + ); + }, +); + +it.effect("cleanup is retained when a reference becomes active after preview", () => { + const refs = makeRefs(); + return runWithHarness( + refs, + Effect.gen(function* () { + const lifecycle = yield* WorktreeLifecycle; + const fileSystem = yield* FileSystem.FileSystem; + const repo = yield* setupRepoWithWorktree; + refs.workspaceRoot = repo.repoDir; + yield* seedThreads([ + makeThreadRow({ + threadId: "t1", + branch: repo.branch, + worktreePath: repo.worktreePath, + archivedAt: now, + }), + // Unarchived (active) sibling appeared between preview and cleanup. + makeThreadRow({ threadId: "t2", branch: repo.branch, worktreePath: repo.worktreePath }), + ]); + + const result = yield* lifecycle.cleanupThreadWorktree({ threadId: ThreadId.make("t1") }); + assert.strictEqual(result.status, "retained-active"); + assert.strictEqual(yield* fileSystem.exists(repo.worktreePath), true); + assert.lengthOf(refs.stopSessionCalls, 0); + }), + ); +}); + +it.effect("cleanup is retained when the target thread itself became active again", () => { + const refs = makeRefs(); + return runWithHarness( + refs, + Effect.gen(function* () { + const lifecycle = yield* WorktreeLifecycle; + const fileSystem = yield* FileSystem.FileSystem; + const repo = yield* setupRepoWithWorktree; + refs.workspaceRoot = repo.repoDir; + yield* seedThreads([ + makeThreadRow({ threadId: "t1", branch: repo.branch, worktreePath: repo.worktreePath }), + ]); + + const result = yield* lifecycle.cleanupThreadWorktree({ threadId: ThreadId.make("t1") }); + assert.strictEqual(result.status, "retained-active"); + assert.strictEqual(yield* fileSystem.exists(repo.worktreePath), true); + }), + ); +}); + +it.effect("cleanup reports an already-missing path without failing", () => { + const refs = makeRefs(); + return runWithHarness( + refs, + Effect.gen(function* () { + const lifecycle = yield* WorktreeLifecycle; + const fileSystem = yield* FileSystem.FileSystem; + const repo = yield* setupRepoWithWorktree; + refs.workspaceRoot = repo.repoDir; + yield* seedThreads([ + makeThreadRow({ + threadId: "t1", + branch: repo.branch, + worktreePath: repo.worktreePath, + archivedAt: now, + }), + ]); + yield* fileSystem.remove(repo.worktreePath, { recursive: true }); + + const result = yield* lifecycle.cleanupThreadWorktree({ threadId: ThreadId.make("t1") }); + assert.strictEqual(result.status, "already-missing"); + }), + ); +}); + +it.effect("cleanup failures surface as a typed WorktreeLifecycleError", () => { + const refs = makeRefs(); + return runWithHarness( + refs, + Effect.gen(function* () { + const lifecycle = yield* WorktreeLifecycle; + const fileSystem = yield* FileSystem.FileSystem; + const repo = yield* setupRepoWithWorktree; + refs.workspaceRoot = repo.repoDir; + // Points at an existing directory that is not a registered worktree, so + // `git worktree remove` fails. + const bogusPath = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-worktree-lifecycle-bogus-", + }); + yield* seedThreads([ + makeThreadRow({ + threadId: "t1", + branch: repo.branch, + worktreePath: bogusPath, + archivedAt: now, + }), + ]); + + const result = yield* Effect.flip( + lifecycle.cleanupThreadWorktree({ threadId: ThreadId.make("t1") }), + ); + assert.isTrue(isWorktreeLifecycleError(result)); + assert.strictEqual(result.operation, "cleanup"); + }), + ); +}); + +it.effect("unarchive restoration recreates a missing worktree and runs the setup script", () => { + const refs = makeRefs(); + return runWithHarness( + refs, + Effect.gen(function* () { + const lifecycle = yield* WorktreeLifecycle; + const fileSystem = yield* FileSystem.FileSystem; + const driver = yield* GitVcsDriver.GitVcsDriver; + const repository = yield* ProjectionThreadRepository; + const repo = yield* setupRepoWithWorktree; + refs.workspaceRoot = repo.repoDir; + const row = makeThreadRow({ + threadId: "t1", + branch: repo.branch, + worktreePath: repo.worktreePath, + archivedAt: now, + }); + yield* seedThreads([row]); + yield* driver.removeWorktree({ cwd: repo.repoDir, path: repo.worktreePath, force: true }); + assert.strictEqual(yield* fileSystem.exists(repo.worktreePath), false); + + let committed = false; + const commit = repository.upsert({ ...row, archivedAt: null }).pipe( + Effect.tap(() => + Effect.sync(() => { + committed = true; + }), + ), + Effect.as({ sequence: 1 }), + ); + const result = yield* lifecycle.restoreThreadWorktree({ threadId: row.threadId }, commit); + assert.deepStrictEqual(result, { sequence: 1 }); + assert.isTrue(committed); + assert.strictEqual(yield* fileSystem.exists(repo.worktreePath), true); + const worktrees = yield* git(repo.repoDir, ["worktree", "list", "--porcelain"]); + assert.include(worktrees, repo.worktreePath); + const branchInWorktree = yield* git(repo.worktreePath, ["branch", "--show-current"]); + assert.strictEqual(branchInWorktree, repo.branch); + assert.deepStrictEqual(refs.setupScriptCalls, [ + { threadId: "t1", worktreePath: repo.worktreePath }, + ]); + }), + ); +}); + +it.effect("unarchive restoration is a no-op when the worktree still exists", () => { + const refs = makeRefs(); + return runWithHarness( + refs, + Effect.gen(function* () { + const lifecycle = yield* WorktreeLifecycle; + const repo = yield* setupRepoWithWorktree; + refs.workspaceRoot = repo.repoDir; + const row = makeThreadRow({ + threadId: "t1", + branch: repo.branch, + worktreePath: repo.worktreePath, + archivedAt: now, + }); + yield* seedThreads([row]); + + let committed = false; + const commit = Effect.sync(() => { + committed = true; + return { sequence: 1 }; + }); + yield* lifecycle.restoreThreadWorktree({ threadId: row.threadId }, commit); + assert.isTrue(committed); + assert.lengthOf(refs.setupScriptCalls, 0); + }), + ); +}); + +it.effect("failed recreation leaves the thread archived and never commits", () => { + const refs = makeRefs(); + return runWithHarness( + refs, + Effect.gen(function* () { + const lifecycle = yield* WorktreeLifecycle; + const driver = yield* GitVcsDriver.GitVcsDriver; + const repo = yield* setupRepoWithWorktree; + refs.workspaceRoot = repo.repoDir; + const row = makeThreadRow({ + threadId: "t1", + branch: repo.branch, + worktreePath: repo.worktreePath, + archivedAt: now, + }); + yield* seedThreads([row]); + yield* driver.removeWorktree({ cwd: repo.repoDir, path: repo.worktreePath, force: true }); + // Delete the branch so recreation cannot succeed. + yield* git(repo.repoDir, ["branch", "-D", repo.branch]); + + let committed = false; + const commit = Effect.sync(() => { + committed = true; + return { sequence: 1 }; + }); + const error = yield* Effect.flip( + lifecycle.restoreThreadWorktree({ threadId: row.threadId }, commit), + ); + assert.isTrue(isWorktreeLifecycleError(error)); + assert.strictEqual((error as WorktreeLifecycleError).operation, "restore"); + assert.isFalse(committed); + assert.lengthOf(refs.setupScriptCalls, 0); + }), + ); +}); + +it.effect("concurrent cleanup and unarchive restoration serialize on the worktree lock", () => { + const refs = makeRefs(); + return runWithHarness( + refs, + Effect.gen(function* () { + const lifecycle = yield* WorktreeLifecycle; + const fileSystem = yield* FileSystem.FileSystem; + const repository = yield* ProjectionThreadRepository; + const repo = yield* setupRepoWithWorktree; + refs.workspaceRoot = repo.repoDir; + const row = makeThreadRow({ + threadId: "t1", + branch: repo.branch, + worktreePath: repo.worktreePath, + archivedAt: now, + }); + yield* seedThreads([row]); + + refs.removeWorktreeStarted = yield* Deferred.make(); + refs.removeWorktreeRelease = yield* Deferred.make(); + + const cleanupFiber = yield* lifecycle + .cleanupThreadWorktree({ threadId: row.threadId }) + .pipe(Effect.forkScoped); + // Cleanup holds the per-path lock and is mid-removal. + yield* Deferred.await(refs.removeWorktreeStarted); + + const commit = repository.upsert({ ...row, archivedAt: null }).pipe(Effect.asVoid); + const restoreFiber = yield* lifecycle + .restoreThreadWorktree({ threadId: row.threadId }, commit) + .pipe(Effect.forkScoped); + yield* Deferred.succeed(refs.removeWorktreeRelease, undefined); + + const cleanupResult = yield* Fiber.join(cleanupFiber); + yield* Fiber.join(restoreFiber); + + // Restoration only ran after the removal finished: it saw the missing + // path and recreated the worktree instead of skipping restoration + // against a doomed checkout. + assert.strictEqual(cleanupResult.status, "removed"); + assert.strictEqual(yield* fileSystem.exists(repo.worktreePath), true); + const restored = yield* repository.getById({ threadId: row.threadId }); + assert.isTrue(Option.isSome(restored) && restored.value.archivedAt === null); + assert.deepStrictEqual(refs.setupScriptCalls, [ + { threadId: "t1", worktreePath: repo.worktreePath }, + ]); + }), + ); +}); diff --git a/apps/server/src/orchestration/Layers/WorktreeLifecycle.ts b/apps/server/src/orchestration/Layers/WorktreeLifecycle.ts new file mode 100644 index 00000000000..046d64b1da1 --- /dev/null +++ b/apps/server/src/orchestration/Layers/WorktreeLifecycle.ts @@ -0,0 +1,395 @@ +import { WorktreeLifecycleError, type ThreadId } from "@t3tools/contracts"; +import { normalizeProjectPathForComparison } from "@t3tools/shared/path"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Semaphore from "effect/Semaphore"; +import * as SynchronizedRef from "effect/SynchronizedRef"; + +import { GitWorkflowService } from "../../git/GitWorkflowService.ts"; +import { + ProjectionThreadRepository, + type ProjectionThread, + type ProjectionThreadWorktreeReference, +} from "../../persistence/Services/ProjectionThreads.ts"; +import { ProjectSetupScriptRunner } from "../../project/ProjectSetupScriptRunner.ts"; +import { ProviderService } from "../../provider/Services/ProviderService.ts"; +import * as TerminalManager from "../../terminal/Manager.ts"; +import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; +import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; +import { WorktreeLifecycle, type WorktreeLifecycleShape } from "../Services/WorktreeLifecycle.ts"; + +// Best-effort cleanup steps must not surface their own error types through +// the lifecycle API: swallow and log everything except interruption. +const swallowCauseUnlessInterrupted = (input: { + readonly effect: Effect.Effect; + readonly message: string; + readonly threadId: ThreadId; +}): Effect.Effect => + input.effect.pipe( + Effect.asVoid, + Effect.catchCause((cause) => { + if (Cause.hasInterruptsOnly(cause)) { + return Effect.failCause(cause as Cause.Cause); + } + return Effect.logDebug(input.message, { + threadId: input.threadId, + cause: Cause.pretty(cause), + }); + }), + ); + +function nonEmptyOrNull(value: string | null): string | null { + const trimmed = value?.trim(); + return trimmed && trimmed.length > 0 ? trimmed : null; +} + +const make = Effect.gen(function* () { + const threadRepository = yield* ProjectionThreadRepository; + const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; + const gitWorkflow = yield* GitWorkflowService; + const providerService = yield* ProviderService; + const terminalManager = yield* TerminalManager.TerminalManager; + const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; + const setupScriptRunner = yield* ProjectSetupScriptRunner; + const fileSystem = yield* FileSystem.FileSystem; + + // Conditional removal and unarchive restoration serialize on the same + // per-normalized-path lock so a cleanup can never interleave with a + // restoration of the same worktree. + const pathLocksRef = yield* SynchronizedRef.make(new Map()); + const getPathSemaphore = (pathKey: string) => + SynchronizedRef.modifyEffect(pathLocksRef, (current) => { + const existing: Option.Option = Option.fromNullishOr( + current.get(pathKey), + ); + return Option.match(existing, { + onNone: () => + Semaphore.make(1).pipe( + Effect.map((semaphore) => { + const next = new Map(current); + next.set(pathKey, semaphore); + return [semaphore, next] as const; + }), + ), + onSome: (semaphore) => Effect.succeed([semaphore, current] as const), + }); + }); + const withWorktreePathLock = (pathKey: string, effect: Effect.Effect) => + Effect.flatMap(getPathSemaphore(pathKey), (semaphore) => semaphore.withPermit(effect)); + + const lifecycleError = (input: { + readonly operation: string; + readonly threadId: ThreadId; + readonly detail: string; + readonly cause?: unknown; + }) => + new WorktreeLifecycleError({ + operation: input.operation, + threadId: input.threadId, + detail: input.detail, + ...(input.cause !== undefined ? { cause: input.cause } : {}), + }); + + const loadNondeletedThreadRow = (operation: string, threadId: ThreadId) => + threadRepository.getById({ threadId }).pipe( + Effect.mapError((cause) => + lifecycleError({ operation, threadId, detail: "Failed to load thread state.", cause }), + ), + Effect.map(Option.filter((row: ProjectionThread) => row.deletedAt === null)), + ); + + const listWorktreeReferences = (operation: string, threadId: ThreadId) => + threadRepository.listWorktreeReferences().pipe( + Effect.mapError((cause) => + lifecycleError({ + operation, + threadId, + detail: "Failed to load worktree references.", + cause, + }), + ), + ); + + const referencesForPath = ( + references: ReadonlyArray, + normalizedPath: string, + ) => + references.filter( + (reference) => normalizeProjectPathForComparison(reference.worktreePath) === normalizedPath, + ); + + const requireProjectWorkspaceRoot = (input: { + readonly operation: string; + readonly thread: ProjectionThread; + }) => + projectionSnapshotQuery.getProjectShellById(input.thread.projectId).pipe( + Effect.mapError((cause) => + lifecycleError({ + operation: input.operation, + threadId: input.thread.threadId, + detail: "Failed to load the thread's project.", + cause, + }), + ), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + lifecycleError({ + operation: input.operation, + threadId: input.thread.threadId, + detail: "The thread's project was not found.", + }), + ), + onSome: (project) => Effect.succeed(project.workspaceRoot), + }), + ), + ); + + const stopThreadRuntime = (threadId: ThreadId) => + swallowCauseUnlessInterrupted({ + effect: providerService.stopSession({ threadId }), + message: "worktree cleanup skipped provider session stop", + threadId, + }).pipe( + Effect.andThen( + swallowCauseUnlessInterrupted({ + effect: terminalManager.close({ threadId }), + message: "worktree cleanup skipped terminal close", + threadId, + }), + ), + ); + + const refreshVcsStatus = (workspaceRoot: string) => + vcsStatusBroadcaster + .refreshStatus(workspaceRoot) + .pipe(Effect.ignoreCause({ log: true }), Effect.forkDetach, Effect.asVoid); + + const worktreePathExists = (input: { + readonly operation: string; + readonly threadId: ThreadId; + readonly worktreePath: string; + }) => + fileSystem.exists(input.worktreePath).pipe( + Effect.mapError((cause) => + lifecycleError({ + operation: input.operation, + threadId: input.threadId, + detail: `Failed to inspect the worktree path ${input.worktreePath}.`, + cause, + }), + ), + ); + + const previewCleanup: WorktreeLifecycleShape["previewCleanup"] = Effect.fn( + "WorktreeLifecycle.previewCleanup", + )(function* ({ threadId }) { + const operation = "cleanup preview"; + const threadRow = yield* loadNondeletedThreadRow(operation, threadId); + if (Option.isNone(threadRow)) { + return { candidate: null }; + } + const thread = threadRow.value; + const worktreePath = nonEmptyOrNull(thread.worktreePath); + const branch = nonEmptyOrNull(thread.branch); + // Only an active thread with a restorable worktree (path + retained + // branch) can produce a candidate. + if (thread.archivedAt !== null || worktreePath === null || branch === null) { + return { candidate: null }; + } + + const normalizedPath = normalizeProjectPathForComparison(worktreePath); + const references = yield* listWorktreeReferences(operation, threadId); + const sharedWithActiveThread = referencesForPath(references, normalizedPath).some( + (reference) => reference.threadId !== threadId && reference.archivedAt === null, + ); + + return { + candidate: sharedWithActiveThread ? null : { worktreePath, branch }, + }; + }); + + const cleanupThreadWorktree: WorktreeLifecycleShape["cleanupThreadWorktree"] = Effect.fn( + "WorktreeLifecycle.cleanupThreadWorktree", + )(function* ({ threadId }) { + const operation = "cleanup"; + const threadRow = yield* loadNondeletedThreadRow(operation, threadId); + if (Option.isNone(threadRow)) { + return yield* lifecycleError({ operation, threadId, detail: "The thread was not found." }); + } + const thread = threadRow.value; + const worktreePath = nonEmptyOrNull(thread.worktreePath); + if (worktreePath === null) { + return yield* lifecycleError({ + operation, + threadId, + detail: "The thread has no worktree path recorded.", + }); + } + // A thread that was unarchived between confirmation and cleanup is an + // active reference again, not an error. + if (thread.archivedAt === null) { + return { status: "retained-active", worktreePath } as const; + } + + const workspaceRoot = yield* requireProjectWorkspaceRoot({ operation, thread }); + const normalizedPath = normalizeProjectPathForComparison(worktreePath); + + return yield* withWorktreePathLock( + normalizedPath, + Effect.gen(function* () { + // Mandatory recheck under the lock: another client may have + // unarchived or attached a thread since the preview. + const references = referencesForPath( + yield* listWorktreeReferences(operation, threadId), + normalizedPath, + ); + if (references.some((reference) => reference.archivedAt === null)) { + return { status: "retained-active", worktreePath } as const; + } + + // Every remaining reference is archived; make sure none of them + // still runs a provider session or terminal inside the worktree. + const threadIdsToStop = new Set([ + threadId, + ...references.map((reference) => reference.threadId), + ]); + yield* Effect.forEach(threadIdsToStop, stopThreadRuntime, { discard: true }); + + const exists = yield* worktreePathExists({ operation, threadId, worktreePath }); + if (!exists) { + return { status: "already-missing", worktreePath } as const; + } + + yield* gitWorkflow + .removeWorktree({ cwd: workspaceRoot, path: worktreePath, force: true }) + .pipe( + Effect.mapError((cause) => + lifecycleError({ + operation, + threadId, + detail: `Failed to remove the worktree at ${worktreePath}: ${cause.detail}`, + cause, + }), + ), + ); + + // Compensate for unavoidable external races: if an active reference + // appeared while the removal ran, recreate the worktree from the + // retained branch at the original path. + const postRemovalReferences = referencesForPath( + yield* listWorktreeReferences(operation, threadId), + normalizedPath, + ); + if (postRemovalReferences.some((reference) => reference.archivedAt === null)) { + const branch = nonEmptyOrNull(thread.branch); + if (branch === null) { + return yield* lifecycleError({ + operation, + threadId, + detail: `The worktree at ${worktreePath} was removed while another thread became active, and no branch is recorded to recreate it.`, + }); + } + yield* gitWorkflow + .createWorktree({ cwd: workspaceRoot, refName: branch, path: worktreePath }) + .pipe( + Effect.mapError((cause) => + lifecycleError({ + operation, + threadId, + detail: `Failed to recreate the worktree at ${worktreePath} after another thread became active.`, + cause, + }), + ), + ); + yield* refreshVcsStatus(workspaceRoot); + return { status: "retained-active", worktreePath } as const; + } + + yield* refreshVcsStatus(workspaceRoot); + return { status: "removed", worktreePath } as const; + }), + ); + }); + + const restoreThreadWorktree: WorktreeLifecycleShape["restoreThreadWorktree"] = ( + { threadId }: { readonly threadId: ThreadId }, + commitUnarchive: Effect.Effect, + ): Effect.Effect => + Effect.gen(function* () { + const operation = "restore"; + const threadRow = yield* loadNondeletedThreadRow(operation, threadId); + if (Option.isNone(threadRow)) { + // Let the dispatch path produce its canonical "unknown thread" error. + return yield* commitUnarchive; + } + const thread = threadRow.value; + const worktreePath = nonEmptyOrNull(thread.worktreePath); + if (worktreePath === null) { + return yield* commitUnarchive; + } + + const normalizedPath = normalizeProjectPathForComparison(worktreePath); + return yield* withWorktreePathLock( + normalizedPath, + Effect.gen(function* () { + const exists = yield* worktreePathExists({ operation, threadId, worktreePath }); + if (exists) { + return yield* commitUnarchive; + } + + const branch = nonEmptyOrNull(thread.branch); + if (branch === null) { + return yield* lifecycleError({ + operation, + threadId, + detail: `The worktree at ${worktreePath} is missing and no branch is recorded to recreate it. The thread stays archived.`, + }); + } + + const workspaceRoot = yield* requireProjectWorkspaceRoot({ operation, thread }); + yield* gitWorkflow + .createWorktree({ cwd: workspaceRoot, refName: branch, path: worktreePath }) + .pipe( + Effect.mapError((cause) => + lifecycleError({ + operation, + threadId, + detail: `Failed to recreate the worktree at ${worktreePath} from branch '${branch}': ${cause.detail}. The thread stays archived.`, + cause, + }), + ), + ); + yield* refreshVcsStatus(workspaceRoot); + + const result = yield* commitUnarchive; + + // The checkout was recreated from scratch, so dependencies and + // generated files are gone: run the worktree setup script again. + yield* swallowCauseUnlessInterrupted({ + effect: setupScriptRunner.runForThread({ + threadId, + projectId: thread.projectId, + worktreePath, + }), + message: "worktree restoration could not start the setup script", + threadId, + }); + + return result; + }), + ); + }); + + return { + previewCleanup, + cleanupThreadWorktree, + restoreThreadWorktree, + } satisfies WorktreeLifecycleShape; +}); + +export const WorktreeLifecycleLive = Layer.effect(WorktreeLifecycle, make); diff --git a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts index 23b291d8778..8daea6d079d 100644 --- a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts @@ -12,6 +12,7 @@ import type { OrchestrationProject, OrchestrationProjectShell, OrchestrationReadModel, + OrchestrationSession, OrchestrationShellSnapshot, OrchestrationThread, OrchestrationThreadDetailSnapshot, @@ -42,6 +43,11 @@ export interface ProjectionThreadCheckpointContext { readonly checkpoints: ReadonlyArray; } +export interface ProjectionSessionStopContext { + readonly threadId: ThreadId; + readonly session: OrchestrationSession | null; +} + export interface ProjectionFullThreadDiffContext { readonly threadId: ThreadId; readonly projectId: ProjectId; @@ -145,6 +151,18 @@ export interface ProjectionSnapshotQueryShape { toTurnCount: number, ) => Effect.Effect, ProjectionRepositoryError>; + /** + * Read the narrow context needed to stop a thread's provider session. + * + * Unlike the shell/detail queries this includes archived, nondeleted + * threads: session-stop commands dispatched as part of archiving must + * still resolve the thread after `archivedAt` is set, or the provider + * session would never be stopped. + */ + readonly getSessionStopContextById: ( + threadId: ThreadId, + ) => Effect.Effect, ProjectionRepositoryError>; + /** * Read a single active thread shell row by id. */ diff --git a/apps/server/src/orchestration/Services/WorktreeLifecycle.ts b/apps/server/src/orchestration/Services/WorktreeLifecycle.ts new file mode 100644 index 00000000000..79037509b60 --- /dev/null +++ b/apps/server/src/orchestration/Services/WorktreeLifecycle.ts @@ -0,0 +1,73 @@ +/** + * WorktreeLifecycle - Server-authoritative worktree cleanup and restoration. + * + * Owns the archive-time cleanup decision (preview + conditional removal) and + * the unarchive-time restoration of a missing worktree. All operations are + * keyed by thread id so clients never make the final safety decision about + * which path is removed or recreated. + * + * @module WorktreeLifecycle + */ +import type { + ThreadId, + WorktreeCleanupPreviewResult, + WorktreeCleanupResult, + WorktreeLifecycleError, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +export interface WorktreeCleanupThreadInput { + readonly threadId: ThreadId; +} + +/** + * WorktreeLifecycleShape - Service API for thread worktree lifecycle. + */ +export interface WorktreeLifecycleShape { + /** + * Decide whether archiving this thread would orphan its worktree. + * + * Returns a candidate only when the thread is active, has both a branch + * and a worktree path (so removal stays restorable), and no other active + * nondeleted thread references the same normalized path. Clients use this + * only to decide whether to show the confirmation prompt. + */ + readonly previewCleanup: ( + input: WorktreeCleanupThreadInput, + ) => Effect.Effect; + + /** + * Force-remove the archived thread's worktree if it is still orphaned. + * + * Re-reads all nondeleted references under a per-path lock before + * removing, stops provider sessions and closes terminals that could still + * use the path, keeps the branch, and refreshes VCS status. Returns a + * structured status instead of failing when the worktree is retained or + * already missing. + */ + readonly cleanupThreadWorktree: ( + input: WorktreeCleanupThreadInput, + ) => Effect.Effect; + + /** + * Recreate a missing worktree before committing a thread unarchive. + * + * Runs `commitUnarchive` unchanged when no restoration is needed. When the + * recorded worktree path is missing, the worktree is recreated from the + * retained branch at the original path while holding the same per-path + * lock used by cleanup, and the commit is dispatched under that lock. If + * recreation fails the commit never runs, so the thread stays archived. + */ + readonly restoreThreadWorktree: ( + input: WorktreeCleanupThreadInput, + commitUnarchive: Effect.Effect, + ) => Effect.Effect; +} + +/** + * WorktreeLifecycle - Service tag for thread worktree lifecycle operations. + */ +export class WorktreeLifecycle extends Context.Service()( + "t3/orchestration/Services/WorktreeLifecycle", +) {} diff --git a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts index 4763f565653..f2c329d211b 100644 --- a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts @@ -195,4 +195,85 @@ projectionRepositoriesLayer("Projection repositories", (it) => { assert.strictEqual(updated?.snoozedAt, null); }), ); + + it.effect("lists nondeleted worktree references including archived rows", () => + Effect.gen(function* () { + const threads = yield* ProjectionThreadRepository; + const sql = yield* SqlClient.SqlClient; + yield* sql`DELETE FROM projection_threads`; + + const baseRow = { + projectId: ProjectId.make("project-worktrees"), + title: "Worktree thread", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: "feature-1", + latestTurnId: null, + createdAt: "2026-03-24T00:00:00.000Z", + updatedAt: "2026-03-24T00:00:00.000Z", + settledOverride: null, + settledAt: null, + snoozedUntil: null, + snoozedAt: null, + latestUserMessageAt: null, + pendingApprovalCount: 0, + pendingUserInputCount: 0, + hasActionableProposedPlan: 0, + } as const; + + yield* threads.upsert({ + ...baseRow, + threadId: ThreadId.make("thread-active-worktree"), + worktreePath: "/tmp/worktrees/feature-1", + archivedAt: null, + deletedAt: null, + }); + yield* threads.upsert({ + ...baseRow, + threadId: ThreadId.make("thread-archived-worktree"), + worktreePath: "/tmp/worktrees/feature-1", + archivedAt: "2026-03-25T00:00:00.000Z", + deletedAt: null, + }); + yield* threads.upsert({ + ...baseRow, + threadId: ThreadId.make("thread-deleted-worktree"), + worktreePath: "/tmp/worktrees/feature-1", + archivedAt: null, + deletedAt: "2026-03-25T00:00:00.000Z", + }); + yield* threads.upsert({ + ...baseRow, + threadId: ThreadId.make("thread-no-worktree"), + worktreePath: null, + archivedAt: null, + deletedAt: null, + }); + + const references = yield* threads.listWorktreeReferences(); + assert.deepStrictEqual( + references.map((reference) => ({ + threadId: reference.threadId, + worktreePath: reference.worktreePath, + archivedAt: reference.archivedAt, + })), + [ + { + threadId: ThreadId.make("thread-active-worktree"), + worktreePath: "/tmp/worktrees/feature-1", + archivedAt: null, + }, + { + threadId: ThreadId.make("thread-archived-worktree"), + worktreePath: "/tmp/worktrees/feature-1", + archivedAt: "2026-03-25T00:00:00.000Z", + }, + ], + ); + }), + ); }); diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index 7e86d49eac3..7d00747427b 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -12,6 +12,7 @@ import { ListProjectionThreadsByProjectInput, ProjectionThread, ProjectionThreadRepository, + ProjectionThreadWorktreeReference, type ProjectionThreadRepositoryShape, } from "../Services/ProjectionThreads.ts"; import { ModelSelection } from "@t3tools/contracts"; @@ -166,6 +167,23 @@ const makeProjectionThreadRepository = Effect.gen(function* () { `, }); + const listProjectionThreadWorktreeReferenceRows = SqlSchema.findAll({ + Request: Schema.Void, + Result: ProjectionThreadWorktreeReference, + execute: () => + sql` + SELECT + thread_id AS "threadId", + project_id AS "projectId", + worktree_path AS "worktreePath", + archived_at AS "archivedAt" + FROM projection_threads + WHERE deleted_at IS NULL + AND worktree_path IS NOT NULL + ORDER BY created_at ASC, thread_id ASC + `, + }); + const deleteProjectionThreadRow = SqlSchema.void({ Request: DeleteProjectionThreadInput, execute: ({ threadId }) => @@ -195,11 +213,19 @@ const makeProjectionThreadRepository = Effect.gen(function* () { Effect.mapError(toPersistenceSqlError("ProjectionThreadRepository.deleteById:query")), ); + const listWorktreeReferences: ProjectionThreadRepositoryShape["listWorktreeReferences"] = () => + listProjectionThreadWorktreeReferenceRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionThreadRepository.listWorktreeReferences:query"), + ), + ); + return { upsert, getById, listByProjectId, deleteById, + listWorktreeReferences, } satisfies ProjectionThreadRepositoryShape; }); diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts index 056425ae886..6ae93624ebc 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -63,6 +63,14 @@ export const ListProjectionThreadsByProjectInput = Schema.Struct({ }); export type ListProjectionThreadsByProjectInput = typeof ListProjectionThreadsByProjectInput.Type; +export const ProjectionThreadWorktreeReference = Schema.Struct({ + threadId: ThreadId, + projectId: ProjectId, + worktreePath: Schema.String, + archivedAt: Schema.NullOr(IsoDateTime), +}); +export type ProjectionThreadWorktreeReference = typeof ProjectionThreadWorktreeReference.Type; + /** * ProjectionThreadRepositoryShape - Service API for projected thread records. */ @@ -96,6 +104,19 @@ export interface ProjectionThreadRepositoryShape { readonly deleteById: ( input: DeleteProjectionThreadInput, ) => Effect.Effect; + + /** + * List every nondeleted thread row that references a worktree path. + * + * Soft-deleted rows are excluded so they never count as worktree + * references; archived rows are included so callers can distinguish + * archived from active references. Path comparison is left to callers so + * they can apply the shared normalization helper. + */ + readonly listWorktreeReferences: () => Effect.Effect< + ReadonlyArray, + ProjectionRepositoryError + >; } /** diff --git a/apps/server/src/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts index 15612908079..b1a8174f3ef 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts @@ -42,6 +42,7 @@ const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) => getThreadCheckpointContext: () => Effect.die("unused"), getFullThreadDiffContext: () => Effect.die("unused"), getThreadShellById: () => Effect.die("unused"), + getSessionStopContextById: () => Effect.die("unused"), getThreadDetailById: () => Effect.die("unused"), getThreadDetailSnapshot: () => Effect.die("unused"), }); diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts index 3843c8acbcd..c75d2729a7c 100644 --- a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts @@ -203,6 +203,7 @@ describe("ProviderSessionReaper", () => { getProjectShellById: () => Effect.die("unused"), getFirstActiveThreadIdByProjectId: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.die("unused"), + getSessionStopContextById: () => Effect.die("unused"), getFullThreadDiffContext: () => Effect.die("unused"), getThreadShellById: (threadId) => Effect.succeed( diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 94b58160a5c..df19d97b724 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -80,6 +80,7 @@ import * as ExternalLauncher from "./process/externalLauncher.ts"; import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; import { OrchestrationListenerCallbackError } from "./orchestration/Errors.ts"; import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as WorktreeLifecycle from "./orchestration/Services/WorktreeLifecycle.ts"; import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts"; import { PersistenceSqlError } from "./persistence/Errors.ts"; import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts"; @@ -340,6 +341,7 @@ const buildAppUnderTest = (options?: { terminalManager?: Partial; orchestrationEngine?: Partial; projectionSnapshotQuery?: Partial; + worktreeLifecycle?: Partial; checkpointDiffQuery?: Partial; browserTraceCollector?: Partial; serverLifecycleEvents?: Partial; @@ -691,34 +693,43 @@ const buildAppUnderTest = (options?: { }), ), Layer.provide( - Layer.mock(ProjectionSnapshotQuery.ProjectionSnapshotQuery)({ - getCommandReadModel: () => Effect.succeed(makeDefaultOrchestrationReadModel()), - getSnapshot: () => Effect.succeed(makeDefaultOrchestrationReadModel()), - getShellSnapshot: () => - Effect.succeed({ - snapshotSequence: 0, - projects: [], - threads: [], - updatedAt: "1970-01-01T00:00:00.000Z", - }), - getArchivedShellSnapshot: () => - Effect.succeed({ - snapshotSequence: 0, - projects: [], - threads: [], - updatedAt: "1970-01-01T00:00:00.000Z", - }), - getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), - getProjectShellById: () => Effect.succeed(Option.none()), - getThreadShellById: () => Effect.succeed(Option.none()), - getThreadDetailById: () => Effect.succeed(Option.none()), - getThreadDetailSnapshot: () => Effect.succeed(Option.none()), - getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), - getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), - getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), - getThreadCheckpointContext: () => Effect.succeed(Option.none()), - ...options?.layers?.projectionSnapshotQuery, - }), + Layer.mergeAll( + Layer.mock(WorktreeLifecycle.WorktreeLifecycle)({ + previewCleanup: () => Effect.succeed({ candidate: null }), + cleanupThreadWorktree: () => Effect.die("unused worktree cleanup"), + restoreThreadWorktree: (_input, commitUnarchive) => commitUnarchive, + ...options?.layers?.worktreeLifecycle, + }), + Layer.mock(ProjectionSnapshotQuery.ProjectionSnapshotQuery)({ + getCommandReadModel: () => Effect.succeed(makeDefaultOrchestrationReadModel()), + getSnapshot: () => Effect.succeed(makeDefaultOrchestrationReadModel()), + getShellSnapshot: () => + Effect.succeed({ + snapshotSequence: 0, + projects: [], + threads: [], + updatedAt: "1970-01-01T00:00:00.000Z", + }), + getArchivedShellSnapshot: () => + Effect.succeed({ + snapshotSequence: 0, + projects: [], + threads: [], + updatedAt: "1970-01-01T00:00:00.000Z", + }), + getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), + getProjectShellById: () => Effect.succeed(Option.none()), + getThreadShellById: () => Effect.succeed(Option.none()), + getThreadDetailById: () => Effect.succeed(Option.none()), + getThreadDetailSnapshot: () => Effect.succeed(Option.none()), + getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), + getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), + getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getThreadCheckpointContext: () => Effect.succeed(Option.none()), + getSessionStopContextById: () => Effect.succeed(Option.none()), + ...options?.layers?.projectionSnapshotQuery, + }), + ), ), Layer.provide( Layer.mock(CheckpointDiffQuery.CheckpointDiffQuery)({ diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index c4b05d204d6..396f67a3d6d 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -50,6 +50,7 @@ import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRun import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderCommandReactor.ts"; import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor.ts"; import { ThreadDeletionReactorLive } from "./orchestration/Layers/ThreadDeletionReactor.ts"; +import { WorktreeLifecycleLive } from "./orchestration/Layers/WorktreeLifecycle.ts"; import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts"; import { hasCloudPublicConfig } from "./cloud/publicConfig.ts"; import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry.ts"; @@ -290,7 +291,7 @@ const ProviderRuntimeLayerLive = ProviderSessionReaperLive.pipe( const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( // Core Services - Layer.provideMerge(CheckpointingLayerLive), + Layer.provideMerge(Layer.mergeAll(WorktreeLifecycleLive, CheckpointingLayerLive)), Layer.provideMerge(SourceControlProviderRegistryLayerLive), Layer.provideMerge(GitLayerLive), Layer.provideMerge(VcsLayerLive), diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index b8102bda9ad..a86ef0cea03 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -94,6 +94,7 @@ it.effect("launchStartupHeartbeat does not block the caller while counts are loa getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), getThreadCheckpointContext: () => Effect.succeed(Option.none()), getFullThreadDiffContext: () => Effect.succeed(Option.none()), + getSessionStopContextById: () => Effect.succeed(Option.none()), getThreadShellById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), getThreadDetailSnapshot: () => Effect.succeed(Option.none()), @@ -157,6 +158,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.some(bootstrapThreadId)), getThreadCheckpointContext: () => Effect.succeed(Option.none()), getFullThreadDiffContext: () => Effect.succeed(Option.none()), + getSessionStopContextById: () => Effect.die("unused"), getThreadShellById: () => Effect.die("unused"), getThreadDetailById: () => Effect.die("unused"), getThreadDetailSnapshot: () => Effect.die("unused"), @@ -201,6 +203,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), getThreadCheckpointContext: () => Effect.succeed(Option.none()), getFullThreadDiffContext: () => Effect.succeed(Option.none()), + getSessionStopContextById: () => Effect.die("unused"), getThreadShellById: () => Effect.die("unused"), getThreadDetailById: () => Effect.die("unused"), getThreadDetailSnapshot: () => Effect.die("unused"), @@ -251,6 +254,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), getThreadCheckpointContext: () => Effect.succeed(Option.none()), getFullThreadDiffContext: () => Effect.succeed(Option.none()), + getSessionStopContextById: () => Effect.die("unused"), getThreadShellById: () => Effect.die("unused"), getThreadDetailById: () => Effect.die("unused"), getThreadDetailSnapshot: () => Effect.die("unused"), diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index db6c06303bb..60f134e89e5 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -2464,7 +2464,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* } args.push(input.path); yield* executeGit("GitVcsDriver.removeWorktree", input.cwd, args, { - timeoutMs: 15_000, + timeoutMs: 30_000, fallbackErrorDetail: "git worktree remove failed", }); }); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index f6f46d1e76e..1cc0dccb1cb 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -71,6 +71,7 @@ import * as ExternalLauncher from "./process/externalLauncher.ts"; import { normalizeDispatchCommand } from "./orchestration/Normalizer.ts"; import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as WorktreeLifecycle from "./orchestration/Services/WorktreeLifecycle.ts"; import { observeRpcEffect as instrumentRpcEffect, observeRpcStream as instrumentRpcStream, @@ -328,6 +329,8 @@ const RPC_REQUIRED_SCOPE = new Map([ [WS_METHODS.vcsListRefs, AuthOrchestrationReadScope], [WS_METHODS.vcsCreateWorktree, AuthOrchestrationOperateScope], [WS_METHODS.vcsRemoveWorktree, AuthOrchestrationOperateScope], + [WS_METHODS.vcsPreviewWorktreeCleanup, AuthOrchestrationReadScope], + [WS_METHODS.vcsCleanupThreadWorktree, AuthOrchestrationOperateScope], [WS_METHODS.vcsCreateRef, AuthOrchestrationOperateScope], [WS_METHODS.vcsSwitchRef, AuthOrchestrationOperateScope], [WS_METHODS.vcsInit, AuthOrchestrationOperateScope], @@ -415,6 +418,7 @@ const makeWsRpcLayer = ( const review = yield* ReviewService.ReviewService; const vcsProvisioning = yield* VcsProvisioningService.VcsProvisioningService; const vcsStatusBroadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + const worktreeLifecycle = yield* WorktreeLifecycle.WorktreeLifecycle; const terminalManager = yield* TerminalManager.TerminalManager; const previewManager = yield* PreviewManager.PreviewManager; const portDiscovery = yield* PortScanner.PortDiscovery; @@ -1126,7 +1130,26 @@ const makeWsRpcLayer = ( Effect.orElseSucceed(() => false), ) : false; - const result = yield* dispatchNormalizedCommand(normalizedCommand); + // Unarchive restores a missing worktree from the retained + // branch before the command commits; a failed restoration + // leaves the thread archived instead of silently detaching it + // to the main project checkout. + const result = + normalizedCommand.type === "thread.unarchive" + ? yield* worktreeLifecycle + .restoreThreadWorktree( + { threadId: normalizedCommand.threadId }, + dispatchNormalizedCommand(normalizedCommand), + ) + .pipe( + Effect.mapError((error) => + toDispatchCommandError( + error, + "Failed to restore the thread's worktree before unarchive.", + ), + ), + ) + : yield* dispatchNormalizedCommand(normalizedCommand); if (normalizedCommand.type === "thread.archive") { if (shouldStopSessionAfterArchive) { yield* Effect.gen(function* () { @@ -1828,6 +1851,18 @@ const makeWsRpcLayer = ( gitWorkflow.removeWorktree(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), { "rpc.aggregate": "vcs" }, ), + [WS_METHODS.vcsPreviewWorktreeCleanup]: (input) => + observeRpcEffect( + WS_METHODS.vcsPreviewWorktreeCleanup, + worktreeLifecycle.previewCleanup(input), + { "rpc.aggregate": "vcs" }, + ), + [WS_METHODS.vcsCleanupThreadWorktree]: (input) => + observeRpcEffect( + WS_METHODS.vcsCleanupThreadWorktree, + worktreeLifecycle.cleanupThreadWorktree(input), + { "rpc.aggregate": "vcs" }, + ), [WS_METHODS.vcsCreateRef]: (input) => observeRpcEffect( WS_METHODS.vcsCreateRef, diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 6b7afedbb96..46cf3329ba4 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -9,6 +9,7 @@ import { settlePromise, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; +import { runArchiveWithWorktreeCleanup } from "@t3tools/client-runtime/state/worktreeCleanup"; import { canSettle, canSnooze } from "@t3tools/client-runtime/state/thread-settled"; import { EnvironmentId, type ScopedThreadRef, ThreadId } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; @@ -156,6 +157,12 @@ export function useThreadActions() { const removeWorktree = useAtomCommand(vcsEnvironment.removeWorktree, { reportFailure: false, }); + const previewWorktreeCleanup = useAtomCommand(vcsEnvironment.previewWorktreeCleanup, { + reportFailure: false, + }); + const cleanupThreadWorktree = useAtomCommand(vcsEnvironment.cleanupThreadWorktree, { + reportFailure: false, + }); const refreshVcsStatus = useAtomCommand(vcsEnvironment.refreshStatus, { reportFailure: false, }); @@ -210,10 +217,82 @@ export function useThreadActions() { const shouldNavigateToDraft = currentRouteThreadRef?.threadId === threadRef.threadId && currentRouteThreadRef.environmentId === threadRef.environmentId; - const archiveResult = await archiveThreadMutation({ - environmentId: threadRef.environmentId, - input: { threadId: threadRef.threadId }, + const localApi = readLocalApi(); + const outcome = await runArchiveWithWorktreeCleanup({ + // Server-authoritative preview: only prompt when archiving the final + // active thread that references a worktree. A preview failure (old + // server, transient error) degrades to a plain archive. + previewCandidate: async () => { + const previewResult = await previewWorktreeCleanup({ + environmentId: threadRef.environmentId, + input: { threadId: threadRef.threadId }, + }); + return previewResult._tag === "Success" ? previewResult.value.candidate : null; + }, + confirmRemoval: localApi + ? async ({ displayWorktreePath }) => { + const confirmationResult = await settlePromise(() => + localApi.dialogs.confirm( + [ + "This thread is the last active one linked to this worktree:", + displayWorktreePath, + "", + "Remove the worktree too? The branch is kept.", + ].join("\n"), + ), + ); + if (confirmationResult._tag === "Failure") { + return { kind: "aborted", result: confirmationResult } as const; + } + return confirmationResult.value + ? ({ kind: "confirmed" } as const) + : ({ kind: "declined" } as const); + } + : null, + archive: () => + archiveThreadMutation({ + environmentId: threadRef.environmentId, + input: { threadId: threadRef.threadId }, + }), + isArchiveSuccess: (result) => result._tag === "Success", + cleanup: async () => { + const cleanupResult = await cleanupThreadWorktree({ + environmentId: threadRef.environmentId, + input: { threadId: threadRef.threadId }, + }); + if (cleanupResult._tag === "Failure") { + const error = squashAtomCommandFailure(cleanupResult); + return { + kind: "failed", + message: error instanceof Error ? error.message : "Unknown error removing worktree.", + } as const; + } + return { kind: "done", status: cleanupResult.value.status } as const; + }, + // Cleanup problems are nonfatal: the archive itself succeeded. + onCleanupFailed: (displayWorktreePath, message) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Thread archived, but worktree removal failed", + description: `Could not remove ${displayWorktreePath}. ${message}`, + }), + ); + }, + onCleanupRetained: (displayWorktreePath) => { + toastManager.add( + stackedThreadToast({ + type: "info", + title: "Worktree kept", + description: `${displayWorktreePath} is still used by another active thread.`, + }), + ); + }, }); + if (outcome.kind === "aborted") { + return outcome.result; + } + const archiveResult = outcome.result; if (archiveResult._tag === "Failure") { return archiveResult; } @@ -232,7 +311,13 @@ export function useThreadActions() { return archiveResult; }, - [archiveThreadMutation, getCurrentRouteThreadRef, resolveThreadTarget], + [ + archiveThreadMutation, + cleanupThreadWorktree, + getCurrentRouteThreadRef, + previewWorktreeCleanup, + resolveThreadTarget, + ], ); const unarchiveThread = useCallback( diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index 4fa05f850e5..d36c4929ce7 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -134,6 +134,10 @@ "./state/vcs": { "types": "./src/state/vcs.ts", "default": "./src/state/vcs.ts" + }, + "./state/worktreeCleanup": { + "types": "./src/state/worktreeCleanup.ts", + "default": "./src/state/worktreeCleanup.ts" } }, "scripts": { diff --git a/packages/client-runtime/src/state/vcs.ts b/packages/client-runtime/src/state/vcs.ts index 4ef94c2619f..72782d019f7 100644 --- a/packages/client-runtime/src/state/vcs.ts +++ b/packages/client-runtime/src/state/vcs.ts @@ -20,7 +20,11 @@ import { safeErrorLogAttributes } from "../errors/safeLog.ts"; import { EnvironmentCacheStore } from "../platform/persistence.ts"; import { request, subscribe, type EnvironmentRpcInput } from "../rpc/client.ts"; import { followStreamInEnvironment } from "./runtime.ts"; -import { vcsCommandConcurrency, vcsCommandScheduler } from "./vcsCommandScheduler.ts"; +import { + vcsCommandConcurrency, + vcsCommandScheduler, + vcsThreadCommandConcurrency, +} from "./vcsCommandScheduler.ts"; const OFFLINE_BRANCH_LIST_LIMIT = 100; const VCS_REFS_REVALIDATE_INTERVAL = "5 seconds"; @@ -200,6 +204,18 @@ export function createVcsEnvironmentAtoms( scheduler: vcsCommandScheduler, concurrency: vcsCommandConcurrency, }), + previewWorktreeCleanup: createEnvironmentRpcCommand(runtime, { + label: "environment-data:vcs:preview-worktree-cleanup", + tag: WS_METHODS.vcsPreviewWorktreeCleanup, + scheduler: vcsCommandScheduler, + concurrency: vcsThreadCommandConcurrency, + }), + cleanupThreadWorktree: createEnvironmentRpcCommand(runtime, { + label: "environment-data:vcs:cleanup-thread-worktree", + tag: WS_METHODS.vcsCleanupThreadWorktree, + scheduler: vcsCommandScheduler, + concurrency: vcsThreadCommandConcurrency, + }), createRef: createEnvironmentRpcCommand(runtime, { label: "environment-data:vcs:create-ref", tag: WS_METHODS.vcsCreateRef, diff --git a/packages/client-runtime/src/state/vcsCommandScheduler.ts b/packages/client-runtime/src/state/vcsCommandScheduler.ts index a11b157bb2d..d7b508709b3 100644 --- a/packages/client-runtime/src/state/vcsCommandScheduler.ts +++ b/packages/client-runtime/src/state/vcsCommandScheduler.ts @@ -11,3 +11,11 @@ export const vcsCommandConcurrency: AtomCommandConcurrency<{ mode: "serial", key: ({ environmentId, input }) => JSON.stringify([environmentId, input.cwd]), }; + +export const vcsThreadCommandConcurrency: AtomCommandConcurrency<{ + readonly environmentId: EnvironmentId; + readonly input: { readonly threadId: string }; +}> = { + mode: "serial", + key: ({ environmentId, input }) => JSON.stringify([environmentId, input.threadId]), +}; diff --git a/packages/client-runtime/src/state/worktreeCleanup.test.ts b/packages/client-runtime/src/state/worktreeCleanup.test.ts new file mode 100644 index 00000000000..487b4a28957 --- /dev/null +++ b/packages/client-runtime/src/state/worktreeCleanup.test.ts @@ -0,0 +1,192 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +import { + formatWorktreePathForDisplay, + runArchiveWithWorktreeCleanup, + type ArchiveWorktreeCleanupCandidate, + type WorktreeCleanupConfirmation, + type WorktreeCleanupOutcome, +} from "./worktreeCleanup.ts"; + +const candidate: ArchiveWorktreeCleanupCandidate = { + worktreePath: "/tmp/worktrees/repo/feature-1", + branch: "feature-1", +}; + +function makeFlow(overrides?: { + readonly previewCandidate?: () => Promise; + readonly confirmation?: WorktreeCleanupConfirmation<{ readonly _tag: "Failure" }> | null; + readonly archiveSucceeds?: boolean; + readonly cleanupOutcome?: WorktreeCleanupOutcome; +}) { + const confirmRemoval = + overrides?.confirmation === null + ? null + : vi.fn(async () => overrides?.confirmation ?? { kind: "confirmed" as const }); + const archive = vi.fn(async () => ({ + _tag: overrides?.archiveSucceeds === false ? ("Failure" as const) : ("Success" as const), + })); + const cleanup = vi.fn( + async (): Promise => + overrides?.cleanupOutcome ?? { kind: "done", status: "removed" }, + ); + const onCleanupFailed = vi.fn(); + const onCleanupRetained = vi.fn(); + const run = () => + runArchiveWithWorktreeCleanup({ + previewCandidate: overrides?.previewCandidate ?? (async () => candidate), + confirmRemoval, + archive, + isArchiveSuccess: (result) => result._tag === "Success", + cleanup, + onCleanupFailed, + onCleanupRetained, + }); + return { run, confirmRemoval, archive, cleanup, onCleanupFailed, onCleanupRetained }; +} + +describe("runArchiveWithWorktreeCleanup", () => { + it("prompts with the formatted final path segment when the thread is the final active reference", async () => { + const flow = makeFlow(); + await flow.run(); + expect(flow.confirmRemoval).toHaveBeenCalledExactlyOnceWith({ + candidate, + displayWorktreePath: "feature-1", + }); + }); + + it("does not prompt when the server preview reports a shared active worktree", async () => { + const flow = makeFlow({ previewCandidate: async () => null }); + await flow.run(); + expect(flow.confirmRemoval).not.toHaveBeenCalled(); + expect(flow.archive).toHaveBeenCalledTimes(1); + expect(flow.cleanup).not.toHaveBeenCalled(); + }); + + it("does not prompt or clean up when no confirmation surface exists", async () => { + const flow = makeFlow({ confirmation: null }); + await flow.run(); + expect(flow.archive).toHaveBeenCalledTimes(1); + expect(flow.cleanup).not.toHaveBeenCalled(); + }); + + it("archives without cleanup when the user declines", async () => { + const flow = makeFlow({ confirmation: { kind: "declined" } }); + const outcome = await flow.run(); + expect(outcome).toEqual({ kind: "archived", result: { _tag: "Success" } }); + expect(flow.archive).toHaveBeenCalledTimes(1); + expect(flow.cleanup).not.toHaveBeenCalled(); + }); + + it("archives and requests conditional cleanup when the user confirms", async () => { + const flow = makeFlow({ confirmation: { kind: "confirmed" } }); + const outcome = await flow.run(); + expect(outcome).toEqual({ kind: "archived", result: { _tag: "Success" } }); + expect(flow.cleanup).toHaveBeenCalledTimes(1); + expect(flow.onCleanupFailed).not.toHaveBeenCalled(); + expect(flow.onCleanupRetained).not.toHaveBeenCalled(); + // Cleanup runs only after the archive. + expect(flow.archive.mock.invocationCallOrder[0]).toBeLessThan( + flow.cleanup.mock.invocationCallOrder[0] ?? 0, + ); + }); + + it("skips cleanup when the archive itself failed", async () => { + const flow = makeFlow({ archiveSucceeds: false }); + const outcome = await flow.run(); + expect(outcome).toEqual({ kind: "archived", result: { _tag: "Failure" } }); + expect(flow.cleanup).not.toHaveBeenCalled(); + }); + + it("reports a cleanup failure as cleanup-only without failing the archive", async () => { + const flow = makeFlow({ + cleanupOutcome: { kind: "failed", message: "git worktree remove failed" }, + }); + const outcome = await flow.run(); + expect(outcome).toEqual({ kind: "archived", result: { _tag: "Success" } }); + expect(flow.onCleanupFailed).toHaveBeenCalledExactlyOnceWith( + "feature-1", + "git worktree remove failed", + ); + }); + + it("reports a retained worktree when another thread became active after the preview", async () => { + const flow = makeFlow({ cleanupOutcome: { kind: "done", status: "retained-active" } }); + await flow.run(); + expect(flow.onCleanupRetained).toHaveBeenCalledExactlyOnceWith("feature-1"); + expect(flow.onCleanupFailed).not.toHaveBeenCalled(); + }); + + it("stays silent when the worktree was already missing", async () => { + const flow = makeFlow({ cleanupOutcome: { kind: "done", status: "already-missing" } }); + await flow.run(); + expect(flow.onCleanupFailed).not.toHaveBeenCalled(); + expect(flow.onCleanupRetained).not.toHaveBeenCalled(); + }); + + it("aborts without archiving when the confirmation surface fails", async () => { + const flow = makeFlow({ confirmation: { kind: "aborted", result: { _tag: "Failure" } } }); + const outcome = await flow.run(); + expect(outcome).toEqual({ kind: "aborted", result: { _tag: "Failure" } }); + expect(flow.archive).not.toHaveBeenCalled(); + expect(flow.cleanup).not.toHaveBeenCalled(); + }); + + it("sequential bulk archive prompts only when each worktree reaches its final active reference", async () => { + // Two threads share one worktree; a third owns its own. Each archive + // requests a fresh preview, so the shared worktree only prompts once the + // earlier archive is already visible to the server. + const activeByThread = new Map([ + ["t1", { worktreePath: "/wt/shared", branch: "shared" }], + ["t2", { worktreePath: "/wt/shared", branch: "shared" }], + ["t3", { worktreePath: "/wt/solo", branch: "solo" }], + ]); + const prompts: Array = []; + + const archiveOne = (threadId: string) => + runArchiveWithWorktreeCleanup({ + previewCandidate: async () => { + const target = activeByThread.get(threadId); + if (!target) return null; + const shared = [...activeByThread.entries()].some( + ([otherId, other]) => + otherId !== threadId && other.worktreePath === target.worktreePath, + ); + return shared ? null : target; + }, + confirmRemoval: async ({ displayWorktreePath }) => { + prompts.push(displayWorktreePath); + return { kind: "declined" }; + }, + archive: async () => { + activeByThread.delete(threadId); + return { _tag: "Success" as const }; + }, + isArchiveSuccess: (result) => result._tag === "Success", + cleanup: async () => ({ kind: "done", status: "removed" }), + onCleanupFailed: () => {}, + onCleanupRetained: () => {}, + }); + + await archiveOne("t1"); + await archiveOne("t2"); + await archiveOne("t3"); + + // t1 shares with t2 (no prompt); t2 is the final shared reference and + // t3 the only solo reference (both prompt). + expect(prompts).toEqual(["shared", "solo"]); + }); +}); + +describe("formatWorktreePathForDisplay", () => { + it("returns the final path segment across separators", () => { + expect(formatWorktreePathForDisplay("/tmp/worktrees/repo/feature-1")).toBe("feature-1"); + expect(formatWorktreePathForDisplay("/tmp/worktrees/repo/feature-1/")).toBe("feature-1"); + expect(formatWorktreePathForDisplay("C:\\worktrees\\repo\\feature-1")).toBe("feature-1"); + }); + + it("falls back to the trimmed input when no segment exists", () => { + expect(formatWorktreePathForDisplay(" ")).toBe(" "); + expect(formatWorktreePathForDisplay("///")).toBe("///"); + }); +}); diff --git a/packages/client-runtime/src/state/worktreeCleanup.ts b/packages/client-runtime/src/state/worktreeCleanup.ts new file mode 100644 index 00000000000..2dfa6b7bf7b --- /dev/null +++ b/packages/client-runtime/src/state/worktreeCleanup.ts @@ -0,0 +1,84 @@ +/** + * Shared archive-time worktree cleanup flow. + * + * The server owns the safety decision (preview + conditional cleanup RPCs); + * this module owns the client sequencing shared by web and mobile: prompt + * only when the server reports a candidate and a confirmation surface + * exists, archive regardless of the answer, run cleanup only after a + * confirmed archive, and report cleanup problems without failing the + * archive itself. + */ +import type { WorktreeCleanupStatus } from "@t3tools/contracts"; + +export interface ArchiveWorktreeCleanupCandidate { + readonly worktreePath: string; + readonly branch: string; +} + +/** Shortens a worktree path to its final segment for prompts and toasts. */ +export function formatWorktreePathForDisplay(worktreePath: string): string { + const trimmed = worktreePath.trim(); + if (!trimmed) { + return worktreePath; + } + const normalized = trimmed.replace(/\\/g, "/").replace(/\/+$/, ""); + const parts = normalized.split("/"); + const lastPart = parts[parts.length - 1]?.trim() ?? ""; + return lastPart.length > 0 ? lastPart : trimmed; +} + +export type WorktreeCleanupConfirmation = + | { readonly kind: "confirmed" } + | { readonly kind: "declined" } + /** The confirmation surface itself failed; the archive is not attempted. */ + | { readonly kind: "aborted"; readonly result: TAbort }; + +export type WorktreeCleanupOutcome = + | { readonly kind: "failed"; readonly message: string } + | { readonly kind: "done"; readonly status: WorktreeCleanupStatus }; + +export type ArchiveWithWorktreeCleanupResult = + | { readonly kind: "archived"; readonly result: TArchive } + | { readonly kind: "aborted"; readonly result: TAbort }; + +export async function runArchiveWithWorktreeCleanup(input: { + /** Server-authoritative preview; null when ineligible or when the preview failed. */ + readonly previewCandidate: () => Promise; + /** Confirmation surface, or null when none is available (no prompt, no cleanup). */ + readonly confirmRemoval: + | ((prompt: { + readonly candidate: ArchiveWorktreeCleanupCandidate; + readonly displayWorktreePath: string; + }) => Promise>) + | null; + readonly archive: () => Promise; + readonly isArchiveSuccess: (result: TArchive) => boolean; + readonly cleanup: () => Promise; + readonly onCleanupFailed: (displayWorktreePath: string, message: string) => void; + readonly onCleanupRetained: (displayWorktreePath: string) => void; +}): Promise> { + const candidate = await input.previewCandidate(); + let shouldCleanup = false; + let displayWorktreePath: string | null = null; + if (candidate && input.confirmRemoval) { + displayWorktreePath = formatWorktreePathForDisplay(candidate.worktreePath); + const confirmation = await input.confirmRemoval({ candidate, displayWorktreePath }); + if (confirmation.kind === "aborted") { + return { kind: "aborted", result: confirmation.result }; + } + shouldCleanup = confirmation.kind === "confirmed"; + } + + const archiveResult = await input.archive(); + if (!shouldCleanup || displayWorktreePath === null || !input.isArchiveSuccess(archiveResult)) { + return { kind: "archived", result: archiveResult }; + } + + const cleanupOutcome = await input.cleanup(); + if (cleanupOutcome.kind === "failed") { + input.onCleanupFailed(displayWorktreePath, cleanupOutcome.message); + } else if (cleanupOutcome.status === "retained-active") { + input.onCleanupRetained(displayWorktreePath); + } + return { kind: "archived", result: archiveResult }; +} diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index ecc334b765d..818a20924f8 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -170,6 +170,46 @@ export const VcsRemoveWorktreeInput = Schema.Struct({ }); export type VcsRemoveWorktreeInput = typeof VcsRemoveWorktreeInput.Type; +// Worktree lifecycle (thread-scoped cleanup) +// +// These inputs are keyed by thread id instead of a client-provided repository +// root and path so the server stays authoritative over which worktree (if +// any) is safe to remove or must be restored. + +export const WorktreeCleanupPreviewInput = Schema.Struct({ + threadId: ThreadId, +}); +export type WorktreeCleanupPreviewInput = typeof WorktreeCleanupPreviewInput.Type; + +export const WorktreeCleanupCandidate = Schema.Struct({ + worktreePath: TrimmedNonEmptyStringSchema, + branch: TrimmedNonEmptyStringSchema, +}); +export type WorktreeCleanupCandidate = typeof WorktreeCleanupCandidate.Type; + +export const WorktreeCleanupPreviewResult = Schema.Struct({ + candidate: Schema.NullOr(WorktreeCleanupCandidate), +}); +export type WorktreeCleanupPreviewResult = typeof WorktreeCleanupPreviewResult.Type; + +export const WorktreeCleanupInput = Schema.Struct({ + threadId: ThreadId, +}); +export type WorktreeCleanupInput = typeof WorktreeCleanupInput.Type; + +export const WorktreeCleanupStatus = Schema.Literals([ + "removed", + "retained-active", + "already-missing", +]); +export type WorktreeCleanupStatus = typeof WorktreeCleanupStatus.Type; + +export const WorktreeCleanupResult = Schema.Struct({ + status: WorktreeCleanupStatus, + worktreePath: TrimmedNonEmptyStringSchema, +}); +export type WorktreeCleanupResult = typeof WorktreeCleanupResult.Type; + export const VcsCreateRefInput = Schema.Struct({ cwd: TrimmedNonEmptyStringSchema, refName: TrimmedNonEmptyStringSchema, @@ -345,6 +385,20 @@ export class GitCommandError extends Schema.TaggedErrorClass()( } } +export class WorktreeLifecycleError extends Schema.TaggedErrorClass()( + "WorktreeLifecycleError", + { + operation: Schema.String, + threadId: ThreadId, + detail: Schema.String, + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return `Worktree ${this.operation} failed: ${this.detail}`; + } +} + export class TextGenerationError extends Schema.TaggedErrorClass()( "TextGenerationError", { diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index fa2d23b8ef2..cbfcb3a9602 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -38,6 +38,11 @@ import { VcsStatusInput, VcsStatusResult, VcsStatusStreamEvent, + WorktreeCleanupInput, + WorktreeCleanupPreviewInput, + WorktreeCleanupPreviewResult, + WorktreeCleanupResult, + WorktreeLifecycleError, } from "./git.ts"; import { ReviewDiffPreviewError, @@ -170,6 +175,8 @@ export const WS_METHODS = { vcsListRefs: "vcs.listRefs", vcsCreateWorktree: "vcs.createWorktree", vcsRemoveWorktree: "vcs.removeWorktree", + vcsPreviewWorktreeCleanup: "vcs.previewWorktreeCleanup", + vcsCleanupThreadWorktree: "vcs.cleanupThreadWorktree", vcsCreateRef: "vcs.createRef", vcsSwitchRef: "vcs.switchRef", vcsInit: "vcs.init", @@ -467,6 +474,18 @@ export const WsVcsRemoveWorktreeRpc = Rpc.make(WS_METHODS.vcsRemoveWorktree, { error: Schema.Union([GitCommandError, EnvironmentAuthorizationError]), }); +export const WsVcsPreviewWorktreeCleanupRpc = Rpc.make(WS_METHODS.vcsPreviewWorktreeCleanup, { + payload: WorktreeCleanupPreviewInput, + success: WorktreeCleanupPreviewResult, + error: Schema.Union([WorktreeLifecycleError, EnvironmentAuthorizationError]), +}); + +export const WsVcsCleanupThreadWorktreeRpc = Rpc.make(WS_METHODS.vcsCleanupThreadWorktree, { + payload: WorktreeCleanupInput, + success: WorktreeCleanupResult, + error: Schema.Union([WorktreeLifecycleError, EnvironmentAuthorizationError]), +}); + export const WsVcsCreateRefRpc = Rpc.make(WS_METHODS.vcsCreateRef, { payload: VcsCreateRefInput, success: VcsCreateRefResult, @@ -734,6 +753,8 @@ export const WsRpcGroup = RpcGroup.make( WsVcsListRefsRpc, WsVcsCreateWorktreeRpc, WsVcsRemoveWorktreeRpc, + WsVcsPreviewWorktreeCleanupRpc, + WsVcsCleanupThreadWorktreeRpc, WsVcsCreateRefRpc, WsVcsSwitchRefRpc, WsVcsInitRpc, diff --git a/plan.md b/plan.md new file mode 100644 index 00000000000..7bc83c09bda --- /dev/null +++ b/plan.md @@ -0,0 +1,193 @@ +# Archived Worktree Cleanup Plan + +## Goal + +Offer to remove a worktree when the user archives the final active thread using it, while preserving archived threads well enough to recreate the worktree if one is later unarchived. + +## Agreed Behavior + +- Prompt only when archiving the final non-archived thread associated with a worktree. +- Use the same generic confirmation behavior as the current thread deletion flow. +- If the user confirms, force-remove the worktree after the archive succeeds. +- If the user declines, archive the thread and leave the worktree unchanged. +- Keep the branch when removing the worktree. +- Recreate a missing worktree at its original path when a thread is unarchived. +- Attempt cleanup only as part of the final archive. Do not add a startup sweep, delayed retention, or periodic cleanup. +- Treat soft-deleted threads as non-references when deciding whether every remaining thread is archived. + +## Current-State Findings + +- Worktrees are not first-class persisted entities. Threads store nullable `branch` and `worktreePath` values. +- Multiple threads can intentionally share one worktree. +- Active and archived threads are returned by separate snapshot queries. +- The existing web deletion flow checks only client-side active thread state before offering worktree deletion. +- Mobile has no worktree cleanup flow. +- `vcs.removeWorktree` accepts a client-provided path and does not check thread references. +- `git worktree remove` leaves the branch in place, which makes later recreation possible. +- Archive currently retains `branch` and `worktreePath`. +- Archive dispatches a session-stop command after the thread has disappeared from active-only projection queries. The real provider reactor can therefore skip the stop, so cleanup must not be added until that path is corrected. + +## Design + +### Server-Authoritative Preview + +Add a narrow RPC that accepts a `threadId` and returns an optional cleanup candidate. + +The server should: + +1. Load the target thread, including nondeleted archived records where required. +2. Require a non-null branch and worktree path so removal remains restorable. +3. Compare normalized worktree paths across all nondeleted thread projections. +4. Return the worktree path only when the target is active and no other active thread references that path. + +Clients use this response only to decide whether to show the confirmation prompt. They must not make the final safety decision. + +### Conditional Cleanup + +Add a second RPC that accepts the archived `threadId` rather than a client-provided repository root and path. + +The server should: + +1. Resolve the project workspace root, branch, and worktree path from persisted state. +2. Require the target thread to be archived and nondeleted. +3. Re-read all nondeleted references to the normalized worktree path. +4. Return a retained result if any reference is active. +5. Ensure the provider session and terminals no longer use the worktree. +6. Force-remove the worktree, as explicitly selected in the prompt. +7. Refresh VCS status for the project. +8. Return a structured result such as `removed`, `retained-active`, or `already-missing`. + +The second check is mandatory because another client may unarchive or attach a thread between preview, confirmation, and removal. + +### Unarchive Restoration + +Before committing `thread.unarchive`, the server should: + +1. Load the archived thread and its project. +2. If `worktreePath` is null, continue normally. +3. If the path exists, continue normally. +4. If the path is missing, require a retained branch and recreate the worktree at the original path. +5. Dispatch unarchive only after recreation succeeds. +6. Refresh VCS status. +7. Run the configured worktree creation setup script again because dependencies and generated files were removed with the checkout. + +If recreation fails, leave the thread archived and return an actionable error. Do not silently detach it to the main project checkout. + +### Concurrency + +Use a per-worktree-path semaphore in the server lifecycle service. + +- Conditional removal and unarchive restoration must use the same lock. +- Recheck active references while holding the lock immediately before removal. +- Hold the lock through worktree recreation and unarchive dispatch. +- Recheck after removal and compensate by recreating the worktree if an active reference appeared during an unavoidable external race. + +### Archive Runtime Cleanup + +Fix provider shutdown before enabling physical cleanup. + +The current provider stop reactor resolves thread detail through an active-only query. Add a narrow projection query for session-stop context that includes archived, nondeleted threads, or otherwise make session stopping independent of active-shell visibility. + +The archive flow must ensure: + +- A non-stopped provider session is actually stopped. +- Session projection reaches `stopped`. +- Thread terminals are closed. +- Worktree removal cannot start while a provider still uses that cwd. + +## Client Changes + +### Web + +Update `apps/web/src/hooks/useThreadActions.ts`: + +1. Ask the server for a cleanup preview before dispatching archive. +2. If eligible, show the existing-style confirmation with the formatted final path segment. +3. Archive regardless of whether the user declines cleanup. +4. After successful archive, call conditional cleanup only when the user confirmed. +5. Show a nonfatal toast if the thread archived but worktree cleanup failed or was retained because another thread became active. + +Bulk archive remains sequential. Each item should request a fresh server preview, so earlier successful archives are visible immediately without depending on client shell propagation. + +### Mobile + +Update `apps/mobile/src/features/home/useThreadListActions.ts`: + +1. Use the same preview RPC before archive. +2. Present the confirmation through `Alert.alert` on iOS and `ConfirmDialogHost` elsewhere. +3. Preserve the current archive guard for an active turn. +4. Archive on decline and archive-plus-cleanup on confirmation. +5. Report cleanup failures without presenting the archive itself as failed. + +## Server and Contract Changes + +Expected areas: + +- `packages/contracts/src/rpc.ts` +- A focused worktree lifecycle contract in `packages/contracts/src/git.ts` or `packages/contracts/src/orchestration.ts` +- `packages/client-runtime/src/state/vcs.ts` or a focused orchestration command module +- `apps/server/src/persistence/Services/ProjectionThreads.ts` +- `apps/server/src/persistence/Layers/ProjectionThreads.ts` +- `apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts` +- `apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts` +- A new server worktree lifecycle service and layer +- `apps/server/src/orchestration/Layers/ProviderCommandReactor.ts` +- `apps/server/src/ws.ts` +- Server layer composition and test layers + +Keep the query lightweight. A repository method can load nondeleted rows with worktree paths and compare them using the shared path normalization helper. This avoids introducing a worktree table solely for this feature while still handling legacy path spellings more safely than raw client-side string equality. + +## Existing Deletion Flow + +Do not expand this change into a deletion redesign. Keep the current deletion prompt behavior, but reuse display formatting and server lifecycle primitives where that reduces duplication without changing deletion semantics. + +## Tests + +### Server + +- Preview returns a candidate for one active worktree thread. +- Preview returns no candidate when another active thread shares the path. +- Archived siblings do not prevent a candidate. +- Deleted siblings do not prevent a candidate. +- Different normalized spellings of the same path are treated as one worktree. +- Cleanup removes a worktree when all nondeleted references are archived. +- Cleanup is retained when a reference becomes active after preview. +- Cleanup force-removes a dirty worktree after confirmation. +- Cleanup preserves the branch. +- Cleanup reports an already-missing path without failing the archive. +- Cleanup failures leave the thread archived and return a typed error. +- Unarchive recreates a missing worktree from the retained branch at the retained path. +- Unarchive starts the worktree setup script after recreation. +- Recreation failure leaves the thread archived. +- Concurrent cleanup and unarchive serialize correctly. +- Real archive-to-provider-reactor coverage proves the provider session stops and its projection reaches `stopped`. + +### Web + +- Final active reference prompts for worktree removal. +- A shared active worktree does not prompt. +- Declining archives without cleanup. +- Confirming archives and requests conditional cleanup. +- Archive success plus cleanup failure is reported as a cleanup-only failure. +- Sequential bulk archive prompts only when each worktree reaches its final active reference. + +### Mobile + +- Final active reference displays the platform-appropriate prompt. +- Decline and confirm paths preserve the agreed behavior. +- Cleanup failures do not report the completed archive as failed. +- Unarchive restoration errors are surfaced. + +## Verification + +Run the smallest focused checks for changed packages and files: + +- Focused server tests for projection queries, lifecycle service, provider reactor, and RPC handling. +- Focused contract and client-runtime tests. +- Focused web hook and sidebar tests. +- Focused mobile action tests. +- Targeted formatting, lint, and type checks for affected packages. +- One integrated web verification pass using the `test-t3-app` skill. +- One integrated mobile verification pass using the `test-t3-mobile` skill. + +Do not run the repository-wide test or typecheck suites as a routine local verification step. From 12abbe47dc71eb4f90f6720da1316bca04097234 Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Sun, 26 Jul 2026 12:26:05 +0200 Subject: [PATCH 11/16] feat(tim): import tim-smart/t3code#14 Pass hosted app channel into Vercel web builds Source: https://github.com/tim-smart/t3code/pull/14 Source head: de6966a6784b4703145c20b84fc482703bca4fa2 Source commits: de6966a6784b4703145c20b84fc482703bca4fa2 Imported: complete product delta from the source PR. --- apps/web/vercel.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/vercel.ts b/apps/web/vercel.ts index 784eceffa53..a080da7b499 100644 --- a/apps/web/vercel.ts +++ b/apps/web/vercel.ts @@ -25,7 +25,7 @@ function channelCookie(channel: "latest" | "nightly"): string { export const config: VercelConfig = { buildCommand: - 'vp run --filter @t3tools/web build && node ../../scripts/apply-web-brand-assets.ts --channel "${VITE_HOSTED_APP_CHANNEL:-latest}"', + 'VITE_HOSTED_APP_CHANNEL="${VITE_HOSTED_APP_CHANNEL:-latest}" vp run --filter @t3tools/web build && node ../../scripts/apply-web-brand-assets.ts --channel "${VITE_HOSTED_APP_CHANNEL:-latest}"', git: { deploymentEnabled: false, }, From c04e27459ab991e50c62737bdda75956b64e4e36 Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Sun, 26 Jul 2026 12:26:07 +0200 Subject: [PATCH 12/16] feat(tim): import tim-smart/t3code#15 Allow worktrees to reuse the selected branch Source: https://github.com/tim-smart/t3code/pull/15 Source head: 2d3900ba36c9397dc4fbe879c613a809f6b45384 Source commits: cd60531253fbafc470f5a5ac18d3e44832d3376d,2d3900ba36c9397dc4fbe879c613a809f6b45384 Imported: complete product delta from the source PR. --- apps/server/src/server.test.ts | 220 ++++++++++++++++++ apps/server/src/ws.ts | 38 ++- apps/web/src/components/BranchToolbar.tsx | 6 + .../BranchToolbarBranchSelector.tsx | 98 +++++--- apps/web/src/components/ChatView.tsx | 38 ++- apps/web/src/composerDraftStore.ts | 26 +++ apps/web/src/hooks/useHandleNewThread.ts | 13 +- apps/web/src/lib/chatThreadActions.ts | 1 + packages/contracts/src/orchestration.ts | 3 + 9 files changed, 403 insertions(+), 40 deletions(-) diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index df19d97b724..2d876c39833 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -6941,6 +6941,226 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("checks out the base branch directly when bootstrap reuses it", () => + Effect.gen(function* () { + const dispatchedCommands: Array = []; + const fetchRemote = vi.fn( + (_: Parameters[0]) => Effect.void, + ); + const listRefs = vi.fn((_: Parameters[0]) => + Effect.succeed({ + refs: [ + { + name: "feature/base", + current: false, + isDefault: false, + worktreePath: null, + }, + ], + isRepo: true, + hasPrimaryRemote: true, + nextCursor: null, + totalCount: 1, + }), + ); + const createWorktree = vi.fn( + (_: Parameters[0]) => + Effect.succeed({ + worktree: { + refName: "feature/base", + path: "/tmp/reuse-worktree", + }, + }), + ); + + yield* buildAppUnderTest({ + layers: { + vcsDriver: { + isInsideWorkTree: () => Effect.succeed(true), + }, + gitVcsDriver: { + fetchRemote, + listRefs, + createWorktree, + }, + orchestrationEngine: { + dispatch: (command) => + Effect.sync(() => { + dispatchedCommands.push(command); + return { sequence: dispatchedCommands.length }; + }), + readEvents: () => Stream.empty, + }, + }, + }); + + const createdAt = "2026-01-01T00:00:00.000Z"; + const wsUrl = yield* getWsServerUrl("/ws"); + const response = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-bootstrap-reuse-branch"), + threadId: ThreadId.make("thread-bootstrap-reuse-branch"), + message: { + messageId: MessageId.make("msg-bootstrap-reuse-branch"), + role: "user", + text: "hello", + attachments: [], + }, + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + bootstrap: { + createThread: { + projectId: defaultProjectId, + title: "Bootstrap Thread", + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: "feature/base", + worktreePath: null, + createdAt, + }, + prepareWorktree: { + projectCwd: "/tmp/project", + baseBranch: "feature/base", + branch: "t3code/bootstrap-refName", + startFromOrigin: true, + reuseBaseBranch: true, + }, + }, + createdAt, + }), + ), + ); + + assert.equal(response.sequence, 3); + assert.deepEqual( + dispatchedCommands.map((command) => command.type), + ["thread.create", "thread.meta.update", "thread.turn.start"], + ); + // Reuse wins over the requested new branch and origin refresh. + assert.equal(fetchRemote.mock.calls.length, 0); + assert.equal(listRefs.mock.calls.length, 1); + assert.deepEqual(createWorktree.mock.calls[0]?.[0], { + cwd: "/tmp/project", + refName: "feature/base", + path: null, + }); + const metaUpdate = dispatchedCommands[1]; + assertTrue(metaUpdate?.type === "thread.meta.update"); + if (metaUpdate?.type === "thread.meta.update") { + assert.equal(metaUpdate.branch, "feature/base"); + assert.equal(metaUpdate.worktreePath, "/tmp/reuse-worktree"); + } + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("materializes a reused remote base branch as its derived local branch", () => + Effect.gen(function* () { + const dispatchedCommands: Array = []; + const listRefs = vi.fn((_: Parameters[0]) => + Effect.succeed({ + refs: [ + { + name: "origin/feature/base", + isRemote: true, + remoteName: "origin", + current: false, + isDefault: false, + worktreePath: null, + }, + ], + isRepo: true, + hasPrimaryRemote: true, + nextCursor: null, + totalCount: 1, + }), + ); + const createWorktree = vi.fn( + (_: Parameters[0]) => + Effect.succeed({ + worktree: { + refName: "feature/base", + path: "/tmp/reuse-worktree", + }, + }), + ); + + yield* buildAppUnderTest({ + layers: { + vcsDriver: { + isInsideWorkTree: () => Effect.succeed(true), + }, + gitVcsDriver: { + listRefs, + createWorktree, + }, + orchestrationEngine: { + dispatch: (command) => + Effect.sync(() => { + dispatchedCommands.push(command); + return { sequence: dispatchedCommands.length }; + }), + readEvents: () => Stream.empty, + }, + }, + }); + + const createdAt = "2026-01-01T00:00:00.000Z"; + const wsUrl = yield* getWsServerUrl("/ws"); + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-bootstrap-reuse-remote-branch"), + threadId: ThreadId.make("thread-bootstrap-reuse-remote-branch"), + message: { + messageId: MessageId.make("msg-bootstrap-reuse-remote-branch"), + role: "user", + text: "hello", + attachments: [], + }, + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + bootstrap: { + createThread: { + projectId: defaultProjectId, + title: "Bootstrap Thread", + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: "origin/feature/base", + worktreePath: null, + createdAt, + }, + prepareWorktree: { + projectCwd: "/tmp/project", + baseBranch: "origin/feature/base", + reuseBaseBranch: true, + }, + }, + createdAt, + }), + ), + ); + + assert.deepEqual(createWorktree.mock.calls[0]?.[0], { + cwd: "/tmp/project", + refName: "origin/feature/base", + newRefName: "feature/base", + path: null, + }); + const metaUpdate = dispatchedCommands[1]; + assertTrue(metaUpdate?.type === "thread.meta.update"); + if (metaUpdate?.type === "thread.meta.update") { + assert.equal(metaUpdate.branch, "feature/base"); + } + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("records setup-script failures without aborting bootstrap turn start", () => Effect.gen(function* () { const dispatchedCommands: Array = []; diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 1cc0dccb1cb..5878198e16e 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -117,6 +117,7 @@ import * as PairingGrantStore from "./auth/PairingGrantStore.ts"; import * as SessionStore from "./auth/SessionStore.ts"; import { failEnvironmentAuthInvalid, failEnvironmentInternal } from "./auth/http.ts"; import * as RelayClient from "@t3tools/shared/relayClient"; +import { deriveLocalBranchNameFromRemoteRef } from "@t3tools/shared/git"; const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchCommandError); const nowIso = Effect.map(DateTime.now, DateTime.formatIso); @@ -1000,24 +1001,45 @@ const makeWsRpcLayer = ( } if (bootstrap?.prepareWorktree) { - let worktreeBaseRef = bootstrap.prepareWorktree.baseBranch; - if (bootstrap.prepareWorktree.startFromOrigin) { + const prepareWorktree = bootstrap.prepareWorktree; + let worktreeBaseRef = prepareWorktree.baseBranch; + let worktreeNewRefName = prepareWorktree.branch; + let worktreeBaseRefName: string | undefined = prepareWorktree.baseBranch; + if (prepareWorktree.reuseBaseBranch) { + // Reuse the selected branch: check it out in the worktree + // instead of branching off it. A remote ref cannot be checked + // out directly (it would detach), so materialize it as its + // derived local branch; the branch keeps its own history, so + // skip the gh-merge-base config that new branches record. + const refsResult = yield* gitWorkflow.listRefs({ + cwd: prepareWorktree.projectCwd, + query: prepareWorktree.baseBranch, + includeMatchingRemoteRefs: true, + }); + const selectedRef = refsResult.refs.find( + (ref) => ref.name === prepareWorktree.baseBranch, + ); + worktreeNewRefName = selectedRef?.isRemote + ? deriveLocalBranchNameFromRemoteRef(prepareWorktree.baseBranch) + : undefined; + worktreeBaseRefName = undefined; + } else if (prepareWorktree.startFromOrigin) { yield* gitWorkflow.fetchRemote({ - cwd: bootstrap.prepareWorktree.projectCwd, + cwd: prepareWorktree.projectCwd, remoteName: "origin", }); const resolvedRemoteBase = yield* gitWorkflow.resolveRemoteTrackingCommit({ - cwd: bootstrap.prepareWorktree.projectCwd, - refName: bootstrap.prepareWorktree.baseBranch, + cwd: prepareWorktree.projectCwd, + refName: prepareWorktree.baseBranch, fallbackRemoteName: "origin", }); worktreeBaseRef = resolvedRemoteBase.commitSha; } const worktree = yield* gitWorkflow.createWorktree({ - cwd: bootstrap.prepareWorktree.projectCwd, + cwd: prepareWorktree.projectCwd, refName: worktreeBaseRef, - newRefName: bootstrap.prepareWorktree.branch, - baseRefName: bootstrap.prepareWorktree.baseBranch, + ...(worktreeNewRefName !== undefined ? { newRefName: worktreeNewRefName } : {}), + ...(worktreeBaseRefName !== undefined ? { baseRefName: worktreeBaseRefName } : {}), path: null, }); targetWorktreePath = worktree.worktree.path; diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index d0d0f308633..6ac68d8cf0a 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -51,6 +51,8 @@ interface BranchToolbarProps { onActiveThreadBranchOverrideChange?: (branch: string | null) => void; startFromOrigin: boolean; onStartFromOriginChange: (startFromOrigin: boolean) => void; + reuseBaseBranch: boolean; + onReuseBaseBranchChange: (reuseBaseBranch: boolean) => void; envLocked: boolean; onCheckoutPullRequestRequest?: (reference: string) => void; onComposerFocusRequest?: () => void; @@ -224,6 +226,8 @@ export const BranchToolbar = memo(function BranchToolbar({ onActiveThreadBranchOverrideChange, startFromOrigin, onStartFromOriginChange, + reuseBaseBranch, + onReuseBaseBranchChange, envLocked, onCheckoutPullRequestRequest, onComposerFocusRequest, @@ -355,6 +359,8 @@ export const BranchToolbar = memo(function BranchToolbar({ {...(onActiveThreadBranchOverrideChange ? { onActiveThreadBranchOverrideChange } : {})} startFromOrigin={startFromOrigin} onStartFromOriginChange={onStartFromOriginChange} + reuseBaseBranch={reuseBaseBranch} + onReuseBaseBranchChange={onReuseBaseBranchChange} {...(onCheckoutPullRequestRequest ? { onCheckoutPullRequestRequest } : {})} {...(onComposerFocusRequest ? { onComposerFocusRequest } : {})} /> diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index cccc444b4f2..46431edf5ec 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -72,6 +72,8 @@ interface BranchToolbarBranchSelectorProps { onActiveThreadBranchOverrideChange?: (refName: string | null) => void; startFromOrigin: boolean; onStartFromOriginChange: (startFromOrigin: boolean) => void; + reuseBaseBranch: boolean; + onReuseBaseBranchChange: (reuseBaseBranch: boolean) => void; onCheckoutPullRequestRequest?: (reference: string) => void; onComposerFocusRequest?: () => void; } @@ -84,12 +86,15 @@ function getBranchTriggerLabel(input: { activeWorktreePath: string | null; effectiveEnvMode: "local" | "worktree"; resolvedActiveBranch: string | null; + reuseBaseBranch: boolean; }): string { - const { activeWorktreePath, effectiveEnvMode, resolvedActiveBranch } = input; + const { activeWorktreePath, effectiveEnvMode, resolvedActiveBranch, reuseBaseBranch } = input; if (!resolvedActiveBranch) { return "Select ref"; } - if (effectiveEnvMode === "worktree" && !activeWorktreePath) { + // "From X" signals a new branch will be created off X; a reused branch is + // checked out as-is, so show its plain name. + if (effectiveEnvMode === "worktree" && !activeWorktreePath && !reuseBaseBranch) { return `From ${resolvedActiveBranch}`; } return resolvedActiveBranch; @@ -106,10 +111,13 @@ export function BranchToolbarBranchSelector({ onActiveThreadBranchOverrideChange, startFromOrigin, onStartFromOriginChange, + reuseBaseBranch, + onReuseBaseBranchChange, onCheckoutPullRequestRequest, onComposerFocusRequest, }: BranchToolbarBranchSelectorProps) { const startFromOriginSwitchId = useId(); + const reuseBaseBranchSwitchId = useId(); const stopThreadSession = useAtomCommand(threadEnvironment.stopSession, "thread session stop"); const updateThreadMetadata = useAtomCommand( threadEnvironment.updateMetadata, @@ -587,6 +595,7 @@ export function BranchToolbarBranchSelector({ activeWorktreePath, effectiveEnvMode, resolvedActiveBranch, + reuseBaseBranch, }); // PR pill shown next to the branch selector when the active branch has one. @@ -800,32 +809,65 @@ export function BranchToolbarBranchSelector({
{isSelectingWorktreeBase ? ( - - - - - onStartFromOriginChange(Boolean(checked))} - /> - - } - /> - - Creates the worktree from the latest matching branch on origin instead of your local - branch. - - +
+ + + + + onReuseBaseBranchChange(Boolean(checked))} + /> + + } + /> + + Checks out the selected branch in the worktree instead of creating a new branch + from it. + + + + + + + onStartFromOriginChange(Boolean(checked))} + /> + + } + /> + + {reuseBaseBranch + ? "Not available when reusing the selected branch." + : "Creates the worktree from the latest matching branch on origin instead of your local branch."} + + +
) : null} {branchStatusText ? {branchStatusText} : null}
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 0f8aa1e9f3e..2ab2f2ffdbb 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1287,6 +1287,10 @@ function ChatViewContent(props: ChatViewProps) { pendingServerThreadStartFromOriginByThreadId, setPendingServerThreadStartFromOriginByThreadId, ] = useState>({}); + const [ + pendingServerThreadReuseBaseBranchByThreadId, + setPendingServerThreadReuseBaseBranchByThreadId, + ] = useState>({}); const [lastInvokedScriptByProjectId, setLastInvokedScriptByProjectId] = useLocalStorage( LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, {}, @@ -3821,12 +3825,18 @@ function ChatViewContent(props: ChatViewProps) { ? (pendingServerThreadStartFromOriginByThreadId[activeThread?.id ?? ""] ?? primaryServerSettings.newWorktreesStartFromOrigin) : false; + const reuseBaseBranch = isLocalDraftThread + ? (draftThread?.reuseBaseBranch ?? false) + : canOverrideServerThreadEnvMode + ? (pendingServerThreadReuseBaseBranchByThreadId[activeThread?.id ?? ""] ?? false) + : false; const handleStartNewThread = useCallback(() => { if (!activeProjectRef) return; void handleNewThread(activeProjectRef, { branch: activeThreadBranch, worktreePath: activeWorktreePath, envMode, + reuseBaseBranch, startFromOrigin, }); }, [ @@ -3835,6 +3845,7 @@ function ChatViewContent(props: ChatViewProps) { activeWorktreePath, envMode, handleNewThread, + reuseBaseBranch, startFromOrigin, ]); const sendEnvMode = resolveSendEnvMode({ @@ -4763,8 +4774,12 @@ function ChatViewContent(props: ChatViewProps) { prepareWorktree: { projectCwd: activeProject.workspaceRoot, baseBranch: baseBranchForWorktree, - branch: buildTemporaryWorktreeBranchName(randomHex), - ...(startFromOrigin ? { startFromOrigin: true } : {}), + ...(reuseBaseBranch + ? { reuseBaseBranch: true } + : { + branch: buildTemporaryWorktreeBranchName(randomHex), + ...(startFromOrigin ? { startFromOrigin: true } : {}), + }), }, runSetupScript: true, } @@ -5466,6 +5481,7 @@ function ChatViewContent(props: ChatViewProps) { envMode: mode, newWorktreesStartFromOrigin: primaryServerSettings.newWorktreesStartFromOrigin, }), + reuseBaseBranch: false, ...(mode === "worktree" && draftThread?.worktreePath ? { worktreePath: null } : {}), }); } @@ -5499,6 +5515,22 @@ function ChatViewContent(props: ChatViewProps) { } }; + const onReuseBaseBranchChange = (nextReuseBaseBranch: boolean) => { + if (canOverrideServerThreadEnvMode && activeThread) { + setPendingServerThreadReuseBaseBranchByThreadId((current) => + current[activeThread.id] === nextReuseBaseBranch + ? current + : { ...current, [activeThread.id]: nextReuseBaseBranch }, + ); + return; + } + if (isLocalDraftThread) { + setDraftThreadContext(composerDraftTarget, { + reuseBaseBranch: nextReuseBaseBranch, + }); + } + }; + const onExpandTimelineImage = useCallback((preview: ExpandedImagePreview) => { setExpandedImage(preview); }, []); @@ -5899,6 +5931,8 @@ function ChatViewContent(props: ChatViewProps) { onEnvModeChange={onEnvModeChange} startFromOrigin={startFromOrigin} onStartFromOriginChange={onStartFromOriginChange} + reuseBaseBranch={reuseBaseBranch} + onReuseBaseBranchChange={onReuseBaseBranchChange} {...(canOverrideServerThreadEnvMode ? { effectiveEnvModeOverride: envMode } : {})} diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index d4f38adcacd..da5d6aabff0 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -216,6 +216,7 @@ const PersistedDraftThreadState = Schema.Struct({ worktreePath: Schema.NullOr(Schema.String), envMode: DraftThreadEnvModeSchema, startFromOrigin: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + reuseBaseBranch: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), promotedTo: Schema.optionalKey( Schema.NullOr( Schema.Struct({ @@ -295,6 +296,7 @@ export interface DraftSessionState { worktreePath: string | null; envMode: DraftThreadEnvMode; startFromOrigin: boolean; + reuseBaseBranch: boolean; promotedTo?: ScopedThreadRef | null; } @@ -357,6 +359,7 @@ interface ComposerDraftStoreState { createdAt?: string; envMode?: DraftThreadEnvMode; startFromOrigin?: boolean; + reuseBaseBranch?: boolean; runtimeMode?: RuntimeMode; interactionMode?: ProviderInteractionMode; }, @@ -372,6 +375,7 @@ interface ComposerDraftStoreState { createdAt?: string; envMode?: DraftThreadEnvMode; startFromOrigin?: boolean; + reuseBaseBranch?: boolean; runtimeMode?: RuntimeMode; interactionMode?: ProviderInteractionMode; }, @@ -386,6 +390,7 @@ interface ComposerDraftStoreState { createdAt?: string; envMode?: DraftThreadEnvMode; startFromOrigin?: boolean; + reuseBaseBranch?: boolean; runtimeMode?: RuntimeMode; interactionMode?: ProviderInteractionMode; }, @@ -1329,6 +1334,7 @@ function createDraftThreadState( createdAt?: string; envMode?: DraftThreadEnvMode; startFromOrigin?: boolean; + reuseBaseBranch?: boolean; runtimeMode?: RuntimeMode; interactionMode?: ProviderInteractionMode; }, @@ -1355,6 +1361,12 @@ function createDraftThreadState( ? false : (existingThread?.startFromOrigin ?? false) : options.startFromOrigin; + const nextReuseBaseBranch = + options?.reuseBaseBranch === undefined + ? projectChanged + ? false + : (existingThread?.reuseBaseBranch ?? false) + : options.reuseBaseBranch; return { threadId, environmentId: projectRef.environmentId, @@ -1374,6 +1386,7 @@ function createDraftThreadState( ? "local" : (existingThread?.envMode ?? "local")), startFromOrigin: nextStartFromOrigin, + reuseBaseBranch: nextReuseBaseBranch, promotedTo: null, }; } @@ -1406,6 +1419,7 @@ function draftThreadsEqual(left: DraftThreadState | undefined, right: DraftThrea left.worktreePath === right.worktreePath && left.envMode === right.envMode && left.startFromOrigin === right.startFromOrigin && + left.reuseBaseBranch === right.reuseBaseBranch && scopedThreadRefsEqual(left.promotedTo, right.promotedTo) ); } @@ -1501,6 +1515,7 @@ function normalizePersistedDraftThreads( const branch = candidateDraftThread.branch; const worktreePath = candidateDraftThread.worktreePath; const startFromOrigin = candidateDraftThread.startFromOrigin === true; + const reuseBaseBranch = candidateDraftThread.reuseBaseBranch === true; const normalizedWorktreePath = typeof worktreePath === "string" ? worktreePath : null; const promotedToCandidate = candidateDraftThread.promotedTo; const promotedToRecord = @@ -1549,6 +1564,7 @@ function normalizePersistedDraftThreads( worktreePath: normalizedWorktreePath, envMode: normalizeDraftThreadEnvMode(candidateDraftThread.envMode, normalizedWorktreePath), startFromOrigin, + reuseBaseBranch, promotedTo, }; } @@ -1595,6 +1611,7 @@ function normalizePersistedDraftThreads( worktreePath: null, envMode: "local", startFromOrigin: false, + reuseBaseBranch: false, promotedTo: null, }; } else if ( @@ -2166,6 +2183,7 @@ function toHydratedDraftThreadState( worktreePath: persistedDraftThread.worktreePath, envMode: persistedDraftThread.envMode, startFromOrigin: persistedDraftThread.startFromOrigin, + reuseBaseBranch: persistedDraftThread.reuseBaseBranch, promotedTo: persistedDraftThread.promotedTo ? scopeThreadRef( persistedDraftThread.promotedTo.environmentId as EnvironmentId, @@ -2357,6 +2375,12 @@ const composerDraftStore = create()( ? false : existing.startFromOrigin : options.startFromOrigin; + const nextReuseBaseBranch = + options.reuseBaseBranch === undefined + ? projectChanged + ? false + : existing.reuseBaseBranch + : options.reuseBaseBranch; const nextDraftThread: DraftThreadState = { threadId: existing.threadId, environmentId: nextProjectRef.environmentId, @@ -2378,6 +2402,7 @@ const composerDraftStore = create()( ? "local" : (existing.envMode ?? "local")), startFromOrigin: nextStartFromOrigin, + reuseBaseBranch: nextReuseBaseBranch, promotedTo: existing.promotedTo ?? null, }; const isUnchanged = @@ -2391,6 +2416,7 @@ const composerDraftStore = create()( nextDraftThread.worktreePath === existing.worktreePath && nextDraftThread.envMode === existing.envMode && nextDraftThread.startFromOrigin === existing.startFromOrigin && + nextDraftThread.reuseBaseBranch === existing.reuseBaseBranch && scopedThreadRefsEqual(nextDraftThread.promotedTo, existing.promotedTo); if (isUnchanged) { return state; diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index 2479d6ba02f..3bfa6287ec1 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -50,6 +50,7 @@ export function useNewThreadHandler() { worktreePath?: string | null; envMode?: DraftThreadEnvMode; startFromOrigin?: boolean; + reuseBaseBranch?: boolean; replace?: boolean; }, ): Promise => { @@ -112,6 +113,7 @@ export function useNewThreadHandler() { const hasWorktreePathOption = options?.worktreePath !== undefined; const hasEnvModeOption = options?.envMode !== undefined; const hasStartFromOriginOption = options?.startFromOrigin !== undefined; + const hasReuseBaseBranchOption = options?.reuseBaseBranch !== undefined; const storedDraftThread = getDraftSessionByLogicalProjectKey(logicalProjectKey); const storedDraftThreadRef = storedDraftThread ? scopeThreadRef(storedDraftThread.environmentId, storedDraftThread.threadId) @@ -137,7 +139,8 @@ export function useNewThreadHandler() { hasBranchOption || hasWorktreePathOption || hasEnvModeOption || - hasStartFromOriginOption; + hasStartFromOriginOption || + hasReuseBaseBranchOption; // Resurrecting a stored draft must not resurrect its stale context: // explicit workspace options win outright; otherwise the env context // resets to the configured defaults so drafts seeded before a @@ -153,6 +156,7 @@ export function useNewThreadHandler() { ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}), ...(hasEnvModeOption ? { envMode: options?.envMode } : {}), ...(hasStartFromOriginOption ? { startFromOrigin: options?.startFromOrigin } : {}), + ...(hasReuseBaseBranchOption ? { reuseBaseBranch: options?.reuseBaseBranch } : {}), } : isDraftAlreadyOpen ? null @@ -164,6 +168,7 @@ export function useNewThreadHandler() { envMode: defaultEnvMode, newWorktreesStartFromOrigin: primaryServerSettings.newWorktreesStartFromOrigin, }), + reuseBaseBranch: false, }; if (workspaceContext) { setDraftThreadContext(reusableStoredDraftThread.draftId, { @@ -220,13 +225,15 @@ export function useNewThreadHandler() { hasBranchOption || hasWorktreePathOption || hasEnvModeOption || - hasStartFromOriginOption + hasStartFromOriginOption || + hasReuseBaseBranchOption ) { setDraftThreadContext(currentRouteTarget.draftId, { ...(hasBranchOption ? { branch: options?.branch ?? null } : {}), ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}), ...(hasEnvModeOption ? { envMode: options?.envMode } : {}), ...(hasStartFromOriginOption ? { startFromOrigin: options?.startFromOrigin } : {}), + ...(hasReuseBaseBranchOption ? { reuseBaseBranch: options?.reuseBaseBranch } : {}), }); } setLogicalProjectDraftThreadId(logicalProjectKey, projectRef, currentRouteTarget.draftId, { @@ -238,6 +245,7 @@ export function useNewThreadHandler() { ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}), ...(hasEnvModeOption ? { envMode: options?.envMode } : {}), ...(hasStartFromOriginOption ? { startFromOrigin: options?.startFromOrigin } : {}), + ...(hasReuseBaseBranchOption ? { reuseBaseBranch: options?.reuseBaseBranch } : {}), }); return Promise.resolve(); } @@ -259,6 +267,7 @@ export function useNewThreadHandler() { envMode: initialEnvMode, newWorktreesStartFromOrigin: primaryServerSettings.newWorktreesStartFromOrigin, }), + reuseBaseBranch: options?.reuseBaseBranch ?? false, runtimeMode: carryRuntimeMode ?? DEFAULT_RUNTIME_MODE, ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}), }); diff --git a/apps/web/src/lib/chatThreadActions.ts b/apps/web/src/lib/chatThreadActions.ts index 4a150d617ea..f40da442b13 100644 --- a/apps/web/src/lib/chatThreadActions.ts +++ b/apps/web/src/lib/chatThreadActions.ts @@ -15,6 +15,7 @@ interface NewThreadHandler { worktreePath?: string | null; envMode?: DraftThreadEnvMode; startFromOrigin?: boolean; + reuseBaseBranch?: boolean; }, ): Promise; } diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 84b7a8fa07f..3fc2fb3e151 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -655,6 +655,9 @@ const ThreadTurnStartBootstrapPrepareWorktree = Schema.Struct({ baseBranch: TrimmedNonEmptyString, branch: Schema.optional(TrimmedNonEmptyString), startFromOrigin: Schema.optional(Schema.Boolean), + // Check out `baseBranch` in the worktree instead of creating a new branch + // from it. Wins over `branch`/`startFromOrigin` when set. + reuseBaseBranch: Schema.optional(Schema.Boolean), }); const ThreadTurnStartBootstrap = Schema.Struct({ From 626628cfb29daba4fc2c006abc939c027ad9261f Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Sun, 26 Jul 2026 12:26:08 +0200 Subject: [PATCH 13/16] feat(tim): import tim-smart/t3code#16 Add optional worktree removal confirmation Source: https://github.com/tim-smart/t3code/pull/16 Source head: c3f509fe8f690b704bb34692d9c132c0644db777 Source commits: 76f063e983ca3c39b20f79d8ea83783ab034251a,c3f509fe8f690b704bb34692d9c132c0644db777 Imported: complete product delta from the source PR. --- .../settings/DesktopClientSettings.test.ts | 1 + .../src/features/home/useThreadListActions.ts | 1 + .../components/settings/SettingsPanels.tsx | 31 ++++++++++++++++ apps/web/src/hooks/useThreadActions.test.ts | 35 ++++++++++++++++++- apps/web/src/hooks/useThreadActions.ts | 23 ++++++++++-- .../src/state/worktreeCleanup.test.ts | 14 ++++++++ .../src/state/worktreeCleanup.ts | 29 +++++++++------ packages/contracts/src/settings.test.ts | 12 +++++++ packages/contracts/src/settings.ts | 2 ++ 9 files changed, 134 insertions(+), 14 deletions(-) diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index bc1a06e7c34..d793f95ddd1 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -16,6 +16,7 @@ const clientSettings: ClientSettings = { autoOpenPlanSidebar: false, confirmThreadArchive: true, confirmThreadDelete: false, + confirmWorktreeRemoval: true, dismissedProviderUpdateNotificationKeys: [], diffIgnoreWhitespace: true, favorites: [], diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index 7da189a5269..7f7c48ee7b3 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -201,6 +201,7 @@ export function useThreadListActions(): { }); return preview._tag === "Success" ? preview.value.candidate : null; }, + removalPolicy: "confirm", confirmRemoval: ({ displayWorktreePath }) => presentWorktreeCleanupConfirmation({ isIos: process.env.EXPO_OS === "ios", diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index fa7c7299667..5ec86368c15 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -445,6 +445,9 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.confirmThreadDelete !== DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete ? ["Delete confirmation"] : []), + ...(settings.confirmWorktreeRemoval !== DEFAULT_UNIFIED_SETTINGS.confirmWorktreeRemoval + ? ["Worktree remove confirmation"] + : []), ...(isGitWritingModelDirty ? ["Git writing model"] : []), ], [ @@ -452,6 +455,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.autoOpenPlanSidebar, settings.confirmThreadArchive, settings.confirmThreadDelete, + settings.confirmWorktreeRemoval, settings.addProjectBaseDirectory, settings.defaultThreadEnvMode, settings.newWorktreesStartFromOrigin, @@ -495,6 +499,7 @@ export function useSettingsRestore(onRestored?: () => void) { addProjectBaseDirectory: DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory, confirmThreadArchive: DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive, confirmThreadDelete: DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete, + confirmWorktreeRemoval: DEFAULT_UNIFIED_SETTINGS.confirmWorktreeRemoval, textGenerationModelSelection: DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection, }); onRestored?.(); @@ -998,6 +1003,32 @@ export function GeneralSettingsPanel() { } /> + + updateSettings({ + confirmWorktreeRemoval: DEFAULT_UNIFIED_SETTINGS.confirmWorktreeRemoval, + }) + } + /> + ) : null + } + control={ + + updateSettings({ confirmWorktreeRemoval: Boolean(checked) }) + } + aria-label="Confirm worktree removal" + /> + } + /> + { expect(deletedKeySnapshots).toEqual([[], [scopedThreadKey(targets[0])]]); }); }); + +describe("getWorktreeRemovalAction", () => { + it("asks before removing an orphaned worktree when confirmation is enabled", () => { + expect( + getWorktreeRemovalAction({ + canRemoveWorktree: true, + confirmWorktreeRemoval: true, + }), + ).toBe("confirm"); + }); + + it("removes an orphaned worktree directly when confirmation is disabled", () => { + expect( + getWorktreeRemovalAction({ + canRemoveWorktree: true, + confirmWorktreeRemoval: false, + }), + ).toBe("remove"); + }); + + it("does not remove a worktree that is still in use", () => { + expect( + getWorktreeRemovalAction({ + canRemoveWorktree: false, + confirmWorktreeRemoval: false, + }), + ).toBe("skip"); + }); +}); diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 46cf3329ba4..724b23b9d45 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -127,6 +127,17 @@ export async function deleteThreadTargetsSequentially< return null; } +export function getWorktreeRemovalAction({ + canRemoveWorktree, + confirmWorktreeRemoval, +}: { + canRemoveWorktree: boolean; + confirmWorktreeRemoval: boolean; +}): "skip" | "confirm" | "remove" { + if (!canRemoveWorktree) return "skip"; + return confirmWorktreeRemoval ? "confirm" : "remove"; +} + export function useThreadActions() { const closeTerminal = useAtomCommand(terminalEnvironment.close); const archiveThreadMutation = useAtomCommand(threadEnvironment.archive, { @@ -168,6 +179,7 @@ export function useThreadActions() { }); const sidebarThreadSortOrder = useClientSettings((settings) => settings.sidebarThreadSortOrder); const confirmThreadDelete = useClientSettings((settings) => settings.confirmThreadDelete); + const confirmWorktreeRemoval = useClientSettings((settings) => settings.confirmWorktreeRemoval); const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearDraftThread); const clearProjectDraftThreadById = useComposerDraftStore( (store) => store.clearProjectDraftThreadById, @@ -229,6 +241,7 @@ export function useThreadActions() { }); return previewResult._tag === "Success" ? previewResult.value.candidate : null; }, + removalPolicy: confirmWorktreeRemoval ? "confirm" : "remove", confirmRemoval: localApi ? async ({ displayWorktreePath }) => { const confirmationResult = await settlePromise(() => @@ -314,6 +327,7 @@ export function useThreadActions() { [ archiveThreadMutation, cleanupThreadWorktree, + confirmWorktreeRemoval, getCurrentRouteThreadRef, previewWorktreeCleanup, resolveThreadTarget, @@ -379,8 +393,12 @@ export function useThreadActions() { : null; const canDeleteWorktree = orphanedWorktreePath !== null && threadProject !== null; const localApi = readLocalApi(); - let shouldDeleteWorktree = false; - if (canDeleteWorktree && localApi) { + const worktreeRemovalAction = getWorktreeRemovalAction({ + canRemoveWorktree: canDeleteWorktree, + confirmWorktreeRemoval, + }); + let shouldDeleteWorktree = worktreeRemovalAction === "remove"; + if (worktreeRemovalAction === "confirm" && localApi) { const confirmationResult = await settlePromise(() => localApi.dialogs.confirm( [ @@ -521,6 +539,7 @@ export function useThreadActions() { clearProjectDraftThreadById, clearTerminalUiState, closeTerminal, + confirmWorktreeRemoval, deleteThreadMutation, getCurrentRouteThreadRef, refreshVcsStatus, diff --git a/packages/client-runtime/src/state/worktreeCleanup.test.ts b/packages/client-runtime/src/state/worktreeCleanup.test.ts index 487b4a28957..d9a3f29f28e 100644 --- a/packages/client-runtime/src/state/worktreeCleanup.test.ts +++ b/packages/client-runtime/src/state/worktreeCleanup.test.ts @@ -15,6 +15,7 @@ const candidate: ArchiveWorktreeCleanupCandidate = { function makeFlow(overrides?: { readonly previewCandidate?: () => Promise; + readonly removalPolicy?: "confirm" | "remove"; readonly confirmation?: WorktreeCleanupConfirmation<{ readonly _tag: "Failure" }> | null; readonly archiveSucceeds?: boolean; readonly cleanupOutcome?: WorktreeCleanupOutcome; @@ -35,6 +36,7 @@ function makeFlow(overrides?: { const run = () => runArchiveWithWorktreeCleanup({ previewCandidate: overrides?.previewCandidate ?? (async () => candidate), + removalPolicy: overrides?.removalPolicy ?? "confirm", confirmRemoval, archive, isArchiveSuccess: (result) => result._tag === "Success", @@ -70,6 +72,17 @@ describe("runArchiveWithWorktreeCleanup", () => { expect(flow.cleanup).not.toHaveBeenCalled(); }); + it("removes the final active worktree without prompting when confirmation is disabled", async () => { + const flow = makeFlow({ removalPolicy: "remove", confirmation: null }); + const outcome = await flow.run(); + expect(outcome).toEqual({ kind: "archived", result: { _tag: "Success" } }); + expect(flow.archive).toHaveBeenCalledTimes(1); + expect(flow.cleanup).toHaveBeenCalledTimes(1); + expect(flow.archive.mock.invocationCallOrder[0]).toBeLessThan( + flow.cleanup.mock.invocationCallOrder[0] ?? 0, + ); + }); + it("archives without cleanup when the user declines", async () => { const flow = makeFlow({ confirmation: { kind: "declined" } }); const outcome = await flow.run(); @@ -154,6 +167,7 @@ describe("runArchiveWithWorktreeCleanup", () => { ); return shared ? null : target; }, + removalPolicy: "confirm", confirmRemoval: async ({ displayWorktreePath }) => { prompts.push(displayWorktreePath); return { kind: "declined" }; diff --git a/packages/client-runtime/src/state/worktreeCleanup.ts b/packages/client-runtime/src/state/worktreeCleanup.ts index 2dfa6b7bf7b..cd663e43c69 100644 --- a/packages/client-runtime/src/state/worktreeCleanup.ts +++ b/packages/client-runtime/src/state/worktreeCleanup.ts @@ -2,11 +2,10 @@ * Shared archive-time worktree cleanup flow. * * The server owns the safety decision (preview + conditional cleanup RPCs); - * this module owns the client sequencing shared by web and mobile: prompt - * only when the server reports a candidate and a confirmation surface - * exists, archive regardless of the answer, run cleanup only after a - * confirmed archive, and report cleanup problems without failing the - * archive itself. + * this module owns the client sequencing shared by web and mobile: act only + * when the server reports a candidate, optionally confirm based on client + * policy, archive before cleanup, and report cleanup problems without + * failing the archive itself. */ import type { WorktreeCleanupStatus } from "@t3tools/contracts"; @@ -41,10 +40,14 @@ export type ArchiveWithWorktreeCleanupResult = | { readonly kind: "archived"; readonly result: TArchive } | { readonly kind: "aborted"; readonly result: TAbort }; +export type WorktreeRemovalPolicy = "confirm" | "remove"; + export async function runArchiveWithWorktreeCleanup(input: { /** Server-authoritative preview; null when ineligible or when the preview failed. */ readonly previewCandidate: () => Promise; - /** Confirmation surface, or null when none is available (no prompt, no cleanup). */ + /** Whether an eligible worktree is confirmed first or removed automatically. */ + readonly removalPolicy: WorktreeRemovalPolicy; + /** Confirmation surface, or null when none is available. */ readonly confirmRemoval: | ((prompt: { readonly candidate: ArchiveWorktreeCleanupCandidate; @@ -60,13 +63,17 @@ export async function runArchiveWithWorktreeCleanup(in const candidate = await input.previewCandidate(); let shouldCleanup = false; let displayWorktreePath: string | null = null; - if (candidate && input.confirmRemoval) { + if (candidate) { displayWorktreePath = formatWorktreePathForDisplay(candidate.worktreePath); - const confirmation = await input.confirmRemoval({ candidate, displayWorktreePath }); - if (confirmation.kind === "aborted") { - return { kind: "aborted", result: confirmation.result }; + if (input.removalPolicy === "remove") { + shouldCleanup = true; + } else if (input.confirmRemoval) { + const confirmation = await input.confirmRemoval({ candidate, displayWorktreePath }); + if (confirmation.kind === "aborted") { + return { kind: "aborted", result: confirmation.result }; + } + shouldCleanup = confirmation.kind === "confirmed"; } - shouldCleanup = confirmation.kind === "confirmed"; } const archiveResult = await input.archive(); diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 78a773a64f4..1c0228c2288 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -39,6 +39,18 @@ describe("ClientSettings word wrap", () => { }); }); +describe("ClientSettings worktree removal confirmation", () => { + it("defaults confirmation on for existing settings", () => { + expect(decodeClientSettings({}).confirmWorktreeRemoval).toBe(true); + }); + + it("accepts confirmation updates", () => { + expect( + decodeClientSettingsPatch({ confirmWorktreeRemoval: false }).confirmWorktreeRemoval, + ).toBe(false); + }); +}); + describe("ClientSettings glass opacity", () => { it("defaults to a readable translucent surface", () => { expect(decodeClientSettings({}).glassOpacity).toBe(80); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index e582031e95b..4c11ac22db8 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -64,6 +64,7 @@ export const ClientSettingsSchema = Schema.Struct({ autoOpenPlanSidebar: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + confirmWorktreeRemoval: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), dismissedProviderUpdateNotificationKeys: Schema.Array(TrimmedNonEmptyString).pipe( Schema.withDecodingDefault(Effect.succeed([])), ), @@ -577,6 +578,7 @@ export const ClientSettingsPatch = Schema.Struct({ autoOpenPlanSidebar: Schema.optionalKey(Schema.Boolean), confirmThreadArchive: Schema.optionalKey(Schema.Boolean), confirmThreadDelete: Schema.optionalKey(Schema.Boolean), + confirmWorktreeRemoval: Schema.optionalKey(Schema.Boolean), diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean), glassOpacity: Schema.optionalKey(GlassOpacity), favorites: Schema.optionalKey( From 50515b7d715b2abe22b9deccde1d1567f49e4f26 Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Sun, 26 Jul 2026 12:26:10 +0200 Subject: [PATCH 14/16] feat(tim): import tim-smart/t3code#17 Stop retrying unavailable thread subscriptions Source: https://github.com/tim-smart/t3code/pull/17 Source head: 1359af8ba0b146e3d49f89b72c250f681e86199d Source commits: 1359af8ba0b146e3d49f89b72c250f681e86199d Imported: complete product delta from the source PR. --- .../checkpointing/CheckpointDiffQuery.test.ts | 5 + .../Layers/OrchestrationEngine.test.ts | 1 + .../Layers/ProjectionSnapshotQuery.test.ts | 116 ++++++++++++++++++ .../Layers/ProjectionSnapshotQuery.ts | 30 +++++ .../Services/ProjectionSnapshotQuery.ts | 13 ++ .../project/ProjectSetupScriptRunner.test.ts | 1 + .../Layers/ProviderSessionReaper.test.ts | 1 + apps/server/src/server.test.ts | 52 ++++++++ apps/server/src/serverRuntimeStartup.test.ts | 4 + apps/server/src/ws.ts | 24 +++- packages/client-runtime/src/rpc/client.ts | 14 ++- .../src/state/threads-sync.test.ts | 79 ++++++++++++ packages/client-runtime/src/state/threads.ts | 35 +++++- packages/contracts/src/orchestration.ts | 15 +++ 14 files changed, 386 insertions(+), 4 deletions(-) diff --git a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts index bb124d03c98..42e3b7d1624 100644 --- a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts +++ b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts @@ -109,6 +109,7 @@ describe("CheckpointDiffQuery.layer", () => { getSessionStopContextById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), getThreadDetailSnapshot: () => Effect.succeed(Option.none()), + getThreadLifecycleById: () => Effect.succeed(Option.none()), }), ), ); @@ -203,6 +204,7 @@ describe("CheckpointDiffQuery.layer", () => { getSessionStopContextById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), getThreadDetailSnapshot: () => Effect.succeed(Option.none()), + getThreadLifecycleById: () => Effect.succeed(Option.none()), }), ), ); @@ -287,6 +289,7 @@ describe("CheckpointDiffQuery.layer", () => { getSessionStopContextById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), getThreadDetailSnapshot: () => Effect.succeed(Option.none()), + getThreadLifecycleById: () => Effect.succeed(Option.none()), }), ), ); @@ -356,6 +359,7 @@ describe("CheckpointDiffQuery.layer", () => { getSessionStopContextById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), getThreadDetailSnapshot: () => Effect.succeed(Option.none()), + getThreadLifecycleById: () => Effect.succeed(Option.none()), }), ), ); @@ -410,6 +414,7 @@ describe("CheckpointDiffQuery.layer", () => { getSessionStopContextById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), getThreadDetailSnapshot: () => Effect.succeed(Option.none()), + getThreadLifecycleById: () => Effect.succeed(Option.none()), }), ), ); diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 5623128c16d..089b4e0e3a0 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -204,6 +204,7 @@ describe("OrchestrationEngine", () => { getSessionStopContextById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), getThreadDetailSnapshot: () => Effect.succeed(Option.none()), + getThreadLifecycleById: () => Effect.succeed(Option.none()), }), ), Layer.provide( diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index fbb8aa5082b..a05ada69b3d 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -11,6 +11,7 @@ import { assert, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; @@ -694,6 +695,121 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { }), ); + it.effect("reads thread lifecycle markers regardless of deleted/archived state", () => + Effect.gen(function* () { + const snapshotQuery = yield* ProjectionSnapshotQuery; + const sql = yield* SqlClient.SqlClient; + + yield* sql`DELETE FROM projection_threads`; + + yield* sql` + INSERT INTO projection_threads ( + thread_id, + project_id, + title, + model_selection_json, + runtime_mode, + interaction_mode, + branch, + worktree_path, + latest_turn_id, + latest_user_message_at, + pending_approval_count, + pending_user_input_count, + has_actionable_proposed_plan, + created_at, + updated_at, + archived_at, + deleted_at + ) + VALUES + ( + 'thread-lifecycle-active', + 'project-lifecycle-test', + 'Active Thread', + '{"provider":"codex","model":"gpt-5-codex"}', + 'full-access', + 'default', + NULL, + NULL, + NULL, + NULL, + 0, + 0, + 0, + '2026-04-06T00:00:02.000Z', + '2026-04-06T00:00:03.000Z', + NULL, + NULL + ), + ( + 'thread-lifecycle-archived', + 'project-lifecycle-test', + 'Archived Thread', + '{"provider":"codex","model":"gpt-5-codex"}', + 'full-access', + 'default', + NULL, + NULL, + NULL, + NULL, + 0, + 0, + 0, + '2026-04-06T00:00:04.000Z', + '2026-04-06T00:00:05.000Z', + '2026-04-06T00:00:06.000Z', + NULL + ), + ( + 'thread-lifecycle-deleted', + 'project-lifecycle-test', + 'Deleted Thread', + '{"provider":"codex","model":"gpt-5-codex"}', + 'full-access', + 'default', + NULL, + NULL, + NULL, + NULL, + 0, + 0, + 0, + '2026-04-06T00:00:07.000Z', + '2026-04-06T00:00:08.000Z', + NULL, + '2026-04-06T00:00:09.000Z' + ) + `; + + const active = yield* snapshotQuery.getThreadLifecycleById( + ThreadId.make("thread-lifecycle-active"), + ); + assert.deepEqual(active, Option.some({ deletedAt: null, archivedAt: null })); + + const archived = yield* snapshotQuery.getThreadLifecycleById( + ThreadId.make("thread-lifecycle-archived"), + ); + assert.deepEqual( + archived, + Option.some({ deletedAt: null, archivedAt: "2026-04-06T00:00:06.000Z" }), + ); + + const deleted = yield* snapshotQuery.getThreadLifecycleById( + ThreadId.make("thread-lifecycle-deleted"), + ); + assert.deepEqual( + deleted, + Option.some({ deletedAt: "2026-04-06T00:00:09.000Z", archivedAt: null }), + ); + + const missing = yield* snapshotQuery.getThreadLifecycleById( + ThreadId.make("thread-lifecycle-missing"), + ); + assert.deepEqual(missing, Option.none()); + }), + ); + it.effect("keeps settled threads in the shell snapshot with non-null settlement fields", () => Effect.gen(function* () { const snapshotQuery = yield* ProjectionSnapshotQuery; diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 0cd3853bfb6..41e52e5bca9 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -798,6 +798,23 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); + const getThreadLifecycleRowById = SqlSchema.findOneOption({ + Request: ThreadIdLookupInput, + Result: Schema.Struct({ + deletedAt: Schema.NullOr(IsoDateTime), + archivedAt: Schema.NullOr(IsoDateTime), + }), + execute: ({ threadId }) => + sql` + SELECT + deleted_at AS "deletedAt", + archived_at AS "archivedAt" + FROM projection_threads + WHERE thread_id = ${threadId} + LIMIT 1 + `, + }); + const listThreadMessageRowsByThread = SqlSchema.findAll({ Request: ThreadIdLookupInput, Result: ProjectionThreadMessageDbRowSchema, @@ -2149,6 +2166,18 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ), ); + const getThreadLifecycleById: ProjectionSnapshotQueryShape["getThreadLifecycleById"] = ( + threadId, + ) => + getThreadLifecycleRowById({ threadId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadLifecycleById:query", + "ProjectionSnapshotQuery.getThreadLifecycleById:decodeRow", + ), + ), + ); + return { getCommandReadModel, getSnapshot, @@ -2164,6 +2193,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { getSessionStopContextById, getThreadShellById, getThreadDetailById, + getThreadLifecycleById, getThreadDetailSnapshot, } satisfies ProjectionSnapshotQueryShape; }); diff --git a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts index 8daea6d079d..06bef064d33 100644 --- a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts @@ -186,6 +186,19 @@ export interface ProjectionSnapshotQueryShape { readonly getThreadDetailSnapshot: ( threadId: ThreadId, ) => Effect.Effect, ProjectionRepositoryError>; + + /** + * Read a thread's lifecycle markers regardless of its deleted/archived + * state. Lets callers that got no active row distinguish a thread that was + * deleted or archived (permanent) from one whose projection row does not + * exist (possibly not projected yet). + */ + readonly getThreadLifecycleById: ( + threadId: ThreadId, + ) => Effect.Effect< + Option.Option<{ readonly deletedAt: string | null; readonly archivedAt: string | null }>, + ProjectionRepositoryError + >; } /** diff --git a/apps/server/src/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts index b1a8174f3ef..1d94f1a0025 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts @@ -45,6 +45,7 @@ const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) => getSessionStopContextById: () => Effect.die("unused"), getThreadDetailById: () => Effect.die("unused"), getThreadDetailSnapshot: () => Effect.die("unused"), + getThreadLifecycleById: () => Effect.die("unused"), }); const makeTerminalManagerLayer = ( diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts index c75d2729a7c..621503a7e65 100644 --- a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts @@ -213,6 +213,7 @@ describe("ProviderSessionReaper", () => { ), getThreadDetailById: () => Effect.die("unused"), getThreadDetailSnapshot: () => Effect.die("unused"), + getThreadLifecycleById: () => Effect.die("unused"), }), ), Layer.provideMerge(NodeServices.layer), diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 2d876c39833..4db56fb341e 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -722,6 +722,7 @@ const buildAppUnderTest = (options?: { getThreadShellById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), getThreadDetailSnapshot: () => Effect.succeed(Option.none()), + getThreadLifecycleById: () => Effect.succeed(Option.none()), getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), @@ -5688,6 +5689,57 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect( + "fails a thread subscription as permanently deleted when the thread row is deleted", + () => + Effect.gen(function* () { + yield* buildAppUnderTest({ + layers: { + projectionSnapshotQuery: { + getThreadLifecycleById: () => + Effect.succeed( + Option.some({ deletedAt: "2026-01-01T00:00:01.000Z", archivedAt: null }), + ), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const result = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.subscribeThread]({ + threadId: defaultThreadId, + }).pipe(Stream.runCollect), + ).pipe(Effect.result), + ); + + assertTrue(result._tag === "Failure"); + assertTrue(result.failure._tag === "OrchestrationGetSnapshotError"); + assert.equal(result.failure.reason, "thread-deleted"); + assert.equal(result.failure.message, `Thread ${defaultThreadId} was deleted`); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("fails a thread subscription as retriable when no thread row exists yet", () => + Effect.gen(function* () { + yield* buildAppUnderTest({}); + + const wsUrl = yield* getWsServerUrl("/ws"); + const result = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.subscribeThread]({ + threadId: defaultThreadId, + }).pipe(Stream.runCollect), + ).pipe(Effect.result), + ); + + assertTrue(result._tag === "Failure"); + assertTrue(result.failure._tag === "OrchestrationGetSnapshotError"); + assert.equal(result.failure.reason, "thread-missing"); + assert.equal(result.failure.message, `Thread ${defaultThreadId} was not found`); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("buffers shell events published while the fallback snapshot loads", () => Effect.gen(function* () { const liveEvents = yield* PubSub.unbounded(); diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index a86ef0cea03..4f50e6cfa21 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -98,6 +98,7 @@ it.effect("launchStartupHeartbeat does not block the caller while counts are loa getThreadShellById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), getThreadDetailSnapshot: () => Effect.succeed(Option.none()), + getThreadLifecycleById: () => Effect.succeed(Option.none()), }), Effect.provideService(AnalyticsService.AnalyticsService, { record: () => Effect.void, @@ -162,6 +163,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa getThreadShellById: () => Effect.die("unused"), getThreadDetailById: () => Effect.die("unused"), getThreadDetailSnapshot: () => Effect.die("unused"), + getThreadLifecycleById: () => Effect.die("unused"), }), Effect.provideService(OrchestrationEngine.OrchestrationEngineService, { readEvents: () => Stream.empty, @@ -207,6 +209,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when getThreadShellById: () => Effect.die("unused"), getThreadDetailById: () => Effect.die("unused"), getThreadDetailSnapshot: () => Effect.die("unused"), + getThreadLifecycleById: () => Effect.die("unused"), }), Effect.provideService(OrchestrationEngine.OrchestrationEngineService, { readEvents: () => Stream.empty, @@ -258,6 +261,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa getThreadShellById: () => Effect.die("unused"), getThreadDetailById: () => Effect.die("unused"), getThreadDetailSnapshot: () => Effect.die("unused"), + getThreadLifecycleById: () => Effect.die("unused"), }), Effect.provideService(OrchestrationEngine.OrchestrationEngineService, { readEvents: () => Stream.empty, diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 5878198e16e..51b20adbb10 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1474,9 +1474,29 @@ const makeWsRpcLayer = ( ); if (Option.isNone(snapshot)) { + // Distinguish permanently unavailable threads from a row that + // may not be projected yet, so clients can stop resubscribing + // to deleted/archived threads instead of retrying forever. + const lifecycle = yield* projectionSnapshotQuery + .getThreadLifecycleById(input.threadId) + .pipe(Effect.orElseSucceed(() => Option.none())); + const reason = Option.match(lifecycle, { + onNone: () => "thread-missing" as const, + onSome: (row) => + row.deletedAt !== null + ? ("thread-deleted" as const) + : row.archivedAt !== null + ? ("thread-archived" as const) + : ("thread-missing" as const), + }); return yield* new OrchestrationGetSnapshotError({ - message: `Thread ${input.threadId} was not found`, - cause: input.threadId, + message: + reason === "thread-deleted" + ? `Thread ${input.threadId} was deleted` + : reason === "thread-archived" + ? `Thread ${input.threadId} is archived` + : `Thread ${input.threadId} was not found`, + reason, }); } diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index c7c928b3c95..205f874883f 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -153,6 +153,15 @@ interface SubscriptionOptions { cause: Cause.Cause>, ) => Effect.Effect; readonly retryExpectedFailureAfter?: Duration.Input; + /** + * When this returns true for an expected failure the subscription ends after + * `onExpectedFailure` instead of retrying — for failures the server reports + * as permanent (e.g. subscribing to a deleted thread), where retrying can + * never succeed. + */ + readonly isExpectedFailureTerminal?: ( + cause: Cause.Cause>, + ) => boolean; readonly resubscribe?: Stream.Stream; } @@ -227,7 +236,10 @@ export function subscribeDynamic( const handled = Stream.fromEffect( options.onExpectedFailure(cause), ).pipe(Stream.drain); - if (options.retryExpectedFailureAfter === undefined) { + if ( + options.retryExpectedFailureAfter === undefined || + options.isExpectedFailureTerminal?.(cause) === true + ) { return handled; } return handled.pipe( diff --git a/packages/client-runtime/src/state/threads-sync.test.ts b/packages/client-runtime/src/state/threads-sync.test.ts index ab2b8898be8..de027fc57d2 100644 --- a/packages/client-runtime/src/state/threads-sync.test.ts +++ b/packages/client-runtime/src/state/threads-sync.test.ts @@ -2,6 +2,7 @@ import { EnvironmentId, EventId, ORCHESTRATION_WS_METHODS, + OrchestrationGetSnapshotError, ProjectId, ProviderInstanceId, ThreadId, @@ -583,6 +584,84 @@ describe("EnvironmentThreads", () => { }), ); + it.effect("marks the thread deleted and stops retrying on a permanent deleted failure", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ cached: BASE_THREAD }); + yield* Queue.offer( + harness.inputs, + new OrchestrationGetSnapshotError({ + message: "Thread thread-1 was deleted", + reason: "thread-deleted", + }), + ); + + const state = yield* awaitThreadState( + harness.observed, + (value) => value.status === "deleted", + ); + expect(Option.isNone(state.data)).toBe(true); + expect(yield* Ref.get(harness.removedThreads)).toEqual([THREAD_ID]); + + yield* TestClock.adjust("2 seconds"); + for (let attempt = 0; attempt < 100; attempt += 1) { + yield* Effect.yieldNow; + } + expect(yield* Ref.get(harness.subscriptionCount)).toBe(1); + }), + ); + + it.effect("keeps cached data and stops retrying when the thread is archived", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ cached: BASE_THREAD }); + yield* Queue.offer( + harness.inputs, + new OrchestrationGetSnapshotError({ + message: "Thread thread-1 is archived", + reason: "thread-archived", + }), + ); + + const state = yield* awaitThreadState(harness.observed, (value) => + Option.isSome(value.error), + ); + expect(Option.getOrThrow(state.data)).toEqual(BASE_THREAD); + expect(state.status).toBe("cached"); + expect(Option.getOrThrow(state.error)).toBe("Thread thread-1 is archived"); + expect(yield* Ref.get(harness.removedThreads)).toEqual([]); + + yield* TestClock.adjust("2 seconds"); + for (let attempt = 0; attempt < 100; attempt += 1) { + yield* Effect.yieldNow; + } + expect(yield* Ref.get(harness.subscriptionCount)).toBe(1); + }), + ); + + it.effect("keeps retrying when the thread row is missing but not permanently gone", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + yield* Queue.offer( + harness.inputs, + new OrchestrationGetSnapshotError({ + message: "Thread thread-1 was not found", + reason: "thread-missing", + }), + ); + + yield* awaitThreadState(harness.observed, (value) => Option.isSome(value.error)); + expect(yield* Ref.get(harness.subscriptionCount)).toBe(1); + + yield* TestClock.adjust("250 millis"); + for (let attempt = 0; attempt < 100; attempt += 1) { + if ((yield* Ref.get(harness.subscriptionCount)) >= 2) { + break; + } + yield* Effect.yieldNow; + } + expect(yield* Ref.get(harness.subscriptionCount)).toBe(2); + }), + ); + it.effect("does not overwrite a live snapshot when the supervisor becomes ready", () => Effect.gen(function* () { const harness = yield* makeHarness({ cached: BASE_THREAD }); diff --git a/packages/client-runtime/src/state/threads.ts b/packages/client-runtime/src/state/threads.ts index 97635a95fb5..50554f5c7e3 100644 --- a/packages/client-runtime/src/state/threads.ts +++ b/packages/client-runtime/src/state/threads.ts @@ -1,6 +1,7 @@ import { ORCHESTRATION_WS_METHODS, type EnvironmentId as EnvironmentIdType, + type OrchestrationGetSnapshotError, type OrchestrationThread, type OrchestrationThreadDetailSnapshot, type OrchestrationThreadStreamItem, @@ -43,6 +44,33 @@ function formatThreadError(cause: Cause.Cause): string { : "Could not synchronize the thread."; } +/** + * Extract a permanent snapshot-unavailable reason from a subscription failure. + * "thread-missing" is intentionally not returned: the projection row may just + * not be written yet (a freshly created thread), so it stays retriable. + */ +function terminalSnapshotReason( + cause: Cause.Cause, +): "thread-deleted" | "thread-archived" | undefined { + for (const reason of cause.reasons) { + if (reason._tag !== "Fail") { + continue; + } + const error: unknown = reason.error; + if ( + typeof error === "object" && + error !== null && + (error as { readonly _tag?: unknown })._tag === "OrchestrationGetSnapshotError" + ) { + const snapshotReason = (error as OrchestrationGetSnapshotError).reason; + if (snapshotReason === "thread-deleted" || snapshotReason === "thread-archived") { + return snapshotReason; + } + } + } + return undefined; +} + function shouldPersistThread(thread: OrchestrationThread): boolean { const status = thread.session?.status; return status !== "starting" && status !== "running"; @@ -302,8 +330,13 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make }; }), { - onExpectedFailure: setStreamError, + // A permanently unavailable thread must not keep resubscribing: the + // server can never satisfy it, and the 250ms retry would hammer the + // socket until the state's idle TTL expires. + onExpectedFailure: (cause) => + terminalSnapshotReason(cause) === "thread-deleted" ? setDeleted() : setStreamError(cause), retryExpectedFailureAfter: "250 millis", + isExpectedFailureTerminal: (cause) => terminalSnapshotReason(cause) !== undefined, resubscribe: foregroundResubscriptions, }, ).pipe(Stream.runForEach(applyItem)), diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 3fc2fb3e151..23e18909f05 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -1405,11 +1405,26 @@ export const OrchestrationRpcSchemas = { }, } as const; +/** + * Why a thread snapshot could not be produced. Lets subscribers distinguish a + * permanently unavailable thread ("thread-deleted"/"thread-archived" — stop + * retrying) from a potentially transient miss ("thread-missing" — the + * projection row may simply not be written yet, so retrying is correct). + */ +export const OrchestrationSnapshotUnavailableReason = Schema.Literals([ + "thread-deleted", + "thread-archived", + "thread-missing", +]); +export type OrchestrationSnapshotUnavailableReason = + typeof OrchestrationSnapshotUnavailableReason.Type; + export class OrchestrationGetSnapshotError extends Schema.TaggedErrorClass()( "OrchestrationGetSnapshotError", { message: TrimmedNonEmptyString, cause: Schema.optional(Schema.Defect()), + reason: Schema.optional(OrchestrationSnapshotUnavailableReason), }, ) {} From c57c9b86ee56d3a37f9c683e25813c92ff2c24d5 Mon Sep 17 00:00:00 2001 From: Patrick Roza Date: Sun, 26 Jul 2026 12:36:59 +0200 Subject: [PATCH 15/16] test(tim-compat): gate macOS bundle fixture to Darwin Compatibility fix for running the selected Tim stack on the fork CI matrix. Source adaptation review: patroza/t3code#31. --- apps/desktop/src/shell/DesktopOpenWith.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/desktop/src/shell/DesktopOpenWith.test.ts b/apps/desktop/src/shell/DesktopOpenWith.test.ts index 55dbafb8fbd..286a7f161a8 100644 --- a/apps/desktop/src/shell/DesktopOpenWith.test.ts +++ b/apps/desktop/src/shell/DesktopOpenWith.test.ts @@ -146,6 +146,8 @@ describe("DesktopOpenWith launch resolution", () => { it.effect("resolves CFBundleExecutable and reports missing bundle executables", () => Effect.gen(function* () { + // oxlint-disable-next-line t3code/no-global-process-runtime -- Platform-gated native fixture. + if (process.platform !== "darwin") return; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const base = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-open-with-test-" }); From e1ea128acc43d77b9438bf9afec8d7d762c6344f Mon Sep 17 00:00:00 2001 From: Patrick Roza Date: Sun, 26 Jul 2026 12:36:59 +0200 Subject: [PATCH 16/16] fix(tim-compat): avoid plutil for custom open-with apps Keep the selected Tim Open With feature portable on non-macOS builders and avoid treating custom app definitions as macOS bundles. Source adaptation review: patroza/t3code#33. --- .../desktop/src/shell/DesktopOpenWith.test.ts | 30 -------- apps/desktop/src/shell/DesktopOpenWith.ts | 68 ++----------------- packages/contracts/src/openWith.ts | 22 ------ 3 files changed, 4 insertions(+), 116 deletions(-) diff --git a/apps/desktop/src/shell/DesktopOpenWith.test.ts b/apps/desktop/src/shell/DesktopOpenWith.test.ts index 286a7f161a8..e98b202a025 100644 --- a/apps/desktop/src/shell/DesktopOpenWith.test.ts +++ b/apps/desktop/src/shell/DesktopOpenWith.test.ts @@ -9,7 +9,6 @@ import { } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; -import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -144,35 +143,6 @@ describe("DesktopOpenWith launch resolution", () => { }).pipe(Effect.provide(NodeServices.layer)), ); - it.effect("resolves CFBundleExecutable and reports missing bundle executables", () => - Effect.gen(function* () { - // oxlint-disable-next-line t3code/no-global-process-runtime -- Platform-gated native fixture. - if (process.platform !== "darwin") return; - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const base = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-open-with-test-" }); - const applicationPath = path.join(base, "Fixture.app"); - const contentsPath = path.join(applicationPath, "Contents"); - const executableDirectory = path.join(contentsPath, "MacOS"); - yield* fileSystem.makeDirectory(executableDirectory, { recursive: true }); - yield* fileSystem.writeFileString( - path.join(contentsPath, "Info.plist"), - ` - CFBundleExecutableFixture`, - ); - const executablePath = path.join(executableDirectory, "Fixture"); - yield* fileSystem.writeFileString(executablePath, "#!/bin/sh\n"); - - assert.equal( - yield* DesktopOpenWith.resolveMacBundleExecutable(applicationPath), - executablePath, - ); - yield* fileSystem.remove(executablePath); - const error = yield* Effect.flip(DesktopOpenWith.resolveMacBundleExecutable(applicationPath)); - assert.equal(error.reason, "missing-executable"); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), - ); - it.effect("reports available and missing command presentations", () => Effect.gen(function* () { const openWith = yield* DesktopOpenWith.DesktopOpenWith; diff --git a/apps/desktop/src/shell/DesktopOpenWith.ts b/apps/desktop/src/shell/DesktopOpenWith.ts index 464d71fce48..73452ba9610 100644 --- a/apps/desktop/src/shell/DesktopOpenWith.ts +++ b/apps/desktop/src/shell/DesktopOpenWith.ts @@ -1,6 +1,5 @@ // @effect-diagnostics nodeBuiltinImport:off - macOS bundle paths use the host path grammar. import { - OpenWithBundleResolutionError, OpenWithEnvironmentError, OpenWithInvalidTargetError, OpenWithMissingEntryError, @@ -70,65 +69,6 @@ const entryIsAvailable = Effect.fn("desktop.openWith.entryIsAvailable")(function return Option.isSome(stat) && stat.value.type === "Directory"; }); -export const resolveMacBundleExecutable = Effect.fn("desktop.openWith.resolveMacBundleExecutable")( - function* (applicationPath: string) { - if (!isMacApplicationPath(applicationPath)) { - return yield* new OpenWithBundleResolutionError({ - applicationPath, - reason: "invalid-application-path", - }); - } - const infoPlistPath = NodePath.join(applicationPath, "Contents", "Info.plist"); - const plistStat = yield* statPath(infoPlistPath); - if (Option.isNone(plistStat) || plistStat.value.type !== "File") { - return yield* new OpenWithBundleResolutionError({ - applicationPath, - reason: "missing-info-plist", - }); - } - - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const executableName = yield* spawner - .string( - ChildProcess.make( - "/usr/bin/plutil", - ["-extract", "CFBundleExecutable", "raw", "-o", "-", infoPlistPath], - { stdin: "ignore", stdout: "pipe", stderr: "pipe" }, - ), - ) - .pipe( - Effect.map((output) => output.trim()), - Effect.mapError( - (cause) => - new OpenWithBundleResolutionError({ - applicationPath, - reason: "malformed-info-plist", - cause, - }), - ), - ); - if ( - executableName.length === 0 || - executableName.includes("/") || - executableName.includes("\\") - ) { - return yield* new OpenWithBundleResolutionError({ - applicationPath, - reason: "malformed-info-plist", - }); - } - const executablePath = NodePath.join(applicationPath, "Contents", "MacOS", executableName); - const executableStat = yield* statPath(executablePath); - if (Option.isNone(executableStat) || executableStat.value.type !== "File") { - return yield* new OpenWithBundleResolutionError({ - applicationPath, - reason: "missing-executable", - }); - } - return executablePath; - }, -); - const expandDirectoryArguments = (args: readonly string[], directory: string): string[] => args.map((argument) => argument.replaceAll("{directory}", directory)); @@ -169,8 +109,8 @@ export const resolveOpenWithLaunch = Effect.fn("desktop.openWith.resolveLaunch") if (entry.directoryMode === "working-directory") { if (entry.invocation.type === "mac-application") { return { - command: yield* resolveMacBundleExecutable(entry.invocation.applicationPath), - args: [...entry.arguments], + command: "/usr/bin/open", + args: ["-a", entry.invocation.applicationPath, "--args", ...entry.arguments], cwd: directory, }; } @@ -187,8 +127,8 @@ export const resolveOpenWithLaunch = Effect.fn("desktop.openWith.resolveLaunch") const args = expandDirectoryArguments(entry.arguments, directory); if (entry.invocation.type === "mac-application") { return { - command: yield* resolveMacBundleExecutable(entry.invocation.applicationPath), - args, + command: "/usr/bin/open", + args: ["-a", entry.invocation.applicationPath, "--args", ...args], cwd: null, }; } diff --git a/packages/contracts/src/openWith.ts b/packages/contracts/src/openWith.ts index 9df4e1db990..94e40032a51 100644 --- a/packages/contracts/src/openWith.ts +++ b/packages/contracts/src/openWith.ts @@ -132,27 +132,6 @@ export class OpenWithUnavailableApplicationError extends Schema.TaggedErrorClass } } -export const OpenWithBundleResolutionReason = Schema.Literals([ - "invalid-application-path", - "missing-info-plist", - "malformed-info-plist", - "missing-executable", -]); -export type OpenWithBundleResolutionReason = typeof OpenWithBundleResolutionReason.Type; - -export class OpenWithBundleResolutionError extends Schema.TaggedErrorClass()( - "OpenWithBundleResolutionError", - { - applicationPath: Schema.String, - reason: OpenWithBundleResolutionReason, - cause: Schema.optional(Schema.Defect()), - }, -) { - override get message(): string { - return `Unable to resolve the executable in macOS application '${this.applicationPath}' (${this.reason}).`; - } -} - export class OpenWithSpawnError extends Schema.TaggedErrorClass()( "OpenWithSpawnError", { @@ -173,7 +152,6 @@ export const OpenWithLaunchError = Schema.Union([ OpenWithMissingEntryError, OpenWithInvalidTargetError, OpenWithUnavailableApplicationError, - OpenWithBundleResolutionError, OpenWithSpawnError, ]); export type OpenWithLaunchError = typeof OpenWithLaunchError.Type;