diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index 78394af58557..d6ba9229b589 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -3,6 +3,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import * as NodeServices from "@effect/platform-node/NodeServices"; import { Effect, Exit, Layer, PubSub, Scope, Stream } from "effect"; import { describe, expect, it, afterEach, vi } from "vitest"; import { createServer } from "./wsServer"; @@ -38,7 +39,7 @@ import type { TerminalWriteInput, } from "@t3tools/contracts"; import { TerminalManager, type TerminalManagerShape } from "./terminal/Services/Manager"; -import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite"; +import { makeSqlitePersistenceLive, SqlitePersistenceMemory } from "./persistence/Layers/Sqlite"; import { SqlClient } from "effect/unstable/sql"; import { ProviderService, type ProviderServiceShape } from "./provider/Services/ProviderService"; import { Open, type OpenShape } from "./open"; @@ -448,23 +449,117 @@ describe("WebSocket Server", () => { const ws = await connectWs(port); connections.push(ws); - await waitForMessage(ws); // welcome + const welcome = (await waitForMessage(ws)) as WsPush; // welcome + expect(welcome.channel).toBe(WS_CHANNELS.serverWelcome); + expect(welcome.data).toEqual( + expect.objectContaining({ + cwd: "/test/bootstrap-workspace", + projectName: "bootstrap-workspace", + bootstrapProjectId: expect.any(String), + bootstrapThreadId: expect.any(String), + }), + ); const snapshotResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.getSnapshot); expect(snapshotResponse.error).toBeUndefined(); const snapshot = snapshotResponse.result as { - projects: Array<{ workspaceRoot: string; title: string; defaultModel: string | null }>; + projects: Array<{ + id: string; + workspaceRoot: string; + title: string; + defaultModel: string | null; + }>; + threads: Array<{ + id: string; + projectId: string; + title: string; + model: string; + branch: string | null; + worktreePath: string | null; + }>; }; + const bootstrapProjectId = (welcome.data as { bootstrapProjectId?: string }).bootstrapProjectId; + const bootstrapThreadId = (welcome.data as { bootstrapThreadId?: string }).bootstrapThreadId; + expect(bootstrapProjectId).toBeDefined(); + expect(bootstrapThreadId).toBeDefined(); expect(snapshot.projects).toEqual( expect.arrayContaining([ expect.objectContaining({ + id: bootstrapProjectId, workspaceRoot: "/test/bootstrap-workspace", title: "bootstrap-workspace", defaultModel: "gpt-5-codex", }), ]), ); + expect(snapshot.threads).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: bootstrapThreadId, + projectId: bootstrapProjectId, + title: "New thread", + model: "gpt-5-codex", + branch: null, + worktreePath: null, + }), + ]), + ); + }); + + it("includes bootstrap ids in welcome when cwd project and thread already exist", async () => { + const stateDir = makeTempDir("t3code-state-bootstrap-existing-"); + const persistenceLayer = makeSqlitePersistenceLive( + path.join(stateDir, "state.sqlite"), + ).pipe(Layer.provide(NodeServices.layer)) as unknown as Layer.Layer; + const cwd = "/test/bootstrap-existing"; + + server = await createTestServer({ + cwd, + stateDir, + persistenceLayer, + autoBootstrapProjectFromCwd: true, + }); + let addr = server.address(); + let port = typeof addr === "object" && addr !== null ? addr.port : 0; + expect(port).toBeGreaterThan(0); + + const firstWs = await connectWs(port); + connections.push(firstWs); + const firstWelcome = (await waitForMessage(firstWs)) as WsPush; + const firstBootstrapProjectId = (firstWelcome.data as { bootstrapProjectId?: string }) + .bootstrapProjectId; + const firstBootstrapThreadId = (firstWelcome.data as { bootstrapThreadId?: string }) + .bootstrapThreadId; + expect(firstBootstrapProjectId).toBeDefined(); + expect(firstBootstrapThreadId).toBeDefined(); + + firstWs.close(); + await closeTestServer(); + server = null; + + server = await createTestServer({ + cwd, + stateDir, + persistenceLayer, + autoBootstrapProjectFromCwd: true, + }); + addr = server.address(); + port = typeof addr === "object" && addr !== null ? addr.port : 0; + expect(port).toBeGreaterThan(0); + + const secondWs = await connectWs(port); + connections.push(secondWs); + const secondWelcome = (await waitForMessage(secondWs)) as WsPush; + expect(secondWelcome.channel).toBe(WS_CHANNELS.serverWelcome); + expect(secondWelcome.data).toEqual( + expect.objectContaining({ + cwd, + projectName: "bootstrap-existing", + bootstrapProjectId: firstBootstrapProjectId, + bootstrapThreadId: firstBootstrapThreadId, + }), + ); }); it("logs outbound websocket push events in dev mode", async () => { diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index aa72c851f9d8..f00a99019c16 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -16,6 +16,7 @@ import { ORCHESTRATION_WS_CHANNELS, ORCHESTRATION_WS_METHODS, ProjectId, + ThreadId, TerminalEvent, WS_CHANNELS, WS_METHODS, @@ -337,22 +338,59 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< yield* Scope.provide(orchestrationReactor.start, subscriptionsScope); + let welcomeBootstrapProjectId: ProjectId | undefined; + let welcomeBootstrapThreadId: ThreadId | undefined; + if (autoBootstrapProjectFromCwd) { yield* Effect.gen(function* () { const snapshot = yield* projectionReadModelQuery.getSnapshot(); - const existing = snapshot.projects.find((project) => project.workspaceRoot === cwd); - if (!existing) { + const existingProject = snapshot.projects.find( + (project) => project.workspaceRoot === cwd && project.deletedAt === null, + ); + let bootstrapProjectId: ProjectId; + let bootstrapProjectDefaultModel: string; + + if (!existingProject) { const createdAt = new Date().toISOString(); - const projectName = path.basename(cwd) || "project"; + bootstrapProjectId = ProjectId.makeUnsafe(crypto.randomUUID()); + const bootstrapProjectTitle = path.basename(cwd) || "project"; + bootstrapProjectDefaultModel = "gpt-5-codex"; yield* orchestrationEngine.dispatch({ type: "project.create", commandId: CommandId.makeUnsafe(crypto.randomUUID()), - projectId: ProjectId.makeUnsafe(crypto.randomUUID()), - title: projectName, + projectId: bootstrapProjectId, + title: bootstrapProjectTitle, workspaceRoot: cwd, - defaultModel: "gpt-5-codex", + defaultModel: bootstrapProjectDefaultModel, createdAt, }); + } else { + bootstrapProjectId = existingProject.id; + bootstrapProjectDefaultModel = existingProject.defaultModel ?? "gpt-5-codex"; + } + + const existingThread = snapshot.threads.find( + (thread) => thread.projectId === bootstrapProjectId && thread.deletedAt === null, + ); + if (!existingThread) { + const createdAt = new Date().toISOString(); + const threadId = ThreadId.makeUnsafe(crypto.randomUUID()); + yield* orchestrationEngine.dispatch({ + type: "thread.create", + commandId: CommandId.makeUnsafe(crypto.randomUUID()), + threadId, + projectId: bootstrapProjectId, + title: "New thread", + model: bootstrapProjectDefaultModel, + branch: null, + worktreePath: null, + createdAt, + }); + welcomeBootstrapProjectId = bootstrapProjectId; + welcomeBootstrapThreadId = threadId; + } else { + welcomeBootstrapProjectId = bootstrapProjectId; + welcomeBootstrapThreadId = existingThread.id; } }).pipe( Effect.mapError( @@ -581,7 +619,12 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< const welcome: WsPush = { type: "push", channel: WS_CHANNELS.serverWelcome, - data: { cwd, projectName }, + data: { + cwd, + projectName, + ...(welcomeBootstrapProjectId ? { bootstrapProjectId: welcomeBootstrapProjectId } : {}), + ...(welcomeBootstrapThreadId ? { bootstrapThreadId: welcomeBootstrapThreadId } : {}), + }, }; logOutgoingPush(welcome, 1); ws.send(JSON.stringify(welcome)); diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 45bb836049e6..51e4e9b01393 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -1,5 +1,11 @@ import { ThreadId } from "@t3tools/contracts"; -import { Outlet, createRootRouteWithContext, type ErrorComponentProps } from "@tanstack/react-router"; +import { + Outlet, + createRootRouteWithContext, + type ErrorComponentProps, + useNavigate, + useRouterState, +} from "@tanstack/react-router"; import { useEffect, useRef } from "react"; import { QueryClient, useQueryClient } from "@tanstack/react-query"; @@ -119,7 +125,12 @@ function errorDetails(error: unknown): string { function EventRouter() { const { dispatch } = useStore(); const queryClient = useQueryClient(); + const navigate = useNavigate(); + const pathname = useRouterState({ select: (state) => state.location.pathname }); + const pathnameRef = useRef(pathname); const lastConfigIssuesSignatureRef = useRef(null); + const handledBootstrapThreadIdRef = useRef(null); + pathnameRef.current = pathname; useEffect(() => { const api = readNativeApi(); @@ -180,8 +191,35 @@ function EventRouter() { hasRunningSubprocess, }); }); - const unsubWelcome = onServerWelcome(() => { - void syncSnapshot(); + const unsubWelcome = onServerWelcome((payload) => { + void (async () => { + await syncSnapshot(); + if (disposed) { + return; + } + + if (!payload.bootstrapProjectId || !payload.bootstrapThreadId) { + return; + } + dispatch({ + type: "SET_PROJECT_EXPANDED", + projectId: payload.bootstrapProjectId, + expanded: true, + }); + + if (pathnameRef.current !== "/") { + return; + } + if (handledBootstrapThreadIdRef.current === payload.bootstrapThreadId) { + return; + } + await navigate({ + to: "/$threadId", + params: { threadId: payload.bootstrapThreadId }, + replace: true, + }); + handledBootstrapThreadIdRef.current = payload.bootstrapThreadId; + })().catch(() => undefined); }); const unsubServerConfigUpdated = onServerConfigUpdated((payload) => { const signature = JSON.stringify(payload.issues); @@ -232,7 +270,7 @@ function EventRouter() { unsubWelcome(); unsubServerConfigUpdated(); }; - }, [dispatch, queryClient]); + }, [dispatch, navigate, queryClient]); return null; } diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 89b010603736..8a9af566a4f1 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -41,6 +41,7 @@ type Action = terminalId: string; hasRunningSubprocess: boolean; } + | { type: "SET_PROJECT_EXPANDED"; projectId: Project["id"]; expanded: boolean } | { type: "TOGGLE_THREAD_TERMINAL"; threadId: ThreadId } | { type: "SET_THREAD_TERMINAL_OPEN"; threadId: ThreadId; open: boolean } | { type: "SET_THREAD_TERMINAL_HEIGHT"; threadId: ThreadId; height: number } @@ -571,6 +572,14 @@ export function reducer(state: AppState, action: Action): AppState { ), }; + case "SET_PROJECT_EXPANDED": + return { + ...state, + projects: state.projects.map((p) => + p.id === action.projectId ? { ...p, expanded: action.expanded } : p, + ), + }; + case "TOGGLE_THREAD_TERMINAL": return { ...state, diff --git a/apps/web/src/wsNativeApi.test.ts b/apps/web/src/wsNativeApi.test.ts index 357c605996e8..aa266f8e0861 100644 --- a/apps/web/src/wsNativeApi.test.ts +++ b/apps/web/src/wsNativeApi.test.ts @@ -65,16 +65,41 @@ describe("wsNativeApi", () => { emitPush(WS_CHANNELS.serverWelcome, payload); expect(listener).toHaveBeenCalledTimes(1); - expect(listener).toHaveBeenCalledWith(payload); + expect(listener).toHaveBeenCalledWith(expect.objectContaining(payload)); const lateListener = vi.fn(); onServerWelcome(lateListener); expect(lateListener).toHaveBeenCalledTimes(1); - expect(lateListener).toHaveBeenCalledWith(payload); + expect(lateListener).toHaveBeenCalledWith(expect.objectContaining(payload)); expect(warnSpy).not.toHaveBeenCalled(); }); + it("preserves bootstrap ids from server.welcome payloads", async () => { + const { createWsNativeApi, onServerWelcome } = await import("./wsNativeApi"); + + createWsNativeApi(); + const listener = vi.fn(); + onServerWelcome(listener); + + emitPush(WS_CHANNELS.serverWelcome, { + cwd: "/tmp/workspace", + projectName: "t3-code", + bootstrapProjectId: "project-1", + bootstrapThreadId: "thread-1", + }); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + cwd: "/tmp/workspace", + projectName: "t3-code", + bootstrapProjectId: "project-1", + bootstrapThreadId: "thread-1", + }), + ); + }); + it("ignores invalid server.welcome payloads and keeps subscription active", async () => { const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const { createWsNativeApi, onServerWelcome } = await import("./wsNativeApi"); @@ -87,7 +112,9 @@ describe("wsNativeApi", () => { emitPush(WS_CHANNELS.serverWelcome, { cwd: "/tmp/workspace", projectName: "t3-code" }); expect(listener).toHaveBeenCalledTimes(1); - expect(listener).toHaveBeenCalledWith({ cwd: "/tmp/workspace", projectName: "t3-code" }); + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ cwd: "/tmp/workspace", projectName: "t3-code" }), + ); expect(warnSpy).toHaveBeenCalledTimes(1); expect(warnSpy).toHaveBeenCalledWith("Dropped inbound WebSocket push payload", { reason: "decode-failed", diff --git a/packages/contracts/src/ws.ts b/packages/contracts/src/ws.ts index e8b0e073244e..7f94615bdd47 100644 --- a/packages/contracts/src/ws.ts +++ b/packages/contracts/src/ws.ts @@ -1,5 +1,5 @@ import { Schema, Struct } from "effect"; -import { TrimmedNonEmptyString } from "./baseSchemas"; +import { ProjectId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas"; import { ClientOrchestrationCommand, @@ -163,5 +163,7 @@ export type WsResponse = typeof WsResponse.Type; export const WsWelcomePayload = Schema.Struct({ cwd: TrimmedNonEmptyString, projectName: TrimmedNonEmptyString, + bootstrapProjectId: Schema.optional(ProjectId), + bootstrapThreadId: Schema.optional(ThreadId), }); export type WsWelcomePayload = typeof WsWelcomePayload.Type;