diff --git a/.github/workflows/upstream-sync-check.yml b/.github/workflows/upstream-sync-check.yml new file mode 100644 index 000000000000..4ecb22c408eb --- /dev/null +++ b/.github/workflows/upstream-sync-check.yml @@ -0,0 +1,95 @@ +name: Upstream Sync Check + +on: + schedule: + # Four times a day. The job only opens/updates an issue; it does not merge. + - cron: "0 */6 * * *" + workflow_dispatch: + +permissions: + contents: read + issues: write + +jobs: + check: + name: Check upstream drift + runs-on: ubuntu-latest + steps: + - name: Checkout fork + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Fetch upstream + run: | + git remote add upstream https://github.com/pingdotgg/t3code.git || git remote set-url upstream https://github.com/pingdotgg/t3code.git + git fetch --no-tags upstream main + + - name: Compute drift + id: drift + run: | + set -euo pipefail + read -r ahead behind < <(git rev-list --left-right --count HEAD...upstream/main) + upstream_sha="$(git rev-parse upstream/main)" + head_sha="$(git rev-parse HEAD)" + echo "ahead=$ahead" >> "$GITHUB_OUTPUT" + echo "behind=$behind" >> "$GITHUB_OUTPUT" + echo "upstream_sha=$upstream_sha" >> "$GITHUB_OUTPUT" + echo "head_sha=$head_sha" >> "$GITHUB_OUTPUT" + git log --oneline --max-count=20 HEAD..upstream/main > /tmp/upstream-commits.txt + + - name: Open or update sync issue + if: steps.drift.outputs.behind != '0' + uses: actions/github-script@v8 + with: + script: | + const fs = require("node:fs"); + const owner = context.repo.owner; + const repo = context.repo.repo; + const title = "Sync fork with upstream"; + const ahead = "${{ steps.drift.outputs.ahead }}"; + const behind = "${{ steps.drift.outputs.behind }}"; + const upstreamSha = "${{ steps.drift.outputs.upstream_sha }}"; + const headSha = "${{ steps.drift.outputs.head_sha }}"; + const commits = fs.readFileSync("/tmp/upstream-commits.txt", "utf8").trim(); + const body = [ + `The fork is currently ${ahead} commits ahead of and ${behind} commits behind \`pingdotgg/t3code:main\`.`, + "", + `Fork head: \`${headSha}\``, + `Upstream head: \`${upstreamSha}\``, + "", + "Run the local sync command from a clean checkout:", + "", + "```bash", + "pnpm run sync:upstream", + "```", + "", + "Recent upstream commits:", + "", + commits ? `\`\`\`text\n${commits}\n\`\`\`` : "_No commit list available._", + "", + "This scheduled workflow only reports drift. It does not auto-merge because upstream syncs can conflict with local harness work.", + ].join("\n"); + + const { data: issues } = await github.rest.issues.listForRepo({ + owner, + repo, + state: "open", + per_page: 100, + }); + const existing = issues.find((issue) => issue.title === title && !issue.pull_request); + if (existing) { + await github.rest.issues.update({ + owner, + repo, + issue_number: existing.number, + body, + }); + } else { + await github.rest.issues.create({ + owner, + repo, + title, + body, + }); + } diff --git a/apps/desktop/scripts/dev-electron.mjs b/apps/desktop/scripts/dev-electron.mjs index c28d5ec358b6..b00c3bcca03f 100644 --- a/apps/desktop/scripts/dev-electron.mjs +++ b/apps/desktop/scripts/dev-electron.mjs @@ -44,14 +44,6 @@ await waitForResources({ tcpPort: port, }); -const childEnv = { ...process.env }; -delete childEnv.ELECTRON_RUN_AS_NODE; -const devProtocolClient = resolveDevProtocolClient(); -if (devProtocolClient) { - childEnv.T3CODE_DESKTOP_APP_USER_MODEL_ID = devProtocolClient.appBundleId; - childEnv.T3CODE_DESKTOP_PROTOCOL_REGISTRATION_MANAGED = "1"; -} - let shuttingDown = false; let restartTimer = null; let currentApp = null; @@ -75,6 +67,37 @@ function cleanupStaleDevApps() { NodeChildProcess.spawnSync("pkill", ["-f", "--", `--t3code-dev-root=${desktopDir}`], { stdio: "ignore", }); + NodeChildProcess.spawnSync( + "pkill", + ["-f", "--", `${NodePath.join(desktopDir, ".electron-runtime")}/T3 Code (Dev).app`], + { + stdio: "ignore", + }, + ); +} + +function isShellScript(path) { + try { + const buffer = Buffer.alloc(2); + const fd = NodeFS.openSync(path, "r"); + try { + NodeFS.readSync(fd, buffer, 0, buffer.length, 0); + return buffer[0] === 0x23 && buffer[1] === 0x21; + } finally { + NodeFS.closeSync(fd); + } + } catch { + return false; + } +} + +cleanupStaleDevApps(); +const childEnv = { ...process.env }; +delete childEnv.ELECTRON_RUN_AS_NODE; +const devProtocolClient = resolveDevProtocolClient(); +if (devProtocolClient) { + childEnv.T3CODE_DESKTOP_APP_USER_MODEL_ID = devProtocolClient.appBundleId; + childEnv.T3CODE_DESKTOP_PROTOCOL_REGISTRATION_MANAGED = "1"; } function startApp() { @@ -85,11 +108,12 @@ function startApp() { const electronArgs = remoteDebuggingPort ? [`--remote-debugging-port=${remoteDebuggingPort}`] : []; - const launchArgs = devProtocolClient - ? electronArgs - : [...electronArgs, `--t3code-dev-root=${desktopDir}`, "dist-electron/main.cjs"]; - const electronCommand = resolveElectronLaunchCommand(launchArgs); - const app = NodeChildProcess.spawn(electronCommand.electronPath, electronCommand.args, { + const electronCommand = resolveElectronLaunchCommand(electronArgs); + const launchArgs = + devProtocolClient && isShellScript(electronCommand.electronPath) + ? electronCommand.args + : [...electronCommand.args, `--t3code-dev-root=${desktopDir}`, "dist-electron/main.cjs"]; + const app = NodeChildProcess.spawn(electronCommand.electronPath, launchArgs, { cwd: desktopDir, env: childEnv, stdio: "inherit", @@ -233,7 +257,6 @@ async function shutdown(exitCode) { } startWatchers(); -cleanupStaleDevApps(); startApp(); process.once("SIGINT", () => { diff --git a/apps/desktop/scripts/electron-launcher.mjs b/apps/desktop/scripts/electron-launcher.mjs index 69df02fb80d1..e7e0efcb088a 100644 --- a/apps/desktop/scripts/electron-launcher.mjs +++ b/apps/desktop/scripts/electron-launcher.mjs @@ -20,7 +20,7 @@ export const APP_BUNDLE_ID = isDevelopment ? `com.t3tools.t3code.dev.${devBundleIdSuffix || "local"}` : "com.t3tools.t3code"; const APP_PROTOCOL_SCHEMES = isDevelopment ? ["t3code-dev"] : ["t3code"]; -const LAUNCHER_VERSION = 12; +const LAUNCHER_VERSION = 13; const defaultIconPath = NodePath.join(desktopDir, "resources", "icon.icns"); const developmentMacIconPngPath = NodePath.join( repoRoot, @@ -100,9 +100,8 @@ function shellSingleQuote(value) { return `'${value.replaceAll("'", "'\\''")}'`; } -function writeDevelopmentLauncherScript(targetBinaryPath, electronBinaryPath) { - const mainEntryPath = NodePath.join(desktopDir, "dist-electron", "main.cjs"); - const envEntries = [ +function resolveDevelopmentLauncherEnvEntries() { + return [ ["VITE_DEV_SERVER_URL", process.env.VITE_DEV_SERVER_URL], ["T3CODE_PORT", process.env.T3CODE_PORT], ["T3CODE_HOME", process.env.T3CODE_HOME], @@ -111,6 +110,11 @@ function writeDevelopmentLauncherScript(targetBinaryPath, electronBinaryPath) { ["T3CODE_OTLP_EXPORT_INTERVAL_MS", process.env.T3CODE_OTLP_EXPORT_INTERVAL_MS], ["T3CODE_DESKTOP_APP_USER_MODEL_ID", APP_BUNDLE_ID], ].filter((entry) => typeof entry[1] === "string" && entry[1].trim().length > 0); +} + +function writeDevelopmentLauncherScript(targetBinaryPath, electronBinaryPath) { + const mainEntryPath = NodePath.join(desktopDir, "dist-electron", "main.cjs"); + const envEntries = resolveDevelopmentLauncherEnvEntries(); NodeFS.writeFileSync( targetBinaryPath, [ @@ -278,6 +282,9 @@ function buildMacLauncher(electronBinaryPath) { iconMtimeMs: NodeFS.statSync(iconPath).mtimeMs, appBundleId: APP_BUNDLE_ID, appProtocolSchemes: APP_PROTOCOL_SCHEMES, + ...(isDevelopment + ? { launcherEnvEntries: Object.fromEntries(resolveDevelopmentLauncherEnvEntries()) } + : {}), }; const currentMetadata = readJson(metadataPath); diff --git a/apps/desktop/src/app/DesktopBackendOutputLog.ts b/apps/desktop/src/app/DesktopBackendOutputLog.ts index cad83229deb1..67cbf1015d31 100644 --- a/apps/desktop/src/app/DesktopBackendOutputLog.ts +++ b/apps/desktop/src/app/DesktopBackendOutputLog.ts @@ -11,6 +11,7 @@ import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; +import { ensureConsoleStreamGuard, isIgnorableConsoleStreamError } from "./DesktopConsole.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; export const DESKTOP_LOG_FILE_MAX_BYTES = 10 * 1024 * 1024; @@ -267,12 +268,18 @@ const writeDevelopmentConsoleOutput = ( streamName: "stdout" | "stderr", chunk: Uint8Array, ): Effect.Effect => - Effect.try({ - try: () => { + Effect.suspend(() => { + try { const output = streamName === "stderr" ? process.stderr : process.stdout; + ensureConsoleStreamGuard(output); + if (!output.writable || output.destroyed || output.writableEnded) return Effect.void; output.write(chunk); - }, - catch: (cause) => new DesktopBackendConsoleWriteError({ streamName, cause }), + return Effect.void; + } catch (cause) { + return isIgnorableConsoleStreamError(cause) + ? Effect.void + : Effect.fail(new DesktopBackendConsoleWriteError({ streamName, cause })); + } }).pipe( Effect.catchTags({ DesktopBackendConsoleWriteError: (error) => Effect.logError(error.message, { error }), diff --git a/apps/desktop/src/app/DesktopConsole.test.ts b/apps/desktop/src/app/DesktopConsole.test.ts new file mode 100644 index 000000000000..f1f3418e688d --- /dev/null +++ b/apps/desktop/src/app/DesktopConsole.test.ts @@ -0,0 +1,39 @@ +import { assert, describe, it } from "@effect/vitest"; + +import "./DesktopConsole.ts"; + +describe("DesktopConsole", () => { + it("ignores EPIPE thrown by console stdout writes", () => { + const originalWrite = process.stdout.write; + process.stdout.write = function () { + throw Object.assign(new Error("write EPIPE"), { code: "EPIPE" }); + } as typeof process.stdout.write; + + try { + const guardedLog: (...data: Array) => void = console["log"].bind(console); + assert.doesNotThrow(() => guardedLog("ignored broken stdout")); + } finally { + process.stdout.write = originalWrite; + } + }); + + it("ignores EPIPE emitted by console stdout writes", async () => { + const originalWrite = process.stdout.write; + process.stdout.write = function (...args: Parameters) { + const error = Object.assign(new Error("write EPIPE"), { code: "EPIPE" }); + queueMicrotask(() => process.stdout.emit("error", error)); + for (const arg of args) { + if (typeof arg === "function") arg(); + } + return false; + } as typeof process.stdout.write; + + try { + const guardedLog: (...data: Array) => void = console["log"].bind(console); + guardedLog("ignored broken stdout"); + await new Promise((resolve) => setImmediate(resolve)); + } finally { + process.stdout.write = originalWrite; + } + }); +}); diff --git a/apps/desktop/src/app/DesktopConsole.ts b/apps/desktop/src/app/DesktopConsole.ts new file mode 100644 index 000000000000..cabf89a4b2c9 --- /dev/null +++ b/apps/desktop/src/app/DesktopConsole.ts @@ -0,0 +1,40 @@ +const guardedConsoleStreams = new WeakSet(); + +export function isIgnorableConsoleStreamError(cause: unknown): boolean { + if (!(cause instanceof Error)) return false; + const errorCode = "code" in cause && typeof cause.code === "string" ? cause.code : undefined; + return errorCode === "EPIPE" || errorCode === "ERR_STREAM_DESTROYED"; +} + +export function ensureConsoleStreamGuard(output: NodeJS.WriteStream): void { + if (guardedConsoleStreams.has(output)) return; + guardedConsoleStreams.add(output); + output.on("error", (cause) => { + if (isIgnorableConsoleStreamError(cause)) return; + throw cause; + }); +} + +function guardConsoleMethod) => void>(method: T): T { + return ((...args: Parameters) => { + try { + method(...args); + } catch (cause) { + if (!isIgnorableConsoleStreamError(cause)) { + throw cause; + } + } + }) as T; +} + +export function installDesktopConsoleGuards(): void { + ensureConsoleStreamGuard(process.stdout); + ensureConsoleStreamGuard(process.stderr); + console.log = guardConsoleMethod(console.log.bind(console)); + console.info = guardConsoleMethod(console.info.bind(console)); + console.warn = guardConsoleMethod(console.warn.bind(console)); + console.error = guardConsoleMethod(console.error.bind(console)); + console.debug = guardConsoleMethod(console.debug.bind(console)); +} + +installDesktopConsoleGuards(); diff --git a/apps/desktop/src/app/DesktopObservability.test.ts b/apps/desktop/src/app/DesktopObservability.test.ts index a78de48d5e19..c75be714b46e 100644 --- a/apps/desktop/src/app/DesktopObservability.test.ts +++ b/apps/desktop/src/app/DesktopObservability.test.ts @@ -159,4 +159,52 @@ describe("DesktopObservability", () => { Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici)), ), ); + + it.effect("ignores a broken development console pipe while persisting backend child output", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-backend-output-epipe-test-", + }); + const environmentLayer = makeEnvironmentLayer(baseDir); + const logPath = yield* Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + return environment.path.join(environment.logDir, "server-child.log"); + }).pipe(Effect.provide(environmentLayer)); + + const originalWrite = process.stdout.write; + process.stdout.write = function (...args: Parameters) { + const error = Object.assign(new Error("write EPIPE"), { code: "EPIPE" }); + queueMicrotask(() => process.stdout.emit("error", error)); + for (const arg of args) { + if (typeof arg === "function") arg(); + } + return false; + } as typeof process.stdout.write; + + try { + yield* Effect.gen(function* () { + const outputLog = yield* DesktopObservability.DesktopBackendOutputLog; + yield* outputLog.writeOutputChunk("stdout", new TextEncoder().encode("hello server\n")); + yield* Effect.promise(() => new Promise((resolve) => setImmediate(resolve))); + }).pipe( + Effect.annotateLogs({ runId: "test-run" }), + Effect.provide(DesktopObservability.layer.pipe(Layer.provideMerge(environmentLayer))), + ); + } finally { + process.stdout.write = originalWrite; + } + + const log = yield* fileSystem.readFileString(logPath); + const lines = log.trimEnd().split("\n"); + const output = yield* decodeDesktopBackendChildLogRecord(lines[0] ?? ""); + + assert.equal(output.message, "backend child process output"); + assert.equal(output.annotations.stream, "stdout"); + assert.equal(output.annotations.text, "hello server\n"); + }).pipe( + Effect.scoped, + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici)), + ), + ); }); diff --git a/apps/desktop/src/electron/ElectronProtocol.test.ts b/apps/desktop/src/electron/ElectronProtocol.test.ts index 56fe009fee22..b0e209c36390 100644 --- a/apps/desktop/src/electron/ElectronProtocol.test.ts +++ b/apps/desktop/src/electron/ElectronProtocol.test.ts @@ -3,15 +3,22 @@ import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import { beforeEach, vi } from "vite-plus/test"; -const { handleMock, netFetchMock, unhandleMock } = vi.hoisted(() => ({ - handleMock: vi.fn(), - netFetchMock: vi.fn(), - unhandleMock: vi.fn(), -})); +const { handleMock, netFetchMock, registerSchemesAsPrivilegedMock, unhandleMock } = vi.hoisted( + () => ({ + handleMock: vi.fn(), + netFetchMock: vi.fn(), + registerSchemesAsPrivilegedMock: vi.fn(), + unhandleMock: vi.fn(), + }), +); vi.mock("electron", () => ({ net: { fetch: netFetchMock }, - protocol: { handle: handleMock, unhandle: unhandleMock }, + protocol: { + handle: handleMock, + registerSchemesAsPrivileged: registerSchemesAsPrivilegedMock, + unhandle: unhandleMock, + }, })); import * as ElectronProtocol from "./ElectronProtocol.ts"; @@ -23,6 +30,37 @@ describe("ElectronProtocol", () => { unhandleMock.mockReset(); }); + it("registers desktop URL schemes with browser-compatible privileges before app ready", () => { + assert.deepEqual(registerSchemesAsPrivilegedMock.mock.calls, [ + [ + [ + { + scheme: "t3code", + privileges: { + standard: true, + secure: true, + supportFetchAPI: true, + corsEnabled: true, + stream: true, + codeCache: true, + }, + }, + { + scheme: "t3code-dev", + privileges: { + standard: true, + secure: true, + supportFetchAPI: true, + corsEnabled: true, + stream: true, + codeCache: true, + }, + }, + ], + ], + ]); + }); + it.effect("proxies the stable renderer origin to the current app server", () => Effect.gen(function* () { let handler: ((request: Request) => Promise) | undefined; @@ -43,7 +81,16 @@ describe("ElectronProtocol", () => { assert.isDefined(handler); const response = yield* Effect.promise(() => - handler!(new Request("t3code-dev://app/api/health?verbose=1")), + handler!( + new Request("t3code-dev://app/api/health?verbose=1", { + headers: { + Accept: "application/json", + Origin: "t3code-dev://app", + Referer: "t3code-dev://app/", + "Sec-Fetch-Site": "same-origin", + }, + }), + ), ); assert.equal(yield* Effect.promise(() => response.text()), "ok"); assert.include( @@ -70,6 +117,9 @@ describe("ElectronProtocol", () => { ["t3code-dev"], ); assert.equal(netFetchMock.mock.calls[0]?.[0], "http://127.0.0.1:3773/api/health?verbose=1"); + assert.deepEqual(Array.from(netFetchMock.mock.calls[0]?.[1]?.headers ?? []), [ + ["accept", "application/json"], + ]); assert.deepEqual(unhandleMock.mock.calls, [["t3code-dev"]]); }).pipe(Effect.provide(ElectronProtocol.layer)), ); diff --git a/apps/desktop/src/electron/ElectronProtocol.ts b/apps/desktop/src/electron/ElectronProtocol.ts index 757c26178d0d..51ed250de026 100644 --- a/apps/desktop/src/electron/ElectronProtocol.ts +++ b/apps/desktop/src/electron/ElectronProtocol.ts @@ -11,6 +11,31 @@ export const DESKTOP_HOST = "app"; export const DESKTOP_PRODUCTION_SCHEME = "t3code"; export const DESKTOP_DEVELOPMENT_SCHEME = "t3code-dev"; +Electron.protocol.registerSchemesAsPrivileged([ + { + scheme: DESKTOP_PRODUCTION_SCHEME, + privileges: { + standard: true, + secure: true, + supportFetchAPI: true, + corsEnabled: true, + stream: true, + codeCache: true, + }, + }, + { + scheme: DESKTOP_DEVELOPMENT_SCHEME, + privileges: { + standard: true, + secure: true, + supportFetchAPI: true, + corsEnabled: true, + stream: true, + codeCache: true, + }, + }, +]); + export function getDesktopScheme(isDevelopment: boolean): string { return isDevelopment ? DESKTOP_DEVELOPMENT_SCHEME : DESKTOP_PRODUCTION_SCHEME; } @@ -103,6 +128,25 @@ function withContentSecurityPolicy(response: Response, policy: string): Response }); } +const PROXIED_REQUEST_HEADERS = new Set([ + "accept", + "accept-language", + "content-type", + "if-modified-since", + "if-none-match", + "range", +]); + +function makeProxyRequestHeaders(headers: Headers): Headers { + const output = new Headers(); + for (const [name, value] of headers) { + if (PROXIED_REQUEST_HEADERS.has(name.toLowerCase())) { + output.set(name, value); + } + } + return output; +} + async function proxyRequest( request: Request, targetOrigin: URL, @@ -116,14 +160,18 @@ async function proxyRequest( const targetUrl = new URL(`${requestUrl.pathname}${requestUrl.search}`, targetOrigin); const init: RequestInit = { method: request.method, - headers: request.headers, + headers: makeProxyRequestHeaders(request.headers), }; if (request.method !== "GET" && request.method !== "HEAD") { init.body = request.body; (init as RequestInit & { duplex: "half" }).duplex = "half"; } - const response = await Electron.net.fetch(targetUrl.toString(), init); - return withContentSecurityPolicy(response, contentSecurityPolicy); + try { + const response = await Electron.net.fetch(targetUrl.toString(), init); + return withContentSecurityPolicy(response, contentSecurityPolicy); + } catch { + return new Response("Desktop protocol proxy request failed.", { status: 502 }); + } } export const make = Effect.gen(function* () { diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index b88eb18e57f9..82e43e27b540 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -1,3 +1,5 @@ +import "./app/DesktopConsole.ts"; + import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"; import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; diff --git a/apps/server/src/bin.test.ts b/apps/server/src/bin.test.ts index 5c713ff2be78..cbe2e792ed01 100644 --- a/apps/server/src/bin.test.ts +++ b/apps/server/src/bin.test.ts @@ -31,6 +31,7 @@ import { persistServerRuntimeState, } from "./serverRuntimeState.ts"; import * as WorkspacePaths from "./workspace/WorkspacePaths.ts"; +import * as WorkspaceContext from "./workspace/WorkspaceContext.ts"; import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import { environmentAuthenticatedAuthLayer } from "./auth/http.ts"; @@ -94,6 +95,7 @@ const makeProjectPersistenceLayer = (config: ServerConfig.ServerConfig["Service" Layer.provideMerge(SqlitePersistenceLayerLive), ), WorkspacePaths.layer, + WorkspaceContext.layer.pipe(Layer.provide(WorkspacePaths.layer)), ).pipe(Layer.provideMerge(NodeServices.layer), Layer.provide(ServerConfig.layer(config))); const readPersistedSnapshot = (baseDir: string) => diff --git a/apps/server/src/cli/project.ts b/apps/server/src/cli/project.ts index 710d39c4c290..b8f6f5288c29 100644 --- a/apps/server/src/cli/project.ts +++ b/apps/server/src/cli/project.ts @@ -36,6 +36,7 @@ import { readPersistedServerRuntimeState, } from "../serverRuntimeState.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; +import * as WorkspaceContext from "../workspace/WorkspaceContext.ts"; import { type CliAuthLocationFlags, projectLocationFlags, resolveCliAuthConfig } from "./config.ts"; type ProjectMutationTarget = { @@ -200,6 +201,7 @@ const projectCommandUuid = Crypto.Crypto.pipe( const ProjectCliRuntimeLive = Layer.mergeAll( WorkspacePaths.layer, + WorkspaceContext.layer.pipe(Layer.provide(WorkspacePaths.layer)), OrchestrationLayerLive.pipe( Layer.provideMerge(RepositoryIdentityResolver.layer), Layer.provideMerge(SqlitePersistenceLayerLive), diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index ce9b498cb1f1..32352b52b888 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -44,13 +44,16 @@ import { browserApiCorsAllowedHeaders, browserApiCorsAllowedMethods } from "./ht const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces"; const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]); +const DESKTOP_APP_ORIGINS = ["t3code://app", "t3code-dev://app"] as const; export const browserApiCorsLayer = Layer.unwrap( Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; const devOrigin = config.devUrl?.origin; return HttpRouter.cors({ - ...(devOrigin ? { allowedOrigins: [devOrigin], credentials: true } : {}), + ...(devOrigin + ? { allowedOrigins: [devOrigin, ...DESKTOP_APP_ORIGINS], credentials: true } + : {}), allowedMethods: browserApiCorsAllowedMethods, allowedHeaders: browserApiCorsAllowedHeaders, maxAge: 600, diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 0999000ed4f9..1024c2499dbe 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -173,6 +173,188 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { } }), ); + + it.effect("persists workspace identity and updates workspace metadata on branch rename", () => + Effect.gen(function* () { + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + const sql = yield* SqlClient.SqlClient; + const createdAt = "2026-01-01T00:00:00.000Z"; + const renamedAt = "2026-01-01T00:05:00.000Z"; + + yield* eventStore.append({ + type: "thread.created", + eventId: EventId.make("evt-workspace-thread-created"), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-workspace"), + occurredAt: createdAt, + commandId: CommandId.make("cmd-workspace-thread-created"), + causationEventId: null, + correlationId: CommandId.make("cmd-workspace-thread-created"), + metadata: {}, + payload: { + threadId: ThreadId.make("thread-workspace"), + projectId: ProjectId.make("project-workspace"), + title: "Workspace thread", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + runtimeMode: "full-access", + branch: "feature/old-name", + worktreePath: "/repo/.t3/worktrees/checks", + createdAt, + updatedAt: createdAt, + }, + }); + yield* eventStore.append({ + type: "thread.meta-updated", + eventId: EventId.make("evt-workspace-thread-renamed"), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-workspace"), + occurredAt: renamedAt, + commandId: CommandId.make("cmd-workspace-thread-renamed"), + causationEventId: null, + correlationId: CommandId.make("cmd-workspace-thread-renamed"), + metadata: {}, + payload: { + threadId: ThreadId.make("thread-workspace"), + branch: "feature/new-name", + updatedAt: renamedAt, + }, + }); + + yield* projectionPipeline.bootstrap; + + const rows = yield* sql<{ + readonly threadWorkspaceId: string | null; + readonly workspaceId: string; + readonly branch: string | null; + readonly worktreePath: string | null; + }>` + SELECT + threads.workspace_id AS "threadWorkspaceId", + workspaces.workspace_id AS "workspaceId", + workspaces.branch, + workspaces.worktree_path AS "worktreePath" + FROM projection_threads AS threads + INNER JOIN projection_workspaces AS workspaces + ON workspaces.workspace_id = threads.workspace_id + WHERE threads.thread_id = 'thread-workspace' + `; + + assert.deepEqual(rows, [ + { + threadWorkspaceId: "project-workspace:workspace:worktree:/repo/.t3/worktrees/checks", + workspaceId: "project-workspace:workspace:worktree:/repo/.t3/worktrees/checks", + branch: "feature/new-name", + worktreePath: "/repo/.t3/worktrees/checks", + }, + ]); + }), + ); + + it.effect( + "moves one thread to a worktree workspace without relabeling sibling branch threads", + () => + Effect.gen(function* () { + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + const sql = yield* SqlClient.SqlClient; + const createdAt = "2026-01-01T00:00:00.000Z"; + const movedAt = "2026-01-01T00:05:00.000Z"; + + for (const threadId of ["thread-branch-a", "thread-branch-b"] as const) { + yield* eventStore.append({ + type: "thread.created", + eventId: EventId.make(`evt-${threadId}-created`), + aggregateKind: "thread", + aggregateId: ThreadId.make(threadId), + occurredAt: createdAt, + commandId: CommandId.make(`cmd-${threadId}-created`), + causationEventId: null, + correlationId: CommandId.make(`cmd-${threadId}-created`), + metadata: {}, + payload: { + threadId: ThreadId.make(threadId), + projectId: ProjectId.make("project-workspace-move"), + title: threadId, + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + runtimeMode: "full-access", + branch: "feat/enhancements", + worktreePath: null, + createdAt, + updatedAt: createdAt, + }, + }); + } + + yield* eventStore.append({ + type: "thread.meta-updated", + eventId: EventId.make("evt-thread-branch-a-moved"), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-branch-a"), + occurredAt: movedAt, + commandId: CommandId.make("cmd-thread-branch-a-moved"), + causationEventId: null, + correlationId: CommandId.make("cmd-thread-branch-a-moved"), + metadata: {}, + payload: { + threadId: ThreadId.make("thread-branch-a"), + branch: "t3code/handle-greeting", + worktreePath: "/repo/.t3/worktrees/handle-greeting", + updatedAt: movedAt, + }, + }); + + yield* projectionPipeline.bootstrap; + + const rows = yield* sql<{ + readonly threadId: string; + readonly threadWorkspaceId: string | null; + readonly threadBranch: string | null; + readonly threadWorktreePath: string | null; + readonly workspaceBranch: string | null; + readonly workspaceWorktreePath: string | null; + }>` + SELECT + threads.thread_id AS "threadId", + threads.workspace_id AS "threadWorkspaceId", + threads.branch AS "threadBranch", + threads.worktree_path AS "threadWorktreePath", + workspaces.branch AS "workspaceBranch", + workspaces.worktree_path AS "workspaceWorktreePath" + FROM projection_threads AS threads + INNER JOIN projection_workspaces AS workspaces + ON workspaces.workspace_id = threads.workspace_id + WHERE threads.thread_id IN ('thread-branch-a', 'thread-branch-b') + ORDER BY threads.thread_id ASC + `; + + assert.deepEqual(rows, [ + { + threadId: "thread-branch-a", + threadWorkspaceId: + "project-workspace-move:workspace:worktree:/repo/.t3/worktrees/handle-greeting", + threadBranch: "t3code/handle-greeting", + threadWorktreePath: "/repo/.t3/worktrees/handle-greeting", + workspaceBranch: "t3code/handle-greeting", + workspaceWorktreePath: "/repo/.t3/worktrees/handle-greeting", + }, + { + threadId: "thread-branch-b", + threadWorkspaceId: "project-workspace-move:workspace:branch:feat/enhancements", + threadBranch: "feat/enhancements", + threadWorktreePath: null, + workspaceBranch: "feat/enhancements", + workspaceWorktreePath: null, + }, + ]); + }), + ); }); it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-base-")))( diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index f12df850941f..2f7d7fb3c51d 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -3,6 +3,7 @@ import { type ChatAttachment, type OrchestrationEvent, type OrchestrationSessionStatus, + type ProjectId, ThreadId, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; @@ -29,6 +30,7 @@ import { ProjectionThreadProposedPlanRepository, } from "../../persistence/Services/ProjectionThreadProposedPlans.ts"; import { ProjectionThreadSessionRepository } from "../../persistence/Services/ProjectionThreadSessions.ts"; +import { ProjectionWorkspaceRepository } from "../../persistence/Services/ProjectionWorkspaces.ts"; import { type ProjectionTurn, ProjectionTurnRepository, @@ -41,6 +43,7 @@ import { ProjectionThreadActivityRepositoryLive } from "../../persistence/Layers import { ProjectionThreadMessageRepositoryLive } from "../../persistence/Layers/ProjectionThreadMessages.ts"; import { ProjectionThreadProposedPlanRepositoryLive } from "../../persistence/Layers/ProjectionThreadProposedPlans.ts"; import { ProjectionThreadSessionRepositoryLive } from "../../persistence/Layers/ProjectionThreadSessions.ts"; +import { ProjectionWorkspaceRepositoryLive } from "../../persistence/Layers/ProjectionWorkspaces.ts"; import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/ProjectionTurns.ts"; import { ProjectionThreadRepositoryLive } from "../../persistence/Layers/ProjectionThreads.ts"; import { ServerConfig } from "../../config.ts"; @@ -48,6 +51,10 @@ import { OrchestrationProjectionPipeline, type OrchestrationProjectionPipelineShape, } from "../Services/ProjectionPipeline.ts"; +import { + deriveThreadWorkspaceRecord, + resolveThreadWorkspaceRecordForPatch, +} from "../threadWorkspaceIdentity.ts"; import { attachmentRelativePath, parseAttachmentIdFromRelativePath, @@ -478,6 +485,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti const projectionThreadProposedPlanRepository = yield* ProjectionThreadProposedPlanRepository; const projectionThreadActivityRepository = yield* ProjectionThreadActivityRepository; const projectionThreadSessionRepository = yield* ProjectionThreadSessionRepository; + const projectionWorkspaceRepository = yield* ProjectionWorkspaceRepository; const projectionTurnRepository = yield* ProjectionTurnRepository; const projectionPendingApprovalRepository = yield* ProjectionPendingApprovalRepository; @@ -589,14 +597,43 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti }); }); + const upsertWorkspaceForThread = Effect.fn("ProjectionPipeline.upsertWorkspaceForThread")( + function* (input: { + readonly projectId: ProjectId; + readonly workspaceId?: string | null | undefined; + readonly branch: string | null; + readonly worktreePath: string | null; + readonly createdAt: string; + readonly updatedAt: string; + }) { + const workspace = deriveThreadWorkspaceRecord(input); + yield* projectionWorkspaceRepository.upsert({ + ...workspace, + createdAt: input.createdAt, + updatedAt: input.updatedAt, + archivedAt: null, + deletedAt: null, + }); + return workspace; + }, + ); + const applyThreadsProjection: ProjectorDefinition["apply"] = Effect.fn( "applyThreadsProjection", )(function* (event, attachmentSideEffects) { switch (event.type) { case "thread.created": + const workspace = yield* upsertWorkspaceForThread({ + projectId: event.payload.projectId, + branch: event.payload.branch, + worktreePath: event.payload.worktreePath, + createdAt: event.payload.createdAt, + updatedAt: event.payload.updatedAt, + }); yield* projectionThreadRepository.upsert({ threadId: event.payload.threadId, projectId: event.payload.projectId, + workspaceId: workspace.workspaceId, title: event.payload.title, modelSelection: event.payload.modelSelection, runtimeMode: event.payload.runtimeMode, @@ -658,12 +695,39 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti ...(event.payload.modelSelection !== undefined ? { modelSelection: event.payload.modelSelection } : {}), - ...(event.payload.branch !== undefined ? { branch: event.payload.branch } : {}), - ...(event.payload.worktreePath !== undefined - ? { worktreePath: event.payload.worktreePath } - : {}), + ...(() => { + const threadWorkspaceUpdate = resolveThreadWorkspaceRecordForPatch({ + projectId: existingRow.value.projectId, + thread: existingRow.value, + patch: event.payload, + }); + return { + ...threadWorkspaceUpdate.patch, + ...(threadWorkspaceUpdate.workspace + ? { workspaceId: threadWorkspaceUpdate.workspace.workspaceId } + : {}), + }; + })(), updatedAt: event.payload.updatedAt, }); + { + const updatedRow = yield* projectionThreadRepository.getById({ + threadId: event.payload.threadId, + }); + if (Option.isSome(updatedRow)) { + const workspace = yield* upsertWorkspaceForThread({ + projectId: updatedRow.value.projectId, + branch: updatedRow.value.branch, + worktreePath: updatedRow.value.worktreePath, + createdAt: updatedRow.value.createdAt, + updatedAt: event.payload.updatedAt, + }); + yield* projectionThreadRepository.upsert({ + ...updatedRow.value, + workspaceId: workspace.workspaceId, + }); + } + } return; } @@ -1596,6 +1660,7 @@ export const OrchestrationProjectionPipelineLive = Layer.effect( Layer.provideMerge(ProjectionThreadProposedPlanRepositoryLive), Layer.provideMerge(ProjectionThreadActivityRepositoryLive), Layer.provideMerge(ProjectionThreadSessionRepositoryLive), + Layer.provideMerge(ProjectionWorkspaceRepositoryLive), Layer.provideMerge(ProjectionTurnRepositoryLive), Layer.provideMerge(ProjectionPendingApprovalRepositoryLive), Layer.provideMerge(ProjectionStateRepositoryLive), diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index e36db35b1074..a402a773206e 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -253,6 +253,30 @@ function mapProposedPlanRow( }; } +function mapThreadWorkspaceFields(row: Schema.Schema.Type): { + readonly workspaceId?: NonNullable< + Schema.Schema.Type["workspaceId"] + >; + readonly workspaceBranch?: string | null; + readonly workspaceWorktreePath?: string | null; + readonly workspaceLocalCheckout?: boolean; +} { + if (row.workspaceId === null) { + return {}; + } + + return { + workspaceId: row.workspaceId, + ...(row.workspaceBranch !== undefined ? { workspaceBranch: row.workspaceBranch } : {}), + ...(row.workspaceWorktreePath !== undefined + ? { workspaceWorktreePath: row.workspaceWorktreePath } + : {}), + ...(row.workspaceLocalCheckout != null + ? { workspaceLocalCheckout: row.workspaceLocalCheckout > 0 } + : {}), + }; +} + function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { return (cause: unknown): ProjectionRepositoryError => Schema.isSchemaError(cause) @@ -321,25 +345,31 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { execute: () => sql` SELECT - thread_id AS "threadId", - project_id AS "projectId", - title, - model_selection_json AS "modelSelection", - runtime_mode AS "runtimeMode", - interaction_mode AS "interactionMode", - branch, - worktree_path AS "worktreePath", - latest_turn_id AS "latestTurnId", - created_at AS "createdAt", - updated_at AS "updatedAt", - archived_at AS "archivedAt", - latest_user_message_at AS "latestUserMessageAt", - pending_approval_count AS "pendingApprovalCount", - pending_user_input_count AS "pendingUserInputCount", - has_actionable_proposed_plan AS "hasActionableProposedPlan", - deleted_at AS "deletedAt" - FROM projection_threads - ORDER BY created_at ASC, thread_id ASC + threads.thread_id AS "threadId", + threads.project_id AS "projectId", + threads.workspace_id AS "workspaceId", + workspaces.branch AS "workspaceBranch", + workspaces.worktree_path AS "workspaceWorktreePath", + workspaces.local_checkout AS "workspaceLocalCheckout", + threads.title, + threads.model_selection_json AS "modelSelection", + threads.runtime_mode AS "runtimeMode", + threads.interaction_mode AS "interactionMode", + threads.branch, + threads.worktree_path AS "worktreePath", + threads.latest_turn_id AS "latestTurnId", + threads.created_at AS "createdAt", + threads.updated_at AS "updatedAt", + threads.archived_at AS "archivedAt", + threads.latest_user_message_at AS "latestUserMessageAt", + threads.pending_approval_count AS "pendingApprovalCount", + threads.pending_user_input_count AS "pendingUserInputCount", + threads.has_actionable_proposed_plan AS "hasActionableProposedPlan", + threads.deleted_at AS "deletedAt" + FROM projection_threads AS threads + LEFT JOIN projection_workspaces AS workspaces + ON workspaces.workspace_id = threads.workspace_id + ORDER BY threads.created_at ASC, threads.thread_id ASC `, }); @@ -349,27 +379,33 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { execute: () => sql` SELECT - thread_id AS "threadId", - project_id AS "projectId", - title, - model_selection_json AS "modelSelection", - runtime_mode AS "runtimeMode", - interaction_mode AS "interactionMode", - branch, - worktree_path AS "worktreePath", - latest_turn_id AS "latestTurnId", - created_at AS "createdAt", - updated_at AS "updatedAt", - archived_at AS "archivedAt", - latest_user_message_at AS "latestUserMessageAt", - pending_approval_count AS "pendingApprovalCount", - pending_user_input_count AS "pendingUserInputCount", - has_actionable_proposed_plan AS "hasActionableProposedPlan", - deleted_at AS "deletedAt" - FROM projection_threads - WHERE deleted_at IS NULL - AND archived_at IS NULL - ORDER BY project_id ASC, created_at ASC, thread_id ASC + threads.thread_id AS "threadId", + threads.project_id AS "projectId", + threads.workspace_id AS "workspaceId", + workspaces.branch AS "workspaceBranch", + workspaces.worktree_path AS "workspaceWorktreePath", + workspaces.local_checkout AS "workspaceLocalCheckout", + threads.title, + threads.model_selection_json AS "modelSelection", + threads.runtime_mode AS "runtimeMode", + threads.interaction_mode AS "interactionMode", + threads.branch, + threads.worktree_path AS "worktreePath", + threads.latest_turn_id AS "latestTurnId", + threads.created_at AS "createdAt", + threads.updated_at AS "updatedAt", + threads.archived_at AS "archivedAt", + threads.latest_user_message_at AS "latestUserMessageAt", + threads.pending_approval_count AS "pendingApprovalCount", + threads.pending_user_input_count AS "pendingUserInputCount", + threads.has_actionable_proposed_plan AS "hasActionableProposedPlan", + threads.deleted_at AS "deletedAt" + FROM projection_threads AS threads + LEFT JOIN projection_workspaces AS workspaces + ON workspaces.workspace_id = threads.workspace_id + WHERE threads.deleted_at IS NULL + AND threads.archived_at IS NULL + ORDER BY threads.project_id ASC, threads.created_at ASC, threads.thread_id ASC `, }); @@ -379,27 +415,33 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { execute: () => sql` SELECT - thread_id AS "threadId", - project_id AS "projectId", - title, - model_selection_json AS "modelSelection", - runtime_mode AS "runtimeMode", - interaction_mode AS "interactionMode", - branch, - worktree_path AS "worktreePath", - latest_turn_id AS "latestTurnId", - created_at AS "createdAt", - updated_at AS "updatedAt", - archived_at AS "archivedAt", - latest_user_message_at AS "latestUserMessageAt", - pending_approval_count AS "pendingApprovalCount", - pending_user_input_count AS "pendingUserInputCount", - has_actionable_proposed_plan AS "hasActionableProposedPlan", - deleted_at AS "deletedAt" - FROM projection_threads - WHERE deleted_at IS NULL - AND archived_at IS NOT NULL - ORDER BY project_id ASC, archived_at DESC, thread_id DESC + threads.thread_id AS "threadId", + threads.project_id AS "projectId", + threads.workspace_id AS "workspaceId", + workspaces.branch AS "workspaceBranch", + workspaces.worktree_path AS "workspaceWorktreePath", + workspaces.local_checkout AS "workspaceLocalCheckout", + threads.title, + threads.model_selection_json AS "modelSelection", + threads.runtime_mode AS "runtimeMode", + threads.interaction_mode AS "interactionMode", + threads.branch, + threads.worktree_path AS "worktreePath", + threads.latest_turn_id AS "latestTurnId", + threads.created_at AS "createdAt", + threads.updated_at AS "updatedAt", + threads.archived_at AS "archivedAt", + threads.latest_user_message_at AS "latestUserMessageAt", + threads.pending_approval_count AS "pendingApprovalCount", + threads.pending_user_input_count AS "pendingUserInputCount", + threads.has_actionable_proposed_plan AS "hasActionableProposedPlan", + threads.deleted_at AS "deletedAt" + FROM projection_threads AS threads + LEFT JOIN projection_workspaces AS workspaces + ON workspaces.workspace_id = threads.workspace_id + WHERE threads.deleted_at IS NULL + AND threads.archived_at IS NOT NULL + ORDER BY threads.project_id ASC, threads.archived_at DESC, threads.thread_id DESC `, }); @@ -741,27 +783,33 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { execute: ({ threadId }) => sql` SELECT - thread_id AS "threadId", - project_id AS "projectId", - title, - model_selection_json AS "modelSelection", - runtime_mode AS "runtimeMode", - interaction_mode AS "interactionMode", - branch, - worktree_path AS "worktreePath", - latest_turn_id AS "latestTurnId", - created_at AS "createdAt", - updated_at AS "updatedAt", - archived_at AS "archivedAt", - latest_user_message_at AS "latestUserMessageAt", - pending_approval_count AS "pendingApprovalCount", - pending_user_input_count AS "pendingUserInputCount", - has_actionable_proposed_plan AS "hasActionableProposedPlan", - deleted_at AS "deletedAt" - FROM projection_threads - WHERE thread_id = ${threadId} - AND deleted_at IS NULL - AND archived_at IS NULL + threads.thread_id AS "threadId", + threads.project_id AS "projectId", + threads.workspace_id AS "workspaceId", + workspaces.branch AS "workspaceBranch", + workspaces.worktree_path AS "workspaceWorktreePath", + workspaces.local_checkout AS "workspaceLocalCheckout", + threads.title, + threads.model_selection_json AS "modelSelection", + threads.runtime_mode AS "runtimeMode", + threads.interaction_mode AS "interactionMode", + threads.branch, + threads.worktree_path AS "worktreePath", + threads.latest_turn_id AS "latestTurnId", + threads.created_at AS "createdAt", + threads.updated_at AS "updatedAt", + threads.archived_at AS "archivedAt", + threads.latest_user_message_at AS "latestUserMessageAt", + threads.pending_approval_count AS "pendingApprovalCount", + threads.pending_user_input_count AS "pendingUserInputCount", + threads.has_actionable_proposed_plan AS "hasActionableProposedPlan", + threads.deleted_at AS "deletedAt" + FROM projection_threads AS threads + LEFT JOIN projection_workspaces AS workspaces + ON workspaces.workspace_id = threads.workspace_id + WHERE threads.thread_id = ${threadId} + AND threads.deleted_at IS NULL + AND threads.archived_at IS NULL LIMIT 1 `, }); @@ -1175,6 +1223,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { const threads: ReadonlyArray = threadRows.map((row) => ({ id: row.threadId, projectId: row.projectId, + ...mapThreadWorkspaceFields(row), title: row.title, modelSelection: row.modelSelection, runtimeMode: row.runtimeMode, @@ -1373,6 +1422,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { threads.push({ id: row.threadId, projectId: row.projectId, + ...mapThreadWorkspaceFields(row), title: row.title, modelSelection: row.modelSelection, runtimeMode: row.runtimeMode, @@ -1502,6 +1552,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ? Result.succeed({ id: row.threadId, projectId: row.projectId, + ...mapThreadWorkspaceFields(row), title: row.title, modelSelection: row.modelSelection, runtimeMode: row.runtimeMode, @@ -1636,6 +1687,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { (row): OrchestrationThreadShell => ({ id: row.threadId, projectId: row.projectId, + ...mapThreadWorkspaceFields(row), title: row.title, modelSelection: row.modelSelection, runtimeMode: row.runtimeMode, @@ -1876,6 +1928,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { return Option.some({ id: threadRow.value.threadId, projectId: threadRow.value.projectId, + ...mapThreadWorkspaceFields(threadRow.value), title: threadRow.value.title, modelSelection: threadRow.value.modelSelection, runtimeMode: threadRow.value.runtimeMode, @@ -1970,6 +2023,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { const thread = { id: threadRow.value.threadId, projectId: threadRow.value.projectId, + ...mapThreadWorkspaceFields(threadRow.value), title: threadRow.value.title, modelSelection: threadRow.value.modelSelection, runtimeMode: threadRow.value.runtimeMode, diff --git a/apps/server/src/orchestration/Normalizer.ts b/apps/server/src/orchestration/Normalizer.ts index bed166eba45d..72e102a46f8d 100644 --- a/apps/server/src/orchestration/Normalizer.ts +++ b/apps/server/src/orchestration/Normalizer.ts @@ -12,6 +12,7 @@ import { createAttachmentId, resolveAttachmentPath } from "../attachmentStore.ts import { ServerConfig } from "../config.ts"; import { parseBase64DataUrl } from "../imageMime.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; +import * as WorkspaceContext from "../workspace/WorkspaceContext.ts"; export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) => Effect.gen(function* () { @@ -19,6 +20,7 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) => const path = yield* Path.Path; const serverConfig = yield* ServerConfig; const workspacePaths = yield* WorkspacePaths.WorkspacePaths; + const workspaceContext = yield* WorkspaceContext.WorkspaceContext; const normalizeProjectWorkspaceRoot = (workspaceRoot: string) => workspacePaths.normalizeWorkspaceRoot(workspaceRoot).pipe( @@ -48,12 +50,21 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) => ); if (command.type === "project.create") { + const workspaceRoot = yield* normalizeProjectWorkspaceRootForCreate( + command.workspaceRoot, + command.createWorkspaceRootIfMissing, + ); + yield* workspaceContext.initialize({ workspaceRoot }).pipe( + Effect.mapError( + (cause) => + new OrchestrationDispatchCommandError({ + message: cause.message, + }), + ), + ); return { ...command, - workspaceRoot: yield* normalizeProjectWorkspaceRootForCreate( - command.workspaceRoot, - command.createWorkspaceRootIfMissing, - ), + workspaceRoot, createWorkspaceRootIfMissing: command.createWorkspaceRootIfMissing === true, } satisfies OrchestrationCommand; } diff --git a/apps/server/src/orchestration/projector.test.ts b/apps/server/src/orchestration/projector.test.ts index fadd50780264..884873c910fe 100644 --- a/apps/server/src/orchestration/projector.test.ts +++ b/apps/server/src/orchestration/projector.test.ts @@ -76,6 +76,10 @@ describe("orchestration projector", () => { { id: "thread-1", projectId: "project-1", + workspaceId: "project-1:workspace:local", + workspaceBranch: null, + workspaceWorktreePath: null, + workspaceLocalCheckout: true, title: "demo", modelSelection: { instanceId: "codex", @@ -205,6 +209,65 @@ describe("orchestration projector", () => { expect(unarchived.threads[0]?.archivedAt).toBeNull(); }); + it("preserves worktree identity when stale local metadata arrives", async () => { + const now = "2026-01-01T00:00:00.000Z"; + const later = "2026-01-01T00:00:01.000Z"; + const worktreePath = "/tmp/provider-project-worktree"; + const created = await Effect.runPromise( + projectEvent( + createEmptyReadModel(now), + makeEvent({ + sequence: 1, + type: "thread.created", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: now, + commandId: "cmd-thread-create", + payload: { + threadId: "thread-1", + projectId: "project-1", + title: "demo", + modelSelection: { + provider: ProviderDriverKind.make("codex"), + model: "gpt-5-codex", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: "t3code/generated-worktree", + worktreePath, + createdAt: now, + updatedAt: now, + }, + }), + ), + ); + + const updated = await Effect.runPromise( + projectEvent( + created, + makeEvent({ + sequence: 2, + type: "thread.meta-updated", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: later, + commandId: "cmd-stale-local-meta", + payload: { + threadId: "thread-1", + title: "Updated title", + branch: "feat/enhancements", + worktreePath: null, + updatedAt: later, + }, + }), + ), + ); + + expect(updated.threads[0]?.title).toBe("Updated title"); + expect(updated.threads[0]?.branch).toBe("t3code/generated-worktree"); + expect(updated.threads[0]?.worktreePath).toBe(worktreePath); + }); + it("keeps projector forward-compatible for unhandled event types", async () => { const now = "2026-01-01T00:00:00.000Z"; const model = createEmptyReadModel(now); @@ -906,13 +969,11 @@ describe("orchestration projector", () => { }, }), ); - const afterMessages = await messageEvents.reduce< - Promise> - >( - (statePromise, event) => - statePromise.then((state) => Effect.runPromise(projectEvent(state, event))), - Promise.resolve(afterCreate), - ); + let afterMessages = afterCreate; + for (const event of messageEvents) { + // oxlint-disable-next-line t3code/no-manual-effect-runtime-in-tests -- This legacy vite-plus suite is not an @effect/vitest layer suite. + afterMessages = Effect.runSync(projectEvent(afterMessages, event)); + } const checkpointEvents: ReadonlyArray = Array.from( { length: 600 }, @@ -936,13 +997,11 @@ describe("orchestration projector", () => { }, }), ); - const finalState = await checkpointEvents.reduce< - Promise> - >( - (statePromise, event) => - statePromise.then((state) => Effect.runPromise(projectEvent(state, event))), - Promise.resolve(afterMessages), - ); + let finalState = afterMessages; + for (const event of checkpointEvents) { + // oxlint-disable-next-line t3code/no-manual-effect-runtime-in-tests -- This legacy vite-plus suite is not an @effect/vitest layer suite. + finalState = Effect.runSync(projectEvent(finalState, event)); + } const thread = finalState.threads[0]; expect(thread?.messages).toHaveLength(2_000); diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index fc6ab8f6fcf8..b8e3318b7cc8 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -27,6 +27,10 @@ import { ThreadSessionSetPayload, ThreadTurnDiffCompletedPayload, } from "./Schemas.ts"; +import { + deriveThreadWorkspaceRecord, + resolveThreadWorkspaceRecordForPatch, +} from "./threadWorkspaceIdentity.ts"; type ThreadPatch = Partial>; const MAX_THREAD_MESSAGES = 2_000; @@ -271,11 +275,16 @@ export function projectEvent( event.type, "payload", ); + const workspace = deriveThreadWorkspaceRecord(payload); const thread: OrchestrationThread = yield* decodeForEvent( OrchestrationThread, { id: payload.threadId, projectId: payload.projectId, + workspaceId: workspace.workspaceId, + workspaceBranch: workspace.branch, + workspaceWorktreePath: workspace.worktreePath, + workspaceLocalCheckout: workspace.localCheckout === 1, title: payload.title, modelSelection: payload.modelSelection, runtimeMode: payload.runtimeMode, @@ -341,14 +350,44 @@ export function projectEvent( return decodeForEvent(ThreadMetaUpdatedPayload, event.payload, event.type, "payload").pipe( Effect.map((payload) => ({ ...nextBase, - threads: updateThread(nextBase.threads, payload.threadId, { - ...(payload.title !== undefined ? { title: payload.title } : {}), - ...(payload.modelSelection !== undefined - ? { modelSelection: payload.modelSelection } - : {}), - ...(payload.branch !== undefined ? { branch: payload.branch } : {}), - ...(payload.worktreePath !== undefined ? { worktreePath: payload.worktreePath } : {}), - updatedAt: payload.updatedAt, + threads: nextBase.threads.map((thread) => { + if (thread.id !== payload.threadId) { + return thread; + } + return { + ...thread, + ...(payload.title !== undefined ? { title: payload.title } : {}), + ...(payload.modelSelection !== undefined + ? { modelSelection: payload.modelSelection } + : {}), + ...(() => { + const threadWorkspaceUpdate = resolveThreadWorkspaceRecordForPatch({ + projectId: thread.projectId, + thread, + patch: payload, + }); + const threadWorkspacePatch = threadWorkspaceUpdate.patch; + if ( + threadWorkspacePatch.branch === undefined && + threadWorkspacePatch.worktreePath === undefined + ) { + return {}; + } + const workspace = threadWorkspaceUpdate.workspace; + return { + ...threadWorkspacePatch, + ...(workspace + ? { + workspaceId: workspace.workspaceId, + workspaceBranch: workspace.branch, + workspaceWorktreePath: workspace.worktreePath, + workspaceLocalCheckout: workspace.localCheckout === 1, + } + : {}), + }; + })(), + updatedAt: payload.updatedAt, + }; }), })), ); diff --git a/apps/server/src/orchestration/threadWorkspaceIdentity.ts b/apps/server/src/orchestration/threadWorkspaceIdentity.ts new file mode 100644 index 000000000000..595fd4297022 --- /dev/null +++ b/apps/server/src/orchestration/threadWorkspaceIdentity.ts @@ -0,0 +1,108 @@ +import { ProjectId, WorkspaceId } from "@t3tools/contracts"; + +export interface ThreadWorkspaceIdentity { + readonly workspaceId?: string | null | undefined; + readonly branch: string | null; + readonly worktreePath: string | null; +} + +export interface ThreadWorkspaceIdentityPatch { + readonly branch?: string | null | undefined; + readonly worktreePath?: string | null | undefined; +} + +export interface ResolvedThreadWorkspaceIdentityPatch { + readonly branch?: string | null; + readonly worktreePath?: string | null; +} + +export interface ResolvedThreadWorkspaceRecordForPatch { + readonly patch: ResolvedThreadWorkspaceIdentityPatch; + readonly workspace?: ThreadWorkspaceRecord; +} + +export interface ThreadWorkspaceRecord { + readonly workspaceId: WorkspaceId; + readonly projectId: ProjectId; + readonly branch: string | null; + readonly worktreePath: string | null; + readonly localCheckout: 0 | 1; +} + +function normalizeWorkspaceContextValue(value: string | null): string | null { + const trimmed = value?.trim(); + return trimmed && trimmed.length > 0 ? trimmed : null; +} + +export function deriveThreadWorkspaceRecord(input: { + readonly projectId: ProjectId; + readonly workspaceId?: string | null | undefined; + readonly branch: string | null; + readonly worktreePath: string | null; +}): ThreadWorkspaceRecord { + const branch = normalizeWorkspaceContextValue(input.branch); + const worktreePath = normalizeWorkspaceContextValue(input.worktreePath); + const contextKey = worktreePath + ? `worktree:${worktreePath}` + : branch + ? `branch:${branch}` + : "local"; + + return { + workspaceId: WorkspaceId.make( + input.workspaceId ?? `${input.projectId}:workspace:${contextKey}`, + ), + projectId: input.projectId, + branch, + worktreePath, + localCheckout: worktreePath === null ? 1 : 0, + }; +} + +export function resolveThreadWorkspaceIdentityPatch( + thread: ThreadWorkspaceIdentity, + patch: ThreadWorkspaceIdentityPatch, +): ResolvedThreadWorkspaceIdentityPatch { + if (thread.worktreePath !== null && patch.worktreePath === null) { + return {}; + } + + return { + ...(patch.branch !== undefined ? { branch: patch.branch } : {}), + ...(patch.worktreePath !== undefined ? { worktreePath: patch.worktreePath } : {}), + }; +} + +export function resolveThreadWorkspaceRecordForPatch(input: { + readonly projectId: ProjectId; + readonly thread: ThreadWorkspaceIdentity; + readonly patch: ThreadWorkspaceIdentityPatch; +}): ResolvedThreadWorkspaceRecordForPatch { + const resolvedPatch = resolveThreadWorkspaceIdentityPatch(input.thread, input.patch); + if (resolvedPatch.branch === undefined && resolvedPatch.worktreePath === undefined) { + return { patch: resolvedPatch }; + } + + const previousWorktreePath = normalizeWorkspaceContextValue(input.thread.worktreePath); + const nextWorktreePath = normalizeWorkspaceContextValue( + resolvedPatch.worktreePath !== undefined + ? resolvedPatch.worktreePath + : input.thread.worktreePath, + ); + const nextBranch = + resolvedPatch.branch !== undefined ? resolvedPatch.branch : input.thread.branch; + const preserveWorkspaceId = + previousWorktreePath !== null && + nextWorktreePath !== null && + previousWorktreePath === nextWorktreePath; + + return { + patch: resolvedPatch, + workspace: deriveThreadWorkspaceRecord({ + projectId: input.projectId, + ...(preserveWorkspaceId ? { workspaceId: input.thread.workspaceId } : {}), + branch: nextBranch, + worktreePath: nextWorktreePath, + }), + }; +} diff --git a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts index a2069e62a14c..e057dbc15bb9 100644 --- a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts @@ -78,6 +78,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { yield* threads.upsert({ threadId: ThreadId.make("thread-null-options"), projectId: ProjectId.make("project-null-options"), + workspaceId: null, title: "Null options thread", modelSelection: { instanceId: ProviderInstanceId.make("claudeAgent"), diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index 1baeb375c152..08f6ebed5e67 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -33,6 +33,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { INSERT INTO projection_threads ( thread_id, project_id, + workspace_id, title, model_selection_json, runtime_mode, @@ -52,6 +53,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { VALUES ( ${row.threadId}, ${row.projectId}, + ${row.workspaceId}, ${row.title}, ${JSON.stringify(row.modelSelection)}, ${row.runtimeMode}, @@ -71,6 +73,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { ON CONFLICT (thread_id) DO UPDATE SET project_id = excluded.project_id, + workspace_id = excluded.workspace_id, title = excluded.title, model_selection_json = excluded.model_selection_json, runtime_mode = excluded.runtime_mode, @@ -97,6 +100,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { SELECT thread_id AS "threadId", project_id AS "projectId", + workspace_id AS "workspaceId", title, model_selection_json AS "modelSelection", runtime_mode AS "runtimeMode", @@ -125,6 +129,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { SELECT thread_id AS "threadId", project_id AS "projectId", + workspace_id AS "workspaceId", title, model_selection_json AS "modelSelection", runtime_mode AS "runtimeMode", diff --git a/apps/server/src/persistence/Layers/ProjectionWorkspaces.ts b/apps/server/src/persistence/Layers/ProjectionWorkspaces.ts new file mode 100644 index 000000000000..f1877b3f38b3 --- /dev/null +++ b/apps/server/src/persistence/Layers/ProjectionWorkspaces.ts @@ -0,0 +1,95 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; + +import { toPersistenceSqlError } from "../Errors.ts"; +import { + GetProjectionWorkspaceInput, + ProjectionWorkspace, + ProjectionWorkspaceRepository, + type ProjectionWorkspaceRepositoryShape, +} from "../Services/ProjectionWorkspaces.ts"; + +const makeProjectionWorkspaceRepository = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const upsertProjectionWorkspaceRow = SqlSchema.void({ + Request: ProjectionWorkspace, + execute: (row) => + sql` + INSERT INTO projection_workspaces ( + workspace_id, + project_id, + branch, + worktree_path, + local_checkout, + created_at, + updated_at, + archived_at, + deleted_at + ) + VALUES ( + ${row.workspaceId}, + ${row.projectId}, + ${row.branch}, + ${row.worktreePath}, + ${row.localCheckout}, + ${row.createdAt}, + ${row.updatedAt}, + ${row.archivedAt}, + ${row.deletedAt} + ) + ON CONFLICT (workspace_id) + DO UPDATE SET + project_id = excluded.project_id, + branch = excluded.branch, + worktree_path = excluded.worktree_path, + local_checkout = excluded.local_checkout, + created_at = MIN(projection_workspaces.created_at, excluded.created_at), + updated_at = excluded.updated_at, + archived_at = excluded.archived_at, + deleted_at = excluded.deleted_at + `, + }); + + const getProjectionWorkspaceRow = SqlSchema.findOneOption({ + Request: GetProjectionWorkspaceInput, + Result: ProjectionWorkspace, + execute: ({ workspaceId }) => + sql` + SELECT + workspace_id AS "workspaceId", + project_id AS "projectId", + branch, + worktree_path AS "worktreePath", + local_checkout AS "localCheckout", + created_at AS "createdAt", + updated_at AS "updatedAt", + archived_at AS "archivedAt", + deleted_at AS "deletedAt" + FROM projection_workspaces + WHERE workspace_id = ${workspaceId} + `, + }); + + const upsert: ProjectionWorkspaceRepositoryShape["upsert"] = (row) => + upsertProjectionWorkspaceRow(row).pipe( + Effect.mapError(toPersistenceSqlError("ProjectionWorkspaceRepository.upsert:query")), + ); + + const getById: ProjectionWorkspaceRepositoryShape["getById"] = (input) => + getProjectionWorkspaceRow(input).pipe( + Effect.mapError(toPersistenceSqlError("ProjectionWorkspaceRepository.getById:query")), + ); + + return { + upsert, + getById, + } satisfies ProjectionWorkspaceRepositoryShape; +}); + +export const ProjectionWorkspaceRepositoryLive = Layer.effect( + ProjectionWorkspaceRepository, + makeProjectionWorkspaceRepository, +); diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index ba1131ee2597..7ae862e8f1f6 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -45,6 +45,8 @@ import Migration0029 from "./Migrations/029_ProjectionThreadDetailOrderingIndexe import Migration0030 from "./Migrations/030_ProjectionThreadShellArchiveIndexes.ts"; import Migration0031 from "./Migrations/031_AuthAuthorizationScopes.ts"; import Migration0032 from "./Migrations/032_AuthPairingProofKeyThumbprint.ts"; +import Migration0033 from "./Migrations/033_ProjectionWorkspaces.ts"; +import Migration0034 from "./Migrations/034_RebuildProjectionWorkspaces.ts"; /** * Migration loader with all migrations defined inline. @@ -89,6 +91,8 @@ export const migrationEntries = [ [30, "ProjectionThreadShellArchiveIndexes", Migration0030], [31, "AuthAuthorizationScopes", Migration0031], [32, "AuthPairingProofKeyThumbprint", Migration0032], + [33, "ProjectionWorkspaces", Migration0033], + [34, "RebuildProjectionWorkspaces", Migration0034], ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/033_ProjectionWorkspaces.ts b/apps/server/src/persistence/Migrations/033_ProjectionWorkspaces.ts new file mode 100644 index 000000000000..d36908446b76 --- /dev/null +++ b/apps/server/src/persistence/Migrations/033_ProjectionWorkspaces.ts @@ -0,0 +1,110 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + CREATE TABLE IF NOT EXISTS projection_workspaces ( + workspace_id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + branch TEXT, + worktree_path TEXT, + local_checkout INTEGER NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + archived_at TEXT, + deleted_at TEXT + ) + `; + + const threadColumns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_threads) + `; + if (!threadColumns.some((column) => column.name === "workspace_id")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN workspace_id TEXT + `; + } + + yield* sql` + INSERT INTO projection_workspaces ( + workspace_id, + project_id, + branch, + worktree_path, + local_checkout, + created_at, + updated_at, + archived_at, + deleted_at + ) + SELECT + workspace_id, + project_id, + branch, + worktree_path, + local_checkout, + MIN(created_at) AS created_at, + MAX(updated_at) AS updated_at, + NULL AS archived_at, + NULL AS deleted_at + FROM ( + SELECT + project_id, + project_id || ':workspace:' || + CASE + WHEN NULLIF(TRIM(worktree_path), '') IS NOT NULL THEN 'worktree:' || TRIM(worktree_path) + WHEN NULLIF(TRIM(branch), '') IS NOT NULL THEN 'branch:' || TRIM(branch) + ELSE 'local' + END AS workspace_id, + CASE + WHEN NULLIF(TRIM(branch), '') IS NOT NULL THEN TRIM(branch) + ELSE NULL + END AS branch, + CASE + WHEN NULLIF(TRIM(worktree_path), '') IS NOT NULL THEN TRIM(worktree_path) + ELSE NULL + END AS worktree_path, + CASE + WHEN NULLIF(TRIM(worktree_path), '') IS NULL THEN 1 + ELSE 0 + END AS local_checkout, + created_at, + updated_at + FROM projection_threads + WHERE deleted_at IS NULL + ) + GROUP BY workspace_id + ON CONFLICT (workspace_id) + DO UPDATE SET + project_id = excluded.project_id, + branch = excluded.branch, + worktree_path = excluded.worktree_path, + local_checkout = excluded.local_checkout, + created_at = MIN(projection_workspaces.created_at, excluded.created_at), + updated_at = MAX(projection_workspaces.updated_at, excluded.updated_at) + `; + + yield* sql` + UPDATE projection_threads + SET workspace_id = project_id || ':workspace:' || + CASE + WHEN NULLIF(TRIM(worktree_path), '') IS NOT NULL THEN 'worktree:' || TRIM(worktree_path) + WHEN NULLIF(TRIM(branch), '') IS NOT NULL THEN 'branch:' || TRIM(branch) + ELSE 'local' + END + WHERE workspace_id IS NULL + `; + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_projection_workspaces_project + ON projection_workspaces(project_id, deleted_at, created_at) + `; + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_projection_threads_workspace + ON projection_threads(workspace_id) + `; +}); diff --git a/apps/server/src/persistence/Migrations/034_RebuildProjectionWorkspaces.ts b/apps/server/src/persistence/Migrations/034_RebuildProjectionWorkspaces.ts new file mode 100644 index 000000000000..e16114c22276 --- /dev/null +++ b/apps/server/src/persistence/Migrations/034_RebuildProjectionWorkspaces.ts @@ -0,0 +1,65 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + UPDATE projection_threads + SET workspace_id = project_id || ':workspace:' || + CASE + WHEN NULLIF(TRIM(worktree_path), '') IS NOT NULL THEN 'worktree:' || TRIM(worktree_path) + WHEN NULLIF(TRIM(branch), '') IS NOT NULL THEN 'branch:' || TRIM(branch) + ELSE 'local' + END + `; + + yield* sql`DELETE FROM projection_workspaces`; + + yield* sql` + INSERT INTO projection_workspaces ( + workspace_id, + project_id, + branch, + worktree_path, + local_checkout, + created_at, + updated_at, + archived_at, + deleted_at + ) + SELECT + workspace_id, + project_id, + branch, + worktree_path, + local_checkout, + MIN(created_at) AS created_at, + MAX(updated_at) AS updated_at, + NULL AS archived_at, + NULL AS deleted_at + FROM ( + SELECT + workspace_id, + project_id, + CASE + WHEN NULLIF(TRIM(branch), '') IS NOT NULL THEN TRIM(branch) + ELSE NULL + END AS branch, + CASE + WHEN NULLIF(TRIM(worktree_path), '') IS NOT NULL THEN TRIM(worktree_path) + ELSE NULL + END AS worktree_path, + CASE + WHEN NULLIF(TRIM(worktree_path), '') IS NULL THEN 1 + ELSE 0 + END AS local_checkout, + created_at, + updated_at + FROM projection_threads + WHERE deleted_at IS NULL + AND workspace_id IS NOT NULL + ) + GROUP BY workspace_id + `; +}); diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts index 44fdc147a4a2..e0702745a693 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -15,6 +15,7 @@ import { RuntimeMode, ThreadId, TurnId, + WorkspaceId, } from "@t3tools/contracts"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; @@ -26,6 +27,10 @@ import type { ProjectionRepositoryError } from "../Errors.ts"; export const ProjectionThread = Schema.Struct({ threadId: ThreadId, projectId: ProjectId, + workspaceId: Schema.NullOr(WorkspaceId), + workspaceBranch: Schema.optional(Schema.NullOr(Schema.String)), + workspaceWorktreePath: Schema.optional(Schema.NullOr(Schema.String)), + workspaceLocalCheckout: Schema.optional(Schema.NullOr(NonNegativeInt)), title: Schema.String, modelSelection: ModelSelection, runtimeMode: RuntimeMode, diff --git a/apps/server/src/persistence/Services/ProjectionWorkspaces.ts b/apps/server/src/persistence/Services/ProjectionWorkspaces.ts new file mode 100644 index 000000000000..b6aa072dd111 --- /dev/null +++ b/apps/server/src/persistence/Services/ProjectionWorkspaces.ts @@ -0,0 +1,40 @@ +import { IsoDateTime, ProjectId, WorkspaceId, NonNegativeInt } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import type { ProjectionRepositoryError } from "../Errors.ts"; + +export const ProjectionWorkspace = Schema.Struct({ + workspaceId: WorkspaceId, + projectId: ProjectId, + branch: Schema.NullOr(Schema.String), + worktreePath: Schema.NullOr(Schema.String), + localCheckout: NonNegativeInt, + createdAt: IsoDateTime, + updatedAt: IsoDateTime, + archivedAt: Schema.NullOr(IsoDateTime), + deletedAt: Schema.NullOr(IsoDateTime), +}); +export type ProjectionWorkspace = typeof ProjectionWorkspace.Type; + +export const GetProjectionWorkspaceInput = Schema.Struct({ + workspaceId: WorkspaceId, +}); +export type GetProjectionWorkspaceInput = typeof GetProjectionWorkspaceInput.Type; + +export interface ProjectionWorkspaceRepositoryShape { + readonly upsert: ( + workspace: ProjectionWorkspace, + ) => Effect.Effect; + + readonly getById: ( + input: GetProjectionWorkspaceInput, + ) => Effect.Effect, ProjectionRepositoryError>; +} + +export class ProjectionWorkspaceRepository extends Context.Service< + ProjectionWorkspaceRepository, + ProjectionWorkspaceRepositoryShape +>()("t3/persistence/Services/ProjectionWorkspaces/ProjectionWorkspaceRepository") {} diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index e1daf20ed570..d8a4aa2216ae 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -95,6 +95,7 @@ import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; +import * as WorkspaceContext from "./workspace/WorkspaceContext.ts"; import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts"; import * as WorkspacePaths from "./workspace/WorkspacePaths.ts"; import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; @@ -331,6 +332,7 @@ const buildAppUnderTest = (options?: { projectSetupScriptRunner?: Partial< ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"] >; + workspaceContext?: Partial; terminalManager?: Partial; orchestrationEngine?: Partial; projectionSnapshotQuery?: Partial; @@ -496,6 +498,14 @@ const buildAppUnderTest = (options?: { Layer.provide(WorkspacePaths.layer), Layer.provide(workspaceEntriesLayer), ), + options?.layers?.workspaceContext + ? Layer.mock(WorkspaceContext.WorkspaceContext)({ + initialize: () => Effect.succeed({ relativePath: ".context" as const }), + readMarkdownFile: () => Effect.die("WorkspaceContext.readMarkdownFile not stubbed"), + writeMarkdownFile: () => Effect.die("WorkspaceContext.writeMarkdownFile not stubbed"), + ...options.layers.workspaceContext, + }) + : WorkspaceContext.layer.pipe(Layer.provide(WorkspacePaths.layer)), ProjectFaviconResolver.layer.pipe(Layer.provide(WorkspacePaths.layer)), ); const gitWorkflowLayer = GitWorkflowService.layer.pipe( @@ -3233,6 +3243,26 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("allows credentialed auth requests from the desktop dev renderer origin", () => + Effect.gen(function* () { + const desktopDevOrigin = "t3code-dev://app"; + yield* buildAppUnderTest({ + config: { devUrl: new URL("http://127.0.0.1:5173") }, + }); + + const sessionUrl = yield* getHttpServerUrl("/api/auth/session"); + const response = yield* fetchEffect(sessionUrl, { + headers: { origin: desktopDevOrigin }, + }); + + assert.equal(response.status, 200); + assertBrowserApiCorsResponseHeaders(response.headers, { + origin: desktopDevOrigin, + credentials: true, + }); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("includes CORS headers on remote websocket-ticket auth failures", () => Effect.gen(function* () { yield* buildAppUnderTest(); @@ -4628,9 +4658,13 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ), ); const stat = yield* fs.stat(missingWorkspaceRoot); + const contextStat = yield* fs.stat(path.join(missingWorkspaceRoot, ".context")); + const gitignore = yield* fs.readFileString(path.join(missingWorkspaceRoot, ".gitignore")); assert.isAtLeast(response.sequence, 0); assert.equal(stat.type, "Directory"); + assert.equal(contextStat.type, "Directory"); + assert.include(gitignore, ".context/"); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); @@ -6027,6 +6061,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { Effect.gen(function* () { const dispatchedCommands: Array = []; const bootstrapGitOperations: string[] = []; + const bootstrapSideEffects: string[] = []; const refreshStatus = vi.fn((_: string) => Effect.succeed({ isRepo: true, @@ -6080,12 +6115,22 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"]["runForThread"] >[0], ) => - Effect.succeed({ - status: "started" as const, - scriptId: "setup", - scriptName: "Setup", - terminalId: "setup-setup", - cwd: "/tmp/bootstrap-worktree", + Effect.sync(() => { + bootstrapSideEffects.push("setup-script"); + return { + status: "started" as const, + scriptId: "setup", + scriptName: "Setup", + terminalId: "setup-setup", + cwd: "/tmp/bootstrap-worktree", + }; + }), + ); + const initializeContext = vi.fn( + (_: Parameters[0]) => + Effect.sync(() => { + bootstrapSideEffects.push("context-init"); + return { relativePath: ".context" as const }; }), ); @@ -6110,6 +6155,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { projectSetupScriptRunner: { runForThread, }, + workspaceContext: { + initialize: initializeContext, + }, }, }); @@ -6186,6 +6234,10 @@ it.layer(NodeServices.layer)("server router seam", (it) => { "resolve-remote-commit", "create-worktree", ]); + assert.deepEqual(initializeContext.mock.calls[0]?.[0], { + workspaceRoot: "/tmp/bootstrap-worktree", + }); + assert.deepEqual(bootstrapSideEffects, ["context-init", "setup-script"]); assert.deepEqual(runForThread.mock.calls[0]?.[0], { threadId: ThreadId.make("thread-bootstrap"), projectId: defaultProjectId, diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 81d0013b20ca..a6121e9c5861 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -55,6 +55,7 @@ import * as ServerSettings from "./serverSettings.ts"; import * as ProjectFaviconResolver from "./project/ProjectFaviconResolver.ts"; import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts"; import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; +import * as WorkspaceContext from "./workspace/WorkspaceContext.ts"; import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts"; import * as WorkspacePaths from "./workspace/WorkspacePaths.ts"; import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; @@ -255,10 +256,13 @@ const WorkspaceFileSystemLayerLive = WorkspaceFileSystem.layer.pipe( Layer.provide(WorkspaceEntriesLayerLive), ); +const WorkspaceContextLayerLive = WorkspaceContext.layer.pipe(Layer.provide(WorkspacePaths.layer)); + const WorkspaceLayerLive = Layer.mergeAll( WorkspacePaths.layer, WorkspaceEntriesLayerLive, WorkspaceFileSystemLayerLive, + WorkspaceContextLayerLive, ); const ProjectFaviconResolverLayerLive = ProjectFaviconResolver.layer.pipe( diff --git a/apps/server/src/workspace/WorkspaceContext.test.ts b/apps/server/src/workspace/WorkspaceContext.test.ts new file mode 100644 index 000000000000..bc9e0206a0c5 --- /dev/null +++ b/apps/server/src/workspace/WorkspaceContext.test.ts @@ -0,0 +1,139 @@ +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 Layer from "effect/Layer"; +import * as Path from "effect/Path"; + +import * as WorkspaceContext from "./WorkspaceContext.ts"; +import * as WorkspacePaths from "./WorkspacePaths.ts"; + +const TestLayer = Layer.empty.pipe( + Layer.provideMerge(WorkspaceContext.layer.pipe(Layer.provide(WorkspacePaths.layer))), + Layer.provideMerge(WorkspacePaths.layer), + Layer.provideMerge(NodeServices.layer), +); + +const makeTempDir = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + return yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-workspace-context-", + }); +}); + +it.layer(TestLayer, { excludeTestServices: true })("WorkspaceContext", (it) => { + describe("initialize", () => { + it.effect("creates the workspace context directory and standard markdown files", () => + Effect.gen(function* () { + const workspaceContext = yield* WorkspaceContext.WorkspaceContext; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + + const result = yield* workspaceContext.initialize({ workspaceRoot: cwd }); + + expect(result.relativePath).toBe(".context"); + const directoryStat = yield* fileSystem.stat(path.join(cwd, ".context")); + expect(directoryStat.type).toBe("Directory"); + for (const fileName of WorkspaceContext.STANDARD_CONTEXT_MARKDOWN_FILES) { + const contents = yield* fileSystem.readFileString(path.join(cwd, ".context", fileName)); + expect(contents).toBe(""); + } + }), + ); + + it.effect("adds .context/ to .gitignore once without overwriting existing entries", () => + Effect.gen(function* () { + const workspaceContext = yield* WorkspaceContext.WorkspaceContext; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + const gitignorePath = path.join(cwd, ".gitignore"); + yield* fileSystem.writeFileString(gitignorePath, "node_modules/\n"); + + yield* workspaceContext.initialize({ workspaceRoot: cwd }); + yield* workspaceContext.initialize({ workspaceRoot: cwd }); + + const gitignore = yield* fileSystem.readFileString(gitignorePath); + expect(gitignore).toBe("node_modules/\n.context/\n"); + }), + ); + + it.effect("preserves existing context markdown contents", () => + Effect.gen(function* () { + const workspaceContext = yield* WorkspaceContext.WorkspaceContext; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + yield* fileSystem.makeDirectory(path.join(cwd, ".context"), { recursive: true }); + yield* fileSystem.writeFileString(path.join(cwd, ".context", "brief.md"), "Existing\n"); + + yield* workspaceContext.initialize({ workspaceRoot: cwd }); + + const contents = yield* fileSystem.readFileString(path.join(cwd, ".context", "brief.md")); + expect(contents).toBe("Existing\n"); + }), + ); + }); + + describe("readMarkdownFile and writeMarkdownFile", () => { + it.effect("writes context markdown files atomically and reads them back", () => + Effect.gen(function* () { + const workspaceContext = yield* WorkspaceContext.WorkspaceContext; + const cwd = yield* makeTempDir; + + const written = yield* workspaceContext.writeMarkdownFile({ + workspaceRoot: cwd, + relativePath: "plan.md", + contents: "# Plan\n", + }); + const read = yield* workspaceContext.readMarkdownFile({ + workspaceRoot: cwd, + relativePath: "plan.md", + }); + + expect(written.relativePath).toBe(".context/plan.md"); + expect(read).toEqual({ + relativePath: ".context/plan.md", + contents: "# Plan\n", + }); + }), + ); + + it.effect("rejects context paths that escape the .context directory", () => + Effect.gen(function* () { + const workspaceContext = yield* WorkspaceContext.WorkspaceContext; + const cwd = yield* makeTempDir; + + const error = yield* workspaceContext + .writeMarkdownFile({ + workspaceRoot: cwd, + relativePath: "../README.md", + contents: "nope\n", + }) + .pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkspaceContext.WorkspaceContextPathError); + expect(error.message).toContain("Workspace context path must stay inside .context/"); + }), + ); + + it.effect("rejects non-markdown context files", () => + Effect.gen(function* () { + const workspaceContext = yield* WorkspaceContext.WorkspaceContext; + const cwd = yield* makeTempDir; + + const error = yield* workspaceContext + .writeMarkdownFile({ + workspaceRoot: cwd, + relativePath: "artifact.json", + contents: "{}\n", + }) + .pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkspaceContext.WorkspaceContextPathError); + expect(error.message).toContain("Workspace context path must target a markdown file"); + }), + ); + }); +}); diff --git a/apps/server/src/workspace/WorkspaceContext.ts b/apps/server/src/workspace/WorkspaceContext.ts new file mode 100644 index 000000000000..e086acc26fda --- /dev/null +++ b/apps/server/src/workspace/WorkspaceContext.ts @@ -0,0 +1,364 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFSP from "node:fs/promises"; + +import * as Context from "effect/Context"; +import * as Clock from "effect/Clock"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Random from "effect/Random"; +import * as Schema from "effect/Schema"; + +import * as WorkspacePaths from "./WorkspacePaths.ts"; + +export const CONTEXT_DIRECTORY_NAME = ".context"; +export const CONTEXT_GITIGNORE_ENTRY = ".context/"; +export const STANDARD_CONTEXT_MARKDOWN_FILES = [ + "brief.md", + "plan.md", + "decisions.md", + "handoff.md", + "review.md", + "checks.md", +] as const; + +export class WorkspaceContextOperationError extends Schema.TaggedErrorClass()( + "WorkspaceContextOperationError", + { + workspaceRoot: Schema.String, + relativePath: Schema.String, + operationPath: Schema.String, + operation: Schema.Literals([ + "make-directory", + "read-file", + "write-file", + "rename-file", + "remove-file", + "stat-file", + ]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Workspace context operation '${this.operation}' failed at '${this.operationPath}' for '${this.relativePath}' in '${this.workspaceRoot}'.`; + } +} + +export class WorkspaceContextPathError extends Schema.TaggedErrorClass()( + "WorkspaceContextPathError", + { + workspaceRoot: Schema.String, + relativePath: Schema.String, + reason: Schema.Literals(["outside-context", "not-markdown"]), + }, +) { + override get message(): string { + switch (this.reason) { + case "outside-context": + return `Workspace context path must stay inside .context/: ${this.relativePath}`; + case "not-markdown": + return `Workspace context path must target a markdown file: ${this.relativePath}`; + } + } +} + +export const WorkspaceContextError = Schema.Union([ + WorkspaceContextOperationError, + WorkspaceContextPathError, + WorkspacePaths.WorkspacePathOutsideRootError, +]); +export type WorkspaceContextError = typeof WorkspaceContextError.Type; + +export interface WorkspaceContextFile { + readonly relativePath: string; + readonly contents: string; +} + +export class WorkspaceContext extends Context.Service< + WorkspaceContext, + { + readonly initialize: (input: { + readonly workspaceRoot: string; + }) => Effect.Effect< + { readonly relativePath: typeof CONTEXT_DIRECTORY_NAME }, + WorkspaceContextError + >; + readonly readMarkdownFile: (input: { + readonly workspaceRoot: string; + readonly relativePath: string; + }) => Effect.Effect; + readonly writeMarkdownFile: (input: { + readonly workspaceRoot: string; + readonly relativePath: string; + readonly contents: string; + }) => Effect.Effect<{ readonly relativePath: string }, WorkspaceContextError>; + } +>()("t3/workspace/WorkspaceContext") {} + +function toPosixRelativePath(input: string): string { + return input.replaceAll("\\", "/"); +} + +function hasGitignoreEntry(contents: string): boolean { + return contents + .split(/\r?\n/g) + .map((line) => line.trim()) + .some((line) => line === CONTEXT_GITIGNORE_ENTRY || line === CONTEXT_DIRECTORY_NAME); +} + +export const make = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workspacePaths = yield* WorkspacePaths.WorkspacePaths; + + const resolveContextMarkdownPath = Effect.fn("WorkspaceContext.resolveContextMarkdownPath")( + function* (input: { readonly workspaceRoot: string; readonly relativePath: string }) { + const trimmed = input.relativePath.trim(); + const relativePath = trimmed.startsWith(`${CONTEXT_DIRECTORY_NAME}/`) + ? trimmed.slice(CONTEXT_DIRECTORY_NAME.length + 1) + : trimmed; + const resolved = yield* workspacePaths.resolveRelativePathWithinRoot({ + workspaceRoot: input.workspaceRoot, + relativePath: path.join(CONTEXT_DIRECTORY_NAME, relativePath), + }); + const contextRelativePath = toPosixRelativePath( + path.relative( + path.join(input.workspaceRoot, CONTEXT_DIRECTORY_NAME), + resolved.absolutePath, + ), + ); + if ( + contextRelativePath.length === 0 || + contextRelativePath === "." || + contextRelativePath === ".." || + contextRelativePath.startsWith("../") || + path.isAbsolute(contextRelativePath) + ) { + return yield* new WorkspaceContextPathError({ + workspaceRoot: input.workspaceRoot, + relativePath: input.relativePath, + reason: "outside-context", + }); + } + if (!contextRelativePath.endsWith(".md")) { + return yield* new WorkspaceContextPathError({ + workspaceRoot: input.workspaceRoot, + relativePath: input.relativePath, + reason: "not-markdown", + }); + } + + return { + absolutePath: resolved.absolutePath, + relativePath: `${CONTEXT_DIRECTORY_NAME}/${contextRelativePath}`, + }; + }, + ); + + const ensureGitignoreEntry = Effect.fn("WorkspaceContext.ensureGitignoreEntry")(function* ( + workspaceRoot: string, + ) { + const gitignorePath = path.join(workspaceRoot, ".gitignore"); + const existing = yield* fileSystem.readFileString(gitignorePath).pipe( + Effect.catch((cause) => + cause.reason._tag === "NotFound" + ? Effect.succeed("") + : Effect.fail( + new WorkspaceContextOperationError({ + workspaceRoot, + relativePath: ".gitignore", + operationPath: gitignorePath, + operation: "read-file", + cause, + }), + ), + ), + ); + if (hasGitignoreEntry(existing)) { + return; + } + + const separator = existing.length === 0 || existing.endsWith("\n") ? "" : "\n"; + const nextContents = `${existing}${separator}${CONTEXT_GITIGNORE_ENTRY}\n`; + yield* fileSystem.writeFileString(gitignorePath, nextContents).pipe( + Effect.mapError( + (cause) => + new WorkspaceContextOperationError({ + workspaceRoot, + relativePath: ".gitignore", + operationPath: gitignorePath, + operation: "write-file", + cause, + }), + ), + ); + }); + + const writeFileIfMissing = Effect.fn("WorkspaceContext.writeFileIfMissing")(function* (input: { + readonly workspaceRoot: string; + readonly relativePath: string; + readonly absolutePath: string; + readonly contents: string; + }) { + const existing = yield* fileSystem.stat(input.absolutePath).pipe( + Effect.matchEffect({ + onFailure: (cause) => + cause.reason._tag === "NotFound" + ? Effect.succeed(null) + : Effect.fail( + new WorkspaceContextOperationError({ + workspaceRoot: input.workspaceRoot, + relativePath: input.relativePath, + operationPath: input.absolutePath, + operation: "stat-file", + cause, + }), + ), + onSuccess: Effect.succeed, + }), + ); + if (existing) { + return; + } + yield* fileSystem.writeFileString(input.absolutePath, input.contents).pipe( + Effect.mapError( + (cause) => + new WorkspaceContextOperationError({ + workspaceRoot: input.workspaceRoot, + relativePath: input.relativePath, + operationPath: input.absolutePath, + operation: "write-file", + cause, + }), + ), + ); + }); + + const initialize: WorkspaceContext["Service"]["initialize"] = Effect.fn( + "WorkspaceContext.initialize", + )(function* (input) { + const contextDirectoryPath = path.join(input.workspaceRoot, CONTEXT_DIRECTORY_NAME); + yield* fileSystem.makeDirectory(contextDirectoryPath, { recursive: true }).pipe( + Effect.mapError( + (cause) => + new WorkspaceContextOperationError({ + workspaceRoot: input.workspaceRoot, + relativePath: CONTEXT_DIRECTORY_NAME, + operationPath: contextDirectoryPath, + operation: "make-directory", + cause, + }), + ), + ); + + for (const fileName of STANDARD_CONTEXT_MARKDOWN_FILES) { + yield* writeFileIfMissing({ + workspaceRoot: input.workspaceRoot, + relativePath: `${CONTEXT_DIRECTORY_NAME}/${fileName}`, + absolutePath: path.join(contextDirectoryPath, fileName), + contents: "", + }); + } + yield* ensureGitignoreEntry(input.workspaceRoot); + + return { relativePath: CONTEXT_DIRECTORY_NAME }; + }); + + const readMarkdownFile: WorkspaceContext["Service"]["readMarkdownFile"] = Effect.fn( + "WorkspaceContext.readMarkdownFile", + )(function* (input) { + const target = yield* resolveContextMarkdownPath(input); + const contents = yield* fileSystem.readFileString(target.absolutePath).pipe( + Effect.mapError( + (cause) => + new WorkspaceContextOperationError({ + workspaceRoot: input.workspaceRoot, + relativePath: target.relativePath, + operationPath: target.absolutePath, + operation: "read-file", + cause, + }), + ), + ); + return { + relativePath: target.relativePath, + contents, + }; + }); + + const writeMarkdownFile: WorkspaceContext["Service"]["writeMarkdownFile"] = Effect.fn( + "WorkspaceContext.writeMarkdownFile", + )(function* (input) { + yield* initialize({ workspaceRoot: input.workspaceRoot }); + const target = yield* resolveContextMarkdownPath(input); + const parentDirectory = path.dirname(target.absolutePath); + yield* fileSystem.makeDirectory(parentDirectory, { recursive: true }).pipe( + Effect.mapError( + (cause) => + new WorkspaceContextOperationError({ + workspaceRoot: input.workspaceRoot, + relativePath: target.relativePath, + operationPath: parentDirectory, + operation: "make-directory", + cause, + }), + ), + ); + + const tempSuffix = yield* Effect.all({ + nowMs: Clock.currentTimeMillis, + random: Random.next, + }).pipe(Effect.map(({ nowMs, random }) => `${nowMs}.${String(random).slice(2)}`)); + const tempPath = path.join( + parentDirectory, + `.${path.basename(target.absolutePath)}.${tempSuffix}.tmp`, + ); + yield* fileSystem.writeFileString(tempPath, input.contents).pipe( + Effect.mapError( + (cause) => + new WorkspaceContextOperationError({ + workspaceRoot: input.workspaceRoot, + relativePath: target.relativePath, + operationPath: tempPath, + operation: "write-file", + cause, + }), + ), + ); + yield* Effect.tryPromise({ + try: () => NodeFSP.rename(tempPath, target.absolutePath), + catch: (cause) => + new WorkspaceContextOperationError({ + workspaceRoot: input.workspaceRoot, + relativePath: target.relativePath, + operationPath: target.absolutePath, + operation: "rename-file", + cause, + }), + }).pipe( + Effect.catch((error) => + Effect.tryPromise({ + try: () => NodeFSP.rm(tempPath, { force: true }), + catch: (cause) => + new WorkspaceContextOperationError({ + workspaceRoot: input.workspaceRoot, + relativePath: target.relativePath, + operationPath: tempPath, + operation: "remove-file", + cause, + }), + }).pipe( + Effect.ignore, + Effect.flatMap(() => Effect.fail(error)), + ), + ), + ); + + return { relativePath: target.relativePath }; + }); + + return WorkspaceContext.of({ initialize, readMarkdownFile, writeMarkdownFile }); +}); + +export const layer = Layer.effect(WorkspaceContext, make); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 554a942d78aa..e76ae874887b 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -84,6 +84,7 @@ import * as PreviewManager from "./preview/Manager.ts"; import { issueAssetUrl } from "./assets/AssetAccess.ts"; import * as PortScanner from "./preview/PortScanner.ts"; import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; +import * as WorkspaceContext from "./workspace/WorkspaceContext.ts"; import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts"; import * as WorkspacePaths from "./workspace/WorkspacePaths.ts"; import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts"; @@ -410,6 +411,7 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => const serverSettings = yield* ServerSettings.ServerSettingsService; const startup = yield* ServerRuntimeStartup.ServerRuntimeStartup; const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const workspaceContext = yield* WorkspaceContext.WorkspaceContext; const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; const projectSetupScriptRunner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; const repositoryIdentityResolver = @@ -853,6 +855,15 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => path: null, }); targetWorktreePath = worktree.worktree.path; + yield* workspaceContext.initialize({ workspaceRoot: targetWorktreePath }).pipe( + Effect.mapError( + (cause) => + new OrchestrationDispatchCommandError({ + message: cause.message, + cause, + }), + ), + ); yield* orchestrationEngine.dispatch({ type: "thread.meta.update", commandId: yield* serverCommandId("bootstrap-thread-meta-update"), diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 43ed895c0db3..75b05c5838c0 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -12,6 +12,8 @@ import { hasServerAcknowledgedLocalDispatch, reconcileMountedTerminalThreadIds, reconcileRetainedMountedThreadIds, + resolveBranchForNewThreadMetadata, + resolveWorkspaceScopedThreadRef, resolveSendEnvMode, shouldWriteThreadErrorToCurrentServerThread, } from "./ChatView.logic"; @@ -142,6 +144,38 @@ describe("deriveComposerSendState", () => { }); }); +describe("resolveBranchForNewThreadMetadata", () => { + it("uses the current git branch for existing worktree chats", () => { + expect( + resolveBranchForNewThreadMetadata({ + activeThreadBranch: "feat/enhancements", + activeWorktreePath: "/repo/.t3/worktrees/enhancements", + currentGitBranch: "t3code/handle-greeting", + }), + ).toBe("t3code/handle-greeting"); + }); + + it("falls back to stored thread branch when git status has no branch", () => { + expect( + resolveBranchForNewThreadMetadata({ + activeThreadBranch: "feat/enhancements", + activeWorktreePath: "/repo/.t3/worktrees/enhancements", + currentGitBranch: null, + }), + ).toBe("feat/enhancements"); + }); + + it("keeps selected base branch for new worktree creation", () => { + expect( + resolveBranchForNewThreadMetadata({ + activeThreadBranch: "main", + activeWorktreePath: null, + currentGitBranch: "feature/current", + }), + ).toBe("main"); + }); +}); + describe("buildExpiredTerminalContextToastCopy", () => { it("formats empty and omission guidance", () => { expect(buildExpiredTerminalContextToastCopy(1, "empty")).toEqual({ @@ -302,6 +336,64 @@ describe("reconcileRetainedMountedThreadIds", () => { }); }); +describe("resolveWorkspaceScopedThreadRef", () => { + it("uses the oldest thread in the same worktree as the workspace owner", () => { + const activeThread = makeThread({ + id: ThreadId.make("thread-active"), + branch: "feature/current-name", + worktreePath: "/repo/.t3/worktrees/workspace-a", + createdAt: "2026-03-29T00:10:00.000Z", + }); + const owner = makeThread({ + id: ThreadId.make("thread-owner"), + branch: "feature/old-name", + worktreePath: "/repo/.t3/worktrees/workspace-a", + createdAt: "2026-03-29T00:00:00.000Z", + }); + const other = makeThread({ + id: ThreadId.make("thread-other"), + branch: "feature/current-name", + worktreePath: "/repo/.t3/worktrees/workspace-b", + createdAt: "2026-03-29T00:00:00.000Z", + }); + + expect( + resolveWorkspaceScopedThreadRef({ + activeThread, + threads: [activeThread, other, owner], + }), + ).toEqual({ environmentId, threadId: ThreadId.make("thread-owner") }); + }); + + it("uses branch as the workspace owner key only when no worktree exists", () => { + const activeThread = makeThread({ + id: ThreadId.make("thread-active"), + branch: "feature/local", + worktreePath: null, + createdAt: "2026-03-29T00:10:00.000Z", + }); + const owner = makeThread({ + id: ThreadId.make("thread-owner"), + branch: "feature/local", + worktreePath: null, + createdAt: "2026-03-29T00:00:00.000Z", + }); + const other = makeThread({ + id: ThreadId.make("thread-other"), + branch: "feature/other", + worktreePath: null, + createdAt: "2026-03-29T00:00:00.000Z", + }); + + expect( + resolveWorkspaceScopedThreadRef({ + activeThread, + threads: [activeThread, other, owner], + }), + ).toEqual({ environmentId, threadId: ThreadId.make("thread-owner") }); + }); +}); + describe("shouldWriteThreadErrorToCurrentServerThread", () => { it("requires the environment, route thread, and target thread to match", () => { const routeThreadRef = { environmentId, threadId }; diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 36947caae6f2..2d251f06613d 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -55,6 +55,17 @@ export function buildLocalDraftThread( }; } +export function resolveBranchForNewThreadMetadata(input: { + activeThreadBranch: string | null; + activeWorktreePath: string | null; + currentGitBranch: string | null; +}): string | null { + if (input.activeWorktreePath && input.currentGitBranch) { + return input.currentGitBranch; + } + return input.activeThreadBranch; +} + export function shouldWriteThreadErrorToCurrentServerThread(input: { serverThread: | { @@ -121,6 +132,57 @@ export function reconcileRetainedMountedThreadIds(input: { return nextThreadIds; } +function normalizedWorkspaceContextValue(value: string | null): string | null { + const trimmed = value?.trim(); + return trimmed && trimmed.length > 0 ? trimmed : null; +} + +function workspaceExecutionContextKey(input: { + branch: string | null; + worktreePath: string | null; +}): string { + const worktreePath = normalizedWorkspaceContextValue(input.worktreePath); + if (worktreePath) { + return `worktree:${worktreePath}`; + } + const branch = normalizedWorkspaceContextValue(input.branch); + if (branch) { + return `branch:${branch}`; + } + return "local"; +} + +export function resolveWorkspaceScopedThreadRef< + TThread extends { + id: ThreadId; + environmentId: EnvironmentId; + projectId: ProjectId; + branch: string | null; + worktreePath: string | null; + createdAt: string; + }, +>(input: { activeThread: TThread; threads: ReadonlyArray }): ScopedThreadRef { + const activeContextKey = workspaceExecutionContextKey(input.activeThread); + const owner = + input.threads + .filter( + (thread) => + thread.environmentId === input.activeThread.environmentId && + thread.projectId === input.activeThread.projectId && + workspaceExecutionContextKey(thread) === activeContextKey, + ) + .sort((left, right) => + left.createdAt === right.createdAt + ? left.id.localeCompare(right.id) + : left.createdAt.localeCompare(right.createdAt), + )[0] ?? input.activeThread; + + return { + environmentId: owner.environmentId, + threadId: owner.id, + }; +} + export function revokeBlobPreviewUrl(previewUrl: string | undefined): void { if (!previewUrl || typeof URL === "undefined" || !previewUrl.startsWith("blob:")) { return; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 44429614b446..e168e5345310 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -187,6 +187,7 @@ import { useThread, useThreadProposedPlans, useThreadRefs, + useThreadShells, } from "../state/entities"; import { environmentShell } from "../state/shell"; import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; @@ -218,6 +219,8 @@ import { deriveLockedProvider, readFileAsDataUrl, reconcileMountedTerminalThreadIds, + resolveBranchForNewThreadMetadata, + resolveWorkspaceScopedThreadRef, resolveSendEnvMode, revokeBlobPreviewUrl, revokeUserMessagePreviewUrls, @@ -1143,9 +1146,6 @@ function ChatViewContent(props: ChatViewProps) { const sendInFlightRef = useRef(false); const terminalUiOpenByThreadRef = useRef>({}); - const terminalUiState = useTerminalUiStateStore((state) => - selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef), - ); const openTerminalThreadKeys = useTerminalUiStateStore( useShallow((state) => Object.entries(state.terminalUiStateByThreadKey).flatMap( @@ -1162,6 +1162,7 @@ function ChatViewContent(props: ChatViewProps) { const storeSetActiveTerminal = useTerminalUiStateStore((s) => s.setActiveTerminal); const storeCloseTerminal = useTerminalUiStateStore((s) => s.closeTerminal); const serverThreadRefs = useThreadRefs(); + const threadShells = useThreadShells(); const serverThreadKeys = useMemo(() => serverThreadRefs.map(scopedThreadKey), [serverThreadRefs]); const draftThreadsByThreadKey = useComposerDraftStore((store) => store.draftThreadsByThreadKey); const draftThreadKeys = useMemo( @@ -1206,6 +1207,24 @@ function ChatViewContent(props: ChatViewProps) { ); const isServerThread = routeKind === "server" && serverThread !== null; const activeThread = isServerThread ? serverThread : localDraftThread; + const activeThreadRef = useMemo( + () => (activeThread ? scopeThreadRef(activeThread.environmentId, activeThread.id) : null), + [activeThread], + ); + const workspaceThreadRef = useMemo( + () => + activeThread + ? resolveWorkspaceScopedThreadRef({ + activeThread, + threads: [activeThread, ...threadShells], + }) + : null, + [activeThread, threadShells], + ); + const workspaceThreadKey = workspaceThreadRef ? scopedThreadKey(workspaceThreadRef) : null; + const terminalUiState = useTerminalUiStateStore((state) => + selectThreadTerminalUiState(state.terminalUiStateByThreadKey, workspaceThreadRef), + ); const threadError = isServerThread ? (localServerError ?? serverThread?.session?.lastError ?? null) : localDraftError; @@ -1215,22 +1234,23 @@ function ChatViewContent(props: ChatViewProps) { const isLocalDraftThread = !isServerThread && localDraftThread !== undefined; const canCheckoutPullRequestIntoThread = isLocalDraftThread; const activeThreadId = activeThread?.id ?? null; + const terminalOwnerThreadId = workspaceThreadRef?.threadId ?? null; const runningTerminalIds = useThreadRunningTerminalIds({ - environmentId: activeThread?.environmentId ?? null, - threadId: activeThreadId, + environmentId: workspaceThreadRef?.environmentId ?? null, + threadId: terminalOwnerThreadId, }); const activeThreadKnownSessionsRaw = useKnownTerminalSessions({ - environmentId: activeThread?.environmentId ?? null, - threadId: activeThreadId, + environmentId: workspaceThreadRef?.environmentId ?? null, + threadId: terminalOwnerThreadId, }); const activeThreadKnownSessions = useMemo(() => { - if (activeThreadId === null) { + if (terminalOwnerThreadId === null) { return []; } return activeThreadKnownSessionsRaw.filter( - (session) => session.target.threadId === activeThreadId, + (session) => session.target.threadId === terminalOwnerThreadId, ); - }, [activeThreadId, activeThreadKnownSessionsRaw]); + }, [activeThreadKnownSessionsRaw, terminalOwnerThreadId]); const activeServerOrderedTerminalIds = useMemo( () => activeThreadKnownSessions.map((session) => session.target.terminalId), [activeThreadKnownSessions], @@ -1249,24 +1269,19 @@ function ChatViewContent(props: ChatViewProps) { } return labels; }, [activeThreadKnownSessions]); - const activeThreadRef = useMemo( - () => (activeThread ? scopeThreadRef(activeThread.environmentId, activeThread.id) : null), - [activeThread], - ); - const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null; const activeRightPanelKind = useRightPanelStore((state) => - selectActiveRightPanel(state.byThreadKey, activeThreadRef), + selectActiveRightPanel(state.byThreadKey, workspaceThreadRef), ); const diffOpen = activeRightPanelKind === "diff"; const rightPanelState = useRightPanelStore((state) => - selectThreadRightPanelState(state.byThreadKey, activeThreadRef), + selectThreadRightPanelState(state.byThreadKey, workspaceThreadRef), ); const activeRightPanelSurface = useRightPanelStore((state) => - selectActiveRightPanelSurface(state.byThreadKey, activeThreadRef), + selectActiveRightPanelSurface(state.byThreadKey, workspaceThreadRef), ); const activeFileSurface = activeRightPanelSurface?.kind === "file" ? activeRightPanelSurface : null; - const activePreviewState = useThreadPreviewState(activeThreadRef); + const activePreviewState = useThreadPreviewState(workspaceThreadRef); const panelTerminalIds = useMemo( () => new Set( @@ -1280,18 +1295,17 @@ function ChatViewContent(props: ChatViewProps) { const rightPanelOpen = rightPanelState.isOpen; const canMaximizeRightPanel = rightPanelOpen && !shouldUsePlanSidebarSheet; const rightPanelMaximized = - canMaximizeRightPanel && maximizedRightPanelThreadKey === routeThreadKey; + canMaximizeRightPanel && maximizedRightPanelThreadKey === workspaceThreadKey; const inlineRightPanelOwnsTitleBar = rightPanelOpen && !shouldUsePlanSidebarSheet; useEffect(() => { - if (!activeThreadRef) return; + if (!workspaceThreadRef) return; useRightPanelStore .getState() - .reconcileBrowserSurfaces(activeThreadRef, Object.keys(activePreviewState.sessions)); - }, [activePreviewState.sessions, activeThreadRef]); + .reconcileBrowserSurfaces(workspaceThreadRef, Object.keys(activePreviewState.sessions)); + }, [activePreviewState.sessions, workspaceThreadRef]); const planSidebarOpen = activeRightPanelKind === "plan"; - const existingOpenTerminalThreadKeys = useMemo(() => { const existingThreadKeys = new Set([...serverThreadKeys, ...draftThreadKeys]); return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey)); @@ -1325,8 +1339,8 @@ function ChatViewContent(props: ChatViewProps) { const nextThreadIds = reconcileMountedTerminalThreadIds({ currentThreadIds, openThreadIds: existingOpenTerminalThreadKeys, - activeThreadId: activeThreadKey, - activeThreadTerminalOpen: Boolean(activeThreadKey && terminalUiState.terminalOpen), + activeThreadId: workspaceThreadKey, + activeThreadTerminalOpen: Boolean(workspaceThreadKey && terminalUiState.terminalOpen), maxHiddenThreadCount: MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, }); return currentThreadIds.length === nextThreadIds.length && @@ -1334,7 +1348,7 @@ function ChatViewContent(props: ChatViewProps) { ? currentThreadIds : nextThreadIds; }); - }, [activeThreadKey, existingOpenTerminalThreadKeys, terminalUiState.terminalOpen]); + }, [existingOpenTerminalThreadKeys, terminalUiState.terminalOpen, workspaceThreadKey]); const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null); const activeProjectRef = activeThread ? scopeProjectRef(activeThread.environmentId, activeThread.projectId) @@ -1377,9 +1391,9 @@ function ChatViewContent(props: ChatViewProps) { ); useEffect(() => { - if (!activeThreadRef || !activeEnvironmentBootstrapComplete) return; - useRightPanelStore.getState().reconcileFileSurfaces(activeThreadRef, activeProject !== null); - }, [activeEnvironmentBootstrapComplete, activeProject, activeThreadRef]); + if (!workspaceThreadRef || !activeEnvironmentBootstrapComplete) return; + useRightPanelStore.getState().reconcileFileSurfaces(workspaceThreadRef, activeProject !== null); + }, [activeEnvironmentBootstrapComplete, activeProject, workspaceThreadRef]); // Compute the list of environments this logical project spans, used to // drive the environment picker in BranchToolbar. @@ -2108,7 +2122,7 @@ function ChatViewContent(props: ChatViewProps) { const activeThreadWorktreePath = activeThread?.worktreePath ?? null; const activeWorkspaceRoot = activeThreadWorktreePath ?? activeProjectCwd ?? undefined; const activeTerminalLaunchContext = - terminalUiLaunchContext?.threadId === activeThreadId ? terminalUiLaunchContext : null; + terminalUiLaunchContext?.threadId === terminalOwnerThreadId ? terminalUiLaunchContext : null; // Default true while loading to avoid toolbar flicker. const isGitRepo = gitStatusQuery.data?.isRepo ?? true; const terminalShortcutLabelOptions = useMemo( @@ -2144,10 +2158,10 @@ function ChatViewContent(props: ChatViewProps) { if (!diffOpen) { onDiffPanelOpen?.(); } - if (activeThreadRef) { - useRightPanelStore.getState().toggle(activeThreadRef, "diff"); + if (workspaceThreadRef) { + useRightPanelStore.getState().toggle(workspaceThreadRef, "diff"); } - }, [activeThreadRef, diffOpen, isServerThread, onDiffPanelOpen]); + }, [diffOpen, isServerThread, onDiffPanelOpen, workspaceThreadRef]); const envLocked = Boolean( activeThread && @@ -2233,16 +2247,16 @@ function ChatViewContent(props: ChatViewProps) { ); const setTerminalOpen = useCallback( (open: boolean) => { - if (!activeThreadRef) return; - storeSetTerminalOpen(activeThreadRef, open); + if (!workspaceThreadRef) return; + storeSetTerminalOpen(workspaceThreadRef, open); }, - [activeThreadRef, storeSetTerminalOpen], + [storeSetTerminalOpen, workspaceThreadRef], ); const toggleTerminalVisibility = useCallback(() => { - if (!activeThreadRef) return; + if (!workspaceThreadRef) return; const nextOpen = !terminalUiState.terminalOpen; if (nextOpen && terminalUiState.terminalIds.length === 0) { - if (!activeThreadId || !activeProject) { + if (!terminalOwnerThreadId || !activeProject) { return; } const cwdForOpen = gitCwd ?? activeProject.workspaceRoot; @@ -2250,11 +2264,11 @@ function ChatViewContent(props: ChatViewProps) { return; } const terminalId = nextTerminalId([...activeKnownTerminalIds, ...panelTerminalIds]); - storeEnsureTerminal(activeThreadRef, terminalId, { open: true }); + storeEnsureTerminal(workspaceThreadRef, terminalId, { open: true }); void openTerminal({ environmentId, input: { - threadId: activeThreadId, + threadId: terminalOwnerThreadId, terminalId, cwd: cwdForOpen, ...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}), @@ -2270,8 +2284,6 @@ function ChatViewContent(props: ChatViewProps) { }, [ activeKnownTerminalIds, activeProject, - activeThreadId, - activeThreadRef, activeThreadWorktreePath, environmentId, gitCwd, @@ -2279,12 +2291,14 @@ function ChatViewContent(props: ChatViewProps) { panelTerminalIds, setTerminalOpen, storeEnsureTerminal, + terminalOwnerThreadId, terminalUiState.terminalIds.length, terminalUiState.terminalOpen, + workspaceThreadRef, ]); const splitTerminal = useCallback( (direction: "horizontal" | "vertical" = "horizontal") => { - if (!activeThreadRef || hasReachedSplitLimit || !activeThreadId || !activeProject) { + if (!workspaceThreadRef || hasReachedSplitLimit || !terminalOwnerThreadId || !activeProject) { return; } const cwdForOpen = gitCwd ?? activeProject.workspaceRoot; @@ -2293,15 +2307,15 @@ function ChatViewContent(props: ChatViewProps) { } const terminalId = nextTerminalId(activeKnownTerminalIds); if (direction === "vertical") { - storeSplitTerminalVertical(activeThreadRef, terminalId); + storeSplitTerminalVertical(workspaceThreadRef, terminalId); } else { - storeSplitTerminal(activeThreadRef, terminalId); + storeSplitTerminal(workspaceThreadRef, terminalId); } setTerminalFocusRequestId((value) => value + 1); void openTerminal({ environmentId, input: { - threadId: activeThreadId, + threadId: terminalOwnerThreadId, terminalId, cwd: cwdForOpen, ...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}), @@ -2315,8 +2329,6 @@ function ChatViewContent(props: ChatViewProps) { [ activeProject, activeKnownTerminalIds, - activeThreadId, - activeThreadRef, openTerminal, activeThreadWorktreePath, environmentId, @@ -2324,10 +2336,12 @@ function ChatViewContent(props: ChatViewProps) { hasReachedSplitLimit, storeSplitTerminal, storeSplitTerminalVertical, + terminalOwnerThreadId, + workspaceThreadRef, ], ); const createNewTerminal = useCallback(() => { - if (!activeThreadRef || !activeThreadId || !activeProject) { + if (!workspaceThreadRef || !terminalOwnerThreadId || !activeProject) { return; } const cwdForOpen = gitCwd ?? activeProject.workspaceRoot; @@ -2335,12 +2349,12 @@ function ChatViewContent(props: ChatViewProps) { return; } const terminalId = nextTerminalId(activeKnownTerminalIds); - storeNewTerminal(activeThreadRef, terminalId); + storeNewTerminal(workspaceThreadRef, terminalId); setTerminalFocusRequestId((value) => value + 1); void openTerminal({ environmentId, input: { - threadId: activeThreadId, + threadId: terminalOwnerThreadId, terminalId, cwd: cwdForOpen, ...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}), @@ -2353,27 +2367,27 @@ function ChatViewContent(props: ChatViewProps) { }, [ activeProject, activeKnownTerminalIds, - activeThreadId, - activeThreadRef, openTerminal, activeThreadWorktreePath, environmentId, gitCwd, storeNewTerminal, + terminalOwnerThreadId, + workspaceThreadRef, ]); const closeTerminal = useCallback( (terminalId: string) => { - if (!activeThreadId || !activeThreadRef) return; + if (!terminalOwnerThreadId || !workspaceThreadRef) return; const fallbackExitWrite = () => writeTerminal({ environmentId, - input: { threadId: activeThreadId, terminalId, data: "exit\n" }, + input: { threadId: terminalOwnerThreadId, terminalId, data: "exit\n" }, }); void (async () => { const closeResult = await closeTerminalMutation({ environmentId, input: { - threadId: activeThreadId, + threadId: terminalOwnerThreadId, terminalId, deleteHistory: true, }, @@ -2382,15 +2396,15 @@ function ChatViewContent(props: ChatViewProps) { await fallbackExitWrite(); } })(); - storeCloseTerminal(activeThreadRef, terminalId); + storeCloseTerminal(workspaceThreadRef, terminalId); setTerminalFocusRequestId((value) => value + 1); }, [ - activeThreadId, - activeThreadRef, closeTerminalMutation, environmentId, storeCloseTerminal, + terminalOwnerThreadId, + workspaceThreadRef, writeTerminal, ], ); @@ -2405,7 +2419,7 @@ function ChatViewContent(props: ChatViewProps) { rememberAsLastInvoked?: boolean; }, ) => { - if (!activeThreadId || !activeProject || !activeThread) return; + if (!terminalOwnerThreadId || !workspaceThreadRef || !activeProject || !activeThread) return; if (options?.rememberAsLastInvoked !== false) { setLastInvokedScriptByProjectId((current) => { if (current[activeProject.id] === script.id) return current; @@ -2421,14 +2435,11 @@ function ChatViewContent(props: ChatViewProps) { const targetWorktreePath = options?.worktreePath ?? activeThread.worktreePath ?? null; setTerminalUiLaunchContext({ - threadId: activeThreadId, + threadId: terminalOwnerThreadId, cwd: targetCwd, worktreePath: targetWorktreePath, }); setTerminalOpen(true); - if (!activeThreadRef) { - return; - } setTerminalFocusRequestId((value) => value + 1); const runtimeEnv = projectScriptRuntimeEnv({ @@ -2443,7 +2454,7 @@ function ChatViewContent(props: ChatViewProps) { : baseTerminalId; const openTerminalInput: TerminalOpenInput = shouldCreateNewTerminal ? { - threadId: activeThreadId, + threadId: terminalOwnerThreadId, terminalId: targetTerminalId, cwd: targetCwd, ...(targetWorktreePath !== null ? { worktreePath: targetWorktreePath } : {}), @@ -2452,7 +2463,7 @@ function ChatViewContent(props: ChatViewProps) { rows: SCRIPT_TERMINAL_ROWS, } : { - threadId: activeThreadId, + threadId: terminalOwnerThreadId, terminalId: targetTerminalId, cwd: targetCwd, ...(targetWorktreePath !== null ? { worktreePath: targetWorktreePath } : {}), @@ -2460,9 +2471,9 @@ function ChatViewContent(props: ChatViewProps) { }; if (shouldCreateNewTerminal) { - storeNewTerminal(activeThreadRef, targetTerminalId); + storeNewTerminal(workspaceThreadRef, targetTerminalId); } else { - storeSetActiveTerminal(activeThreadRef, targetTerminalId); + storeSetActiveTerminal(workspaceThreadRef, targetTerminalId); } const openResult = await openTerminal({ environmentId, input: openTerminalInput }); @@ -2480,7 +2491,7 @@ function ChatViewContent(props: ChatViewProps) { const writeResult = await writeTerminal({ environmentId, input: { - threadId: activeThreadId, + threadId: terminalOwnerThreadId, terminalId: targetTerminalId, data: `${script.command}\r`, }, @@ -2496,8 +2507,6 @@ function ChatViewContent(props: ChatViewProps) { [ activeProject, activeThread, - activeThreadId, - activeThreadRef, gitCwd, setTerminalOpen, setThreadError, @@ -2509,6 +2518,8 @@ function ChatViewContent(props: ChatViewProps) { activeKnownTerminalIds, runningTerminalIds, terminalUiState.activeTerminalId, + terminalOwnerThreadId, + workspaceThreadRef, writeTerminal, ], ); @@ -2711,69 +2722,69 @@ function ChatViewContent(props: ChatViewProps) { activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__"; }, [activePlan?.turnId, sidebarProposedPlan?.turnId]); const togglePlanSidebar = useCallback(() => { - if (!activeThreadRef) return; + if (!workspaceThreadRef) return; if (planSidebarOpen) { dismissPlanSidebarForCurrentTurn(); } else { planSidebarDismissedForTurnRef.current = null; } - useRightPanelStore.getState().toggle(activeThreadRef, "plan"); - }, [activeThreadRef, dismissPlanSidebarForCurrentTurn, planSidebarOpen]); + useRightPanelStore.getState().toggle(workspaceThreadRef, "plan"); + }, [dismissPlanSidebarForCurrentTurn, planSidebarOpen, workspaceThreadRef]); const closePlanSidebar = useCallback(() => { - if (!activeThreadRef) return; + if (!workspaceThreadRef) return; setMaximizedRightPanelThreadKey(null); - useRightPanelStore.getState().close(activeThreadRef); + useRightPanelStore.getState().close(workspaceThreadRef); dismissPlanSidebarForCurrentTurn(); - }, [activeThreadRef, dismissPlanSidebarForCurrentTurn]); + }, [dismissPlanSidebarForCurrentTurn, workspaceThreadRef]); const createBrowserSurface = useCallback(() => { - if (!activeThreadRef) return; - void addBrowserSurface({ threadRef: activeThreadRef, openPreview }); - }, [activeThreadRef, openPreview]); + if (!workspaceThreadRef) return; + void addBrowserSurface({ threadRef: workspaceThreadRef, openPreview }); + }, [openPreview, workspaceThreadRef]); const addDiffSurface = useCallback(() => { - if (!activeThreadRef || !isServerThread || !isGitRepo) return; - useRightPanelStore.getState().open(activeThreadRef, "diff"); + if (!workspaceThreadRef || !isServerThread || !isGitRepo) return; + useRightPanelStore.getState().open(workspaceThreadRef, "diff"); onDiffPanelOpen?.(); - }, [activeThreadRef, isGitRepo, isServerThread, onDiffPanelOpen]); + }, [isGitRepo, isServerThread, onDiffPanelOpen, workspaceThreadRef]); const addFilesSurface = useCallback(() => { - if (!activeThreadRef || !activeProject) return; - useRightPanelStore.getState().open(activeThreadRef, "files"); - }, [activeProject, activeThreadRef]); + if (!workspaceThreadRef || !activeProject) return; + useRightPanelStore.getState().open(workspaceThreadRef, "files"); + }, [activeProject, workspaceThreadRef]); const openFileSurface = useCallback( (relativePath: string) => { - if (!activeThreadRef || !activeProject) return; - useRightPanelStore.getState().openFile(activeThreadRef, relativePath); + if (!workspaceThreadRef || !activeProject) return; + useRightPanelStore.getState().openFile(workspaceThreadRef, relativePath); }, - [activeProject, activeThreadRef], + [activeProject, workspaceThreadRef], ); const togglePreviewPanel = useCallback(() => { - if (!activeThreadRef || !isPreviewSupportedInRuntime()) return; + if (!workspaceThreadRef || !isPreviewSupportedInRuntime()) return; if (previewPanelOpen) { - useRightPanelStore.getState().close(activeThreadRef); + useRightPanelStore.getState().close(workspaceThreadRef); return; } const activeTabId = activePreviewState.activeTabId; if (activeTabId) { - useRightPanelStore.getState().openBrowser(activeThreadRef, activeTabId); + useRightPanelStore.getState().openBrowser(workspaceThreadRef, activeTabId); } else { createBrowserSurface(); } - }, [activePreviewState.activeTabId, activeThreadRef, createBrowserSurface, previewPanelOpen]); + }, [activePreviewState.activeTabId, createBrowserSurface, previewPanelOpen, workspaceThreadRef]); const closePreviewPanel = useCallback(() => { - if (activeThreadRef) { + if (workspaceThreadRef) { setMaximizedRightPanelThreadKey(null); - useRightPanelStore.getState().close(activeThreadRef); + useRightPanelStore.getState().close(workspaceThreadRef); } - }, [activeThreadRef]); + }, [workspaceThreadRef]); const addTerminalSurface = useCallback(() => { - if (!activeThreadRef || !activeThreadId || !activeProject) return; + if (!workspaceThreadRef || !terminalOwnerThreadId || !activeProject) return; const cwd = gitCwd ?? activeProject.workspaceRoot; const terminalId = nextTerminalId([...activeKnownTerminalIds, ...panelTerminalIds]); - useRightPanelStore.getState().openTerminal(activeThreadRef, terminalId); + useRightPanelStore.getState().openTerminal(workspaceThreadRef, terminalId); setTerminalFocusRequestId((value) => value + 1); void openTerminal({ - environmentId: activeThreadRef.environmentId, + environmentId: workspaceThreadRef.environmentId, input: { - threadId: activeThreadId, + threadId: terminalOwnerThreadId, terminalId, cwd, ...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}), @@ -2786,18 +2797,18 @@ function ChatViewContent(props: ChatViewProps) { }, [ activeKnownTerminalIds, activeProject, - activeThreadId, - activeThreadRef, activeThreadWorktreePath, gitCwd, openTerminal, panelTerminalIds, + terminalOwnerThreadId, + workspaceThreadRef, ]); const splitPanelTerminal = useCallback( (direction: "horizontal" | "vertical" = "horizontal") => { if ( - !activeThreadRef || - !activeThreadId || + !workspaceThreadRef || + !terminalOwnerThreadId || !activeProject || activeRightPanelSurface?.kind !== "terminal" || activeRightPanelSurface.terminalIds.length >= MAX_TERMINALS_PER_GROUP @@ -2808,12 +2819,12 @@ function ChatViewContent(props: ChatViewProps) { const cwd = gitCwd ?? activeProject.workspaceRoot; useRightPanelStore .getState() - .splitTerminal(activeThreadRef, activeRightPanelSurface.id, terminalId, direction); + .splitTerminal(workspaceThreadRef, activeRightPanelSurface.id, terminalId, direction); setTerminalFocusRequestId((value) => value + 1); void openTerminal({ - environmentId: activeThreadRef.environmentId, + environmentId: workspaceThreadRef.environmentId, input: { - threadId: activeThreadId, + threadId: terminalOwnerThreadId, terminalId, cwd, ...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}), @@ -2828,12 +2839,12 @@ function ChatViewContent(props: ChatViewProps) { activeKnownTerminalIds, activeProject, activeRightPanelSurface, - activeThreadId, - activeThreadRef, activeThreadWorktreePath, gitCwd, openTerminal, panelTerminalIds, + terminalOwnerThreadId, + workspaceThreadRef, ], ); const splitPanelTerminalVertical = useCallback(() => { @@ -2841,40 +2852,40 @@ function ChatViewContent(props: ChatViewProps) { }, [splitPanelTerminal]); const activatePanelTerminal = useCallback( (terminalId: string) => { - if (!activeThreadRef || activeRightPanelSurface?.kind !== "terminal") return; + if (!workspaceThreadRef || activeRightPanelSurface?.kind !== "terminal") return; useRightPanelStore .getState() - .activateTerminal(activeThreadRef, activeRightPanelSurface.id, terminalId); + .activateTerminal(workspaceThreadRef, activeRightPanelSurface.id, terminalId); setTerminalFocusRequestId((value) => value + 1); }, - [activeRightPanelSurface, activeThreadRef], + [activeRightPanelSurface, workspaceThreadRef], ); const closePanelTerminal = useCallback( (terminalId: string) => { - if (!activeThreadRef || activeRightPanelSurface?.kind !== "terminal") return; + if (!workspaceThreadRef || activeRightPanelSurface?.kind !== "terminal") return; void closeTerminalMutation({ - environmentId: activeThreadRef.environmentId, - input: { threadId: activeThreadRef.threadId, terminalId, deleteHistory: true }, + environmentId: workspaceThreadRef.environmentId, + input: { threadId: workspaceThreadRef.threadId, terminalId, deleteHistory: true }, }); - storeCloseTerminal(activeThreadRef, terminalId); + storeCloseTerminal(workspaceThreadRef, terminalId); useRightPanelStore .getState() - .closeTerminal(activeThreadRef, activeRightPanelSurface.id, terminalId); + .closeTerminal(workspaceThreadRef, activeRightPanelSurface.id, terminalId); setTerminalFocusRequestId((value) => value + 1); }, - [activeRightPanelSurface, activeThreadRef, closeTerminalMutation, storeCloseTerminal], + [activeRightPanelSurface, closeTerminalMutation, storeCloseTerminal, workspaceThreadRef], ); const activateRightPanelSurface = useCallback( (surface: RightPanelSurface) => { - if (!activeThreadRef) return; + if (!workspaceThreadRef) return; if (surface.kind === "plan") { planSidebarDismissedForTurnRef.current = null; } else if (planSidebarOpen) { dismissPlanSidebarForCurrentTurn(); } - useRightPanelStore.getState().activateSurface(activeThreadRef, surface.id); + useRightPanelStore.getState().activateSurface(workspaceThreadRef, surface.id); if (surface.kind === "preview" && surface.resourceId) { - setActivePreviewTab(activeThreadRef, surface.resourceId); + setActivePreviewTab(workspaceThreadRef, surface.resourceId); } if (surface.kind === "terminal") { setTerminalFocusRequestId((value) => value + 1); @@ -2883,10 +2894,16 @@ function ChatViewContent(props: ChatViewProps) { onDiffPanelOpen?.(); } }, - [activeThreadRef, diffOpen, dismissPlanSidebarForCurrentTurn, onDiffPanelOpen, planSidebarOpen], + [ + diffOpen, + dismissPlanSidebarForCurrentTurn, + onDiffPanelOpen, + planSidebarOpen, + workspaceThreadRef, + ], ); const toggleRightPanel = useCallback(() => { - if (!activeThreadRef) return; + if (!workspaceThreadRef) return; if (rightPanelOpen) { if (planSidebarOpen) { closePlanSidebar(); @@ -2895,17 +2912,17 @@ function ChatViewContent(props: ChatViewProps) { } return; } - useRightPanelStore.getState().toggleVisibility(activeThreadRef); - }, [activeThreadRef, closePlanSidebar, closePreviewPanel, planSidebarOpen, rightPanelOpen]); + useRightPanelStore.getState().toggleVisibility(workspaceThreadRef); + }, [closePlanSidebar, closePreviewPanel, planSidebarOpen, rightPanelOpen, workspaceThreadRef]); const toggleRightPanelMaximized = useCallback(() => { if (!canMaximizeRightPanel) return; setMaximizedRightPanelThreadKey((threadKey) => - threadKey === routeThreadKey ? null : routeThreadKey, + threadKey === workspaceThreadKey ? null : workspaceThreadKey, ); - }, [canMaximizeRightPanel, routeThreadKey]); + }, [canMaximizeRightPanel, workspaceThreadKey]); const cleanupRightPanelSurfaces = useCallback( (surfaces: readonly RightPanelSurface[]) => { - if (!activeThreadRef) return; + if (!workspaceThreadRef) return; if (surfaces.some((surface) => surface.kind === "plan")) { dismissPlanSidebarForCurrentTurn(); } @@ -2916,85 +2933,85 @@ function ChatViewContent(props: ChatViewProps) { closePreview, snapshot: activePreviewState.sessions[surface.resourceId] ?? null, tabId: surface.resourceId, - threadRef: activeThreadRef, + threadRef: workspaceThreadRef, }); } if (surface.kind === "terminal") { for (const terminalId of surface.terminalIds) { - storeCloseTerminal(activeThreadRef, terminalId); + storeCloseTerminal(workspaceThreadRef, terminalId); void closeTerminalMutation({ - environmentId: activeThreadRef.environmentId, - input: { threadId: activeThreadRef.threadId, terminalId, deleteHistory: true }, + environmentId: workspaceThreadRef.environmentId, + input: { threadId: workspaceThreadRef.threadId, terminalId, deleteHistory: true }, }); } } } }, [ - activeThreadRef, activePreviewState.sessions, closePreview, closeTerminalMutation, dismissPlanSidebarForCurrentTurn, storeCloseTerminal, + workspaceThreadRef, ], ); const syncActivePreviewSurface = useCallback(() => { - if (!activeThreadRef) return; + if (!workspaceThreadRef) return; const nextActiveSurface = selectActiveRightPanelSurface( useRightPanelStore.getState().byThreadKey, - activeThreadRef, + workspaceThreadRef, ); if (nextActiveSurface?.kind === "preview" && nextActiveSurface.resourceId) { - setActivePreviewTab(activeThreadRef, nextActiveSurface.resourceId); + setActivePreviewTab(workspaceThreadRef, nextActiveSurface.resourceId); } - }, [activeThreadRef]); + }, [workspaceThreadRef]); const closeRightPanelSurface = useCallback( (surface: RightPanelSurface) => { - if (!activeThreadRef) return; + if (!workspaceThreadRef) return; cleanupRightPanelSurfaces([surface]); - useRightPanelStore.getState().closeSurface(activeThreadRef, surface.id); + useRightPanelStore.getState().closeSurface(workspaceThreadRef, surface.id); syncActivePreviewSurface(); }, - [activeThreadRef, cleanupRightPanelSurfaces, syncActivePreviewSurface], + [cleanupRightPanelSurfaces, syncActivePreviewSurface, workspaceThreadRef], ); const closeOtherRightPanelSurfaces = useCallback( (surface: RightPanelSurface) => { - if (!activeThreadRef) return; + if (!workspaceThreadRef) return; const surfaces = rightPanelState.surfaces.filter((entry) => entry.id !== surface.id); cleanupRightPanelSurfaces(surfaces); - useRightPanelStore.getState().closeOtherSurfaces(activeThreadRef, surface.id); + useRightPanelStore.getState().closeOtherSurfaces(workspaceThreadRef, surface.id); syncActivePreviewSurface(); }, [ - activeThreadRef, cleanupRightPanelSurfaces, rightPanelState.surfaces, syncActivePreviewSurface, + workspaceThreadRef, ], ); const closeRightPanelSurfacesToRight = useCallback( (surface: RightPanelSurface) => { - if (!activeThreadRef) return; + if (!workspaceThreadRef) return; const surfaceIndex = rightPanelState.surfaces.findIndex((entry) => entry.id === surface.id); if (surfaceIndex < 0) return; const surfaces = rightPanelState.surfaces.slice(surfaceIndex + 1); cleanupRightPanelSurfaces(surfaces); - useRightPanelStore.getState().closeSurfacesToRight(activeThreadRef, surface.id); + useRightPanelStore.getState().closeSurfacesToRight(workspaceThreadRef, surface.id); syncActivePreviewSurface(); }, [ - activeThreadRef, cleanupRightPanelSurfaces, rightPanelState.surfaces, syncActivePreviewSurface, + workspaceThreadRef, ], ); const closeAllRightPanelSurfaces = useCallback(() => { - if (!activeThreadRef) return; + if (!workspaceThreadRef) return; cleanupRightPanelSurfaces(rightPanelState.surfaces); - useRightPanelStore.getState().closeAllSurfaces(activeThreadRef); - }, [activeThreadRef, cleanupRightPanelSurfaces, rightPanelState.surfaces]); + useRightPanelStore.getState().closeAllSurfaces(workspaceThreadRef); + }, [cleanupRightPanelSurfaces, rightPanelState.surfaces, workspaceThreadRef]); const copyRightPanelFilePath = useCallback((relativePath: string) => { if (typeof window === "undefined" || !navigator.clipboard?.writeText) { toastManager.add( @@ -3138,8 +3155,8 @@ function ChatViewContent(props: ChatViewProps) { setShowScrollToBottom(false); if (planSidebarOpenOnNextThreadRef.current) { planSidebarOpenOnNextThreadRef.current = false; - if (activeThreadRef) { - useRightPanelStore.getState().open(activeThreadRef, "plan"); + if (workspaceThreadRef) { + useRightPanelStore.getState().open(workspaceThreadRef, "plan"); } } planSidebarDismissedForTurnRef.current = null; @@ -3157,16 +3174,16 @@ function ChatViewContent(props: ChatViewProps) { if (latestTurnId && activePlan.turnId !== latestTurnId) return; const turnKey = activePlan.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__"; if (planSidebarDismissedForTurnRef.current === turnKey) return; - if (activeThreadRef) { - useRightPanelStore.getState().open(activeThreadRef, "plan"); + if (workspaceThreadRef) { + useRightPanelStore.getState().open(workspaceThreadRef, "plan"); } }, [ activePlan, activeLatestTurn?.turnId, - activeThreadRef, autoOpenPlanSidebar, planSidebarOpen, sidebarProposedPlan?.turnId, + workspaceThreadRef, ]); useEffect(() => { @@ -3246,6 +3263,12 @@ function ChatViewContent(props: ChatViewProps) { canOverrideServerThreadEnvMode && pendingServerThreadBranch !== undefined ? pendingServerThreadBranch : (activeThread?.branch ?? null); + const currentGitBranch = gitStatusQuery.data?.refName ?? null; + const branchForNewThreadMetadata = resolveBranchForNewThreadMetadata({ + activeThreadBranch, + activeWorktreePath, + currentGitBranch, + }); const startFromOrigin = isLocalDraftThread ? (draftThread?.startFromOrigin ?? false) : canOverrideServerThreadEnvMode @@ -3271,23 +3294,23 @@ function ChatViewContent(props: ChatViewProps) { }, [canOverrideServerThreadEnvMode]); useEffect(() => { - if (!activeThreadId) { + if (!terminalOwnerThreadId) { setTerminalUiLaunchContext(null); return; } setTerminalUiLaunchContext((current) => { if (!current) return current; - if (current.threadId === activeThreadId) return current; + if (current.threadId === terminalOwnerThreadId) return current; return null; }); - }, [activeThreadId]); + }, [terminalOwnerThreadId]); useEffect(() => { - if (!activeThreadId || !activeProjectCwd) { + if (!terminalOwnerThreadId || !activeProjectCwd) { return; } setTerminalUiLaunchContext((current) => { - if (!current || current.threadId !== activeThreadId) { + if (!current || current.threadId !== terminalOwnerThreadId) { return current; } const settledCwd = projectScriptCwd({ @@ -3302,28 +3325,28 @@ function ChatViewContent(props: ChatViewProps) { } return current; }); - }, [activeProjectCwd, activeThreadId, activeThreadWorktreePath]); + }, [activeProjectCwd, activeThreadWorktreePath, terminalOwnerThreadId]); useEffect(() => { if (terminalUiState.terminalOpen) { return; } setTerminalUiLaunchContext((current) => - current?.threadId === activeThreadId ? null : current, + current?.threadId === terminalOwnerThreadId ? null : current, ); - }, [activeThreadId, terminalUiState.terminalOpen]); + }, [terminalOwnerThreadId, terminalUiState.terminalOpen]); useEffect(() => { - if (!activeThreadKey) return; - const previous = terminalUiOpenByThreadRef.current[activeThreadKey] ?? false; + if (!workspaceThreadKey) return; + const previous = terminalUiOpenByThreadRef.current[workspaceThreadKey] ?? false; const current = Boolean(terminalUiState.terminalOpen); if (!previous && current) { - terminalUiOpenByThreadRef.current[activeThreadKey] = current; + terminalUiOpenByThreadRef.current[workspaceThreadKey] = current; setTerminalFocusRequestId((value) => value + 1); return; } else if (previous && !current) { - terminalUiOpenByThreadRef.current[activeThreadKey] = current; + terminalUiOpenByThreadRef.current[workspaceThreadKey] = current; const frame = window.requestAnimationFrame(() => { focusComposer(); }); @@ -3332,8 +3355,8 @@ function ChatViewContent(props: ChatViewProps) { }; } - terminalUiOpenByThreadRef.current[activeThreadKey] = current; - }, [activeThreadKey, focusComposer, terminalUiState.terminalOpen]); + terminalUiOpenByThreadRef.current[workspaceThreadKey] = current; + }, [focusComposer, terminalUiState.terminalOpen, workspaceThreadKey]); useEffect(() => { const handler = (event: globalThis.KeyboardEvent) => { @@ -3632,14 +3655,14 @@ function ChatViewContent(props: ChatViewProps) { const isFirstMessage = !isServerThread || activeThread.messages.length === 0; const baseBranchForWorktree = isFirstMessage && sendEnvMode === "worktree" && !activeThread.worktreePath - ? activeThreadBranch + ? branchForNewThreadMetadata : null; // In worktree mode, require an explicit base branch so we don't silently // fall back to local execution when branch selection is missing. const shouldCreateWorktree = isFirstMessage && sendEnvMode === "worktree" && !activeThread.worktreePath; - if (shouldCreateWorktree && !activeThreadBranch) { + if (shouldCreateWorktree && !branchForNewThreadMetadata) { setThreadError(threadIdForSend, "Select a base branch before sending in New worktree mode."); return; } @@ -3802,7 +3825,7 @@ function ChatViewContent(props: ChatViewProps) { modelSelection: threadCreateModelSelection, runtimeMode, interactionMode, - branch: activeThreadBranch, + branch: branchForNewThreadMetadata, worktreePath: activeThread.worktreePath, createdAt: activeThread.createdAt, }, @@ -4197,8 +4220,8 @@ function ChatViewContent(props: ChatViewProps) { // step-tracking activities that the sidebar will display. if (nextInteractionMode === "default" && autoOpenPlanSidebar) { planSidebarDismissedForTurnRef.current = null; - if (activeThreadRef) { - useRightPanelStore.getState().open(activeThreadRef, "plan"); + if (workspaceThreadRef) { + useRightPanelStore.getState().open(workspaceThreadRef, "plan"); } } sendInFlightRef.current = false; @@ -4293,7 +4316,7 @@ function ChatViewContent(props: ChatViewProps) { modelSelection: nextThreadModelSelection, runtimeMode, interactionMode: "default", - branch: activeThreadBranch, + branch: branchForNewThreadMetadata, worktreePath: activeThread.worktreePath, createdAt, }, @@ -4379,8 +4402,8 @@ function ChatViewContent(props: ChatViewProps) { }, [ activeProject, activeProposedPlan, - activeThreadBranch, activeThread, + branchForNewThreadMetadata, beginLocalDispatch, activeEnvironmentUnavailable, createThread, @@ -4542,12 +4565,13 @@ function ChatViewContent(props: ChatViewProps) { }, []); const onOpenTurnDiff = useCallback( (turnId: TurnId, filePath?: string) => { - if (!isServerThread || !activeThreadRef) return; - useDiffPanelStore.getState().selectTurn(activeThreadRef, turnId, filePath); - useRightPanelStore.getState().open(activeThreadRef, "diff"); + const diffThreadRef = workspaceThreadRef ?? activeThreadRef; + if (!isServerThread || !diffThreadRef) return; + useDiffPanelStore.getState().selectTurn(diffThreadRef, turnId, filePath); + useRightPanelStore.getState().open(diffThreadRef, "diff"); onDiffPanelOpen?.(); }, - [activeThreadRef, isServerThread, onDiffPanelOpen], + [activeThreadRef, isServerThread, onDiffPanelOpen, workspaceThreadRef], ); // Both the Map and the revert handler are read from refs at call-time so // the callback reference is fully stable and never busts context identity. @@ -4596,7 +4620,7 @@ function ChatViewContent(props: ChatViewProps) { ) : activeRightPanelSurface?.kind === "terminal" ? ( {isElectron && activeThreadRef ? ( - + ) : null} {rightPanelOpen && !shouldUsePlanSidebarSheet ? panelLayoutControls : null}
{ it("preserves thread project-name matches when there is no stronger title match", () => { const group: CommandPaletteGroup = { value: "threads-search", - label: "Threads", + label: "Chats", items: [ { kind: "action", @@ -162,4 +164,91 @@ describe("buildThreadActionItems", () => { expect(items.map((item) => item.value)).toEqual(["thread:thread-active"]); }); + + it("describes branch-backed threads as chats in their workspace", () => { + const items = buildThreadActionItems({ + threads: [ + makeThread({ + id: ThreadId.make("thread-active"), + title: "Find Next Tracker Task", + branch: "ios-app-stripe-plan", + }), + ], + activeThreadId: ThreadId.make("thread-active"), + projectTitleById: new Map([[PROJECT_ID, "capsi-ios-app"]]), + sortOrder: "updated_at", + icon: null, + runThread: async (_thread) => undefined, + }); + + expect(items[0]?.description).toBe("capsi-ios-app · ios-app-stripe-plan · Current chat"); + }); + + it("falls back to the worktree folder for the workspace subtitle", () => { + const items = buildThreadActionItems({ + threads: [ + makeThread({ + id: ThreadId.make("thread-worktree"), + title: "Run App Command", + worktreePath: "/repo/.t3/worktrees/ios-app-stripe-plan", + }), + ], + projectTitleById: new Map([[PROJECT_ID, "capsi-ios-app"]]), + sortOrder: "updated_at", + icon: null, + runThread: async (_thread) => undefined, + }); + + expect(items[0]?.description).toBe("capsi-ios-app · ios-app-stripe-plan"); + }); +}); + +describe("buildRootGroups", () => { + it("labels recent threads as recent chats", () => { + const groups = buildRootGroups({ + actionItems: [], + recentThreadItems: [ + { + kind: "action", + value: "thread:thread-1", + searchTerms: ["Thread"], + title: "Thread", + icon: null, + run: async () => undefined, + }, + ], + }); + + expect(groups[0]?.label).toBe("Recent Chats"); + }); +}); + +describe("filterCommandPaletteGroups", () => { + it("labels thread search results as chats", () => { + const threadItems = buildThreadActionItems({ + threads: [makeThread({ title: "Find Next Tracker Task" })], + projectTitleById: new Map([[PROJECT_ID, "capsi-ios-app"]]), + sortOrder: "updated_at", + icon: null, + runThread: async (_thread) => undefined, + }); + + const groups = filterCommandPaletteGroups({ + activeGroups: [], + query: "tracker", + isInSubmenu: false, + projectSearchItems: [], + threadSearchItems: threadItems, + }); + + expect(groups[0]?.label).toBe("Chats"); + }); +}); + +describe("getCommandPaletteInputPlaceholder", () => { + it("uses chat terminology in the root placeholder", () => { + expect(getCommandPaletteInputPlaceholder("root")).toBe( + "Search commands, projects, and chats...", + ); + }); }); diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index ab53adbefb16..caa2f1ca9221 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -6,6 +6,7 @@ import { type ReactNode } from "react"; import { sortThreads } from "../lib/threadSort"; import { formatRelativeTimeLabel } from "../timestampFormat"; import { type Project, type SidebarThreadSummary, type Thread } from "../types"; +import { resolveDefaultWorkspaceTitle } from "./WorkspacePresentation.logic"; export const RECENT_THREAD_LIMIT = 12; export const ITEM_ICON_CLASS = "size-4 text-muted-foreground/80"; @@ -113,7 +114,14 @@ export function buildProjectActionItems(input: { export type BuildThreadActionItemsThread = Pick< SidebarThreadSummary, - "archivedAt" | "branch" | "createdAt" | "environmentId" | "id" | "projectId" | "title" + | "archivedAt" + | "branch" + | "createdAt" + | "environmentId" + | "id" + | "projectId" + | "title" + | "worktreePath" > & { updatedAt: string; latestUserMessageAt?: string | null; @@ -146,11 +154,15 @@ export function buildThreadActionItems 0) { searchableGroups.push({ value: "threads-search", - label: "Threads", + label: "Chats", items: input.threadSearchItems, }); } @@ -342,7 +354,7 @@ export function buildRootGroups(input: { if (input.recentThreadItems.length > 0) { groups.push({ value: "recent-threads", - label: "Recent Threads", + label: "Recent Chats", items: input.recentThreadItems, }); } @@ -352,7 +364,7 @@ export function buildRootGroups(input: { export function getCommandPaletteInputPlaceholder(mode: CommandPaletteMode): string { switch (mode) { case "root": - return "Search commands, projects, and threads..."; + return "Search commands, projects, and chats..."; case "root-browse": return "Enter project path (e.g. ~/projects/my-app)"; case "submenu": diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index a11d6c4cb071..b90815a99d7b 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -8,6 +8,7 @@ import { } from "@t3tools/client-runtime/state/runtime"; import { DEFAULT_MODEL, + DEFAULT_SERVER_SETTINGS, type EnvironmentId, type FilesystemBrowseResult, type ProjectId, @@ -45,7 +46,7 @@ import { import { useAtomValue } from "@effect/atom-react"; import { OpenAddProjectCommandPaletteProvider } from "../commandPaletteContext"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; -import { useClientSettings } from "../hooks/useSettings"; +import { useClientSettings, usePrimarySettings } from "../hooks/useSettings"; import { readLocalApi } from "../localApi"; import { filesystemEnvironment } from "../state/filesystem"; import { projectEnvironment } from "../state/projects"; @@ -54,7 +55,7 @@ import { sourceControlEnvironment } from "../state/sourceControl"; import { useAtomCommand } from "../state/use-atom-command"; import { useAtomQueryRunner } from "../state/use-atom-query-runner"; import { useEnvironments, usePrimaryEnvironment } from "../state/environments"; -import { useProjects, useThreadShells } from "../state/entities"; +import { useProjects, useServerConfigs, useThreadShells } from "../state/entities"; import { startNewThreadInProjectFromContext, startNewThreadFromContext, @@ -448,6 +449,7 @@ function OpenCommandPaletteDialog(props: { const isActionsOnly = deferredQuery.startsWith(">"); const [highlightedItemValue, setHighlightedItemValue] = useState(null); const clientSettings = useClientSettings(); + const primarySettings = usePrimarySettings(); const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false, }); @@ -459,6 +461,7 @@ function OpenCommandPaletteDialog(props: { }); const { environments } = useEnvironments(); const primaryEnvironment = usePrimaryEnvironment(); + const serverConfigs = useServerConfigs(); const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread } = useHandleNewThread(); const projects = useProjects(); @@ -640,13 +643,16 @@ function OpenCommandPaletteDialog(props: { activeDraftThread, activeThread: activeThread ?? undefined, defaultProjectRef, + defaultThreadEnvMode: + serverConfigs.get(project.environmentId)?.settings.defaultThreadEnvMode ?? + DEFAULT_SERVER_SETTINGS.defaultThreadEnvMode, handleNewThread, }, scopeProjectRef(project.environmentId, project.id), ); }, }), - [activeDraftThread, activeThread, defaultProjectRef, handleNewThread, projects], + [activeDraftThread, activeThread, defaultProjectRef, handleNewThread, projects, serverConfigs], ); const allThreadItems = useMemo( @@ -925,10 +931,10 @@ function OpenCommandPaletteDialog(props: { actionItems.push({ kind: "action", value: "action:new-thread", - searchTerms: ["new thread", "chat", "create", "draft"], + searchTerms: ["new chat", "new thread", "chat", "create", "draft"], title: ( <> - New thread in {activeProjectTitle} + New chat in {activeProjectTitle} ), icon: , @@ -938,6 +944,8 @@ function OpenCommandPaletteDialog(props: { activeDraftThread, activeThread: activeThread ?? undefined, defaultProjectRef, + defaultThreadEnvMode: + primarySettings.defaultThreadEnvMode ?? DEFAULT_SERVER_SETTINGS.defaultThreadEnvMode, handleNewThread, }); }, @@ -947,8 +955,8 @@ function OpenCommandPaletteDialog(props: { actionItems.push({ kind: "submenu", value: "action:new-thread-in", - searchTerms: ["new thread", "project", "pick", "choose", "select"], - title: "New thread in...", + searchTerms: ["new chat", "new thread", "project", "pick", "choose", "select"], + title: "New chat in...", icon: , addonIcon: , groups: [{ value: "projects", label: "Projects", items: projectThreadItems }], diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 574e33d4dab7..b445ede76cde 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -3,8 +3,11 @@ import { createThreadJumpHintVisibilityController, getSidebarThreadIdsToPrewarm, getVisibleSidebarThreadIds, + getVisibleWorkspaceThreads, resolveAdjacentThreadId, getFallbackThreadIdAfterDelete, + buildDefaultWorkspacesForThreads, + getVisibleWorkspaceSidebarThreadKeys, getVisibleThreadsForProject, getProjectSortTimestamp, hasUnseenCompletion, @@ -27,6 +30,7 @@ import { ProjectId, ProviderInstanceId, ThreadId, + WorkspaceId, } from "@t3tools/contracts"; import { DEFAULT_INTERACTION_MODE, @@ -792,6 +796,256 @@ describe("getVisibleThreadsForProject", () => { }); }); +describe("buildDefaultWorkspacesForThreads", () => { + it("groups legacy threads that share the same execution context into one default workspace", () => { + const workspaces = buildDefaultWorkspacesForThreads({ + projectKey: "project:local:project-1", + threads: [ + makeThread({ + id: ThreadId.make("thread-1"), + title: "Add settings", + branch: "feature/settings", + worktreePath: "/repo/.t3/worktrees/settings", + createdAt: "2026-03-09T10:00:00.000Z", + updatedAt: "2026-03-09T10:05:00.000Z", + }), + makeThread({ + id: ThreadId.make("thread-2"), + title: "Review settings", + branch: "feature/settings", + worktreePath: "/repo/.t3/worktrees/settings", + createdAt: "2026-03-09T10:10:00.000Z", + updatedAt: "2026-03-09T10:20:00.000Z", + }), + makeThread({ + id: ThreadId.make("thread-3"), + title: "Fix auth", + branch: null, + worktreePath: null, + createdAt: "2026-03-09T10:30:00.000Z", + updatedAt: "2026-03-09T10:40:00.000Z", + }), + ], + getThreadKey: (thread) => `thread:${thread.id}`, + }); + + expect(workspaces).toEqual([ + { + id: "project:local:project-1:workspace:worktree:/repo/.t3/worktrees/settings", + projectKey: "project:local:project-1", + title: "feature/settings", + branch: "feature/settings", + worktreePath: "/repo/.t3/worktrees/settings", + lastActiveThreadKey: "thread:thread-1", + threads: [ + expect.objectContaining({ + id: ThreadId.make("thread-1"), + title: "Add settings", + }), + expect.objectContaining({ + id: ThreadId.make("thread-2"), + title: "Review settings", + }), + ], + createdAt: "2026-03-09T10:00:00.000Z", + updatedAt: "2026-03-09T10:20:00.000Z", + }, + { + id: "project:local:project-1:workspace:local", + projectKey: "project:local:project-1", + title: "Local workspace", + branch: null, + worktreePath: null, + lastActiveThreadKey: "thread:thread-3", + threads: [ + expect.objectContaining({ + id: ThreadId.make("thread-3"), + title: "Fix auth", + }), + ], + createdAt: "2026-03-09T10:30:00.000Z", + updatedAt: "2026-03-09T10:40:00.000Z", + }, + ]); + }); + + it("falls back to the worktree folder when a legacy thread has no branch", () => { + const workspaces = buildDefaultWorkspacesForThreads({ + projectKey: "project:local:project-1", + threads: [ + makeThread({ + id: ThreadId.make("thread-worktree"), + title: "Fix checkout", + branch: null, + worktreePath: "/repo/.t3/worktrees/checkout-fix", + }), + ], + getThreadKey: (thread) => `thread:${thread.id}`, + }); + + expect(workspaces[0]?.title).toBe("checkout-fix"); + }); + + it("keeps the generated workspace id stable when a worktree branch is renamed", () => { + const beforeRename = buildDefaultWorkspacesForThreads({ + projectKey: "project:local:project-1", + threads: [ + makeThread({ + id: ThreadId.make("thread-worktree"), + title: "Run checks", + branch: "feature/old-name", + worktreePath: "/repo/.t3/worktrees/checks", + }), + ], + getThreadKey: (thread) => `thread:${thread.id}`, + }); + const afterRename = buildDefaultWorkspacesForThreads({ + projectKey: "project:local:project-1", + threads: [ + makeThread({ + id: ThreadId.make("thread-worktree"), + title: "Run checks", + branch: "feature/new-name", + worktreePath: "/repo/.t3/worktrees/checks", + }), + ], + getThreadKey: (thread) => `thread:${thread.id}`, + }); + + expect(afterRename[0]?.id).toBe(beforeRename[0]?.id); + expect(afterRename[0]?.title).toBe("feature/new-name"); + }); + + it("uses persisted workspace identity and metadata when available", () => { + const beforeRename = buildDefaultWorkspacesForThreads({ + projectKey: "project:local:project-1", + threads: [ + makeThread({ + id: ThreadId.make("thread-worktree"), + title: "Run checks", + branch: "feature/old-name", + worktreePath: "/repo/.t3/worktrees/checks", + workspaceId: WorkspaceId.make("workspace-checks"), + workspaceBranch: "feature/old-name", + workspaceWorktreePath: "/repo/.t3/worktrees/checks", + }), + ], + getThreadKey: (thread) => `thread:${thread.id}`, + }); + const afterRename = buildDefaultWorkspacesForThreads({ + projectKey: "project:local:project-1", + threads: [ + makeThread({ + id: ThreadId.make("thread-worktree"), + title: "Run checks", + branch: "feature/new-name", + worktreePath: "/repo/.t3/worktrees/checks", + workspaceId: WorkspaceId.make("workspace-checks"), + workspaceBranch: "feature/new-name", + workspaceWorktreePath: "/repo/.t3/worktrees/checks", + }), + ], + getThreadKey: (thread) => `thread:${thread.id}`, + }); + + expect(beforeRename[0]?.id).toBe("project:local:project-1:workspace:workspace-checks"); + expect(afterRename[0]?.id).toBe(beforeRename[0]?.id); + expect(afterRename[0]?.title).toBe("feature/new-name"); + expect(afterRename[0]?.branch).toBe("feature/new-name"); + }); + + it("uses a stable local workspace label for legacy threads without branch or worktree context", () => { + const workspaces = buildDefaultWorkspacesForThreads({ + projectKey: "project:local:project-1", + threads: [ + makeThread({ + id: ThreadId.make("thread-untitled"), + title: " ", + }), + ], + getThreadKey: (thread) => `thread:${thread.id}`, + }); + + expect(workspaces[0]?.title).toBe("Local workspace"); + }); +}); + +describe("getVisibleWorkspaceThreads", () => { + it("shows all workspace chats while expanded", () => { + const threads = [ + makeThread({ id: ThreadId.make("thread-1") }), + makeThread({ id: ThreadId.make("thread-2") }), + ]; + + expect(getVisibleWorkspaceThreads({ threads, workspaceExpanded: true })).toEqual(threads); + }); + + it("hides all workspace chats while collapsed, including the active chat", () => { + const threads = [ + makeThread({ id: ThreadId.make("thread-1") }), + makeThread({ id: ThreadId.make("thread-2") }), + ]; + + expect(getVisibleWorkspaceThreads({ threads, workspaceExpanded: false })).toEqual([]); + }); +}); + +describe("getVisibleWorkspaceSidebarThreadKeys", () => { + it("returns visible chat keys for expanded workspaces", () => { + const threads = [ + makeThread({ id: ThreadId.make("thread-1") }), + makeThread({ id: ThreadId.make("thread-2") }), + ]; + const workspaces = buildDefaultWorkspacesForThreads({ + projectKey: "project:local:project-1", + threads, + getThreadKey: (thread) => `thread:${thread.id}`, + }); + + expect( + getVisibleWorkspaceSidebarThreadKeys({ + workspaces, + projectExpanded: true, + activeThreadKey: null, + workspaceExpandedById: {}, + getThreadKey: (thread) => `thread:${thread.id}`, + }), + ).toEqual(["thread:thread-1", "thread:thread-2"]); + }); + + it("does not expose active chat shortcuts when its workspace is collapsed", () => { + const threads = [ + makeThread({ + id: ThreadId.make("thread-1"), + branch: "feature/settings", + worktreePath: "/repo/.t3/worktrees/settings", + }), + makeThread({ + id: ThreadId.make("thread-2"), + branch: "feature/settings", + worktreePath: "/repo/.t3/worktrees/settings", + }), + ]; + const workspaces = buildDefaultWorkspacesForThreads({ + projectKey: "project:local:project-1", + threads, + getThreadKey: (thread) => `thread:${thread.id}`, + }); + + expect( + getVisibleWorkspaceSidebarThreadKeys({ + workspaces, + projectExpanded: true, + activeThreadKey: "thread:thread-2", + workspaceExpandedById: { + [workspaces[0]?.id ?? ""]: false, + }, + getThreadKey: (thread) => `thread:${thread.id}`, + }), + ).toEqual([]); + }); +}); + function makeProject(overrides: Partial = {}): Project { const { defaultModelSelection, ...rest } = overrides; return { diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 4e7614ed5516..0eb54b833e0a 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -10,6 +10,7 @@ import type { SidebarThreadSummary, Thread } from "../types"; import { cn } from "../lib/utils"; import { isLatestTurnSettled } from "../session-logic"; import { resolveServerBackedAppStageLabel } from "../branding.logic"; +import { resolveDefaultWorkspaceTitle } from "./WorkspacePresentation.logic"; export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]"; export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 100; @@ -490,6 +491,141 @@ export function getVisibleThreadsForProject>(input: }; } +export interface SidebarDefaultWorkspace { + id: string; + projectKey: string; + title: string; + branch: string | null; + worktreePath: string | null; + lastActiveThreadKey: string; + threads: readonly TThread[]; + createdAt: string; + updatedAt: string; +} + +function normalizeWorkspaceContextValue(value: string | null): string | null { + const trimmed = value?.trim(); + return trimmed && trimmed.length > 0 ? trimmed : null; +} + +function defaultWorkspaceContextKey(input: { + branch: string | null; + worktreePath: string | null; +}): string { + const branch = normalizeWorkspaceContextValue(input.branch); + const worktreePath = normalizeWorkspaceContextValue(input.worktreePath); + if (worktreePath) { + return `worktree:${worktreePath}`; + } + if (branch) { + return `branch:${branch}`; + } + return "local"; +} + +function minIsoTimestamp(left: string, right: string): string { + return left <= right ? left : right; +} + +function maxIsoTimestamp(left: string, right: string): string { + return left >= right ? left : right; +} + +export function buildDefaultWorkspacesForThreads< + TThread extends { + id: Thread["id"]; + title: string; + workspaceId?: string | null | undefined; + workspaceBranch?: string | null | undefined; + workspaceWorktreePath?: string | null | undefined; + branch: string | null; + worktreePath: string | null; + createdAt: string; + updatedAt: string; + }, +>(input: { + projectKey: string; + threads: readonly TThread[]; + getThreadKey: (thread: TThread) => string; +}): SidebarDefaultWorkspace[] { + const workspaceByContextKey = new Map>(); + + for (const thread of input.threads) { + const threadKey = input.getThreadKey(thread); + const persistedWorkspaceId = normalizeWorkspaceContextValue(thread.workspaceId ?? null); + const workspaceBranch = thread.workspaceBranch ?? thread.branch; + const workspaceWorktreePath = thread.workspaceWorktreePath ?? thread.worktreePath; + const contextKey = defaultWorkspaceContextKey({ + branch: workspaceBranch, + worktreePath: workspaceWorktreePath, + }); + const workspaceKey = persistedWorkspaceId ?? contextKey; + const existing = workspaceByContextKey.get(workspaceKey); + if (existing) { + workspaceByContextKey.set(workspaceKey, { + ...existing, + title: resolveDefaultWorkspaceTitle({ + branch: workspaceBranch, + worktreePath: workspaceWorktreePath, + }), + branch: workspaceBranch, + worktreePath: workspaceWorktreePath, + threads: [...existing.threads, thread], + createdAt: minIsoTimestamp(existing.createdAt, thread.createdAt), + updatedAt: maxIsoTimestamp(existing.updatedAt, thread.updatedAt), + }); + continue; + } + + workspaceByContextKey.set(workspaceKey, { + id: `${input.projectKey}:workspace:${workspaceKey}`, + projectKey: input.projectKey, + title: resolveDefaultWorkspaceTitle({ + branch: workspaceBranch, + worktreePath: workspaceWorktreePath, + }), + branch: workspaceBranch, + worktreePath: workspaceWorktreePath, + lastActiveThreadKey: threadKey, + threads: [thread], + createdAt: thread.createdAt, + updatedAt: thread.updatedAt, + }); + } + + return Array.from(workspaceByContextKey.values()); +} + +export function getVisibleWorkspaceThreads(input: { + threads: readonly TThread[]; + workspaceExpanded: boolean; +}): readonly TThread[] { + return input.workspaceExpanded ? input.threads : []; +} + +export function getVisibleWorkspaceSidebarThreadKeys(input: { + workspaces: readonly SidebarDefaultWorkspace[]; + projectExpanded: boolean; + activeThreadKey: string | null; + workspaceExpandedById: Readonly>; + getThreadKey: (thread: TThread) => string; +}): string[] { + return input.workspaces.flatMap((workspace) => { + const hasActiveThread = + input.activeThreadKey !== null && + workspace.threads.some((thread) => input.getThreadKey(thread) === input.activeThreadKey); + if (!input.projectExpanded && !hasActiveThread) { + return []; + } + + const workspaceExpanded = input.workspaceExpandedById[workspace.id] ?? true; + return getVisibleWorkspaceThreads({ + threads: workspace.threads, + workspaceExpanded, + }).map(input.getThreadKey); + }); +} + export function getFallbackThreadIdAfterDelete< T extends Pick & ThreadSortInput, >(input: { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index ce925618caa0..5765c173142a 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -181,6 +181,9 @@ import { useThreadSelectionStore } from "../threadSelectionStore"; import { useOpenAddProjectCommandPalette } from "../commandPaletteContext"; import { getSidebarThreadIdsToPrewarm, + buildDefaultWorkspacesForThreads, + getVisibleWorkspaceSidebarThreadKeys, + getVisibleWorkspaceThreads, resolveAdjacentThreadId, isContextMenuPointerDown, isTrailingDoubleClick, @@ -195,6 +198,7 @@ import { sortProjectsForSidebar, useThreadJumpHintVisibility, ThreadStatusPill, + type SidebarDefaultWorkspace, } from "./Sidebar.logic"; import { sortThreads } from "../lib/threadSort"; import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill"; @@ -231,6 +235,7 @@ const SIDEBAR_LIST_ANIMATION_OPTIONS = { easing: "ease-out", } as const; const EMPTY_THREAD_JUMP_LABELS = new Map(); +const WORKSPACE_LAYOUT_ENABLED = import.meta.env.VITE_T3CODE_WORKSPACE_LAYOUT === "1"; const PROJECT_GROUPING_MODE_LABELS: Record = { repository: "Group by repository", repository_path: "Group by repository path", @@ -1048,6 +1053,208 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( ); }); +interface SidebarProjectWorkspaceListProps extends Omit< + SidebarProjectThreadListProps, + | "hasOverflowingThreads" + | "hiddenThreadStatus" + | "renderedThreads" + | "showEmptyThreadState" + | "shouldShowThreadPanel" + | "isThreadListExpanded" + | "expandThreadListForProject" + | "collapseThreadListForProject" +> { + projectExpanded: boolean; + workspaces: readonly SidebarDefaultWorkspace[]; + workspaceExpandedById: Readonly>; + onWorkspaceExpandedChange: (workspaceId: string, expanded: boolean) => void; + onCreateThreadInWorkspace: ( + event: React.MouseEvent, + workspace: SidebarDefaultWorkspace, + ) => void; +} + +const SidebarProjectWorkspaceList = memo(function SidebarProjectWorkspaceList( + props: SidebarProjectWorkspaceListProps, +) { + const { + projectExpanded, + workspaces, + workspaceExpandedById, + onWorkspaceExpandedChange, + onCreateThreadInWorkspace, + orderedProjectThreadKeys, + projectCwd, + activeRouteThreadKey, + threadJumpLabelByKey, + appSettingsConfirmThreadArchive, + renamingThreadKey, + renamingTitle, + setRenamingTitle, + startThreadRename, + renamingInputRef, + renamingCommittedRef, + confirmingArchiveThreadKey, + setConfirmingArchiveThreadKey, + confirmArchiveButtonRefs, + attachThreadListAutoAnimateRef, + handleThreadClick, + navigateToThread, + handleMultiSelectContextMenu, + handleThreadContextMenu, + clearSelection, + commitRename, + cancelRename, + attemptArchiveThread, + openPrLink, + } = props; + + return ( + + {projectExpanded && workspaces.length === 0 ? ( + +
+ No workspaces yet +
+
+ ) : null} + {workspaces.map((workspace) => { + const activeThread = workspace.threads.find( + (thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === + activeRouteThreadKey, + ); + if (!projectExpanded && !activeThread) { + return null; + } + + const workspaceExpanded = workspaceExpandedById[workspace.id] ?? true; + const workspaceThreads = getVisibleWorkspaceThreads({ + threads: workspace.threads, + workspaceExpanded, + }); + const workspaceStatus = resolveProjectStatusIndicator( + workspace.threads.map((thread) => resolveThreadStatusPill({ thread })), + ); + const workspaceLabel = + workspace.branch && workspace.branch !== workspace.title ? workspace.branch : null; + + return ( + + +
+ + { + event.preventDefault(); + event.stopPropagation(); + onWorkspaceExpandedChange(workspace.id, !workspaceExpanded); + }} + /> + } + > + + + + {workspaceExpanded ? "Collapse workspace" : "Expand workspace"} + + + + + onCreateThreadInWorkspace(event, workspace)} + /> + } + > + + + New chat + +
+
+ {workspaceThreads.map((thread) => { + const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + return ( +
+ +
+ ); + })} +
+ ); + })} +
+ ); +}); + interface SidebarProjectItemProps { project: SidebarProjectSnapshot; isThreadListExpanded: boolean; @@ -1181,6 +1388,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const projectExpanded = useUiStateStore((state) => resolveProjectExpanded(state.projectExpandedById, projectPreferenceKeys), ); + const workspaceExpandedById = useUiStateStore((state) => state.projectExpandedById); const threadLastVisitedAts = useUiStateStore( useShallow((state) => projectThreads.map( @@ -1339,6 +1547,15 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec threadLastVisitedAts, visibleProjectThreads, ]); + const defaultWorkspaces = useMemo( + () => + buildDefaultWorkspacesForThreads({ + projectKey: project.projectKey, + threads: visibleProjectThreads, + getThreadKey: (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + }), + [project.projectKey, visibleProjectThreads], + ); const handleProjectButtonClick = useCallback( (event: React.MouseEvent) => { @@ -1948,6 +2165,58 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec [createThreadForProjectMember, project.groupedProjectCount, project.memberProjects], ); + const handleWorkspaceExpandedChange = useCallback( + (workspaceId: string, expanded: boolean) => { + setProjectExpanded(workspaceId, expanded); + }, + [setProjectExpanded], + ); + + const handleCreateThreadInWorkspace = useCallback( + ( + event: React.MouseEvent, + workspace: SidebarDefaultWorkspace, + ) => { + event.preventDefault(); + event.stopPropagation(); + + const sourceThread = workspace.threads[0]; + if (!sourceThread) { + return; + } + const member = memberProjectByScopedKey.get( + scopedProjectKey(scopeProjectRef(sourceThread.environmentId, sourceThread.projectId)), + ); + if (!member) { + return; + } + if (isMobile) { + setOpenMobile(false); + } + + void (async () => { + const result = await settlePromise(() => + handleNewThread(scopeProjectRef(member.environmentId, member.id), { + branch: workspace.branch, + worktreePath: workspace.worktreePath, + envMode: workspace.worktreePath ? "worktree" : "local", + }), + ); + if (result._tag === "Failure") { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not create chat", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + })(); + }, + [handleNewThread, isMobile, memberProjectByScopedKey, setOpenMobile], + ); + const attemptArchiveThread = useCallback( async (threadRef: ScopedThreadRef) => { const result = await archiveThread(threadRef); @@ -2286,42 +2555,77 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
- + {WORKSPACE_LAYOUT_ENABLED ? ( + + ) : ( + + )} + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + }); + return getVisibleWorkspaceSidebarThreadKeys({ + workspaces, + projectExpanded, + activeThreadKey: activeThreadKey ?? null, + workspaceExpandedById: projectExpandedById, + getThreadKey: (thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + }); + } + const pinnedCollapsedThread = !projectExpanded && activeThreadKey ? (projectThreads.find( diff --git a/apps/web/src/components/WorkspacePresentation.logic.ts b/apps/web/src/components/WorkspacePresentation.logic.ts new file mode 100644 index 000000000000..09a1b73f1d52 --- /dev/null +++ b/apps/web/src/components/WorkspacePresentation.logic.ts @@ -0,0 +1,20 @@ +export function resolveDefaultWorkspaceTitle(input: { + branch: string | null; + worktreePath: string | null; +}): string { + const branch = input.branch?.trim(); + if (branch) { + return branch; + } + + const worktreePath = input.worktreePath?.trim(); + if (worktreePath) { + const normalized = worktreePath.replace(/[/\\]+$/u, ""); + const folderName = normalized.match(/[^/\\]+$/u)?.[0]?.trim(); + if (folderName) { + return folderName; + } + } + + return "Local workspace"; +} diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts index 75235a053076..3d10fddac43e 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts @@ -5,6 +5,7 @@ import { buildKeybindingRows, buildKeybindingCommandOptions, buildWhenVariableOptions, + commandDescription, commandLabel, keybindingConflictLabels, keybindingFromKeyboardEvent, @@ -125,6 +126,12 @@ describe("KeybindingsSettings.logic", () => { expect(commandLabel("script.setup-db.run")).toBe("Run Script: Setup Db"); }); + it("describes chat creation commands with workspace context behavior", () => { + expect(commandDescription("chat.new")).toContain("preserving branch/worktree"); + expect(commandDescription("chat.newLocal")).toContain("branch/worktree cleared"); + expect(commandDescription("terminal.toggle")).toBeNull(); + }); + it("builds known when variable options from defaults without frontend labels", () => { const options = buildWhenVariableOptions(); diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.ts index da54e86e42e1..dd06a9e5e065 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.ts @@ -275,6 +275,17 @@ export function commandLabel(command: KeybindingCommand): string { return raw.split(".").map(titleCaseCommandSegment).join(": "); } +export function commandDescription(command: KeybindingCommand): string | null { + switch (command) { + case "chat.new": + return "Create a chat in the active context, preserving branch/worktree for the current project."; + case "chat.newLocal": + return "Create a chat for the active project with branch/worktree cleared."; + default: + return null; + } +} + function titleCaseCommandSegment(segment: string): string { const words: Array = []; for (const part of segment.replace(/([a-z0-9])([A-Z])/g, "$1 $2").split(/[-_\s]+/)) { diff --git a/apps/web/src/components/settings/KeybindingsSettings.tsx b/apps/web/src/components/settings/KeybindingsSettings.tsx index b7dbbd3575bc..bd5e53379f0e 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.tsx +++ b/apps/web/src/components/settings/KeybindingsSettings.tsx @@ -57,6 +57,7 @@ import { buildKeybindingRows, buildKeybindingCommandOptions, buildWhenVariableOptions, + commandDescription, commandLabel, DEFAULT_WHEN_VARIABLE, isKnownWhenVariable, @@ -781,6 +782,7 @@ function KeybindingTableRow({ const whenDraftExpression = whenAstToExpression(whenDraft); const isDirty = keyDraft !== row.key || whenDraftExpression !== row.when; const displayShortcut = formatShortcutLabel(row.binding.shortcut); + const description = commandDescription(row.command); const canReset = row.source === "Custom" && row.defaultKey !== null; const canRemove = row.source !== "Default"; const hasRowActions = canReset || canRemove; @@ -830,6 +832,11 @@ function KeybindingTableRow({ {row.command} + {description ? ( +
+ {description} +
+ ) : null}
{showPill ? ( diff --git a/apps/web/src/lib/chatThreadActions.test.ts b/apps/web/src/lib/chatThreadActions.test.ts index 2b1d7b09b9f1..219699a5cf04 100644 --- a/apps/web/src/lib/chatThreadActions.test.ts +++ b/apps/web/src/lib/chatThreadActions.test.ts @@ -5,6 +5,7 @@ import { resolveThreadActionProjectRef, resolveNewDraftStartFromOrigin, startNewLocalThreadFromContext, + startNewThreadInProjectFromContext, startNewThreadFromContext, type ChatThreadActionContext, } from "./chatThreadActions"; @@ -18,6 +19,7 @@ function createContext(overrides: Partial = {}): ChatTh activeDraftThread: null, activeThread: undefined, defaultProjectRef: scopeProjectRef(ENVIRONMENT_ID, FALLBACK_PROJECT_ID), + defaultThreadEnvMode: "worktree", handleNewThread: async () => {}, ...overrides, }; @@ -92,6 +94,33 @@ describe("chatThreadActions", () => { }); }); + it("does not copy active thread branch context when starting in another project", async () => { + const handleNewThread = vi.fn(async () => {}); + + await startNewThreadInProjectFromContext( + createContext({ + activeThread: { + environmentId: ENVIRONMENT_ID, + projectId: PROJECT_ID, + branch: "ios-app-stripe-plan", + worktreePath: "/tmp/ios-app-stripe-plan", + }, + defaultThreadEnvMode: "worktree", + handleNewThread, + }), + scopeProjectRef(ENVIRONMENT_ID, FALLBACK_PROJECT_ID), + ); + + expect(handleNewThread).toHaveBeenCalledWith( + scopeProjectRef(ENVIRONMENT_ID, FALLBACK_PROJECT_ID), + { + branch: null, + worktreePath: null, + envMode: "worktree", + }, + ); + }); + it("preserves an explicitly disabled origin base in contextual thread options", async () => { const handleNewThread = vi.fn(async () => {}); @@ -117,7 +146,7 @@ describe("chatThreadActions", () => { }); }); - it("delegates the target environment defaults to the new-thread handler", async () => { + it("starts a local thread with the configured default env mode", async () => { const handleNewThread = vi.fn(async () => {}); const didStart = await startNewLocalThreadFromContext( @@ -128,7 +157,11 @@ describe("chatThreadActions", () => { ); expect(didStart).toBe(true); - expect(handleNewThread).toHaveBeenCalledWith(scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID)); + expect(handleNewThread).toHaveBeenCalledWith(scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID), { + branch: null, + worktreePath: null, + envMode: "worktree", + }); }); it("does not start a thread when there is no project context", async () => { diff --git a/apps/web/src/lib/chatThreadActions.ts b/apps/web/src/lib/chatThreadActions.ts index 4f30885610ad..72c42c519164 100644 --- a/apps/web/src/lib/chatThreadActions.ts +++ b/apps/web/src/lib/chatThreadActions.ts @@ -32,6 +32,7 @@ export interface ChatThreadActionContext { readonly activeDraftThread: DraftThreadContextLike | null; readonly activeThread: ThreadContextLike | undefined; readonly defaultProjectRef: ScopedProjectRef | null; + readonly defaultThreadEnvMode: DraftThreadEnvMode; readonly handleNewThread: NewThreadHandler; } @@ -57,7 +58,21 @@ export function resolveThreadActionProjectRef( return context.defaultProjectRef; } -function buildContextualThreadOptions(context: ChatThreadActionContext): NewThreadOptions { +function sameProjectRef(left: ScopedProjectRef, right: ScopedProjectRef): boolean { + return left.environmentId === right.environmentId && left.projectId === right.projectId; +} + +function buildContextualThreadOptions( + context: ChatThreadActionContext, + projectRef?: ScopedProjectRef, +): NewThreadOptions { + if (projectRef) { + const contextProjectRef = resolveThreadActionProjectRef(context); + if (!contextProjectRef || !sameProjectRef(contextProjectRef, projectRef)) { + return buildDefaultThreadOptions(context); + } + } + return { branch: context.activeThread?.branch ?? context.activeDraftThread?.branch ?? null, worktreePath: @@ -71,11 +86,19 @@ function buildContextualThreadOptions(context: ChatThreadActionContext): NewThre }; } +function buildDefaultThreadOptions(context: ChatThreadActionContext): NewThreadOptions { + return { + branch: null, + worktreePath: null, + envMode: context.defaultThreadEnvMode, + }; +} + export async function startNewThreadInProjectFromContext( context: ChatThreadActionContext, projectRef: ScopedProjectRef, ): Promise { - await context.handleNewThread(projectRef, buildContextualThreadOptions(context)); + await context.handleNewThread(projectRef, buildContextualThreadOptions(context, projectRef)); } export async function startNewThreadFromContext( @@ -98,6 +121,6 @@ export async function startNewLocalThreadFromContext( return false; } - await context.handleNewThread(projectRef); + await context.handleNewThread(projectRef, buildDefaultThreadOptions(context)); return true; } diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index 9fb1eae721e2..c50c18ac69dd 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -16,7 +16,9 @@ import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../termina import { isPreviewSupportedInRuntime } from "../previewStateStore"; import { selectActiveRightPanel, useRightPanelStore } from "../rightPanelStore"; import { useThreadSelectionStore } from "../threadSelectionStore"; +import { resolveSidebarNewThreadEnvMode } from "~/components/Sidebar.logic"; import { stackedThreadToast, toastManager } from "~/components/ui/toast"; +import { usePrimarySettings } from "~/hooks/useSettings"; import { primaryServerKeybindingsAtom } from "~/state/server"; function ChatRouteGlobalShortcuts() { @@ -38,6 +40,7 @@ function ChatRouteGlobalShortcuts() { ? selectActiveRightPanel(state.byThreadKey, routeThreadRef) === "preview" : false, ); + const primarySettings = usePrimarySettings(); useEffect(() => { const onWindowKeyDown = (event: KeyboardEvent) => { if (event.defaultPrevented) return; @@ -67,6 +70,9 @@ function ChatRouteGlobalShortcuts() { activeDraftThread, activeThread: activeThread ?? undefined, defaultProjectRef, + defaultThreadEnvMode: resolveSidebarNewThreadEnvMode({ + defaultEnvMode: primarySettings.defaultThreadEnvMode, + }), handleNewThread, }); return; @@ -79,6 +85,9 @@ function ChatRouteGlobalShortcuts() { activeDraftThread, activeThread: activeThread ?? undefined, defaultProjectRef, + defaultThreadEnvMode: resolveSidebarNewThreadEnvMode({ + defaultEnvMode: primarySettings.defaultThreadEnvMode, + }), handleNewThread, }); return; @@ -140,6 +149,7 @@ function ChatRouteGlobalShortcuts() { keybindings, defaultProjectRef, previewOpen, + primarySettings.defaultThreadEnvMode, routeThreadRef, selectedThreadKeysSize, terminalOpen, diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts index d8a6d71b49ad..ea5841140188 100644 --- a/apps/web/src/vite-env.d.ts +++ b/apps/web/src/vite-env.d.ts @@ -12,6 +12,7 @@ interface ImportMetaEnv { readonly VITE_RELAY_OTLP_TRACES_URL: string; readonly VITE_RELAY_OTLP_TRACES_DATASET: string; readonly VITE_RELAY_OTLP_TRACES_TOKEN: string; + readonly VITE_T3CODE_WORKSPACE_LAYOUT: string; readonly APP_VERSION: string; } diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 8f984c850dc1..1e2caf40cf7e 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -22,6 +22,7 @@ const configuredRelayTracingUrl = repoEnv.VITE_RELAY_OTLP_TRACES_URL?.trim() || const configuredRelayTracingDataset = repoEnv.VITE_RELAY_OTLP_TRACES_DATASET?.trim() || ""; const configuredRelayTracingToken = repoEnv.VITE_RELAY_OTLP_TRACES_TOKEN?.trim() || ""; const configuredHostedAppChannel = process.env.VITE_HOSTED_APP_CHANNEL?.trim() || ""; +const configuredWorkspaceLayout = process.env.T3CODE_WORKSPACE_LAYOUT?.trim() || ""; const configuredAppVersion = process.env.APP_VERSION?.trim() || pkg.version; const configuredHostedAppUrl = (() => { const explicitHostedAppUrl = process.env.VITE_HOSTED_APP_URL?.trim(); @@ -122,6 +123,7 @@ export default defineConfig(() => { "import.meta.env.VITE_RELAY_OTLP_TRACES_TOKEN": JSON.stringify(configuredRelayTracingToken), "import.meta.env.VITE_HOSTED_APP_URL": JSON.stringify(configuredHostedAppUrl ?? ""), "import.meta.env.VITE_HOSTED_APP_CHANNEL": JSON.stringify(configuredHostedAppChannel), + "import.meta.env.VITE_T3CODE_WORKSPACE_LAYOUT": JSON.stringify(configuredWorkspaceLayout), "import.meta.env.APP_VERSION": JSON.stringify(configuredAppVersion), }, resolve: { diff --git a/docs/project/harness-enhancements.md b/docs/project/harness-enhancements.md new file mode 100644 index 000000000000..c4e3584cfe89 --- /dev/null +++ b/docs/project/harness-enhancements.md @@ -0,0 +1,1007 @@ +# Harness Enhancements Tracker + +> Last updated: 2026-06-22 + +This tracks planned improvements to make T3 Code a stronger harness around coding agents, inspired by the useful parts of Conductor's workspace model: persistent context, injected guidance, action-specific prompts, isolated workspaces, review flow, and merge readiness. + +## Current Direction + +Do not build a second workspace identity model for harness features. + +T3 Code now has a durable workspace foundation through `WorkspaceId`, +`projection_workspaces`, and `projection_threads.workspace_id`. New harness capabilities should +attach to that existing identity instead of introducing separate context/workspace/task IDs. + +Use existing workspace identity as the anchor for: + +- `.context/` location and lifecycle +- durable workspace task state +- setup scripts and local file copy rules +- prompt injection context +- sleep-prevention active-work accounting +- lifecycle dashboard state +- file review, comments, checks, and merge readiness + +## Current Upstream Baseline + +The fork is synced through upstream `pingdotgg/t3code` `92e54fb96` plus local harness planning, dev-sandbox, workspace-sidebar, durable-workspace, and fork-sync changes. + +Relevant upstream changes to build on: + +- Right-panel and diff state now use store-driven surfaces instead of URL-driven diff state. Harness review/editor work should build on that model. +- Desktop backend output logging was extracted into `DesktopBackendOutputLog`; avoid reintroducing old observability helpers. +- Settings are split between server/environment settings and client-local settings. Workspace defaults such as local vs worktree mode should come from the target environment. +- Source-control, workspace filesystem/search, preview automation, and provider/runtime error handling received broad structured-error improvements. Harness work should reuse those primitives instead of adding parallel error wrappers. +- The repo now enforces stricter script/import conventions, including namespace imports for Node built-ins. +- The latest upstream batch continues standardizing Effect/Schema error boundaries across auth, VCS, CLI, release, desktop update, checkpoint diff, provider, and preview flows. Harness features should follow those typed-error conventions from the start. +- Upstream now includes durable workspace identity, main sidebar toggling, persistent chat word-wrap settings, mobile composer/draft stability improvements, T3 Connect account controls, preview host fixes, and additional guardrails around remote pairing, DPoP fallback URLs, pending input, and trace ID copy. + +## Priority Summary + +| Priority | Enhancement | Status | Why It Matters | +| -------- | ------------------------------------------------ | --------------- | ------------------------------------------------------------------------------------------------------------------------------- | +| P0 | Workspace identity and sidebar hierarchy | Compat complete | Creates the Project -> Workspace -> Chat model that every other harness feature can attach to. | +| P0 | Workspace migration and compatibility layer | Compat complete | Lets existing projects, threads, routes, and APIs keep working while workspace ownership rolls out. | +| P0 | Durable workspace persistence model | Complete | Gives workspaces stable IDs and stored ownership so branch/worktree state no longer depends on thread metadata. | +| P0 | Dev/prod data isolation and feature flag rollout | Complete | Lets the new workspace layout run in dev without risking the user's deployed/current T3 Code data. | +| P0 | Workspace context folder | Partial | Gives each workspace durable memory across turns, restarts, and provider handoffs. | +| P0 | Durable task list | Not started | Gives every workspace a trustworthy task state instead of relying on the agent to update a checklist. | +| P1 | Workspace setup scripts | Partial | Existing setup scripts can run on worktree creation with project/worktree env; repo-shared setup profiles still need polish. | +| P1 | Local file copy rules | Not started | Copies `.env*` and user-selected local files into new worktrees safely before agents start work. | +| P1 | Prevent system sleep during active work | Not started | Keeps long-running agent turns, checks, terminals, and previews alive while T3 Code is actively working. | +| P1 | T3 Code harness prompt injection | Partial | Existing Codex developer instructions provide T3 browser/tool guidance; full workspace/context/task prompt assembly is pending. | +| P1 | Context and task update reactor | Not started | Keeps `.context` and task state useful after meaningful turns without asking users to manually summarize state. | +| P1 | Action-specific prompts | Not started | Makes UI actions such as review, PR creation, fix checks, and handoff more consistent. | +| P1 | Source-control action target resolution | Partial | Structured VCS/provider context exists; a canonical active-workspace target resolver is still needed. | +| P1 | Fork sync command and drift monitor | Complete | Keeps a fork close to upstream with a repeatable checked command and scheduled drift alerts. | +| P1 | Fork release policy | Complete | Defines when a fork build should become a tagged prerelease or stable release instead of staying as local/dev work. | +| P1 | Workspace lifecycle dashboard | Not started | Makes the workspace/worktree/branch/terminal/diff/PR lifecycle visible as one unit of work. | +| P1 | Integrated file editor and review surface | Partial | In-app file preview/editing exists; default review navigation and review-specific workflows need completion. | +| P1 | File diff review improvements | Partial | Turn selection, file selection, and some collapse behavior exist; filters, review states, comments, and summaries remain. | +| P2 | Merge readiness checks panel | Not started | Gives users a clear "ready to merge" gate for git status, tests, PR state, comments, and todos. | +| P2 | Structured review loop | Not started | Lets users send diff comments and unresolved review feedback back to agents with precise context. | +| P3 | Issue and PR fanout | Not started | Enables one workspace per GitHub/Linear issue or PR for parallel agent work. | +| P3 | Spotlight-style root runner | Not started | Supports projects that need one fixed root checkout, fixed port, shared database, or expensive dev stack. | + +## P0: Workspace Identity and Sidebar Hierarchy + +Add `Workspace` as the durable unit of work above `Thread`/chat, then update the left sidebar to show project -> workspace -> chat. + +Target hierarchy: + +```txt +Project A + Workspace 1 + Chat 1 + Chat 2 + Chat 3 + Workspace 2 +Project B + Workspace 1 +Project C +``` + +Ownership model: + +- Workspace owns task-level lifecycle state: branch, worktree path, `.context`, task list, + terminal sessions, right-panel surfaces, setup scripts, checks, review state, and PR state. +- Chat/thread owns conversation-level state: messages, provider session, turns, turn diffs, and approvals. +- Existing work should migrate into generated default workspaces grouped by stable execution + identity: worktree path when present, otherwise branch-only or the local checkout. + +Expected behavior: + +- Projects remain top-level groups. +- Workspaces appear under projects. +- Chats appear under workspaces only when the workspace is expanded. +- Clicking a chat opens that one chat in the center, matching current T3 behavior. +- The workspace row has separate controls: caret/title toggle expand/collapse, chat rows open individual chats, and `+` creates a new chat in that workspace. +- Collapsed workspaces show only workspace-level status such as name, branch/status, and changed-file count. +- Expanded workspaces show their chats, with the active chat highlighted. +- The center layout does not change in the first implementation; no Conductor-style center chat tabs. +- Terminal drawer state and right-panel surfaces should follow the active workspace rather than + resetting per chat, while chat-specific plan/diff content can still render the selected chat's data. + +Initial implementation notes: + +- Start with a compatibility layer so existing thread URLs still resolve. +- Preserve existing turn/diff behavior during migration. +- Persist project/workspace expansion state in local UI state. +- Add workspace-level new-chat creation from the sidebar before adding a larger workspace dashboard. +- Prevent more than one active agent run per workspace until concurrent same-workspace runs are designed. + +Completed compatibility scope: + +- The workspace layout groups existing threads into generated default workspaces by worktree path, branch, or local checkout. +- Workspace rows expand/collapse independently from the selected center chat. +- Terminal drawer state and right-panel visibility follow the active workspace-scoped thread reference, while chat-specific diff and plan data remains attached to the selected chat. +- Settings and keybinding documentation describe the branch/worktree behavior for new chat creation. +- Projection-backed workspace IDs and metadata are now consumed by the sidebar when present, with legacy thread metadata retained as a fallback. + +Still pending: + +- Move branch/worktree ownership fully from thread fields to workspace fields after compatibility is proven. +- Add workspace-level lifecycle surfaces for changed files, checks, review state, and PR state. + +## P0: Workspace Migration and Compatibility Layer + +Make the workspace rollout additive first so existing T3 Code users do not lose projects, chats, diffs, routes, or provider sessions. + +Migration goals: + +- Existing projects should open without manual intervention. +- Existing threads should appear under generated default workspaces. +- Existing thread URLs should keep resolving. +- Existing turn/diff/checkpoint history should remain attached to the visible chat. +- Existing commands that accept `threadId` should keep working while workspace-aware commands are introduced. +- Workspace ownership should be introduced without immediately deleting thread-owned fields. + +Backfill model: + +```txt +Before: +Project A + Thread 1 + Thread 2 + +After migration: +Project A + Workspace generated from shared branch/worktree context + Chat/Thread 1 + Chat/Thread 2 +``` + +Compatibility rules: + +- Add workspace tables/fields before removing or repurposing thread fields. +- Backfill one workspace per stable execution identity for the first migration, so multiple + legacy threads on the same worktree or local branch appear as sibling chats. +- Treat branch name as mutable workspace metadata, not workspace identity, so branch renames + update the label without creating a new sidebar workspace. +- Keep `thread.branch` and `thread.worktreePath` readable during the transition. +- Add `workspace.branch` and `workspace.worktreePath`, initially copied or derived from the thread. +- If a thread is missing workspace linkage, synthesize a default workspace in the projection rather than failing the UI. +- Old thread routes should resolve to the containing workspace and selected chat. +- New workspace-aware routes can be added while old routes remain aliases. +- Server commands that only know `threadId` should resolve the containing workspace internally. +- Only after the workspace model is proven should branch/worktree ownership move fully off thread. + +Rollback and safety: + +- First migration should be additive and reversible at the application level. +- Avoid destructive data movement in the initial rollout. +- Keep a clear invariant: every visible chat belongs to exactly one workspace. +- Add tests for old snapshots, new snapshots, and mixed snapshots where some threads have workspace linkage and some do not. + +Completed compatibility scope: + +- Legacy snapshots still synthesize compatible workspace groups from thread project, branch, worktree, and local-checkout metadata when durable workspace fields are absent. +- Projection and client reducer paths preserve an existing worktree identity when stale restored local metadata arrives without a worktree path. +- Project-scoped new chat creation clears active branch/worktree context when the selected project differs from the current chat, so the draft appears under the selected project instead of the previously active workspace. +- Durable projection workspace records now backfill and repair thread workspace linkage while old thread routes and thread-owned branch/worktree fields remain readable. + +Still pending: + +- Add workspace-aware routes/API commands. +- Keep old thread routes as aliases during the durable migration. + +## P0: Durable Workspace Persistence Model + +Replace the compatibility-only synthesized workspace groups with stored workspace records. + +This is the next task after the completed sidebar compatibility slice. It should preserve the +current Project -> Workspace -> Chat UI while making workspace identity durable across restarts, +branch renames, archive/restore cycles, and future workspace-level features. + +Target ownership model: + +- Workspace has a stable ID and belongs to one project. +- Thread/chat points to a workspace. +- Workspace owns branch, worktree path, local-checkout mode, terminal sessions, right-panel state, + lifecycle status, and future `.context` and task state. +- Thread keeps conversation-specific state: messages, provider session, turns, approvals, and + turn-level diffs. + +Expected behavior: + +- Existing threads are backfilled into durable workspace records using the same stable execution + identity rules as the compatibility sidebar: worktree path first, then branch, then local checkout. +- Branch names are mutable workspace metadata, not workspace identity. +- Renaming a branch updates the workspace label/status instead of creating a different workspace. +- Old thread URLs continue to resolve and select the containing workspace and chat. +- New workspace-aware routes and commands can be added without breaking existing `threadId` flows. +- Every visible chat belongs to exactly one persisted workspace. + +Initial implementation notes: + +- Add the workspace schema/contract first, then projection backfill, then UI consumption. +- Keep thread-owned branch/worktree fields readable until the durable migration is proven. +- Add tests for old snapshots, new snapshots, mixed snapshots, branch rename, archive/restore, and + project-scoped new chat creation. +- Only after this model is stable should branch/worktree ownership move fully off thread records. + +Completed scope: + +- Added `WorkspaceId`, persisted projection workspace records, and thread workspace linkage. +- Backfilled existing projection threads into workspaces using worktree path first, then branch, then local checkout. +- Updated snapshot queries and sidebar grouping to prefer persisted workspace metadata while preserving legacy fallback behavior. +- Preserved same-worktree workspace identity across branch label changes. +- Fixed branch/local-to-worktree transitions so only the moved chat changes workspace instead of relabeling sibling branch chats. +- Added a corrective projection migration that rebuilds workspace rows from each thread's actual branch/worktree fields. +- Covered workspace persistence, sidebar grouping, branch rename, and branch-to-worktree movement with focused tests. + +Harness integration rule: + +- Future harness features must attach to this existing workspace identity. Do not add parallel + workspace/context/task identity tables unless they reference `WorkspaceId` as the owning key. + +## P0: Dev/Prod Data Isolation and Feature Flag Rollout + +Keep the deployed/current T3 Code experience on the existing layout and data store while the dev build can run the new workspace layout safely. + +Why this matters: + +- Users may already run a deployed or installed T3 Code with real projects and chat history. +- Workspace migrations should not be tested first against the user's production app data. +- The dev build should be able to break, reset, and migrate independently. +- The old layout needs to stay available until the workspace model is proven. + +Required behavior: + +- Dev and prod must use separate ports and separate T3 home/data directories. +- The workspace-first sidebar should be gated behind a feature flag until migration is stable. +- Running dev should not automatically migrate the production `T3CODE_HOME`. +- Migrations should be tested against copied fixtures or a dev-only data directory first. +- The app should make the active mode obvious in diagnostics or settings: current layout vs workspace layout. + +Recommended local commands: + +```bash +# Existing/current app keeps its normal data directory. +npx t3@latest + +# Dev app uses shifted ports and a separate data directory. +vp run dev:sandbox +``` + +Recommended long-term package script: + +```json +{ + "scripts": { + "dev:sandbox": "node scripts/dev-sandbox.ts" + } +} +``` + +The sandbox wrapper sets these defaults: + +- `T3CODE_DEV_INSTANCE=dev` so dev ports are shifted away from the normal app. +- `T3CODE_HOME=$HOME/.t3-dev` so dev data does not touch the user's current T3 Code data. +- `T3CODE_WORKSPACE_LAYOUT=1` so workspace-first UI work can be developed behind a flag. +- Refuses `T3CODE_HOME=$HOME/.t3` unless `T3CODE_ALLOW_PROD_HOME=1` is set. + +Completed scope: + +- `vp run dev:sandbox` is available as the repeatable development entrypoint for the workspace layout. +- The sandbox uses dev-only state, shifted ports, and the workspace layout flag by default. + +Implementation notes: + +- `T3CODE_DEV_INSTANCE` shifts dev ports, but data isolation requires `T3CODE_HOME` or `--base-dir`. +- Add a feature flag such as `T3CODE_WORKSPACE_LAYOUT=1` for the workspace-first sidebar. +- Keep old sidebar code path available while the flag is off. +- Add a startup warning if workspace-layout dev mode points at a non-dev-looking `T3CODE_HOME`. +- Document how to copy production data into a throwaway dev directory for migration testing, but never do it automatically. +- Prefer a committed `dev:sandbox` script over relying on a local `.env` file, because the script is explicit, repeatable, and harder to accidentally point at production data. +- `.env.local` can still be used for machine-specific secrets or overrides, but it should not be the only guardrail for data isolation. + +## P0: Workspace Context Folder + +Create a gitignored context directory for each workspace. + +Decision: + +- `.context/` lives only inside the active workspace root or worktree. +- `.context/` should be gitignored by default. +- T3 Code should not persist a second copy of context files in app data. +- T3 Code may read `.context/` on demand and may keep short-lived in-memory UI cache, but the files remain the only durable source. +- If a user edits or deletes `.context/`, T3 should reflect that state rather than resurrecting stale app-data copies. +- `.context/` is T3/agent-managed workspace memory, not a normal user-authored project folder. +- Users can inspect or reset `.context/` when needed, but the normal workflow should not require users to edit it manually. +- Agents can write any `.context/*.md` file by default. +- Context quality should come from structured updates tied to workspace events, not from asking users to curate files by hand. + +Proposed structure: + +```txt +.context/ + brief.md + plan.md + decisions.md + handoff.md + review.md + checks.md + artifacts/ +``` + +Expected behavior: + +- Context is stored in the active workspace root or worktree. +- T3 Code creates `.context/` when a workspace is created. +- T3 Code only stores durable workspace state, not full transcripts. +- Agents are instructed to read relevant files before starting substantial work. +- Agents are instructed to update any relevant `.context/*.md` file that changed meaningfully. + +Initial implementation notes: + +- Add server-side context file helpers under `apps/server/src/workspace/` or a focused `apps/server/src/context/` module. +- Add contracts for reading and writing context summaries if the UI needs direct access. +- Ensure `.context/` is ignored or recommend adding it to `.gitignore`. +- Avoid persisted app-data mirrors of `.context/`; prefer file reads, file watchers, or invalidatable in-memory cache. +- Keep writes atomic and scoped to the active workspace path. +- Keep `.context/` out of the normal changed-files/diff review flow by default. +- Add a later context inspector/reset surface for debugging bad or stale memory. + +Completed first slice: + +- Added server-side `.context` helpers under `apps/server/src/workspace/`. +- Project creation initializes `.context/`, standard markdown files, and a `.gitignore` entry in the project root. +- First-send worktree bootstrap initializes `.context/` in the created worktree before setup scripts run. +- Context markdown reads/writes are scoped to `.context/`, reject traversal and non-markdown files, and write via atomic temp-file rename. +- No app-data mirror is persisted; filesystem files remain the durable source. + +Still pending: + +- Add UI/RPC access for direct context inspection or reset. +- Add prompt assembly so agents read the relevant context files automatically. +- Add the context/task update reactor that keeps context current after meaningful turns. + +## P0: Durable Task List + +Make workspace task state a first-class T3 Code object instead of relying on the agent to keep a markdown checklist current. + +Decision: + +- Structured T3 task state becomes canonical at the workspace level. +- Current provider plan/todo events and proposed plans remain inputs for seeding and updating workspace tasks. +- `.context/tasks.md` is a generated readable mirror for agents and handoffs, not the source of truth. +- T3 should avoid parsing arbitrary markdown as canonical task state. +- Task state must support `todo`, `in progress`, `done`, `blocked`, and `stale/needs review`. + +Problem to solve: + +- Agents sometimes complete or discover work without updating the visible task list. +- Users cannot tell whether the task list is current, stale, or just forgotten. +- A task list that lies is worse than no task list because it breaks trust in the harness. + +Expected behavior: + +- Each workspace has a durable task list in T3 Code state. +- The task list is mirrored into `.context/tasks.md` so agents and handoffs can read it. +- After each meaningful turn, T3 Code reconciles task state from assistant output, checkpoint diff summaries, command activity, check results, and any explicit plan updates. +- The UI clearly distinguishes `todo`, `in progress`, `done`, `blocked`, and `stale/needs review`. +- If task reconciliation is uncertain, T3 Code marks the list as needing review instead of silently pretending it is up to date. + +Initial implementation notes: + +- Reuse current `turn.plan.updated`, provider TodoWrite/task events, and proposed plan projections as migration inputs. +- Add task schemas to orchestration contracts rather than parsing arbitrary markdown as the source of truth. +- Keep `.context/tasks.md` as a readable mirror, not the canonical database. +- Start with user/agent-visible task state before adding automatic reconciliation. + +## P1: Workspace Setup Scripts + +Standardize workspace setup behavior around existing project scripts and worktree creation. + +Decision: + +- Project scripts remain the user-facing setup/run action model. +- Setup scripts are identified by `runOnWorktreeCreate`. +- Setup scripts run in the new worktree when a workspace/worktree is created. +- Script runtime env includes both the original project root and the active worktree path. +- Checked-in repo defaults can later standardize setup/run/archive behavior across teams. + +Candidate file: + +```txt +.t3code/settings.toml +``` + +Candidate settings: + +```toml +[scripts] +setup = "vp i" +run = "vp dev --port $T3CODE_PORT" +archive = "" +run_mode = "concurrent" + +[workspace] +file_include_globs = ".env.local\n.env.development.local" +port_count = 10 +context_dir = ".context" +``` + +Completed scope: + +- `ProjectSetupScriptRunner` can launch the setup script for a new worktree-backed thread. +- `setupProjectScript(project.scripts)` resolves the setup script from existing project scripts. +- `projectScriptRuntimeEnv` exposes `T3CODE_PROJECT_ROOT` and `T3CODE_WORKTREE_PATH`. +- Existing UI helpers distinguish the primary run script from setup scripts. + +Still pending: + +- Read repo-shared setup defaults from `.t3code/settings.toml` or equivalent. +- Show setup script state as workspace lifecycle/checks state instead of only terminal activity. +- Add archive script behavior. +- Add clearer UI for setup/run/archive script roles. + +## P1: Local File Copy Rules + +Copy selected local files from the source checkout into new worktrees before agents begin work. + +Decision: + +- First workspace setup for a project asks whether to copy local env files into new worktrees. +- The setup UI pre-fills recommended patterns: `.env` and `.env.*`. +- Users can edit the pattern list one entry per line. +- The default copy mode is `missing-only`; never overwrite existing files unless the user explicitly changes that mode. +- Remember the choice per project. +- Warn if a matched file is not gitignored. +- Show setup results: copied, skipped because present, missing in source, or blocked. + +Candidate settings: + +```toml +[workspace.copy] +files = [".env", ".env.*"] +mode = "missing-only" +source = "project-root" +``` + +Expected behavior: + +- Offer a default "Copy local environment files into new worktrees" option. +- Default patterns should include `.env` and `.env*`. +- Users can add more files or globs in a simple one-entry-per-line UI. +- One-entry-per-line is preferred over comma-separated input because paths and globs are easier to scan, edit, reorder, and validate. +- Copy from `T3CODE_PROJECT_ROOT` into `T3CODE_WORKTREE_PATH` during workspace/worktree setup. +- Default copy mode should be `missing-only` so T3 Code does not overwrite existing worktree files. +- Show copy results in the setup/checks UI: copied, skipped because present, missing in source, blocked because unsafe. +- Warn when a file pattern appears to copy sensitive files that are not gitignored. + +Initial implementation notes: + +- T3 Code already exposes `T3CODE_PROJECT_ROOT` and `T3CODE_WORKTREE_PATH` to project scripts; the first-class copy feature should use the same root/worktree distinction. +- Support local overrides separately from checked-in settings. +- Avoid copying unignored secret files. +- Store shared copy defaults in repo config, and user-specific additions in local app settings or an ignored local config file. + +## P1: Prevent System Sleep During Active Work + +Add an opt-in setting that keeps the machine awake while T3 Code is actively doing agent work. + +Problem to solve: + +- Long-running agent tasks can be interrupted if the laptop sleeps. +- A sleeping machine can stop local terminals, dev servers, preview sessions, file watchers, and provider subprocesses. +- Users should not have to remember to run a separate `caffeinate` command before starting autonomous work. + +Decision: + +- Add a Settings toggle: "Prevent system sleep while T3 Code is working". +- Default should be off unless product decides desktop users expect this by default. +- The setting should live near runtime, desktop, or automation settings, not hidden inside provider settings. +- T3 Code should only hold the wake lock while there is active work, not for the whole time the app is open. +- Releasing the lock must be reliable when work finishes, fails, is interrupted, the app quits, or the setting is turned off. + +What counts as active work: + +- A provider turn is running or waiting on tool approval/user input. +- A project script/check launched by T3 Code is running. +- A workspace terminal launched by T3 Code is running a long-lived command, if T3 can reliably identify it. +- Preview automation is actively running a task. + +What should not count by itself: + +- A passive open chat. +- A completed thread with uncommitted changes. +- An idle terminal prompt. +- A preview tab that is merely open. + +Expected behavior: + +- Desktop app requests a platform wake lock when the first active work item starts. +- Desktop app releases it when the last active work item settles. +- UI shows a small status indicator when sleep prevention is active. +- Settings explain that display sleep is separate from system sleep unless the implementation explicitly prevents both. +- The wake lock is reference-counted or keyed by work item so one completed task does not release it while another task is still running. + +Initial implementation notes: + +- Start desktop-only. Web browsers have limited and inconsistent system sleep control. +- macOS can use a managed `caffeinate` child process or native Electron/power APIs if available. +- Windows/Linux should use platform-specific power-save blockers if the desktop runtime supports them. +- Reuse orchestration/thread activity, terminal session state, script/check state, and preview automation state instead of polling raw processes. +- Add tests for active count transitions: first active starts lock, additional active work keeps lock, final completion releases lock, setting disable releases immediately, app shutdown releases. +- Make failure safe: if the wake-lock implementation crashes, agent work should continue and the user should see a warning rather than losing the whole run. + +## P1: T3 Code Harness Prompt Injection + +Inject a concise system/developer prompt into provider sessions explaining T3 Code's environment. + +Decision: + +- Users inspect and edit injected prompts through a Settings UI. +- Projects can override or extend defaults with checked-in files such as `.t3code/prompts/base.md`, `.t3code/prompts/review.md`, and `.t3code/prompts/pr.md`. +- T3 should show the final assembled prompt before a run, including built-in defaults, project overrides, and action-specific additions. +- Repo prompt files are for shared/team defaults; user-specific prompt preferences stay in local app settings or ignored local config. +- Prompt injection uses a shared core harness prompt plus provider-specific adapters. +- The shared core owns workspace rules, `.context`, task/check expectations, diff/review behavior, and safety expectations. +- Provider adapters translate the shared core into the right instruction shape for Codex, Claude, Cursor, OpenCode, or other providers. + +Prompt should cover: + +- The agent is running inside T3 Code. +- A thread may be backed by a git worktree and branch. +- The workspace has terminals, scripts, preview surfaces, diffs, checkpoints, and PR actions. +- `.context/` contains durable workspace state. +- The agent should update context only when goal, plan, decisions, blockers, touched areas, checks, or next steps change. +- Verification should prefer project scripts and repo instructions. +- The agent should avoid hidden destructive operations and respect runtime mode. + +Initial implementation notes: + +- Start with Codex prompt injection because `apps/server/src/provider/CodexDeveloperInstructions.ts` already exists. +- Generalize provider harness instructions through provider adapter capabilities after the Codex path is proven. +- Make injected prompts visible and editable in settings before adding more aggressive behavior. +- Keep prompt assembly deterministic and inspectable so users can understand what the agent actually received. +- Avoid maintaining fully separate provider prompts that can drift in behavior. + +Completed foundation: + +- Codex developer instructions already tell the agent it is running in T3 Code for the collaborative browser/tooling path. +- Default and plan-mode Codex instruction blocks are centralized in `CodexDeveloperInstructions.ts`. + +Still pending: + +- Add the full shared harness prompt covering workspace identity, `.context`, tasks, checks, diffs, and review behavior. +- Add prompt assembly that can combine built-in defaults, project prompt files, user overrides, and action-specific additions. +- Expose the assembled prompt for inspection/editing before a run. +- Add provider adapters beyond Codex after the shared prompt is stable. + +## P1: Context and Task Update Reactor + +Add a server-side reactor that updates `.context` after meaningful turn completion. + +Decision: + +- Use a hybrid update model. +- T3 writes deterministic facts it already knows: task state, branch/worktree, changed files, commands, checks, review state, PR state, timestamps, and turn IDs. +- The agent/model writes narrative context: summaries, decisions, rationale, handoff notes, risks, and open questions. +- Prefer deterministic facts whenever T3 can know them. +- Do not update `.context/` after every message; update after meaningful turns and workspace lifecycle events. + +Update triggers: + +- A turn completes and produced file changes. +- A plan was proposed or implemented. +- A checkpoint diff was finalized. +- A command/check failed or became green. +- Review comments or PR/check state changed. + +Update rules: + +- Do not append every assistant message. +- Write short structured summaries. +- Preserve user-authored context where possible. +- Prefer updating `plan.md`, `decisions.md`, `checks.md`, `review.md`, and `handoff.md` over creating many files. + +Initial implementation notes: + +- Hook after `turn.processing.quiesced` or checkpoint diff finalization. +- Use existing turn diff summaries and thread activity rather than rereading the full transcript. +- Start with deterministic reconciliation for explicit plan/task events. +- Add model-assisted reconciliation only after the deterministic path is reliable. +- Consider a small text-generation summarizer, but keep a deterministic fallback that writes basic changed-file/check metadata. + +## P1: Action-Specific Prompts + +Create reusable prompts for UI actions that currently depend on generic chat behavior. + +Initial actions: + +- Review current diff. +- Address selected diff comments. +- Fix failing checks. +- Create PR title/body. +- Continue from proposed plan. +- Write handoff for another agent. +- Summarize workspace before archive. + +Initial implementation notes: + +- Keep prompts short and action-scoped. +- Store prompt templates in a server module or checked-in prompt directory. +- Expose repository/user overrides in settings after default prompts are stable. + +## P1: Source-Control Action Target Resolution + +Make commit, push, and PR creation resolve their target repository and refs from the active +workspace checkout instead of relying on ambient GitHub CLI defaults. + +Problem to solve: + +- A checkout can have both `origin` and `upstream` remotes. +- `gh repo view` may default to the upstream project even when the branch was pushed to the user's fork. +- PR creation can fail with misleading provider errors such as "Head sha can't be blank", + "Base sha can't be blank", "No commits between main and branch", or "Head ref must be a branch" + even when the local branch and fork branch are valid. +- Branch names can contain slashes, and worktrees can make the checked-out branch/ref context less obvious. + +Expected behavior: + +- T3 Code determines the intended source-control target from the selected workspace checkout and + project configuration, not from the global `gh` default repository. +- Fork-only PRs target the checked-out fork explicitly. +- Upstream contribution PRs use an explicit `owner:branch` head and upstream base repository. +- Before creating a PR, T3 Code verifies the base ref, head ref, and ahead/behind counts against the + chosen repository pair. +- The UI shows the resolved target before running a destructive or publishing action: repository, + base branch, head branch, and remote owner. +- Failures include the resolved repository, base ref, head ref, remote URLs, and commit counts so the + user can see whether the issue is "wrong repo" versus "no commits". + +Initial implementation notes: + +- Add a source-control target resolver that normalizes `origin`, `upstream`, fork owner, base branch, + and head branch for the active workspace. +- Use explicit repository flags for provider commands, for example `gh pr create --repo owner/repo`. +- Use explicit head owner when creating cross-repository PRs, for example `--head owner:branch`. +- Add tests for fork-only PR creation, upstream PR creation, slash-containing branch names, detached or + stale worktree metadata, missing remote head refs, and no-commit branches. +- Keep commit and push preflights in the same resolver so "commit, push & PR" reports one coherent + target instead of resolving each step differently. + +Completed foundation: + +- VCS and source-control providers now carry structured request context and safer error causes. +- GitHub/GitLab/Bitbucket/Azure provider paths have improved typed error handling. +- Git status already reports branch/upstream/default-branch divergence data that the resolver can reuse. + +Still pending: + +- Add one canonical active-workspace source-control target resolver. +- Wire commit, push, and PR actions through the resolver instead of relying on provider defaults. +- Show the resolved target in UI before publishing actions. + +## P1: Fork Sync Command and Drift Monitor + +Add repeatable tooling for keeping a fork synced with upstream without losing local harness work. + +Decision: + +- Prefer a local checked command for the actual merge: `pnpm run sync:upstream`. +- Add a scheduled workflow that checks upstream drift several times a day and opens/updates a tracking issue. +- Do not let the scheduler auto-merge into `main` by default; upstream changes can conflict with local harness changes and should be resolved with context. +- The local command should refuse to run on a dirty worktree so uncommitted planning or implementation work is not overwritten. + +Expected behavior: + +- Fetch `origin` and `upstream`. +- Report ahead/behind counts before merging. +- Merge `upstream/main` into local `main`. +- If dependency files changed, refresh dependencies from the lockfile. +- Run `vp check` and `vp run typecheck`. +- Push `origin/main` only after the merge and checks succeed. +- If conflicts occur, stop with the merge in progress and clear instructions. + +Scheduled behavior: + +- Run multiple times per day. +- Report drift with ahead/behind counts and recent upstream commits. +- Open or update one issue instead of creating duplicate notifications. +- Never force-push, reset, discard local commits, or auto-resolve conflicts. + +Completed scope: + +- `pnpm run sync:upstream` runs `scripts/sync-upstream.ts --verify --push`. +- `.github/workflows/upstream-sync-check.yml` checks upstream drift four times daily and opens or updates a sync issue. + +Initial implementation notes: + +- Later, T3 Code itself can expose this as an in-app repository maintenance action. +- A Codex/Claude skill can wrap the same command for agent-assisted conflict resolution. +- If automatic PR creation becomes useful, create a sync branch and PR instead of pushing directly to `main`. + +## P1: Fork Release Policy + +Define when this fork should publish a GitHub Release instead of staying as synced development work. + +Default rule: + +- Do not create a fork release after every upstream sync. +- Treat upstream syncs as maintenance, not release events. +- Create a fork release only when there is a concrete install/test/distribution reason. + +Create a fork prerelease when: + +- A harness feature needs desktop dogfooding outside `vp run dev:sandbox`. +- Another machine or tester needs an installable app artifact. +- A migration or workspace/data behavior needs validation against packaged desktop state. +- The release/update pipeline itself needs a smoke test. +- A milestone has a coherent user-visible improvement, such as workspace identity plus sidebar polish, + `.context` v1, durable task list v1, setup/file-copy v1, or review-surface v1. + +Create a fork stable release only when: + +- The fork is intended to be the user's daily-driver build for a while. +- The branch is synced with upstream and `0` commits behind. +- `vp check`, `vp run typecheck`, and relevant tests pass. +- The release workflow/secrets are configured for the fork, or unsupported publish/deploy steps are disabled. +- Data compatibility is understood, especially for workspace migrations and app-data changes. +- There is a clear rollback path to the previous fork release or upstream release. + +Do not create a fork release when: + +- The change is docs-only or planning-only. +- The fork is behind upstream and the release is not explicitly testing the conflict window. +- There is active unreviewed migration work. +- The only purpose is "we synced upstream." +- Local dev or a sandbox build is enough. + +Recommended fork release shape: + +- Prefer GitHub prereleases for harness work until the feature set is stable. +- Use semver prerelease tags such as `vX.Y.Z-harness.N` or `vX.Y.Z-dev.N` for fork-specific builds, + rather than plain `vX.Y.Z` tags that look like upstream stable releases. +- Use plain stable tags only for deliberate daily-driver releases. +- Release notes should state: + - upstream base commit + - fork-only harness changes + - migration/data compatibility notes + - known risks + - rollback instructions + +Pre-release checklist: + +1. Run `pnpm run sync:upstream`. +2. Confirm `git rev-list --left-right --count HEAD...upstream/main` ends with `0` behind. +3. Run `vp check`. +4. Run `vp run typecheck`. +5. Run tests relevant to changed areas. +6. Run `node scripts/release-smoke.ts` before touching release workflow behavior. +7. Create a prerelease tag only after the above is green. + +## P1: Workspace Lifecycle Dashboard + +Create a workspace-level overview that makes each active thread feel like one shippable unit. + +Show per workspace: + +- Title and provider/model. +- Branch and worktree path. +- Runtime mode and interaction mode. +- Latest turn state. +- Changed files and diff size. +- Running terminals/scripts. +- Preview URL/status. +- PR/MR state when available. +- Next recommended action. + +Initial implementation notes: + +- Reuse existing thread shell projections, VCS status, source-control state, terminal sessions, and preview sessions while migrating terminal/right-panel ownership from thread keys to workspace keys. +- Start read-only before adding lifecycle actions. +- This should become the user's home base for parallel agent work. + +## P1: Integrated File Editor and Review Surface + +Build a Conductor-style file editor inside the workspace view for reviewing and editing project files, with external editor opening as an explicit secondary action. + +Decision: + +- Preserve the current right-side changed-files/diff review surface as the base. +- Improve the current surface incrementally rather than replacing it with a full IDE-like editor. +- File clicks from review contexts should stay in T3 Code by default: changed files, diffs, chat file links, review comments, and checkpoint files. +- VS Code/external editor remains an obvious secondary action for larger edits and deeper refactors. +- The goal is a stronger agent review surface, not a general-purpose IDE. +- T3 supports review-sized edits only; larger coding work stays in the external editor. + +Why this is useful: + +- Review stays in the same workspace as chat, diff, terminal, preview, and PR actions. +- Users can inspect the full file around a diff without losing agent context. +- T3 Code can attach file selections, lines, and comments directly back to the agent. +- Remote and desktop workflows become more consistent because opening an external editor may not always work. + +Expected behavior: + +- Clicking a changed file opens an integrated editor tab by default. +- The file surface supports syntax highlighting, line numbers, search, copy path, copy selection, and "ask agent about selection." +- The editor supports safe direct edits with debounced saves and clear pending/error state. +- In-app edit scope is limited to small line/block edits, hunk actions, and review comments. +- Diff hunks can jump to the corresponding file and line. +- The file tab shows whether it is viewing the workspace file, a checkpoint version, or a diff side. +- External editor remains available as `Open in editor`. + +Initial implementation notes: + +- Build on the existing Files right-panel surface, editable file preview, `@pierre/diffs/editor`, and save coordinator. +- Update changed-file, diff, chat-link, and git-action file clicks to prefer the integrated editor surface. +- Keep the editor focused on agent review workflows; do not try to become a full IDE. +- Add a clear fallback when file content is binary, too large, missing, or outside the workspace. +- Keep external editor integrations for advanced refactors and user preference. +- Prioritize targeted improvements first: collapse all, filters, review state, clearer turn labels, and better file navigation. + +Completed foundation: + +- `FilePreviewPanel` already provides an in-app file viewing/editing surface built on `@pierre/diffs/editor`. +- File saves use a coordinator with debounced writes and pending/error state. +- Markdown task checkboxes can be toggled in file preview. +- Local review comments can be created from file selections and attached back to composer context. + +Still pending: + +- Make changed-file, diff, and chat file links consistently prefer the integrated file surface in review contexts. +- Add review-specific navigation/state around selected files, checkpoint versions, and diff origins. +- Keep external editor opening available as an explicit secondary action. + +## P1: File Diff Review Improvements + +Improve diff navigation and review so large agent changes are easier to understand and act on. + +Expected behavior: + +- File tree grouped by directory with additions/deletions, file type, generated/lockfile badges, and test/docs labels. +- Filters for current turn, all turns, unreviewed files, files with comments, tests only, docs only, and generated files hidden. +- Per-file review status: unreviewed, reviewed, commented, resolved, approved. +- Collapse all / expand all controls with state that survives switching files, turns, and panel visibility. +- Compare any two checkpoints or turns, not only the latest summarized range. +- Clear turn labels that explain whether the user is viewing one assistant turn or the aggregate diff across all turns. +- Summary panel with touched areas, risky files, tests/docs changed, large generated changes, and missing-test hints. +- Hunk-level actions: copy hunk, ask agent about hunk, comment on hunk, revert hunk or file when safe. +- Sticky file navigation for large diffs. + +Initial implementation notes: + +- Build on `DiffPanel`, `ChangedFilesTree`, checkpoint diff queries, and `reviewCommentContext`. +- Start with navigation, grouping, and filters before adding mutation actions like hunk/file revert. +- Persist collapsed file/hunk state by thread, selected turn/checkpoint range, and file path. +- Feed unresolved diff comments into the structured review loop and merge readiness panel. +- Keep diff parsing and rendering in shared/tested helpers; avoid one-off UI parsing. + +Completed foundation: + +- `DiffPanel` already supports turn selection, latest/all-git scopes, selected file state, and branch base selection. +- Changed-file cards include directory grouping and expand/collapse behavior. +- Some collapse state is keyed by diff scope and file path. + +Still pending: + +- Add review filters, generated/test/docs labels, and per-file reviewed/commented/resolved state. +- Add a summary panel for touched areas, risky files, and missing-test hints. +- Add hunk-level actions and persist structured review comments. + +## P2: Merge Readiness Checks Panel + +Add a single readiness panel for work that may be merged. + +Decision: + +- The v1 Checks panel starts with local checks and workspace health. +- Do not wait for GitHub/provider CI integration before building the first version. +- Include git status, uncommitted file count, branch/worktree info, last command/check run, configured check results, blocked/stale task state, context freshness, and PR-created state when available. +- Treat provider CI and review data as later signals layered onto the same local readiness model. + +Checks to aggregate: + +- Git working tree status. +- Uncommitted file count. +- Ahead/behind/default branch delta. +- Branch and worktree identity. +- Project scripts and latest command results. +- Task blocked/stale state. +- Context freshness. +- PR/MR state. +- CI/status checks when provider data is available. +- Unresolved review comments. +- Context todos or open checklist items. +- Last checkpoint/diff availability. + +Initial implementation notes: + +- Start with local signals already available in T3 Code. +- Add provider-backed CI/review signals incrementally. +- Use blockers as guidance, not hard locks, until the data is reliable. + +## P2: Structured Review Loop + +Make review feedback first-class instead of plain prompt text. + +Expected behavior: + +- User selects changed lines in diff. +- T3 Code creates structured review comments. +- Comments can be unresolved or resolved. +- A user can send selected unresolved comments to the active agent. +- The Checks panel reflects unresolved comments. + +Initial implementation notes: + +- Build on `apps/web/src/reviewCommentContext.ts`. +- Persist review comments in orchestration state or a focused review projection. +- Later, sync GitHub/GitLab review comments into the same model. + +## P3: Issue and PR Fanout + +Let users create multiple workspaces from a list of issues or PRs. + +Expected behavior: + +- Pick GitHub/Linear issues or PRs. +- Create one workspace per selected item, with an initial chat seeded from that issue or PR. +- Seed each workspace with issue/PR context in `.context/brief.md`. +- Run setup script per workspace if configured. +- Show all spawned workspaces in the lifecycle dashboard. + +Initial implementation notes: + +- Start with GitHub because source-control support already exists. +- Add Linear later behind a separate integration. +- Keep fanout explicit; do not auto-spawn agents without user confirmation. + +## P3: Spotlight-Style Root Runner + +Support testing one worktree through the repository root. + +Use cases: + +- Fixed local port. +- One shared local database. +- Heavy Docker or microservice stack. +- Expensive build cache that only exists in the root checkout. +- Apps that assume the repository root path. + +Initial implementation notes: + +- Treat this as a later feature because it can mutate the root checkout. +- Require clear UI state showing which workspace is currently active in root. +- Preserve and restore root state carefully. +- Start with a design doc and safety tests before implementation. + +## Suggested Build Order + +Completed foundation: + +1. Keep dev/prod data isolated and run workspace layout behind a feature flag. +2. Add workspace identity above thread/chat with additive migration/backfill for old threads. +3. Preserve old thread routes and thread-id commands while resolving the containing workspace internally. +4. Change the sidebar to Project -> Workspace -> Chat while keeping the center one-chat layout behind the feature flag. +5. Add the durable workspace persistence model with stable workspace IDs and thread-to-workspace links. +6. Add fork sync command and scheduled upstream drift monitor. + +Next implementation sequence: + +1. Treat existing `WorkspaceId` as the owning key for all new harness state. +2. Move remaining branch/worktree ownership toward workspace while preserving existing turn/diff behavior. +3. Add `.context/` creation and basic read/write helpers scoped to workspace. +4. Add durable workspace task-list schemas and a basic task UI. +5. Mirror task state into `.context/tasks.md`. +6. Add repo setup profiles for setup/run/archive scripts. +7. Add prebuilt `.env*` and user-selected local file copy rules. +8. Add desktop sleep prevention while T3 Code has active agent/script/check work. +9. Inject a full T3 Code harness prompt for Codex sessions using workspace context. +10. Add manual "Update handoff" and "Read workspace context" actions. +11. Add deterministic context and task updates after turn completion. +12. Add action-specific prompts for review and PR creation. +13. Add source-control target resolution for commit, push, and PR actions. +14. Keep fork releases as intentional prerelease/stable checkpoints, not automatic sync events. +15. Make changed-file clicks use the current integrated review surface by default. +16. Add diff grouping, filters, collapse state, clearer turn labels, and per-file review state. +17. Build the workspace lifecycle dashboard. +18. Add the merge readiness checks panel. +19. Persist structured review comments. +20. Add issue/PR fanout. +21. Design and build Spotlight-style root runner. diff --git a/docs/reference/scripts.md b/docs/reference/scripts.md index d4d2b96869ee..3cb3ed6fdd83 100644 --- a/docs/reference/scripts.md +++ b/docs/reference/scripts.md @@ -3,6 +3,7 @@ - `bun run dev` — Starts contracts, server, and web in `turbo watch` mode. - `bun run dev:server` — Starts just the WebSocket server (uses Bun TypeScript execution). - `bun run dev:web` — Starts just the Vite dev server for the web app. +- `vp run dev:sandbox` — Starts desktop dev with isolated ports, isolated `~/.t3-dev` data, and the workspace-layout feature flag enabled. - Dev commands default `T3CODE_STATE_DIR` to `~/.t3/dev` to keep dev state isolated from desktop/prod state. - Override server CLI-equivalent flags from root dev commands with `--`, for example: `bun run dev -- --base-dir ~/.t3-2` @@ -43,3 +44,25 @@ Set `T3CODE_DEV_INSTANCE` to any value to deterministically shift all dev ports - Example: `T3CODE_DEV_INSTANCE=branch-a bun run dev:desktop` If you want full control instead of hashing, set `T3CODE_PORT_OFFSET` to a numeric offset. + +## Running the sandbox app + +Use the sandbox command when developing experimental workspace or migration flows while keeping your installed/current T3 Code data untouched: + +```bash +vp run dev:sandbox +``` + +The wrapper defaults to: + +- `T3CODE_DEV_INSTANCE=dev` +- `T3CODE_HOME=$HOME/.t3-dev` +- `T3CODE_WORKSPACE_LAYOUT=1` + +It refuses to use `$HOME/.t3` unless `T3CODE_ALLOW_PROD_HOME=1` is set, because `$HOME/.t3` is the normal app data directory. + +You can still override any of those values explicitly: + +```bash +T3CODE_HOME="$HOME/.t3-dev-migration-test" vp run dev:sandbox +``` diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md index 254aa92c6a05..8ba0d5f08c7a 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -64,11 +64,13 @@ Invalid rules are ignored. Invalid config files are ignored. Warnings are logged - `preview.zoomOut`: zoom the preview viewport out one step (in focused preview context by default) - `preview.resetZoom`: reset the preview zoom to 100% (in focused preview context by default) - `commandPalette.toggle`: open or close the global command palette -- `chat.new`: create a new chat thread preserving the active thread's branch/worktree state -- `chat.newLocal`: create a new chat thread for the active project in a new environment (local/worktree determined by app settings (default `local`)) +- `chat.new`: create a new chat thread in the active context, preserving the current branch/worktree when the target is the current project +- `chat.newLocal`: create a new chat thread for the active project with branch/worktree cleared and the app's default new-thread environment mode - `editor.openFavorite`: open current project/worktree in the last-used editor - `script.{id}.run`: run a project script by id (for example `script.test.run`) +In the command palette, **New chat in...** starts from the selected project. If that project differs from the active chat's project, T3 Code clears the active branch/worktree context so the new draft is created under the selected project. + ### Key Syntax Supported modifiers: diff --git a/package.json b/package.json index f97275e60bbf..4e33e1c370b3 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "dev": "node scripts/dev-runner.ts dev", "dev:server": "node scripts/dev-runner.ts dev:server", "dev:web": "node scripts/dev-runner.ts dev:web", + "dev:sandbox": "node scripts/dev-sandbox.ts", "dev:marketing": "vp run --filter @t3tools/marketing dev", "dev:desktop": "node scripts/dev-runner.ts dev:desktop", "start": "vp run --filter t3 start", @@ -35,7 +36,8 @@ "dist:desktop:win:x64": "node scripts/build-desktop-artifact.ts --platform win --target nsis --arch x64", "release:smoke": "node scripts/release-smoke.ts", "clean": "rm -rf node_modules apps/*/node_modules packages/*/node_modules apps/*/dist apps/*/dist-electron packages/*/dist .vite-plus apps/*/.vite-plus packages/*/.vite-plus", - "sync:repos": "node scripts/sync-reference-repos.ts" + "sync:repos": "node scripts/sync-reference-repos.ts", + "sync:upstream": "node scripts/sync-upstream.ts --verify --push" }, "devDependencies": { "@babel/plugin-transform-react-jsx": "7.28.6", diff --git a/packages/client-runtime/src/state/threadReducer.test.ts b/packages/client-runtime/src/state/threadReducer.test.ts index 94eb1c65370e..c3c1f9e1996d 100644 --- a/packages/client-runtime/src/state/threadReducer.test.ts +++ b/packages/client-runtime/src/state/threadReducer.test.ts @@ -189,6 +189,37 @@ describe("applyThreadDetailEvent", () => { expect(result.thread.modelSelection).toEqual(baseThread.modelSelection); } }); + + it("preserves worktree identity when stale local metadata arrives", () => { + const worktreeThread: OrchestrationThread = { + ...baseThread, + branch: "t3code/generated-worktree", + worktreePath: "/tmp/provider-project-worktree", + }; + + const result = applyThreadDetailEvent(worktreeThread, { + ...baseEventFields, + sequence: 6, + occurredAt: "2026-04-01T06:00:00.000Z", + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + type: "thread.meta-updated", + payload: { + threadId: ThreadId.make("thread-1"), + title: "Updated Title", + branch: "feat/enhancements", + worktreePath: null, + updatedAt: "2026-04-01T06:00:00.000Z", + }, + }); + + expect(result.kind).toBe("updated"); + if (result.kind === "updated") { + expect(result.thread.title).toBe("Updated Title"); + expect(result.thread.branch).toBe("t3code/generated-worktree"); + expect(result.thread.worktreePath).toBe("/tmp/provider-project-worktree"); + } + }); }); describe("thread.message-sent", () => { diff --git a/packages/client-runtime/src/state/threadReducer.ts b/packages/client-runtime/src/state/threadReducer.ts index 670540fee701..28f9517f4f40 100644 --- a/packages/client-runtime/src/state/threadReducer.ts +++ b/packages/client-runtime/src/state/threadReducer.ts @@ -35,6 +35,13 @@ const activityOrder = O.combineAll([ O.mapInput(O.String, (a) => a.id), ]); +function shouldPreserveExistingWorktreeIdentity( + thread: Pick, + payload: { readonly worktreePath?: string | null | undefined }, +): boolean { + return thread.worktreePath !== null && payload.worktreePath === null; +} + /** * Apply a single orchestration event to an `OrchestrationThread`, returning * the updated thread, a deletion signal, or an "unchanged" marker when the @@ -101,7 +108,11 @@ export function applyThreadDetailEvent( }; // ── Thread metadata ───────────────────────────────────────────── - case "thread.meta-updated": + case "thread.meta-updated": { + const preserveWorktreeIdentity = shouldPreserveExistingWorktreeIdentity( + thread, + event.payload, + ); return { kind: "updated", thread: { @@ -110,13 +121,16 @@ export function applyThreadDetailEvent( ...(event.payload.modelSelection !== undefined ? { modelSelection: event.payload.modelSelection } : {}), - ...(event.payload.branch !== undefined ? { branch: event.payload.branch } : {}), - ...(event.payload.worktreePath !== undefined + ...(!preserveWorktreeIdentity && event.payload.branch !== undefined + ? { branch: event.payload.branch } + : {}), + ...(!preserveWorktreeIdentity && event.payload.worktreePath !== undefined ? { worktreePath: event.payload.worktreePath } : {}), updatedAt: event.payload.updatedAt, }, }; + } case "thread.runtime-mode-set": return { diff --git a/packages/contracts/src/baseSchemas.ts b/packages/contracts/src/baseSchemas.ts index 614ea5131fbc..be9a7d3748ee 100644 --- a/packages/contracts/src/baseSchemas.ts +++ b/packages/contracts/src/baseSchemas.ts @@ -31,6 +31,8 @@ export const ThreadId = makeEntityId("ThreadId"); export type ThreadId = typeof ThreadId.Type; export const ProjectId = makeEntityId("ProjectId"); export type ProjectId = typeof ProjectId.Type; +export const WorkspaceId = makeEntityId("WorkspaceId"); +export type WorkspaceId = typeof WorkspaceId.Type; export const EnvironmentId = makeEntityId("EnvironmentId"); export type EnvironmentId = typeof EnvironmentId.Type; export const CommandId = makeEntityId("CommandId"); diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 623fed0917bf..56b20b754eab 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -19,6 +19,7 @@ import { ThreadId, TrimmedNonEmptyString, TurnId, + WorkspaceId, } from "./baseSchemas.ts"; import { ProviderInstanceId } from "./providerInstance.ts"; @@ -344,6 +345,10 @@ export type OrchestrationLatestTurn = typeof OrchestrationLatestTurn.Type; export const OrchestrationThread = Schema.Struct({ id: ThreadId, projectId: ProjectId, + workspaceId: Schema.optional(Schema.NullOr(WorkspaceId)), + workspaceBranch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + workspaceWorktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + workspaceLocalCheckout: Schema.optional(Schema.Boolean), title: TrimmedNonEmptyString, modelSelection: ModelSelection, runtimeMode: RuntimeMode, @@ -367,9 +372,23 @@ export const OrchestrationThread = Schema.Struct({ }); export type OrchestrationThread = typeof OrchestrationThread.Type; +export const OrchestrationWorkspace = Schema.Struct({ + id: WorkspaceId, + projectId: ProjectId, + branch: Schema.NullOr(TrimmedNonEmptyString), + worktreePath: Schema.NullOr(TrimmedNonEmptyString), + localCheckout: Schema.Boolean, + createdAt: IsoDateTime, + updatedAt: IsoDateTime, + archivedAt: Schema.NullOr(IsoDateTime), + deletedAt: Schema.NullOr(IsoDateTime), +}); +export type OrchestrationWorkspace = typeof OrchestrationWorkspace.Type; + export const OrchestrationReadModel = Schema.Struct({ snapshotSequence: NonNegativeInt, projects: Schema.Array(OrchestrationProject), + workspaces: Schema.optional(Schema.Array(OrchestrationWorkspace)), threads: Schema.Array(OrchestrationThread), updatedAt: IsoDateTime, }); @@ -390,6 +409,10 @@ export type OrchestrationProjectShell = typeof OrchestrationProjectShell.Type; export const OrchestrationThreadShell = Schema.Struct({ id: ThreadId, projectId: ProjectId, + workspaceId: Schema.optional(Schema.NullOr(WorkspaceId)), + workspaceBranch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + workspaceWorktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + workspaceLocalCheckout: Schema.optional(Schema.Boolean), title: TrimmedNonEmptyString, modelSelection: ModelSelection, runtimeMode: RuntimeMode, @@ -413,6 +436,7 @@ export type OrchestrationThreadShell = typeof OrchestrationThreadShell.Type; export const OrchestrationShellSnapshot = Schema.Struct({ snapshotSequence: NonNegativeInt, projects: Schema.Array(OrchestrationProjectShell), + workspaces: Schema.optional(Schema.Array(OrchestrationWorkspace)), threads: Schema.Array(OrchestrationThreadShell), updatedAt: IsoDateTime, }); diff --git a/scripts/dev-sandbox.ts b/scripts/dev-sandbox.ts new file mode 100644 index 000000000000..17d981acb836 --- /dev/null +++ b/scripts/dev-sandbox.ts @@ -0,0 +1,80 @@ +#!/usr/bin/env node + +import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as NodeOS from "node:os"; +import * as NodeURL from "node:url"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; +import * as Path from "effect/Path"; +import { ChildProcess } from "effect/unstable/process"; + +const devRunnerPath = NodeURL.fileURLToPath(new URL("./dev-runner.ts", import.meta.url)); + +class DevSandboxError extends Data.TaggedError("DevSandboxError")<{ + readonly message: string; +}> {} + +const resolveSandboxEnvironment = Effect.gen(function* () { + const path = yield* Path.Path; + const productionHome = path.resolve(path.join(NodeOS.homedir(), ".t3")); + const sandboxHome = path.resolve( + process.env.T3CODE_HOME?.trim() || path.join(NodeOS.homedir(), ".t3-dev"), + ); + const devInstance = process.env.T3CODE_DEV_INSTANCE?.trim() || "dev"; + const workspaceLayout = process.env.T3CODE_WORKSPACE_LAYOUT?.trim() || "1"; + + if (sandboxHome === productionHome && process.env.T3CODE_ALLOW_PROD_HOME !== "1") { + return yield* new DevSandboxError({ + message: [ + "[dev-sandbox] Refusing to use the production T3 Code data directory.", + `T3CODE_HOME=${sandboxHome}`, + "Use a dev directory such as T3CODE_HOME=$HOME/.t3-dev, or set T3CODE_ALLOW_PROD_HOME=1 if this is intentional.", + ].join("\n"), + }); + } + + return { devInstance, sandboxHome, workspaceLayout }; +}); + +const runDevSandbox = Effect.gen(function* () { + const { devInstance, sandboxHome, workspaceLayout } = yield* resolveSandboxEnvironment; + + yield* Effect.logWarning( + `[dev-sandbox] instance=${devInstance} home=${sandboxHome} workspaceLayout=${workspaceLayout}`, + ); + + const env = { + ...process.env, + T3CODE_DEV_INSTANCE: devInstance, + T3CODE_HOME: sandboxHome, + T3CODE_WORKSPACE_LAYOUT: workspaceLayout, + }; + + const child = yield* ChildProcess.make( + process.execPath, + [devRunnerPath, "dev:desktop", ...process.argv.slice(2)], + { + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + env, + extendEnv: false, + detached: false, + forceKillAfter: "1500 millis", + }, + ); + + const exitCode = yield* child.exitCode; + if (exitCode !== 0) { + return yield* new DevSandboxError({ message: `dev-runner exited with code ${exitCode}` }); + } +}); + +runDevSandbox.pipe( + Effect.scoped, + Effect.provide(Layer.mergeAll(Logger.layer([Logger.consolePretty()]), NodeServices.layer)), + NodeRuntime.runMain, +); diff --git a/scripts/sync-upstream.ts b/scripts/sync-upstream.ts new file mode 100644 index 000000000000..f1304abdede0 --- /dev/null +++ b/scripts/sync-upstream.ts @@ -0,0 +1,222 @@ +#!/usr/bin/env node + +import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as NodeURL from "node:url"; +import * as Console from "effect/Console"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { Command, Flag } from "effect/unstable/cli"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +export class SyncUpstreamError extends Schema.TaggedErrorClass()( + "SyncUpstreamError", + { + message: Schema.String, + }, +) {} + +interface SyncOptions { + readonly branch: string; + readonly dryRun: boolean; + readonly push: boolean; + readonly upstreamRef: string; + readonly verify: boolean; +} + +const collectStreamAsString = (stream: Stream.Stream): Effect.Effect => + stream.pipe( + Stream.decodeText(), + Stream.runFold( + () => "", + (acc, chunk) => acc + chunk, + ), + ); + +const spawnAndCollect = Effect.fn("spawnAndCollect")(function* ( + command: string, + args: ReadonlyArray, +) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const child = yield* spawner.spawn(ChildProcess.make(command, args)); + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + collectStreamAsString(child.stdout), + collectStreamAsString(child.stderr), + child.exitCode.pipe(Effect.map(Number)), + ], + { concurrency: "unbounded" }, + ); + return { stdout, stderr, exitCode } as const; +}); + +const run = Effect.fn("run")(function* (command: string, args: ReadonlyArray) { + yield* Console.log(`> ${[command, ...args].join(" ")}`); + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const child = yield* spawner.spawn( + ChildProcess.make(command, args, { + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + }), + ); + const exitCode = Number(yield* child.exitCode); + if (exitCode !== 0) { + return yield* new SyncUpstreamError({ + message: `${command} ${args.join(" ")} exited with ${exitCode}`, + }); + } +}); + +const read = Effect.fn("read")(function* (command: string, args: ReadonlyArray) { + const result = yield* spawnAndCollect(command, args); + if (result.exitCode !== 0) { + return yield* new SyncUpstreamError({ + message: `${command} ${args.join(" ")} exited with ${result.exitCode}: ${result.stderr}`, + }); + } + return result.stdout.trim(); +}); + +const ensureCleanWorktree = Effect.fn("ensureCleanWorktree")(function* () { + const status = yield* read("git", ["status", "--porcelain"]); + if (status.length > 0) { + return yield* new SyncUpstreamError({ + message: [ + "Working tree is not clean. Commit or stash local changes before syncing upstream.", + status, + ].join("\n"), + }); + } +}); + +const ensureBranch = Effect.fn("ensureBranch")(function* (branch: string) { + const currentBranch = yield* read("git", ["branch", "--show-current"]); + if (currentBranch !== branch) { + return yield* new SyncUpstreamError({ + message: `Expected branch ${branch}, but current branch is ${currentBranch}.`, + }); + } +}); + +const countAheadBehind = Effect.fn("countAheadBehind")(function* ( + leftRef: string, + rightRef: string, +) { + const [aheadRaw, behindRaw] = (yield* read("git", [ + "rev-list", + "--left-right", + "--count", + `${leftRef}...${rightRef}`, + ])).split(/\s+/); + return { ahead: Number(aheadRaw), behind: Number(behindRaw) }; +}); + +const maybeInstallDependencies = Effect.fn("maybeInstallDependencies")(function* ( + previousHead: string, +) { + const changedFiles = (yield* read("git", ["diff", "--name-only", `${previousHead}..HEAD`])) + .split("\n") + .filter((file) => file.length > 0); + const dependencyFilesChanged = changedFiles.some((file) => + [ + "package.json", + "pnpm-lock.yaml", + "pnpm-workspace.yaml", + "apps/desktop/package.json", + "apps/mobile/package.json", + "apps/server/package.json", + "apps/web/package.json", + "packages/contracts/package.json", + "packages/shared/package.json", + ].includes(file), + ); + if (dependencyFilesChanged) { + yield* run("pnpm", ["install", "--frozen-lockfile"]); + } +}); + +export const syncUpstream = Effect.fn("syncUpstream")(function* (options: SyncOptions) { + yield* ensureBranch(options.branch); + yield* ensureCleanWorktree(); + + yield* run("git", ["fetch", "--prune", "origin"]); + yield* run("git", ["fetch", "--prune", "upstream"]); + + const before = yield* countAheadBehind("HEAD", options.upstreamRef); + yield* Console.log( + `Current branch is ${before.ahead} commits ahead of and ${before.behind} commits behind ${options.upstreamRef}.`, + ); + + if (before.behind === 0) { + yield* Console.log("Already up to date with upstream."); + if (options.push) { + yield* run("git", ["push", "origin", options.branch]); + } + return; + } + + if (options.dryRun) { + yield* Console.log("Dry run complete. No merge performed."); + return; + } + + const previousHead = yield* read("git", ["rev-parse", "HEAD"]); + yield* run("git", ["merge", "--no-edit", options.upstreamRef]).pipe( + Effect.mapError( + (cause) => + new SyncUpstreamError({ + message: [ + "Upstream merge stopped with conflicts.", + "Resolve the conflicts, run checks, commit the merge, then push origin.", + String(cause), + ].join("\n"), + }), + ), + ); + + yield* maybeInstallDependencies(previousHead); + + if (options.verify) { + yield* run("pnpm", ["exec", "vp", "check"]); + yield* run("pnpm", ["exec", "vp", "run", "typecheck"]); + } + + if (options.push) { + yield* run("git", ["push", "origin", options.branch]); + } + + const after = yield* countAheadBehind("HEAD", options.upstreamRef); + yield* Console.log( + `Done. Current branch is ${after.ahead} commits ahead of and ${after.behind} commits behind ${options.upstreamRef}.`, + ); +}); + +const syncUpstreamCommand = Command.make( + "sync-upstream", + { + branch: Flag.string("branch").pipe(Flag.withDefault("main")), + upstream: Flag.string("upstream").pipe(Flag.withDefault("upstream/main")), + dryRun: Flag.boolean("dry-run").pipe(Flag.withDefault(false)), + push: Flag.boolean("push").pipe(Flag.withDefault(false)), + verify: Flag.boolean("verify").pipe(Flag.withDefault(false)), + }, + ({ branch, upstream, dryRun, push, verify }) => + syncUpstream({ branch, upstreamRef: upstream, dryRun, push, verify }), +).pipe( + Command.withDescription( + "Merge upstream/main into the fork safely, optionally verify and push origin/main.", + ), +); + +const isEntryPoint = + typeof process.argv[1] === "string" && process.argv[1] === NodeURL.fileURLToPath(import.meta.url); + +if (isEntryPoint) { + Command.run(syncUpstreamCommand, { version: "0.0.0" }).pipe( + Effect.scoped, + Effect.provide(NodeServices.layer), + NodeRuntime.runMain, + ); +}