diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d90479087cf..b229a224b77 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -109,7 +109,7 @@ jobs: NIGHTLY_RUN_NUMBER: ${{ github.run_number }} run: | if [[ "${GITHUB_EVENT_NAME}" == "schedule" || ( "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && "${DISPATCH_CHANNEL:-stable}" == "nightly" ) ]]; then - nightly_date="$(date -u -d "$NIGHTLY_DATE" +%Y%m%d)" + nightly_date="$(date -d "$NIGHTLY_DATE" +%Y%m%d)" node scripts/resolve-nightly-release.ts \ --date "$nightly_date" \ diff --git a/CLAUDE.md b/CLAUDE.md index c3170642553..47d29cb5ec2 120000 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1 +1 @@ -AGENTS.md +agents.md \ No newline at end of file diff --git a/apps/desktop/package.json b/apps/desktop/package.json index ead57b5cbf2..8067f473bca 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -32,5 +32,5 @@ "typescript": "catalog:", "vitest": "catalog:" }, - "productName": "T3 Code (Alpha)" + "productName": "T3 Code (A3)" } diff --git a/apps/desktop/scripts/electron-launcher.mjs b/apps/desktop/scripts/electron-launcher.mjs index 1453cbe666e..942e3be1969 100644 --- a/apps/desktop/scripts/electron-launcher.mjs +++ b/apps/desktop/scripts/electron-launcher.mjs @@ -17,7 +17,7 @@ import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL); -const APP_DISPLAY_NAME = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)"; +const APP_DISPLAY_NAME = isDevelopment ? "T3 Code (Dev)" : "T3 Code (A3)"; const APP_BUNDLE_ID = isDevelopment ? "com.t3tools.t3code.dev" : "com.t3tools.t3code"; const LAUNCHER_VERSION = 2; diff --git a/apps/desktop/src/app/DesktopAppIdentity.test.ts b/apps/desktop/src/app/DesktopAppIdentity.test.ts index f95fd1bef71..bd290eeb40c 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.test.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.test.ts @@ -23,6 +23,7 @@ const defaultEnvironmentInput = { isPackaged: true, resourcesPath: "/Applications/T3 Code.app/Contents/Resources", runningUnderArm64Translation: false, + buildTimestamp: "20260508-1430", } satisfies DesktopEnvironment.MakeDesktopEnvironmentInput; type TestEnvironmentInput = Partial & { @@ -156,9 +157,9 @@ describe("DesktopAppIdentity", () => { const identity = yield* DesktopAppIdentity.DesktopAppIdentity; yield* identity.configure; - assert.deepEqual(calls.setName, ["T3 Code (Alpha)"]); - assert.equal(calls.setAboutPanelOptions[0]?.applicationName, "T3 Code (Alpha)"); - assert.equal(calls.setAboutPanelOptions[0]?.applicationVersion, "1.2.3"); + assert.deepEqual(calls.setName, ["T3 Code (A3)"]); + assert.equal(calls.setAboutPanelOptions[0]?.applicationName, "T3 Code (A3)"); + assert.equal(calls.setAboutPanelOptions[0]?.applicationVersion, "1.2.3-a3-20260508-1430"); assert.equal(calls.setAboutPanelOptions[0]?.version, "0123456789ab"); assert.deepEqual(calls.setDockIcon, ["/icon.png"]); }), diff --git a/apps/desktop/src/app/DesktopAppIdentity.ts b/apps/desktop/src/app/DesktopAppIdentity.ts index c525d01d9d8..ff4cf33d6ad 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.ts @@ -15,6 +15,7 @@ const COMMIT_HASH_DISPLAY_LENGTH = 12; const AppPackageMetadata = Schema.Struct({ t3codeCommitHash: Schema.optional(Schema.String), + t3codeBuildTimestamp: Schema.optional(Schema.String), }); const decodeAppPackageMetadata = Schema.decodeEffect(Schema.fromJsonString(AppPackageMetadata)); @@ -98,7 +99,7 @@ const make = Effect.gen(function* () { yield* electronApp.setName(environment.displayName); yield* electronApp.setAboutPanelOptions({ applicationName: environment.displayName, - applicationVersion: environment.appVersion, + applicationVersion: environment.displayVersion, version: Option.getOrElse(commitHash, () => "unknown"), }); diff --git a/apps/desktop/src/app/DesktopEnvironment.test.ts b/apps/desktop/src/app/DesktopEnvironment.test.ts index 427b8848833..fb6a30fa25a 100644 --- a/apps/desktop/src/app/DesktopEnvironment.test.ts +++ b/apps/desktop/src/app/DesktopEnvironment.test.ts @@ -17,6 +17,7 @@ const defaultInput = { isPackaged: false, resourcesPath: "/Applications/T3 Code.app/Contents/Resources", runningUnderArm64Translation: false, + buildTimestamp: "20260508-1430", } satisfies DesktopEnvironment.MakeDesktopEnvironmentInput; const makeEnvironmentLayer = ( diff --git a/apps/desktop/src/app/DesktopEnvironment.ts b/apps/desktop/src/app/DesktopEnvironment.ts index a5212f25358..391489c3197 100644 --- a/apps/desktop/src/app/DesktopEnvironment.ts +++ b/apps/desktop/src/app/DesktopEnvironment.ts @@ -17,6 +17,7 @@ import { } from "../settings/DesktopAppSettings.ts"; import * as DesktopConfig from "./DesktopConfig.ts"; import { isNightlyDesktopVersion } from "../updates/updateChannels.ts"; +import { FORK_STAGE_LABEL, formatForkDisplayVersion } from "./forkBranding.ts"; export interface MakeDesktopEnvironmentInput { readonly dirname: string; @@ -28,6 +29,7 @@ export interface MakeDesktopEnvironmentInput { readonly isPackaged: boolean; readonly resourcesPath: string; readonly runningUnderArm64Translation: boolean; + readonly buildTimestamp: string; } export interface DesktopEnvironmentShape { @@ -73,6 +75,7 @@ export interface DesktopEnvironmentShape { readonly resolvePickFolderDefaultPath: (rawOptions: unknown) => Option.Option; readonly resolveResourcePathCandidates: (fileName: string) => readonly string[]; readonly developmentDockIconPath: string; + readonly displayVersion: string; } export class DesktopEnvironment extends Context.Service< @@ -90,18 +93,21 @@ function resolveDesktopAppStageLabel(input: { return "Dev"; } - return isNightlyDesktopVersion(input.appVersion) ? "Nightly" : "Alpha"; + return isNightlyDesktopVersion(input.appVersion) ? "Nightly" : FORK_STAGE_LABEL; } function resolveDesktopAppBranding(input: { readonly isDevelopment: boolean; readonly appVersion: string; + readonly displayVersion: string; }): DesktopAppBranding { const stageLabel = resolveDesktopAppStageLabel(input); return { baseName: APP_BASE_NAME, stageLabel, displayName: `${APP_BASE_NAME} (${stageLabel})`, + displayVersion: input.displayVersion, + appVersion: input.appVersion, }; } @@ -154,9 +160,11 @@ const makeDesktopEnvironment = Effect.fn("desktop.environment.make")(function* ( const baseDir = Option.getOrElse(config.t3Home, () => path.join(homeDirectory, ".t3")); const rootDir = path.resolve(input.dirname, "../../.."); const appRoot = input.isPackaged ? input.appPath : rootDir; + const displayVersion = formatForkDisplayVersion(input.appVersion, input.buildTimestamp); const branding = resolveDesktopAppBranding({ isDevelopment, appVersion: input.appVersion, + displayVersion, }); const displayName = branding.displayName; const stateDir = path.join(baseDir, isDevelopment ? "dev" : "userdata"); @@ -242,6 +250,7 @@ const makeDesktopEnvironment = Effect.fn("desktop.environment.make")(function* ( path.join(resourcesPath, fileName), ], developmentDockIconPath: path.join(rootDir, "assets", "dev", "blueprint-macos-1024.png"), + displayVersion, }); }); diff --git a/apps/desktop/src/app/DesktopLifecycle.ts b/apps/desktop/src/app/DesktopLifecycle.ts index b9a7636a411..ca0dce66f2a 100644 --- a/apps/desktop/src/app/DesktopLifecycle.ts +++ b/apps/desktop/src/app/DesktopLifecycle.ts @@ -6,6 +6,10 @@ import * as Layer from "effect/Layer"; import * as Ref from "effect/Ref"; import * as Scope from "effect/Scope"; +// @effect-diagnostics nodeBuiltinImport:off globalTimers:off +import * as NodeTimers from "node:timers"; + +import { app as electronAppModule, type Event as ElectronEvent } from "electron"; import type * as Electron from "electron"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; @@ -97,40 +101,49 @@ const requestDesktopShutdownAndWait = Effect.fn("desktop.lifecycle.requestShutdo }, ); +const SHUTDOWN_EXIT_TIMEOUT_MS = 5000; + function handleBeforeQuit( - event: Electron.Event, + event: ElectronEvent, runEffect: (effect: Effect.Effect) => Promise, - allowQuit: () => boolean, - markQuitAllowed: () => void, + getShutdownPromise: () => Promise | null, + setShutdownPromise: (promise: Promise) => void, ): void { - if (allowQuit()) { - void runEffect( - Effect.gen(function* () { - const state = yield* DesktopState.DesktopState; - yield* Ref.set(state.quitting, true); - yield* logLifecycleInfo("before-quit received"); - }).pipe(Effect.withSpan("desktop.lifecycle.beforeQuit")), - ); + if (getShutdownPromise() !== null) { + event.preventDefault(); return; } event.preventDefault(); - void runEffect( + + const shutdownEffect = runEffect( Effect.gen(function* () { const state = yield* DesktopState.DesktopState; yield* Ref.set(state.quitting, true); yield* logLifecycleInfo("before-quit received"); yield* requestDesktopShutdownAndWait(); }).pipe(Effect.withSpan("desktop.lifecycle.beforeQuit")), - ).finally(() => { - markQuitAllowed(); - void runEffect( - Effect.gen(function* () { - const electronApp = yield* ElectronApp.ElectronApp; - yield* electronApp.quit; - }).pipe(Effect.withSpan("desktop.lifecycle.quitAfterShutdown")), - ); + ); + + let timeoutHandle: ReturnType | null = null; + const timeoutPromise = new Promise((resolve) => { + timeoutHandle = NodeTimers.setTimeout(resolve, SHUTDOWN_EXIT_TIMEOUT_MS); }); + + const racePromise = Promise.race([ + shutdownEffect.then( + () => undefined, + () => undefined, + ), + timeoutPromise, + ]).finally(() => { + if (timeoutHandle !== null) { + NodeTimers.clearTimeout(timeoutHandle); + } + electronAppModule.exit(0); + }); + + setShutdownPromise(racePromise); } function quitFromSignal( @@ -189,7 +202,7 @@ export const layer = Layer.succeed( const environment = yield* DesktopEnvironment.DesktopEnvironment; const context = yield* Effect.context(); const runEffect = Effect.runPromiseWith(context); - let quitAllowed = false; + let shutdownPromise: Promise | null = null; yield* electronTheme.onUpdated(() => { void runEffect( desktopWindow.syncAppearance.pipe(Effect.withSpan("desktop.lifecycle.themeUpdated")), @@ -199,9 +212,9 @@ export const layer = Layer.succeed( handleBeforeQuit( event, runEffect, - () => quitAllowed, - () => { - quitAllowed = true; + () => shutdownPromise, + (promise) => { + shutdownPromise = promise; }, ); }); diff --git a/apps/desktop/src/app/DesktopObservability.test.ts b/apps/desktop/src/app/DesktopObservability.test.ts index a78de48d5e1..16c85df8221 100644 --- a/apps/desktop/src/app/DesktopObservability.test.ts +++ b/apps/desktop/src/app/DesktopObservability.test.ts @@ -47,6 +47,7 @@ const environmentInput = (baseDir: string) => isPackaged: false, resourcesPath: "/repo/resources", runningUnderArm64Translation: false, + buildTimestamp: "20260508-1430", }) satisfies DesktopEnvironment.MakeDesktopEnvironmentInput; const makeEnvironmentLayer = (baseDir: string) => diff --git a/apps/desktop/src/app/forkBranding.ts b/apps/desktop/src/app/forkBranding.ts new file mode 100644 index 00000000000..b84719605fb --- /dev/null +++ b/apps/desktop/src/app/forkBranding.ts @@ -0,0 +1,8 @@ +import type { DesktopAppStageLabel } from "@t3tools/contracts"; + +export const FORK_TAG = "a3"; +export const FORK_STAGE_LABEL: DesktopAppStageLabel = "A3"; + +export function formatForkDisplayVersion(pkgVersion: string, buildTimestamp: string): string { + return `${pkgVersion}-${FORK_TAG}-${buildTimestamp}`; +} diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index 96e56a87c9d..c79cdd4ca8c 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -53,6 +53,7 @@ function makeEnvironmentLayer( isPackaged: options?.isPackaged ?? true, resourcesPath: "/missing/resources", runningUnderArm64Translation: false, + buildTimestamp: "20260508-1430", }).pipe( Layer.provide( Layer.mergeAll( diff --git a/apps/desktop/src/backend/DesktopServerExposure.test.ts b/apps/desktop/src/backend/DesktopServerExposure.test.ts index 0f3e9eaeb45..9512d692407 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.test.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.test.ts @@ -75,6 +75,7 @@ function makeEnvironmentLayer(baseDir: string, env: Record void, + details: PermissionRequest, + ) => void) + | null, + ): void; + } +} diff --git a/apps/desktop/src/electron/ElectronPermissions.ts b/apps/desktop/src/electron/ElectronPermissions.ts new file mode 100644 index 00000000000..51eca09b0f5 --- /dev/null +++ b/apps/desktop/src/electron/ElectronPermissions.ts @@ -0,0 +1,34 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import * as Electron from "electron"; + +import * as ElectronApp from "./ElectronApp.ts"; + +const ALLOWED_PERMISSIONS = new Set([ + "clipboard-sanitized-write", + "clipboard-read", + "local-fonts", +]); + +const install = Effect.acquireRelease( + Effect.sync(() => { + Electron.session.defaultSession.setPermissionRequestHandler( + (_webContents, permission, callback) => { + callback(ALLOWED_PERMISSIONS.has(permission)); + }, + ); + }), + () => + Effect.sync(() => { + Electron.session.defaultSession.setPermissionRequestHandler(null); + }), +); + +export const layer = Layer.effectDiscard( + Effect.gen(function* () { + const app = yield* ElectronApp.ElectronApp; + yield* app.whenReady; + yield* install; + }), +); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 0bc1badff2d..c7391ec4e11 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -3,10 +3,13 @@ import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as NodeOS from "node:os"; 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 Electron from "electron"; +import { formatBuildTimestamp } from "@t3tools/shared/buildTimestamp"; import * as NetService from "@t3tools/shared/Net"; import { resolveRemoteT3CliPackageSpec } from "@t3tools/ssh/command"; @@ -18,6 +21,7 @@ import * as DesktopIpc from "./ipc/DesktopIpc.ts"; import * as ElectronApp from "./electron/ElectronApp.ts"; import * as ElectronDialog from "./electron/ElectronDialog.ts"; import * as ElectronMenu from "./electron/ElectronMenu.ts"; +import * as ElectronPermissions from "./electron/ElectronPermissions.ts"; import * as ElectronProtocol from "./electron/ElectronProtocol.ts"; import * as DesktopSecretStorage from "./electron/ElectronSafeStorage.ts"; import * as ElectronShell from "./electron/ElectronShell.ts"; @@ -47,14 +51,33 @@ import * as DesktopWindow from "./window/DesktopWindow.ts"; const desktopEnvironmentLayer = Layer.unwrap( Effect.gen(function* () { - const metadata = yield* Effect.service(ElectronApp.ElectronApp).pipe( - Effect.flatMap((app) => app.metadata), - ); + const electronApp = yield* Effect.service(ElectronApp.ElectronApp); + const metadata = yield* electronApp.metadata; + let buildTimestamp = process.env.T3CODE_BUILD_TIMESTAMP; + if (!buildTimestamp && metadata.isPackaged) { + const path = yield* Path.Path; + const fs = yield* FileSystem.FileSystem; + const packagePath = path.join(metadata.appPath, "package.json"); + const fileContent = yield* fs.readFileString(packagePath).pipe(Effect.option); + if (Option.isSome(fileContent)) { + // @effect-diagnostics-next-line tryCatchInEffectGen:off + try { + // @effect-diagnostics-next-line preferSchemaOverJson:off + const packageJson = JSON.parse(fileContent.value); + if (typeof packageJson?.t3codeBuildTimestamp === "string") { + buildTimestamp = packageJson.t3codeBuildTimestamp; + } + } catch {} + } + } + // @effect-diagnostics-next-line globalDateInEffect:off + buildTimestamp = buildTimestamp || formatBuildTimestamp(new Date()); return DesktopEnvironment.layer({ dirname: __dirname, homeDirectory: NodeOS.homedir(), platform: process.platform, processArch: process.arch, + buildTimestamp, ...metadata, }); }), @@ -137,6 +160,7 @@ const desktopApplicationLayer = Layer.mergeAll( DesktopLifecycle.layer, DesktopApplicationMenu.layer, DesktopShellEnvironment.layer, + ElectronPermissions.layer, desktopSshLayer, ).pipe(Layer.provideMerge(DesktopUpdates.layer), Layer.provideMerge(desktopBackendLayer)); diff --git a/apps/desktop/src/settings/DesktopAppSettings.test.ts b/apps/desktop/src/settings/DesktopAppSettings.test.ts index db6194cf8f7..92e9d33ddfe 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.test.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.test.ts @@ -37,6 +37,7 @@ function makeEnvironmentLayer(baseDir: string, appVersion = "0.0.17") { isPackaged: true, resourcesPath: "/missing/resources", runningUnderArm64Translation: false, + buildTimestamp: "20260508-1430", }).pipe( Layer.provide( Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({ T3CODE_HOME: baseDir })), diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index f666e692860..7fb0e8a4e3f 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -12,12 +12,16 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopClientSettings from "./DesktopClientSettings.ts"; const clientSettings: ClientSettings = { + autoCreatePrOnPush: true, autoOpenPlanSidebar: false, + changedFilesExpandedByDefault: false, confirmThreadArchive: true, confirmThreadDelete: false, - dismissedProviderUpdateNotificationKeys: [], + diffFontFamily: "", diffIgnoreWhitespace: true, diffWordWrap: true, + dismissedProviderUpdateNotificationKeys: [], + hideUnavailableProviders: false, favorites: [], providerModelPreferences: {}, sidebarProjectGroupingMode: "repository_path", @@ -27,6 +31,7 @@ const clientSettings: ClientSettings = { sidebarProjectSortOrder: "manual", sidebarThreadSortOrder: "created_at", sidebarThreadPreviewCount: 6, + terminalFontFamily: "", timestampFormat: "24-hour", }; @@ -46,6 +51,7 @@ function makeLayer(baseDir: string) { isPackaged: true, resourcesPath: "/missing/resources", runningUnderArm64Translation: false, + buildTimestamp: "20260508-1430", }).pipe( Layer.provide( Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({ T3CODE_HOME: baseDir })), diff --git a/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts b/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts index d1d37b96e11..a5bcba2180f 100644 --- a/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts +++ b/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts @@ -102,6 +102,7 @@ function makeLayer( isPackaged: true, resourcesPath: "/missing/resources", runningUnderArm64Translation: false, + buildTimestamp: "20260508-1430", }).pipe( Layer.provide( Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({ T3CODE_HOME: baseDir })), diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index 34d18f11a77..2a54b756eae 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -124,6 +124,7 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { isPackaged: true, resourcesPath: "/missing/resources", runningUnderArm64Translation: false, + buildTimestamp: "20260508-1430", }).pipe( Layer.provide( Layer.mergeAll( diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index fc589b3e39b..18ebffeccff 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -26,6 +26,7 @@ const environmentInput = { isPackaged: false, resourcesPath: "/repo/resources", runningUnderArm64Translation: false, + buildTimestamp: "20260508-1430", } satisfies DesktopEnvironment.MakeDesktopEnvironmentInput; const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, { diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index fbcc60934aa..d633f631503 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -29,6 +29,7 @@ const environmentInput = { isPackaged: false, resourcesPath: "/repo/resources", runningUnderArm64Translation: false, + buildTimestamp: "20260508-1430", } satisfies DesktopEnvironment.MakeDesktopEnvironmentInput; function makeFakeBrowserWindow() { diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 530e0488cf3..0c22c36cc5f 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -1084,7 +1084,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { state: "open", }); expect(ghCalls).toContain( - "pr list --head jasonLaster:statemachine --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + "pr list --head jasonLaster:statemachine --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner --repo jasonLaster/codething-mvp", ); }), 20_000, diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 8dfb957b89d..59f2ce71e10 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -906,6 +906,9 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { headSelector, state: "open", limit: 1, + ...(headContext.headRepositoryNameWithOwner + ? { repository: headContext.headRepositoryNameWithOwner } + : {}), }); const normalizedPullRequests = pullRequests.map(toPullRequestInfo); @@ -941,6 +944,9 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { headSelector, state: "all", limit: 20, + ...(headContext.headRepositoryNameWithOwner + ? { repository: headContext.headRepositoryNameWithOwner } + : {}), }); for (const pr of pullRequests.map(toPullRequestInfo)) { @@ -1058,7 +1064,10 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { cwd: string, branch: string, upstreamRef: string | null, - headContext: Pick, + headContext: Pick< + BranchHeadContext, + "isCrossRepository" | "remoteName" | "headRepositoryNameWithOwner" + >, ) { const configured = yield* gitCore.readConfigValue(cwd, `branch.${branch}.gh-merge-base`); if (configured) return configured; @@ -1073,7 +1082,14 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { } const defaultFromProvider = yield* sourceControlProvider(cwd).pipe( - Effect.flatMap((provider) => provider.getDefaultBranch({ cwd })), + Effect.flatMap((provider) => + provider.getDefaultBranch({ + cwd, + ...(headContext.headRepositoryNameWithOwner + ? { repository: headContext.headRepositoryNameWithOwner } + : {}), + }), + ), Effect.catch(() => Effect.succeed(null)), ); if (defaultFromProvider) { @@ -1110,6 +1126,16 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { }; } + const { commitMessagePromptInstructions } = yield* serverSettingsService.getSettings.pipe( + Effect.mapError((cause) => + gitManagerError( + "resolveCommitAndBranchSuggestion", + "Failed to get server settings.", + cause, + ), + ), + ); + const generated = yield* textGeneration .generateCommitMessage({ cwd: input.cwd, @@ -1118,6 +1144,9 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { stagedPatch: limitContext(context.stagedPatch, 50_000), ...(input.includeBranch ? { includeBranch: true } : {}), modelSelection: input.modelSelection, + ...(commitMessagePromptInstructions.length > 0 + ? { instructionsOverride: commitMessagePromptInstructions } + : {}), }) .pipe(Effect.map((result) => sanitizeCommitMessage(result))); @@ -1291,6 +1320,12 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { }); const rangeContext = yield* gitCore.readRangeContext(cwd, baseBranch); + const { prContentPromptInstructions } = yield* serverSettingsService.getSettings.pipe( + Effect.mapError((cause) => + gitManagerError("runPrStep", "Failed to get server settings.", cause), + ), + ); + const generated = yield* textGeneration.generatePrContent({ cwd, baseBranch, @@ -1299,6 +1334,9 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { diffSummary: limitContext(rangeContext.diffSummary, 20_000), diffPatch: limitContext(rangeContext.diffPatch, 60_000), modelSelection, + ...(prContentPromptInstructions.length > 0 + ? { instructionsOverride: prContentPromptInstructions } + : {}), }); const bodyFile = path.join(tempDir, `t3code-pr-body-${process.pid}-${randomUUID()}.md`); @@ -1321,6 +1359,9 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { headSelector: headContext.preferredHeadSelector, title: generated.title, bodyFile, + ...(headContext.headRepositoryNameWithOwner + ? { headRepository: headContext.headRepositoryNameWithOwner } + : {}), }) .pipe(Effect.ensuring(fileSystem.remove(bodyFile).pipe(Effect.catch(() => Effect.void)))); diff --git a/apps/server/src/git/Utils.ts b/apps/server/src/git/Utils.ts index b414daaa0a4..2cba2e2349c 100644 --- a/apps/server/src/git/Utils.ts +++ b/apps/server/src/git/Utils.ts @@ -1,7 +1,10 @@ // @effect-diagnostics nodeBuiltinImport:off import { existsSync } from "node:fs"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; -export function isGitRepository(cwd: string): boolean { - return existsSync(join(cwd, ".git")); +export function isGitRepository(current: string): boolean { + do { + if (existsSync(join(current, ".git"))) return true; + } while (current !== (current = dirname(current))); + return false; } diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 1161ff6a7d7..2db90cfca22 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -170,11 +170,11 @@ function deriveHasActionableProposedPlan(input: { } } if (latestForTurn !== null) { - return latestForTurn.implementedAt === null; + return latestForTurn.implementedAt === null && latestForTurn.revertedAt === null; } const latestPlan = sorted.at(-1) ?? null; - return latestPlan !== null && latestPlan.implementedAt === null; + return latestPlan !== null && latestPlan.implementedAt === null && latestPlan.revertedAt === null; } function retainProjectionMessagesAfterRevert( @@ -688,6 +688,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti case "thread.message-sent": case "thread.proposed-plan-upserted": + case "thread.proposed-plan-removed": case "thread.activity-appended": case "thread.approval-response-requested": case "thread.user-input-response-requested": { @@ -871,11 +872,18 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti planMarkdown: event.payload.proposedPlan.planMarkdown, implementedAt: event.payload.proposedPlan.implementedAt, implementationThreadId: event.payload.proposedPlan.implementationThreadId, + revertedAt: event.payload.proposedPlan.revertedAt, createdAt: event.payload.proposedPlan.createdAt, updatedAt: event.payload.proposedPlan.updatedAt, }); return; + case "thread.proposed-plan-removed": + yield* projectionThreadProposedPlanRepository.deleteByPlanId({ + planId: event.payload.planId, + }); + return; + case "thread.reverted": { const existingRows = yield* projectionThreadProposedPlanRepository.listByThreadId({ threadId: event.payload.threadId, diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 7db2a23e5ec..6bec875dec4 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -328,6 +328,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { planMarkdown: "# Ship it", implementedAt: "2026-02-24T00:00:05.500Z", implementationThreadId: ThreadId.make("thread-2"), + revertedAt: null, createdAt: "2026-02-24T00:00:05.000Z", updatedAt: "2026-02-24T00:00:05.500Z", }, @@ -1161,6 +1162,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { yield* sql`DELETE FROM projection_projects`; yield* sql`DELETE FROM projection_threads`; + yield* sql`DELETE FROM projection_thread_messages`; yield* sql`DELETE FROM projection_turns`; yield* sql`DELETE FROM projection_state`; @@ -1280,6 +1282,31 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { ) `; + yield* sql` + INSERT INTO projection_thread_messages ( + message_id, + thread_id, + turn_id, + role, + text, + is_streaming, + created_at, + updated_at, + attachments_json + ) + VALUES ( + 'message-assistant-1', + 'thread-1', + 'turn-completed', + 'assistant', + 'persisted assistant answer', + 0, + '2026-04-03T00:00:20.000Z', + '2026-04-03T00:00:20.000Z', + NULL + ) + `; + yield* sql` INSERT INTO projection_state (projector, last_applied_sequence, updated_at) VALUES @@ -1295,6 +1322,11 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { const commandReadModel = yield* snapshotQuery.getCommandReadModel(); assert.equal(commandReadModel.threads[0]?.latestTurn?.turnId, asTurnId("turn-running")); assert.equal(commandReadModel.threads[0]?.latestTurn?.state, "running"); + assert.equal( + commandReadModel.threads[0]?.messages[0]?.id, + asMessageId("message-assistant-1"), + ); + assert.equal(commandReadModel.threads[0]?.messages[0]?.text, "persisted assistant answer"); const shellSnapshot = yield* snapshotQuery.getShellSnapshot(); assert.equal(shellSnapshot.threads[0]?.latestTurn?.turnId, asTurnId("turn-running")); diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 9b3c0fa7ad4..e6549c06882 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -246,6 +246,7 @@ function mapProposedPlanRow( planMarkdown: row.planMarkdown, implementedAt: row.implementedAt, implementationThreadId: row.implementationThreadId, + revertedAt: row.revertedAt, createdAt: row.createdAt, updatedAt: row.updatedAt, }; @@ -433,6 +434,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { plan_markdown AS "planMarkdown", implemented_at AS "implementedAt", implementation_thread_id AS "implementationThreadId", + reverted_at AS "revertedAt", created_at AS "createdAt", updated_at AS "updatedAt" FROM projection_thread_proposed_plans @@ -797,6 +799,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { plan_markdown AS "planMarkdown", implemented_at AS "implementedAt", implementation_thread_id AS "implementationThreadId", + reverted_at AS "revertedAt", created_at AS "createdAt", updated_at AS "updatedAt" FROM projection_thread_proposed_plans @@ -1064,6 +1067,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { planMarkdown: row.planMarkdown, implementedAt: row.implementedAt, implementationThreadId: row.implementationThreadId, + revertedAt: row.revertedAt, createdAt: row.createdAt, updatedAt: row.updatedAt, }); @@ -1233,6 +1237,14 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ), ), ), + listThreadMessageRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getCommandReadModel:listThreadMessages:query", + "ProjectionSnapshotQuery.getCommandReadModel:listThreadMessages:decodeRows", + ), + ), + ), listThreadProposedPlanRows(undefined).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( @@ -1269,7 +1281,15 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ) .pipe( Effect.flatMap( - ([projectRows, threadRows, proposedPlanRows, sessionRows, latestTurnRows, stateRows]) => + ([ + projectRows, + threadRows, + messageRows, + proposedPlanRows, + sessionRows, + latestTurnRows, + stateRows, + ]) => Effect.sync(() => { let updatedAt: string | null = null; const projects: OrchestrationProject[] = []; @@ -1306,6 +1326,13 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { } updatedAt = maxIso(updatedAt, row.updatedAt); } + for (let index = 0; index < messageRows.length; index += 1) { + const row = messageRows[index]; + if (!row) { + continue; + } + updatedAt = maxIso(updatedAt, row.updatedAt); + } for (let index = 0; index < sessionRows.length; index += 1) { const row = sessionRows[index]; if (!row) { @@ -1342,9 +1369,29 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { } latestTurnByThread.set(row.threadId, mapLatestTurn(row)); } + const messagesByThread = new Map>(); const proposedPlansByThread = new Map>(); const sessionByThread = new Map(); + for (let index = 0; index < messageRows.length; index += 1) { + const row = messageRows[index]; + if (!row) { + continue; + } + const threadMessages = messagesByThread.get(row.threadId) ?? []; + threadMessages.push({ + id: row.messageId, + role: row.role, + text: row.text, + ...(row.attachments !== null ? { attachments: row.attachments } : {}), + turnId: row.turnId, + streaming: row.isStreaming === 1, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }); + messagesByThread.set(row.threadId, threadMessages); + } + for (let index = 0; index < sessionRows.length; index += 1) { const row = sessionRows[index]; if (!row) { @@ -1382,7 +1429,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { updatedAt: row.updatedAt, archivedAt: row.archivedAt, deletedAt: row.deletedAt, - messages: [], + messages: messagesByThread.get(row.threadId) ?? [], proposedPlans: proposedPlansByThread.get(row.threadId) ?? [], activities: [], checkpoints: [], diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 8b71a976808..987a1c12464 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -593,7 +593,7 @@ const make = Effect.gen(function* () { const cwd = input.worktreePath; const attachments = input.attachments ?? []; yield* Effect.gen(function* () { - const { textGenerationModelSelection: modelSelection } = + const { textGenerationModelSelection: modelSelection, branchNamePromptInstructions } = yield* serverSettingsService.getSettings; const generated = yield* textGeneration.generateBranchName({ @@ -601,6 +601,9 @@ const make = Effect.gen(function* () { message: input.messageText, ...(attachments.length > 0 ? { attachments } : {}), modelSelection, + ...(branchNamePromptInstructions.length > 0 + ? { instructionsOverride: branchNamePromptInstructions } + : {}), }); if (!generated) return; diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 2c07ac91b1e..207676e9bff 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -121,14 +121,12 @@ function findMessageById( return undefined; } -function findProposedPlanById( - proposedPlans: ReadonlyArray< - Pick - >, - planId: string, -): - | Pick - | undefined { +function findProposedPlanById< + T extends Pick< + OrchestrationProposedPlan, + "id" | "createdAt" | "implementedAt" | "implementationThreadId" + > & { revertedAt?: string | null }, +>(proposedPlans: ReadonlyArray, planId: string): T | undefined { for (let index = 0; index < proposedPlans.length; index += 1) { const proposedPlan = proposedPlans[index]; if (proposedPlan?.id === planId) { @@ -987,6 +985,7 @@ const make = Effect.gen(function* () { createdAt: string; implementedAt: string | null; implementationThreadId: ThreadId | null; + revertedAt?: string | null; }>; planId: string; turnId?: TurnId; @@ -1011,6 +1010,7 @@ const make = Effect.gen(function* () { planMarkdown, implementedAt: existingPlan?.implementedAt ?? null, implementationThreadId: existingPlan?.implementationThreadId ?? null, + revertedAt: existingPlan?.revertedAt ?? null, createdAt: existingPlan?.createdAt ?? input.createdAt, updatedAt: input.updatedAt, }, @@ -1026,6 +1026,7 @@ const make = Effect.gen(function* () { createdAt: string; implementedAt: string | null; implementationThreadId: ThreadId | null; + revertedAt?: string | null; }>; planId: string; turnId?: TurnId; diff --git a/apps/server/src/orchestration/Schemas.ts b/apps/server/src/orchestration/Schemas.ts index f7ebf693440..8294d5412bb 100644 --- a/apps/server/src/orchestration/Schemas.ts +++ b/apps/server/src/orchestration/Schemas.ts @@ -11,6 +11,7 @@ import { ThreadUnarchivedPayload as ContractsThreadUnarchivedPayloadSchema, ThreadMessageSentPayload as ContractsThreadMessageSentPayloadSchema, ThreadProposedPlanUpsertedPayload as ContractsThreadProposedPlanUpsertedPayloadSchema, + ThreadProposedPlanRemovedPayload as ContractsThreadProposedPlanRemovedPayloadSchema, ThreadSessionSetPayload as ContractsThreadSessionSetPayloadSchema, ThreadTurnDiffCompletedPayload as ContractsThreadTurnDiffCompletedPayloadSchema, ThreadRevertedPayload as ContractsThreadRevertedPayloadSchema, @@ -37,6 +38,7 @@ export const ThreadUnarchivedPayload = ContractsThreadUnarchivedPayloadSchema; export const MessageSentPayloadSchema = ContractsThreadMessageSentPayloadSchema; export const ThreadProposedPlanUpsertedPayload = ContractsThreadProposedPlanUpsertedPayloadSchema; +export const ThreadProposedPlanRemovedPayload = ContractsThreadProposedPlanRemovedPayloadSchema; export const ThreadSessionSetPayload = ContractsThreadSessionSetPayloadSchema; export const ThreadTurnDiffCompletedPayload = ContractsThreadTurnDiffCompletedPayloadSchema; export const ThreadRevertedPayload = ContractsThreadRevertedPayloadSchema; diff --git a/apps/server/src/orchestration/decider.proposedPlan.test.ts b/apps/server/src/orchestration/decider.proposedPlan.test.ts new file mode 100644 index 00000000000..361c682625f --- /dev/null +++ b/apps/server/src/orchestration/decider.proposedPlan.test.ts @@ -0,0 +1,444 @@ +import { + CommandId, + DEFAULT_PROVIDER_INTERACTION_MODE, + EventId, + MessageId, + ProjectId, + ProviderInstanceId, + ThreadId, + TurnId, + type OrchestrationCommand, + type OrchestrationReadModel, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import { describe, expect, it } from "vitest"; + +import { decideOrchestrationCommand } from "./decider.ts"; +import { createEmptyReadModel, projectEvent } from "./projector.ts"; + +const asCommandId = (value: string): CommandId => CommandId.make(value); +const asEventId = (value: string): EventId => EventId.make(value); +const asProjectId = (value: string): ProjectId => ProjectId.make(value); +const asThreadId = (value: string): ThreadId => ThreadId.make(value); +const asMessageId = (value: string): MessageId => MessageId.make(value); +const asTurnId = (value: string): TurnId => TurnId.make(value); + +async function seedReadModelWithProject(): Promise { + const now = "2026-01-01T00:00:00.000Z"; + const initial = createEmptyReadModel(now); + return await Effect.runPromise( + projectEvent(initial, { + sequence: 1, + eventId: asEventId("evt-project-create"), + aggregateKind: "project", + aggregateId: asProjectId("project-proposedplan"), + type: "project.created", + occurredAt: now, + commandId: asCommandId("cmd-project-create"), + causationEventId: null, + correlationId: asCommandId("cmd-project-create"), + metadata: {}, + payload: { + projectId: asProjectId("project-proposedplan"), + title: "Project ProposedPlan", + workspaceRoot: "/tmp/project-proposedplan", + defaultModelSelection: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }), + ); +} + +async function seedReadModelWithThreadAndMessages( + projectReadModel: OrchestrationReadModel, +): Promise { + const now = "2026-01-01T00:00:00.000Z"; + let readModel = projectReadModel; + let sequence = projectReadModel.snapshotSequence + 1; + + readModel = await Effect.runPromise( + projectEvent(readModel, { + sequence, + eventId: asEventId("evt-thread-create"), + aggregateKind: "thread", + aggregateId: asThreadId("thread-proposedplan"), + type: "thread.created", + occurredAt: now, + commandId: asCommandId("cmd-thread-create"), + causationEventId: null, + correlationId: asCommandId("cmd-thread-create"), + metadata: {}, + payload: { + threadId: asThreadId("thread-proposedplan"), + projectId: asProjectId("project-proposedplan"), + title: "Thread ProposedPlan", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }), + ); + sequence += 1; + + readModel = await Effect.runPromise( + projectEvent(readModel, { + sequence, + eventId: asEventId("evt-message-sent-1"), + aggregateKind: "thread", + aggregateId: asThreadId("thread-proposedplan"), + type: "thread.message-sent", + occurredAt: now, + commandId: asCommandId("cmd-message-send-1"), + causationEventId: null, + correlationId: asCommandId("cmd-message-send-1"), + metadata: {}, + payload: { + threadId: asThreadId("thread-proposedplan"), + messageId: asMessageId("msg-user-1"), + role: "user", + text: "What should we do?", + attachments: [], + turnId: asTurnId("turn-1"), + streaming: false, + createdAt: now, + updatedAt: now, + }, + }), + ); + sequence += 1; + + readModel = await Effect.runPromise( + projectEvent(readModel, { + sequence, + eventId: asEventId("evt-message-sent-2"), + aggregateKind: "thread", + aggregateId: asThreadId("thread-proposedplan"), + type: "thread.message-sent", + occurredAt: now, + commandId: asCommandId("cmd-message-send-2"), + causationEventId: null, + correlationId: asCommandId("cmd-message-send-2"), + metadata: {}, + payload: { + threadId: asThreadId("thread-proposedplan"), + messageId: asMessageId("msg-assistant-1"), + role: "assistant", + text: "Here's a plan:\n1. Do this\n2. Then that", + attachments: [], + turnId: asTurnId("turn-1"), + streaming: false, + createdAt: now, + updatedAt: now, + }, + }), + ); + sequence += 1; + + readModel = await Effect.runPromise( + projectEvent(readModel, { + sequence, + eventId: asEventId("evt-message-sent-3"), + aggregateKind: "thread", + aggregateId: asThreadId("thread-proposedplan"), + type: "thread.message-sent", + occurredAt: now, + commandId: asCommandId("cmd-message-send-3"), + causationEventId: null, + correlationId: asCommandId("cmd-message-send-3"), + metadata: {}, + payload: { + threadId: asThreadId("thread-proposedplan"), + messageId: asMessageId("msg-user-2"), + role: "user", + text: "Can we improve it?", + attachments: [], + turnId: asTurnId("turn-2"), + streaming: false, + createdAt: now, + updatedAt: now, + }, + }), + ); + sequence += 1; + + readModel = await Effect.runPromise( + projectEvent(readModel, { + sequence, + eventId: asEventId("evt-message-sent-4"), + aggregateKind: "thread", + aggregateId: asThreadId("thread-proposedplan"), + type: "thread.message-sent", + occurredAt: now, + commandId: asCommandId("cmd-message-send-4"), + causationEventId: null, + correlationId: asCommandId("cmd-message-send-4"), + metadata: {}, + payload: { + threadId: asThreadId("thread-proposedplan"), + messageId: asMessageId("msg-assistant-2"), + role: "assistant", + text: "Updated plan:\n1. Do this first\n2. Then that\n3. Finally this", + attachments: [], + turnId: asTurnId("turn-2"), + streaming: false, + createdAt: now, + updatedAt: now, + }, + }), + ); + + return readModel; +} + +describe("proposed plan promote command", () => { + it("promotes the latest eligible assistant message", async () => { + const projectReadModel = await seedReadModelWithProject(); + const readModel = await seedReadModelWithThreadAndMessages(projectReadModel); + + const promoteCommand: OrchestrationCommand = { + type: "thread.proposed-plan.promote", + commandId: asCommandId("cmd-promote"), + threadId: asThreadId("thread-proposedplan"), + createdAt: "2026-01-01T00:00:01.000Z", + }; + + const decided = await Effect.runPromise( + decideOrchestrationCommand({ + command: promoteCommand, + readModel, + }), + ); + + const event = Array.isArray(decided) ? decided[0] : decided; + expect(event).toBeDefined(); + expect(event.type).toBe("thread.proposed-plan-upserted"); + expect(event.aggregateKind).toBe("thread"); + expect(event.aggregateId).toBe(asThreadId("thread-proposedplan")); + + if (event.type === "thread.proposed-plan-upserted") { + const plan = event.payload.proposedPlan; + expect(plan.planMarkdown).toBe( + "Updated plan:\n1. Do this first\n2. Then that\n3. Finally this", + ); + expect(plan.turnId).toBe(asTurnId("turn-2")); + expect(plan.id).toMatch(/^plan:thread-proposedplan:promoted:msg-assistant-2$/); + } + }); + + it("returns error when no eligible assistant message exists", async () => { + const projectReadModel = await seedReadModelWithProject(); + let readModel = projectReadModel; + let sequence = projectReadModel.snapshotSequence + 1; + const now = "2026-01-01T00:00:00.000Z"; + + readModel = await Effect.runPromise( + projectEvent(readModel, { + sequence, + eventId: asEventId("evt-thread-create"), + aggregateKind: "thread", + aggregateId: asThreadId("thread-empty"), + type: "thread.created", + occurredAt: now, + commandId: asCommandId("cmd-thread-create"), + causationEventId: null, + correlationId: asCommandId("cmd-thread-create"), + metadata: {}, + payload: { + threadId: asThreadId("thread-empty"), + projectId: asProjectId("project-proposedplan"), + title: "Thread Empty", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }), + ); + + const promoteCommand: OrchestrationCommand = { + type: "thread.proposed-plan.promote", + commandId: asCommandId("cmd-promote-empty"), + threadId: asThreadId("thread-empty"), + createdAt: "2026-01-01T00:00:01.000Z", + }; + + await expect( + Effect.runPromise( + decideOrchestrationCommand({ + command: promoteCommand, + readModel, + }), + ), + ).rejects.toThrow("No assistant message available to promote"); + }); + + it("returns error when no assistant message has non-empty text", async () => { + const projectReadModel = await seedReadModelWithProject(); + let readModel = projectReadModel; + let sequence = projectReadModel.snapshotSequence + 1; + const now = "2026-01-01T00:00:00.000Z"; + + readModel = await Effect.runPromise( + projectEvent(readModel, { + sequence, + eventId: asEventId("evt-thread-create-2"), + aggregateKind: "thread", + aggregateId: asThreadId("thread-empty-messages"), + type: "thread.created", + occurredAt: now, + commandId: asCommandId("cmd-thread-create-2"), + causationEventId: null, + correlationId: asCommandId("cmd-thread-create-2"), + metadata: {}, + payload: { + threadId: asThreadId("thread-empty-messages"), + projectId: asProjectId("project-proposedplan"), + title: "Thread Empty Messages", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }), + ); + sequence += 1; + + readModel = await Effect.runPromise( + projectEvent(readModel, { + sequence, + eventId: asEventId("evt-message-sent-user"), + aggregateKind: "thread", + aggregateId: asThreadId("thread-empty-messages"), + type: "thread.message-sent", + occurredAt: now, + commandId: asCommandId("cmd-message-send-user"), + causationEventId: null, + correlationId: asCommandId("cmd-message-send-user"), + metadata: {}, + payload: { + threadId: asThreadId("thread-empty-messages"), + messageId: asMessageId("msg-user-empty"), + role: "user", + text: "Hello?", + attachments: [], + turnId: asTurnId("turn-3"), + streaming: false, + createdAt: now, + updatedAt: now, + }, + }), + ); + sequence += 1; + + readModel = await Effect.runPromise( + projectEvent(readModel, { + sequence, + eventId: asEventId("evt-message-sent-assistant-empty"), + aggregateKind: "thread", + aggregateId: asThreadId("thread-empty-messages"), + type: "thread.message-sent", + occurredAt: now, + commandId: asCommandId("cmd-message-send-assistant-empty"), + causationEventId: null, + correlationId: asCommandId("cmd-message-send-assistant-empty"), + metadata: {}, + payload: { + threadId: asThreadId("thread-empty-messages"), + messageId: asMessageId("msg-assistant-empty"), + role: "assistant", + text: " \n\t ", + attachments: [], + turnId: asTurnId("turn-3"), + streaming: false, + createdAt: now, + updatedAt: now, + }, + }), + ); + + const promoteCommand: OrchestrationCommand = { + type: "thread.proposed-plan.promote", + commandId: asCommandId("cmd-promote-empty-text"), + threadId: asThreadId("thread-empty-messages"), + createdAt: "2026-01-01T00:00:02.000Z", + }; + + await expect( + Effect.runPromise( + decideOrchestrationCommand({ + command: promoteCommand, + readModel, + }), + ), + ).rejects.toThrow("No assistant message available to promote"); + }); + + it("picks M1 when a revert retains only turn T1 (M2 from T2 has been pruned)", async () => { + const projectReadModel = await seedReadModelWithProject(); + const fullReadModel = await seedReadModelWithThreadAndMessages(projectReadModel); + + const fullThread = fullReadModel.threads.find( + (t) => t.id === asThreadId("thread-proposedplan"), + ); + if (!fullThread) throw new Error("expected seeded thread"); + + const retainedMessages = fullThread.messages.filter( + (entry) => entry.turnId === asTurnId("turn-1"), + ); + const readModel: OrchestrationReadModel = { + ...fullReadModel, + threads: fullReadModel.threads.map((entry) => + entry.id === fullThread.id ? { ...entry, messages: retainedMessages } : entry, + ), + }; + + const promoteCommand: OrchestrationCommand = { + type: "thread.proposed-plan.promote", + commandId: asCommandId("cmd-promote-after-revert"), + threadId: asThreadId("thread-proposedplan"), + createdAt: "2026-01-01T00:00:02.000Z", + }; + + const decided = await Effect.runPromise( + decideOrchestrationCommand({ + command: promoteCommand, + readModel, + }), + ); + + const event = Array.isArray(decided) ? decided[0] : decided; + expect(event).toBeDefined(); + expect(event.type).toBe("thread.proposed-plan-upserted"); + + if (event.type === "thread.proposed-plan-upserted") { + expect(event.payload.proposedPlan.id).toBe( + "plan:thread-proposedplan:promoted:msg-assistant-1", + ); + expect(event.payload.proposedPlan.turnId).toBe(asTurnId("turn-1")); + expect(event.payload.proposedPlan.planMarkdown).toBe( + "Here's a plan:\n1. Do this\n2. Then that", + ); + } + }); +}); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 1004c945dbf..d7b39873d02 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -654,6 +654,114 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + case "thread.proposed-plan.promote": { + const thread = yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); + + const latestRevertedPlan = [...thread.proposedPlans] + .filter((entry) => entry.revertedAt !== null) + .toSorted( + (left, right) => + (left.revertedAt ?? "").localeCompare(right.revertedAt ?? "") || + left.id.localeCompare(right.id), + ) + .at(-1); + if (latestRevertedPlan) { + return { + ...withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + }), + type: "thread.proposed-plan-upserted", + payload: { + threadId: command.threadId, + proposedPlan: { + ...latestRevertedPlan, + revertedAt: null, + updatedAt: command.createdAt, + }, + }, + }; + } + + let message: (typeof thread.messages)[number] | undefined; + for (let i = thread.messages.length - 1; i >= 0; i -= 1) { + const entry = thread.messages[i]; + if (entry?.role === "assistant" && entry.text.trim().length > 0) { + message = entry; + break; + } + } + if (!message) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `No assistant message available to promote in thread '${command.threadId}'.`, + }); + } + const planMarkdown = message.text.trim(); + const planId = `plan:${command.threadId}:promoted:${message.id}`; + const existingPlan = thread.proposedPlans.find((entry) => entry.id === planId); + return { + ...withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + }), + type: "thread.proposed-plan-upserted", + payload: { + threadId: command.threadId, + proposedPlan: { + id: planId, + turnId: message.turnId ?? null, + planMarkdown, + implementedAt: existingPlan?.implementedAt ?? null, + implementationThreadId: existingPlan?.implementationThreadId ?? null, + revertedAt: null, + createdAt: existingPlan?.createdAt ?? command.createdAt, + updatedAt: command.createdAt, + }, + }, + }; + } + + case "thread.proposed-plan.revert": { + const thread = yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); + const existingPlan = thread.proposedPlans.find((entry) => entry.id === command.planId); + if (!existingPlan) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Proposed plan '${command.planId}' not found in thread '${command.threadId}'.`, + }); + } + return { + ...withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + }), + type: "thread.proposed-plan-upserted", + payload: { + threadId: command.threadId, + proposedPlan: { + ...existingPlan, + revertedAt: command.createdAt, + updatedAt: command.createdAt, + }, + }, + }; + } + case "thread.turn.diff.complete": { yield* requireThread({ readModel, diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 0c92f965433..83e15e55c5e 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -21,6 +21,7 @@ import { ThreadInteractionModeSetPayload, ThreadMetaUpdatedPayload, ThreadProposedPlanUpsertedPayload, + ThreadProposedPlanRemovedPayload, ThreadRuntimeModeSetPayload, ThreadUnarchivedPayload, ThreadRevertedPayload, @@ -499,6 +500,31 @@ export function projectEvent( }; }); + case "thread.proposed-plan-removed": + return Effect.gen(function* () { + const payload = yield* decodeForEvent( + ThreadProposedPlanRemovedPayload, + event.payload, + event.type, + "payload", + ); + const thread = nextBase.threads.find((entry) => entry.id === payload.threadId); + if (!thread) { + return nextBase; + } + const proposedPlans = thread.proposedPlans.filter((entry) => entry.id !== payload.planId); + if (proposedPlans.length === thread.proposedPlans.length) { + return nextBase; + } + return { + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + proposedPlans, + updatedAt: event.occurredAt, + }), + }; + }); + case "thread.turn-diff-completed": return Effect.gen(function* () { const payload = yield* decodeForEvent( diff --git a/apps/server/src/persistence/Layers/ProjectionThreadProposedPlans.ts b/apps/server/src/persistence/Layers/ProjectionThreadProposedPlans.ts index 63aed1a1670..90b82cdde73 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadProposedPlans.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadProposedPlans.ts @@ -5,6 +5,7 @@ import * as SqlSchema from "effect/unstable/sql/SqlSchema"; import { toPersistenceSqlError } from "../Errors.ts"; import { + DeleteProjectionThreadProposedPlanByIdInput, DeleteProjectionThreadProposedPlansInput, ListProjectionThreadProposedPlansInput, ProjectionThreadProposedPlan, @@ -25,6 +26,7 @@ const makeProjectionThreadProposedPlanRepository = Effect.gen(function* () { plan_markdown, implemented_at, implementation_thread_id, + reverted_at, created_at, updated_at ) @@ -35,6 +37,7 @@ const makeProjectionThreadProposedPlanRepository = Effect.gen(function* () { ${row.planMarkdown}, ${row.implementedAt}, ${row.implementationThreadId}, + ${row.revertedAt}, ${row.createdAt}, ${row.updatedAt} ) @@ -45,6 +48,7 @@ const makeProjectionThreadProposedPlanRepository = Effect.gen(function* () { plan_markdown = excluded.plan_markdown, implemented_at = excluded.implemented_at, implementation_thread_id = excluded.implementation_thread_id, + reverted_at = excluded.reverted_at, created_at = excluded.created_at, updated_at = excluded.updated_at `, @@ -61,6 +65,7 @@ const makeProjectionThreadProposedPlanRepository = Effect.gen(function* () { plan_markdown AS "planMarkdown", implemented_at AS "implementedAt", implementation_thread_id AS "implementationThreadId", + reverted_at AS "revertedAt", created_at AS "createdAt", updated_at AS "updatedAt" FROM projection_thread_proposed_plans @@ -77,6 +82,14 @@ const makeProjectionThreadProposedPlanRepository = Effect.gen(function* () { `, }); + const deleteProjectionThreadProposedPlanRowById = SqlSchema.void({ + Request: DeleteProjectionThreadProposedPlanByIdInput, + execute: ({ planId }) => sql` + DELETE FROM projection_thread_proposed_plans + WHERE plan_id = ${planId} + `, + }); + const upsert: ProjectionThreadProposedPlanRepositoryShape["upsert"] = (row) => upsertProjectionThreadProposedPlanRow(row).pipe( Effect.mapError(toPersistenceSqlError("ProjectionThreadProposedPlanRepository.upsert:query")), @@ -98,10 +111,18 @@ const makeProjectionThreadProposedPlanRepository = Effect.gen(function* () { ), ); + const deleteByPlanId: ProjectionThreadProposedPlanRepositoryShape["deleteByPlanId"] = (input) => + deleteProjectionThreadProposedPlanRowById(input).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionThreadProposedPlanRepository.deleteByPlanId:query"), + ), + ); + return { upsert, listByThreadId, deleteByThreadId, + deleteByPlanId, } satisfies ProjectionThreadProposedPlanRepositoryShape; }); diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index cc5024d5f51..d499a81d7db 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -43,6 +43,7 @@ import Migration0027 from "./Migrations/027_ProviderSessionRuntimeInstanceId.ts" import Migration0028 from "./Migrations/028_ProjectionThreadSessionInstanceId.ts"; import Migration0029 from "./Migrations/029_ProjectionThreadDetailOrderingIndexes.ts"; import Migration0030 from "./Migrations/030_ProjectionThreadShellArchiveIndexes.ts"; +import Migration0031 from "./Migrations/031_ProjectionThreadProposedPlanRevertedAt.ts"; /** * Migration loader with all migrations defined inline. @@ -85,6 +86,7 @@ export const migrationEntries = [ [28, "ProjectionThreadSessionInstanceId", Migration0028], [29, "ProjectionThreadDetailOrderingIndexes", Migration0029], [30, "ProjectionThreadShellArchiveIndexes", Migration0030], + [31, "ProjectionThreadProposedPlanRevertedAt", Migration0031], ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/031_ProjectionThreadProposedPlanRevertedAt.ts b/apps/server/src/persistence/Migrations/031_ProjectionThreadProposedPlanRevertedAt.ts new file mode 100644 index 00000000000..82d3241f0b3 --- /dev/null +++ b/apps/server/src/persistence/Migrations/031_ProjectionThreadProposedPlanRevertedAt.ts @@ -0,0 +1,17 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const columns = yield* sql<{ name: string }>` + PRAGMA table_info(projection_thread_proposed_plans) + `; + + if (!columns.some((column) => column.name === "reverted_at")) { + yield* sql` + ALTER TABLE projection_thread_proposed_plans + ADD COLUMN reverted_at TEXT + `; + } +}); diff --git a/apps/server/src/persistence/Services/ProjectionThreadProposedPlans.ts b/apps/server/src/persistence/Services/ProjectionThreadProposedPlans.ts index b4bc2bcc328..e5b04f2cefe 100644 --- a/apps/server/src/persistence/Services/ProjectionThreadProposedPlans.ts +++ b/apps/server/src/persistence/Services/ProjectionThreadProposedPlans.ts @@ -18,6 +18,7 @@ export const ProjectionThreadProposedPlan = Schema.Struct({ planMarkdown: TrimmedNonEmptyString, implementedAt: Schema.NullOr(IsoDateTime), implementationThreadId: Schema.NullOr(ThreadId), + revertedAt: Schema.NullOr(IsoDateTime), createdAt: IsoDateTime, updatedAt: IsoDateTime, }); @@ -35,6 +36,12 @@ export const DeleteProjectionThreadProposedPlansInput = Schema.Struct({ export type DeleteProjectionThreadProposedPlansInput = typeof DeleteProjectionThreadProposedPlansInput.Type; +export const DeleteProjectionThreadProposedPlanByIdInput = Schema.Struct({ + planId: OrchestrationProposedPlanId, +}); +export type DeleteProjectionThreadProposedPlanByIdInput = + typeof DeleteProjectionThreadProposedPlanByIdInput.Type; + export interface ProjectionThreadProposedPlanRepositoryShape { readonly upsert: ( proposedPlan: ProjectionThreadProposedPlan, @@ -45,6 +52,9 @@ export interface ProjectionThreadProposedPlanRepositoryShape { readonly deleteByThreadId: ( input: DeleteProjectionThreadProposedPlansInput, ) => Effect.Effect; + readonly deleteByPlanId: ( + input: DeleteProjectionThreadProposedPlanByIdInput, + ) => Effect.Effect; } export class ProjectionThreadProposedPlanRepository extends Context.Service< diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index e9bd8fb7a16..7ff1ac64b14 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -123,6 +123,7 @@ interface ClaudeTurnState { readonly assistantTextBlocks: Map; readonly assistantTextBlockOrder: Array; readonly capturedProposedPlanKeys: Set; + readonly interactionMode: "plan" | "default"; nextSyntheticAssistantBlockIndex: number; } @@ -179,6 +180,7 @@ interface ClaudeSessionContext { lastKnownTokenUsage: ThreadTokenUsageSnapshot | undefined; lastAssistantUuid: string | undefined; lastThreadStartedId: string | undefined; + currentInteractionMode: "plan" | "default"; stopped: boolean; } @@ -1377,7 +1379,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( input: { readonly planMarkdown: string; readonly toolUseId?: string | undefined; - readonly rawSource: "claude.sdk.message" | "claude.sdk.permission"; + readonly rawSource: "claude.sdk.message" | "claude.sdk.permission" | "client.user-promoted"; readonly rawMethod: string; readonly rawPayload: unknown; }, @@ -1974,6 +1976,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( assistantTextBlocks: new Map(), assistantTextBlockOrder: [], capturedProposedPlanKeys: new Set(), + interactionMode: context.currentInteractionMode, nextSyntheticAssistantBlockIndex: -1, }; context.session = { @@ -3001,6 +3004,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( lastKnownTokenUsage: undefined, lastAssistantUuid: resumeState?.resumeSessionAt, lastThreadStartedId: undefined, + currentInteractionMode: permissionMode === "plan" ? "plan" : "default", stopped: false, }; yield* Ref.set(contextRef, context); @@ -3113,14 +3117,17 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( try: () => context.query.setPermissionMode("plan"), catch: (cause) => toRequestError(input.threadId, "turn/setPermissionMode", cause), }); + context.currentInteractionMode = "plan"; } else if (input.interactionMode === "default") { yield* Effect.tryPromise({ try: () => context.query.setPermissionMode(context.basePermissionMode ?? "default"), catch: (cause) => toRequestError(input.threadId, "turn/setPermissionMode", cause), }); + context.currentInteractionMode = "default"; } const turnId = TurnId.make(yield* Random.nextUUIDv4); + const resolvedInteractionMode = context.currentInteractionMode; const turnState: ClaudeTurnState = { turnId, startedAt: yield* nowIso, @@ -3128,6 +3135,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( assistantTextBlocks: new Map(), assistantTextBlockOrder: [], capturedProposedPlanKeys: new Set(), + interactionMode: resolvedInteractionMode, nextSyntheticAssistantBlockIndex: -1, }; diff --git a/apps/server/src/sourceControl/AzureDevOpsCli.ts b/apps/server/src/sourceControl/AzureDevOpsCli.ts index d4a4d69267b..025cdf779e6 100644 --- a/apps/server/src/sourceControl/AzureDevOpsCli.ts +++ b/apps/server/src/sourceControl/AzureDevOpsCli.ts @@ -80,10 +80,12 @@ export interface AzureDevOpsCliShape { readonly target?: SourceControlProvider.SourceControlRefSelector; readonly title: string; readonly bodyFile: string; + readonly headRepository?: string; }) => Effect.Effect; readonly getDefaultBranch: (input: { readonly cwd: string; + readonly repository?: string; }) => Effect.Effect; readonly checkoutPullRequest: (input: { diff --git a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts index 8d8e081cb89..3fb9a890137 100644 --- a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts +++ b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts @@ -116,6 +116,7 @@ export const make = Effect.fn("makeAzureDevOpsSourceControlProvider")(function* ...(input.target !== undefined ? { target: input.target } : {}), title: input.title, bodyFile: input.bodyFile, + ...(input.headRepository !== undefined ? { headRepository: input.headRepository } : {}), }) .pipe(Effect.mapError((error) => providerError("createChangeRequest", error))); }, @@ -129,7 +130,10 @@ export const make = Effect.fn("makeAzureDevOpsSourceControlProvider")(function* .pipe(Effect.mapError((error) => providerError("createRepository", error))), getDefaultBranch: (input) => azure - .getDefaultBranch({ cwd: input.cwd }) + .getDefaultBranch({ + cwd: input.cwd, + ...(input.repository !== undefined ? { repository: input.repository } : {}), + }) .pipe(Effect.mapError((error) => providerError("getDefaultBranch", error))), checkoutChangeRequest: (input) => azure diff --git a/apps/server/src/sourceControl/BitbucketApi.ts b/apps/server/src/sourceControl/BitbucketApi.ts index 748113ba04f..bd1bd4336fc 100644 --- a/apps/server/src/sourceControl/BitbucketApi.ts +++ b/apps/server/src/sourceControl/BitbucketApi.ts @@ -140,10 +140,12 @@ export interface BitbucketApiShape { readonly target?: SourceControlProvider.SourceControlRefSelector; readonly title: string; readonly bodyFile: string; + readonly headRepository?: string; }) => Effect.Effect; readonly getDefaultBranch: (input: { readonly cwd: string; readonly context?: SourceControlProvider.SourceControlProviderContext; + readonly repository?: string; }) => Effect.Effect; readonly checkoutPullRequest: (input: { readonly cwd: string; diff --git a/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts b/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts index f3fd502f7fb..a7949055d53 100644 --- a/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts +++ b/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts @@ -82,6 +82,7 @@ export const make = Effect.fn("makeBitbucketSourceControlProvider")(function* () ...(input.target ? { target: input.target } : {}), title: input.title, bodyFile: input.bodyFile, + ...(input.headRepository !== undefined ? { headRepository: input.headRepository } : {}), }) .pipe(Effect.mapError((error) => providerError("createChangeRequest", error))); }, @@ -98,6 +99,7 @@ export const make = Effect.fn("makeBitbucketSourceControlProvider")(function* () .getDefaultBranch({ cwd: input.cwd, ...(input.context ? { context: input.context } : {}), + ...(input.repository !== undefined ? { repository: input.repository } : {}), }) .pipe(Effect.mapError((error) => providerError("getDefaultBranch", error))), checkoutChangeRequest: (input) => diff --git a/apps/server/src/sourceControl/GitHubCli.test.ts b/apps/server/src/sourceControl/GitHubCli.test.ts index fb765b352c2..aa0f94b2875 100644 --- a/apps/server/src/sourceControl/GitHubCli.test.ts +++ b/apps/server/src/sourceControl/GitHubCli.test.ts @@ -267,6 +267,77 @@ describe("GitHubCli.layer", () => { }).pipe(Effect.provide(layer)), ); + it.effect("appends --repo when headRepository is provided to createPullRequest", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce(Effect.succeed(processOutput(""))); + + const gh = yield* GitHubCli.GitHubCli; + yield* gh.createPullRequest({ + cwd: "/repo", + baseBranch: "main", + headSelector: "aa/test", + title: "Test PR", + bodyFile: "/tmp/body.md", + headRepository: "imabdulazeez/t3code", + }); + + expect(mockRun).toHaveBeenCalledWith({ + operation: "GitHubCli.execute", + command: "gh", + args: [ + "pr", + "create", + "--base", + "main", + "--head", + "aa/test", + "--title", + "Test PR", + "--body-file", + "/tmp/body.md", + "--repo", + "imabdulazeez/t3code", + ], + cwd: "/repo", + timeoutMs: 30_000, + }); + }).pipe(Effect.provide(layer)), + ); + + it.effect("omits --repo when headRepository is not provided to createPullRequest", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce(Effect.succeed(processOutput(""))); + + const gh = yield* GitHubCli.GitHubCli; + yield* gh.createPullRequest({ + cwd: "/repo", + baseBranch: "main", + headSelector: "aa/test", + title: "Test PR", + bodyFile: "/tmp/body.md", + }); + + expect(mockRun).toHaveBeenCalledWith({ + operation: "GitHubCli.execute", + command: "gh", + args: [ + "pr", + "create", + "--base", + "main", + "--head", + "aa/test", + "--title", + "Test PR", + "--body-file", + "/tmp/body.md", + ], + cwd: "/repo", + timeoutMs: 30_000, + }); + }).pipe(Effect.provide(layer)), + ); + it.effect("surfaces a friendly error when the pull request is not found", () => Effect.gen(function* () { mockRun.mockReturnValueOnce( diff --git a/apps/server/src/sourceControl/GitHubCli.ts b/apps/server/src/sourceControl/GitHubCli.ts index 14d01aab2ed..5c17b90ab53 100644 --- a/apps/server/src/sourceControl/GitHubCli.ts +++ b/apps/server/src/sourceControl/GitHubCli.ts @@ -55,6 +55,7 @@ export interface GitHubCliShape { readonly cwd: string; readonly headSelector: string; readonly limit?: number; + readonly repository?: string; }) => Effect.Effect, GitHubCliError>; readonly getPullRequest: (input: { @@ -79,10 +80,12 @@ export interface GitHubCliShape { readonly headSelector: string; readonly title: string; readonly bodyFile: string; + readonly headRepository?: string; }) => Effect.Effect; readonly getDefaultBranch: (input: { readonly cwd: string; + readonly repository?: string; }) => Effect.Effect; readonly checkoutPullRequest: (input: { @@ -256,6 +259,7 @@ export const make = Effect.fn("makeGitHubCli")(function* () { String(input.limit ?? 1), "--json", "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", + ...(input.repository ? ["--repo", input.repository] : []), ], }).pipe( Effect.map((result) => result.stdout.trim()), @@ -352,12 +356,21 @@ export const make = Effect.fn("makeGitHubCli")(function* () { input.title, "--body-file", input.bodyFile, + ...(input.headRepository ? ["--repo", input.headRepository] : []), ], }).pipe(Effect.asVoid), getDefaultBranch: (input) => execute({ cwd: input.cwd, - args: ["repo", "view", "--json", "defaultBranchRef", "--jq", ".defaultBranchRef.name"], + args: [ + "repo", + "view", + "--json", + "defaultBranchRef", + "--jq", + ".defaultBranchRef.name", + ...(input.repository ? ["--repo", input.repository] : []), + ], }).pipe( Effect.map((value) => { const trimmed = value.stdout.trim(); diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts index cc892015fce..158b453ac54 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -103,6 +103,7 @@ export const make = Effect.fn("makeGitHubSourceControlProvider")(function* () { cwd: input.cwd, headSelector: input.headSelector, ...(input.limit !== undefined ? { limit: input.limit } : {}), + ...(input.repository !== undefined ? { repository: input.repository } : {}), }) .pipe( Effect.map((items) => items.map(toChangeRequest)), @@ -125,6 +126,7 @@ export const make = Effect.fn("makeGitHubSourceControlProvider")(function* () { String(input.limit ?? 20), "--json", "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + ...(input.repository ? ["--repo", input.repository] : []), ], }) .pipe( @@ -177,6 +179,7 @@ export const make = Effect.fn("makeGitHubSourceControlProvider")(function* () { headSelector: input.headSelector, title: input.title, bodyFile: input.bodyFile, + ...(input.headRepository !== undefined ? { headRepository: input.headRepository } : {}), }) .pipe(Effect.mapError((error) => providerError("createChangeRequest", error))), getRepositoryCloneUrls: (input) => @@ -189,7 +192,10 @@ export const make = Effect.fn("makeGitHubSourceControlProvider")(function* () { .pipe(Effect.mapError((error) => providerError("createRepository", error))), getDefaultBranch: (input) => github - .getDefaultBranch(input) + .getDefaultBranch({ + cwd: input.cwd, + ...(input.repository !== undefined ? { repository: input.repository } : {}), + }) .pipe(Effect.mapError((error) => providerError("getDefaultBranch", error))), checkoutChangeRequest: (input) => github diff --git a/apps/server/src/sourceControl/GitLabCli.ts b/apps/server/src/sourceControl/GitLabCli.ts index faabe87263d..df4580e18dc 100644 --- a/apps/server/src/sourceControl/GitLabCli.ts +++ b/apps/server/src/sourceControl/GitLabCli.ts @@ -83,10 +83,12 @@ export interface GitLabCliShape { readonly target?: SourceControlProvider.SourceControlRefSelector; readonly title: string; readonly bodyFile: string; + readonly headRepository?: string; }) => Effect.Effect; readonly getDefaultBranch: (input: { readonly cwd: string; + readonly repository?: string; }) => Effect.Effect; readonly checkoutMergeRequest: (input: { diff --git a/apps/server/src/sourceControl/GitLabSourceControlProvider.ts b/apps/server/src/sourceControl/GitLabSourceControlProvider.ts index ccab2bd1f76..fc905a37be1 100644 --- a/apps/server/src/sourceControl/GitLabSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitLabSourceControlProvider.ts @@ -121,6 +121,7 @@ export const make = Effect.fn("makeGitLabSourceControlProvider")(function* () { ...(input.target ? { target: input.target } : {}), title: input.title, bodyFile: input.bodyFile, + ...(input.headRepository !== undefined ? { headRepository: input.headRepository } : {}), }) .pipe(Effect.mapError((error) => providerError("createChangeRequest", error))); }, @@ -134,7 +135,10 @@ export const make = Effect.fn("makeGitLabSourceControlProvider")(function* () { .pipe(Effect.mapError((error) => providerError("createRepository", error))), getDefaultBranch: (input) => gitlab - .getDefaultBranch(input) + .getDefaultBranch({ + cwd: input.cwd, + ...(input.repository !== undefined ? { repository: input.repository } : {}), + }) .pipe(Effect.mapError((error) => providerError("getDefaultBranch", error))), checkoutChangeRequest: (input) => gitlab diff --git a/apps/server/src/sourceControl/SourceControlProvider.ts b/apps/server/src/sourceControl/SourceControlProvider.ts index a0465008212..3d6e6fa2372 100644 --- a/apps/server/src/sourceControl/SourceControlProvider.ts +++ b/apps/server/src/sourceControl/SourceControlProvider.ts @@ -58,6 +58,7 @@ export interface SourceControlProviderShape { readonly headSelector: string; readonly state: ChangeRequestState | "all"; readonly limit?: number; + readonly repository?: string; }) => Effect.Effect, SourceControlProviderError>; readonly getChangeRequest: (input: { readonly cwd: string; @@ -73,6 +74,7 @@ export interface SourceControlProviderShape { readonly headSelector: string; readonly title: string; readonly bodyFile: string; + readonly headRepository?: string; }) => Effect.Effect; readonly getRepositoryCloneUrls: (input: { readonly cwd: string; @@ -87,6 +89,7 @@ export interface SourceControlProviderShape { readonly getDefaultBranch: (input: { readonly cwd: string; readonly context?: SourceControlProviderContext; + readonly repository?: string; }) => Effect.Effect; readonly checkoutChangeRequest: (input: { readonly cwd: string; diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.ts index 3c1e8c69673..55fc32eb52f 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.ts @@ -27,6 +27,7 @@ import { import { normalizeCliError, sanitizeCommitSubject, + sanitizePrBody, sanitizePrTitle, sanitizeThreadTitle, toJsonSchemaObject, @@ -266,6 +267,7 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu stagedSummary: input.stagedSummary, stagedPatch: input.stagedPatch, includeBranch: input.includeBranch === true, + ...(input.instructionsOverride ? { instructionsOverride: input.instructionsOverride } : {}), }); const generated = yield* runClaudeJson({ @@ -294,6 +296,7 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu commitSummary: input.commitSummary, diffSummary: input.diffSummary, diffPatch: input.diffPatch, + ...(input.instructionsOverride ? { instructionsOverride: input.instructionsOverride } : {}), }); const generated = yield* runClaudeJson({ @@ -306,7 +309,7 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu return { title: sanitizePrTitle(generated.title), - body: generated.body.trim(), + body: sanitizePrBody(generated.body), }; }); @@ -316,6 +319,7 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu const { prompt, outputSchema } = buildBranchNamePrompt({ message: input.message, attachments: input.attachments, + ...(input.instructionsOverride ? { instructionsOverride: input.instructionsOverride } : {}), }); const generated = yield* runClaudeJson({ diff --git a/apps/server/src/textGeneration/CodexTextGeneration.ts b/apps/server/src/textGeneration/CodexTextGeneration.ts index 3d1637a7fc0..e37d14de31b 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.ts @@ -29,6 +29,7 @@ import { import { normalizeCliError, sanitizeCommitSubject, + sanitizePrBody, sanitizePrTitle, sanitizeThreadTitle, toJsonSchemaObject, @@ -312,6 +313,7 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func stagedSummary: input.stagedSummary, stagedPatch: input.stagedPatch, includeBranch: input.includeBranch === true, + ...(input.instructionsOverride ? { instructionsOverride: input.instructionsOverride } : {}), }); const generated = yield* runCodexJson({ @@ -340,6 +342,7 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func commitSummary: input.commitSummary, diffSummary: input.diffSummary, diffPatch: input.diffPatch, + ...(input.instructionsOverride ? { instructionsOverride: input.instructionsOverride } : {}), }); const generated = yield* runCodexJson({ @@ -352,7 +355,7 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func return { title: sanitizePrTitle(generated.title), - body: generated.body.trim(), + body: sanitizePrBody(generated.body), }; }); @@ -366,6 +369,7 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func const { prompt, outputSchema } = buildBranchNamePrompt({ message: input.message, attachments: input.attachments, + ...(input.instructionsOverride ? { instructionsOverride: input.instructionsOverride } : {}), }); const generated = yield* runCodexJson({ diff --git a/apps/server/src/textGeneration/CursorTextGeneration.ts b/apps/server/src/textGeneration/CursorTextGeneration.ts index c4ef1af21d1..6cc078e9c1b 100644 --- a/apps/server/src/textGeneration/CursorTextGeneration.ts +++ b/apps/server/src/textGeneration/CursorTextGeneration.ts @@ -18,6 +18,7 @@ import { } from "./TextGenerationPrompts.ts"; import { sanitizeCommitSubject, + sanitizePrBody, sanitizePrTitle, sanitizeThreadTitle, } from "./TextGenerationUtils.ts"; @@ -183,6 +184,7 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu stagedSummary: input.stagedSummary, stagedPatch: input.stagedPatch, includeBranch: input.includeBranch === true, + ...(input.instructionsOverride ? { instructionsOverride: input.instructionsOverride } : {}), }); const generated = yield* runCursorJson({ @@ -211,6 +213,7 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu commitSummary: input.commitSummary, diffSummary: input.diffSummary, diffPatch: input.diffPatch, + ...(input.instructionsOverride ? { instructionsOverride: input.instructionsOverride } : {}), }); const generated = yield* runCursorJson({ @@ -223,7 +226,7 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu return { title: sanitizePrTitle(generated.title), - body: generated.body.trim(), + body: sanitizePrBody(generated.body), }; }); @@ -233,6 +236,7 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu const { prompt, outputSchema } = buildBranchNamePrompt({ message: input.message, attachments: input.attachments, + ...(input.instructionsOverride ? { instructionsOverride: input.instructionsOverride } : {}), }); const generated = yield* runCursorJson({ diff --git a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts index b865b2e5ef5..0b87a0eaad9 100644 --- a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts +++ b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts @@ -26,6 +26,7 @@ import { import { type TextGenerationShape } from "./TextGeneration.ts"; import { sanitizeCommitSubject, + sanitizePrBody, sanitizePrTitle, sanitizeThreadTitle, } from "./TextGenerationUtils.ts"; @@ -374,6 +375,7 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" stagedSummary: input.stagedSummary, stagedPatch: input.stagedPatch, includeBranch: input.includeBranch === true, + ...(input.instructionsOverride ? { instructionsOverride: input.instructionsOverride } : {}), }); const generated = yield* runOpenCodeJson({ operation: "generateCommitMessage", @@ -401,6 +403,7 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" commitSummary: input.commitSummary, diffSummary: input.diffSummary, diffPatch: input.diffPatch, + ...(input.instructionsOverride ? { instructionsOverride: input.instructionsOverride } : {}), }); const generated = yield* runOpenCodeJson({ operation: "generatePrContent", @@ -412,7 +415,7 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" return { title: sanitizePrTitle(generated.title), - body: generated.body.trim(), + body: sanitizePrBody(generated.body), }; }); @@ -422,6 +425,7 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" const { prompt, outputSchema } = buildBranchNamePrompt({ message: input.message, attachments: input.attachments, + ...(input.instructionsOverride ? { instructionsOverride: input.instructionsOverride } : {}), }); const generated = yield* runOpenCodeJson({ operation: "generateBranchName", diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts index 36a23d509db..71843f27412 100644 --- a/apps/server/src/textGeneration/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -21,6 +21,8 @@ export interface CommitMessageGenerationInput { includeBranch?: boolean; /** What model and provider to use for generation. */ modelSelection: ModelSelection; + /** Custom prompt instructions to replace the built-in ones. */ + instructionsOverride?: string | undefined; } export interface CommitMessageGenerationResult { @@ -39,6 +41,8 @@ export interface PrContentGenerationInput { diffPatch: string; /** What model and provider to use for generation. */ modelSelection: ModelSelection; + /** Custom prompt instructions to replace the built-in ones. */ + instructionsOverride?: string | undefined; } export interface PrContentGenerationResult { @@ -52,6 +56,8 @@ export interface BranchNameGenerationInput { attachments?: ReadonlyArray | undefined; /** What model and provider to use for generation. */ modelSelection: ModelSelection; + /** Custom prompt instructions to replace the built-in ones. */ + instructionsOverride?: string | undefined; } export interface BranchNameGenerationResult { diff --git a/apps/server/src/textGeneration/TextGenerationPrompts.ts b/apps/server/src/textGeneration/TextGenerationPrompts.ts index 6015e83b5d4..6d15124712f 100644 --- a/apps/server/src/textGeneration/TextGenerationPrompts.ts +++ b/apps/server/src/textGeneration/TextGenerationPrompts.ts @@ -17,6 +17,17 @@ function policyInstruction(instruction: string | undefined): ReadonlyArray; + instructionsOverride: string | undefined; + contractLine: string; + contextLines: ReadonlyArray; +}): string { + const override = input.instructionsOverride?.trim(); + const instructionLines = override ? [override] : input.instructions; + return [...instructionLines, input.contractLine, ...input.contextLines].join("\n"); +} + // --------------------------------------------------------------------------- // Commit message // --------------------------------------------------------------------------- @@ -27,16 +38,14 @@ export interface CommitMessagePromptInput { stagedPatch: string; includeBranch: boolean; policy?: TextGenerationPolicy | undefined; + instructionsOverride?: string | undefined; } export function buildCommitMessagePrompt(input: CommitMessagePromptInput) { const wantsBranch = input.includeBranch; - const prompt = [ + const instructions = [ "You write concise git commit messages.", - wantsBranch - ? "Return a JSON object with keys: subject, body, branch." - : "Return a JSON object with keys: subject, body.", "Rules:", "- subject must be imperative, <= 72 chars, and no trailing period", "- body can be empty string or short bullet points", @@ -44,7 +53,14 @@ export function buildCommitMessagePrompt(input: CommitMessagePromptInput) { ? ["- branch must be a short semantic git branch fragment for this change"] : []), "- capture the primary user-visible or developer-visible change", - ...policyInstruction(input.policy?.commitInstructions), + ]; + + const contractLine = wantsBranch + ? "Return a JSON object with keys: subject, body, branch." + : "Return a JSON object with keys: subject, body."; + + const contextLines = [ + ...policyInstruction(input.instructionsOverride ? undefined : input.policy?.commitInstructions), "", `Branch: ${input.branch ?? "(detached)"}`, "", @@ -53,7 +69,14 @@ export function buildCommitMessagePrompt(input: CommitMessagePromptInput) { "", "Staged patch:", limitSection(input.stagedPatch, 40_000), - ].join("\n"); + ]; + + const prompt = buildPromptSections({ + instructions, + instructionsOverride: input.instructionsOverride, + contractLine, + contextLines, + }); if (wantsBranch) { return { @@ -86,18 +109,27 @@ export interface PrContentPromptInput { diffSummary: string; diffPatch: string; policy?: TextGenerationPolicy | undefined; + instructionsOverride?: string | undefined; } export function buildPrContentPrompt(input: PrContentPromptInput) { - const prompt = [ + const instructions = [ "You write GitHub pull request content.", - "Return a JSON object with keys: title, body.", "Rules:", "- title should be concise and specific", "- body must be markdown and include headings '## Summary' and '## Testing'", + "- body must be plain markdown text only — do NOT wrap it in JSON, code fences, or repeat the title/body keys inside the body", + "- do NOT serialize the response as a string inside a field; the title and body fields receive their literal values directly", "- under Summary, provide short bullet points", "- under Testing, include bullet points with concrete checks or 'Not run' where appropriate", - ...policyInstruction(input.policy?.changeRequestInstructions), + ]; + + const contractLine = "Return a JSON object with keys: title, body."; + + const contextLines = [ + ...policyInstruction( + input.instructionsOverride ? undefined : input.policy?.changeRequestInstructions, + ), "", `Base branch: ${input.baseBranch}`, `Head branch: ${input.headBranch}`, @@ -110,7 +142,14 @@ export function buildPrContentPrompt(input: PrContentPromptInput) { "", "Diff patch:", limitSection(input.diffPatch, 40_000), - ].join("\n"); + ]; + + const prompt = buildPromptSections({ + instructions, + instructionsOverride: input.instructionsOverride, + contractLine, + contextLines, + }); const outputSchema = Schema.Struct({ title: Schema.String, @@ -128,6 +167,7 @@ export interface BranchNamePromptInput { message: string; attachments?: ReadonlyArray | undefined; policy?: TextGenerationPolicy | undefined; + instructionsOverride?: string | undefined; } interface PromptFromMessageInput { @@ -137,6 +177,7 @@ interface PromptFromMessageInput { message: string; attachments?: ReadonlyArray | undefined; additionalInstructions?: string | undefined; + instructionsOverride?: string | undefined; } function buildPromptFromMessage(input: PromptFromMessageInput): string { @@ -144,25 +185,26 @@ function buildPromptFromMessage(input: PromptFromMessageInput): string { (attachment) => `- ${attachment.name} (${attachment.mimeType}, ${attachment.sizeBytes} bytes)`, ); - const promptSections = [ - input.instruction, - input.responseShape, - "Rules:", - ...input.rules.map((rule) => `- ${rule}`), + const instructions = [input.instruction, "Rules:", ...input.rules.map((rule) => `- ${rule}`)]; + const contractLine = input.responseShape; + + const contextLines = [ + ...policyInstruction(input.instructionsOverride ? undefined : input.additionalInstructions), "", "User message:", limitSection(input.message, 8_000), - ...policyInstruction(input.additionalInstructions), ]; + if (attachmentLines.length > 0) { - promptSections.push( - "", - "Attachment metadata:", - limitSection(attachmentLines.join("\n"), 4_000), - ); + contextLines.push("", "Attachment metadata:", limitSection(attachmentLines.join("\n"), 4_000)); } - return promptSections.join("\n"); + return buildPromptSections({ + instructions, + instructionsOverride: input.instructionsOverride, + contractLine, + contextLines, + }); } export function buildBranchNamePrompt(input: BranchNamePromptInput) { @@ -178,6 +220,7 @@ export function buildBranchNamePrompt(input: BranchNamePromptInput) { message: input.message, attachments: input.attachments, additionalInstructions: input.policy?.branchInstructions, + instructionsOverride: input.instructionsOverride, }); const outputSchema = Schema.Struct({ branch: Schema.String, diff --git a/apps/server/src/textGeneration/TextGenerationUtils.ts b/apps/server/src/textGeneration/TextGenerationUtils.ts index a786f81b2c8..3d7d4572ff2 100644 --- a/apps/server/src/textGeneration/TextGenerationUtils.ts +++ b/apps/server/src/textGeneration/TextGenerationUtils.ts @@ -42,6 +42,34 @@ export function sanitizePrTitle(raw: string): string { return "Update project changes"; } +export function sanitizePrBody(raw: string): string { + const trimmed = raw.trim(); + return unwrapPrBodyEnvelope(trimmed, 3); +} + +function unwrapPrBodyEnvelope(value: string, depth: number): string { + if (depth <= 0) return value; + if (!(value.startsWith("{") && value.endsWith("}"))) return value; + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + return value; + } + if ( + parsed !== null && + typeof parsed === "object" && + "body" in parsed && + typeof (parsed as { body: unknown }).body === "string" && + "title" in parsed && + typeof (parsed as { title: unknown }).title === "string" + ) { + const inner = (parsed as { body: string }).body.trim(); + return unwrapPrBodyEnvelope(inner, depth - 1); + } + return value; +} + /** Normalise a raw thread title to a compact single-line sidebar-safe label. */ export function sanitizeThreadTitle(raw: string): string { const normalized = raw diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index e99672161c9..a0e653224d2 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -99,6 +99,7 @@ function isThreadDetailEvent(event: OrchestrationEvent): event is Extract< type: | "thread.message-sent" | "thread.proposed-plan-upserted" + | "thread.proposed-plan-removed" | "thread.activity-appended" | "thread.turn-diff-completed" | "thread.reverted" @@ -108,6 +109,7 @@ function isThreadDetailEvent(event: OrchestrationEvent): event is Extract< return ( event.type === "thread.message-sent" || event.type === "thread.proposed-plan-upserted" || + event.type === "thread.proposed-plan-removed" || event.type === "thread.activity-appended" || event.type === "thread.turn-diff-completed" || event.type === "thread.reverted" || diff --git a/apps/web/index.html b/apps/web/index.html index 88e1c8b4f23..33ada2e35b7 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -89,7 +89,7 @@ href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300..800;1,9..40,300..800&display=swap" rel="stylesheet" /> - T3 Code (Alpha) + T3 Code (A3)
diff --git a/apps/web/src/branding.test.ts b/apps/web/src/branding.test.ts index 096fc16ecc0..498233f62b4 100644 --- a/apps/web/src/branding.test.ts +++ b/apps/web/src/branding.test.ts @@ -23,6 +23,8 @@ describe("branding", () => { baseName: "T3 Code", stageLabel: "Nightly", displayName: "T3 Code (Nightly)", + displayVersion: "1.2.3-a3-20260508-1430", + appVersion: "1.2.3", }), }, }, diff --git a/apps/web/src/branding.ts b/apps/web/src/branding.ts index 5c1309ca06b..a1bc75173e5 100644 --- a/apps/web/src/branding.ts +++ b/apps/web/src/branding.ts @@ -19,7 +19,10 @@ export const APP_BASE_NAME = injectedDesktopAppBranding?.baseName ?? "T3 Code"; export const APP_STAGE_LABEL = injectedDesktopAppBranding?.stageLabel ?? HOSTED_APP_CHANNEL_LABEL ?? - (import.meta.env.DEV ? "Dev" : "Alpha"); + (import.meta.env.DEV ? "Dev" : "A3"); export const APP_DISPLAY_NAME = injectedDesktopAppBranding?.displayName ?? `${APP_BASE_NAME} (${APP_STAGE_LABEL})`; -export const APP_VERSION = import.meta.env.APP_VERSION || "0.0.0"; +export const APP_VERSION = + injectedDesktopAppBranding?.displayVersion ?? import.meta.env.APP_VERSION ?? "0.0.0"; +export const APP_PKG_VERSION = + injectedDesktopAppBranding?.appVersion ?? import.meta.env.APP_VERSION ?? "0.0.0"; diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 2e30edfc02c..93ea545faa7 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -70,7 +70,7 @@ function getBranchTriggerLabel(input: { }): string { const { activeWorktreePath, effectiveEnvMode, resolvedActiveBranch } = input; if (!resolvedActiveBranch) { - return "Select ref"; + return "Select branch"; } if (effectiveEnvMode === "worktree" && !activeWorktreePath) { return `From ${resolvedActiveBranch}`; @@ -295,11 +295,11 @@ export function BranchToolbarBranchSelector({ const shouldVirtualizeBranchList = filteredBranchPickerItems.length > 40; const totalBranchCount = branchesSearchData?.pages[0]?.totalCount ?? 0; const branchStatusText = isBranchesSearchPending - ? "Loading refs..." + ? "Loading branches..." : isFetchingNextPage - ? "Loading more refs..." + ? "Loading more branches..." : hasNextPage - ? `Showing ${refs.length} of ${totalBranchCount} refs` + ? `Showing ${refs.length} of ${totalBranchCount} branches` : null; // --------------------------------------------------------------------------- @@ -363,7 +363,7 @@ export function BranchToolbarBranchSelector({ toastManager.add( stackedThreadToast({ type: "error", - title: "Failed to switch ref.", + title: "Failed to switch branch.", description: toBranchActionErrorMessage(error), }), ); @@ -395,7 +395,7 @@ export function BranchToolbarBranchSelector({ toastManager.add( stackedThreadToast({ type: "error", - title: "Failed to create and switch ref.", + title: "Failed to create and switch branch.", description: toBranchActionErrorMessage(error), }), ); @@ -535,7 +535,7 @@ export function BranchToolbarBranchSelector({ value={itemValue} onClick={() => createRef(trimmedBranchQuery)} > - Create new ref "{trimmedBranchQuery}" + Create new branch "{trimmedBranchQuery}" ); } @@ -602,14 +602,14 @@ export function BranchToolbarBranchSelector({ setBranchQuery(event.target.value)} />
- No refs found. + No branches found. {shouldVirtualizeBranchList ? ( diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index bb62d8edbc0..25459d37e86 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -2431,11 +2431,11 @@ describe("ChatView timeline estimator parity (full app)", () => { branchButton.click(); const branchInput = await waitForElement( - () => document.querySelector('input[placeholder="Search refs..."]'), + () => document.querySelector('input[placeholder="Search branches..."]'), "Unable to find ref search input.", ); branchInput.focus(); - await page.getByPlaceholder("Search refs...").fill("1359"); + await page.getByPlaceholder("Search branches...").fill("1359"); const checkoutItem = await waitForElement( () => @@ -3314,7 +3314,7 @@ describe("ChatView timeline estimator parity (full app)", () => { branchButton.click(); await waitForElement( - () => document.querySelector('input[placeholder="Search refs..."]'), + () => document.querySelector('input[placeholder="Search branches..."]'), "Unable to find ref search input.", ); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index d66d2487ce3..cfccc235f80 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -59,7 +59,7 @@ import { findSidebarProposedPlan, findLatestProposedPlan, deriveWorkLogEntries, - hasActionableProposedPlan, + hasReimplementableProposedPlan, hasToolActivityForTurn, isLatestTurnSettled, formatElapsed, @@ -90,12 +90,14 @@ import { DEFAULT_THREAD_TERMINAL_ID, MAX_TERMINALS_PER_GROUP, type ChatMessage, + type ChatAttachment, type SessionPhase, type Thread, type TurnDiffSummary, } from "../types"; import { useTheme } from "../hooks/useTheme"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; +import { useStartImplementationDraftFromPlan } from "../hooks/useHandleNewThread"; import { useCommandPaletteStore } from "../commandPaletteStore"; import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; import { useMediaQuery } from "../hooks/useMediaQuery"; @@ -604,6 +606,34 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra ); }); +async function chatImageAttachmentsToComposerImages( + attachments: ReadonlyArray, +): Promise { + const restoredImages: ComposerImageAttachment[] = []; + for (const attachment of attachments) { + if (attachment.type !== "image" || !attachment.previewUrl) { + continue; + } + try { + const response = await fetch(attachment.previewUrl); + const blob = await response.blob(); + const file = new File([blob], attachment.name, { type: attachment.mimeType }); + restoredImages.push({ + type: "image", + id: attachment.id, + name: attachment.name, + mimeType: attachment.mimeType, + sizeBytes: attachment.sizeBytes, + previewUrl: attachment.previewUrl, + file, + }); + } catch { + continue; + } + } + return restoredImages; +} + export default function ChatView(props: ChatViewProps) { const { environmentId, @@ -1314,14 +1344,11 @@ export default function ChatView(props: ChatViewProps) { ? respondingUserInputRequestIds.includes(activePendingUserInput.requestId) : false; const activeProposedPlan = useMemo(() => { - if (!latestTurnSettled) { - return null; - } return findLatestProposedPlan( activeThread?.proposedPlans ?? [], activeLatestTurn?.turnId ?? null, ); - }, [activeLatestTurn?.turnId, activeThread?.proposedPlans, latestTurnSettled]); + }, [activeLatestTurn?.turnId, activeThread?.proposedPlans]); const sidebarProposedPlan = useMemo( () => findSidebarProposedPlan({ @@ -1340,8 +1367,10 @@ export default function ChatView(props: ChatViewProps) { const showPlanFollowUpPrompt = pendingUserInputs.length === 0 && interactionMode === "plan" && - latestTurnSettled && - hasActionableProposedPlan(activeProposedPlan); + phase !== "running" && + hasReimplementableProposedPlan(activeProposedPlan); + const isPlanReimplementation = + activeProposedPlan !== null && activeProposedPlan.implementedAt !== null; const activePendingApproval = pendingApprovals[0] ?? null; const { beginLocalDispatch, @@ -1605,6 +1634,17 @@ export default function ChatView(props: ChatViewProps) { return byUserMessageId; }, [inferredCheckpointTurnCountByTurnId, timelineEntries, turnDiffSummaryByAssistantMessageId]); + const userMessageById = useMemo(() => { + const byMessageId = new Map(); + for (const entry of timelineEntries) { + if (!entry || entry.kind !== "message" || entry.message.role !== "user") { + continue; + } + byMessageId.set(entry.message.id, entry.message); + } + return byMessageId; + }, [timelineEntries]); + const completionSummary = useMemo(() => { if (!latestTurnSettled) return null; if (!activeLatestTurn?.startedAt) return null; @@ -2550,21 +2590,21 @@ export default function ChatView(props: ChatViewProps) { ]); const onRevertToTurnCount = useCallback( - async (turnCount: number) => { + async (turnCount: number): Promise => { const api = readEnvironmentApi(environmentId); const localApi = readLocalApi(); - if (!api || !localApi || !activeThread || isRevertingCheckpoint) return; + if (!api || !localApi || !activeThread || isRevertingCheckpoint) return false; if (activeEnvironmentUnavailable && activeEnvironmentUnavailableLabel) { setThreadError( activeThread.id, `Reconnect ${activeEnvironmentUnavailableLabel} before reverting checkpoints.`, ); - return; + return false; } if (phase === "running" || isSendBusy || isConnecting) { setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints."); - return; + return false; } const confirmed = await localApi.dialogs.confirm( [ @@ -2574,7 +2614,7 @@ export default function ChatView(props: ChatViewProps) { ].join("\n"), ); if (!confirmed) { - return; + return false; } setIsRevertingCheckpoint(true); @@ -2594,6 +2634,7 @@ export default function ChatView(props: ChatViewProps) { ); } setIsRevertingCheckpoint(false); + return true; }, [ activeThread, @@ -2869,6 +2910,9 @@ export default function ChatView(props: ChatViewProps) { runtimeMode, interactionMode, ...(bootstrap ? { bootstrap } : {}), + ...(isLocalDraftThread && draftThread?.pendingSourceProposedPlan + ? { sourceProposedPlan: draftThread.pendingSourceProposedPlan } + : {}), createdAt: messageCreatedAt, }); turnStartSucceeded = true; @@ -3366,6 +3410,135 @@ export default function ChatView(props: ChatViewProps) { environmentId, ]); + const latestPromotableAssistantMessageId = useMemo(() => { + const messages = activeThread?.messages; + if (!messages || messages.length === 0) return undefined; + for (let i = messages.length - 1; i >= 0; i -= 1) { + const message = messages[i]; + if (message?.role === "assistant" && message.text.trim().length > 0) { + return message.id; + } + } + return undefined; + }, [activeThread?.messages]); + + const hasRevertedPromotablePlan = useMemo( + () => (activeThread?.proposedPlans ?? []).some((entry) => entry.revertedAt !== null), + [activeThread?.proposedPlans], + ); + + const canPromoteToPlan = + interactionMode === "plan" && + activeProposedPlan === null && + pendingUserInputs.length === 0 && + (latestPromotableAssistantMessageId !== undefined || hasRevertedPromotablePlan) && + isServerThread && + !isSendBusy && + !isConnecting && + !activeEnvironmentUnavailable; + + const onPromoteToPlan = useCallback(() => { + if (!activeThread) return; + const api = readEnvironmentApi(activeThread.environmentId); + if (!api) return; + void api.orchestration + .dispatchCommand({ + type: "thread.proposed-plan.promote", + commandId: newCommandId(), + threadId: activeThread.id, + createdAt: new Date().toISOString(), + }) + .catch((err: unknown) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not promote message to plan", + description: + err instanceof Error ? err.message : "An error occurred while promoting the message.", + }), + ); + }); + }, [activeThread]); + + const canRevertPlan = + activeProposedPlan !== null && + /:promoted:/.test(activeProposedPlan.id) && + isServerThread && + !isSendBusy && + !isConnecting && + !activeEnvironmentUnavailable; + + const onRevertPlan = useCallback(() => { + if (!activeThread || !activeProposedPlan) return; + const api = readEnvironmentApi(activeThread.environmentId); + if (!api) return; + void api.orchestration + .dispatchCommand({ + type: "thread.proposed-plan.revert", + commandId: newCommandId(), + threadId: activeThread.id, + planId: activeProposedPlan.id, + createdAt: new Date().toISOString(), + }) + .catch((err: unknown) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not revert plan", + description: + err instanceof Error ? err.message : "An error occurred while reverting the plan.", + }), + ); + }); + }, [activeThread, activeProposedPlan]); + + const startImplementationDraftFromPlan = useStartImplementationDraftFromPlan(); + const onImplementPlanInNewThreadDraft = useCallback(async () => { + if ( + !activeThread || + !activeProject || + !activeProposedPlan || + !isServerThread || + isSendBusy || + isConnecting || + activeEnvironmentUnavailable || + sendInFlightRef.current + ) { + return; + } + const sendCtx = composerRef.current?.getSendContext(); + if (!sendCtx) { + return; + } + const { selectedModelSelection: ctxSelectedModelSelection } = sendCtx; + const implementationPrompt = buildPlanImplementationPrompt(activeProposedPlan.planMarkdown); + const logicalProjectKey = deriveLogicalProjectKeyFromSettings( + activeProject, + projectGroupingSettings, + ); + await startImplementationDraftFromPlan({ + projectRef: scopeProjectRef(activeProject.environmentId, activeProject.id), + logicalProjectKey, + prompt: implementationPrompt, + modelSelection: ctxSelectedModelSelection, + sourceThreadId: activeThread.id, + sourcePlanId: activeProposedPlan.id, + branch: activeThreadBranch, + worktreePath: activeThread.worktreePath, + }); + }, [ + activeEnvironmentUnavailable, + activeProject, + activeProposedPlan, + activeThread, + activeThreadBranch, + isConnecting, + isSendBusy, + isServerThread, + projectGroupingSettings, + startImplementationDraftFromPlan, + ]); + const onProviderModelSelect = useCallback( (instanceId: ProviderInstanceId, model: string) => { if (!activeThread) return; @@ -3481,14 +3654,37 @@ export default function ChatView(props: ChatViewProps) { // the callback reference is fully stable and never busts context identity. const revertTurnCountRef = useRef(revertTurnCountByUserMessageId); revertTurnCountRef.current = revertTurnCountByUserMessageId; + const userMessageByIdRef = useRef(userMessageById); + userMessageByIdRef.current = userMessageById; const onRevertToTurnCountRef = useRef(onRevertToTurnCount); onRevertToTurnCountRef.current = onRevertToTurnCount; - const onRevertUserMessage = useCallback((messageId: MessageId) => { + const setComposerDraftPromptRef = useRef(setComposerDraftPrompt); + setComposerDraftPromptRef.current = setComposerDraftPrompt; + const addComposerDraftImagesRef = useRef(addComposerDraftImages); + addComposerDraftImagesRef.current = addComposerDraftImages; + const composerDraftTargetRef = useRef(composerDraftTarget); + composerDraftTargetRef.current = composerDraftTarget; + + const onRevertUserMessage = useCallback(async (messageId: MessageId) => { const targetTurnCount = revertTurnCountRef.current.get(messageId); if (typeof targetTurnCount !== "number") { return; } - void onRevertToTurnCountRef.current(targetTurnCount); + const didProceed = await onRevertToTurnCountRef.current(targetTurnCount); + if (!didProceed) { + return; + } + + const userMessage = userMessageByIdRef.current.get(messageId); + if (userMessage) { + setComposerDraftPromptRef.current(composerDraftTargetRef.current, userMessage.text); + if (userMessage.attachments && userMessage.attachments.length > 0) { + const restoredImages = await chatImageAttachmentsToComposerImages(userMessage.attachments); + if (restoredImages.length > 0) { + addComposerDraftImagesRef.current(composerDraftTargetRef.current, restoredImages); + } + } + } }, []); // Empty state: no active thread @@ -3633,6 +3829,7 @@ export default function ChatView(props: ChatViewProps) { activePendingQuestionIndex={activePendingQuestionIndex} respondingRequestIds={respondingRequestIds} showPlanFollowUpPrompt={showPlanFollowUpPrompt} + isPlanReimplementation={isPlanReimplementation} activeProposedPlan={activeProposedPlan} activePlan={activePlan as { turnId?: TurnId } | null} sidebarProposedPlan={sidebarProposedPlan as { turnId?: TurnId } | null} @@ -3655,9 +3852,14 @@ export default function ChatView(props: ChatViewProps) { composerTerminalContextsRef={composerTerminalContextsRef} shouldAutoScrollRef={isAtEndRef} scheduleStickToBottom={scrollToEnd} + canPromoteToPlan={canPromoteToPlan} + onPromoteToPlan={onPromoteToPlan} + canRevertPlan={canRevertPlan} + onRevertPlan={onRevertPlan} onSend={onSend} onInterrupt={onInterrupt} onImplementPlanInNewThread={onImplementPlanInNewThread} + onImplementPlanInNewThreadDraft={onImplementPlanInNewThreadDraft} onRespondToApproval={onRespondToApproval} onSelectActivePendingUserInputOption={onSelectActivePendingUserInputOption} onAdvanceActivePendingUserInput={onAdvanceActivePendingUserInput} diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index f178a69fb43..394e080f0bc 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -14,6 +14,7 @@ import { TextWrapIcon, } from "lucide-react"; import { + type CSSProperties, type WheelEvent as ReactWheelEvent, useCallback, useEffect, @@ -107,6 +108,15 @@ const DIFF_PANEL_UNSAFE_CSS = ` } `; +const DIFF_PANEL_FONT_FAMILY_CSS = ` +[data-diff], +[data-diff] *, +[data-file], +[data-file] * { + font-family: var(--diff-font-family) !important; +} +`; + type RenderablePatch = | { kind: "files"; @@ -187,6 +197,14 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { const navigate = useNavigate(); const { resolvedTheme } = useTheme(); const settings = useSettings(); + const diffFontFamily = settings.diffFontFamily?.trim() ?? ""; + const diffFontFamilyValue = diffFontFamily ? `"${diffFontFamily}"` : null; + const diffPanelUnsafeCss = diffFontFamilyValue + ? `${DIFF_PANEL_UNSAFE_CSS}${DIFF_PANEL_FONT_FAMILY_CSS}` + : DIFF_PANEL_UNSAFE_CSS; + const diffSurfaceStyle = diffFontFamilyValue + ? ({ "--diff-font-family": diffFontFamilyValue } as CSSProperties) + : undefined; const [diffRenderMode, setDiffRenderMode] = useState("stacked"); const [diffWordWrap, setDiffWordWrap] = useState(settings.diffWordWrap); const [diffIgnoreWhitespace, setDiffIgnoreWhitespace] = useState(settings.diffIgnoreWhitespace); @@ -654,6 +672,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { ) : renderablePatch.kind === "files" ? ( @@ -729,6 +748,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { ? "overflow-auto whitespace-pre-wrap wrap-break-word" : "overflow-auto", )} + style={diffFontFamilyValue ? { fontFamily: diffFontFamilyValue } : undefined} > {renderablePatch.text} diff --git a/apps/web/src/components/GitActionsControl.logic.test.ts b/apps/web/src/components/GitActionsControl.logic.test.ts index 7950753330e..e358bdc3102 100644 --- a/apps/web/src/components/GitActionsControl.logic.test.ts +++ b/apps/web/src/components/GitActionsControl.logic.test.ts @@ -874,7 +874,7 @@ describe("when: ref has no upstream configured", () => { }); describe("requiresDefaultBranchConfirmation", () => { - it("requires confirmation for push actions on default ref", () => { + it("requires confirmation for push actions on default branch", () => { assert.isFalse(requiresDefaultBranchConfirmation("commit", true)); assert.isTrue(requiresDefaultBranchConfirmation("push", true)); assert.isTrue(requiresDefaultBranchConfirmation("create_pr", true)); @@ -894,9 +894,9 @@ describe("resolveDefaultBranchActionDialogCopy", () => { }); assert.deepEqual(copy, { - title: "Push to default ref?", + title: "Push to default branch?", description: - 'This action will push local commits on "main". You can continue on this ref or create a feature ref and run the same action there.', + 'This action will push local commits on "main". You can continue on this branch or create a feature branch and run the same action there.', continueLabel: "Push to main", }); }); @@ -909,9 +909,9 @@ describe("resolveDefaultBranchActionDialogCopy", () => { }); assert.deepEqual(copy, { - title: "Push & create PR from default ref?", + title: "Push & create PR from default branch?", description: - 'This action will push local commits and create a pull request on "main". You can continue on this ref or create a feature ref and run the same action there.', + 'This action will push local commits and create a pull request on "main". You can continue on this branch or create a feature branch and run the same action there.', continueLabel: "Push & create PR", }); }); @@ -924,9 +924,9 @@ describe("resolveDefaultBranchActionDialogCopy", () => { }); assert.deepEqual(copy, { - title: "Commit, push & create PR from default ref?", + title: "Commit, push & create PR from default branch?", description: - 'This action will commit, push, and create a pull request on "main". You can continue on this ref or create a feature ref and run the same action there.', + 'This action will commit, push, and create a pull request on "main". You can continue on this branch or create a feature branch and run the same action there.', continueLabel: "Commit, push & create PR", }); }); @@ -1131,3 +1131,73 @@ describe("resolveAutoFeatureBranchName", () => { assert.equal(ref, "feature/update"); }); }); + +describe("when: autoCreatePr is disabled", () => { + it("downgrades feature-branch commit+push from PR to plain commit & push", () => { + const quick = resolveQuickAction( + status({ hasWorkingTreeChanges: true }), + false, + false, + true, + false, + ); + assert.deepInclude(quick, { + kind: "run_action", + action: "commit_push", + label: "Commit & push", + disabled: false, + }); + }); + + it("downgrades feature-branch ahead-with-upstream from create PR to plain push", () => { + const quick = resolveQuickAction(status({ aheadCount: 2 }), false, false, true, false); + assert.deepInclude(quick, { + kind: "run_action", + action: "push", + label: "Push", + disabled: false, + }); + }); + + it("downgrades feature-branch ahead-without-upstream from create PR to plain push", () => { + const quick = resolveQuickAction( + status({ aheadCount: 1, hasUpstream: false }), + false, + false, + true, + false, + ); + assert.deepInclude(quick, { + kind: "run_action", + action: "push", + label: "Push", + disabled: false, + }); + }); + + it("preserves Commit, push & PR when autoCreatePr is true (default)", () => { + const quick = resolveQuickAction( + status({ hasWorkingTreeChanges: true }), + false, + false, + true, + true, + ); + assert.deepInclude(quick, { + kind: "run_action", + action: "commit_push_pr", + label: "Commit, push & PR", + disabled: false, + }); + }); + + it("preserves Push & create PR when autoCreatePr is true (default)", () => { + const quick = resolveQuickAction(status({ aheadCount: 2 }), false, false, true, true); + assert.deepInclude(quick, { + kind: "run_action", + action: "create_pr", + label: "Push & create PR", + disabled: false, + }); + }); +}); diff --git a/apps/web/src/components/GitActionsControl.logic.ts b/apps/web/src/components/GitActionsControl.logic.ts index 3f6bae61cdd..3c050b7cfe0 100644 --- a/apps/web/src/components/GitActionsControl.logic.ts +++ b/apps/web/src/components/GitActionsControl.logic.ts @@ -61,7 +61,7 @@ export function buildGitActionProgressStages(input: { terminology?: ChangeRequestTerminology; }): string[] { const terminology = input.terminology ?? DEFAULT_CHANGE_REQUEST_TERMINOLOGY; - const branchStages = input.featureBranch ? ["Preparing feature ref..."] : []; + const branchStages = input.featureBranch ? ["Preparing feature branch..."] : []; const pushStage = input.pushTarget ? `Pushing to ${input.pushTarget}...` : "Pushing..."; const prStages = [ `Preparing ${terminology.shortLabel}...`, @@ -169,6 +169,7 @@ export function resolveQuickAction( isBusy: boolean, isDefaultRef = false, hasPrimaryRemote = true, + autoCreatePr = true, ): GitQuickAction { if (isBusy) { return { label: "Commit", disabled: true, kind: "show_hint", hint: "Git action in progress." }; @@ -197,7 +198,7 @@ export function resolveQuickAction( label: "Commit", disabled: true, kind: "show_hint", - hint: `Create and checkout a ref before pushing or opening a ${terminology.singular}.`, + hint: `Create and checkout a branch before pushing or opening a ${terminology.singular}.`, }; } @@ -205,7 +206,7 @@ export function resolveQuickAction( if (!gitStatus.hasUpstream && !hasPrimaryRemote) { return { label: "Commit", disabled: false, kind: "run_action", action: "commit" }; } - if (hasOpenPr || isDefaultRef) { + if (hasOpenPr || isDefaultRef || !autoCreatePr) { return { label: "Commit & push", disabled: false, kind: "run_action", action: "commit_push" }; } return { @@ -238,7 +239,7 @@ export function resolveQuickAction( hint: "No local commits to push.", }; } - if (hasOpenPr || isDefaultRef) { + if (hasOpenPr || isDefaultRef || !autoCreatePr) { return { label: "Push", disabled: false, @@ -272,7 +273,7 @@ export function resolveQuickAction( } if (isAhead) { - if (hasOpenPr || isDefaultRef) { + if (hasOpenPr || isDefaultRef || !autoCreatePr) { return { label: "Push", disabled: false, @@ -329,19 +330,19 @@ export function resolveDefaultBranchActionDialogCopy(input: { terminology?: ChangeRequestTerminology; }): DefaultBranchActionDialogCopy { const branchLabel = input.branchName; - const suffix = ` on "${branchLabel}". You can continue on this ref or create a feature ref and run the same action there.`; + const suffix = ` on "${branchLabel}". You can continue on this branch or create a feature branch and run the same action there.`; const terminology = input.terminology ?? DEFAULT_CHANGE_REQUEST_TERMINOLOGY; if (input.action === "push" || input.action === "commit_push") { if (input.includesCommit) { return { - title: "Commit & push to default ref?", + title: "Commit & push to default branch?", description: `This action will commit and push changes${suffix}`, continueLabel: `Commit & push to ${branchLabel}`, }; } return { - title: "Push to default ref?", + title: "Push to default branch?", description: `This action will push local commits${suffix}`, continueLabel: `Push to ${branchLabel}`, }; @@ -349,13 +350,13 @@ export function resolveDefaultBranchActionDialogCopy(input: { if (input.includesCommit) { return { - title: `Commit, push & create ${terminology.shortLabel} from default ref?`, + title: `Commit, push & create ${terminology.shortLabel} from default branch?`, description: `This action will commit, push, and create a ${terminology.singular}${suffix}`, continueLabel: `Commit, push & create ${terminology.shortLabel}`, }; } return { - title: `Push & create ${terminology.shortLabel} from default ref?`, + title: `Push & create ${terminology.shortLabel} from default branch?`, description: `This action will push local commits and create a ${terminology.singular}${suffix}`, continueLabel: `Push & create ${terminology.shortLabel}`, }; diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 341aaed9f1b..c9606396e0a 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -44,6 +44,7 @@ import { resolveThreadBranchUpdate, } from "./GitActionsControl.logic"; import { AnimatedHeight } from "./AnimatedHeight"; +import { useSettings } from "../hooks/useSettings"; import { Button } from "~/components/ui/button"; import { Checkbox } from "~/components/ui/checkbox"; import { @@ -262,7 +263,7 @@ function getMenuActionDisabledReason({ if (item.id === "push") { if (!hasBranch) { - return "Detached HEAD: checkout a refName before pushing."; + return "Detached HEAD: checkout a branch before pushing."; } if (hasChanges) { return "Commit or stash local changes before pushing."; @@ -283,7 +284,7 @@ function getMenuActionDisabledReason({ return `View ${terminology.singular} is currently unavailable.`; } if (!hasBranch) { - return `Detached HEAD: checkout a refName before creating a ${terminology.singular}.`; + return `Detached HEAD: checkout a branch before creating a ${terminology.singular}.`; } if (hasChanges) { return `Commit local changes before creating a ${terminology.singular}.`; @@ -1136,10 +1137,17 @@ export default function GitActionsControl({ () => buildMenuItems(gitStatusForActions, isGitActionRunning, hasPrimaryRemote), [gitStatusForActions, hasPrimaryRemote, isGitActionRunning], ); + const autoCreatePrOnPush = useSettings((s) => s.autoCreatePrOnPush); const quickAction = useMemo( () => - resolveQuickAction(gitStatusForActions, isGitActionRunning, isDefaultRef, hasPrimaryRemote), - [gitStatusForActions, hasPrimaryRemote, isDefaultRef, isGitActionRunning], + resolveQuickAction( + gitStatusForActions, + isGitActionRunning, + isDefaultRef, + hasPrimaryRemote, + autoCreatePrOnPush, + ), + [autoCreatePrOnPush, gitStatusForActions, hasPrimaryRemote, isDefaultRef, isGitActionRunning], ); const quickActionDisabledReason = quickAction.disabled ? (quickAction.hint ?? "This action is currently unavailable.") @@ -1741,7 +1749,7 @@ export default function GitActionsControl({ ) : null} {gitStatusForActions?.refName === null && (

- Detached HEAD: create and checkout a refName to enable push and pull request + Detached HEAD: create and checkout a branch to enable push and pull request actions.

)} @@ -1787,9 +1795,7 @@ export default function GitActionsControl({ {gitStatusForActions?.refName ?? "(detached HEAD)"} {isDefaultRef && ( - - Warning: default refName - + Warning: default branch )} @@ -1922,7 +1928,7 @@ export default function GitActionsControl({ disabled={noneSelected} onClick={runDialogActionOnNewBranch} > - Commit on new refName + Commit on new branch + + ) : null} + {props.showPlanToggle ? ( <> @@ -297,15 +317,19 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( } | null; isRunning: boolean; showPlanFollowUpPrompt: boolean; + isPlanReimplementation: boolean; promptHasText: boolean; isSendBusy: boolean; isConnecting: boolean; isEnvironmentUnavailable: boolean; hasSendableContent: boolean; preserveComposerFocusOnPointerDown?: boolean; + canRevertPlan?: boolean; onPreviousPendingQuestion: () => void; onInterrupt: () => void; onImplementPlanInNewThread: () => void; + onImplementPlanInNewThreadDraft: () => void; + onRevertPlan?: () => void; }) { return ( <> @@ -318,6 +342,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( pendingAction={props.pendingAction} isRunning={props.isRunning} showPlanFollowUpPrompt={props.showPlanFollowUpPrompt} + isPlanReimplementation={props.isPlanReimplementation} promptHasText={props.promptHasText} isSendBusy={props.isSendBusy} isConnecting={props.isConnecting} @@ -325,9 +350,12 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( isPreparingWorktree={props.isPreparingWorktree} hasSendableContent={props.hasSendableContent} preserveComposerFocusOnPointerDown={props.preserveComposerFocusOnPointerDown ?? false} + canRevertPlan={props.canRevertPlan ?? false} onPreviousPendingQuestion={props.onPreviousPendingQuestion} onInterrupt={props.onInterrupt} onImplementPlanInNewThread={props.onImplementPlanInNewThread} + onImplementPlanInNewThreadDraft={props.onImplementPlanInNewThreadDraft} + {...(props.onRevertPlan !== undefined && { onRevertPlan: props.onRevertPlan })} /> ); @@ -418,6 +446,7 @@ export interface ChatComposerProps { // Plan showPlanFollowUpPrompt: boolean; + isPlanReimplementation: boolean; activeProposedPlan: Thread["proposedPlans"][number] | null; activePlan: { turnId?: TurnId } | null; sidebarProposedPlan: { turnId?: TurnId } | null; @@ -454,10 +483,17 @@ export interface ChatComposerProps { shouldAutoScrollRef: React.RefObject; scheduleStickToBottom: () => void; + // Promote-to-plan + canPromoteToPlan: boolean; + onPromoteToPlan: () => void; + canRevertPlan: boolean; + onRevertPlan: () => void; + // Callbacks onSend: (e?: { preventDefault: () => void }) => void; onInterrupt: () => void; onImplementPlanInNewThread: () => void; + onImplementPlanInNewThreadDraft: () => void; onRespondToApproval: ( requestId: ApprovalRequestId, decision: ProviderApprovalDecision, @@ -516,6 +552,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) activePendingQuestionIndex, respondingRequestIds, showPlanFollowUpPrompt, + isPlanReimplementation, activeProposedPlan, activePlan, sidebarProposedPlan, @@ -539,9 +576,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerTerminalContextsRef, shouldAutoScrollRef, scheduleStickToBottom, + canPromoteToPlan, + onPromoteToPlan, + canRevertPlan, + onRevertPlan, onSend, onInterrupt, onImplementPlanInNewThread, + onImplementPlanInNewThreadDraft, onRespondToApproval, onSelectActivePendingUserInputOption, onAdvanceActivePendingUserInput, @@ -1784,6 +1826,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const handleImplementPlanInNewThreadPrimaryAction = useCallback(() => { void onImplementPlanInNewThread(); }, [onImplementPlanInNewThread]); + const handleImplementPlanInNewThreadDraftPrimaryAction = useCallback(() => { + void onImplementPlanInNewThreadDraft(); + }, [onImplementPlanInNewThreadDraft]); const scheduleComposerCollapseCheck = useCallback(() => { if (!isMobileViewport) { return; @@ -2007,6 +2052,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ) : null)} @@ -2070,6 +2116,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) pendingAction={pendingPrimaryAction} isRunning={false} showPlanFollowUpPrompt={false} + isPlanReimplementation={false} promptHasText={false} isSendBusy={isSendBusy} isConnecting={isConnecting} @@ -2080,6 +2127,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion} onInterrupt={handleInterruptPrimaryAction} onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction} + onImplementPlanInNewThreadDraft={ + handleImplementPlanInNewThreadDraftPrimaryAction + } /> ) : null} @@ -2252,7 +2302,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) : activePendingProgress ? "Type your own answer, or leave this blank to use the selected option" : showPlanFollowUpPrompt && activeProposedPlan - ? "Add feedback to refine the plan, or leave this blank to implement it" + ? isPlanReimplementation + ? "Add feedback to refine the plan, or leave this blank to reimplement it" + : "Add feedback to refine the plan, or leave this blank to implement it" : environmentUnavailable ? `${environmentUnavailable.label} is ${ environmentUnavailable.connectionState === "connecting" @@ -2279,6 +2331,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) pendingAction={pendingPrimaryAction} isRunning={false} showPlanFollowUpPrompt={false} + isPlanReimplementation={false} promptHasText={false} isSendBusy={isSendBusy} isConnecting={isConnecting} @@ -2289,6 +2342,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion} onInterrupt={handleInterruptPrimaryAction} onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction} + onImplementPlanInNewThreadDraft={ + handleImplementPlanInNewThreadDraftPrimaryAction + } /> ) : null} @@ -2346,6 +2402,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) runtimeMode={runtimeMode} showInteractionModeToggle={composerProviderControls.showInteractionModeToggle} traitsMenuContent={providerTraitsMenuContent} + canPromoteToPlan={canPromoteToPlan} + onPromoteToPlan={onPromoteToPlan} onToggleInteractionMode={toggleInteractionMode} onTogglePlanSidebar={togglePlanSidebar} onRuntimeModeChange={handleRuntimeModeChange} @@ -2365,6 +2423,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) showPlanToggle={showPlanSidebarToggle} planSidebarLabel={planSidebarLabel} planSidebarOpen={planSidebarOpen} + canPromoteToPlan={canPromoteToPlan} + onPromoteToPlan={onPromoteToPlan} onToggleInteractionMode={toggleInteractionMode} onRuntimeModeChange={handleRuntimeModeChange} onTogglePlanSidebar={togglePlanSidebar} @@ -2387,6 +2447,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) pendingAction={pendingPrimaryAction} isRunning={phase === "running"} showPlanFollowUpPrompt={pendingUserInputs.length === 0 && showPlanFollowUpPrompt} + isPlanReimplementation={isPlanReimplementation} promptHasText={prompt.trim().length > 0} isSendBusy={isSendBusy} isConnecting={isConnecting} @@ -2394,9 +2455,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isPreparingWorktree={isPreparingWorktree} hasSendableContent={composerSendState.hasSendableContent} preserveComposerFocusOnPointerDown={isMobileViewport} + canRevertPlan={canRevertPlan} onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion} onInterrupt={handleInterruptPrimaryAction} onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction} + onImplementPlanInNewThreadDraft={handleImplementPlanInNewThreadDraftPrimaryAction} + onRevertPlan={onRevertPlan} /> diff --git a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx index 49eb5fbb94b..4056a7ee533 100644 --- a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx +++ b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx @@ -152,6 +152,8 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str onPromptChange={onPromptChange} /> } + canPromoteToPlan={false} + onPromoteToPlan={vi.fn()} onToggleInteractionMode={vi.fn()} onTogglePlanSidebar={vi.fn()} onRuntimeModeChange={vi.fn()} @@ -303,6 +305,8 @@ describe("CompactComposerControlsMenu", () => { planSidebarOpen={false} runtimeMode="approval-required" showInteractionModeToggle={false} + canPromoteToPlan={false} + onPromoteToPlan={vi.fn()} onToggleInteractionMode={vi.fn()} onTogglePlanSidebar={vi.fn()} onRuntimeModeChange={vi.fn()} diff --git a/apps/web/src/components/chat/CompactComposerControlsMenu.tsx b/apps/web/src/components/chat/CompactComposerControlsMenu.tsx index f1fbd193a63..f6a32c0443a 100644 --- a/apps/web/src/components/chat/CompactComposerControlsMenu.tsx +++ b/apps/web/src/components/chat/CompactComposerControlsMenu.tsx @@ -1,6 +1,6 @@ import { ProviderInteractionMode, RuntimeMode } from "@t3tools/contracts"; import { memo, type ReactNode } from "react"; -import { EllipsisIcon, ListTodoIcon } from "lucide-react"; +import { CornerRightUpIcon, EllipsisIcon, ListTodoIcon } from "lucide-react"; import { Button } from "../ui/button"; import { Menu, @@ -20,6 +20,8 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls runtimeMode: RuntimeMode; showInteractionModeToggle: boolean; traitsMenuContent?: ReactNode; + canPromoteToPlan: boolean; + onPromoteToPlan: () => void; onToggleInteractionMode: () => void; onTogglePlanSidebar: () => void; onRuntimeModeChange: (mode: RuntimeMode) => void; @@ -73,6 +75,15 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls Auto-accept edits Full access + {props.interactionMode === "plan" && props.canPromoteToPlan ? ( + <> + + + + Promote to plan + + + ) : null} {props.activePlan ? ( <> diff --git a/apps/web/src/components/chat/ComposerPlanFollowUpBanner.tsx b/apps/web/src/components/chat/ComposerPlanFollowUpBanner.tsx index 49b03f7724b..ef9c24b5f49 100644 --- a/apps/web/src/components/chat/ComposerPlanFollowUpBanner.tsx +++ b/apps/web/src/components/chat/ComposerPlanFollowUpBanner.tsx @@ -2,13 +2,17 @@ import { memo } from "react"; export const ComposerPlanFollowUpBanner = memo(function ComposerPlanFollowUpBanner({ planTitle, + isReimplementation = false, }: { planTitle: string | null; + isReimplementation?: boolean; }) { return (
- Plan ready + + {isReimplementation ? "Plan implemented" : "Plan ready"} + {planTitle ? ( {planTitle} ) : null} diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.tsx index fbeb9de30b8..a780b21fb86 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.tsx +++ b/apps/web/src/components/chat/ComposerPrimaryActions.tsx @@ -17,6 +17,7 @@ interface ComposerPrimaryActionsProps { pendingAction: PendingActionState | null; isRunning: boolean; showPlanFollowUpPrompt: boolean; + isPlanReimplementation: boolean; promptHasText: boolean; isSendBusy: boolean; isConnecting: boolean; @@ -24,9 +25,12 @@ interface ComposerPrimaryActionsProps { isPreparingWorktree: boolean; hasSendableContent: boolean; preserveComposerFocusOnPointerDown?: boolean; + canRevertPlan?: boolean; onPreviousPendingQuestion: () => void; onInterrupt: () => void; onImplementPlanInNewThread: () => void; + onImplementPlanInNewThreadDraft: () => void; + onRevertPlan?: () => void; } export const formatPendingPrimaryActionLabel = (input: { @@ -56,6 +60,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ pendingAction, isRunning, showPlanFollowUpPrompt, + isPlanReimplementation, promptHasText, isSendBusy, isConnecting, @@ -63,9 +68,12 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ isPreparingWorktree, hasSendableContent, preserveComposerFocusOnPointerDown = false, + canRevertPlan = false, onPreviousPendingQuestion, onInterrupt, onImplementPlanInNewThread, + onImplementPlanInNewThreadDraft, + onRevertPlan, }: ComposerPrimaryActionsProps) { const pointerFocusProps = preserveComposerFocusOnPointerDown ? { onPointerDown: preventPointerFocus } @@ -162,7 +170,13 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ {...pointerFocusProps} disabled={isSendBusy || isConnecting || isEnvironmentUnavailable} > - {isConnecting || isSendBusy ? "Sending..." : "Implement"} + {isConnecting || isSendBusy + ? isPlanReimplementation + ? "Reimplementing..." + : "Sending..." + : isPlanReimplementation + ? "Reimplement" + : "Implement"} void onImplementPlanInNewThread()} > - Implement in a new thread + {isPlanReimplementation ? "Reimplement in a new thread" : "Implement in a new thread"} + void onImplementPlanInNewThreadDraft()} + > + {isPlanReimplementation + ? "Reimplement in a new thread (don't send)" + : "Implement in a new thread (don't send)"} + + {canRevertPlan && onRevertPlan ? ( + void onRevertPlan()} + > + Revert plan to message + + ) : null}
diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 1540d5f344a..10e94256144 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -59,6 +59,7 @@ import { cn } from "~/lib/utils"; import { useUiStateStore } from "~/uiStateStore"; import { type TimestampFormat } from "@t3tools/contracts/settings"; import { formatTimestamp } from "../../timestampFormat"; +import { useSettings } from "../../hooks/useSettings"; import { buildInlineTerminalContextText, @@ -684,8 +685,11 @@ function AssistantChangedFilesSectionInner({ resolvedTheme: "light" | "dark"; onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; }) { + const changedFilesExpandedByDefault = useSettings((s) => s.changedFilesExpandedByDefault); const allDirectoriesExpanded = useUiStateStore( - (store) => store.threadChangedFilesExpandedById[routeThreadKey]?.[turnSummary.turnId] ?? true, + (store) => + store.threadChangedFilesExpandedById[routeThreadKey]?.[turnSummary.turnId] ?? + changedFilesExpandedByDefault, ); const setExpanded = useUiStateStore((store) => store.setThreadChangedFilesExpanded); const summaryStat = summarizeTurnDiffStats(checkpointFiles); @@ -709,7 +713,14 @@ function AssistantChangedFilesSectionInner({ size="xs" variant="outline" data-scroll-anchor-ignore - onClick={() => setExpanded(routeThreadKey, turnSummary.turnId, !allDirectoriesExpanded)} + onClick={() => + setExpanded( + routeThreadKey, + turnSummary.turnId, + !allDirectoriesExpanded, + changedFilesExpandedByDefault, + ) + } > {allDirectoriesExpanded ? "Collapse all" : "Expand all"} diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index c3468ef8c65..6ea3abf0cba 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -1,7 +1,7 @@ import { type ProviderInstanceId, - type ProviderDriverKind, type ResolvedKeybindingsConfig, + ProviderDriverKind, } from "@t3tools/contracts"; import { resolveSelectableModel } from "@t3tools/shared/model"; import { memo, useMemo, useState, useCallback, useEffect, useLayoutEffect, useRef } from "react"; @@ -21,6 +21,7 @@ import { import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; import { cn } from "~/lib/utils"; import { TooltipProvider } from "../ui/tooltip"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; import type { ProviderInstanceEntry } from "../../providerInstances"; import { providerModelKey, sortProviderModelItems } from "../../modelOrdering"; @@ -37,6 +38,8 @@ type ModelPickerItem = { }; const EMPTY_MODEL_JUMP_LABELS = new Map(); +const OPENCODE_DRIVER_KIND = ProviderDriverKind.make("opencode"); +const ALL_OPENCODE_SUB_PROVIDERS = "all"; // Split a `${instanceId}:${slug}` combobox key back into its pieces. Slugs // can contain colons (e.g. some vendor model ids), so we only split on the @@ -90,10 +93,14 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { onInstanceModelChange, } = props; const [searchQuery, setSearchQuery] = useState(""); + const [selectedOpenCodeSubProvider, setSelectedOpenCodeSubProvider] = useState( + ALL_OPENCODE_SUB_PROVIDERS, + ); const searchInputRef = useRef(null); const listRegionRef = useRef(null); const highlightedModelKeyRef = useRef(null); const favorites = useSettings((s) => s.favorites ?? []); + const hideUnavailableProviders = useSettings((s) => s.hideUnavailableProviders); const [selectedInstanceId, setSelectedInstanceId] = useState( () => { if (props.lockedProvider !== null) { @@ -220,14 +227,90 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { ); const showLockedInstanceSidebar = isLocked && lockedInstanceEntries.length > 1; const showSidebar = !isSearching && (!isLocked || showLockedInstanceSidebar); - const sidebarInstanceEntries = showLockedInstanceSidebar + const baseSidebarInstanceEntries = showLockedInstanceSidebar ? lockedInstanceEntries : instanceEntries; + const sidebarInstanceEntries = + !isLocked && hideUnavailableProviders + ? baseSidebarInstanceEntries.filter((entry) => entry.isAvailable && entry.status === "ready") + : baseSidebarInstanceEntries; + const selectedInstanceEntry = + selectedInstanceId === "favorites" ? null : entryByInstanceId.get(selectedInstanceId); + const isSelectedOpenCodeInstance = selectedInstanceEntry?.driverKind === OPENCODE_DRIVER_KIND; + const isOpenCodeProviderScope = + selectedInstanceId !== "favorites" && + ((!isLocked && isSelectedOpenCodeInstance) || + (isLocked && + props.lockedProvider === OPENCODE_DRIVER_KIND && + (!showLockedInstanceSidebar || isSelectedOpenCodeInstance))); const instanceOrder = useMemo( () => instanceEntries.map((entry) => entry.instanceId), [instanceEntries], ); + const openCodeProviderOptions = useMemo(() => { + if (selectedInstanceId === "favorites") { + return []; + } + + let candidateModels: ReadonlyArray = []; + + if (!isLocked) { + if (!isSelectedOpenCodeInstance) { + return []; + } + candidateModels = flatModels.filter((model) => model.instanceId === selectedInstanceId); + } else if (props.lockedProvider === OPENCODE_DRIVER_KIND) { + if (showLockedInstanceSidebar) { + if (!isSelectedOpenCodeInstance) { + return []; + } + candidateModels = flatModels.filter((model) => model.instanceId === selectedInstanceId); + } else { + candidateModels = flatModels.filter((model) => matchesLockedProvider(model)); + } + } else { + return []; + } + + const subProviders = new Set(); + for (const model of candidateModels) { + if (model.subProvider) { + subProviders.add(model.subProvider); + } + } + + if (subProviders.size < 2) { + return []; + } + + return Array.from(subProviders).toSorted((a, b) => a.localeCompare(b)); + }, [ + flatModels, + isLocked, + isSelectedOpenCodeInstance, + matchesLockedProvider, + props.lockedProvider, + selectedInstanceId, + showLockedInstanceSidebar, + ]); + + const shouldShowOpenCodeProviderFilter = !isSearching && openCodeProviderOptions.length >= 2; + + useEffect(() => { + if (!isOpenCodeProviderScope) { + return; + } + + const selectionIsValid = + selectedOpenCodeSubProvider === ALL_OPENCODE_SUB_PROVIDERS || + openCodeProviderOptions.includes(selectedOpenCodeSubProvider); + if (selectionIsValid) { + return; + } + setSelectedOpenCodeSubProvider(ALL_OPENCODE_SUB_PROVIDERS); + }, [isOpenCodeProviderScope, openCodeProviderOptions, selectedOpenCodeSubProvider]); + // Filter models based on search query and selected instance const filteredModels = useMemo(() => { let result = flatModels; @@ -312,6 +395,13 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { result = result.filter((m) => m.instanceId === selectedInstanceId); } + if ( + shouldShowOpenCodeProviderFilter && + selectedOpenCodeSubProvider !== ALL_OPENCODE_SUB_PROVIDERS + ) { + result = result.filter((m) => m.subProvider === selectedOpenCodeSubProvider); + } + return sortProviderModelItems(result, { favoriteModelKeys: favoritesSet, groupFavorites: selectedInstanceId !== "favorites", @@ -326,6 +416,8 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { searchQuery, showLockedInstanceSidebar, selectedInstanceId, + shouldShowOpenCodeProviderFilter, + selectedOpenCodeSubProvider, ]); const handleModelSelect = useCallback( @@ -538,7 +630,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { onSelectInstance={handleSelectInstance} instanceEntries={sidebarInstanceEntries} showFavorites={!isLocked} - showComingSoon={!isLocked} + showComingSoon={!isLocked && !hideUnavailableProviders} /> )} @@ -606,6 +698,41 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { />
+ {/* OpenCode provider filter */} + {shouldShowOpenCodeProviderFilter && ( +
+ +
+ )} + {/* Model list */}
( + '[aria-label="OpenCode provider"]', + ); + expect(providerSelect).not.toBeNull(); + await userEvent.click(providerSelect!); + + let providerItem: HTMLElement | undefined; + await vi.waitFor(() => { + providerItem = Array.from( + document.querySelectorAll('[data-slot="select-item"]'), + ).find((item) => item.textContent?.trim() === provider); + expect(providerItem).toBeDefined(); + }); + await userEvent.click(providerItem!); +} + describe("ProviderModelPicker", () => { beforeEach(async () => { // Reset test environment before each test @@ -1223,4 +1240,337 @@ describe("ProviderModelPicker", () => { await mounted.cleanup(); } }); + + it("shows OpenCode provider filter and filters models by provider", async () => { + const providers: ReadonlyArray = [ + buildOpenCodeProvider([ + { + slug: "github-copilot/claude-opus-4.7", + name: "Claude Opus 4.7", + subProvider: "GitHub Copilot", + isCustom: false, + capabilities: createModelCapabilities({ + optionDescriptors: [ + selectDescriptor("reasoningEffort", "Reasoning", [ + { id: "low", label: "low" }, + { id: "medium", label: "medium", isDefault: true }, + { id: "high", label: "high" }, + ]), + ], + }), + }, + { + slug: "github-copilot/claude-sonnet-4.6", + name: "Claude Sonnet 4.6", + subProvider: "GitHub Copilot", + isCustom: false, + capabilities: createModelCapabilities({ + optionDescriptors: [ + selectDescriptor("reasoningEffort", "Reasoning", [ + { id: "low", label: "low" }, + { id: "medium", label: "medium", isDefault: true }, + { id: "high", label: "high" }, + ]), + ], + }), + }, + { + slug: "openai/gpt-4-turbo", + name: "GPT-4 Turbo", + subProvider: "OpenAI", + isCustom: false, + capabilities: createModelCapabilities({ + optionDescriptors: [ + selectDescriptor("reasoningEffort", "Reasoning", [ + { id: "low", label: "low" }, + { id: "medium", label: "medium", isDefault: true }, + { id: "high", label: "high" }, + ]), + ], + }), + }, + ]), + ]; + const mounted = await mountPicker({ + activeInstanceId: OPENCODE_INSTANCE_ID, + model: "github-copilot/claude-opus-4.7", + lockedProvider: null, + providers, + }); + + try { + await page.getByRole("button").click(); + + await vi.waitFor(() => { + expect(getVisibleModelNames()).toEqual([ + "Claude Opus 4.7", + "Claude Sonnet 4.6", + "GPT-4 Turbo", + ]); + }); + + const providerSelect = document.querySelector( + '[aria-label="OpenCode provider"]', + ); + expect(providerSelect).not.toBeNull(); + + await selectOpenCodeProviderFilter("GitHub Copilot"); + + await vi.waitFor(() => { + expect(getVisibleModelNames()).toEqual(["Claude Opus 4.7", "Claude Sonnet 4.6"]); + }); + + await selectOpenCodeProviderFilter("All providers"); + + await vi.waitFor(() => { + expect(getVisibleModelNames()).toEqual([ + "Claude Opus 4.7", + "Claude Sonnet 4.6", + "GPT-4 Turbo", + ]); + }); + } finally { + await mounted.cleanup(); + } + }); + + it("does not show OpenCode provider filter with only one upstream provider", async () => { + const providers: ReadonlyArray = [ + buildOpenCodeProvider([ + { + slug: "github-copilot/claude-opus-4.7", + name: "Claude Opus 4.7", + subProvider: "GitHub Copilot", + isCustom: false, + capabilities: createModelCapabilities({ + optionDescriptors: [ + selectDescriptor("reasoningEffort", "Reasoning", [ + { id: "low", label: "low" }, + { id: "medium", label: "medium", isDefault: true }, + { id: "high", label: "high" }, + ]), + ], + }), + }, + { + slug: "github-copilot/claude-sonnet-4.6", + name: "Claude Sonnet 4.6", + subProvider: "GitHub Copilot", + isCustom: false, + capabilities: createModelCapabilities({ + optionDescriptors: [ + selectDescriptor("reasoningEffort", "Reasoning", [ + { id: "low", label: "low" }, + { id: "medium", label: "medium", isDefault: true }, + { id: "high", label: "high" }, + ]), + ], + }), + }, + ]), + ]; + const mounted = await mountPicker({ + activeInstanceId: OPENCODE_INSTANCE_ID, + model: "github-copilot/claude-opus-4.7", + lockedProvider: null, + providers, + }); + + try { + await page.getByRole("button").click(); + + await vi.waitFor(() => { + const providerSelect = document.querySelector( + '[aria-label="OpenCode provider"]', + ); + expect(providerSelect).toBeNull(); + }); + } finally { + await mounted.cleanup(); + } + }); + + it("shows OpenCode filter only for OpenCode provider, not other providers", async () => { + const providers: ReadonlyArray = [ + buildCodexProvider([ + { + slug: "gpt-5-codex", + name: "GPT-5 Codex", + isCustom: false, + capabilities: createModelCapabilities({ + optionDescriptors: [ + selectDescriptor("reasoningEffort", "Reasoning", [ + { id: "low", label: "low" }, + { id: "medium", label: "medium", isDefault: true }, + { id: "high", label: "high" }, + ]), + ], + }), + }, + ]), + buildOpenCodeProvider([ + { + slug: "github-copilot/claude-opus-4.7", + name: "Claude Opus 4.7", + subProvider: "GitHub Copilot", + isCustom: false, + capabilities: createModelCapabilities({ + optionDescriptors: [ + selectDescriptor("reasoningEffort", "Reasoning", [ + { id: "low", label: "low" }, + { id: "medium", label: "medium", isDefault: true }, + { id: "high", label: "high" }, + ]), + ], + }), + }, + { + slug: "openai/gpt-4-turbo", + name: "GPT-4 Turbo", + subProvider: "OpenAI", + isCustom: false, + capabilities: createModelCapabilities({ + optionDescriptors: [ + selectDescriptor("reasoningEffort", "Reasoning", [ + { id: "low", label: "low" }, + { id: "medium", label: "medium", isDefault: true }, + { id: "high", label: "high" }, + ]), + ], + }), + }, + ]), + ]; + const mounted = await mountPicker({ + activeInstanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + lockedProvider: null, + providers, + }); + + try { + await page.getByRole("button").click(); + + await vi.waitFor(() => { + const providerSelect = document.querySelector( + '[aria-label="OpenCode provider"]', + ); + expect(providerSelect).toBeNull(); + expect(getVisibleModelNames()).toEqual(["GPT-5 Codex"]); + }); + + await page.getByRole("button", { name: "OpenCode", exact: true }).click(); + + await vi.waitFor(() => { + const providerSelect = document.querySelector( + '[aria-label="OpenCode provider"]', + ); + expect(providerSelect).not.toBeNull(); + expect(getVisibleModelNames()).toEqual(["Claude Opus 4.7", "GPT-4 Turbo"]); + }); + + await selectOpenCodeProviderFilter("GitHub Copilot"); + + await vi.waitFor(() => { + expect(getVisibleModelNames()).toEqual(["Claude Opus 4.7"]); + }); + + await page.getByRole("button", { name: "Codex", exact: true }).click(); + + await vi.waitFor(() => { + expect( + document.querySelector('[aria-label="OpenCode provider"]'), + ).toBeNull(); + expect(getVisibleModelNames()).toEqual(["GPT-5 Codex"]); + }); + + await page.getByRole("button", { name: "OpenCode", exact: true }).click(); + + await vi.waitFor(() => { + expect( + document.querySelector('[aria-label="OpenCode provider"]'), + ).not.toBeNull(); + expect(getVisibleModelNames()).toEqual(["Claude Opus 4.7"]); + }); + } finally { + await mounted.cleanup(); + } + }); + + it("preserves OpenCode provider filter selection across search", async () => { + const providers: ReadonlyArray = [ + buildOpenCodeProvider([ + { + slug: "github-copilot/claude-opus-4.7", + name: "Claude Opus 4.7", + subProvider: "GitHub Copilot", + isCustom: false, + capabilities: createModelCapabilities({ + optionDescriptors: [ + selectDescriptor("reasoningEffort", "Reasoning", [ + { id: "low", label: "low" }, + { id: "medium", label: "medium", isDefault: true }, + { id: "high", label: "high" }, + ]), + ], + }), + }, + { + slug: "openai/gpt-4-turbo", + name: "GPT-4 Turbo", + subProvider: "OpenAI", + isCustom: false, + capabilities: createModelCapabilities({ + optionDescriptors: [ + selectDescriptor("reasoningEffort", "Reasoning", [ + { id: "low", label: "low" }, + { id: "medium", label: "medium", isDefault: true }, + { id: "high", label: "high" }, + ]), + ], + }), + }, + ]), + ]; + const mounted = await mountPicker({ + activeInstanceId: OPENCODE_INSTANCE_ID, + model: "github-copilot/claude-opus-4.7", + lockedProvider: null, + providers, + }); + + try { + await page.getByRole("button").click(); + + await selectOpenCodeProviderFilter("GitHub Copilot"); + + await vi.waitFor(() => { + expect(getVisibleModelNames()).toEqual(["Claude Opus 4.7"]); + }); + + const searchInput = page.getByPlaceholder("Search models..."); + await searchInput.fill("turbo"); + + await vi.waitFor(() => { + const providerSelect = document.querySelector( + '[aria-label="OpenCode provider"]', + ); + expect(providerSelect).toBeNull(); + const text = document.body.textContent ?? ""; + expect(text).toContain("GPT-4 Turbo"); + }); + + await searchInput.fill(""); + + await vi.waitFor(() => { + const providerSelect = document.querySelector( + '[aria-label="OpenCode provider"]', + ); + expect(providerSelect).not.toBeNull(); + expect(getVisibleModelNames()).toEqual(["Claude Opus 4.7"]); + }); + } finally { + await mounted.cleanup(); + } + }); }); diff --git a/apps/web/src/components/settings/FontPicker.tsx b/apps/web/src/components/settings/FontPicker.tsx new file mode 100644 index 00000000000..831c2870052 --- /dev/null +++ b/apps/web/src/components/settings/FontPicker.tsx @@ -0,0 +1,193 @@ +import { ChevronsUpDownIcon } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; + +import { cn } from "../../lib/utils"; +import { isMonospaceFamily, loadSystemFonts, type SystemFont } from "../../lib/systemFonts"; +import { + Combobox, + ComboboxEmpty, + ComboboxInput, + ComboboxItem, + ComboboxList, + ComboboxPopup, + ComboboxStatus, + ComboboxTrigger, + useComboboxFilter, +} from "../ui/combobox"; +import { Switch } from "../ui/switch"; + +const FONT_PICKER_TRIGGER_CLASS_NAME = + "relative inline-flex cursor-pointer select-none items-center justify-between gap-2 border rounded-lg text-left text-base outline-none transition-[color,box-shadow,background-color] data-disabled:pointer-events-none data-disabled:opacity-64 sm:text-sm w-full min-w-36 border-input bg-background not-dark:bg-clip-padding text-foreground shadow-xs/5 ring-ring/24 before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] not-data-popup-open:before:shadow-[0_1px_--theme(--color-black/4%)] focus-visible:border-ring focus-visible:ring-[3px] dark:bg-input/32 dark:not-data-popup-open:before:shadow-[0_-1px_--theme(--color-white/6%)] data-popup-open:shadow-none min-h-9 px-[calc(--spacing(3)-1px)] sm:min-h-8"; + +const FONT_PICKER_TRIGGER_ICON_CLASS_NAME = + "-me-1 size-4.5 opacity-80 sm:size-4 shrink-0 pointer-events-none"; + +const SYSTEM_DEFAULT_VALUE = "__system_default__"; +const SYSTEM_DEFAULT_LABEL = "System default"; + +interface FontPickerProps { + readonly value: string; + readonly onValueChange: (next: string) => void; + readonly className?: string; +} + +interface PickerItem { + readonly value: string; + readonly label: string; + readonly fontFamily: string | null; + readonly isMonospace: boolean; +} + +function buildItems(fonts: ReadonlyArray, currentValue: string): PickerItem[] { + const items: PickerItem[] = [ + { + value: SYSTEM_DEFAULT_VALUE, + label: SYSTEM_DEFAULT_LABEL, + fontFamily: null, + isMonospace: true, + }, + ]; + const seen = new Set(); + for (const font of fonts) { + if (seen.has(font.family)) continue; + seen.add(font.family); + items.push({ + value: font.family, + label: font.family, + fontFamily: font.family, + isMonospace: font.isMonospace, + }); + } + if (currentValue && !seen.has(currentValue)) { + items.push({ + value: currentValue, + label: currentValue, + fontFamily: currentValue, + isMonospace: isMonospaceFamily(currentValue), + }); + } + return items; +} + +export function FontPicker({ value, onValueChange, className }: FontPickerProps) { + const [open, setOpen] = useState(false); + const [hasLoaded, setHasLoaded] = useState(false); + const [fonts, setFonts] = useState>([]); + const [source, setSource] = useState<"local-fonts" | "fallback" | null>(null); + const [query, setQuery] = useState(""); + const [monospaceOnly, setMonospaceOnly] = useState(true); + const filter = useComboboxFilter(); + + useEffect(() => { + if (!open || hasLoaded) return; + let cancelled = false; + void loadSystemFonts() + .then((result) => { + if (cancelled) return; + setFonts(result.fonts); + setSource(result.source); + setHasLoaded(true); + }) + .catch((error) => { + if (cancelled) return; + console.warn("[FontPicker] failed to load system fonts", error); + setHasLoaded(true); + }); + return () => { + cancelled = true; + }; + }, [open, hasLoaded]); + + const items = useMemo(() => buildItems(fonts, value), [fonts, value]); + + const filteredItems = useMemo(() => { + const base = monospaceOnly + ? items.filter((item) => item.value === SYSTEM_DEFAULT_VALUE || item.isMonospace) + : items; + const trimmedQuery = query.trim(); + if (trimmedQuery.length === 0) return base; + return base.filter((item) => filter.contains(item.label, trimmedQuery)); + }, [items, monospaceOnly, query, filter]); + + const triggerLabel = value ? value : SYSTEM_DEFAULT_LABEL; + const selectedValue = value ? value : SYSTEM_DEFAULT_VALUE; + + const handleValueChange = (next: string | null) => { + if (next === null) return; + if (next === SYSTEM_DEFAULT_VALUE) { + onValueChange(""); + } else { + onValueChange(next); + } + setOpen(false); + setQuery(""); + }; + + const handleOpenChange = (nextOpen: boolean) => { + setOpen(nextOpen); + if (!nextOpen) { + setQuery(""); + } + }; + + const statusText = !hasLoaded + ? "Loading fonts..." + : source === "fallback" + ? "Showing common monospace fonts." + : null; + + return ( + item.value)} + filter={null} + value={selectedValue} + onValueChange={handleValueChange} + open={open} + onOpenChange={handleOpenChange} + > + + + {triggerLabel} + + + + +
+ setQuery(event.target.value)} + /> +
+
+ +
+ No fonts found. + + {filteredItems.map((item, index) => ( + + + {item.label} + + + ))} + + {statusText ? {statusText} : null} +
+
+ ); +} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index e7f21da4809..ab464431ab2 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -1,7 +1,8 @@ import { ArchiveIcon, ArchiveX, LoaderIcon, PlusIcon, RefreshCwIcon } from "lucide-react"; import { useQueryClient } from "@tanstack/react-query"; import { Link } from "@tanstack/react-router"; -import { useCallback, useMemo, useRef, useState } from "react"; +import type React from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { defaultInstanceIdForDriver, type DesktopUpdateChannel, @@ -12,7 +13,12 @@ import { type ScopedThreadRef, } from "@t3tools/contracts"; import { scopeThreadRef } from "@t3tools/client-runtime"; -import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; +import { + DEFAULT_BRANCH_NAME_PROMPT_INSTRUCTIONS, + DEFAULT_COMMIT_MESSAGE_PROMPT_INSTRUCTIONS, + DEFAULT_PR_CONTENT_PROMPT_INSTRUCTIONS, + DEFAULT_UNIFIED_SETTINGS, +} from "@t3tools/contracts/settings"; import { createModelSelection } from "@t3tools/shared/model"; import * as Duration from "effect/Duration"; import * as Equal from "effect/Equal"; @@ -50,11 +56,13 @@ import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState"; import { formatRelativeTime, formatRelativeTimeLabel } from "../../timestampFormat"; import { Button } from "../ui/button"; import { DraftInput } from "../ui/draft-input"; +import { Textarea } from "../ui/textarea"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; import { Switch } from "../ui/switch"; import { stackedThreadToast, toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { AddProviderInstanceDialog } from "./AddProviderInstanceDialog"; +import { FontPicker } from "./FontPicker"; import { canOneClickUpdateProviderCandidate, collectProviderUpdateCandidates, @@ -402,9 +410,17 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.diffIgnoreWhitespace !== DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace ? ["Diff whitespace changes"] : []), + ...(settings.diffFontFamily !== DEFAULT_UNIFIED_SETTINGS.diffFontFamily ? ["Diff font"] : []), + ...(settings.hideUnavailableProviders !== DEFAULT_UNIFIED_SETTINGS.hideUnavailableProviders + ? ["Hide unavailable providers"] + : []), ...(settings.autoOpenPlanSidebar !== DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar ? ["Auto-open task panel"] : []), + ...(settings.changedFilesExpandedByDefault !== + DEFAULT_UNIFIED_SETTINGS.changedFilesExpandedByDefault + ? ["Expand changed files by default"] + : []), ...(settings.enableAssistantStreaming !== DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming ? ["Assistant output"] : []), @@ -425,16 +441,34 @@ export function useSettingsRestore(onRestored?: () => void) { ? ["Delete confirmation"] : []), ...(isGitWritingModelDirty ? ["Git writing model"] : []), + ...(settings.commitMessagePromptInstructions !== + DEFAULT_UNIFIED_SETTINGS.commitMessagePromptInstructions + ? ["Commit message instructions"] + : []), + ...(settings.prContentPromptInstructions !== + DEFAULT_UNIFIED_SETTINGS.prContentPromptInstructions + ? ["PR content instructions"] + : []), + ...(settings.branchNamePromptInstructions !== + DEFAULT_UNIFIED_SETTINGS.branchNamePromptInstructions + ? ["Branch name instructions"] + : []), ], [ isGitWritingModelDirty, + settings.branchNamePromptInstructions, + settings.commitMessagePromptInstructions, + settings.prContentPromptInstructions, settings.autoOpenPlanSidebar, + settings.changedFilesExpandedByDefault, settings.confirmThreadArchive, settings.confirmThreadDelete, settings.addProjectBaseDirectory, settings.defaultThreadEnvMode, + settings.diffFontFamily, settings.diffIgnoreWhitespace, settings.diffWordWrap, + settings.hideUnavailableProviders, settings.automaticGitFetchInterval, settings.enableAssistantStreaming, settings.sidebarThreadPreviewCount, @@ -460,6 +494,8 @@ export function useSettingsRestore(onRestored?: () => void) { diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace, sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount, autoOpenPlanSidebar: DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar, + changedFilesExpandedByDefault: DEFAULT_UNIFIED_SETTINGS.changedFilesExpandedByDefault, + hideUnavailableProviders: DEFAULT_UNIFIED_SETTINGS.hideUnavailableProviders, enableAssistantStreaming: DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming, automaticGitFetchInterval: DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval, defaultThreadEnvMode: DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode, @@ -467,6 +503,9 @@ export function useSettingsRestore(onRestored?: () => void) { confirmThreadArchive: DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive, confirmThreadDelete: DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete, textGenerationModelSelection: DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection, + commitMessagePromptInstructions: DEFAULT_UNIFIED_SETTINGS.commitMessagePromptInstructions, + prContentPromptInstructions: DEFAULT_UNIFIED_SETTINGS.prContentPromptInstructions, + branchNamePromptInstructions: DEFAULT_UNIFIED_SETTINGS.branchNamePromptInstructions, }); onRestored?.(); }, [changedSettingLabels, onRestored, setTheme, updateSettings]); @@ -477,6 +516,85 @@ export function useSettingsRestore(onRestored?: () => void) { }; } +function DraftTextarea({ + value, + onCommit, + className, + ...rest +}: Omit, "value" | "onChange" | "defaultValue"> & { + readonly value: string; + readonly onCommit: (next: string) => void; +}) { + const [draft, setDraft] = useState(value); + const focusedRef = useRef(false); + + useEffect(() => { + if (!focusedRef.current) setDraft(value); + }, [value]); + + return ( +