diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index 78394af58557afd93d258465718e17b5bf74d66a..3d8f53ec6957b7198a5992ed5257821e66685467 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -448,23 +448,62 @@ 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("logs outbound websocket push events in dev mode", async () => { diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index aa72c851f9d8f3a0f33eb776edb1d0598ef2a4d4..0100141017407fe598e3deccbfeff7c5cdb83308 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,56 @@ 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; } }).pipe( Effect.mapError( @@ -581,7 +616,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 45bb836049e6bce2e61c7160a45b05c374b34bbe..b21389e3d4dcdac828b4b68b3e7f1c69cf922a26 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,10 @@ 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 lastConfigIssuesSignatureRef = useRef(null); + const handledBootstrapThreadIdRef = useRef(null); useEffect(() => { const api = readNativeApi(); @@ -180,8 +189,31 @@ function EventRouter() { hasRunningSubprocess, }); }); - const unsubWelcome = onServerWelcome(() => { - void syncSnapshot(); + const unsubWelcome = onServerWelcome((payload) => { + void (async () => { + await syncSnapshot(); + + if (!payload.bootstrapProjectId || !payload.bootstrapThreadId) { + return; + } + dispatch({ + type: "SET_PROJECT_EXPANDED", + projectId: payload.bootstrapProjectId, + expanded: true, + }); + if (pathname !== "/") { + return; + } + if (handledBootstrapThreadIdRef.current === payload.bootstrapThreadId) { + return; + } + handledBootstrapThreadIdRef.current = payload.bootstrapThreadId; + await navigate({ + to: "/$threadId", + params: { threadId: payload.bootstrapThreadId }, + replace: true, + }); + })().catch(() => undefined); }); const unsubServerConfigUpdated = onServerConfigUpdated((payload) => { const signature = JSON.stringify(payload.issues); @@ -232,7 +264,7 @@ function EventRouter() { unsubWelcome(); unsubServerConfigUpdated(); }; - }, [dispatch, queryClient]); + }, [dispatch, navigate, pathname, queryClient]); return null; } diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 89b0106037366d644bf52aab0e32ca31ead3fb84..8a9af566a4f17e86fe547d38eba1e9eb89094c25 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 357c605996e8319d9f55494004905dca049f5c95..aa266f8e08610c9015c238b78e3af2563784d664 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 e8b0e073244e00cafdc8d3ee127fa0357533f2fa..7f94615bdd47cc146697ac238a5b4590627b4c4f 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;