diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 688af5f6aba..4c5d179f80c 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/desktop", - "version": "0.1.12", + "version": "0.1.13", "private": true, "type": "module", "main": "dist-electron/main.cjs", diff --git a/apps/server/package.json b/apps/server/package.json index 743e944e7b5..1ba84205b30 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,6 +1,6 @@ { "name": "t3", - "version": "0.1.12", + "version": "0.1.13", "license": "MIT", "repository": { "type": "git", @@ -28,7 +28,7 @@ "@effect/platform-node-shared": "catalog:", "@effect/sql-sqlite-bun": "catalog:", "@ff-labs/fff-node": "0.9.4", - "@opencode-ai/sdk": "^1.3.15", + "@opencode-ai/sdk": "^1.17.8", "@pierre/diffs": "catalog:", "effect": "catalog:", "node-pty": "^1.1.0" diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 3f483d8fd7e..e7d3afdff09 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -53,6 +53,7 @@ type MessageEntry = { const runtimeMock = { state: { startCalls: [] as string[], + connectReuseLocalServer: [] as Array, sessionCreateUrls: [] as string[], authHeaders: [] as Array, abortCalls: [] as string[], @@ -66,6 +67,7 @@ const runtimeMock = { }, reset() { this.state.startCalls.length = 0; + this.state.connectReuseLocalServer.length = 0; this.state.sessionCreateUrls.length = 0; this.state.authHeaders.length = 0; this.state.abortCalls.length = 0; @@ -97,9 +99,11 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { exitCode: Effect.never, }; }), - connectToOpenCodeServer: ({ serverUrl }) => + connectToOpenCodeServer: ({ serverUrl, reuseLocalServer }) => Effect.gen(function* () { - const url = serverUrl ?? "http://127.0.0.1:4301"; + const externalServerUrl = serverUrl?.trim(); + const url = externalServerUrl ? externalServerUrl : "http://127.0.0.1:4301"; + runtimeMock.state.connectReuseLocalServer.push(reuseLocalServer); // Unconditionally register a scope finalizer for test observability — // preserves the `closeCalls` / `closeError` probes that the existing // suites rely on. Production code never attaches a finalizer to an @@ -115,7 +119,7 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { return { url, exitCode: null, - external: Boolean(serverUrl), + external: Boolean(externalServerUrl), }; }), runOpenCodeCommand: () => Effect.succeed({ stdout: "", stderr: "", code: 0 }), @@ -198,6 +202,10 @@ const openCodeAdapterTestSettings = Schema.decodeSync(OpenCodeSettings)({ serverUrl: "http://127.0.0.1:9999", serverPassword: "secret-password", }); +const openCodeAdapterLocalServerTestSettings = Schema.decodeSync(OpenCodeSettings)({ + binaryPath: "fake-opencode", + serverUrl: "", +}); const OpenCodeAdapterTestLayer = Layer.effect( OpenCodeAdapter, @@ -220,6 +228,26 @@ const OpenCodeAdapterTestLayer = Layer.effect( Layer.provideMerge(NodeServices.layer), ); +const OpenCodeAdapterLocalServerTestLayer = Layer.effect( + OpenCodeAdapter, + makeOpenCodeAdapter(openCodeAdapterLocalServerTestSettings), +).pipe( + Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)), + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge( + ServerSettingsService.layerTest({ + providers: { + opencode: { + binaryPath: "fake-opencode", + serverUrl: "", + }, + }, + }), + ), + Layer.provideMerge(providerSessionDirectoryTestLayer), + Layer.provideMerge(NodeServices.layer), +); + beforeEach(() => { runtimeMock.reset(); }); @@ -248,6 +276,21 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }), ); + it.effect("requests an exclusive local server lease for chat sessions", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId: asThreadId("thread-opencode-local-exclusive"), + runtimeMode: "full-access", + }); + + assert.deepEqual(runtimeMock.state.connectReuseLocalServer, [false]); + assert.deepEqual(runtimeMock.state.sessionCreateUrls, ["http://127.0.0.1:4301"]); + }).pipe(Effect.provide(OpenCodeAdapterLocalServerTestLayer)), + ); + it.effect("stops a configured-server session without trying to own server lifecycle", () => Effect.gen(function* () { const adapter = yield* OpenCodeAdapter; diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index ccad1cc25a4..6250301cfed 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -1048,6 +1048,7 @@ export function makeOpenCodeAdapter( binaryPath, serverUrl, ...(options?.environment ? { environment: options.environment } : {}), + reuseLocalServer: false, }); const client = openCodeRuntime.createOpenCodeSdkClient({ baseUrl: server.url, diff --git a/apps/server/src/provider/opencodeRuntime.test.ts b/apps/server/src/provider/opencodeRuntime.test.ts index 6d31d954f1b..1c71219b9da 100644 --- a/apps/server/src/provider/opencodeRuntime.test.ts +++ b/apps/server/src/provider/opencodeRuntime.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from "vitest"; -import { buildOpenCodeServerEnvironment } from "./opencodeRuntime.ts"; +import { + buildOpenCodeServerEnvironment, + isOpenCodeDatabaseLockedError, + OpenCodeRuntimeError, + openCodeLocalServerLockKey, +} from "./opencodeRuntime.ts"; describe("buildOpenCodeServerEnvironment", () => { it("does not inject inline OpenCode config when OPENCODE_CONFIG points at a file", () => { @@ -23,3 +28,61 @@ describe("buildOpenCodeServerEnvironment", () => { expect(env.OPENCODE_CONFIG_CONTENT).toBe("{}"); }); }); + +describe("OpenCode local server locking", () => { + it("recognizes OpenCode database lock startup failures", () => { + const error = new OpenCodeRuntimeError({ + operation: "startOpenCodeServerProcess", + detail: + "OpenCode server exited before startup completed (code: 1).\n\nstderr:\ndatabase is locked", + }); + + expect(isOpenCodeDatabaseLockedError(error)).toBe(true); + }); + + it("keys local server leases by OpenCode data scope", () => { + const first = openCodeLocalServerLockKey({ + binaryPath: "/managed/opencode-a", + environment: { + OPENCODE_CONFIG: "/Users/test/.agents/ucsd/config/opencode/opencode.json", + XDG_DATA_HOME: "/Users/test/.agents/ucsd/data", + }, + }); + const second = openCodeLocalServerLockKey({ + binaryPath: "/managed/opencode-b", + environment: { + OPENCODE_CONFIG: "/Users/test/.agents/ucsd/config/opencode/opencode.json", + XDG_DATA_HOME: "/Users/test/.agents/ucsd/data", + }, + }); + const isolated = openCodeLocalServerLockKey({ + binaryPath: "/managed/opencode-a", + environment: { + OPENCODE_CONFIG: "/Users/test/.config/opencode/opencode.json", + XDG_DATA_HOME: "/Users/test/.local/share", + }, + }); + + expect(first).toBe(second); + expect(first).not.toBe(isolated); + }); + + it("does not split default data-directory locks by config path", () => { + const first = openCodeLocalServerLockKey({ + binaryPath: "/managed/opencode-a", + environment: { + HOME: "/Users/test", + OPENCODE_CONFIG: "/Users/test/.config/opencode/opencode.json", + }, + }); + const second = openCodeLocalServerLockKey({ + binaryPath: "/managed/opencode-b", + environment: { + HOME: "/Users/test", + OPENCODE_CONFIG: "/Users/test/.agents/ucsd/config/opencode/opencode.json", + }, + }); + + expect(first).toBe(second); + }); +}); diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index 0569418ebb5..d70ad2ba890 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -24,6 +24,7 @@ import * as P from "effect/Predicate"; import * as Ref from "effect/Ref"; import * as Result from "effect/Result"; import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; @@ -39,6 +40,7 @@ const OPENCODE_EMPTY_CONFIG_CONTENT = "{}"; const OPENCODE_SERVER_READY_PREFIX = "opencode server listening"; const DEFAULT_OPENCODE_SERVER_TIMEOUT_MS = 5_000; const DEFAULT_HOSTNAME = "127.0.0.1"; +const OPENCODE_DATABASE_LOCKED_RETRY_DELAYS = ["250 millis", "750 millis", "1500 millis"] as const; export interface OpenCodeServerProcess { readonly url: string; readonly exitCode: Effect.Effect; @@ -50,6 +52,12 @@ export interface OpenCodeServerConnection { readonly external: boolean; } +interface SharedOpenCodeServerEntry { + readonly server: OpenCodeServerProcess; + readonly scope: Scope.Closeable; + readonly refCount: number; +} + const OPENCODE_RUNTIME_ERROR_TAG = "OpenCodeRuntimeError"; export class OpenCodeRuntimeError extends Data.TaggedError(OPENCODE_RUNTIME_ERROR_TAG)<{ readonly operation: string; @@ -81,6 +89,34 @@ export function openCodeRuntimeErrorDetail(cause: unknown): string { return String(cause); } +export function isOpenCodeDatabaseLockedError(cause: unknown): boolean { + return openCodeRuntimeErrorDetail(cause).toLowerCase().includes("database is locked"); +} + +function nonEmptyEnvironmentValue(value: string | undefined): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +export function openCodeLocalServerLockKey(input: { + readonly binaryPath: string; + readonly environment?: NodeJS.ProcessEnv; +}): string { + const environment = buildOpenCodeServerEnvironment(input.environment ?? process.env); + const dataScope = + nonEmptyEnvironmentValue(environment.XDG_DATA_HOME) ?? + nonEmptyEnvironmentValue(environment.HOME) ?? + nonEmptyEnvironmentValue(environment.USERPROFILE) ?? + nonEmptyEnvironmentValue(environment.XDG_STATE_HOME) ?? + nonEmptyEnvironmentValue(environment.OPENCODE_CONFIG) ?? + input.binaryPath; + + return `opencode-local-server:${dataScope}`; +} + export const runOpenCodeSdk = ( operation: string, fn: () => Promise, @@ -138,6 +174,7 @@ export interface OpenCodeRuntimeShape { readonly binaryPath: string; readonly serverUrl?: string | null; readonly environment?: NodeJS.ProcessEnv; + readonly reuseLocalServer?: boolean; readonly port?: number; readonly hostname?: string; readonly timeoutMs?: number; @@ -304,9 +341,110 @@ const makeOpenCodeRuntime = Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const netService = yield* NetService.NetService; const hostPlatform = yield* HostProcessPlatform; + const runtimeScope = yield* Effect.scope; + const localServerLocksRef = yield* Ref.make>(new Map()); + const sharedLocalServersRef = yield* Ref.make>( + new Map(), + ); const resolveCommand = (command: string, args: ReadonlyArray, env?: NodeJS.ProcessEnv) => resolveSpawnCommand(command, args, env ? { env } : {}); + const getLocalServerLock = Effect.fn("getOpenCodeLocalServerLock")(function* (lockKey: string) { + const existing = (yield* Ref.get(localServerLocksRef)).get(lockKey); + if (existing) { + return existing; + } + + const lock = yield* Semaphore.make(1); + return yield* Ref.modify(localServerLocksRef, (locks) => { + const current = locks.get(lockKey); + if (current) { + return [current, locks] as const; + } + const next = new Map(locks); + next.set(lockKey, lock); + return [lock, next] as const; + }); + }); + + const removeSharedLocalServer = ( + lockKey: string, + server: OpenCodeServerProcess, + ): Effect.Effect => + Ref.modify(sharedLocalServersRef, (servers) => { + const current = servers.get(lockKey); + if (!current || current.server !== server) { + return [null, servers] as const; + } + const next = new Map(servers); + next.delete(lockKey); + return [current, next] as const; + }); + + const releaseSharedLocalServer = ( + lockKey: string, + server: OpenCodeServerProcess, + ): Effect.Effect => + Effect.gen(function* () { + const lock = yield* getLocalServerLock(lockKey); + yield* lock.withPermits(1)( + Effect.gen(function* () { + const scopeToClose = yield* Ref.modify(sharedLocalServersRef, (servers) => { + const current = servers.get(lockKey); + if (!current || current.server !== server) { + return [null, servers] as const; + } + if (current.refCount > 1) { + const next = new Map(servers); + next.set(lockKey, { ...current, refCount: current.refCount - 1 }); + return [null, next] as const; + } + const next = new Map(servers); + next.delete(lockKey); + return [current.scope, next] as const; + }); + if (scopeToClose) { + yield* Scope.close(scopeToClose, Exit.void).pipe(Effect.ignore); + } + }), + ); + }); + + const tryAcquireExclusiveLocalServerLock = Effect.fn( + "tryAcquireOpenCodeExclusiveLocalServerLock", + )(function* (lockKey: string, exclusiveScope: Scope.Closeable) { + const lock = yield* getLocalServerLock(lockKey); + return yield* Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + yield* restore(lock.take(1)); + const existing = (yield* Ref.get(sharedLocalServersRef)).get(lockKey); + if (existing) { + yield* lock.release(1); + return false; + } + yield* Scope.addFinalizer(exclusiveScope, lock.release(1)); + return true; + }), + ); + }); + + const acquireExclusiveLocalServerLock = Effect.fn("acquireOpenCodeExclusiveLocalServerLock")( + function* (lockKey: string, exclusiveScope: Scope.Closeable) { + while (true) { + const acquired = yield* tryAcquireExclusiveLocalServerLock(lockKey, exclusiveScope); + if (acquired) { + return; + } + const existing = (yield* Ref.get(sharedLocalServersRef)).get(lockKey); + if (existing) { + yield* existing.server.exitCode.pipe(Effect.timeoutOption("100 millis"), Effect.ignore); + } else { + yield* Effect.sleep("100 millis"); + } + } + }, + ); + const runOpenCodeCommand: OpenCodeRuntimeShape["runOpenCodeCommand"] = (input) => Effect.gen(function* () { const spawnCommand = yield* resolveCommand(input.binaryPath, input.args, input.environment); @@ -343,7 +481,9 @@ const makeOpenCodeRuntime = Effect.gen(function* () { ), ); - const startOpenCodeServerProcess: OpenCodeRuntimeShape["startOpenCodeServerProcess"] = (input) => + const startOpenCodeServerProcessOnce: OpenCodeRuntimeShape["startOpenCodeServerProcess"] = ( + input, + ) => Effect.gen(function* () { // Bind this server's lifetime to the caller's scope. When the caller's // scope closes, the spawned child is killed and all associated fibers @@ -471,6 +611,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () { if (Exit.isFailure(readyExit)) { yield* Fiber.interrupt(exitFiber).pipe(Effect.ignore); + yield* terminateChild; const squashed = Cause.squash(readyExit.cause); return yield* ensureRuntimeError( "startOpenCodeServerProcess", @@ -482,6 +623,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () { const readyOption = readyExit.value; if (Option.isNone(readyOption)) { yield* Fiber.interrupt(exitFiber).pipe(Effect.ignore); + yield* terminateChild; return yield* new OpenCodeRuntimeError({ operation: "startOpenCodeServerProcess", detail: `Timed out waiting for OpenCode server start after ${timeoutMs}ms.`, @@ -497,6 +639,159 @@ const makeOpenCodeRuntime = Effect.gen(function* () { } satisfies OpenCodeServerProcess; }); + const startOpenCodeServerProcessWithRetry = ( + input: Parameters[0], + attempt = 0, + ): ReturnType => + startOpenCodeServerProcessOnce(input).pipe( + Effect.catch((cause: OpenCodeRuntimeError) => { + const retryDelay = OPENCODE_DATABASE_LOCKED_RETRY_DELAYS[attempt]; + if (!retryDelay || !isOpenCodeDatabaseLockedError(cause)) { + return Effect.fail(cause); + } + return Effect.logWarning( + `OpenCode server startup hit a locked database; retrying in ${retryDelay}.`, + ).pipe( + Effect.andThen(Effect.sleep(retryDelay)), + Effect.andThen(startOpenCodeServerProcessWithRetry(input, attempt + 1)), + ); + }), + ); + + const startOpenCodeServerProcess: OpenCodeRuntimeShape["startOpenCodeServerProcess"] = (input) => + startOpenCodeServerProcessWithRetry(input); + + const retainSharedLocalServer = ( + input: Omit[0], "serverUrl">, + ): Effect.Effect => { + const lockKey = openCodeLocalServerLockKey({ + binaryPath: input.binaryPath, + ...(input.environment !== undefined ? { environment: input.environment } : {}), + }); + + return Effect.gen(function* () { + const callerScope = yield* Scope.Scope; + const lock = yield* getLocalServerLock(lockKey); + + return yield* lock.withPermits(1)( + Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const existing = (yield* Ref.get(sharedLocalServersRef)).get(lockKey); + if (existing) { + const exited = yield* existing.server.exitCode.pipe(Effect.timeoutOption("1 millis")); + if (Option.isNone(exited)) { + yield* Ref.update(sharedLocalServersRef, (servers) => { + const current = servers.get(lockKey); + if (!current || current.server !== existing.server) { + return servers; + } + const next = new Map(servers); + next.set(lockKey, { ...current, refCount: current.refCount + 1 }); + return next; + }); + yield* Scope.addFinalizer( + callerScope, + releaseSharedLocalServer(lockKey, existing.server), + ); + return { + url: existing.server.url, + exitCode: existing.server.exitCode, + external: false, + } satisfies OpenCodeServerConnection; + } + + yield* removeSharedLocalServer(lockKey, existing.server); + yield* Scope.close(existing.scope, Exit.void).pipe(Effect.ignore); + } + + const serverScope = yield* Scope.make(); + const startedExit = yield* Effect.exit( + restore( + startOpenCodeServerProcess({ + binaryPath: input.binaryPath, + ...(input.environment !== undefined ? { environment: input.environment } : {}), + ...(input.port !== undefined ? { port: input.port } : {}), + ...(input.hostname !== undefined ? { hostname: input.hostname } : {}), + ...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}), + }).pipe(Effect.provideService(Scope.Scope, serverScope)), + ), + ); + + if (Exit.isFailure(startedExit)) { + yield* Scope.close(serverScope, Exit.void).pipe(Effect.ignore); + return yield* Effect.failCause(startedExit.cause); + } + + const server = startedExit.value; + yield* Ref.update(sharedLocalServersRef, (servers) => { + const next = new Map(servers); + next.set(lockKey, { server, scope: serverScope, refCount: 1 }); + return next; + }); + + const exitMonitor = yield* server.exitCode.pipe( + Effect.flatMap(() => lock.withPermits(1)(removeSharedLocalServer(lockKey, server))), + Effect.flatMap((removed) => + removed ? Scope.close(removed.scope, Exit.void).pipe(Effect.ignore) : Effect.void, + ), + Effect.ignore, + Effect.forkIn(runtimeScope), + ); + yield* Scope.addFinalizer( + serverScope, + Fiber.interrupt(exitMonitor).pipe(Effect.ignore), + ); + yield* Scope.addFinalizer(callerScope, releaseSharedLocalServer(lockKey, server)); + + return { + url: server.url, + exitCode: server.exitCode, + external: false, + } satisfies OpenCodeServerConnection; + }), + ), + ); + }); + }; + + const retainExclusiveLocalServer = ( + input: Omit< + Parameters[0], + "serverUrl" | "reuseLocalServer" + >, + ): Effect.Effect => { + const lockKey = openCodeLocalServerLockKey({ + binaryPath: input.binaryPath, + ...(input.environment !== undefined ? { environment: input.environment } : {}), + }); + + return Effect.gen(function* () { + const exclusiveScope = yield* Effect.acquireRelease(Scope.make(), (scope) => + Scope.close(scope, Exit.void).pipe(Effect.ignore), + ); + yield* acquireExclusiveLocalServerLock(lockKey, exclusiveScope); + + const serverScope = yield* Scope.make(); + yield* Scope.addFinalizer( + exclusiveScope, + Scope.close(serverScope, Exit.void).pipe(Effect.ignore), + ); + const server = yield* startOpenCodeServerProcess({ + binaryPath: input.binaryPath, + ...(input.environment !== undefined ? { environment: input.environment } : {}), + ...(input.port !== undefined ? { port: input.port } : {}), + ...(input.hostname !== undefined ? { hostname: input.hostname } : {}), + ...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}), + }).pipe(Effect.provideService(Scope.Scope, serverScope)); + + return { + url: server.url, + exitCode: server.exitCode, + external: false, + } satisfies OpenCodeServerConnection; + }); + }; + const connectToOpenCodeServer: OpenCodeRuntimeShape["connectToOpenCodeServer"] = (input) => { const serverUrl = input.serverUrl?.trim(); if (serverUrl) { @@ -508,19 +803,23 @@ const makeOpenCodeRuntime = Effect.gen(function* () { }); } - return startOpenCodeServerProcess({ + if (input.reuseLocalServer === false) { + return retainExclusiveLocalServer({ + binaryPath: input.binaryPath, + ...(input.environment !== undefined ? { environment: input.environment } : {}), + ...(input.port !== undefined ? { port: input.port } : {}), + ...(input.hostname !== undefined ? { hostname: input.hostname } : {}), + ...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}), + }); + } + + return retainSharedLocalServer({ binaryPath: input.binaryPath, ...(input.environment !== undefined ? { environment: input.environment } : {}), ...(input.port !== undefined ? { port: input.port } : {}), ...(input.hostname !== undefined ? { hostname: input.hostname } : {}), ...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}), - }).pipe( - Effect.map((server) => ({ - url: server.url, - exitCode: server.exitCode, - external: false, - })), - ); + }); }; const createOpenCodeSdkClient: OpenCodeRuntimeShape["createOpenCodeSdkClient"] = (input) => diff --git a/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts b/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts index ba1f3a0435c..3e6c5de1220 100644 --- a/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts +++ b/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts @@ -1,11 +1,9 @@ import { OpenCodeSettings, ProviderInstanceId } from "@t3tools/contracts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; -import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; -import * as TestClock from "effect/testing/TestClock"; import * as NetService from "@t3tools/shared/Net"; import { beforeEach, expect } from "vite-plus/test"; @@ -37,30 +35,41 @@ const runtimeMock = { }, }; +function startFakeOpenCodeServer(binaryPath: string) { + return Effect.gen(function* () { + const index = runtimeMock.state.startCalls.length + 1; + const url = `http://127.0.0.1:${4_300 + index}`; + runtimeMock.state.startCalls.push(binaryPath); + // The production runtime binds server lifetime to the caller's scope. + // Mirror that here so the closeCalls probe observes scope close. + yield* Effect.addFinalizer(() => + Effect.sync(() => { + runtimeMock.state.closeCalls.push(url); + }), + ); + return { + url, + exitCode: Effect.never, + }; + }); +} + const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { - startOpenCodeServerProcess: ({ binaryPath }) => - Effect.gen(function* () { - const index = runtimeMock.state.startCalls.length + 1; - const url = `http://127.0.0.1:${4_300 + index}`; - runtimeMock.state.startCalls.push(binaryPath); - // The production runtime binds server lifetime to the caller's scope. - // Mirror that here so the closeCalls probe observes scope close. - yield* Effect.addFinalizer(() => - Effect.sync(() => { - runtimeMock.state.closeCalls.push(url); - }), - ); - return { - url, - exitCode: Effect.never, - }; - }), - connectToOpenCodeServer: ({ serverUrl }) => - Effect.succeed({ - url: serverUrl ?? "http://127.0.0.1:4301", - exitCode: null, - external: Boolean(serverUrl), - }), + startOpenCodeServerProcess: ({ binaryPath }) => startFakeOpenCodeServer(binaryPath), + connectToOpenCodeServer: ({ binaryPath, serverUrl }) => + serverUrl + ? Effect.succeed({ + url: serverUrl, + exitCode: null, + external: true, + }) + : startFakeOpenCodeServer(binaryPath).pipe( + Effect.map((server) => ({ + url: server.url, + exitCode: server.exitCode, + external: false, + })), + ), runOpenCodeCommand: () => Effect.succeed({ stdout: "", stderr: "", code: 0 }), createOpenCodeSdkClient: ({ baseUrl, serverPassword }) => ({ @@ -104,8 +113,6 @@ const DEFAULT_TEST_MODEL_SELECTION = { model: "openai/gpt-5", }; -const OPENCODE_TEXT_GENERATION_IDLE_TTL_MS = 30_000; - const OpenCodeTextGenerationTestLayer = Layer.succeed( OpenCodeRuntime, OpenCodeRuntimeTestDouble, @@ -155,14 +162,8 @@ beforeEach(() => { runtimeMock.reset(); }); -const advanceIdleClock = Effect.gen(function* () { - yield* Effect.yieldNow; - yield* TestClock.adjust(Duration.millis(OPENCODE_TEXT_GENERATION_IDLE_TTL_MS + 1)); - yield* Effect.yieldNow; -}); - it.layer(OpenCodeTextGenerationTestLayer)("OpenCodeTextGeneration", (it) => { - it.effect("reuses a warm server across back-to-back requests and closes it after idling", () => + it.effect("releases the runtime lease as soon as local requests drain", () => withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => Effect.gen(function* () { yield* textGeneration.generateCommitMessage({ @@ -180,49 +181,17 @@ it.layer(OpenCodeTextGenerationTestLayer)("OpenCodeTextGeneration", (it) => { modelSelection: DEFAULT_TEST_MODEL_SELECTION, }); - expect(runtimeMock.state.startCalls).toEqual(["fake-opencode"]); + expect(runtimeMock.state.startCalls).toEqual(["fake-opencode", "fake-opencode"]); expect(runtimeMock.state.promptUrls).toEqual([ "http://127.0.0.1:4301", - "http://127.0.0.1:4301", + "http://127.0.0.1:4302", ]); - expect(runtimeMock.state.closeCalls).toEqual([]); - - yield* advanceIdleClock; - - expect(runtimeMock.state.closeCalls).toEqual(["http://127.0.0.1:4301"]); - }), - ).pipe(Effect.provide(TestClock.layer())), - ); - - it.effect("starts a new server after the warm server idles out", () => - withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => - Effect.gen(function* () { - yield* textGeneration.generateCommitMessage({ - cwd: process.cwd(), - branch: "feature/opencode-reuse", - stagedSummary: "M README.md", - stagedPatch: "diff --git a/README.md b/README.md", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }); - - yield* advanceIdleClock; - - yield* textGeneration.generateCommitMessage({ - cwd: process.cwd(), - branch: "feature/opencode-reuse", - stagedSummary: "M README.md", - stagedPatch: "diff --git a/README.md b/README.md", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }); - - expect(runtimeMock.state.startCalls).toEqual(["fake-opencode", "fake-opencode"]); - expect(runtimeMock.state.promptUrls).toEqual([ + expect(runtimeMock.state.closeCalls).toEqual([ "http://127.0.0.1:4301", "http://127.0.0.1:4302", ]); - expect(runtimeMock.state.closeCalls).toEqual(["http://127.0.0.1:4301"]); }), - ).pipe(Effect.provide(TestClock.layer())), + ), ); it.effect("returns a typed empty-output error when OpenCode returns no text parts", () => @@ -339,11 +308,9 @@ it.layer(OpenCodeTextGenerationExistingServerTestLayer)( `Basic ${btoa("opencode:secret-password")}`, ]); - yield* advanceIdleClock; - expect(runtimeMock.state.closeCalls).toEqual([]); }), - ).pipe(Effect.provide(TestClock.layer())), + ), ); }, ); diff --git a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts index cf81244432e..db0a148f43b 100644 --- a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts +++ b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts @@ -1,6 +1,5 @@ import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; -import * as Fiber from "effect/Fiber"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Semaphore from "effect/Semaphore"; @@ -32,14 +31,11 @@ import { import { OpenCodeRuntime, type OpenCodeServerConnection, - type OpenCodeServerProcess, openCodeRuntimeErrorDetail, parseOpenCodeModelSlug, toOpenCodeFileParts, } from "../provider/opencodeRuntime.ts"; -const OPENCODE_TEXT_GENERATION_IDLE_TTL = "30 seconds"; - function getOpenCodePromptErrorMessage(error: unknown): string | null { if (!error || typeof error !== "object") { return null; @@ -84,17 +80,15 @@ function getOpenCodeTextResponse(parts: ReadonlyArray | undefined): str } interface SharedOpenCodeTextGenerationServerState { - server: OpenCodeServerProcess | null; + server: OpenCodeServerConnection | null; /** - * The scope that owns the shared server's lifetime. Closing this scope - * terminates the OpenCode child process and interrupts any fibers the - * runtime forked during startup. We don't hold a `close()` function on - * the server handle anymore — the scope is the only lifecycle handle. + * The scope that owns this text-generation lease. Closing this scope releases + * the runtime-managed OpenCode server; the runtime terminates the child only + * when no probes, chats, or text-generation calls still hold a lease. */ serverScope: Scope.Closeable | null; binaryPath: string | null; activeRequests: number; - idleCloseFiber: Fiber.Fiber | null; } export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration")(function* ( @@ -104,16 +98,12 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" const serverConfig = yield* ServerConfig; const openCodeRuntime = yield* OpenCodeRuntime; const resolvedEnvironment = environment ?? process.env; - const idleFiberScope = yield* Effect.acquireRelease(Scope.make(), (scope) => - Scope.close(scope, Exit.void), - ); const sharedServerMutex = yield* Semaphore.make(1); const sharedServerState: SharedOpenCodeTextGenerationServerState = { server: null, serverScope: null, binaryPath: null, activeRequests: 0, - idleCloseFiber: null, }; const closeSharedServer = Effect.fn("closeSharedServer")(function* () { @@ -126,35 +116,6 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" } }); - const cancelIdleCloseFiber = Effect.fn("cancelIdleCloseFiber")(function* () { - const idleCloseFiber = sharedServerState.idleCloseFiber; - sharedServerState.idleCloseFiber = null; - if (idleCloseFiber !== null) { - yield* Fiber.interrupt(idleCloseFiber).pipe(Effect.ignore); - } - }); - - const scheduleIdleClose = Effect.fn("scheduleIdleClose")(function* ( - server: OpenCodeServerProcess, - ) { - yield* cancelIdleCloseFiber(); - const fiber = yield* Effect.sleep(OPENCODE_TEXT_GENERATION_IDLE_TTL).pipe( - Effect.andThen( - sharedServerMutex.withPermit( - Effect.gen(function* () { - if (sharedServerState.server !== server || sharedServerState.activeRequests > 0) { - return; - } - sharedServerState.idleCloseFiber = null; - yield* closeSharedServer(); - }), - ), - ), - Effect.forkIn(idleFiberScope), - ); - sharedServerState.idleCloseFiber = fiber; - }); - const acquireSharedServer = (input: { readonly binaryPath: string; readonly operation: @@ -165,8 +126,6 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" }) => sharedServerMutex.withPermit( Effect.gen(function* () { - yield* cancelIdleCloseFiber(); - const existingServer = sharedServerState.server; if (existingServer !== null) { if ( @@ -189,16 +148,15 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" } } - // Create a fresh scope that owns this shared server. The runtime - // will attach its child-process and fiber finalizers to this scope; - // closing it kills the server and interrupts those fibers. + // Create a fresh scope that owns this text-generation lease. The + // runtime keeps the actual local OpenCode server alive while any + // probe, chat session, or text-generation call still holds a lease. // // The `Scope.make` / spawn / record-or-close transitions run inside // `uninterruptibleMask` so an interrupt arriving between any two - // steps can't orphan the scope (and the child process attached to - // it) before we either close it on failure or hand ownership to - // `sharedServerState`. `restore` keeps the actual spawn - // interruptible; an interrupt during the spawn is captured by + // steps can't orphan the scope before we either close it on failure + // or hand ownership to `sharedServerState`. `restore` keeps the + // runtime connection attempt interruptible; an interrupt is captured by // `Effect.exit` and drives us through the failure branch that // closes the fresh scope. return yield* Effect.uninterruptibleMask((restore) => @@ -207,7 +165,7 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" const startedExit = yield* Effect.exit( restore( openCodeRuntime - .startOpenCodeServerProcess({ + .connectToOpenCodeServer({ binaryPath: input.binaryPath, environment: resolvedEnvironment, }) @@ -240,7 +198,7 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" }), ); - const releaseSharedServer = (server: OpenCodeServerProcess) => + const releaseSharedServer = (server: OpenCodeServerConnection) => sharedServerMutex.withPermit( Effect.gen(function* () { if (sharedServerState.server !== server) { @@ -248,18 +206,17 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" } sharedServerState.activeRequests = Math.max(0, sharedServerState.activeRequests - 1); if (sharedServerState.activeRequests === 0) { - yield* scheduleIdleClose(server); + yield* closeSharedServer(); } }), ); - // Module-level finalizer: on layer shutdown, cancel the idle close fiber - // and close the shared server scope. Consumers therefore cannot leak - // the shared OpenCode server by forgetting to call anything. + // Module-level finalizer: on layer shutdown, close any active shared + // text-generation lease. Consumers therefore cannot leak the shared + // OpenCode server by forgetting to call anything. yield* Effect.addFinalizer(() => sharedServerMutex.withPermit( Effect.gen(function* () { - yield* cancelIdleCloseFiber(); sharedServerState.activeRequests = 0; yield* closeSharedServer(); }), diff --git a/apps/web/package.json b/apps/web/package.json index 2117222a4fd..3b326ff21ec 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/web", - "version": "0.1.12", + "version": "0.1.13", "private": true, "type": "module", "scripts": { diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index db647cc3dfa..d46164e794c 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -79,6 +79,7 @@ import { } from "../store"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; import { buildThreadRouteParams, resolveThreadRouteTarget } from "../threadRoutes"; +import { isTritonAiChatsWorkspacePath } from "../tritonAiWorkspace"; import { ADDON_ICON_CLASS, buildBrowseGroups, @@ -402,7 +403,11 @@ function OpenCommandPaletteDialog() { const settings = useSettings(); const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread } = useHandleNewThread(); - const projects = useStore(useShallow(selectProjectsAcrossEnvironments)); + const allProjects = useStore(useShallow(selectProjectsAcrossEnvironments)); + const projects = useMemo( + () => allProjects.filter((project) => !isTritonAiChatsWorkspacePath(project.cwd)), + [allProjects], + ); const threads = useStore(useShallow(selectSidebarThreadsAcrossEnvironments)); const keybindings = useServerKeybindings(); const [viewStack, setViewStack] = useState([]); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index e5217a0f52f..6ad98750533 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -40,6 +40,7 @@ import { CSS } from "@dnd-kit/utilities"; import { type ContextMenuItem, type DesktopUpdateState, + type EnvironmentId, ProjectId, type ScopedThreadRef, type SidebarProjectGroupingMode, @@ -65,7 +66,7 @@ import { usePrimaryEnvironmentId } from "../environments/primary"; import { isElectron } from "../env"; import { APP_VERSION } from "../branding"; import { isTerminalFocused } from "../lib/terminalFocus"; -import { isMacPlatform, newCommandId } from "../lib/utils"; +import { isMacPlatform, newCommandId, newProjectId } from "../lib/utils"; import { selectProjectByRef, selectProjectsAcrossEnvironments, @@ -195,6 +196,7 @@ import { useSavedEnvironmentRuntimeStore, } from "../environments/runtime"; import type { SidebarThreadSummary } from "../types"; +import type { Project } from "../types"; import { buildPhysicalToLogicalProjectKeyMap, buildSidebarProjectSnapshots, @@ -203,6 +205,11 @@ import { } from "../sidebarProjectGrouping"; import { SidebarProviderUpdatePill } from "./sidebar/SidebarProviderUpdatePill"; import { openDiscoveredPort } from "./preview/openDiscoveredPort"; +import { + TRITONAI_CHATS_PROJECT_TITLE, + isTritonAiChatsWorkspacePath, + resolveTritonAiChatsWorkspacePath, +} from "../tritonAiWorkspace"; const SIDEBAR_SORT_LABELS: Record = { updated_at: "Last user message", created_at: "Created at", @@ -224,6 +231,7 @@ const PROJECT_GROUPING_MODE_LABELS: Record = }; const SIDEBAR_ICON_ACTION_BUTTON_CLASS = "inline-flex h-6 min-w-6 cursor-pointer items-center justify-center rounded-md px-[calc(--spacing(1)-1px)] text-muted-foreground/60 hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring"; +const CHAT_PROJECT_CREATE_WAIT_TIMEOUT_MS = 5_000; function clampSidebarThreadPreviewCount(value: number): SidebarThreadPreviewCount { return Math.min( @@ -284,6 +292,36 @@ function buildThreadJumpLabelMap(input: { return mapping.size > 0 ? mapping : EMPTY_THREAD_JUMP_LABELS; } +function findProjectById(environmentId: EnvironmentId, projectId: ProjectId): Project | null { + const environmentState = useStore.getState().environmentStateById[environmentId] ?? null; + if (!environmentState) return null; + return environmentState.projectById[projectId] ?? null; +} + +function waitForProjectById(input: { + environmentId: EnvironmentId; + projectId: ProjectId; + timeoutMs: number; +}): Promise { + const immediate = findProjectById(input.environmentId, input.projectId); + if (immediate) return Promise.resolve(immediate); + + return new Promise((resolve) => { + const timeout = window.setTimeout(() => { + unsubscribe(); + resolve(findProjectById(input.environmentId, input.projectId)); + }, input.timeoutMs); + + const unsubscribe = useStore.subscribe(() => { + const project = findProjectById(input.environmentId, input.projectId); + if (!project) return; + window.clearTimeout(timeout); + unsubscribe(); + resolve(project); + }); + }); +} + interface SidebarThreadRowProps { thread: SidebarThreadSummary; projectCwd: string | null; @@ -810,6 +848,7 @@ interface SidebarProjectThreadListProps { showEmptyThreadState: boolean; shouldShowThreadPanel: boolean; isThreadListExpanded: boolean; + emptyThreadLabel: string; projectCwd: string; activeRouteThreadKey: string | null; threadJumpLabelByKey: ReadonlyMap; @@ -861,6 +900,7 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( showEmptyThreadState, shouldShowThreadPanel, isThreadListExpanded, + emptyThreadLabel, projectCwd, activeRouteThreadKey, threadJumpLabelByKey, @@ -901,7 +941,7 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( data-thread-selection-safe className="flex h-6 w-full translate-x-0 items-center px-2 text-left text-[10px] text-muted-foreground/60" > - No threads yet + {emptyThreadLabel} ) : null} @@ -978,6 +1018,9 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( interface SidebarProjectItemProps { project: SidebarProjectSnapshot; + hideProjectHeader?: boolean; + forceThreadPanelOpen?: boolean; + emptyThreadLabel?: string; isThreadListExpanded: boolean; activeRouteThreadKey: string | null; newThreadShortcutLabel: string | null; @@ -998,6 +1041,9 @@ interface SidebarProjectItemProps { const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjectItemProps) { const { project, + hideProjectHeader = false, + forceThreadPanelOpen = false, + emptyThreadLabel = "No threads yet", isThreadListExpanded, activeRouteThreadKey, newThreadShortcutLabel, @@ -1128,9 +1174,10 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const sidebarThreadByKeyRef = useRef(sidebarThreadByKey); sidebarThreadByKeyRef.current = sidebarThreadByKey; const projectThreads = sidebarThreads; - const projectExpanded = useUiStateStore( + const storedProjectExpanded = useUiStateStore( (state) => state.projectExpandedById[project.projectKey] ?? true, ); + const projectExpanded = forceThreadPanelOpen ? true : storedProjectExpanded; const threadLastVisitedAts = useUiStateStore( useShallow((state) => projectThreads.map( @@ -2077,105 +2124,107 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec return ( <> -
- - {!projectExpanded && projectStatus ? ( + {!hideProjectHeader ? ( +
+ + {!projectExpanded && projectStatus ? ( + + + } + > + + + + + + {projectStatus.label} + + ) : ( + + )} + + + + {project.displayName} + + {project.groupedProjectCount > 1 ? ( + + {project.groupedProjectCount} projects + + ) : null} + + + {/* Environment badge – visible by default, crossfades with the + "new thread" button on hover using the same pointer-events + + opacity pattern as the thread row archive/timestamp swap. */} + {project.environmentPresence === "remote-only" && ( } > - - - - + - {projectStatus.label} + + Remote environment: {project.remoteEnvironmentLabels.join(", ")} + - ) : ( - )} - - - - {project.displayName} - - {project.groupedProjectCount > 1 ? ( - - {project.groupedProjectCount} projects - - ) : null} - - - {/* Environment badge – visible by default, crossfades with the - "new thread" button on hover using the same pointer-events + - opacity pattern as the thread row archive/timestamp swap. */} - {project.environmentPresence === "remote-only" && ( +
+ +
} - > - -
+ /> - Remote environment: {project.remoteEnvironmentLabels.join(", ")} + {newThreadShortcutLabel ? `New thread (${newThreadShortcutLabel})` : "New thread"}
- )} - - - -
- } - /> - - {newThreadShortcutLabel ? `New thread (${newThreadShortcutLabel})` : "New thread"} - - -
+ + ) : null} void; handleProjectDragCancel: (event: DragCancelEvent) => void; handleNewThread: ReturnType["handleNewThread"]; + handleNewChat: () => void; archiveThread: ReturnType["archiveThread"]; deleteThread: ReturnType["deleteThread"]; sortedProjects: readonly SidebarProjectSnapshot[]; + chatsProject: SidebarProjectSnapshot | null; expandedThreadListsByProject: ReadonlySet; activeRouteProjectKey: string | null; routeThreadKey: string | null; @@ -2684,9 +2736,11 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( handleProjectDragEnd, handleProjectDragCancel, handleNewThread, + handleNewChat, archiveThread, deleteThread, sortedProjects, + chatsProject, expandedThreadListsByProject, activeRouteProjectKey, routeThreadKey, @@ -2729,7 +2783,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( ); return ( - + @@ -2890,6 +2944,64 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( )} + +
+ + CHATS + + + + } + > + + + + {newThreadShortcutLabel ? `New chat (${newThreadShortcutLabel})` : "New chat"} + + +
+ + {chatsProject ? ( + + ) : ( + +
+ No chats +
+
+ )} +
+
); }); @@ -2908,6 +3020,7 @@ export default function Sidebar() { const sidebarProjectGroupingMode = useSettings((s) => s.sidebarProjectGroupingMode); const projectGroupingSettings = useSettings(selectProjectGroupingSettings); const sidebarThreadPreviewCount = useSettings((s) => s.sidebarThreadPreviewCount); + const textGenerationModelSelection = useSettings((s) => s.textGenerationModelSelection); const { updateSettings } = useUpdateSettings(); const { handleNewThread } = useNewThreadHandler(); const { archiveThread, deleteThread } = useThreadActions(); @@ -2981,6 +3094,108 @@ export default function Sidebar() { savedEnvironmentRegistry, savedEnvironmentRuntimeById, ]); + const chatsProject = useMemo(() => { + const project = + sidebarProjects.find((candidate) => + candidate.memberProjects.some((member) => isTritonAiChatsWorkspacePath(member.cwd)), + ) ?? null; + return project ? { ...project, displayName: TRITONAI_CHATS_PROJECT_TITLE } : null; + }, [sidebarProjects]); + const regularSidebarProjects = useMemo( + () => + chatsProject + ? sidebarProjects.filter((project) => project.projectKey !== chatsProject.projectKey) + : sidebarProjects, + [chatsProject, sidebarProjects], + ); + const ensureChatsProjectInFlightRef = useRef | null>(null); + const ensureChatsProject = useCallback((): Promise => { + const inFlight = ensureChatsProjectInFlightRef.current; + if (inFlight) { + return inFlight; + } + + const promise = (async (): Promise => { + const currentProjects = () => selectProjectsAcrossEnvironments(useStore.getState()); + + if (!primaryEnvironmentId) { + const existingProject = + currentProjects().find((project) => isTritonAiChatsWorkspacePath(project.cwd)) ?? null; + if (existingProject) { + return existingProject; + } + throw new Error("Primary environment is not ready."); + } + + const api = readEnvironmentApi(primaryEnvironmentId); + if (!api) { + throw new Error("Project API unavailable."); + } + + const existingProject = + currentProjects().find( + (project) => + project.environmentId === primaryEnvironmentId && + isTritonAiChatsWorkspacePath(project.cwd), + ) ?? null; + if (existingProject) { + return existingProject; + } + + const projectId = newProjectId(); + const createdAt = new Date().toISOString(); + await api.orchestration.dispatchCommand({ + type: "project.create", + commandId: newCommandId(), + projectId, + title: TRITONAI_CHATS_PROJECT_TITLE, + workspaceRoot: resolveTritonAiChatsWorkspacePath(), + createWorkspaceRootIfMissing: true, + defaultModelSelection: textGenerationModelSelection, + createdAt, + }); + + const project = await waitForProjectById({ + environmentId: primaryEnvironmentId, + projectId, + timeoutMs: CHAT_PROJECT_CREATE_WAIT_TIMEOUT_MS, + }); + if (!project) { + throw new Error("Chats project was not created."); + } + return project; + })(); + ensureChatsProjectInFlightRef.current = promise; + void promise.finally(() => { + if (ensureChatsProjectInFlightRef.current === promise) { + ensureChatsProjectInFlightRef.current = null; + } + }); + return promise; + }, [primaryEnvironmentId, textGenerationModelSelection]); + const handleNewChat = useCallback(() => { + void (async () => { + try { + const project = await ensureChatsProject(); + if (isMobile) { + setOpenMobile(false); + } + await handleNewThread(scopeProjectRef(project.environmentId, project.id), { + branch: null, + worktreePath: null, + envMode: "local", + }); + } catch (error) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not start chat", + description: error instanceof Error ? error.message : "An unexpected error occurred.", + }), + ); + } + })(); + }, [ensureChatsProject, handleNewThread, isMobile, setOpenMobile]); const sidebarProjectByKey = useMemo( () => new Map(sidebarProjects.map((project) => [project.projectKey, project] as const)), @@ -3097,8 +3312,10 @@ export default function Sidebar() { dragInProgressRef.current = false; const { active, over } = event; if (!over || active.id === over.id) return; - const activeProject = sidebarProjects.find((project) => project.projectKey === active.id); - const overProject = sidebarProjects.find((project) => project.projectKey === over.id); + const activeProject = regularSidebarProjects.find( + (project) => project.projectKey === active.id, + ); + const overProject = regularSidebarProjects.find((project) => project.projectKey === over.id); if (!activeProject || !overProject) return; const activeMemberKeys = activeProject.memberProjects.map( (member) => member.physicalProjectKey, @@ -3106,7 +3323,7 @@ export default function Sidebar() { const overMemberKeys = overProject.memberProjects.map((member) => member.physicalProjectKey); reorderProjects(activeMemberKeys, overMemberKeys); }, - [sidebarProjectSortOrder, reorderProjects, sidebarProjects], + [sidebarProjectSortOrder, reorderProjects, regularSidebarProjects], ); const handleProjectDragStart = useCallback( @@ -3147,7 +3364,7 @@ export default function Sidebar() { [sidebarThreads], ); const sortedProjects = useMemo(() => { - const sortableProjects = sidebarProjects.map((project) => ({ + const sortableProjects = regularSidebarProjects.map((project) => ({ ...project, id: project.projectKey, })); @@ -3174,13 +3391,17 @@ export default function Sidebar() { physicalToLogicalKey, projectPhysicalKeyByScopedRef, sidebarProjectByKey, - sidebarProjects, + regularSidebarProjects, visibleThreads, ]); + const sidebarProjectsForThreadNavigation = useMemo( + () => (chatsProject ? [...sortedProjects, chatsProject] : sortedProjects), + [chatsProject, sortedProjects], + ); const isManualProjectSorting = sidebarProjectSortOrder === "manual"; const visibleSidebarThreadKeys = useMemo( () => - sortedProjects.flatMap((project) => { + sidebarProjectsForThreadNavigation.flatMap((project) => { const projectThreads = sortThreads( (threadsByProjectKey.get(project.projectKey) ?? []).filter( (thread) => thread.archivedAt === null, @@ -3218,7 +3439,7 @@ export default function Sidebar() { expandedThreadListsByProject, projectExpandedById, routeThreadKey, - sortedProjects, + sidebarProjectsForThreadNavigation, threadsByProjectKey, ], ); @@ -3547,9 +3768,11 @@ export default function Sidebar() { handleProjectDragEnd={handleProjectDragEnd} handleProjectDragCancel={handleProjectDragCancel} handleNewThread={handleNewThread} + handleNewChat={handleNewChat} archiveThread={archiveThread} deleteThread={deleteThread} sortedProjects={sortedProjects} + chatsProject={chatsProject} expandedThreadListsByProject={expandedThreadListsByProject} activeRouteProjectKey={activeRouteProjectKey} routeThreadKey={routeThreadKey} @@ -3563,7 +3786,7 @@ export default function Sidebar() { suppressProjectClickAfterDragRef={suppressProjectClickAfterDragRef} suppressProjectClickForContextMenuRef={suppressProjectClickForContextMenuRef} attachProjectListAutoAnimateRef={attachProjectListAutoAnimateRef} - projectsLength={projects.length} + projectsLength={regularSidebarProjects.length} /> diff --git a/apps/web/src/firstRunOnboarding.test.ts b/apps/web/src/firstRunOnboarding.test.ts index 8aae2ef7719..c71e1cee1ac 100644 --- a/apps/web/src/firstRunOnboarding.test.ts +++ b/apps/web/src/firstRunOnboarding.test.ts @@ -2,9 +2,12 @@ import { describe, expect, it } from "vitest"; import { TRITONAI_FIRST_RUN_WORKSPACE, + TRITONAI_CHATS_WORKSPACE, hasPriorProjectOrConversationState, isTritonAiCodeBrand, + isTritonAiChatsWorkspacePath, isTritonAiWorkspacePath, + resolveTritonAiChatsWorkspacePath, resolveTritonAiFirstRunWorkspacePath, shouldRunTritonAiFirstRunOnboarding, } from "./firstRunOnboarding"; @@ -15,18 +18,35 @@ describe("firstRunOnboarding", () => { expect(isTritonAiCodeBrand("T3 Code")).toBe(false); }); - it("uses the installer-created TritonAI documents workspace", () => { + it("uses the installer-created TritonAI home workspace", () => { expect(resolveTritonAiFirstRunWorkspacePath()).toBe(TRITONAI_FIRST_RUN_WORKSPACE); + expect(resolveTritonAiChatsWorkspacePath()).toBe(TRITONAI_CHATS_WORKSPACE); }); - it("recognizes tilde, macOS/Linux, and Windows TritonAI documents paths", () => { - expect(isTritonAiWorkspacePath("~/Documents/TritonAI")).toBe(true); - expect(isTritonAiWorkspacePath("/Users/alice/Documents/TritonAI")).toBe(true); - expect(isTritonAiWorkspacePath("/home/alice/Documents/TritonAI/")).toBe(true); - expect(isTritonAiWorkspacePath("C:\\Users\\alice\\Documents\\TritonAI")).toBe(true); + it("recognizes tilde, macOS/Linux, and Windows TritonAI home paths", () => { + expect(isTritonAiWorkspacePath("~/TritonAI")).toBe(true); + expect(isTritonAiWorkspacePath("/Users/alice/TritonAI")).toBe(true); + expect(isTritonAiWorkspacePath("/home/alice/TritonAI/")).toBe(true); + expect(isTritonAiWorkspacePath("C:\\Users\\alice\\TritonAI")).toBe(true); + expect(isTritonAiWorkspacePath("~/Documents/TritonAI")).toBe(false); expect(isTritonAiWorkspacePath("/Users/alice/Projects/TritonAI")).toBe(false); }); + it("recognizes the hidden managed chats workspace", () => { + expect(isTritonAiChatsWorkspacePath("~/.agents/ucsd/state/tritonai-code/chats")).toBe(true); + expect( + isTritonAiChatsWorkspacePath("/Users/alice/.agents/ucsd/state/tritonai-code/chats"), + ).toBe(true); + expect( + isTritonAiChatsWorkspacePath("/home/alice/.agents/ucsd/state/tritonai-code/chats/"), + ).toBe(true); + expect( + isTritonAiChatsWorkspacePath("C:\\Users\\alice\\.agents\\ucsd\\state\\tritonai-code\\chats"), + ).toBe(true); + expect(isTritonAiChatsWorkspacePath("~/TritonAI/Chats")).toBe(false); + expect(isTritonAiChatsWorkspacePath("~/Documents/TritonAI/Chats")).toBe(false); + }); + it("treats existing non-onboarding projects, threads, and drafts as prior state", () => { expect( hasPriorProjectOrConversationState({ diff --git a/apps/web/src/firstRunOnboarding.ts b/apps/web/src/firstRunOnboarding.ts index 917f5bdf9f4..f42d2bd4ec9 100644 --- a/apps/web/src/firstRunOnboarding.ts +++ b/apps/web/src/firstRunOnboarding.ts @@ -22,31 +22,31 @@ import { useClientSettingsHydrated, useSettings, useUpdateSettings } from "./hoo import { usePrimaryEnvironmentId } from "./environments/primary"; import { useStore, type EnvironmentState, type AppState } from "./store"; import { buildDraftThreadRouteParams } from "./threadRoutes"; +import { + TRITONAI_CHATS_WORKSPACE, + TRITONAI_FIRST_RUN_PROMPT, + TRITONAI_FIRST_RUN_WORKSPACE, + isTritonAiCodeBrand, + isTritonAiChatsWorkspacePath, + isTritonAiWorkspacePath, + resolveTritonAiChatsWorkspacePath, + resolveTritonAiFirstRunWorkspacePath, +} from "./tritonAiWorkspace"; import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type Project } from "./types"; -export const TRITONAI_FIRST_RUN_PROMPT = "How does TritonAI Code work, and how can it help me?"; -export const TRITONAI_FIRST_RUN_WORKSPACE = "~/Documents/TritonAI"; - -const TRITONAI_APP_BASE_NAME = "TritonAI Code"; const ONBOARDING_PROJECT_TITLE = "TritonAI"; const PROJECT_CREATE_WAIT_TIMEOUT_MS = 5_000; -export function isTritonAiCodeBrand(appBaseName: string): boolean { - return appBaseName.trim() === TRITONAI_APP_BASE_NAME; -} - -export function isTritonAiWorkspacePath(path: string): boolean { - const normalized = path.trim().replaceAll("\\", "/").replace(/\/+$/g, "").toLowerCase(); - return ( - normalized === "~/documents/tritonai" || - normalized.endsWith("/documents/tritonai") || - /^[a-z]:\/users\/[^/]+\/documents\/tritonai$/i.test(normalized) - ); -} - -export function resolveTritonAiFirstRunWorkspacePath(): string { - return TRITONAI_FIRST_RUN_WORKSPACE; -} +export { + TRITONAI_CHATS_WORKSPACE, + TRITONAI_FIRST_RUN_PROMPT, + TRITONAI_FIRST_RUN_WORKSPACE, + isTritonAiCodeBrand, + isTritonAiChatsWorkspacePath, + isTritonAiWorkspacePath, + resolveTritonAiChatsWorkspacePath, + resolveTritonAiFirstRunWorkspacePath, +}; export function hasPriorProjectOrConversationState(input: { projectCount: number; diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index e440497ba42..895a03ae1aa 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -17,6 +17,7 @@ import { } from "../logicalProject"; import { selectProjectsAcrossEnvironments, useStore } from "../store"; import { createThreadSelectorByRef } from "../storeSelectors"; +import { isTritonAiChatsWorkspacePath } from "../tritonAiWorkspace"; import { resolveThreadRouteTarget } from "../threadRoutes"; import { useUiStateStore } from "../uiStateStore"; import { useSettings } from "./useSettings"; @@ -167,8 +168,11 @@ export function useHandleNewThread() { ); const projects = useStore(useShallow((store) => selectProjectsAcrossEnvironments(store))); const orderedProjects = useMemo(() => { + const visibleProjects = projects.filter( + (project) => !isTritonAiChatsWorkspacePath(project.cwd), + ); return orderItemsByPreferredIds({ - items: projects, + items: visibleProjects, preferredIds: projectOrder, getId: getProjectOrderKey, }); diff --git a/apps/web/src/tritonAiWorkspace.ts b/apps/web/src/tritonAiWorkspace.ts new file mode 100644 index 00000000000..42907fe917a --- /dev/null +++ b/apps/web/src/tritonAiWorkspace.ts @@ -0,0 +1,43 @@ +export const TRITONAI_FIRST_RUN_PROMPT = "How does TritonAI Code work, and how can it help me?"; +export const TRITONAI_FIRST_RUN_WORKSPACE = "~/TritonAI"; +export const TRITONAI_CHATS_WORKSPACE = "~/.agents/ucsd/state/tritonai-code/chats"; +export const TRITONAI_CHATS_PROJECT_TITLE = "Chats"; + +const TRITONAI_APP_BASE_NAME = "TritonAI Code"; + +export function isTritonAiCodeBrand(appBaseName: string): boolean { + return appBaseName.trim() === TRITONAI_APP_BASE_NAME; +} + +function normalizeWorkspacePath(path: string): string { + return path.trim().replaceAll("\\", "/").replace(/\/+$/g, "").toLowerCase(); +} + +function isPathAtHomeTritonAi(normalizedPath: string): boolean { + return ( + normalizedPath === "~/tritonai" || + /^\/(users|home)\/[^/]+\/tritonai$/i.test(normalizedPath) || + /^[a-z]:\/users\/[^/]+\/tritonai$/i.test(normalizedPath) + ); +} + +export function isTritonAiWorkspacePath(path: string): boolean { + return isPathAtHomeTritonAi(normalizeWorkspacePath(path)); +} + +export function isTritonAiChatsWorkspacePath(path: string): boolean { + const normalized = normalizeWorkspacePath(path); + return ( + normalized === "~/.agents/ucsd/state/tritonai-code/chats" || + /^\/(users|home)\/[^/]+\/\.agents\/ucsd\/state\/tritonai-code\/chats$/i.test(normalized) || + /^[a-z]:\/users\/[^/]+\/\.agents\/ucsd\/state\/tritonai-code\/chats$/i.test(normalized) + ); +} + +export function resolveTritonAiFirstRunWorkspacePath(): string { + return TRITONAI_FIRST_RUN_WORKSPACE; +} + +export function resolveTritonAiChatsWorkspacePath(): string { + return TRITONAI_CHATS_WORKSPACE; +} diff --git a/packages/contracts/package.json b/packages/contracts/package.json index f6b692796b1..623f4eeee36 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/contracts", - "version": "0.1.12", + "version": "0.1.13", "private": true, "files": [ "dist" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ce5f5ec66ed..29c3220103d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -412,8 +412,8 @@ importers: specifier: 0.9.4 version: 0.9.4(patch_hash=2b16019ce7ab61aec6478dd02f79ef468cc1d5c51e9d00764f7d2ab8167210c8) '@opencode-ai/sdk': - specifier: ^1.3.15 - version: 1.15.13 + specifier: ^1.17.8 + version: 1.17.8 '@pierre/diffs': specifier: 'catalog:' version: 1.3.0-beta.5(patch_hash=7cb6da88544119adda056b2f46f43956f99326227732da0b345081e285a6c53a)(@shikijs/themes@4.2.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -2985,8 +2985,8 @@ packages: '@open-draft/until@2.1.0': resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} - '@opencode-ai/sdk@1.15.13': - resolution: {integrity: sha512-4TwojIoQ8EG6/mVBuUVYZXiFcwNmiiytEnjnvyuvSJjGwFIlw2YIBFxtSVC3FbwwbwHT63teh1RHiQUUC4U5xw==} + '@opencode-ai/sdk@1.17.8': + resolution: {integrity: sha512-6MKmsj2ujZyL44jy+12dpwWYDYKPS9fUr+0wVQxaIlPYQ/eAt8T8T3QrybplJ5ZtHfZUX+esXZ02x2UYYm7oEw==} '@oslojs/encoding@1.1.0': resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==} @@ -12518,7 +12518,7 @@ snapshots: '@open-draft/until@2.1.0': {} - '@opencode-ai/sdk@1.15.13': + '@opencode-ai/sdk@1.17.8': dependencies: cross-spawn: 7.0.6