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", () => {...@@ -448,23 +448,62 @@ describe("WebSocket Server", () => {
448448
449 const ws = await connectWs(port);449 const ws = await connectWs(port);
450 connections.push(ws);450 connections.push(ws);
451 await waitForMessage(ws); // welcome451 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
453 const snapshotResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.getSnapshot);462 const snapshotResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.getSnapshot);
454 expect(snapshotResponse.error).toBeUndefined();463 expect(snapshotResponse.error).toBeUndefined();
455 const snapshot = snapshotResponse.result as {464 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 }>;
457 };479 };
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
459 expect(snapshot.projects).toEqual(485 expect(snapshot.projects).toEqual(
460 expect.arrayContaining([486 expect.arrayContaining([
461 expect.objectContaining({487 expect.objectContaining({
488 id: bootstrapProjectId,
462 workspaceRoot: "/test/bootstrap-workspace",489 workspaceRoot: "/test/bootstrap-workspace",
463 title: "bootstrap-workspace",490 title: "bootstrap-workspace",
464 defaultModel: "gpt-5-codex",491 defaultModel: "gpt-5-codex",
465 }),492 }),
466 ]),493 ]),
467 );494 );
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 );
468 });507 });
469508
470 it("logs outbound websocket push events in dev mode", async () => {509 it("logs outbound websocket push events in dev mode", async () => {
apps/server/src/wsServer.ts+47-7
...@@ -16,6 +16,7 @@ import {...@@ -16,6 +16,7 @@ import {
16 ORCHESTRATION_WS_CHANNELS,16 ORCHESTRATION_WS_CHANNELS,
17 ORCHESTRATION_WS_METHODS,17 ORCHESTRATION_WS_METHODS,
18 ProjectId,18 ProjectId,
19 ThreadId,
19 TerminalEvent,20 TerminalEvent,
20 WS_CHANNELS,21 WS_CHANNELS,
21 WS_METHODS,22 WS_METHODS,
...@@ -337,22 +338,56 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<...@@ -337,22 +338,56 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
337338
338 yield* Scope.provide(orchestrationReactor.start, subscriptionsScope);339 yield* Scope.provide(orchestrationReactor.start, subscriptionsScope);
339340
341 let welcomeBootstrapProjectId: ProjectId | undefined;
342 let welcomeBootstrapThreadId: ThreadId | undefined;
343
340 if (autoBootstrapProjectFromCwd) {344 if (autoBootstrapProjectFromCwd) {
341 yield* Effect.gen(function* () {345 yield* Effect.gen(function* () {
342 const snapshot = yield* projectionReadModelQuery.getSnapshot();346 const snapshot = yield* projectionReadModelQuery.getSnapshot();
343 const existing = snapshot.projects.find((project) => project.workspaceRoot === cwd);347 const existingProject = snapshot.projects.find(
344 if (!existing) {348 (project) => project.workspaceRoot === cwd && project.deletedAt === null,
349 );
350 let bootstrapProjectId: ProjectId;
351 let bootstrapProjectDefaultModel: string;
352
353 if (!existingProject) {
345 const createdAt = new Date().toISOString();354 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";
347 yield* orchestrationEngine.dispatch({358 yield* orchestrationEngine.dispatch({
348 type: "project.create",359 type: "project.create",
349 commandId: CommandId.makeUnsafe(crypto.randomUUID()),360 commandId: CommandId.makeUnsafe(crypto.randomUUID()),
350 projectId: ProjectId.makeUnsafe(crypto.randomUUID()),361 projectId: bootstrapProjectId,
351 title: projectName,362 title: bootstrapProjectTitle,
352 workspaceRoot: cwd,363 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,
354 createdAt,387 createdAt,
355 });388 });
389 welcomeBootstrapProjectId = bootstrapProjectId;
390 welcomeBootstrapThreadId = threadId;
356 }391 }
357 }).pipe(392 }).pipe(
358 Effect.mapError(393 Effect.mapError(
...@@ -581,7 +616,12 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<...@@ -581,7 +616,12 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
581 const welcome: WsPush = {616 const welcome: WsPush = {
582 type: "push",617 type: "push",
583 channel: WS_CHANNELS.serverWelcome,618 channel: WS_CHANNELS.serverWelcome,
584 data: { cwd, projectName },619 data: {
620 cwd,
621 projectName,
622 ...(welcomeBootstrapProjectId ? { bootstrapProjectId: welcomeBootstrapProjectId } : {}),
623 ...(welcomeBootstrapThreadId ? { bootstrapThreadId: welcomeBootstrapThreadId } : {}),
624 },
585 };625 };
586 logOutgoingPush(welcome, 1);626 logOutgoingPush(welcome, 1);
587 ws.send(JSON.stringify(welcome));627 ws.send(JSON.stringify(welcome));
apps/web/src/routes/__root.tsx+36-4
...@@ -1,5 +1,11 @@...@@ -1,5 +1,11 @@
1import { ThreadId } from "@t3tools/contracts";1import { 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";
3import { useEffect, useRef } from "react";9import { useEffect, useRef } from "react";
4import { QueryClient, useQueryClient } from "@tanstack/react-query";10import { QueryClient, useQueryClient } from "@tanstack/react-query";
511
...@@ -119,7 +125,10 @@ function errorDetails(error: unknown): string {...@@ -119,7 +125,10 @@ function errorDetails(error: unknown): string {
119function EventRouter() {125function EventRouter() {
120 const { dispatch } = useStore();126 const { dispatch } = useStore();
121 const queryClient = useQueryClient();127 const queryClient = useQueryClient();
128 const navigate = useNavigate();
129 const pathname = useRouterState({ select: (state) => state.location.pathname });
122 const lastConfigIssuesSignatureRef = useRef<string | null>(null);130 const lastConfigIssuesSignatureRef = useRef<string | null>(null);
131 const handledBootstrapThreadIdRef = useRef<string | null>(null);
123132
124 useEffect(() => {133 useEffect(() => {
125 const api = readNativeApi();134 const api = readNativeApi();
...@@ -180,8 +189,31 @@ function EventRouter() {...@@ -180,8 +189,31 @@ function EventRouter() {
180 hasRunningSubprocess,189 hasRunningSubprocess,
181 });190 });
182 });191 });
183 const unsubWelcome = onServerWelcome(() => {192 const unsubWelcome = onServerWelcome((payload) => {
184 void syncSnapshot();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);
185 });217 });
186 const unsubServerConfigUpdated = onServerConfigUpdated((payload) => {218 const unsubServerConfigUpdated = onServerConfigUpdated((payload) => {
187 const signature = JSON.stringify(payload.issues);219 const signature = JSON.stringify(payload.issues);
...@@ -232,7 +264,7 @@ function EventRouter() {...@@ -232,7 +264,7 @@ function EventRouter() {
232 unsubWelcome();264 unsubWelcome();
233 unsubServerConfigUpdated();265 unsubServerConfigUpdated();
234 };266 };
235 }, [dispatch, queryClient]);267 }, [dispatch, navigate, pathname, queryClient]);
236268
237 return null;269 return null;
238}270}
apps/web/src/store.ts+9
...@@ -41,6 +41,7 @@ type Action =...@@ -41,6 +41,7 @@ type Action =
41 terminalId: string;41 terminalId: string;
42 hasRunningSubprocess: boolean;42 hasRunningSubprocess: boolean;
43 }43 }
44 | { type: "SET_PROJECT_EXPANDED"; projectId: Project["id"]; expanded: boolean }
44 | { type: "TOGGLE_THREAD_TERMINAL"; threadId: ThreadId }45 | { type: "TOGGLE_THREAD_TERMINAL"; threadId: ThreadId }
45 | { type: "SET_THREAD_TERMINAL_OPEN"; threadId: ThreadId; open: boolean }46 | { type: "SET_THREAD_TERMINAL_OPEN"; threadId: ThreadId; open: boolean }
46 | { type: "SET_THREAD_TERMINAL_HEIGHT"; threadId: ThreadId; height: number }47 | { type: "SET_THREAD_TERMINAL_HEIGHT"; threadId: ThreadId; height: number }
...@@ -571,6 +572,14 @@ export function reducer(state: AppState, action: Action): AppState {...@@ -571,6 +572,14 @@ export function reducer(state: AppState, action: Action): AppState {
571 ),572 ),
572 };573 };
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
574 case "TOGGLE_THREAD_TERMINAL":583 case "TOGGLE_THREAD_TERMINAL":
575 return {584 return {
576 ...state,585 ...state,
apps/web/src/wsNativeApi.test.ts+30-3
...@@ -65,16 +65,41 @@ describe("wsNativeApi", () => {...@@ -65,16 +65,41 @@ describe("wsNativeApi", () => {
65 emitPush(WS_CHANNELS.serverWelcome, payload);65 emitPush(WS_CHANNELS.serverWelcome, payload);
6666
67 expect(listener).toHaveBeenCalledTimes(1);67 expect(listener).toHaveBeenCalledTimes(1);
68 expect(listener).toHaveBeenCalledWith(payload);68 expect(listener).toHaveBeenCalledWith(expect.objectContaining(payload));
6969
70 const lateListener = vi.fn();70 const lateListener = vi.fn();
71 onServerWelcome(lateListener);71 onServerWelcome(lateListener);
7272
73 expect(lateListener).toHaveBeenCalledTimes(1);73 expect(lateListener).toHaveBeenCalledTimes(1);
74 expect(lateListener).toHaveBeenCalledWith(payload);74 expect(lateListener).toHaveBeenCalledWith(expect.objectContaining(payload));
75 expect(warnSpy).not.toHaveBeenCalled();75 expect(warnSpy).not.toHaveBeenCalled();
76 });76 });
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
78 it("ignores invalid server.welcome payloads and keeps subscription active", async () => {103 it("ignores invalid server.welcome payloads and keeps subscription active", async () => {
79 const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});104 const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
80 const { createWsNativeApi, onServerWelcome } = await import("./wsNativeApi");105 const { createWsNativeApi, onServerWelcome } = await import("./wsNativeApi");
...@@ -87,7 +112,9 @@ describe("wsNativeApi", () => {...@@ -87,7 +112,9 @@ describe("wsNativeApi", () => {
87 emitPush(WS_CHANNELS.serverWelcome, { cwd: "/tmp/workspace", projectName: "t3-code" });112 emitPush(WS_CHANNELS.serverWelcome, { cwd: "/tmp/workspace", projectName: "t3-code" });
88113
89 expect(listener).toHaveBeenCalledTimes(1);114 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 );
91 expect(warnSpy).toHaveBeenCalledTimes(1);118 expect(warnSpy).toHaveBeenCalledTimes(1);
92 expect(warnSpy).toHaveBeenCalledWith("Dropped inbound WebSocket push payload", {119 expect(warnSpy).toHaveBeenCalledWith("Dropped inbound WebSocket push payload", {
93 reason: "decode-failed",120 reason: "decode-failed",
packages/contracts/src/ws.ts+3-1
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import { Schema, Struct } from "effect";1import { Schema, Struct } from "effect";
2import { TrimmedNonEmptyString } from "./baseSchemas";2import { ProjectId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas";
33
4import {4import {
5 ClientOrchestrationCommand,5 ClientOrchestrationCommand,
...@@ -163,5 +163,7 @@ export type WsResponse = typeof WsResponse.Type;...@@ -163,5 +163,7 @@ export type WsResponse = typeof WsResponse.Type;
163export const WsWelcomePayload = Schema.Struct({163export const WsWelcomePayload = Schema.Struct({
164 cwd: TrimmedNonEmptyString,164 cwd: TrimmedNonEmptyString,
165 projectName: TrimmedNonEmptyString,165 projectName: TrimmedNonEmptyString,
166 bootstrapProjectId: Schema.optional(ProjectId),
167 bootstrapThreadId: Schema.optional(ThreadId),
166});168});
167export type WsWelcomePayload = typeof WsWelcomePayload.Type;169export type WsWelcomePayload = typeof WsWelcomePayload.Type;