diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts index a29e0fd3baf6..a6e2cc69cb60 100644 --- a/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts @@ -3,7 +3,7 @@ import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import { beforeEach, vi } from "vite-plus/test"; +import { afterEach, beforeEach, vi } from "vite-plus/test"; const { appendSwitchMock, getSwitchValueMock, hasSwitchMock, registerSchemesMock } = vi.hoisted( () => ({ @@ -37,6 +37,10 @@ describe("DesktopPreReadyPlatform", () => { registerSchemesMock.mockReset(); }); + afterEach(() => { + vi.unstubAllEnvs(); + }); + it("reads an explicit Electron command-line switch value", () => { const value = DesktopPreReadyPlatform.readCommandLineSwitchValue( { @@ -78,6 +82,23 @@ describe("DesktopPreReadyPlatform", () => { assert.isNull(value); }); + it.effect("appends the packaged Linux window class before Electron is ready", () => + Effect.gen(function* () { + vi.stubEnv("VITE_DEV_SERVER_URL", ""); + hasSwitchMock.mockReturnValue(false); + + const preReadyLayer = DesktopPreReadyPlatform.layer.pipe( + Layer.provide(Layer.succeed(HostProcessPlatform, "linux")), + ); + const options = yield* Effect.gen(function* () { + return yield* DesktopPreReadyPlatform.DesktopPreReadyElectronOptions; + }).pipe(Effect.provide(preReadyLayer)); + + assert.equal(options.linux?.linuxWmClass, "t3code"); + assert.deepEqual(appendSwitchMock.mock.calls[0], ["class", "t3code"]); + }), + ); + it.effect( "acquires a synchronous pre-ready layer before an asynchronous Clerk-shaped layer", () => diff --git a/apps/mobile/src/components/ProviderIcon.tsx b/apps/mobile/src/components/ProviderIcon.tsx index bdddf2c45951..b0e05b7b51e3 100644 --- a/apps/mobile/src/components/ProviderIcon.tsx +++ b/apps/mobile/src/components/ProviderIcon.tsx @@ -22,6 +22,17 @@ export function ProviderIcon(props: ProviderIconProps) { ); } + if (props.provider === "antigravity") { + return ( + + + + ); + } + if (props.provider === "grok") { const fill = isDarkMode ? "#F5F5F5" : "#0F0F0F"; return ( diff --git a/apps/mobile/src/lib/modelOptions.ts b/apps/mobile/src/lib/modelOptions.ts index cb7a8c4198ec..f78d687d75d2 100644 --- a/apps/mobile/src/lib/modelOptions.ts +++ b/apps/mobile/src/lib/modelOptions.ts @@ -35,6 +35,7 @@ function providerDisplayLabel(provider: { if (provider.displayName) return provider.displayName; if (provider.driver === "codex") return "Codex"; if (provider.driver === "claudeAgent") return "Claude"; + if (provider.driver === "antigravity") return "Antigravity"; return provider.instanceId; } diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.ts b/apps/server/src/provider/Drivers/AntigravityDriver.ts new file mode 100644 index 000000000000..e3a2f9a8eb21 --- /dev/null +++ b/apps/server/src/provider/Drivers/AntigravityDriver.ts @@ -0,0 +1,168 @@ +import { AntigravitySettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { HttpClient } from "effect/unstable/http"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; +import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { makeAntigravityTextGeneration } from "../../textGeneration/AntigravityTextGeneration.ts"; +import { ProviderDriverError } from "../Errors.ts"; +import { makeAntigravityAdapter } from "../Layers/AntigravityAdapter.ts"; +import { + buildInitialAntigravityProviderSnapshot, + checkAntigravityProviderStatus, + enrichAntigravitySnapshot, +} from "../Layers/AntigravityProvider.ts"; +import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; +import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import { + defaultProviderContinuationIdentity, + type ProviderDriver, + type ProviderInstance, +} from "../ProviderDriver.ts"; +import type { ServerProviderDraft } from "../providerSnapshot.ts"; +import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +import { + makeManualOnlyProviderMaintenanceCapabilities, + makeStaticProviderMaintenanceResolver, + resolveProviderMaintenanceCapabilitiesEffect, +} from "../providerMaintenance.ts"; +import { + haveProviderSnapshotSettingsChanged, + makeProviderSnapshotSettingsSource, + type ProviderSnapshotSettings, +} from "../providerUpdateSettings.ts"; + +const decodeAntigravitySettings = Schema.decodeSync(AntigravitySettings); + +const DRIVER_KIND = ProviderDriverKind.make("antigravity"); +const UPDATE = makeStaticProviderMaintenanceResolver( + makeManualOnlyProviderMaintenanceCapabilities({ + provider: DRIVER_KIND, + packageName: null, + }), +); + +export type AntigravityDriverEnv = + | BackgroundPolicy.BackgroundPolicy + | ChildProcessSpawner.ChildProcessSpawner + | Crypto.Crypto + | FileSystem.FileSystem + | HttpClient.HttpClient + | Path.Path + | ProviderEventLoggers + | ServerConfig + | ServerSettingsService; + +const withInstanceIdentity = + (input: { + readonly instanceId: ProviderInstance["instanceId"]; + readonly displayName: string | undefined; + readonly accentColor: string | undefined; + readonly continuationGroupKey: string; + }) => + (snapshot: ServerProviderDraft): ServerProvider => ({ + ...snapshot, + instanceId: input.instanceId, + driver: DRIVER_KIND, + ...(input.displayName ? { displayName: input.displayName } : {}), + ...(input.accentColor ? { accentColor: input.accentColor } : {}), + continuation: { groupKey: input.continuationGroupKey }, + }); + +export const AntigravityDriver: ProviderDriver = { + driverKind: DRIVER_KIND, + metadata: { + displayName: "Antigravity", + supportsMultipleInstances: true, + }, + configSchema: AntigravitySettings, + defaultConfig: (): AntigravitySettings => decodeAntigravitySettings({}), + create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => + Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const httpClient = yield* HttpClient.HttpClient; + const serverSettings = yield* ServerSettingsService; + const eventLoggers = yield* ProviderEventLoggers; + const processEnv = mergeProviderInstanceEnvironment(environment); + const continuationIdentity = defaultProviderContinuationIdentity({ + driverKind: DRIVER_KIND, + instanceId, + }); + const stampIdentity = withInstanceIdentity({ + instanceId, + displayName, + accentColor, + continuationGroupKey: continuationIdentity.continuationKey, + }); + const effectiveConfig = { ...config, enabled } satisfies AntigravitySettings; + const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { + binaryPath: effectiveConfig.binaryPath, + env: processEnv, + }); + + const adapter = yield* makeAntigravityAdapter(effectiveConfig, { + environment: processEnv, + ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), + instanceId, + }); + const textGeneration = yield* makeAntigravityTextGeneration(effectiveConfig, processEnv); + + const checkProvider = checkAntigravityProviderStatus(effectiveConfig, processEnv).pipe( + Effect.map(stampIdentity), + Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + + const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); + const snapshot = yield* makeManagedServerProvider< + ProviderSnapshotSettings + >({ + maintenanceCapabilities, + getSettings: snapshotSettings.getSettings, + streamSettings: snapshotSettings.streamSettings, + haveSettingsChanged: haveProviderSnapshotSettingsChanged, + initialSnapshot: (settings) => + buildInitialAntigravityProviderSnapshot(settings.provider).pipe( + Effect.map(stampIdentity), + ), + checkProvider, + enrichSnapshot: ({ settings, snapshot: currentSnapshot, publishSnapshot }) => + enrichAntigravitySnapshot({ + snapshot: currentSnapshot, + maintenanceCapabilities, + enableProviderUpdateChecks: settings.enableProviderUpdateChecks, + publishSnapshot, + httpClient, + }), + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: "Failed to build the Antigravity provider snapshot.", + cause, + }), + ), + ); + + return { + instanceId, + driverKind: DRIVER_KIND, + continuationIdentity, + displayName, + accentColor, + enabled, + snapshot, + adapter, + textGeneration, + } satisfies ProviderInstance; + }), +}; diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index e099d52e5189..259e37c194f7 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -184,6 +184,7 @@ export const ClaudeDriver: ProviderDriver = { initialSnapshot: (settings) => makePendingClaudeProvider(settings.provider).pipe(Effect.map(stampIdentity)), checkProvider, + beforeRefresh: Cache.invalidate(capabilitiesProbeCache, capabilitiesCacheKey), enrichSnapshot: ({ settings, snapshot, publishSnapshot }) => enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities, { enableProviderUpdateChecks: settings.enableProviderUpdateChecks, diff --git a/apps/server/src/provider/Layers/AntigravityAdapter.test.ts b/apps/server/src/provider/Layers/AntigravityAdapter.test.ts new file mode 100644 index 000000000000..d24599590a88 --- /dev/null +++ b/apps/server/src/provider/Layers/AntigravityAdapter.test.ts @@ -0,0 +1,141 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { AntigravitySettings, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; + +import { makeAntigravityAdapter } from "./AntigravityAdapter.ts"; + +const decodeAntigravitySettings = Schema.decodeSync(AntigravitySettings); + +const makeMockAgyScript = (lines: ReadonlyArray) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectory({ prefix: "agy-mock-" }); + const scriptPath = path.join(dir, "fake-agy.sh"); + const outputScript = [ + "#!/bin/sh", + ...lines.map((line) => `printf '%s\\n' '${line}'`), + "exit 0", + "", + ].join("\n"); + yield* fs.writeFileString(scriptPath, outputScript); + yield* fs.chmod(scriptPath, 0o755); + return scriptPath; + }); + +it.layer(NodeServices.layer)("makeAntigravityAdapter", (it) => { + it.effect("starts and stops a session", () => + Effect.gen(function* () { + const adapter = yield* makeAntigravityAdapter(decodeAntigravitySettings({ enabled: true })); + const threadId = ThreadId.make("thread-1"); + + const session = yield* adapter.startSession({ + threadId, + runtimeMode: "full-access", + }); + + expect(session.threadId).toBe(threadId); + expect(session.provider).toBe("antigravity"); + expect(session.providerInstanceId).toBe("antigravity"); + expect(session.status).toBe("ready"); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("binds custom instanceId to session", () => + Effect.gen(function* () { + const customInstanceId = ProviderInstanceId.make("antigravity-secondary"); + const adapter = yield* makeAntigravityAdapter(decodeAntigravitySettings({ enabled: true }), { + instanceId: customInstanceId, + }); + const threadId = ThreadId.make("thread-2"); + + const session = yield* adapter.startSession({ + threadId, + runtimeMode: "full-access", + }); + + expect(session.providerInstanceId).toBe(customInstanceId); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("executes a turn and translates stream events", () => + Effect.gen(function* () { + const mockScript = yield* makeMockAgyScript([ + '{"event":"init","conversation_id":"conv-123"}', + '{"event":"step_update","step_update":{"step_index":0,"step_type":"agent_response","text_delta":"Hello from Antigravity!","state":"DONE","usage":{"total_tokens":100,"input_tokens":40,"output_tokens":60}}}', + '{"event":"result","result":{"conversation_id":"conv-123"}}', + ]); + + const adapter = yield* makeAntigravityAdapter( + decodeAntigravitySettings({ + enabled: true, + binaryPath: mockScript, + }), + ); + + const threadId = ThreadId.make("thread-turn-test"); + yield* adapter.startSession({ threadId, runtimeMode: "full-access" }); + + const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 5).pipe( + Stream.runCollect, + Effect.forkChild, + ); + + yield* Effect.yieldNow; + + const result = yield* adapter.sendTurn({ + threadId, + input: "Hi", + }); + + expect(result.threadId).toBe(threadId); + expect(result.turnId).toBeDefined(); + + const eventsChunk = yield* Fiber.join(runtimeEventsFiber); + const types = Array.from(eventsChunk).map((e) => e.type); + + expect(types).toContain("turn.started"); + expect(types).toContain("session.state.changed"); + expect(types).toContain("content.delta"); + expect(types).toContain("thread.token-usage.updated"); + expect(types).toContain("turn.completed"); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("handles turn interruption safely", () => + Effect.gen(function* () { + const mockScript = yield* makeMockAgyScript([ + '{"event":"init","conversation_id":"conv-long"}', + ]); + + const adapter = yield* makeAntigravityAdapter( + decodeAntigravitySettings({ + enabled: true, + binaryPath: mockScript, + }), + ); + + const threadId = ThreadId.make("thread-interrupt-test"); + yield* adapter.startSession({ threadId, runtimeMode: "full-access" }); + + const turnResult = yield* adapter.sendTurn({ + threadId, + input: "Test interrupt", + }); + + yield* adapter.interruptTurn(threadId, turnResult.turnId); + yield* adapter.stopSession(threadId); + }), + ); +}); diff --git a/apps/server/src/provider/Layers/AntigravityAdapter.ts b/apps/server/src/provider/Layers/AntigravityAdapter.ts new file mode 100644 index 000000000000..bb4cca9583a6 --- /dev/null +++ b/apps/server/src/provider/Layers/AntigravityAdapter.ts @@ -0,0 +1,772 @@ +import { + type CanonicalItemType, + type AntigravitySettings, + EventId, + ProviderDriverKind, + ProviderInstanceId, + type ProviderRuntimeEvent, + type ProviderSession, + type ThreadTokenUsageSnapshot, + RuntimeItemId, + ThreadId, + TurnId, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Option from "effect/Option"; +import * as PubSub from "effect/PubSub"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import * as SynchronizedRef from "effect/SynchronizedRef"; +import * as Semaphore from "effect/Semaphore"; +import * as ChildProcess from "effect/unstable/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; + +import { + ProviderAdapterProcessError, + ProviderAdapterRequestError, + ProviderAdapterSessionNotFoundError, + ProviderAdapterValidationError, + type ProviderAdapterError, +} from "../Errors.ts"; +import { type AntigravityAdapterShape } from "../Services/AntigravityAdapter.ts"; +import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; + +const PROVIDER = ProviderDriverKind.make("antigravity"); +const decodeJsonExit = Schema.decodeUnknownExit(Schema.fromJsonString(Schema.Unknown)); + +export interface AntigravityAdapterLiveOptions { + readonly environment?: NodeJS.ProcessEnv; + readonly nativeEventLogPath?: string; + readonly nativeEventLogger?: EventNdjsonLogger; + readonly instanceId?: ProviderInstanceId; +} + +interface ActiveProcessHandle { + readonly kill: () => Effect.Effect; +} + +interface AntigravitySessionContext { + readonly threadId: ThreadId; + session: ProviderSession; + readonly scope: Scope.Closeable; + cwd: string; + antigravityConversationId: string | undefined; + activeProcess: ActiveProcessHandle | undefined; + activeTurnId: TurnId | undefined; + turns: Array<{ id: TurnId; items: Array }>; + stopped: boolean; +} + +function toolNameToItemType(toolName: string): CanonicalItemType { + switch (toolName) { + case "run_command": + case "command_status": + case "send_command_input": + return "command_execution"; + case "replace_file_content": + case "multi_replace_file_content": + case "write_to_file": + case "sed_file": + case "notebook_edit": + return "file_change"; + case "view_file": + case "list_dir": + case "read_resource": + return "dynamic_tool_call"; + case "search_web": + case "read_url_content": + case "grep_search": + case "find_by_name": + return "web_search"; + case "call_mcp_tool": + return "mcp_tool_call"; + case "invoke_subagent": + case "define_subagent": + case "manage_subagents": + case "send_message": + return "collab_agent_tool_call"; + default: + return "dynamic_tool_call"; + } +} + +function toolParamsToDetail(parameters: unknown): string | undefined { + if (!parameters || typeof parameters !== "object") return undefined; + const p = parameters as Record; + const candidate = + p.CommandLine ?? + p.TargetFile ?? + p.AbsolutePath ?? + p.DirectoryPath ?? + p.Query ?? + p.query ?? + p.Url ?? + p.Prompt; + return typeof candidate === "string" && candidate.trim().length > 0 + ? candidate.trim() + : undefined; +} + +export function makeAntigravityAdapter( + settings: AntigravitySettings, + options?: AntigravityAdapterLiveOptions, +) { + return Effect.gen(function* () { + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const crypto = yield* Crypto.Crypto; + const processEnv = options?.environment ?? process.env; + + const nativeEventLogger = + options?.nativeEventLogger ?? + (options?.nativeEventLogPath !== undefined + ? yield* makeEventNdjsonLogger(options.nativeEventLogPath, { stream: "native" }) + : undefined); + const managedNativeEventLogger = + options?.nativeEventLogger === undefined ? nativeEventLogger : undefined; + + const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("antigravity"); + const sessions = new Map(); + const threadLocksRef = yield* SynchronizedRef.make(new Map()); + const runtimeEventPubSub = yield* PubSub.unbounded(); + + const getThreadSemaphore = (threadId: string) => + SynchronizedRef.modifyEffect(threadLocksRef, (current) => { + const existing: Option.Option = Option.fromNullishOr( + current.get(threadId), + ); + return Option.match(existing, { + onNone: () => + Semaphore.make(1).pipe( + Effect.map((semaphore) => { + const next = new Map(current); + next.set(threadId, semaphore); + return [semaphore, next] as const; + }), + ), + onSome: (semaphore) => Effect.succeed([semaphore, current] as const), + }); + }); + + const withThreadLock = (threadId: string, effect: Effect.Effect) => + Effect.flatMap(getThreadSemaphore(threadId), (semaphore) => semaphore.withPermit(effect)); + + const publishEvent = (event: ProviderRuntimeEvent) => + PubSub.publish(runtimeEventPubSub, event).pipe(Effect.asVoid); + + const nowIso = Effect.map(DateTime.now, DateTime.formatIso); + + const randomUUIDv4 = crypto.randomUUIDv4.pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "crypto/randomUUIDv4", + detail: "Failed to generate Antigravity runtime identifier.", + cause, + }), + ), + ); + + const nextEventId = Effect.map(randomUUIDv4, (id) => EventId.make(id)); + const nextTurnId = Effect.map(randomUUIDv4, (id) => TurnId.make(id)); + const makeEventStamp = () => Effect.all({ eventId: nextEventId, createdAt: nowIso }); + + const requireSession = ( + threadId: ThreadId, + ): Effect.Effect => + Effect.gen(function* () { + const ctx = sessions.get(threadId); + if (!ctx || ctx.stopped) { + return yield* new ProviderAdapterSessionNotFoundError({ + provider: PROVIDER, + threadId, + }); + } + return ctx; + }); + + const startSession: AntigravityAdapterShape["startSession"] = (input) => + withThreadLock( + input.threadId, + Effect.gen(function* () { + const threadId = input.threadId; + const existing = sessions.get(threadId); + if (existing && !existing.stopped) { + return existing.session; + } + + const sessionScope = yield* Scope.make(); + const createdAt = yield* nowIso; + + const session: ProviderSession = { + threadId, + status: "ready", + provider: PROVIDER, + providerInstanceId: boundInstanceId, + runtimeMode: input.runtimeMode, + model: + input.modelSelection?.instanceId === boundInstanceId + ? input.modelSelection.model + : undefined, + createdAt, + updatedAt: createdAt, + }; + + const ctx: AntigravitySessionContext = { + threadId, + session, + scope: sessionScope, + cwd: input.cwd ?? process.cwd(), + antigravityConversationId: undefined, + activeProcess: undefined, + activeTurnId: undefined, + turns: [], + stopped: false, + }; + + sessions.set(threadId, ctx); + + const stamp = yield* makeEventStamp(); + yield* publishEvent({ + ...stamp, + provider: PROVIDER, + threadId, + type: "session.started", + payload: {}, + }); + + return session; + }), + ); + + const sendTurn: AntigravityAdapterShape["sendTurn"] = (input) => + withThreadLock( + input.threadId, + Effect.gen(function* () { + const threadId = input.threadId; + const ctx = yield* requireSession(threadId); + + if (ctx.activeProcess) { + yield* ctx.activeProcess.kill(); + ctx.activeProcess = undefined; + } + + if (ctx.activeTurnId) { + const abortedStamp = yield* makeEventStamp(); + yield* publishEvent({ + ...abortedStamp, + provider: PROVIDER, + threadId, + turnId: ctx.activeTurnId, + type: "turn.aborted", + payload: { + reason: "Superseded by new turn", + }, + }); + ctx.activeTurnId = undefined; + } + + const turnId = yield* nextTurnId; + + ctx.activeTurnId = turnId; + const turnStartedAt = yield* nowIso; + const turnStartEventId = yield* nextEventId; + + const turnRecord = { + id: turnId, + items: [{ prompt: input.input ?? "" }], + }; + ctx.turns.push(turnRecord); + + yield* publishEvent({ + eventId: turnStartEventId, + provider: PROVIDER, + threadId, + turnId, + createdAt: turnStartedAt, + type: "turn.started", + payload: {}, + }); + + const binary = settings.binaryPath || "agy"; + const args = ["--output-format", "stream-json"]; + + if (settings.dangerouslySkipPermissions !== false) { + args.push("--dangerously-skip-permissions"); + } + + const selectedModel = + input.modelSelection?.instanceId === boundInstanceId + ? input.modelSelection.model + : undefined; + + if (selectedModel) { + args.push("--model", selectedModel); + } + + if (settings.effort) { + args.push("--effort", settings.effort); + } + + if (ctx.antigravityConversationId) { + args.push("--conversation", ctx.antigravityConversationId); + } + + const promptText = input.input ?? ""; + args.push("-p", promptText); + + const spawnCommand = yield* resolveSpawnCommand(binary, args, { + env: processEnv, + }).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId, + detail: `Failed to resolve spawn command (${binary})`, + cause, + }), + ), + Effect.tapError((error) => + Effect.gen(function* () { + ctx.activeTurnId = undefined; + const completedStamp = yield* makeEventStamp(); + yield* publishEvent({ + ...completedStamp, + provider: PROVIDER, + threadId, + turnId, + type: "turn.completed", + payload: { + state: "failed", + errorMessage: error.detail, + }, + }); + }), + ), + ); + + const command = ChildProcess.make(spawnCommand.command, spawnCommand.args, { + cwd: ctx.cwd, + env: processEnv, + shell: spawnCommand.shell, + stdin: { + stream: Stream.empty, + }, + }); + + const processHandle = yield* childProcessSpawner.spawn(command).pipe( + Effect.provideService(Scope.Scope, ctx.scope), + Effect.mapError( + (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId, + detail: `Failed to spawn Antigravity CLI process (${binary})`, + cause, + }), + ), + Effect.tapError((error) => + Effect.gen(function* () { + ctx.activeTurnId = undefined; + const completedStamp = yield* makeEventStamp(); + yield* publishEvent({ + ...completedStamp, + provider: PROVIDER, + threadId, + turnId, + type: "turn.completed", + payload: { + state: "failed", + errorMessage: error.detail, + }, + }); + }), + ), + ); + + ctx.activeProcess = { + kill: () => Effect.asVoid(Effect.ignore(processHandle.kill())), + }; + + const monitorEffect = Effect.gen(function* () { + yield* Stream.runDrain(processHandle.stderr).pipe(Effect.forkChild); + const stdoutLines = processHandle.stdout.pipe(Stream.decodeText(), Stream.splitLines); + + yield* Stream.runForEach(stdoutLines, (line) => + Effect.gen(function* () { + const trimmed = line.trim(); + if (!trimmed) return; + + const decoded = decodeJsonExit(trimmed); + if ( + Exit.isSuccess(decoded) && + typeof decoded.value === "object" && + decoded.value !== null + ) { + const data = decoded.value as Record; + const stamp = yield* makeEventStamp(); + + if (nativeEventLogger) { + yield* nativeEventLogger.write(data, threadId); + } + + if (data.event === "init") { + if (typeof data.conversation_id === "string") { + ctx.antigravityConversationId = data.conversation_id; + } + yield* publishEvent({ + ...stamp, + provider: PROVIDER, + threadId, + turnId, + type: "session.state.changed", + payload: { state: "running" }, + }); + } else if ( + data.event === "step_update" && + typeof data.step_update === "object" && + data.step_update !== null + ) { + const step = data.step_update as Record; + turnRecord.items.push(step); + + if (step.step_type === "agent_response") { + if (typeof step.text_delta === "string") { + yield* publishEvent({ + ...stamp, + provider: PROVIDER, + threadId, + turnId, + type: "content.delta", + payload: { + streamKind: "assistant_text", + delta: step.text_delta, + }, + }); + } + + if ( + step.state === "DONE" && + typeof step.usage === "object" && + step.usage !== null + ) { + const usage = step.usage as Record; + const count = (v: unknown): number | undefined => + typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : undefined; + const usedTokens = count(usage.total_tokens) ?? 0; + const usageSnapshot: ThreadTokenUsageSnapshot = { + usedTokens, + ...(count(usage.input_tokens) !== undefined + ? { inputTokens: count(usage.input_tokens) } + : {}), + ...(count(usage.output_tokens) !== undefined + ? { outputTokens: count(usage.output_tokens) } + : {}), + ...(count(usage.thinking_tokens) !== undefined + ? { reasoningOutputTokens: count(usage.thinking_tokens) } + : {}), + compactsAutomatically: true, + }; + yield* publishEvent({ + ...stamp, + provider: PROVIDER, + threadId, + turnId, + type: "thread.token-usage.updated", + payload: { usage: usageSnapshot }, + }); + } + } else if (step.step_type === "tool" && typeof step.tool_name === "string") { + const toolItemType = toolNameToItemType(step.tool_name); + const toolInfo = + typeof step.tool_info === "object" && step.tool_info !== null + ? (step.tool_info as Record) + : undefined; + const toolDetail = toolParamsToDetail(toolInfo?.parameters); + const itemId = RuntimeItemId.make(`step-${step.step_index}`); + + if (step.state === "ACTIVE") { + yield* publishEvent({ + ...stamp, + provider: PROVIDER, + threadId, + turnId, + itemId, + type: "item.started", + payload: { + itemType: toolItemType, + status: "inProgress", + title: step.tool_name, + ...(toolDetail ? { detail: toolDetail } : {}), + ...(toolInfo ? { data: toolInfo } : {}), + }, + }); + } else if (step.state === "DONE") { + yield* publishEvent({ + ...stamp, + provider: PROVIDER, + threadId, + turnId, + itemId, + type: "item.completed", + payload: { + itemType: toolItemType, + status: "completed", + title: step.tool_name, + ...(toolDetail ? { detail: toolDetail } : {}), + ...(toolInfo ? { data: toolInfo } : {}), + }, + }); + } + } + } else if ( + data.event === "result" && + typeof data.result === "object" && + data.result !== null + ) { + const resultObj = data.result as Record; + turnRecord.items.push(resultObj); + if (typeof resultObj.conversation_id === "string") { + ctx.antigravityConversationId = resultObj.conversation_id; + } + } + } + }), + ); + + const exitCode = yield* processHandle.exitCode; + const isSuccess = exitCode === 0; + + yield* withThreadLock( + threadId, + Effect.gen(function* () { + if (ctx.activeTurnId !== turnId) return; + if (ctx.activeProcess === processHandle) { + ctx.activeProcess = undefined; + } + ctx.activeTurnId = undefined; + + const completedStamp = yield* makeEventStamp(); + yield* publishEvent({ + ...completedStamp, + provider: PROVIDER, + threadId, + turnId, + type: "turn.completed", + payload: { + state: isSuccess ? "completed" : "failed", + ...(!isSuccess + ? { errorMessage: `Antigravity CLI process exited with code ${exitCode}` } + : {}), + }, + }); + }), + ); + }).pipe( + Effect.catchCause((cause) => + withThreadLock( + threadId, + Effect.gen(function* () { + if (Cause.hasInterruptsOnly(cause)) { + return yield* Effect.failCause(cause); + } + if (ctx.activeTurnId !== turnId) return; + if (ctx.activeProcess === processHandle) { + yield* processHandle.kill(); + ctx.activeProcess = undefined; + } + ctx.activeTurnId = undefined; + + const completedStamp = yield* makeEventStamp(); + yield* publishEvent({ + ...completedStamp, + provider: PROVIDER, + threadId, + turnId, + type: "turn.completed", + payload: { + state: "failed", + errorMessage: "Antigravity CLI process monitor failed.", + }, + }); + yield* Effect.logWarning("Antigravity process monitor failed", { + cause, + }); + }), + ), + ), + ); + + yield* Effect.forkChild(monitorEffect); + + return { + threadId, + turnId, + }; + }), + ); + + const interruptTurn: AntigravityAdapterShape["interruptTurn"] = (threadId, turnId) => + withThreadLock( + threadId, + Effect.gen(function* () { + const ctx = sessions.get(threadId); + if (!ctx) return; + + if (turnId !== undefined && ctx.activeTurnId !== turnId) { + return; + } + + const effectiveTurnId = turnId ?? ctx.activeTurnId; + if (ctx.activeProcess) { + yield* ctx.activeProcess.kill(); + ctx.activeProcess = undefined; + } + + if (effectiveTurnId) { + ctx.activeTurnId = undefined; + const stamp = yield* makeEventStamp(); + + yield* publishEvent({ + ...stamp, + provider: PROVIDER, + threadId, + turnId: effectiveTurnId, + type: "turn.aborted", + payload: { + reason: "Turn interrupted by user", + }, + }); + + yield* publishEvent({ + ...stamp, + provider: PROVIDER, + threadId, + turnId: effectiveTurnId, + type: "turn.completed", + payload: { + state: "interrupted", + }, + }); + } + }), + ); + + const respondToRequest: AntigravityAdapterShape["respondToRequest"] = () => Effect.void; + + const respondToUserInput: AntigravityAdapterShape["respondToUserInput"] = () => Effect.void; + + const stopSessionInternal = ( + ctx: AntigravitySessionContext, + ): Effect.Effect => + Effect.gen(function* () { + ctx.stopped = true; + if (ctx.activeProcess) { + yield* ctx.activeProcess.kill(); + ctx.activeProcess = undefined; + } + + yield* Scope.close(ctx.scope, Exit.void).pipe(Effect.ignore); + sessions.delete(ctx.threadId); + + const stamp = yield* makeEventStamp(); + yield* publishEvent({ + ...stamp, + provider: PROVIDER, + threadId: ctx.threadId, + type: "session.exited", + payload: { + exitKind: "graceful", + }, + }); + }); + + const stopSession: AntigravityAdapterShape["stopSession"] = (threadId) => + withThreadLock( + threadId, + Effect.gen(function* () { + const ctx = sessions.get(threadId); + if (!ctx) return; + yield* stopSessionInternal(ctx); + }), + ); + + const listSessions: AntigravityAdapterShape["listSessions"] = () => + Effect.sync(() => Array.from(sessions.values(), (c) => ({ ...c.session }))); + + const hasSession: AntigravityAdapterShape["hasSession"] = (threadId) => + Effect.sync(() => { + const c = sessions.get(threadId); + return c !== undefined && !c.stopped; + }); + + const readThread: AntigravityAdapterShape["readThread"] = (threadId) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + return { + threadId, + turns: ctx.turns.map((turn) => ({ + id: turn.id, + items: turn.items, + })), + }; + }); + + const rollbackThread: AntigravityAdapterShape["rollbackThread"] = (threadId, numTurns) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + if (!Number.isInteger(numTurns) || numTurns < 1) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "rollbackThread", + issue: "numTurns must be an integer >= 1.", + }); + } + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "thread/rollback", + detail: "Antigravity CLI sessions do not support provider-side rollback.", + }); + }); + + const stopAll: AntigravityAdapterShape["stopAll"] = () => + Effect.forEach(Array.from(sessions.values()), stopSessionInternal, { discard: true }); + + yield* Effect.addFinalizer(() => + Effect.ignore(stopAll()).pipe( + Effect.tap(() => PubSub.shutdown(runtimeEventPubSub)), + Effect.tap(() => + managedNativeEventLogger ? managedNativeEventLogger.close() : Effect.void, + ), + ), + ); + + const streamEvents = Stream.fromPubSub(runtimeEventPubSub); + + return { + provider: PROVIDER, + capabilities: { + sessionModelSwitch: "in-session", + }, + startSession, + sendTurn, + interruptTurn, + respondToRequest, + respondToUserInput, + stopSession, + listSessions, + hasSession, + readThread, + rollbackThread, + stopAll, + streamEvents, + } satisfies AntigravityAdapterShape; + }); +} diff --git a/apps/server/src/provider/Layers/AntigravityProvider.test.ts b/apps/server/src/provider/Layers/AntigravityProvider.test.ts new file mode 100644 index 000000000000..9fc2ff62ef0e --- /dev/null +++ b/apps/server/src/provider/Layers/AntigravityProvider.test.ts @@ -0,0 +1,122 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { AntigravitySettings } from "@t3tools/contracts"; + +import { + buildInitialAntigravityProviderSnapshot, + checkAntigravityProviderStatus, +} from "./AntigravityProvider.ts"; + +const decodeAntigravitySettings = Schema.decodeSync(AntigravitySettings); + +describe("buildInitialAntigravityProviderSnapshot", () => { + it.effect("returns a disabled snapshot when settings.enabled is false", () => + Effect.gen(function* () { + const snapshot = yield* buildInitialAntigravityProviderSnapshot( + decodeAntigravitySettings({ enabled: false }), + ); + expect(snapshot.enabled).toBe(false); + expect(snapshot.status).toBe("disabled"); + expect(snapshot.installed).toBe(false); + expect(snapshot.message).toContain("disabled"); + }), + ); + + it.effect("returns a pending snapshot by default", () => + Effect.gen(function* () { + const snapshot = yield* buildInitialAntigravityProviderSnapshot( + decodeAntigravitySettings({}), + ); + expect(snapshot.enabled).toBe(true); + expect(snapshot.installed).toBe(true); + expect(snapshot.status).toBe("warning"); + expect(snapshot.version).toBeNull(); + expect(snapshot.message).toContain("Checking Antigravity"); + expect(snapshot.requiresNewThreadForModelChange).toBe(false); + }), + ); +}); + +it.layer(NodeServices.layer)("checkAntigravityProviderStatus", (it) => { + it.effect("reports binary as missing when binary path does not resolve", () => + Effect.gen(function* () { + const snapshot = yield* checkAntigravityProviderStatus( + decodeAntigravitySettings({ + enabled: true, + binaryPath: "/definitely/not/installed/agy-binary", + }), + ); + expect(snapshot.enabled).toBe(true); + expect(snapshot.installed).toBe(false); + expect(snapshot.status).toBe("error"); + expect(snapshot.message).toMatch(/not installed|not on PATH|Failed to execute/); + }), + ); + + it.effect("reports an installed CLI as unhealthy when --help exits non-zero", () => + Effect.gen(function* () { + const secretStderr = "broken agy install: secret-token-value"; + const snapshot = yield* Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-agy-help-" }); + const agyPath = path.join(dir, "agy"); + yield* fs.writeFileString( + agyPath, + ["#!/bin/sh", `printf "%s\\n" "${secretStderr}" >&2`, "exit 2", ""].join("\n"), + ); + yield* fs.chmod(agyPath, 0o755); + + return yield* checkAntigravityProviderStatus( + decodeAntigravitySettings({ enabled: true, binaryPath: agyPath }), + ); + }), + ); + + expect(snapshot.enabled).toBe(true); + expect(snapshot.installed).toBe(true); + expect(snapshot.status).toBe("error"); + expect(snapshot.message).toBe( + "Antigravity CLI is installed but exited with non-zero status.", + ); + expect(snapshot.message).not.toContain(secretStderr); + }), + ); + + it.effect("reports ready when binary outputs help successfully", () => + Effect.gen(function* () { + const snapshot = yield* Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-agy-success-" }); + const agyPath = path.join(dir, "agy"); + yield* fs.writeFileString( + agyPath, + ["#!/bin/sh", 'printf "Usage of agy:\\n --help\\n"', "exit 0", ""].join("\n"), + ); + yield* fs.chmod(agyPath, 0o755); + + return yield* checkAntigravityProviderStatus( + decodeAntigravitySettings({ + enabled: true, + binaryPath: agyPath, + accountEmail: "developer@example.com", + }), + ); + }), + ); + + expect(snapshot.enabled).toBe(true); + expect(snapshot.installed).toBe(true); + expect(snapshot.status).toBe("ready"); + expect(snapshot.auth?.status).toBe("authenticated"); + expect(snapshot.auth?.email).toBe("developer@example.com"); + }), + ); +}); diff --git a/apps/server/src/provider/Layers/AntigravityProvider.ts b/apps/server/src/provider/Layers/AntigravityProvider.ts new file mode 100644 index 000000000000..16d2a7ab8265 --- /dev/null +++ b/apps/server/src/provider/Layers/AntigravityProvider.ts @@ -0,0 +1,284 @@ +import { + type AntigravitySettings, + type ModelCapabilities, + type ServerProvider, + type ServerProviderModel, +} from "@t3tools/contracts"; +import { causeErrorTag } from "@t3tools/shared/observability"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Result from "effect/Result"; +import { HttpClient } from "effect/unstable/http"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { createModelCapabilities } from "@t3tools/shared/model"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; + +import { + buildServerProvider, + isCommandMissingCause, + parseGenericCliVersion, + providerModelsFromSettings, + spawnAndCollect, + type ServerProviderDraft, +} from "../providerSnapshot.ts"; +import { + enrichProviderSnapshotWithVersionAdvisory, + type ProviderMaintenanceCapabilities, +} from "../providerMaintenance.ts"; + +const ANTIGRAVITY_PRESENTATION = { + displayName: "Antigravity", + badgeLabel: "Gemini", + showInteractionModeToggle: true, + requiresNewThreadForModelChange: false, +} as const; + +const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ + optionDescriptors: [], +}); + +const VERSION_PROBE_TIMEOUT_MS = 4_000; + +const ANTIGRAVITY_BUILT_IN_MODELS: ReadonlyArray = [ + { + slug: "gemini-3.7-flash", + name: "Gemini 3.7 Flash", + isCustom: false, + capabilities: EMPTY_CAPABILITIES, + }, + { + slug: "gemini-3.7-pro", + name: "Gemini 3.7 Pro", + isCustom: false, + capabilities: EMPTY_CAPABILITIES, + }, + { + slug: "gemini-2.5-pro", + name: "Gemini 2.5 Pro", + isCustom: false, + capabilities: EMPTY_CAPABILITIES, + }, + { + slug: "gemini-2.5-flash", + name: "Gemini 2.5 Flash", + isCustom: false, + capabilities: EMPTY_CAPABILITIES, + }, +]; + +function resolveAntigravityAccount( + settings: AntigravitySettings, + environment: NodeJS.ProcessEnv = process.env, +): { readonly email?: string; readonly label?: string; readonly type?: string } { + const email = + settings.accountEmail?.trim() || + environment.ANTIGRAVITY_ACCOUNT_EMAIL || + environment.GOOGLE_ACCOUNT_EMAIL || + undefined; + const label = settings.subscriptionLabel?.trim() || undefined; + return { + ...(email ? { email } : {}), + ...(label ? { label } : {}), + type: email ? "oauth" : "antigravity", + }; +} + +export function buildInitialAntigravityProviderSnapshot( + settings: AntigravitySettings, +): Effect.Effect { + return Effect.gen(function* () { + const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); + const models = antigravityModelsFromSettings(settings.customModels); + + if (!settings.enabled) { + return buildServerProvider({ + presentation: ANTIGRAVITY_PRESENTATION, + enabled: false, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Antigravity is disabled in T3 Code settings.", + }, + }); + } + + return buildServerProvider({ + presentation: ANTIGRAVITY_PRESENTATION, + enabled: true, + checkedAt, + models, + probe: { + installed: true, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Checking Antigravity CLI availability...", + }, + }); + }); +} + +function antigravityModelsFromSettings( + customModels: ReadonlyArray | undefined, + builtInModels: ReadonlyArray = ANTIGRAVITY_BUILT_IN_MODELS, +): ReadonlyArray { + return providerModelsFromSettings(builtInModels, customModels ?? [], EMPTY_CAPABILITIES); +} + +const runAntigravityProbeCommand = ( + settings: AntigravitySettings, + environment: NodeJS.ProcessEnv = process.env, +) => + Effect.gen(function* () { + const command = settings.binaryPath || "agy"; + const spawnCommand = yield* resolveSpawnCommand(command, ["--help"], { + env: environment, + }); + return yield* spawnAndCollect( + command, + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + env: environment, + shell: spawnCommand.shell, + }), + ); + }); + +export const checkAntigravityProviderStatus = Effect.fn("checkAntigravityProviderStatus")( + function* ( + settings: AntigravitySettings, + environment: NodeJS.ProcessEnv = process.env, + ): Effect.fn.Return< + ServerProviderDraft, + never, + ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto + > { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + const models = antigravityModelsFromSettings(settings.customModels); + const account = resolveAntigravityAccount(settings, environment); + + if (!settings.enabled) { + return buildServerProvider({ + presentation: ANTIGRAVITY_PRESENTATION, + enabled: false, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Antigravity is disabled in T3 Code settings.", + }, + }); + } + + const probeResult = yield* runAntigravityProbeCommand(settings, environment).pipe( + Effect.timeoutOption(VERSION_PROBE_TIMEOUT_MS), + Effect.result, + ); + + if (Result.isFailure(probeResult)) { + const error = probeResult.failure; + yield* Effect.logWarning("Antigravity CLI health check failed.", { + errorTag: error._tag, + }); + return buildServerProvider({ + presentation: ANTIGRAVITY_PRESENTATION, + enabled: settings.enabled, + checkedAt, + models, + probe: { + installed: !isCommandMissingCause(error), + version: null, + status: "error", + auth: { status: "unknown" }, + message: isCommandMissingCause(error) + ? "Antigravity CLI (`agy`) is not installed or not on PATH." + : "Failed to execute Antigravity CLI health check.", + }, + }); + } + + if (Option.isNone(probeResult.success)) { + return buildServerProvider({ + presentation: ANTIGRAVITY_PRESENTATION, + enabled: settings.enabled, + checkedAt, + models, + probe: { + installed: true, + version: null, + status: "error", + auth: { status: "unknown" }, + message: "Antigravity CLI is installed but timed out while running `agy --help`.", + }, + }); + } + + const probeOutput = probeResult.success.value; + const version = parseGenericCliVersion(`${probeOutput.stdout}\n${probeOutput.stderr}`); + + if (probeOutput.code !== 0) { + return buildServerProvider({ + presentation: ANTIGRAVITY_PRESENTATION, + enabled: settings.enabled, + checkedAt, + models, + probe: { + installed: true, + version, + status: "error", + auth: { status: "unknown" }, + message: "Antigravity CLI is installed but exited with non-zero status.", + }, + }); + } + + return buildServerProvider({ + presentation: ANTIGRAVITY_PRESENTATION, + enabled: settings.enabled, + checkedAt, + models, + probe: { + installed: true, + version, + status: "ready", + auth: account.email + ? { + status: "authenticated", + ...account, + } + : { status: "unknown" }, + }, + }); + }, +); + +export const enrichAntigravitySnapshot = (input: { + readonly snapshot: ServerProvider; + readonly maintenanceCapabilities: ProviderMaintenanceCapabilities; + readonly enableProviderUpdateChecks?: boolean; + readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect; + readonly httpClient: HttpClient.HttpClient; +}): Effect.Effect => { + const { snapshot, publishSnapshot } = input; + + return enrichProviderSnapshotWithVersionAdvisory(snapshot, input.maintenanceCapabilities, { + enableProviderUpdateChecks: input.enableProviderUpdateChecks, + }).pipe( + Effect.provideService(HttpClient.HttpClient, input.httpClient), + Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), + Effect.catchCause((cause) => + Effect.logWarning("Antigravity version advisory enrichment failed", { + errorTag: causeErrorTag(cause), + }), + ), + Effect.asVoid, + ); +}; diff --git a/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts b/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts index 040e63b80229..9c0ee8008435 100644 --- a/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts +++ b/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts @@ -10,6 +10,7 @@ import { buildClaudeCapabilitiesProbeQueryOptions, CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES, isLegacyClaudeModel, + mapClaudeUsageToStatus, probeClaudeCapabilities, } from "./ClaudeProvider.ts"; @@ -55,8 +56,125 @@ it("isolates Claude capability probes without dropping workspace setting sources assert.equal(options.env?.ENABLE_CLAUDEAI_MCP_SERVERS, "false"); }); +it("maps Claude Code plan windows to the status snapshot", () => { + assert.deepEqual( + mapClaudeUsageToStatus( + { + session: { + total_cost_usd: 0, + total_api_duration_ms: 0, + total_duration_ms: 0, + total_lines_added: 0, + total_lines_removed: 0, + model_usage: {}, + }, + subscription_type: "pro", + rate_limits_available: true, + rate_limits: { + five_hour: { + utilization: 66.4, + resets_at: "2026-08-12T20:49:59.750101+00:00", + }, + seven_day: { + utilization: 12, + resets_at: "2026-08-17T02:59:59.750124+00:00", + }, + }, + behaviors: { + day: { + request_count: 0, + session_count: 0, + behaviors: [], + agents: [], + skills: [], + plugins: [], + mcp_servers: [], + }, + week: { + request_count: 0, + session_count: 0, + behaviors: [], + agents: [], + skills: [], + plugins: [], + mcp_servers: [], + }, + }, + }, + "+50% weekly limits promo through Aug 19 ยท clau.de/cc-50-promo", + ), + { + rateLimits: { + currentSession: { + usedPercent: 66, + resetsAt: "2026-08-12T20:49:59.750101+00:00", + }, + currentWeek: { + usedPercent: 12, + resetsAt: "2026-08-17T02:59:59.750124+00:00", + }, + currentWeekPromo: "+50% weekly limits promo through Aug 19 ยท clau.de/cc-50-promo", + }, + }, + ); +}); + +it("omits a Claude reset timestamp when the SDK does not provide one", () => { + const usage = { + session: { + total_cost_usd: 0, + total_api_duration_ms: 0, + total_duration_ms: 0, + total_lines_added: 0, + total_lines_removed: 0, + model_usage: {}, + }, + subscription_type: "pro", + rate_limits_available: true, + rate_limits: { + five_hour: { + utilization: 10, + // Older SDK responses can omit this field even though the current + // generated type declares it as required. + resets_at: undefined as unknown as string, + }, + seven_day: { + utilization: 20, + resets_at: null, + }, + }, + behaviors: { + day: { + request_count: 0, + session_count: 0, + behaviors: [], + agents: [], + skills: [], + plugins: [], + mcp_servers: [], + }, + week: { + request_count: 0, + session_count: 0, + behaviors: [], + agents: [], + skills: [], + plugins: [], + mcp_servers: [], + }, + }, + }; + + assert.deepEqual(mapClaudeUsageToStatus(usage), { + rateLimits: { + currentSession: { usedPercent: 10 }, + currentWeek: { usedPercent: 20 }, + }, + }); +}); + it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => { - it.effect("serializes strict no-MCP options and still resolves account capabilities", () => + it.effect("keeps account capabilities when the usage lookup stalls", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -89,6 +207,18 @@ it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => { "const lines = createInterface({ input: process.stdin });", 'lines.on("line", (line) => {', " const message = JSON.parse(line);", + ' if (message.type === "control_request" && message.request?.subtype === "get_usage") {', + ' if (process.env.T3_PROBE_STALL_USAGE === "true") return;', + " process.stdout.write(JSON.stringify({", + ' type: "control_response",', + " response: {", + ' subtype: "success",', + " request_id: message.request_id,", + " response: { rate_limits_available: false, rate_limits: null },", + " },", + ' }) + "\\n");', + " return;", + " }", ' if (message.type !== "control_request" || message.request?.subtype !== "initialize") return;', " process.stdout.write(JSON.stringify({", ' type: "control_response",', @@ -117,6 +247,7 @@ it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => { { ...process.env, T3_PROBE_INVOCATION_PATH: invocationPath, + T3_PROBE_STALL_USAGE: "true", ENABLE_CLAUDEAI_MCP_SERVERS: "true", }, workspaceCwd, diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index b08b5db68ee0..58df8b47ed67 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -2,6 +2,8 @@ import { type ClaudeSettings, type ModelCapabilities, type ModelSelection, + type ServerProviderClaudeRateLimitWindow, + type ServerProviderClaudeStatus, type ServerProviderModel, type ServerProviderSlashCommand, } from "@t3tools/contracts"; @@ -22,6 +24,7 @@ import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { compareSemverVersions } from "@t3tools/shared/semver"; import { query as claudeQuery, + type SDKControlGetUsageResponse, type Options as ClaudeQueryOptions, type SlashCommand as ClaudeSlashCommand, type SDKUserMessage, @@ -583,6 +586,7 @@ function apiProviderAuthMetadata( // account info. The previous 8s budget expired mid-init, so the probe returned // `undefined` and left the provider unverified and unselectable in the picker. const CAPABILITIES_PROBE_TIMEOUT_MS = 25_000; +const CLAUDE_USAGE_LOOKUP_TIMEOUT_MS = 5_000; /** * Keep workspace-scoped command discovery intact while isolating the periodic @@ -641,9 +645,85 @@ type ClaudeCapabilitiesProbe = { * the subscription/token fields are absent and auth is external AWS creds. */ readonly apiProvider: string | undefined; + readonly claudeStatus?: ServerProviderClaudeStatus; readonly slashCommands: ReadonlyArray; }; +type ClaudeUsageRateLimitWindow = NonNullable< + NonNullable["five_hour"] +>; + +type ClaudeRateLimitPromoNotice = { + readonly bar?: unknown; + readonly text?: unknown; +}; + +const readClaudeRateLimitPromo = (environment: NodeJS.ProcessEnv) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const configDirectories = [environment.CLAUDE_CONFIG_DIR, environment.HOME].filter( + (value): value is string => typeof value === "string" && value.length > 0, + ); + + for (const directory of configDirectories) { + const configContents = yield* fs + .readFileString(path.join(directory, ".claude.json")) + .pipe(Effect.option); + if (Option.isNone(configContents)) continue; + + try { + // @effect-diagnostics-next-line preferSchemaOverJson:off - optional local Claude cache. + const config = JSON.parse(configContents.value) as { + readonly cachedGrowthBookFeatures?: Record; + }; + const notices = config.cachedGrowthBookFeatures?.tengu_rate_limit_promo_notices; + if (!Array.isArray(notices)) continue; + const notice = notices.find((entry): entry is ClaudeRateLimitPromoNotice => { + if (!entry || typeof entry !== "object") return false; + const candidate = entry as ClaudeRateLimitPromoNotice; + return candidate.bar === "seven_day" && typeof candidate.text === "string"; + }); + if (typeof notice?.text === "string" && notice.text.trim()) return notice.text.trim(); + } catch { + // The promotion is optional; an unavailable local cache must not fail status. + } + } + return undefined; + }); + +function mapClaudeUsageRateLimitWindow( + window: ClaudeUsageRateLimitWindow | null | undefined, +): ServerProviderClaudeRateLimitWindow | undefined { + if (!window || typeof window.utilization !== "number" || !Number.isFinite(window.utilization)) { + return undefined; + } + + return { + usedPercent: Math.max(0, Math.min(100, Math.floor(window.utilization))), + ...(window.resets_at != null ? { resetsAt: window.resets_at } : {}), + }; +} + +export function mapClaudeUsageToStatus( + usage: SDKControlGetUsageResponse, + promo?: string, +): ServerProviderClaudeStatus | undefined { + if (!usage.rate_limits_available || usage.rate_limits == null) return undefined; + + const currentSession = mapClaudeUsageRateLimitWindow(usage.rate_limits.five_hour); + const currentWeek = mapClaudeUsageRateLimitWindow(usage.rate_limits.seven_day); + if (!currentSession && !currentWeek) return undefined; + + return { + rateLimits: { + ...(currentSession ? { currentSession } : {}), + ...(currentWeek ? { currentWeek } : {}), + ...(currentWeek && promo ? { currentWeekPromo: promo } : {}), + }, + }; +} + function parseClaudeInitializationCommands( commands: ReadonlyArray | undefined, ): ReadonlyArray { @@ -741,6 +821,7 @@ const probeClaudeCapabilities = ( claudeSettings.binaryPath, claudeEnvironment, ); + const claudePromo = yield* readClaudeRateLimitPromo(claudeEnvironment); return yield* Effect.tryPromise(async () => { const q = claudeQuery({ // Never yield โ€” we only need initialization data, not a conversation. @@ -756,7 +837,24 @@ const probeClaudeCapabilities = ( cwd, }), }); - const init = await q.initializationResult(); + const init = await Promise.race([ + q.initializationResult(), + new Promise((resolve) => { + // @effect-diagnostics-next-line globalTimers:off - Promise.race needs a native timer. + globalThis.setTimeout(resolve, CAPABILITIES_PROBE_TIMEOUT_MS); + }), + ]); + if (!init) return undefined; + const claudeStatus = await Promise.race([ + q + .usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET() + .then((usage) => mapClaudeUsageToStatus(usage, claudePromo)) + .catch(() => undefined), + new Promise((resolve) => { + // @effect-diagnostics-next-line globalTimers:off - Promise.race needs a native timer. + globalThis.setTimeout(resolve, CLAUDE_USAGE_LOOKUP_TIMEOUT_MS); + }), + ]); const account = init.account as | { readonly email?: string; @@ -770,6 +868,7 @@ const probeClaudeCapabilities = ( subscriptionType: account?.subscriptionType, tokenSource: account?.tokenSource, apiProvider: account?.apiProvider, + ...(claudeStatus ? { claudeStatus } : {}), slashCommands: parseClaudeInitializationCommands(init.commands), } satisfies ClaudeCapabilitiesProbe; }); @@ -779,7 +878,7 @@ const probeClaudeCapabilities = ( if (!abort.signal.aborted) abort.abort(); }), ), - Effect.timeoutOption(CAPABILITIES_PROBE_TIMEOUT_MS), + Effect.timeoutOption(CAPABILITIES_PROBE_TIMEOUT_MS + CLAUDE_USAGE_LOOKUP_TIMEOUT_MS + 2_000), Effect.result, Effect.map((result) => { if (Result.isFailure(result)) return undefined; @@ -969,6 +1068,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( ...(capabilities.email ? { email: capabilities.email } : {}), ...(authMetadata ? authMetadata : {}), }, + ...(capabilities.claudeStatus ? { claudeStatus: capabilities.claudeStatus } : {}), ...(versionUpgradeMessage ? { message: versionUpgradeMessage } : {}), }, }); diff --git a/apps/server/src/provider/Layers/CodexProvider.test.ts b/apps/server/src/provider/Layers/CodexProvider.test.ts index 26e77f82a79e..5b8efffd03aa 100644 --- a/apps/server/src/provider/Layers/CodexProvider.test.ts +++ b/apps/server/src/provider/Layers/CodexProvider.test.ts @@ -3,7 +3,9 @@ import { assert, it } from "@effect/vitest"; import { applyPreferredCodexDefaultModel, isLegacyCodexModel, + mapCodexStatus, mapCodexModelCapabilities, + type CodexAppServerProviderSnapshot, } from "./CodexProvider.ts"; it("keeps only the GPT-5.6 Codex family out of legacy models", () => { @@ -21,6 +23,58 @@ it("keeps only the GPT-5.6 Codex family out of legacy models", () => { ); }); +it("projects Codex account and rate-limit snapshots into provider status", () => { + const snapshot = { + account: { + account: { + type: "chatgpt", + email: "dev@example.com", + planType: "plus", + }, + requiresOpenaiAuth: false, + }, + rateLimits: { + rateLimits: { + limitName: "codex", + primary: { + usedPercent: 24, + resetsAt: 1_781_654_400, + windowDurationMins: 300, + }, + secondary: { + usedPercent: 12, + resetsAt: null, + windowDurationMins: null, + }, + }, + }, + version: "1.0.0", + models: [], + skills: [], + } satisfies CodexAppServerProviderSnapshot; + + assert.deepStrictEqual(mapCodexStatus(snapshot), { + account: { + type: "chatgpt", + email: "dev@example.com", + planType: "plus", + }, + rateLimits: { + limitName: "codex", + primary: { + usedPercent: 24, + resetsAt: 1_781_654_400, + windowDurationMins: 300, + }, + secondary: { + usedPercent: 12, + resetsAt: null, + windowDurationMins: null, + }, + }, + }); +}); + it("maps current Codex model capability fields", () => { const capabilities = mapCodexModelCapabilities({ additionalSpeedTiers: [], diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 5c0f76dff4e3..c697f1baf2ca 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -19,6 +19,7 @@ import type { ServerProviderState, ModelCapabilities, ProviderOptionDescriptor, + ServerProviderCodexStatus, ServerProviderModel, ServerProviderSkill, } from "@t3tools/contracts"; @@ -45,6 +46,7 @@ const CODEX_PRESENTATION = { export interface CodexAppServerProviderSnapshot { readonly account: CodexSchema.V2GetAccountResponse; + readonly rateLimits?: CodexSchema.V2GetAccountRateLimitsResponse; readonly version: string | undefined; readonly models: ReadonlyArray; readonly skills: ReadonlyArray; @@ -112,6 +114,49 @@ function codexAccountEmail(account: CodexSchema.V2GetAccountResponse["account"]) return account.email; } +function mapCodexRateLimitWindow( + window: CodexSchema.V2GetAccountRateLimitsResponse["rateLimits"]["primary"], +) { + if (window === undefined || window === null) return window; + return { + usedPercent: window.usedPercent, + ...(window.resetsAt !== undefined ? { resetsAt: window.resetsAt } : {}), + ...(window.windowDurationMins !== undefined + ? { windowDurationMins: window.windowDurationMins } + : {}), + }; +} + +export function mapCodexStatus( + snapshot: CodexAppServerProviderSnapshot, +): ServerProviderCodexStatus { + const account = snapshot.account.account; + const rateLimits = snapshot.rateLimits?.rateLimits; + const primary = rateLimits ? mapCodexRateLimitWindow(rateLimits.primary) : undefined; + const secondary = rateLimits ? mapCodexRateLimitWindow(rateLimits.secondary) : undefined; + + return { + account: + account === undefined || account === null + ? null + : { + type: account.type, + ...(account.type === "chatgpt" + ? { email: account.email, planType: account.planType } + : {}), + }, + ...(rateLimits + ? { + rateLimits: { + ...(rateLimits.limitName !== undefined ? { limitName: rateLimits.limitName } : {}), + ...(primary !== undefined ? { primary } : {}), + ...(secondary !== undefined ? { secondary } : {}), + }, + } + : {}), + }; +} + export function mapCodexModelCapabilities( model: CodexSchema.V2ModelListResponse__Model, ): ModelCapabilities { @@ -395,18 +440,24 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun } satisfies CodexAppServerProviderSnapshot; } - const [skillsResponse, models] = yield* Effect.all( + const [skillsResponse, models, rateLimits] = yield* Effect.all( [ client.request("skills/list", { cwds: [input.cwd], }), requestAllCodexModels(client), + accountResponse.account + ? client + .request("account/rateLimits/read", undefined) + .pipe(Effect.catch(() => Effect.succeed(undefined))) + : Effect.succeed(undefined), ], { concurrency: "unbounded" }, ); return { account: accountResponse, + ...(rateLimits ? { rateLimits } : {}), version, models: applyPreferredCodexDefaultModel( appendCustomCodexModels(models, input.customModels ?? []), @@ -606,6 +657,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu version: snapshot.version ?? null, status: accountStatus.status, auth: accountStatus.auth, + codexStatus: mapCodexStatus(snapshot), ...(accountStatus.message ? { message: accountStatus.message } : {}), }, }); diff --git a/apps/server/src/provider/Services/AntigravityAdapter.ts b/apps/server/src/provider/Services/AntigravityAdapter.ts new file mode 100644 index 000000000000..1179666bd17c --- /dev/null +++ b/apps/server/src/provider/Services/AntigravityAdapter.ts @@ -0,0 +1,4 @@ +import type { ProviderAdapterError } from "../Errors.ts"; +import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; + +export interface AntigravityAdapterShape extends ProviderAdapterShape {} diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index 791a96e1da3c..2ff7cd557bb8 100644 --- a/apps/server/src/provider/builtInDrivers.ts +++ b/apps/server/src/provider/builtInDrivers.ts @@ -20,6 +20,7 @@ * * @module provider/builtInDrivers */ +import { AntigravityDriver, type AntigravityDriverEnv } from "./Drivers/AntigravityDriver.ts"; import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts"; import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts"; import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts"; @@ -33,6 +34,7 @@ import type { AnyProviderDriver } from "./ProviderDriver.ts"; * layer must provide every service in this union. */ export type BuiltInDriversEnv = + | AntigravityDriverEnv | ClaudeDriverEnv | CodexDriverEnv | CursorDriverEnv @@ -47,6 +49,7 @@ export type BuiltInDriversEnv = export const BUILT_IN_DRIVERS: ReadonlyArray> = [ CodexDriver, ClaudeDriver, + AntigravityDriver, CursorDriver, GrokDriver, OpenCodeDriver, diff --git a/apps/server/src/provider/makeManagedServerProvider.test.ts b/apps/server/src/provider/makeManagedServerProvider.test.ts index 5bfd3e14cfd7..10de680c2a17 100644 --- a/apps/server/src/provider/makeManagedServerProvider.test.ts +++ b/apps/server/src/provider/makeManagedServerProvider.test.ts @@ -192,6 +192,100 @@ describe("makeManagedServerProvider", () => { ).pipe(Effect.provide(AlwaysRunTestLayer)), ); + it.effect("runs the refresh hook before an explicit provider refresh", () => + Effect.scoped( + Effect.gen(function* () { + const events = yield* Ref.make>([]); + const initialCheckDone = yield* Deferred.make(); + const provider = yield* makeManagedServerProvider({ + maintenanceCapabilities, + getSettings: Effect.succeed({ enabled: true }), + streamSettings: Stream.empty, + haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, + initialSnapshot: () => Effect.succeed(initialSnapshot), + checkProvider: Ref.update( + events, + (previous) => [...previous, "check"] as Array<"beforeRefresh" | "check">, + ).pipe( + Effect.tap(() => Deferred.succeed(initialCheckDone, undefined).pipe(Effect.ignore)), + Effect.as(refreshedSnapshot), + ), + beforeRefresh: Ref.update( + events, + (previous) => [...previous, "beforeRefresh"] as Array<"beforeRefresh" | "check">, + ), + refreshInterval: "1 hour", + }); + + yield* Deferred.await(initialCheckDone); + yield* Ref.set(events, []); + yield* provider.refresh; + + assert.deepStrictEqual(yield* Ref.get(events), ["beforeRefresh", "check"]); + }), + ).pipe(Effect.provide(AlwaysRunTestLayer)), + ); + + it.effect("serializes refresh hooks with the provider check", () => + Effect.scoped( + Effect.gen(function* () { + const events = yield* Ref.make([]); + const beforeRefreshCalls = yield* Ref.make(0); + const checkCalls = yield* Ref.make(0); + const initialCheckDone = yield* Deferred.make(); + const firstRefreshCheckStarted = yield* Deferred.make(); + const releaseFirstRefresh = yield* Deferred.make(); + const provider = yield* makeManagedServerProvider({ + maintenanceCapabilities, + getSettings: Effect.succeed({ enabled: true }), + streamSettings: Stream.empty, + haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, + initialSnapshot: () => Effect.succeed(initialSnapshot), + checkProvider: Effect.gen(function* () { + const call = yield* Ref.updateAndGet(checkCalls, (count) => count + 1); + yield* Ref.update(events, (previous) => [...previous, `check:${call}`]); + if (call === 1) { + yield* Deferred.succeed(initialCheckDone, undefined).pipe(Effect.ignore); + } else if (call === 2) { + yield* Deferred.succeed(firstRefreshCheckStarted, undefined).pipe(Effect.ignore); + yield* Deferred.await(releaseFirstRefresh); + } + return call === 2 ? refreshedSnapshot : refreshedSnapshotSecond; + }), + beforeRefresh: Effect.gen(function* () { + const call = yield* Ref.updateAndGet(beforeRefreshCalls, (count) => count + 1); + yield* Ref.update(events, (previous) => [...previous, `before:${call}`]); + }), + refreshInterval: "1 hour", + }); + + yield* Deferred.await(initialCheckDone); + yield* Ref.set(events, []); + + const firstRefresh = yield* provider.refresh.pipe(Effect.forkChild); + yield* Deferred.await(firstRefreshCheckStarted); + + const secondRefresh = yield* provider.refresh.pipe(Effect.forkChild); + yield* Effect.yieldNow; + + assert.strictEqual(yield* Ref.get(beforeRefreshCalls), 1); + assert.deepStrictEqual(yield* Ref.get(events), ["before:1", "check:2"]); + + yield* Deferred.succeed(releaseFirstRefresh, undefined); + yield* Fiber.join(firstRefresh); + yield* Fiber.join(secondRefresh); + + assert.strictEqual(yield* Ref.get(beforeRefreshCalls), 2); + assert.deepStrictEqual(yield* Ref.get(events), [ + "before:1", + "check:2", + "before:2", + "check:3", + ]); + }), + ).pipe(Effect.provide(AlwaysRunTestLayer)), + ); + it.effect("skips periodic provider refreshes without foreground provider-status demand", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/provider/makeManagedServerProvider.ts b/apps/server/src/provider/makeManagedServerProvider.ts index d2b6b52e8f1c..93bf9f17bd8a 100644 --- a/apps/server/src/provider/makeManagedServerProvider.ts +++ b/apps/server/src/provider/makeManagedServerProvider.ts @@ -33,6 +33,8 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( readonly haveSettingsChanged: (previous: Settings, next: Settings) => boolean; readonly initialSnapshot: (settings: Settings) => Effect.Effect; readonly checkProvider: Effect.Effect; + /** Run immediately before a manual or periodic refresh check. */ + readonly beforeRefresh?: Effect.Effect; readonly enrichSnapshot?: (input: { readonly settings: Settings; readonly snapshot: ServerProvider; @@ -143,8 +145,13 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( refreshSemaphore.withPermits(1)(applySnapshotBase(nextSettings, options)); const refreshSnapshot = Effect.fn("refreshSnapshot")(function* () { - const nextSettings = yield* input.getSettings; - return yield* applySnapshot(nextSettings, { forceRefresh: true }); + return yield* refreshSemaphore.withPermits(1)( + Effect.gen(function* () { + yield* input.beforeRefresh ?? Effect.void; + const nextSettings = yield* input.getSettings; + return yield* applySnapshotBase(nextSettings, { forceRefresh: true }); + }), + ); }); const hasProviderStatusDemand = Effect.gen(function* () { diff --git a/apps/server/src/provider/providerSnapshot.ts b/apps/server/src/provider/providerSnapshot.ts index 03b4cf4a3b6e..0aeb27334206 100644 --- a/apps/server/src/provider/providerSnapshot.ts +++ b/apps/server/src/provider/providerSnapshot.ts @@ -2,7 +2,9 @@ import type { ProviderDriverKind, ModelCapabilities, ServerProvider, + ServerProviderClaudeStatus, ServerProviderAuth, + ServerProviderCodexStatus, ServerProviderSkill, ServerProviderSlashCommand, ServerProviderModel, @@ -50,6 +52,8 @@ export interface ProviderProbeResult { readonly status: Exclude; readonly auth: ServerProviderAuth; readonly message?: string; + readonly codexStatus?: ServerProviderCodexStatus; + readonly claudeStatus?: ServerProviderClaudeStatus; } export interface ServerProviderPresentation { @@ -246,6 +250,8 @@ export function buildServerProvider(input: { auth: input.probe.auth, checkedAt: input.checkedAt, ...(input.probe.message ? { message: input.probe.message } : {}), + ...(input.probe.codexStatus ? { codexStatus: input.probe.codexStatus } : {}), + ...(input.probe.claudeStatus ? { claudeStatus: input.probe.claudeStatus } : {}), models: input.models, slashCommands: [...(input.slashCommands ?? [])], skills: [...(input.skills ?? [])], diff --git a/apps/server/src/textGeneration/AntigravityTextGeneration.ts b/apps/server/src/textGeneration/AntigravityTextGeneration.ts new file mode 100644 index 000000000000..6cf6b2923d09 --- /dev/null +++ b/apps/server/src/textGeneration/AntigravityTextGeneration.ts @@ -0,0 +1,288 @@ +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { + type AntigravitySettings, + type ModelSelection, + TextGenerationError, +} from "@t3tools/contracts"; +import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; +import { extractJsonObject } from "@t3tools/shared/schemaJson"; + +import * as TextGeneration from "./TextGeneration.ts"; +import { + buildBranchNamePrompt, + buildCommitMessagePrompt, + buildPrContentPrompt, + buildThreadTitlePrompt, +} from "./TextGenerationPrompts.ts"; +import { + normalizeCliError, + sanitizeCommitSubject, + sanitizePrTitle, + sanitizeThreadTitle, +} from "./TextGenerationUtils.ts"; + +const ANTIGRAVITY_TIMEOUT_MS = 180_000; +const decodeJsonExit = Schema.decodeUnknownExit(Schema.fromJsonString(Schema.Unknown)); + +export const makeAntigravityTextGeneration = Effect.fn("makeAntigravityTextGeneration")(function* ( + settings: AntigravitySettings, + environment?: NodeJS.ProcessEnv, +) { + const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const processEnv = environment ?? process.env; + + const readStreamAsString = ( + operation: string, + stream: Stream.Stream, + ): Effect.Effect => + stream.pipe( + Stream.decodeText(), + Stream.runFold( + () => "", + (acc, chunk) => acc + chunk, + ), + Effect.mapError((cause) => + normalizeCliError("antigravity", operation, cause, "Failed to collect process output"), + ), + ); + + const runAntigravityJson = Effect.fn("runAntigravityJson")(function* ({ + operation, + cwd, + prompt, + outputSchemaJson, + modelSelection, + }: { + readonly operation: + | "generateCommitMessage" + | "generatePrContent" + | "generateBranchName" + | "generateThreadTitle"; + readonly cwd: string; + readonly prompt: string; + readonly outputSchemaJson: S; + readonly modelSelection: ModelSelection; + }): Effect.fn.Return { + const binary = settings.binaryPath || "agy"; + const args = ["-p", prompt, "--output-format", "stream-json"]; + + if (settings.dangerouslySkipPermissions) { + args.push("--dangerously-skip-permissions"); + } + + if (settings.effort) { + args.push("--effort", settings.effort); + } + + if (modelSelection.model) { + args.push("--model", modelSelection.model); + } + + const spawnCommand = yield* resolveSpawnCommand(binary, args, { + env: processEnv, + }); + + const command = ChildProcess.make(spawnCommand.command, spawnCommand.args, { + cwd, + env: processEnv, + shell: spawnCommand.shell, + }); + + const processHandle = yield* commandSpawner + .spawn(command) + .pipe( + Effect.mapError((cause) => + normalizeCliError( + "antigravity", + operation, + cause, + `Failed to spawn Antigravity CLI process (${binary})`, + ), + ), + ); + + const [stdout, _stderr, exitCode] = yield* Effect.all( + [ + readStreamAsString(operation, processHandle.stdout), + readStreamAsString(operation, processHandle.stderr), + processHandle.exitCode.pipe( + Effect.mapError((cause) => + normalizeCliError( + "antigravity", + operation, + cause, + "Failed to read Antigravity CLI exit code", + ), + ), + ), + ], + { concurrency: "unbounded" }, + ).pipe( + Effect.timeoutOption(ANTIGRAVITY_TIMEOUT_MS), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + new TextGenerationError({ operation, detail: "Antigravity CLI request timed out." }), + ), + onSome: (value) => Effect.succeed(value), + }), + ), + ); + + if (exitCode !== 0) { + return yield* new TextGenerationError({ + operation, + detail: `Antigravity CLI command failed with code ${exitCode}.`, + }); + } + + let responseText = ""; + const lines = stdout.split("\n"); + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + const decoded = decodeJsonExit(trimmed); + if (Exit.isSuccess(decoded) && typeof decoded.value === "object" && decoded.value !== null) { + const parsed = decoded.value as Record; + const resultRecord = + typeof parsed.result === "object" && parsed.result !== null + ? (parsed.result as Record) + : undefined; + const stepRecord = + typeof parsed.step_update === "object" && parsed.step_update !== null + ? (parsed.step_update as Record) + : undefined; + + if (parsed.event === "result" && typeof resultRecord?.response === "string") { + responseText = resultRecord.response; + } else if (parsed.event === "step_update" && typeof stepRecord?.text_delta === "string") { + responseText += stepRecord.text_delta; + } + } + } + + if (!responseText) { + responseText = stdout; + } + + const jsonSnippet = extractJsonObject(responseText); + const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(outputSchemaJson)); + return yield* decodeOutput(jsonSnippet).pipe( + Effect.catchTags({ + SchemaError: (cause) => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Antigravity returned invalid structured output.", + cause, + }), + ), + }), + ); + }); + + const generateCommitMessage: TextGeneration.TextGeneration["Service"]["generateCommitMessage"] = + Effect.fn("AntigravityTextGeneration.generateCommitMessage")(function* (input) { + const { prompt, outputSchema } = buildCommitMessagePrompt({ + branch: input.branch, + stagedSummary: input.stagedSummary, + stagedPatch: input.stagedPatch, + includeBranch: input.includeBranch === true, + policy: input.policy, + }); + const generated = yield* runAntigravityJson({ + operation: "generateCommitMessage", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + subject: sanitizeCommitSubject(generated.subject), + body: generated.body.trim(), + ...("branch" in generated && typeof generated.branch === "string" + ? { branch: sanitizeFeatureBranchName(generated.branch) } + : {}), + }; + }); + + const generatePrContent: TextGeneration.TextGeneration["Service"]["generatePrContent"] = + Effect.fn("AntigravityTextGeneration.generatePrContent")(function* (input) { + const { prompt, outputSchema } = buildPrContentPrompt({ + baseBranch: input.baseBranch, + headBranch: input.headBranch, + commitSummary: input.commitSummary, + diffSummary: input.diffSummary, + diffPatch: input.diffPatch, + policy: input.policy, + changeRequestTemplate: input.changeRequestTemplate, + }); + const generated = yield* runAntigravityJson({ + operation: "generatePrContent", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + title: sanitizePrTitle(generated.title), + body: generated.body.trim(), + }; + }); + + const generateBranchName: TextGeneration.TextGeneration["Service"]["generateBranchName"] = + Effect.fn("AntigravityTextGeneration.generateBranchName")(function* (input) { + const { prompt, outputSchema } = buildBranchNamePrompt({ + message: input.message, + attachments: input.attachments, + }); + const generated = yield* runAntigravityJson({ + operation: "generateBranchName", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + branch: sanitizeBranchFragment(generated.branch), + }; + }); + + const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] = + Effect.fn("AntigravityTextGeneration.generateThreadTitle")(function* (input) { + const { prompt, outputSchema } = buildThreadTitlePrompt({ + message: input.message, + previousTitle: input.previousTitle, + attachments: input.attachments, + }); + const generated = yield* runAntigravityJson({ + operation: "generateThreadTitle", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + title: sanitizeThreadTitle(generated.title), + }; + }); + + return { + generateCommitMessage, + generatePrContent, + generateBranchName, + generateThreadTitle, + } satisfies TextGeneration.TextGeneration["Service"]; +}); diff --git a/apps/web/src/audio/turnChime.test.ts b/apps/web/src/audio/turnChime.test.ts new file mode 100644 index 000000000000..5d6edc5cd9b9 --- /dev/null +++ b/apps/web/src/audio/turnChime.test.ts @@ -0,0 +1,50 @@ +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { __resetAudioContextForTests, playTurnCompletionSound } from "./turnChime"; + +describe("turnChime", () => { + let createdOscillators: Array<{ + frequency: { setValueAtTime: ReturnType }; + start: ReturnType; + stop: ReturnType; + }> = []; + + beforeEach(() => { + __resetAudioContextForTests(); + createdOscillators = []; + const mockGainNode = { + gain: { + setValueAtTime: vi.fn(), + linearRampToValueAtTime: vi.fn(), + exponentialRampToValueAtTime: vi.fn(), + }, + connect: vi.fn(), + }; + class MockAudioContext { + state = "running"; + currentTime = 10; + destination = {}; + resume = vi.fn().mockResolvedValue(undefined); + createGain = vi.fn().mockReturnValue(mockGainNode); + createOscillator = vi.fn().mockImplementation(() => { + const osc = { + type: "sine", + frequency: { setValueAtTime: vi.fn() }, + connect: vi.fn(), + start: vi.fn(), + stop: vi.fn(), + }; + createdOscillators.push(osc); + return osc; + }); + } + + vi.stubGlobal("AudioContext", MockAudioContext); + }); + + it("plays a two-tone chime when called", () => { + playTurnCompletionSound(); + expect(createdOscillators.length).toBe(2); + expect(createdOscillators[0]?.start).toHaveBeenCalledWith(10); + expect(createdOscillators[1]?.start).toHaveBeenCalledWith(10.08); + }); +}); diff --git a/apps/web/src/audio/turnChime.ts b/apps/web/src/audio/turnChime.ts new file mode 100644 index 000000000000..86b0f97a4937 --- /dev/null +++ b/apps/web/src/audio/turnChime.ts @@ -0,0 +1,66 @@ +let audioContextInstance: AudioContext | null = null; + +export function __resetAudioContextForTests(): void { + audioContextInstance = null; +} + +function getAudioContext(): AudioContext | null { + const AudioContextClass = + (typeof window !== "undefined" + ? window.AudioContext || + (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext + : undefined) ?? + (typeof globalThis !== "undefined" + ? (globalThis as unknown as { AudioContext?: typeof AudioContext }).AudioContext + : undefined); + + if (!AudioContextClass) return null; + if (!audioContextInstance || audioContextInstance.state === "closed") { + try { + audioContextInstance = new AudioContextClass(); + } catch { + return null; + } + } + return audioContextInstance; +} + +export function playTurnCompletionSound(): void { + try { + const ctx = getAudioContext(); + if (!ctx) return; + + if (ctx.state === "suspended") { + void ctx.resume(); + } + + const now = ctx.currentTime; + const masterGain = ctx.createGain(); + masterGain.gain.setValueAtTime(0.12, now); + masterGain.connect(ctx.destination); + + const osc1 = ctx.createOscillator(); + const gain1 = ctx.createGain(); + osc1.type = "sine"; + osc1.frequency.setValueAtTime(587.33, now); + gain1.gain.setValueAtTime(0, now); + gain1.gain.linearRampToValueAtTime(1, now + 0.01); + gain1.gain.exponentialRampToValueAtTime(0.001, now + 0.09); + osc1.connect(gain1); + gain1.connect(masterGain); + osc1.start(now); + osc1.stop(now + 0.09); + + const osc2 = ctx.createOscillator(); + const gain2 = ctx.createGain(); + osc2.type = "sine"; + osc2.frequency.setValueAtTime(880, now + 0.08); + gain2.gain.setValueAtTime(0, now + 0.08); + gain2.gain.linearRampToValueAtTime(1, now + 0.09); + gain2.gain.exponentialRampToValueAtTime(0.001, now + 0.22); + osc2.connect(gain2); + gain2.connect(masterGain); + osc2.start(now + 0.08); + osc2.stop(now + 0.22); + } catch {} +} diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index a3ba76679689..029080daa0d9 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -40,6 +40,7 @@ import { useSidebarVisibility, } from "./ui/sidebar"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; +import { useUnreadBackgroundThreadCount } from "../hooks/useUnreadBackgroundThreadCount"; const MACOS_TRAFFIC_LIGHTS_LEFT_INSET = "90px"; @@ -73,6 +74,7 @@ function SidebarControl() { environmentIdentificationMode === "artwork", ); const shortcutLabel = shortcutLabelForCommand(keybindings, "sidebar.toggle"); + const unreadCount = useUnreadBackgroundThreadCount(); useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { @@ -107,6 +109,7 @@ function SidebarControl() { 0 + ? `Toggle main sidebar (${unreadCount} unread)` + : "Toggle main sidebar" + } /> } /> Toggle main sidebar{shortcutLabel ? ` (${shortcutLabel})` : ""} + {unreadCount > 0 ? ` ยท ${unreadCount} unread` : ""} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index feac2d5978ce..3bb7ba739527 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -251,6 +251,7 @@ import { environmentShell } from "../state/shell"; import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; import { DraftHeroHeadline } from "./chat/DraftHeroHeadline"; import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; +import { ThreadHandoffDialog } from "./chat/ThreadHandoffDialog"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; import { MessagesTimeline } from "./chat/MessagesTimeline"; import { resolveTimelineIsAtEnd } from "./chat/MessagesTimeline.logic"; @@ -321,6 +322,7 @@ import { revokeUserMessagePreviewUrls, shouldWriteThreadErrorToCurrentServerThread, startNewThreadForProject, + threadHasStarted, waitForStartedServerThread, } from "./ChatView.logic"; import type { ThreadSyncPhase } from "../threadSync"; @@ -1382,6 +1384,9 @@ function ChatViewContent(props: ChatViewProps) { const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); const [pullRequestDialogState, setPullRequestDialogState] = useState(null); + const [isHandoffDialogOpen, setIsHandoffDialogOpen] = useState(false); + const [handoffTargetModelSelection, setHandoffTargetModelSelection] = + useState(null); const [terminalUiLaunchContext, setTerminalUiLaunchContext] = useState(null); const [attachmentPreviewHandoffByMessageId, setAttachmentPreviewHandoffByMessageId] = useState< @@ -5875,11 +5880,141 @@ function ChatViewContent(props: ChatViewProps) { composerRef, ]); + const handleConfirmHandoff = useCallback( + async (handoffMarkdown: string, targetModelSelection: ModelSelection) => { + if (!activeProject || !activeThread) return; + const nextThreadId = newThreadId(); + const nextThreadTitle = `${activeThread.title} (Handoff)`; + const createdAt = new Date().toISOString(); + + sendInFlightRef.current = true; + beginLocalDispatch({ preparingWorktree: false }); + const finish = () => { + sendInFlightRef.current = false; + resetLocalDispatch(); + }; + + const createResult = await createThread({ + environmentId, + input: { + threadId: nextThreadId, + projectId: activeProject.id, + title: nextThreadTitle, + modelSelection: targetModelSelection, + runtimeMode, + interactionMode: "default", + branch: activeThreadBranch, + worktreePath: activeThread.worktreePath, + createdAt, + }, + }); + let failure: AtomCommandResult | null = + createResult._tag === "Failure" ? createResult : null; + + if (failure === null) { + const startResult = await startThreadTurn({ + environmentId, + input: { + threadId: nextThreadId, + message: { + messageId: newMessageId(), + role: "user", + text: handoffMarkdown, + attachments: [], + }, + modelSelection: targetModelSelection, + titleSeed: nextThreadTitle, + runtimeMode, + interactionMode: "default", + createdAt, + }, + }); + failure = startResult._tag === "Failure" ? startResult : null; + } + + if (failure === null) { + const startedResult = await settlePromise(() => + waitForStartedServerThread(scopeThreadRef(activeThread.environmentId, nextThreadId)), + ); + failure = startedResult._tag === "Failure" ? startedResult : null; + } + + if (failure === null) { + const navigateResult = await settlePromise(() => + navigate({ + to: "/$environmentId/$threadId", + params: { + environmentId: activeThread.environmentId, + threadId: nextThreadId, + }, + }), + ); + failure = navigateResult._tag === "Failure" ? navigateResult : null; + } + + if (failure !== null) { + const cleanupResult = await deleteThread({ + environmentId, + input: { + threadId: nextThreadId, + }, + }); + if (cleanupResult._tag === "Failure" && !isAtomCommandInterrupted(cleanupResult)) { + console.warn( + "Failed to clean up handoff thread after start failure.", + squashAtomCommandFailure(cleanupResult), + ); + } + if (!isAtomCommandInterrupted(failure)) { + const error = squashAtomCommandFailure(failure); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not start handoff thread", + description: + error instanceof Error + ? error.message + : typeof error === "string" + ? error + : "Failed to continue conversation in new thread.", + }), + ); + } + finish(); + return; + } + + finish(); + toastManager.add({ + type: "success", + title: "Thread started with handoff", + description: `Continuing conversation with ${targetModelSelection.model}`, + }); + }, + [ + activeProject, + activeThread, + activeThreadBranch, + beginLocalDispatch, + createThread, + deleteThread, + environmentId, + navigate, + resetLocalDispatch, + runtimeMode, + startThreadTurn, + waitForStartedServerThread, + ], + ); + const getModelDisabledReason = useCallback( (instanceId: ProviderInstanceId, model: string): string | null => { if (!activeThread) { return null; } + if (isServerThread && activeServerThread && threadHasStarted(activeServerThread)) { + return null; + } const reason = getStartedThreadModelChangeBlockReason({ providers: providerStatuses, hasStartedSession: activeThread.session !== null, @@ -5889,38 +6024,14 @@ function ChatViewContent(props: ChatViewProps) { }); return reason ? `${reason.description} Start a new thread to use this model.` : null; }, - [activeThread, providerStatuses], + [activeServerThread, activeThread, isServerThread, providerStatuses], ); const onProviderModelSelect = useCallback( (instanceId: ProviderInstanceId, model: string) => { if (!activeThread) return; - // Look up the configured instance so model normalization and custom - // model lookup stay scoped to that exact instance. Unknown instance ids - // are rejected by returning early; the server remains authoritative too. const entry = providerStatuses.find((snapshot) => snapshot.instanceId === instanceId); const resolvedDriverKind = entry?.driver ?? null; - if ( - lockedProvider !== null && - resolvedDriverKind !== null && - resolvedDriverKind !== lockedProvider - ) { - scheduleComposerFocus(); - return; - } - if (lockedProvider !== null && activeThread.session?.providerInstanceId) { - const currentEntry = providerStatuses.find( - (snapshot) => snapshot.instanceId === activeThread.session?.providerInstanceId, - ); - if ( - currentEntry?.continuation?.groupKey && - entry?.continuation?.groupKey && - currentEntry.continuation.groupKey !== entry.continuation.groupKey - ) { - scheduleComposerFocus(); - return; - } - } const resolvedModel = resolveAppModelSelectionForInstance( instanceId, settings, @@ -5935,6 +6046,28 @@ function ChatViewContent(props: ChatViewProps) { instanceId, model: resolvedModel, }; + + const isCrossProvider = + isServerThread && + activeServerThread !== null && + threadHasStarted(activeServerThread) && + ((lockedProvider !== null && + resolvedDriverKind !== null && + resolvedDriverKind !== lockedProvider) || + (lockedProvider !== null && + activeServerThread.session?.providerInstanceId && + (() => { + const currentEntry = providerStatuses.find( + (snapshot) => + snapshot.instanceId === activeServerThread.session?.providerInstanceId, + ); + return Boolean( + currentEntry?.continuation?.groupKey && + entry?.continuation?.groupKey && + currentEntry.continuation.groupKey !== entry.continuation.groupKey, + ); + })())); + const modelChangeBlockReason = getStartedThreadModelChangeBlockReason({ providers: providerStatuses, hasStartedSession: activeThread.session !== null, @@ -5942,6 +6075,19 @@ function ChatViewContent(props: ChatViewProps) { currentProviderInstanceId: activeThread.session?.providerInstanceId ?? null, nextModelSelection, }); + + if ( + isCrossProvider || + (modelChangeBlockReason && + isServerThread && + activeServerThread !== null && + threadHasStarted(activeServerThread)) + ) { + setHandoffTargetModelSelection(nextModelSelection); + setIsHandoffDialogOpen(true); + return; + } + if (modelChangeBlockReason) { toastManager.add({ type: "warning", @@ -5959,12 +6105,14 @@ function ChatViewContent(props: ChatViewProps) { scheduleComposerFocus(); }, [ + activeServerThread, activeThread, + isServerThread, lockedProvider, + providerStatuses, scheduleComposerFocus, setComposerDraftModelSelection, setStickyComposerModelSelection, - providerStatuses, settings, ], ); @@ -6594,6 +6742,16 @@ function ChatViewContent(props: ChatViewProps) { onPrepared={handlePreparedPullRequestThread} /> ) : null} + + {activeServerThread && handoffTargetModelSelection ? ( + + ) : null} {/* end chat column */} diff --git a/apps/web/src/components/CommandPaletteResults.tsx b/apps/web/src/components/CommandPaletteResults.tsx index bbdbc28b0609..b5c353a7cb55 100644 --- a/apps/web/src/components/CommandPaletteResults.tsx +++ b/apps/web/src/components/CommandPaletteResults.tsx @@ -20,7 +20,7 @@ function foldAsciiCase(value: string): string { return value.replace(/[A-Z]/g, (character) => character.toLowerCase()); } -function HighlightedSearchText(props: { text: string; query: string }) { +export function HighlightedSearchText(props: { text: string; query: string }) { const query = props.query.trim(); if (query.length === 0) return props.text; diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index c6e113a44523..8e6f4e823c47 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -22,6 +22,7 @@ import { resolveThreadStatusPill, resolveWorkingStartedAt, searchSidebarThreadsByTitle, + mergeSidebarThreadSearchResults, formatWorkingDurationLabel, shouldNavigateAfterProjectRemoval, shouldClearThreadSelectionOnMouseDown, @@ -755,6 +756,107 @@ describe("searchSidebarThreadsByTitle", () => { }); }); +describe("mergeSidebarThreadSearchResults", () => { + const threads = [ + { id: "thread-1", environmentId: "env-1", title: "Fix workspace search" }, + { id: "thread-2", environmentId: "env-1", title: "Review providers" }, + { id: "thread-3", environmentId: "env-1", title: "Refactor engine" }, + ]; + + it("returns title matches when there are no content matches", () => { + const results = mergeSidebarThreadSearchResults(threads, "workspace", []); + expect(results).toEqual([ + { + thread: threads[0], + contentMatch: undefined, + }, + ]); + }); + + it("returns content matches for threads whose title does not match", () => { + const contentMatches = [ + { + environmentId: EnvironmentId.make("env-1"), + threadId: ThreadId.make("thread-3"), + projectId: ProjectId.make("proj-1"), + source: "user" as const, + snippet: "Need to refactor the search engine", + messageCreatedAt: "2026-03-09T10:00:00.000Z", + }, + ]; + const results = mergeSidebarThreadSearchResults(threads, "engine", contentMatches); + expect(results).toEqual([ + { + thread: threads[2], + contentMatch: { + source: "user", + snippet: "Need to refactor the search engine", + query: "engine", + }, + }, + ]); + }); + + it("prioritizes title matches and attaches content match info when thread matches both", () => { + const contentMatches = [ + { + environmentId: EnvironmentId.make("env-1"), + threadId: ThreadId.make("thread-2"), + projectId: ProjectId.make("proj-1"), + source: "assistant" as const, + snippet: "Here is the search result for providers", + messageCreatedAt: "2026-03-09T11:00:00.000Z", + }, + { + environmentId: EnvironmentId.make("env-1"), + threadId: ThreadId.make("thread-1"), + projectId: ProjectId.make("proj-1"), + source: "user" as const, + snippet: "Let's test search functionality", + messageCreatedAt: "2026-03-09T12:00:00.000Z", + }, + ]; + const results = mergeSidebarThreadSearchResults(threads, "search", contentMatches); + expect(results).toEqual([ + { + thread: threads[0], + contentMatch: { + source: "user", + snippet: "Let's test search functionality", + query: "search", + }, + }, + { + thread: threads[1], + contentMatch: { + source: "assistant", + snippet: "Here is the search result for providers", + query: "search", + }, + }, + ]); + }); + + it("ignores content matches for threads not in the given thread collection", () => { + const contentMatches = [ + { + environmentId: EnvironmentId.make("env-1"), + threadId: ThreadId.make("deleted-thread"), + projectId: ProjectId.make("proj-1"), + source: "user" as const, + snippet: "some search match", + messageCreatedAt: "2026-03-09T10:00:00.000Z", + }, + ]; + const results = mergeSidebarThreadSearchResults(threads, "match", contentMatches); + expect(results).toEqual([]); + }); + + it("returns empty array for empty query", () => { + expect(mergeSidebarThreadSearchResults(threads, " ", [])).toEqual([]); + }); +}); + describe("sortThreadsForSidebar", () => { const sortable = (input: { id: string; createdAt: string }) => ({ id: input.id, diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 9cb09219df09..496358792017 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -1,5 +1,6 @@ import * as React from "react"; import type { ContextMenuItem } from "@t3tools/contracts"; +import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search"; import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; import { getThreadSortTimestamp, @@ -568,6 +569,86 @@ export function searchSidebarThreadsByTitle thread.title.toLowerCase().includes(normalizedQuery)); } +export interface SidebarThreadContentMatch { + readonly source: "user" | "assistant"; + readonly snippet: string; + readonly query: string; +} + +export interface SidebarSearchResult { + readonly thread: T; + readonly contentMatch?: SidebarThreadContentMatch | undefined; +} + +export function mergeSidebarThreadSearchResults< + T extends { + readonly id: string; + readonly environmentId?: string | undefined; + readonly title: string; + }, +>( + threads: readonly T[], + query: string, + contentMatches: ReadonlyArray = [], +): SidebarSearchResult[] { + const normalizedQuery = query.trim().toLowerCase(); + if (normalizedQuery.length === 0) return []; + + const titleMatches = searchSidebarThreadsByTitle(threads, query); + const contentMatchByThreadKey = new Map(); + for (const match of contentMatches) { + const key = `${match.environmentId}:${match.threadId}`; + if (!contentMatchByThreadKey.has(key)) { + contentMatchByThreadKey.set(key, match); + } + } + + const threadByKey = new Map(); + for (const thread of threads) { + const key = `${thread.environmentId ?? ""}:${thread.id}`; + if (!threadByKey.has(key)) { + threadByKey.set(key, thread); + } + } + + const seenKeys = new Set(); + const results: SidebarSearchResult[] = []; + + for (const thread of titleMatches) { + const key = `${thread.environmentId ?? ""}:${thread.id}`; + seenKeys.add(key); + const contentMatch = contentMatchByThreadKey.get(key); + results.push({ + thread, + contentMatch: contentMatch + ? { + source: contentMatch.source, + snippet: contentMatch.snippet, + query, + } + : undefined, + }); + } + + for (const match of contentMatches) { + const key = `${match.environmentId}:${match.threadId}`; + if (seenKeys.has(key)) continue; + const thread = threadByKey.get(key); + if (!thread) continue; + seenKeys.add(key); + results.push({ + thread, + contentMatch: { + source: match.source, + snippet: match.snippet, + query, + }, + }); + } + + return results; +} + type SettledTimestampInput = Pick< SidebarThreadSummary, "settledAt" | "latestUserMessageAt" | "latestTurn" | "updatedAt" diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 5ae583b66bb2..e6d4945320e8 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1,13 +1,19 @@ +import { createPortal } from "react-dom"; import { autoAnimate } from "@formkit/auto-animate"; import { useAtomValue } from "@effect/atom-react"; import * as Schema from "effect/Schema"; import { DndContext, + DragOverlay, PointerSensor, - closestCenter, + pointerWithin, + rectIntersection, + useDroppable, useSensor, useSensors, + type CollisionDetection, type DragEndEvent, + type DragStartEvent, } from "@dnd-kit/core"; import { SortableContext, @@ -15,13 +21,14 @@ import { useSortable, verticalListSortingStrategy, } from "@dnd-kit/sortable"; -import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd-kit/modifiers"; import { CSS } from "@dnd-kit/utilities"; +import { snapCenterToCursor } from "@dnd-kit/modifiers"; import { canSnooze, changeRequestAutoSettles, effectiveSettled, effectiveSnoozed, + hasQueuedTurnStart, threadWokeAt, } from "@t3tools/client-runtime/state/thread-settled"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; @@ -44,6 +51,7 @@ import { FolderIcon, FolderPlusIcon, GitBranchIcon, + LoaderCircleIcon, MessageSquareIcon, PinIcon, PlusIcon, @@ -105,11 +113,12 @@ import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; import { useLocalStorage } from "../hooks/useLocalStorage"; import { useNowMinute } from "../hooks/useNowMinute"; import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; -import { useProjects, useThreadShells } from "../state/entities"; +import { readThreadShell, useProjects, useThreadShells } from "../state/entities"; import { environmentServerConfigsAtom, primaryServerKeybindingsAtom } from "../state/server"; import { vcsEnvironment } from "../state/vcs"; import { threadEnvironment } from "../state/threads"; import { useEnvironmentQuery } from "../state/query"; +import { useThreadSearch } from "../state/queries"; import { useAtomCommand } from "../state/use-atom-command"; import { buildThreadRouteParams, @@ -119,6 +128,7 @@ import { import { formatRelativeTimeLabel, parseTimestampDate } from "../timestampFormat"; import type { SidebarThreadSummary } from "../types"; import { cn } from "~/lib/utils"; +import { HighlightedSearchText } from "./CommandPaletteResults"; import { buildThreadActionMenuItems } from "./threadActionMenu.logic"; import { buildBulkTitleRegenerationContextMenuItem, @@ -127,7 +137,9 @@ import { hasUnseenCompletion, isSidebarNestedLinkClick, isTrailingDoubleClick, + mergeSidebarThreadSearchResults, orderItemsByPreferredIds, + pinOrderKeyBetween, planPinnedReorder, resolveAdjacentThreadId, resolveSettledTimestamp, @@ -135,6 +147,7 @@ import { searchSidebarThreadsByTitle, shouldCreateNewThreadInCurrentProject, resolveWorkingStartedAt, + type SidebarThreadContentMatch, sortLogicalProjectsForSidebar, sortPinnedThreadsForSidebar, sortSettledThreadsForSidebar, @@ -143,15 +156,10 @@ import { import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic"; import { ThreadWorktreeIndicator, - nextThreadChangeRequestSnapshot, prStatusIndicator, - resolveDisplayedThreadPr, - resolveDisplayedThreadPrProvider, - setThreadChangeRequestSnapshot, + resolveThreadPr, settledPrHoverColorClass, terminalStatusFromRunningIds, - threadChangeRequestSnapshotsAtom, - type ThreadChangeRequestSnapshot, type TerminalStatusIndicator, } from "./ThreadStatusIndicators"; import { @@ -163,11 +171,7 @@ import { import { ProjectFavicon } from "./ProjectFavicon"; import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon"; import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils"; -import { - deriveProviderInstanceEntries, - shouldShowInstanceBadge, - type ProviderInstanceEntry, -} from "../providerInstances"; +import { deriveProviderInstanceEntries, type ProviderInstanceEntry } from "../providerInstances"; import { primaryServerProvidersAtom } from "../state/server"; import { useThreadRunningTerminalIds } from "../state/terminalSessions"; import { stackedThreadToast, toastManager } from "./ui/toast"; @@ -256,8 +260,7 @@ function SidebarThreadTooltip({ projectCwd, projectFaviconPath, environmentLabel, - providerEntry, - showInstanceBadge, + driverKind, modelInstanceId, modelLabel, branchMismatch, @@ -269,8 +272,7 @@ function SidebarThreadTooltip({ projectCwd: string | null; projectFaviconPath: string | null; environmentLabel: string | null; - providerEntry: ProviderInstanceEntry | null; - showInstanceBadge: boolean; + driverKind: ProviderInstanceEntry["driverKind"] | null; modelInstanceId: string; modelLabel: string; branchMismatch: { @@ -280,7 +282,6 @@ function SidebarThreadTooltip({ terminalStatus: TerminalStatusIndicator | null; terminalProcessCount: number; }) { - const driverKind = providerEntry?.driverKind ?? null; return ( -
- {showInstanceBadge && providerEntry - ? `${modelLabel} ยท ${providerEntry.displayName}` - : modelLabel} -
+
{modelLabel}
) : null} {terminalStatus ? ( @@ -452,6 +442,82 @@ function SortablePinnedThreadRow(props: { return props.children({ listeners, setNodeRef, transform, transition, isDragging }); } +function DraggableThreadRow(props: { + id: string; + children: (bag: SortablePinnedRowBag) => ReactNode; +}) { + const { listeners, setNodeRef, isDragging } = useSortable({ + id: props.id, + }); + return props.children({ + listeners, + setNodeRef, + transform: null, + transition: undefined, + isDragging, + }); +} + +function DroppableShelfHeader(props: { id: string; className?: string; children: ReactNode }) { + const { setNodeRef, isOver } = useDroppable({ id: props.id }); + return ( +
  • + {props.children} +
  • + ); +} + +function DroppableThreadZone(props: { + id: string; + className?: string | undefined; + children?: ReactNode; +}) { + const { setNodeRef, isOver } = useDroppable({ id: props.id }); + return ( +
  • + {props.children ?? "Move to active"} +
  • + ); +} + +const waitForSessionStopped = async (ref: ScopedThreadRef, timeoutMs = 4000): Promise => { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const shell = readThreadShell(ref); + if (shell) { + const status = shell.session?.status; + const isWorking = + status === "starting" || + status === "running" || + shell.hasPendingApprovals || + shell.hasPendingUserInput; + if (!isWorking) { + return true; + } + } + await new Promise((resolve) => setTimeout(resolve, 50)); + } + return false; +}; + // One unsent draft session the user has invested content in. Two lines, // nothing else: project name, then the typed prompt. All the draft's // settings (model, env mode, branch, worktree) still travel with it โ€” @@ -734,16 +800,12 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { onUnsnooze: (threadRef: ScopedThreadRef) => void; onUnpin: (threadRef: ScopedThreadRef) => void; onAcknowledgeWoke: (threadRef: ScopedThreadRef, visitedAt: string) => void; - changeRequestSnapshot: ThreadChangeRequestSnapshot | null; - onChangeRequestSnapshot: ( - threadKey: string, - snapshot: ThreadChangeRequestSnapshot | null, - ) => void; + onChangeRequestState: (threadKey: string, state: "open" | "closed" | "merged" | null) => void; + isOverlay?: boolean | undefined; }) { const { isRenaming, - changeRequestSnapshot, - onChangeRequestSnapshot, + onChangeRequestState, onCancelRename, onCommitRename, onContextMenu, @@ -788,12 +850,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { }) : null, ); - const retainTerminalOnBranchMismatch = thread.worktreePath === null; - const pr = resolveDisplayedThreadPr({ + const pr = resolveThreadPr({ threadBranch: thread.branch, gitStatus: gitStatus.data, - snapshot: changeRequestSnapshot, - retainTerminalOnBranchMismatch, }); const prState = pr?.state ?? null; @@ -887,38 +946,17 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { activeThreadBranch: thread.branch, currentGitBranch: gitStatus.data?.refName ?? null, }); - const prProvider = resolveDisplayedThreadPrProvider({ - threadBranch: thread.branch, - gitStatus: gitStatus.data, - snapshot: changeRequestSnapshot, - retainTerminalOnBranchMismatch, - }); - const prStatus = prStatusIndicator(pr, prProvider); + const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider); const settledPrHoverClass = pr ? settledPrHoverColorClass(pr.state) : undefined; + // Report the PR state so the parent can apply the configured merge rule + // and the always-on close rule during partitioning. useEffect(() => { - const nextSnapshot = nextThreadChangeRequestSnapshot({ - threadBranch: thread.branch, - gitStatus: gitStatus.data, - snapshot: changeRequestSnapshot, - retainTerminalOnBranchMismatch, - }); - if (nextSnapshot === undefined) return; - onChangeRequestSnapshot(threadKey, nextSnapshot); - }, [ - changeRequestSnapshot, - gitStatus.data, - onChangeRequestSnapshot, - retainTerminalOnBranchMismatch, - thread.branch, - threadKey, - ]); + onChangeRequestState(threadKey, prState); + }, [onChangeRequestState, prState, threadKey]); const modelInstanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId; const providerEntry = props.providerEntryByInstanceId.get(modelInstanceId) ?? null; const driverKind = providerEntry?.driverKind ?? null; - const showInstanceBadge = - providerEntry !== null && - shouldShowInstanceBadge(providerEntry, props.providerEntryByInstanceId.values()); const selectedModel = providerEntry?.models.find( (model) => model.slug === thread.modelSelection.model, ); @@ -929,15 +967,14 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { const isRemote = props.currentEnvironmentId !== null && thread.environmentId !== props.currentEnvironmentId; - const detailsTooltip = ( + const detailsTooltip = props.isOverlay ? null : ( @@ -1534,19 +1594,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { ) : null} {driverKind ? ( - + ) : null} @@ -1572,6 +1624,7 @@ function latestTurnDiff( const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { thread: SidebarThreadSummary; + contentMatch?: SidebarThreadContentMatch | undefined; projectCwd: string | null; projectFaviconPath: string | null; projectTitle: string | null; @@ -1583,9 +1636,7 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { onHighlight: () => void; onSelect: () => void; }) { - const { thread } = props; - // Same details tooltip as the regular rows: a search hit is still a thread, - // and the hover card is how you disambiguate identically-titled results. + const { thread, contentMatch } = props; const gitCwd = thread.worktreePath ?? props.projectCwd; const gitStatus = useEnvironmentQuery( (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null @@ -1603,9 +1654,7 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { }); const modelInstanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId; const providerEntry = props.providerEntryByInstanceId.get(modelInstanceId) ?? null; - const showInstanceBadge = - providerEntry !== null && - shouldShowInstanceBadge(providerEntry, props.providerEntryByInstanceId.values()); + const driverKind = providerEntry?.driverKind ?? null; const selectedModel = providerEntry?.models.find( (model) => model.slug === thread.modelSelection.model, ); @@ -1626,8 +1675,6 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { id={props.resultId} type="button" role="option" - // aria-activedescendant options: focus stays on the search input, - // which owns all keyboard interaction for the listbox. tabIndex={-1} aria-selected={props.isHighlighted} aria-current={props.isRouteActive ? "page" : undefined} @@ -1637,7 +1684,8 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { onMouseMove={props.onHighlight} onClick={props.onSelect} className={cn( - "flex h-9 w-full cursor-pointer items-center gap-2.5 rounded-md px-2.5 text-left text-sm outline-none", + "flex w-full cursor-pointer items-center gap-2.5 rounded-md px-2.5 text-left text-sm outline-none", + contentMatch ? "min-h-9 py-1.5" : "h-9", props.isHighlighted || props.isRouteActive ? "bg-sidebar-row-active text-sidebar-foreground" : "text-sidebar-muted-foreground/75 hover:bg-sidebar-row-hover hover:text-sidebar-foreground", @@ -1652,10 +1700,28 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { className="size-4 shrink-0" fallbackIcon={MessageSquareIcon} /> - {thread.title} - - {threadTimeLabel(thread)} - +
    +
    + {thread.title} + + {threadTimeLabel(thread)} + +
    + {contentMatch ? ( + + + {contentMatch.source === "user" ? "You:" : "Agent:"} + {" "} + + + ) : null} +
    ({ onCopy: ({ path }) => { toastManager.add({ @@ -1889,7 +1960,26 @@ export default function Sidebar() { // fresh clock whenever it recomputes. const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0); - const changeRequestSnapshotByKey = useAtomValue(threadChangeRequestSnapshotsAtom); + // PR states stream in per-row. The next partition applies the configured + // merge rule and the always-on close rule. + const [changeRequestStateByKey, setChangeRequestStateByKey] = useState< + ReadonlyMap + >(() => new Map()); + const handleChangeRequestState = useCallback( + (threadKey: string, state: "open" | "closed" | "merged" | null) => { + setChangeRequestStateByKey((current) => { + if ((current.get(threadKey) ?? null) === state) return current; + const next = new Map(current); + if (state === null) { + next.delete(threadKey); + } else { + next.set(threadKey, state); + } + return next; + }); + }, + [], + ); // Project scope: one menu above the list. Scoping filters the list without // making the header width depend on the number or length of project names. @@ -2005,11 +2095,7 @@ export default function Sidebar() { const supportsSnooze = serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true; const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); - const snapshot = changeRequestSnapshotByKey.get(threadKey); - const changeRequestState = - snapshot != null && (thread.worktreePath === null || snapshot.branch === thread.branch) - ? snapshot.pr.state - : null; + const changeRequestState = changeRequestStateByKey.get(threadKey) ?? null; // Snooze outranks everything, including a pin: "hide until Tuesday" // temporarily suspends "keep on top". The pin survives underneath โ€” // and so does its pinOrderKey, so on wake the thread reappears at @@ -2067,7 +2153,7 @@ export default function Sidebar() { }, [ autoSettleAfterDays, autoSettleOnMerge, - changeRequestSnapshotByKey, + changeRequestStateByKey, nowMinute, scopedProjectKeys, serverConfigs, @@ -2075,20 +2161,30 @@ export default function Sidebar() { threads, ]); + const environmentIds = useMemo( + () => + environments + .filter((environment) => environment.connection.phase === "connected") + .map((environment) => environment.environmentId), + [environments], + ); + const threadSearchInputRef = useRef(null); const [threadSearchQuery, setThreadSearchQuery] = useState(""); const [activeSearchResultIndex, setActiveSearchResultIndex] = useState(0); const isSearchingThreads = threadSearchQuery.trim().length > 0; + const threadSearch = useThreadSearch(environmentIds, threadSearchQuery); const searchableThreads = useMemo( () => [...pinnedThreads, ...activeThreads, ...snoozedThreads, ...settledThreads], [activeThreads, pinnedThreads, settledThreads, snoozedThreads], ); const threadSearchResults = useMemo( - () => searchSidebarThreadsByTitle(searchableThreads, threadSearchQuery), - [searchableThreads, threadSearchQuery], + () => + mergeSidebarThreadSearchResults(searchableThreads, threadSearchQuery, threadSearch.matches), + [searchableThreads, threadSearchQuery, threadSearch.matches], ); const threadSearchResultOrderKey = threadSearchResults - .map((thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))) + .map((result) => scopedThreadKey(scopeThreadRef(result.thread.environmentId, result.thread.id))) .join("\0"); useEffect(() => { @@ -2165,12 +2261,12 @@ export default function Sidebar() { const renderedSettledThreads = useMemo(() => { if (settledShelfExpanded) return visibleSettledThreads; if (routeThreadKey === null) return []; - const routeThread = visibleSettledThreads.find( + const routeThread = settledThreads.find( (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === routeThreadKey, ); return routeThread === undefined ? [] : [routeThread]; - }, [routeThreadKey, settledShelfExpanded, visibleSettledThreads]); + }, [routeThreadKey, settledShelfExpanded, settledThreads, visibleSettledThreads]); // The snoozed shelf is collapsed by default: out of the way, never gone. // Collapsed threads don't render (and so don't participate in jump @@ -2209,6 +2305,41 @@ export default function Sidebar() { ), [orderedThreads], ); + const [activeDragKey, setActiveDragKey] = useState(null); + const activeDragThread = useMemo(() => { + if (activeDragKey === null) return null; + return ( + searchableThreads.find( + (thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === activeDragKey, + ) ?? null + ); + }, [activeDragKey, searchableThreads]); + const activeDragSection = useMemo<"pinned" | "active" | "snoozed" | "settled">(() => { + if (!activeDragThread) return "active"; + if ( + pinnedThreads.some( + (t) => t.id === activeDragThread.id && t.environmentId === activeDragThread.environmentId, + ) + ) { + return "pinned"; + } + if ( + snoozedThreads.some( + (t) => t.id === activeDragThread.id && t.environmentId === activeDragThread.environmentId, + ) + ) { + return "snoozed"; + } + if ( + settledThreads.some( + (t) => t.id === activeDragThread.id && t.environmentId === activeDragThread.environmentId, + ) + ) { + return "settled"; + } + return "active"; + }, [activeDragThread, pinnedThreads, snoozedThreads, settledThreads]); // Rows call back into the click handler without carrying the ordered list as // a prop โ€” a fresh array identity per shell update would defeat every row's // memoization. The ref keeps shift-range-select working against the list as @@ -2225,9 +2356,7 @@ export default function Sidebar() { ), [orderedThreads], ); - // Handlers read these through refs: depending on per-update Map/Set - // identities would give every row a fresh callback prop on each shell - // event and defeat row memoization during streaming. + const listContainerRef = useRef(null); const threadByKeyRef = useRef(threadByKey); threadByKeyRef.current = threadByKey; // handleNewThread is inherently unstable (depends on the projects list); @@ -2342,7 +2471,7 @@ export default function Sidebar() { if (event.key === "Enter") { event.preventDefault(); const result = threadSearchResults[activeSearchResultIndex]; - if (result) selectThreadSearchResult(result); + if (result) selectThreadSearchResult(result.thread); } }, [ @@ -2448,16 +2577,21 @@ export default function Sidebar() { ); const attemptSettle = useCallback( - (threadRef: ScopedThreadRef, opts: { coSettlingKeys?: ReadonlySet } = {}) => { + ( + threadRef: ScopedThreadRef, + opts: { coSettlingKeys?: ReadonlySet; force?: boolean } = {}, + ) => { void (async () => { const threadKey = scopedThreadKey(threadRef); if (settlingThreadKeysRef.current.has(threadKey)) return; settlingThreadKeysRef.current.add(threadKey); try { const navigateAfterSettle = planForwardNavigation(threadKey, opts.coSettlingKeys); - const result = await settleThread(threadRef); + const result = await settleThread( + threadRef, + opts.force !== undefined ? { force: opts.force } : undefined, + ); if (result._tag === "Failure") { - // Never navigate away from a thread that did not settle. if (!isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); toastManager.add( @@ -2470,8 +2604,6 @@ export default function Sidebar() { } return; } - // Only move forward if the user is still on the settled thread โ€” - // a navigation made during the await wins over ours. if (routeThreadKeyRef.current === threadKey) { navigateAfterSettle?.(); } @@ -2534,6 +2666,33 @@ export default function Sidebar() { const pinnedDndSensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), ); + const collisionDetectionStrategy: CollisionDetection = useCallback((args) => { + const pointerCollisions = pointerWithin(args); + if (pointerCollisions.length > 0) { + return pointerCollisions; + } + const intersections = rectIntersection(args); + if (intersections.length > 0) { + return intersections; + } + if (args.pointerCoordinates) { + const settledHeader = args.droppableContainers.find( + (c) => + c.id === "settled-shelf-header" || + c.id === "settled-drop-zone" || + c.id === "settled-bottom-drop-zone", + ); + if (settledHeader && settledHeader.rect.current) { + if (args.pointerCoordinates.y >= settledHeader.rect.current.top) { + const target = + args.droppableContainers.find((c) => c.id === "settled-bottom-drop-zone") ?? + settledHeader; + return [{ id: target.id }]; + } + } + } + return []; + }, []); const [optimisticPinnedOrder, setOptimisticPinnedOrder] = useState<{ readonly order: readonly string[]; /** pinOrderKey per thread as of the drop โ€” the baseline that tells a @@ -2551,6 +2710,13 @@ export default function Sidebar() { getId: (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), }); }, [optimisticPinnedOrder, pinnedThreads]); + const orderedPinnedKeys = useMemo( + () => + orderedPinnedThreads.map((thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + ), + [orderedPinnedThreads], + ); useEffect(() => { if (optimisticPinnedOrder === null) return; const canonical = pinnedThreads.filter((thread) => @@ -2629,80 +2795,13 @@ export default function Sidebar() { }, [unpinThread], ); - - const handlePinnedDragEnd = useCallback( - (event: DragEndEvent) => { - const activeKey = String(event.active.id); - const overKey = event.over === null ? null : String(event.over.id); - if (overKey === null || activeKey === overKey) return; - const reorderable = orderedPinnedThreads.filter((thread) => - reorderablePinnedKeys.has(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))), - ); - const keys = reorderable.map((thread) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - ); - const fromIndex = keys.indexOf(activeKey); - const toIndex = keys.indexOf(overKey); - if (fromIndex === -1 || toIndex === -1) return; - const newOrder = arrayMove([...keys], fromIndex, toIndex); - const threadByKey = new Map(reorderable.map((thread, index) => [keys[index]!, thread])); - const keysAtDrop = new Map( - reorderable.map((thread, index) => [keys[index]!, thread.pinOrderKey ?? null]), - ); - const assignments = planPinnedReorder({ - orderedIds: newOrder, - keysById: keysAtDrop, - movedId: activeKey, - }); - if (assignments.length === 0) return; - setOptimisticPinnedOrder({ - order: newOrder, - keysAtDrop, - assignedKeys: new Map( - assignments.map((assignment) => [assignment.id, assignment.orderKey]), - ), - }); - void (async () => { - // Sequential, stop on first failure. There is deliberately no - // rollback: every key write is a complete, valid placement on its - // own, so a partial materialization leaves a sensible order (and - // the next drag repairs the rest) โ€” unwinding writes across - // servers would trade that for real inconsistency windows. - for (const assignment of assignments) { - const thread = threadByKey.get(assignment.id); - if (thread === undefined) continue; - const result = await reorderPinnedThread( - scopeThreadRef(thread.environmentId, thread.id), - assignment.orderKey, - ); - if (result._tag === "Failure") { - // Any failure โ€” interrupted included โ€” releases the override: - // a key that never lands would otherwise hold it until some - // unrelated world change came along. - setOptimisticPinnedOrder(null); - if (isAtomCommandInterrupted(result)) return; - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to reorder pinned threads", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - return; - } - } - })(); - }, - [orderedPinnedThreads, reorderPinnedThread, reorderablePinnedKeys], - ); // One snooze per thread at a time โ€” same double-dispatch guard as settle. const snoozingThreadKeysRef = useRef(new Set()); const performSnooze = useCallback( async ( threadRef: ScopedThreadRef, preset: SnoozePreset, - opts: { coSnoozingKeys?: ReadonlySet } = {}, + opts: { coSnoozingKeys?: ReadonlySet; force?: boolean } = {}, ) => { const threadKey = scopedThreadKey(threadRef); if (snoozingThreadKeysRef.current.has(threadKey)) { @@ -2710,18 +2809,17 @@ export default function Sidebar() { } snoozingThreadKeysRef.current.add(threadKey); try { - // Snoozing the open thread moves you forward, same as settle โ€” - // both park the thread you're done with for now. const navigateAfterSnooze = planForwardNavigation(threadKey, opts.coSnoozingKeys); - const result = await snoozeThread(threadRef, preset.snoozedUntil); + const result = await snoozeThread( + threadRef, + preset.snoozedUntil, + opts.force !== undefined ? { force: opts.force } : undefined, + ); if (result._tag === "Failure") { - // Never navigate away from a thread that did not snooze. return isAtomCommandInterrupted(result) ? ({ status: "interrupted" } as const) : ({ status: "failure", error: squashAtomCommandFailure(result) } as const); } - // Only move forward if the user is still on the snoozed thread โ€” - // a navigation made during the await wins over ours. if (routeThreadKeyRef.current === threadKey) { navigateAfterSnooze?.(); } @@ -2736,7 +2834,7 @@ export default function Sidebar() { ( threadRef: ScopedThreadRef, preset: SnoozePreset, - opts: { coSnoozingKeys?: ReadonlySet } = {}, + opts: { coSnoozingKeys?: ReadonlySet; force?: boolean } = {}, ) => { void (async () => { const outcome = await performSnooze(threadRef, preset, opts); @@ -2770,6 +2868,342 @@ export default function Sidebar() { [attemptUnsnooze, performSnooze, timestampFormat], ); + const handleThreadDragEnd = useCallback( + (event: DragEndEvent) => { + const activeKey = String(event.active.id); + const overKey = event.over === null ? null : String(event.over.id); + if (overKey === null || activeKey === overKey) return; + + const activeThread = searchableThreads.find( + (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === activeKey, + ); + if (activeThread === undefined) return; + const activeThreadRef = scopeThreadRef(activeThread.environmentId, activeThread.id); + + const activeSection: "pinned" | "active" | "snoozed" | "settled" = pinnedThreads.some( + (thread) => + thread.id === activeThread.id && thread.environmentId === activeThread.environmentId, + ) + ? "pinned" + : snoozedThreads.some( + (thread) => + thread.id === activeThread.id && + thread.environmentId === activeThread.environmentId, + ) + ? "snoozed" + : settledThreads.some( + (thread) => + thread.id === activeThread.id && + thread.environmentId === activeThread.environmentId, + ) + ? "settled" + : "active"; + + const overThread = searchableThreads.find( + (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === overKey, + ); + + let overSection: "pinned" | "active" | "snoozed" | "settled" | null = null; + if (overKey === "snoozed-shelf-header" || overKey === "snoozed-drop-zone") { + overSection = "snoozed"; + } else if ( + overKey === "settled-shelf-header" || + overKey === "settled-drop-zone" || + overKey === "settled-bottom-drop-zone" + ) { + overSection = "settled"; + } else if (overKey === "active-drop-zone") { + overSection = "active"; + } else if (overThread !== undefined) { + overSection = pinnedThreads.some( + (thread) => + thread.id === overThread.id && thread.environmentId === overThread.environmentId, + ) + ? "pinned" + : snoozedThreads.some( + (thread) => + thread.id === overThread.id && thread.environmentId === overThread.environmentId, + ) + ? "snoozed" + : settledThreads.some( + (thread) => + thread.id === overThread.id && + thread.environmentId === overThread.environmentId, + ) + ? "settled" + : "active"; + } + + if (overSection === null) return; + + if (activeSection === "pinned" && overSection === "pinned") { + const reorderable = orderedPinnedThreads.filter((thread) => + reorderablePinnedKeys.has( + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + ), + ); + const keys = reorderable.map((thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + ); + const fromIndex = keys.indexOf(activeKey); + const toIndex = keys.indexOf(overKey); + if (fromIndex === -1 || toIndex === -1) return; + const newOrder = arrayMove([...keys], fromIndex, toIndex); + const threadByKey = new Map(reorderable.map((thread, index) => [keys[index]!, thread])); + const keysAtDrop = new Map( + reorderable.map((thread, index) => [keys[index]!, thread.pinOrderKey ?? null]), + ); + const assignments = planPinnedReorder({ + orderedIds: newOrder, + keysById: keysAtDrop, + movedId: activeKey, + }); + if (assignments.length === 0) return; + setOptimisticPinnedOrder({ + order: newOrder, + keysAtDrop, + assignedKeys: new Map( + assignments.map((assignment) => [assignment.id, assignment.orderKey]), + ), + }); + void (async () => { + for (const assignment of assignments) { + const thread = threadByKey.get(assignment.id); + if (thread === undefined) continue; + const result = await reorderPinnedThread( + scopeThreadRef(thread.environmentId, thread.id), + assignment.orderKey, + ); + if (result._tag === "Failure") { + setOptimisticPinnedOrder(null); + if (isAtomCommandInterrupted(result)) return; + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to reorder pinned threads", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + return; + } + } + })(); + return; + } + + if (overSection === "settled") { + if (activeSection !== "settled") { + void (async () => { + if (activeSection === "snoozed") { + const unsnoozeResult = await unsnoozeThread(activeThreadRef); + if (unsnoozeResult._tag === "Failure" && !isAtomCommandInterrupted(unsnoozeResult)) { + const error = squashAtomCommandFailure(unsnoozeResult); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to unsnooze thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + return; + } + } + const shell = readThreadShell(activeThreadRef) ?? activeThread; + const isWorking = + shell.session?.status === "running" || + shell.session?.status === "starting" || + shell.hasPendingApprovals || + shell.hasPendingUserInput || + hasQueuedTurnStart(shell, { now: new Date().toISOString() }); + if (isWorking) { + const runningTurnId = + shell.session?.status === "running" + ? (shell.session.activeTurnId ?? undefined) + : undefined; + await interruptThreadTurn({ + environmentId: activeThreadRef.environmentId, + input: { + threadId: activeThreadRef.threadId, + ...(runningTurnId ? { turnId: runningTurnId } : {}), + }, + }); + let stopped = await waitForSessionStopped(activeThreadRef, 1500); + if (!stopped) { + await stopThreadSession({ + environmentId: activeThreadRef.environmentId, + input: { threadId: activeThreadRef.threadId }, + }); + stopped = await waitForSessionStopped(activeThreadRef, 2500); + } + if (!stopped) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to settle thread", + description: "Active session is still stopping. Please try again in a moment.", + }), + ); + return; + } + } + attemptSettle(activeThreadRef, { force: true }); + })(); + } + return; + } + + if (overSection === "snoozed") { + if (activeSection !== "snoozed") { + const presets = resolveSnoozePresets(new Date(), timestampFormat); + const defaultPreset = presets.find((p) => p.id === "tomorrow") ?? presets[0]; + if (defaultPreset) { + void (async () => { + const shell = readThreadShell(activeThreadRef) ?? activeThread; + const isWorking = + shell.session?.status === "running" || + shell.session?.status === "starting" || + shell.hasPendingApprovals || + shell.hasPendingUserInput || + hasQueuedTurnStart(shell, { now: new Date().toISOString() }); + if (isWorking) { + const runningTurnId = + shell.session?.status === "running" + ? (shell.session.activeTurnId ?? undefined) + : undefined; + await interruptThreadTurn({ + environmentId: activeThreadRef.environmentId, + input: { + threadId: activeThreadRef.threadId, + ...(runningTurnId ? { turnId: runningTurnId } : {}), + }, + }); + let stopped = await waitForSessionStopped(activeThreadRef, 1500); + if (!stopped) { + await stopThreadSession({ + environmentId: activeThreadRef.environmentId, + input: { threadId: activeThreadRef.threadId }, + }); + stopped = await waitForSessionStopped(activeThreadRef, 2500); + } + if (!stopped) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to snooze thread", + description: + "Active session is still stopping. Please try again in a moment.", + }), + ); + return; + } + } + attemptSnooze(activeThreadRef, defaultPreset, { force: true }); + })(); + } + } + return; + } + + if ( + overSection === "active" && + (activeSection === "settled" || activeSection === "snoozed") + ) { + if (activeSection === "settled") { + attemptUnsettle(activeThreadRef); + } else { + attemptUnsnooze(activeThreadRef); + } + return; + } + + if (activeSection === "pinned" && overSection === "active") { + attemptUnpin(activeThreadRef); + return; + } + + if (overSection === "pinned" && activeSection !== "pinned") { + const reorderable = orderedPinnedThreads.filter((thread) => + reorderablePinnedKeys.has( + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + ), + ); + const keys = reorderable.map((thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + ); + const overIndex = keys.indexOf(overKey); + let orderKey: string | undefined; + if (overIndex !== -1) { + const prevKey = overIndex > 0 ? (reorderable[overIndex - 1]?.pinOrderKey ?? null) : null; + const targetKey = reorderable[overIndex]?.pinOrderKey ?? null; + orderKey = pinOrderKeyBetween(prevKey, targetKey) ?? undefined; + } + void (async () => { + if (activeSection === "snoozed") { + const unsnoozeResult = await unsnoozeThread(activeThreadRef); + if (unsnoozeResult._tag === "Failure" && !isAtomCommandInterrupted(unsnoozeResult)) { + const error = squashAtomCommandFailure(unsnoozeResult); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to unsnooze thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + return; + } + } else if (activeSection === "settled") { + const unsettleResult = await unsettleThread(activeThreadRef); + if (unsettleResult._tag === "Failure" && !isAtomCommandInterrupted(unsettleResult)) { + const error = squashAtomCommandFailure(unsettleResult); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to unsettle thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + return; + } + } + const result = await pinThread( + activeThreadRef, + orderKey !== undefined ? { orderKey } : {}, + ); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to pin thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + })(); + return; + } + }, + [ + attemptSettle, + attemptSnooze, + attemptUnpin, + attemptUnsettle, + attemptUnsnooze, + interruptThreadTurn, + stopThreadSession, + orderedPinnedThreads, + pinThread, + pinnedThreads, + reorderPinnedThread, + reorderablePinnedKeys, + searchableThreads, + settledThreads, + snoozedThreads, + timestampFormat, + ], + ); + const removeFromSelection = useThreadSelectionStore((s) => s.removeFromSelection); const handleMultiSelectContextMenu = useCallback( async (position: { x: number; y: number }) => { @@ -3338,6 +3772,80 @@ export default function Sidebar() { shortcutLabelForCommand(keybindings, "chat.new") ?? (projectGroups.length <= 1 ? shortcutLabelForCommand(keybindings, "chat.newLocal") : undefined); const newThreadInProjectShortcutLabel = shortcutLabelForCommand(keybindings, "chat.newLocal"); + + const renderThreadRow = ( + thread: EnvironmentThreadShell, + section: "pinned" | "active" | "snoozed" | "settled", + sortable?: SortablePinnedRowBag, + isOverlay?: boolean, + ) => { + const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + const isCard = section === "active" || section === "pinned"; + const rowVariant = isCard ? "card" : "slim"; + return ( + + ); + }; + return ( <> @@ -3379,19 +3887,24 @@ export default function Sidebar() { className="min-w-0 flex-1 [&_[data-slot=input]]:h-auto [&_[data-slot=input]]:p-0 [&_[data-slot=input]]:leading-normal [&_[data-slot=input]]:text-sm [&_[data-slot=input]]:font-medium [&_[data-slot=input]]:text-sidebar-foreground [&_[data-slot=input]]:placeholder:text-sidebar-muted-foreground" /> {isSearchingThreads ? ( - + <> + {threadSearch.isPending ? ( + + ) : null} + + ) : null}
    @@ -3554,7 +4067,8 @@ export default function Sidebar() { aria-label="Thread search results" className="flex flex-col gap-px" > - {threadSearchResults.map((thread, index) => { + {threadSearchResults.map((result, index) => { + const { thread, contentMatch } = result; const threadKey = scopedThreadKey( scopeThreadRef(thread.environmentId, thread.id), ); @@ -3562,6 +4076,7 @@ export default function Sidebar() { -
      - {(() => { - const renderThreadRow = ( - thread: EnvironmentThreadShell, - section: "pinned" | "active" | "snoozed" | "settled", - sortable?: SortablePinnedRowBag, - ) => { - const threadKey = scopedThreadKey( - scopeThreadRef(thread.environmentId, thread.id), - ); - // Settled and snoozed are the ONLY things that collapse a - // row: every other thread is a full card. Density comes - // from users (or the auto rules) actually parking work, - // not from the sidebar second-guessing what still matters. - const isCard = section === "active" || section === "pinned"; - const rowVariant = isCard ? "card" : "slim"; - return ( - - ); - }; - // Draft block above everything, then the pinned block: - // full cards above the inbox, closed by a thin divider (the - // pin glyphs carry the meaning, so no header text). Both - // vanish entirely at count 0. - // Pinned rows render in the one shared pinned order; only - // reorder-capable rows register as sortable (legacy-server - // pins render in place as plain rows). - const items: ReactNode[] = [ - , - - - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - ) - .filter((threadKey) => reorderablePinnedKeys.has(threadKey))} - strategy={verticalListSortingStrategy} - > - {orderedPinnedThreads.map((thread) => { - const threadKey = scopedThreadKey( - scopeThreadRef(thread.environmentId, thread.id), - ); - if (!reorderablePinnedKeys.has(threadKey)) { - return renderThreadRow(thread, "pinned"); - } - return ( - - {(bag) => renderThreadRow(thread, "pinned", bag)} - - ); - })} - - , - ]; - if (pinnedThreads.length > 0) { - items.push( -
    • { + setActiveDragKey(String(event.active.id)); + }} + onDragEnd={(event: DragEndEvent) => { + setActiveDragKey(null); + handleThreadDragEnd(event); + }} + onDragCancel={() => { + setActiveDragKey(null); + }} + > +
        { + listContainerRef.current = el; + attachListAutoAnimateRef(el); + }} + role="list" + className="flex flex-col gap-px" + > + {(() => { + const items: ReactNode[] = [ + , - ); - } - for (const thread of activeThreads) { - items.push(renderThreadRow(thread, "active")); - } - // Snoozed shelf: between the inbox and Settled โ€” out of the - // way, never gone. The header always renders while anything - // is snoozed (the count is the whole footprint when - // collapsed); rows only when expanded. Vanishes entirely at - // count 0. - if (snoozedThreads.length > 0) { - items.push( -
      • - -
      • , - ); - for (const thread of visibleSnoozedThreads) { - items.push(renderThreadRow(thread, "snoozed")); + {orderedPinnedThreads.map((thread) => { + const threadKey = scopedThreadKey( + scopeThreadRef(thread.environmentId, thread.id), + ); + return ( + + {(bag) => renderThreadRow(thread, "pinned", bag)} + + ); + })} + , + ); } - } - if (settledThreads.length > 0) { - items.push( -
      • 0 && activeThreads.length > 0) { + items.push( +
      • , + ); + } + if (activeThreads.length === 0 && activeDragKey !== null) { + items.push( + , + ); + } + for (const thread of activeThreads) { + const threadKey = scopedThreadKey( + scopeThreadRef(thread.environmentId, thread.id), + ); + items.push( + + {(bag) => renderThreadRow(thread, "active", bag)} + , + ); + } + if (snoozedThreads.length > 0 || activeDragKey !== null) { + items.push( + + + , + ); + for (const thread of visibleSnoozedThreads) { + const threadKey = scopedThreadKey( + scopeThreadRef(thread.environmentId, thread.id), + ); + items.push( + + {(bag) => renderThreadRow(thread, "snoozed", bag)} + , + ); + } + if (activeDragKey !== null && visibleSnoozedThreads.length === 0) { + items.push( + + Move to snoozed + , + ); + } + } + if (settledThreads.length > 0 || activeDragKey !== null) { + items.push( + + + , + ); + for (const thread of renderedSettledThreads) { + const threadKey = scopedThreadKey( + scopeThreadRef(thread.environmentId, thread.id), + ); + items.push( + + {(bag) => renderThreadRow(thread, "settled", bag)} + , + ); + } + if (activeDragKey !== null) { + items.push( + + Move to settled + , + ); + } + } + return items; + })()} + {settledShelfExpanded && hiddenSettledCount > 0 ? ( +
      • + -
      • , - ); - } - for (const thread of renderedSettledThreads) { - items.push(renderThreadRow(thread, "settled")); - } - return items; - })()} - {settledShelfExpanded && hiddenSettledCount > 0 ? ( -
      • - -
      • - ) : null} -
      + + Show {Math.min(hiddenSettledCount, SETTLED_TAIL_PAGE_COUNT)} more + +
    • + ) : null} +
    + {typeof document !== "undefined" + ? createPortal( + + {activeDragThread ? ( +
    + {renderThreadRow(activeDragThread, activeDragSection, undefined, true)} +
    + ) : null} +
    , + document.body, + ) + : null} + ) : null} {!isSearchingThreads && diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index 08e0422dd255..b71b59c74afa 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -42,6 +42,8 @@ import { WorkspaceBreadcrumbItem, WorkspaceBreadcrumbSeparator, } from "../WorkspaceBreadcrumb"; +import { useTabsEnabled } from "~/hooks/useSettings"; +import { WorkspaceTabs } from "./WorkspaceTabs"; import { cn } from "~/lib/utils"; interface ChatHeaderProps { @@ -131,6 +133,7 @@ export const ChatHeader = memo(function ChatHeader({ onUpdateProjectScript, onDeleteProjectScript, }: ChatHeaderProps) { + const tabsEnabled = useTabsEnabled(); const primaryEnvironmentId = usePrimaryEnvironmentId(); const fileScripts = useT3ProjectFileScripts( activeThreadEnvironmentId, @@ -229,88 +232,112 @@ export const ChatHeader = memo(function ChatHeader({ className="@container/header-actions flex min-w-0 flex-1 items-center gap-2 sm:gap-3" onContextMenu={handleHeaderContextMenu} > - - {/* The project always leads the header: knowing which project a - thread lives in is priority zero, and the thread title alone - doesn't answer it. */} - {activeProjectName ? ( - <> - + {tabsEnabled ? ( + { + renameCommittedRef.current = true; + setRenaming(null); + }} + onRenameKeyDown={handleRenameKeyDown} + onOpenThreadMenu={(rect) => { + if (rect) { + openMenu({ x: rect.left, y: rect.bottom + 4 }); + } else { + openMenuFromTitle(); + } + }} + /> + ) : ( + + {activeProjectName ? ( + <> + + + + } + > + + {activeProjectName} + + New thread in {activeProjectName} + + + + + ) : null} + + {renamingTitle !== null ? ( + { + if (renameCommittedRef.current) return; + commitRename(event.currentTarget.value); + }} + onFocus={(event) => event.currentTarget.select()} + onKeyDown={handleRenameKeyDown} + /> + ) : isServerThread ? ( } > - {activeThreadTitle} + - {activeProjectName} - New thread in {activeProjectName} + {activeThreadTitle} - - - - ) : null} - - {renamingTitle !== null ? ( - { - if (renameCommittedRef.current) return; - commitRename(event.currentTarget.value); - }} - onFocus={(event) => event.currentTarget.select()} - onKeyDown={handleRenameKeyDown} - /> - ) : isServerThread ? ( - - - } - > -

    {activeThreadTitle}

    - + + {activeThreadTitle} + + } /> - - {activeThreadTitle} -
    - ) : ( - - - {activeThreadTitle} - - } - /> - {activeThreadTitle} - - )} -
    -
    + {activeThreadTitle} + + )} +
    +
    + )}
    void; @@ -75,6 +76,14 @@ export const ModelListRow = memo(function ModelListRow(props: { New ) : null} + {props.isHandoff ? ( + + Handoff + + ) : null}
    {props.showProvider && (
    diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index 7ffb2bf077da..f96784b8f941 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -73,30 +73,23 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { * between the default Codex and a custom Codex Personal). */ lockedProvider: ProviderDriverKind | null; - lockedContinuationGroupKey?: string | null; - /** - * All configured provider instances in display order. Used to render - * the sidebar (one button per instance) and to resolve display names - * for the locked-mode header. - */ + lockedContinuationGroupKey?: string | null | undefined; instanceEntries: ReadonlyArray; - keybindings?: ResolvedKeybindingsConfig; - /** - * Model options per instance. Keyed by `ProviderInstanceId` so the - * default Codex instance and any custom Codex instances each have their - * own list (custom instances typically start with the same built-in - * model set but are free to diverge via customModels). - */ + keybindings?: ResolvedKeybindingsConfig | undefined; modelOptionsByInstance: ReadonlyMap>; terminalOpen: boolean; - onRequestClose?: () => void; - getModelDisabledReason?: (instanceId: ProviderInstanceId, model: string) => string | null; + onRequestClose?: (() => void) | undefined; + allowHandoff?: boolean | undefined; + getModelDisabledReason?: + | ((instanceId: ProviderInstanceId, model: string) => string | null) + | undefined; onInstanceModelChange: (instanceId: ProviderInstanceId, model: string) => void; }) { const { keybindings: providedKeybindings, modelOptionsByInstance, instanceEntries, + allowHandoff = true, getModelDisabledReason, onInstanceModelChange, } = props; @@ -235,7 +228,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { return out; }, [modelOptionsByInstance, entryByInstanceId, readyInstanceSet]); - const isLocked = props.lockedProvider !== null; + const isLocked = props.lockedProvider !== null && !allowHandoff; const isSearching = searchQuery.trim().length > 0; const lockedDisabledInstanceIds = useMemo(() => { if (!isLocked) { @@ -314,7 +307,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { // When searching, we only respect locked provider (by driver kind), // ignoring sidebar selection so account-scoped searches can find a // model before the user chooses a specific instance rail item. - if (props.lockedProvider !== null) { + if (!allowHandoff && props.lockedProvider !== null) { const lockedProviderMatches: Array<(typeof rankedMatches)[number]> = []; for (const rankedModel of rankedMatches) { if (matchesLockedProvider(rankedModel.model)) { @@ -349,7 +342,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { .map((rankedModel) => rankedModel.model); } - if (props.lockedProvider !== null) { + if (!allowHandoff && props.lockedProvider !== null) { result = result.filter((m) => matchesLockedProvider(m)); if (selectedInstanceId === "favorites") { result = result.filter((m) => favoritesSet.has(providerModelKey(m.instanceId, m.slug))); @@ -747,8 +740,11 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { if (!model) { return null; } - const disabledReason = - getModelDisabledReason?.(model.instanceId, model.slug) ?? null; + const isCrossProvider = + props.lockedProvider !== null && !matchesLockedProvider(model); + const disabledReason = isCrossProvider + ? null + : (getModelDisabledReason?.(model.instanceId, model.slug) ?? null); return ( toggleFavorite(model.instanceId, model.slug)} diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index bd374a0fd6f5..8e311969a75a 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -41,6 +41,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { triggerClassName?: string; triggerAriaLabel?: string; onOpenChange?: (open: boolean) => void; + allowHandoff?: boolean; getModelDisabledReason?: (instanceId: ProviderInstanceId, model: string) => string | null; onInstanceModelChange: (instanceId: ProviderInstanceId, model: string) => void; }) { @@ -194,6 +195,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { model={props.model} lockedProvider={props.lockedProvider} lockedContinuationGroupKey={props.lockedContinuationGroupKey ?? null} + allowHandoff={props.allowHandoff} instanceEntries={props.instanceEntries} {...(props.keybindings ? { keybindings: props.keybindings } : {})} modelOptionsByInstance={props.modelOptionsByInstance} diff --git a/apps/web/src/components/chat/ThreadHandoffDialog.test.tsx b/apps/web/src/components/chat/ThreadHandoffDialog.test.tsx new file mode 100644 index 000000000000..fcb7da9b733b --- /dev/null +++ b/apps/web/src/components/chat/ThreadHandoffDialog.test.tsx @@ -0,0 +1,117 @@ +import { + IsoDateTime, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vite-plus/test"; +import { Dialog } from "../ui/dialog"; +import { ThreadHandoffContent } from "./ThreadHandoffDialog"; +import type { ProviderInstanceEntry } from "../../providerInstances"; + +describe("ThreadHandoffContent", () => { + const dummyInstanceEntries: ReadonlyArray = [ + { + instanceId: ProviderInstanceId.make("claudeAgent"), + displayName: "Claude Code", + driverKind: ProviderDriverKind.make("claudeAgent"), + enabled: true, + installed: true, + status: "ready", + isAvailable: true, + isDefault: true, + models: [], + snapshot: { + instanceId: ProviderInstanceId.make("claudeAgent"), + driver: ProviderDriverKind.make("claudeAgent"), + displayName: "Claude Code", + enabled: true, + installed: true, + status: "ready", + isDefault: true, + models: [], + } as any, + }, + { + instanceId: ProviderInstanceId.make("codex"), + displayName: "Codex", + driverKind: ProviderDriverKind.make("codex"), + enabled: true, + installed: true, + status: "ready", + isAvailable: true, + isDefault: false, + models: [], + snapshot: { + instanceId: ProviderInstanceId.make("codex"), + driver: ProviderDriverKind.make("codex"), + displayName: "Codex", + enabled: true, + installed: true, + status: "ready", + isDefault: false, + models: [], + } as any, + }, + ]; + + it("renders handoff content with source and target model indicators and markdown preview", () => { + const markup = renderToStaticMarkup( + + {}} + /> + , + ); + + expect(markup).toContain("Continue in new thread"); + expect(markup).toContain("claude-3-7-sonnet"); + expect(markup).toContain("gpt-5.3-codex"); + expect(markup).toContain("Handoff context (editable)"); + expect(markup).toContain("handoff-context-textarea"); + expect(markup).toContain("Continue with gpt-5.3-codex"); + expect(markup).toContain("Add index to user_id column"); + }); +}); diff --git a/apps/web/src/components/chat/ThreadHandoffDialog.tsx b/apps/web/src/components/chat/ThreadHandoffDialog.tsx new file mode 100644 index 000000000000..795a82913cab --- /dev/null +++ b/apps/web/src/components/chat/ThreadHandoffDialog.tsx @@ -0,0 +1,231 @@ +import { type ModelSelection, type OrchestrationThread } from "@t3tools/contracts"; +import { buildThreadHandoffMarkdown } from "@t3tools/client-runtime/handoff"; +import { ArrowRightIcon } from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Button } from "../ui/button"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "../ui/dialog"; +import { Textarea } from "../ui/textarea"; +import { Spinner } from "../ui/spinner"; +import { ProviderInstanceIcon } from "./ProviderInstanceIcon"; +import type { ProviderInstanceEntry } from "../../providerInstances"; + +export interface ThreadHandoffContentProps { + readonly sourceThread: OrchestrationThread; + readonly targetModelSelection: ModelSelection; + readonly providerInstanceEntries?: ReadonlyArray | undefined; + readonly isSubmitting?: boolean | undefined; + readonly onCancel?: (() => void) | undefined; + readonly onConfirmHandoff: ( + handoffMarkdown: string, + targetModelSelection: ModelSelection, + ) => Promise | void; +} + +export function ThreadHandoffContent({ + sourceThread, + targetModelSelection, + providerInstanceEntries, + isSubmitting = false, + onCancel, + onConfirmHandoff, +}: ThreadHandoffContentProps) { + const [handoffText, setHandoffText] = useState(() => + buildThreadHandoffMarkdown({ + thread: sourceThread, + targetModelSelection, + }), + ); + + const isUserDirtyRef = useRef(false); + const targetKeyRef = useRef(`${targetModelSelection.instanceId}:${targetModelSelection.model}`); + + const sourceEntry = useMemo(() => { + const instanceId = sourceThread.modelSelection?.instanceId; + return providerInstanceEntries?.find((entry) => entry.instanceId === instanceId) ?? null; + }, [providerInstanceEntries, sourceThread.modelSelection?.instanceId]); + + const targetEntry = useMemo(() => { + return ( + providerInstanceEntries?.find( + (entry) => entry.instanceId === targetModelSelection.instanceId, + ) ?? null + ); + }, [providerInstanceEntries, targetModelSelection]); + + useEffect(() => { + const currentTargetKey = `${targetModelSelection.instanceId}:${targetModelSelection.model}`; + if (targetKeyRef.current !== currentTargetKey || !isUserDirtyRef.current) { + targetKeyRef.current = currentTargetKey; + const generated = buildThreadHandoffMarkdown({ + thread: sourceThread, + targetModelSelection, + }); + setHandoffText(generated); + } + }, [sourceThread, targetModelSelection]); + + const handleConfirm = useCallback(() => { + return onConfirmHandoff(handoffText, targetModelSelection); + }, [handoffText, onConfirmHandoff, targetModelSelection]); + + const sourceModelLabel = sourceThread.modelSelection?.model ?? "Current model"; + const targetModelLabel = targetModelSelection.model; + + return ( + <> + + Continue in new thread + + Switch to a different model with a structured handoff of your current progress. The + original thread remains untouched. + + + + +
    +
    + {sourceEntry && ( + + )} +
    + From + {sourceModelLabel} +
    +
    + + + +
    + {targetEntry && ( + + )} +
    + To + {targetModelLabel} +
    +
    +
    + +
    + +