authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-26 18:43:08-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-26 21:05:53-08:00
log5fd632c357914553f2baf746df0454527db89b09
tree641e95313c6817fe19e61c6553c09ef3c0d71fa5
parentbb2164c9459eeeff603aebd540701918e0d9286f

Bootstrap default thread and route to it on server welcome

- Create or reuse cwd project, create initial thread, and include bootstrap IDs in `server.welcome` - Update web event routing to expand the project and auto-navigate from `/` to the bootstrapped thread - Extend WebSocket welcome contract and add server/web tests for bootstrap payload handling

6 files changed, 166 insertions(+), 17 deletions(-)

apps/server/src/wsServer.test.ts+41-2
......@@ -448,23 +448,62 @@ describe("WebSocket Server", () => {
448448
449449 const ws = await connectWs(port);
450450 connections.push(ws);
451 await waitForMessage(ws); // welcome
451 const welcome = (await waitForMessage(ws)) as WsPush; // welcome
452 expect(welcome.channel).toBe(WS_CHANNELS.serverWelcome);
453 expect(welcome.data).toEqual(
454 expect.objectContaining({
455 cwd: "/test/bootstrap-workspace",
456 projectName: "bootstrap-workspace",
457 bootstrapProjectId: expect.any(String),
458 bootstrapThreadId: expect.any(String),
459 }),
460 );
452461
453462 const snapshotResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.getSnapshot);
454463 expect(snapshotResponse.error).toBeUndefined();
455464 const snapshot = snapshotResponse.result as {
456 projects: Array<{ workspaceRoot: string; title: string; defaultModel: string | null }>;
465 projects: Array<{
466 id: string;
467 workspaceRoot: string;
468 title: string;
469 defaultModel: string | null;
470 }>;
471 threads: Array<{
472 id: string;
473 projectId: string;
474 title: string;
475 model: string;
476 branch: string | null;
477 worktreePath: string | null;
478 }>;
457479 };
480 const bootstrapProjectId = (welcome.data as { bootstrapProjectId?: string }).bootstrapProjectId;
481 const bootstrapThreadId = (welcome.data as { bootstrapThreadId?: string }).bootstrapThreadId;
482 expect(bootstrapProjectId).toBeDefined();
483 expect(bootstrapThreadId).toBeDefined();
458484
459485 expect(snapshot.projects).toEqual(
460486 expect.arrayContaining([
461487 expect.objectContaining({
488 id: bootstrapProjectId,
462489 workspaceRoot: "/test/bootstrap-workspace",
463490 title: "bootstrap-workspace",
464491 defaultModel: "gpt-5-codex",
465492 }),
466493 ]),
467494 );
495 expect(snapshot.threads).toEqual(
496 expect.arrayContaining([
497 expect.objectContaining({
498 id: bootstrapThreadId,
499 projectId: bootstrapProjectId,
500 title: "New thread",
501 model: "gpt-5-codex",
502 branch: null,
503 worktreePath: null,
504 }),
505 ]),
506 );
468507 });
469508
470509 it("logs outbound websocket push events in dev mode", async () => {
apps/server/src/wsServer.ts+47-7
......@@ -16,6 +16,7 @@ import {
1616 ORCHESTRATION_WS_CHANNELS,
1717 ORCHESTRATION_WS_METHODS,
1818 ProjectId,
19 ThreadId,
1920 TerminalEvent,
2021 WS_CHANNELS,
2122 WS_METHODS,
......@@ -337,22 +338,56 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
337338
338339 yield* Scope.provide(orchestrationReactor.start, subscriptionsScope);
339340
341 let welcomeBootstrapProjectId: ProjectId | undefined;
342 let welcomeBootstrapThreadId: ThreadId | undefined;
343
340344 if (autoBootstrapProjectFromCwd) {
341345 yield* Effect.gen(function* () {
342346 const snapshot = yield* projectionReadModelQuery.getSnapshot();
343 const existing = snapshot.projects.find((project) => project.workspaceRoot === cwd);
344 if (!existing) {
347 const existingProject = snapshot.projects.find(
348 (project) => project.workspaceRoot === cwd && project.deletedAt === null,
349 );
350 let bootstrapProjectId: ProjectId;
351 let bootstrapProjectDefaultModel: string;
352
353 if (!existingProject) {
345354 const createdAt = new Date().toISOString();
346 const projectName = path.basename(cwd) || "project";
355 bootstrapProjectId = ProjectId.makeUnsafe(crypto.randomUUID());
356 const bootstrapProjectTitle = path.basename(cwd) || "project";
357 bootstrapProjectDefaultModel = "gpt-5-codex";
347358 yield* orchestrationEngine.dispatch({
348359 type: "project.create",
349360 commandId: CommandId.makeUnsafe(crypto.randomUUID()),
350 projectId: ProjectId.makeUnsafe(crypto.randomUUID()),
351 title: projectName,
361 projectId: bootstrapProjectId,
362 title: bootstrapProjectTitle,
352363 workspaceRoot: cwd,
353 defaultModel: "gpt-5-codex",
364 defaultModel: bootstrapProjectDefaultModel,
365 createdAt,
366 });
367 } else {
368 bootstrapProjectId = existingProject.id;
369 bootstrapProjectDefaultModel = existingProject.defaultModel ?? "gpt-5-codex";
370 }
371
372 const existingThread = snapshot.threads.find(
373 (thread) => thread.projectId === bootstrapProjectId && thread.deletedAt === null,
374 );
375 if (!existingThread) {
376 const createdAt = new Date().toISOString();
377 const threadId = ThreadId.makeUnsafe(crypto.randomUUID());
378 yield* orchestrationEngine.dispatch({
379 type: "thread.create",
380 commandId: CommandId.makeUnsafe(crypto.randomUUID()),
381 threadId,
382 projectId: bootstrapProjectId,
383 title: "New thread",
384 model: bootstrapProjectDefaultModel,
385 branch: null,
386 worktreePath: null,
354387 createdAt,
355388 });
389 welcomeBootstrapProjectId = bootstrapProjectId;
390 welcomeBootstrapThreadId = threadId;
356391 }
357392 }).pipe(
358393 Effect.mapError(
......@@ -581,7 +616,12 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
581616 const welcome: WsPush = {
582617 type: "push",
583618 channel: WS_CHANNELS.serverWelcome,
584 data: { cwd, projectName },
619 data: {
620 cwd,
621 projectName,
622 ...(welcomeBootstrapProjectId ? { bootstrapProjectId: welcomeBootstrapProjectId } : {}),
623 ...(welcomeBootstrapThreadId ? { bootstrapThreadId: welcomeBootstrapThreadId } : {}),
624 },
585625 };
586626 logOutgoingPush(welcome, 1);
587627 ws.send(JSON.stringify(welcome));
apps/web/src/routes/__root.tsx+36-4
......@@ -1,5 +1,11 @@
11import { ThreadId } from "@t3tools/contracts";
2import { Outlet, createRootRouteWithContext, type ErrorComponentProps } from "@tanstack/react-router";
2import {
3 Outlet,
4 createRootRouteWithContext,
5 type ErrorComponentProps,
6 useNavigate,
7 useRouterState,
8} from "@tanstack/react-router";
39import { useEffect, useRef } from "react";
410import { QueryClient, useQueryClient } from "@tanstack/react-query";
511
......@@ -119,7 +125,10 @@ function errorDetails(error: unknown): string {
119125function EventRouter() {
120126 const { dispatch } = useStore();
121127 const queryClient = useQueryClient();
128 const navigate = useNavigate();
129 const pathname = useRouterState({ select: (state) => state.location.pathname });
122130 const lastConfigIssuesSignatureRef = useRef<string | null>(null);
131 const handledBootstrapThreadIdRef = useRef<string | null>(null);
123132
124133 useEffect(() => {
125134 const api = readNativeApi();
......@@ -180,8 +189,31 @@ function EventRouter() {
180189 hasRunningSubprocess,
181190 });
182191 });
183 const unsubWelcome = onServerWelcome(() => {
184 void syncSnapshot();
192 const unsubWelcome = onServerWelcome((payload) => {
193 void (async () => {
194 await syncSnapshot();
195
196 if (!payload.bootstrapProjectId || !payload.bootstrapThreadId) {
197 return;
198 }
199 dispatch({
200 type: "SET_PROJECT_EXPANDED",
201 projectId: payload.bootstrapProjectId,
202 expanded: true,
203 });
204 if (pathname !== "/") {
205 return;
206 }
207 if (handledBootstrapThreadIdRef.current === payload.bootstrapThreadId) {
208 return;
209 }
210 handledBootstrapThreadIdRef.current = payload.bootstrapThreadId;
211 await navigate({
212 to: "/$threadId",
213 params: { threadId: payload.bootstrapThreadId },
214 replace: true,
215 });
216 })().catch(() => undefined);
185217 });
186218 const unsubServerConfigUpdated = onServerConfigUpdated((payload) => {
187219 const signature = JSON.stringify(payload.issues);
......@@ -232,7 +264,7 @@ function EventRouter() {
232264 unsubWelcome();
233265 unsubServerConfigUpdated();
234266 };
235 }, [dispatch, queryClient]);
267 }, [dispatch, navigate, pathname, queryClient]);
236268
237269 return null;
238270}
apps/web/src/store.ts+9
......@@ -41,6 +41,7 @@ type Action =
4141 terminalId: string;
4242 hasRunningSubprocess: boolean;
4343 }
44 | { type: "SET_PROJECT_EXPANDED"; projectId: Project["id"]; expanded: boolean }
4445 | { type: "TOGGLE_THREAD_TERMINAL"; threadId: ThreadId }
4546 | { type: "SET_THREAD_TERMINAL_OPEN"; threadId: ThreadId; open: boolean }
4647 | { type: "SET_THREAD_TERMINAL_HEIGHT"; threadId: ThreadId; height: number }
......@@ -571,6 +572,14 @@ export function reducer(state: AppState, action: Action): AppState {
571572 ),
572573 };
573574
575 case "SET_PROJECT_EXPANDED":
576 return {
577 ...state,
578 projects: state.projects.map((p) =>
579 p.id === action.projectId ? { ...p, expanded: action.expanded } : p,
580 ),
581 };
582
574583 case "TOGGLE_THREAD_TERMINAL":
575584 return {
576585 ...state,
apps/web/src/wsNativeApi.test.ts+30-3
......@@ -65,16 +65,41 @@ describe("wsNativeApi", () => {
6565 emitPush(WS_CHANNELS.serverWelcome, payload);
6666
6767 expect(listener).toHaveBeenCalledTimes(1);
68 expect(listener).toHaveBeenCalledWith(payload);
68 expect(listener).toHaveBeenCalledWith(expect.objectContaining(payload));
6969
7070 const lateListener = vi.fn();
7171 onServerWelcome(lateListener);
7272
7373 expect(lateListener).toHaveBeenCalledTimes(1);
74 expect(lateListener).toHaveBeenCalledWith(payload);
74 expect(lateListener).toHaveBeenCalledWith(expect.objectContaining(payload));
7575 expect(warnSpy).not.toHaveBeenCalled();
7676 });
7777
78 it("preserves bootstrap ids from server.welcome payloads", async () => {
79 const { createWsNativeApi, onServerWelcome } = await import("./wsNativeApi");
80
81 createWsNativeApi();
82 const listener = vi.fn();
83 onServerWelcome(listener);
84
85 emitPush(WS_CHANNELS.serverWelcome, {
86 cwd: "/tmp/workspace",
87 projectName: "t3-code",
88 bootstrapProjectId: "project-1",
89 bootstrapThreadId: "thread-1",
90 });
91
92 expect(listener).toHaveBeenCalledTimes(1);
93 expect(listener).toHaveBeenCalledWith(
94 expect.objectContaining({
95 cwd: "/tmp/workspace",
96 projectName: "t3-code",
97 bootstrapProjectId: "project-1",
98 bootstrapThreadId: "thread-1",
99 }),
100 );
101 });
102
78103 it("ignores invalid server.welcome payloads and keeps subscription active", async () => {
79104 const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
80105 const { createWsNativeApi, onServerWelcome } = await import("./wsNativeApi");
......@@ -87,7 +112,9 @@ describe("wsNativeApi", () => {
87112 emitPush(WS_CHANNELS.serverWelcome, { cwd: "/tmp/workspace", projectName: "t3-code" });
88113
89114 expect(listener).toHaveBeenCalledTimes(1);
90 expect(listener).toHaveBeenCalledWith({ cwd: "/tmp/workspace", projectName: "t3-code" });
115 expect(listener).toHaveBeenCalledWith(
116 expect.objectContaining({ cwd: "/tmp/workspace", projectName: "t3-code" }),
117 );
91118 expect(warnSpy).toHaveBeenCalledTimes(1);
92119 expect(warnSpy).toHaveBeenCalledWith("Dropped inbound WebSocket push payload", {
93120 reason: "decode-failed",
packages/contracts/src/ws.ts+3-1
......@@ -1,5 +1,5 @@
11import { Schema, Struct } from "effect";
2import { TrimmedNonEmptyString } from "./baseSchemas";
2import { ProjectId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas";
33
44import {
55 ClientOrchestrationCommand,
......@@ -163,5 +163,7 @@ export type WsResponse = typeof WsResponse.Type;
163163export const WsWelcomePayload = Schema.Struct({
164164 cwd: TrimmedNonEmptyString,
165165 projectName: TrimmedNonEmptyString,
166 bootstrapProjectId: Schema.optional(ProjectId),
167 bootstrapThreadId: Schema.optional(ThreadId),
166168});
167169export type WsWelcomePayload = typeof WsWelcomePayload.Type;