authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-04-13 11:52:59-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-04-13 11:52:59-07:00
log801b83e9372cd5ccd7803c3137db7ba24f7bf897
treefb6429e904f2333d1e208f6a192db8ca42632675
parentf2205bdc4297ddc4b854bc10bdcc0234113e102e
signature Signed by PGP key B5690EEEBB952194

Allow empty server threads to bootstrap new worktrees (#1936)


12 files changed, 446 insertions(+), 33 deletions(-)

apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+1-6
...@@ -11,6 +11,7 @@ import {...@@ -11,6 +11,7 @@ import {
11 type RuntimeMode,11 type RuntimeMode,
12 type TurnId,12 type TurnId,
13} from "@t3tools/contracts";13} from "@t3tools/contracts";
14import { isTemporaryWorktreeBranch, WORKTREE_BRANCH_PREFIX } from "@t3tools/shared/git";
14import { Cache, Cause, Duration, Effect, Equal, Layer, Option, Schema, Stream } from "effect";15import { Cache, Cause, Duration, Effect, Equal, Layer, Option, Schema, Stream } from "effect";
15import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker";16import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker";
1617
...@@ -72,8 +73,6 @@ const serverCommandId = (tag: string): CommandId =>...@@ -72,8 +73,6 @@ const serverCommandId = (tag: string): CommandId =>
72const HANDLED_TURN_START_KEY_MAX = 10_000;73const HANDLED_TURN_START_KEY_MAX = 10_000;
73const HANDLED_TURN_START_KEY_TTL = Duration.minutes(30);74const HANDLED_TURN_START_KEY_TTL = Duration.minutes(30);
74const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access";75const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access";
75const WORKTREE_BRANCH_PREFIX = "t3code";
76const TEMP_WORKTREE_BRANCH_PATTERN = new RegExp(`^${WORKTREE_BRANCH_PREFIX}\\/[0-9a-f]{8}$`);
77const DEFAULT_THREAD_TITLE = "New thread";76const DEFAULT_THREAD_TITLE = "New thread";
7877
79function canReplaceThreadTitle(currentTitle: string, titleSeed?: string): boolean {78function canReplaceThreadTitle(currentTitle: string, titleSeed?: string): boolean {
...@@ -119,10 +118,6 @@ function stalePendingRequestDetail(...@@ -119,10 +118,6 @@ function stalePendingRequestDetail(
119 return `Stale pending ${requestKind} request: ${requestId}. Provider callback state does not survive app restarts or recovered sessions. Restart the turn to continue.`;118 return `Stale pending ${requestKind} request: ${requestId}. Provider callback state does not survive app restarts or recovered sessions. Restart the turn to continue.`;
120}119}
121120
122function isTemporaryWorktreeBranch(branch: string): boolean {
123 return TEMP_WORKTREE_BRANCH_PATTERN.test(branch.trim().toLowerCase());
124}
125
126function buildGeneratedWorktreeBranchName(raw: string): string {121function buildGeneratedWorktreeBranchName(raw: string): string {
127 const normalized = raw122 const normalized = raw
128 .trim()123 .trim()
apps/web/src/components/BranchToolbar.logic.test.ts+11
...@@ -10,6 +10,7 @@ import {...@@ -10,6 +10,7 @@ import {
10 resolveEffectiveEnvMode,10 resolveEffectiveEnvMode,
11 resolveEnvModeLabel,11 resolveEnvModeLabel,
12 resolveBranchToolbarValue,12 resolveBranchToolbarValue,
13 resolveLockedWorkspaceLabel,
13 shouldIncludeBranchPickerItem,14 shouldIncludeBranchPickerItem,
14} from "./BranchToolbar.logic";15} from "./BranchToolbar.logic";
1516
...@@ -157,6 +158,16 @@ describe("resolveCurrentWorkspaceLabel", () => {...@@ -157,6 +158,16 @@ describe("resolveCurrentWorkspaceLabel", () => {
157 });158 });
158});159});
159160
161describe("resolveLockedWorkspaceLabel", () => {
162 it("uses a shorter label for the main repo checkout", () => {
163 expect(resolveLockedWorkspaceLabel(null)).toBe("Local checkout");
164 });
165
166 it("uses a shorter label for an attached worktree", () => {
167 expect(resolveLockedWorkspaceLabel("/repo/.t3/worktrees/feature-a")).toBe("Worktree");
168 });
169});
170
160describe("deriveLocalBranchNameFromRemoteRef", () => {171describe("deriveLocalBranchNameFromRemoteRef", () => {
161 it("strips the remote prefix from a remote ref", () => {172 it("strips the remote prefix from a remote ref", () => {
162 expect(deriveLocalBranchNameFromRemoteRef("origin/feature/demo")).toBe("feature/demo");173 expect(deriveLocalBranchNameFromRemoteRef("origin/feature/demo")).toBe("feature/demo");
apps/web/src/components/BranchToolbar.logic.ts+4
...@@ -50,6 +50,10 @@ export function resolveCurrentWorkspaceLabel(activeWorktreePath: string | null):...@@ -50,6 +50,10 @@ export function resolveCurrentWorkspaceLabel(activeWorktreePath: string | null):
50 return activeWorktreePath ? "Current worktree" : resolveEnvModeLabel("local");50 return activeWorktreePath ? "Current worktree" : resolveEnvModeLabel("local");
51}51}
5252
53export function resolveLockedWorkspaceLabel(activeWorktreePath: string | null): string {
54 return activeWorktreePath ? "Worktree" : "Local checkout";
55}
56
53export function resolveEffectiveEnvMode(input: {57export function resolveEffectiveEnvMode(input: {
54 activeWorktreePath: string | null;58 activeWorktreePath: string | null;
55 hasServerThread: boolean;59 hasServerThread: boolean;
apps/web/src/components/BranchToolbar.tsx+16-5
...@@ -20,6 +20,9 @@ interface BranchToolbarProps {...@@ -20,6 +20,9 @@ interface BranchToolbarProps {
20 threadId: ThreadId;20 threadId: ThreadId;
21 draftId?: DraftId;21 draftId?: DraftId;
22 onEnvModeChange: (mode: EnvMode) => void;22 onEnvModeChange: (mode: EnvMode) => void;
23 effectiveEnvModeOverride?: EnvMode;
24 activeThreadBranchOverride?: string | null;
25 onActiveThreadBranchOverrideChange?: (branch: string | null) => void;
23 envLocked: boolean;26 envLocked: boolean;
24 onCheckoutPullRequestRequest?: (reference: string) => void;27 onCheckoutPullRequestRequest?: (reference: string) => void;
25 onComposerFocusRequest?: () => void;28 onComposerFocusRequest?: () => void;
...@@ -32,6 +35,9 @@ export const BranchToolbar = memo(function BranchToolbar({...@@ -32,6 +35,9 @@ export const BranchToolbar = memo(function BranchToolbar({
32 threadId,35 threadId,
33 draftId,36 draftId,
34 onEnvModeChange,37 onEnvModeChange,
38 effectiveEnvModeOverride,
39 activeThreadBranchOverride,
40 onActiveThreadBranchOverrideChange,
35 envLocked,41 envLocked,
36 onCheckoutPullRequestRequest,42 onCheckoutPullRequestRequest,
37 onComposerFocusRequest,43 onComposerFocusRequest,
...@@ -59,11 +65,13 @@ export const BranchToolbar = memo(function BranchToolbar({...@@ -59,11 +65,13 @@ export const BranchToolbar = memo(function BranchToolbar({
59 const activeProject = useStore(activeProjectSelector);65 const activeProject = useStore(activeProjectSelector);
60 const hasActiveThread = serverThread !== undefined || draftThread !== null;66 const hasActiveThread = serverThread !== undefined || draftThread !== null;
61 const activeWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null;67 const activeWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null;
62 const effectiveEnvMode = resolveEffectiveEnvMode({68 const effectiveEnvMode =
63 activeWorktreePath,69 effectiveEnvModeOverride ??
64 hasServerThread: serverThread !== undefined,70 resolveEffectiveEnvMode({
65 draftThreadEnvMode: draftThread?.envMode,71 activeWorktreePath,
66 });72 hasServerThread: serverThread !== undefined,
73 draftThreadEnvMode: draftThread?.envMode,
74 });
67 const envModeLocked = envLocked || (serverThread !== undefined && activeWorktreePath !== null);75 const envModeLocked = envLocked || (serverThread !== undefined && activeWorktreePath !== null);
6876
69 const showEnvironmentPicker =77 const showEnvironmentPicker =
...@@ -98,6 +106,9 @@ export const BranchToolbar = memo(function BranchToolbar({...@@ -98,6 +106,9 @@ export const BranchToolbar = memo(function BranchToolbar({
98 threadId={threadId}106 threadId={threadId}
99 {...(draftId ? { draftId } : {})}107 {...(draftId ? { draftId } : {})}
100 envLocked={envLocked}108 envLocked={envLocked}
109 {...(effectiveEnvModeOverride ? { effectiveEnvModeOverride } : {})}
110 {...(activeThreadBranchOverride !== undefined ? { activeThreadBranchOverride } : {})}
111 {...(onActiveThreadBranchOverrideChange ? { onActiveThreadBranchOverrideChange } : {})}
101 {...(onCheckoutPullRequestRequest ? { onCheckoutPullRequestRequest } : {})}112 {...(onCheckoutPullRequestRequest ? { onCheckoutPullRequestRequest } : {})}
102 {...(onComposerFocusRequest ? { onComposerFocusRequest } : {})}113 {...(onComposerFocusRequest ? { onComposerFocusRequest } : {})}
103 />114 />
apps/web/src/components/BranchToolbarBranchSelector.tsx+19-6
...@@ -49,6 +49,9 @@ interface BranchToolbarBranchSelectorProps {...@@ -49,6 +49,9 @@ interface BranchToolbarBranchSelectorProps {
49 threadId: ThreadId;49 threadId: ThreadId;
50 draftId?: DraftId;50 draftId?: DraftId;
51 envLocked: boolean;51 envLocked: boolean;
52 effectiveEnvModeOverride?: "local" | "worktree";
53 activeThreadBranchOverride?: string | null;
54 onActiveThreadBranchOverrideChange?: (branch: string | null) => void;
52 onCheckoutPullRequestRequest?: (reference: string) => void;55 onCheckoutPullRequestRequest?: (reference: string) => void;
53 onComposerFocusRequest?: () => void;56 onComposerFocusRequest?: () => void;
54}57}
...@@ -77,6 +80,9 @@ export function BranchToolbarBranchSelector({...@@ -77,6 +80,9 @@ export function BranchToolbarBranchSelector({
77 threadId,80 threadId,
78 draftId,81 draftId,
79 envLocked,82 envLocked,
83 effectiveEnvModeOverride,
84 activeThreadBranchOverride,
85 onActiveThreadBranchOverrideChange,
80 onCheckoutPullRequestRequest,86 onCheckoutPullRequestRequest,
81 onComposerFocusRequest,87 onComposerFocusRequest,
82}: BranchToolbarBranchSelectorProps) {88}: BranchToolbarBranchSelectorProps) {
...@@ -108,16 +114,21 @@ export function BranchToolbarBranchSelector({...@@ -108,16 +114,21 @@ export function BranchToolbarBranchSelector({
108 const activeProject = useStore(activeProjectSelector);114 const activeProject = useStore(activeProjectSelector);
109115
110 const activeThreadId = serverThread?.id ?? (draftThread ? threadId : undefined);116 const activeThreadId = serverThread?.id ?? (draftThread ? threadId : undefined);
111 const activeThreadBranch = serverThread?.branch ?? draftThread?.branch ?? null;117 const activeThreadBranch =
118 activeThreadBranchOverride !== undefined
119 ? activeThreadBranchOverride
120 : (serverThread?.branch ?? draftThread?.branch ?? null);
112 const activeWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null;121 const activeWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null;
113 const activeProjectCwd = activeProject?.cwd ?? null;122 const activeProjectCwd = activeProject?.cwd ?? null;
114 const branchCwd = activeWorktreePath ?? activeProjectCwd;123 const branchCwd = activeWorktreePath ?? activeProjectCwd;
115 const hasServerThread = serverThread !== undefined;124 const hasServerThread = serverThread !== undefined;
116 const effectiveEnvMode = resolveEffectiveEnvMode({125 const effectiveEnvMode =
117 activeWorktreePath,126 effectiveEnvModeOverride ??
118 hasServerThread,127 resolveEffectiveEnvMode({
119 draftThreadEnvMode: draftThread?.envMode,128 activeWorktreePath,
120 });129 hasServerThread,
130 draftThreadEnvMode: draftThread?.envMode,
131 });
121132
122 // ---------------------------------------------------------------------------133 // ---------------------------------------------------------------------------
123 // Thread branch mutation (colocated — only this component calls it)134 // Thread branch mutation (colocated — only this component calls it)
...@@ -146,6 +157,7 @@ export function BranchToolbarBranchSelector({...@@ -146,6 +157,7 @@ export function BranchToolbarBranchSelector({
146 });157 });
147 }158 }
148 if (hasServerThread) {159 if (hasServerThread) {
160 onActiveThreadBranchOverrideChange?.(branch);
149 setThreadBranchAction(threadRef, branch, worktreePath);161 setThreadBranchAction(threadRef, branch, worktreePath);
150 return;162 return;
151 }163 }
...@@ -167,6 +179,7 @@ export function BranchToolbarBranchSelector({...@@ -167,6 +179,7 @@ export function BranchToolbarBranchSelector({
167 serverSession,179 serverSession,
168 activeWorktreePath,180 activeWorktreePath,
169 hasServerThread,181 hasServerThread,
182 onActiveThreadBranchOverrideChange,
170 setThreadBranchAction,183 setThreadBranchAction,
171 setDraftThreadContext,184 setDraftThreadContext,
172 draftId,185 draftId,
apps/web/src/components/BranchToolbarEnvModeSelector.tsx+3-2
...@@ -4,6 +4,7 @@ import { memo, useMemo } from "react";...@@ -4,6 +4,7 @@ import { memo, useMemo } from "react";
4import {4import {
5 resolveCurrentWorkspaceLabel,5 resolveCurrentWorkspaceLabel,
6 resolveEnvModeLabel,6 resolveEnvModeLabel,
7 resolveLockedWorkspaceLabel,
7 type EnvMode,8 type EnvMode,
8} from "./BranchToolbar.logic";9} from "./BranchToolbar.logic";
9import {10import {
...@@ -43,12 +44,12 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe...@@ -43,12 +44,12 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe
43 {activeWorktreePath ? (44 {activeWorktreePath ? (
44 <>45 <>
45 <FolderGitIcon className="size-3" />46 <FolderGitIcon className="size-3" />
46 {resolveCurrentWorkspaceLabel(activeWorktreePath)}47 {resolveLockedWorkspaceLabel(activeWorktreePath)}
47 </>48 </>
48 ) : (49 ) : (
49 <>50 <>
50 <FolderIcon className="size-3" />51 <FolderIcon className="size-3" />
51 {resolveCurrentWorkspaceLabel(activeWorktreePath)}52 {resolveLockedWorkspaceLabel(activeWorktreePath)}
52 </>53 </>
53 )}54 )}
54 </span>55 </span>
apps/web/src/components/ChatView.browser.tsx+281
...@@ -2286,6 +2286,287 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -2286,6 +2286,287 @@ describe("ChatView timeline estimator parity (full app)", () => {
2286 }2286 }
2287 });2287 });
22882288
2289 it("keeps new-worktree mode on empty server threads and bootstraps the first send", async () => {
2290 const snapshot = addThreadToSnapshot(createDraftOnlySnapshot(), THREAD_ID);
2291 const mounted = await mountChatView({
2292 viewport: DEFAULT_VIEWPORT,
2293 snapshot: {
2294 ...snapshot,
2295 threads: snapshot.threads.map((thread) =>
2296 thread.id === THREAD_ID ? Object.assign({}, thread, { session: null }) : thread,
2297 ),
2298 },
2299 resolveRpc: (body) => {
2300 if (body._tag === WS_METHODS.gitListBranches) {
2301 return {
2302 isRepo: true,
2303 hasOriginRemote: true,
2304 nextCursor: null,
2305 totalCount: 1,
2306 branches: [
2307 {
2308 name: "main",
2309 current: true,
2310 isDefault: true,
2311 worktreePath: null,
2312 },
2313 ],
2314 };
2315 }
2316 if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) {
2317 return {
2318 sequence: fixture.snapshot.snapshotSequence + 1,
2319 };
2320 }
2321 return undefined;
2322 },
2323 });
2324
2325 try {
2326 (await waitForButtonByText("Current checkout")).click();
2327 await page.getByText("New worktree", { exact: true }).click();
2328
2329 await vi.waitFor(
2330 () => {
2331 expect(findButtonByText("New worktree")).toBeTruthy();
2332 },
2333 { timeout: 8_000, interval: 16 },
2334 );
2335
2336 useComposerDraftStore.getState().setPrompt(THREAD_REF, "Ship it");
2337 await waitForLayout();
2338
2339 const sendButton = await waitForSendButton();
2340 expect(sendButton.disabled).toBe(false);
2341 sendButton.click();
2342
2343 await vi.waitFor(
2344 () => {
2345 const turnStartRequest = wsRequests.find(
2346 (request) =>
2347 request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand &&
2348 request.type === "thread.turn.start",
2349 ) as
2350 | {
2351 _tag: string;
2352 type?: string;
2353 bootstrap?: {
2354 createThread?: { projectId?: string };
2355 prepareWorktree?: { projectCwd?: string; baseBranch?: string; branch?: string };
2356 runSetupScript?: boolean;
2357 };
2358 }
2359 | undefined;
2360
2361 expect(turnStartRequest).toMatchObject({
2362 _tag: ORCHESTRATION_WS_METHODS.dispatchCommand,
2363 type: "thread.turn.start",
2364 bootstrap: {
2365 prepareWorktree: {
2366 projectCwd: "/repo/project",
2367 baseBranch: "main",
2368 branch: expect.stringMatching(/^t3code\/[0-9a-f]{8}$/),
2369 },
2370 runSetupScript: true,
2371 },
2372 });
2373 expect(turnStartRequest?.bootstrap?.createThread).toBeUndefined();
2374 },
2375 { timeout: 8_000, interval: 16 },
2376 );
2377 } finally {
2378 await mounted.cleanup();
2379 }
2380 });
2381
2382 it("updates the selected worktree base branch on empty server threads", async () => {
2383 const snapshot = addThreadToSnapshot(createDraftOnlySnapshot(), THREAD_ID);
2384 const mounted = await mountChatView({
2385 viewport: DEFAULT_VIEWPORT,
2386 snapshot: {
2387 ...snapshot,
2388 threads: snapshot.threads.map((thread) =>
2389 thread.id === THREAD_ID ? Object.assign({}, thread, { session: null }) : thread,
2390 ),
2391 },
2392 resolveRpc: (body) => {
2393 if (body._tag === WS_METHODS.gitListBranches) {
2394 return {
2395 isRepo: true,
2396 hasOriginRemote: true,
2397 nextCursor: null,
2398 totalCount: 2,
2399 branches: [
2400 {
2401 name: "main",
2402 current: true,
2403 isDefault: true,
2404 worktreePath: null,
2405 },
2406 {
2407 name: "release/next",
2408 current: false,
2409 isDefault: false,
2410 worktreePath: null,
2411 },
2412 ],
2413 };
2414 }
2415 if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) {
2416 return {
2417 sequence: fixture.snapshot.snapshotSequence + 1,
2418 };
2419 }
2420 return undefined;
2421 },
2422 });
2423
2424 try {
2425 (await waitForButtonByText("Current checkout")).click();
2426 await page.getByText("New worktree", { exact: true }).click();
2427 await page.getByText("From main", { exact: true }).click();
2428 await page.getByText("release/next", { exact: true }).click();
2429
2430 await vi.waitFor(
2431 () => {
2432 expect(findButtonByText("From release/next")).toBeTruthy();
2433 },
2434 { timeout: 8_000, interval: 16 },
2435 );
2436
2437 useComposerDraftStore.getState().setPrompt(THREAD_REF, "Ship it");
2438 await waitForLayout();
2439
2440 const sendButton = await waitForSendButton();
2441 expect(sendButton.disabled).toBe(false);
2442 sendButton.click();
2443
2444 await vi.waitFor(
2445 () => {
2446 const turnStartRequest = wsRequests.find(
2447 (request) =>
2448 request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand &&
2449 request.type === "thread.turn.start",
2450 ) as
2451 | {
2452 _tag: string;
2453 type?: string;
2454 bootstrap?: {
2455 prepareWorktree?: { baseBranch?: string };
2456 };
2457 }
2458 | undefined;
2459
2460 expect(turnStartRequest?.bootstrap?.prepareWorktree?.baseBranch).toBe("release/next");
2461 },
2462 { timeout: 8_000, interval: 16 },
2463 );
2464 } finally {
2465 await mounted.cleanup();
2466 }
2467 });
2468
2469 it("clears pending worktree overrides when switching empty server threads", async () => {
2470 const secondThreadId = "thread-browser-test-second" as ThreadId;
2471 const snapshot = addThreadToSnapshot(createDraftOnlySnapshot(), THREAD_ID);
2472 const snapshotWithSecondThread = addThreadToSnapshot(snapshot, secondThreadId);
2473 const snapshotWithTwoThreads = {
2474 ...snapshotWithSecondThread,
2475 threads: snapshotWithSecondThread.threads.map((thread) => {
2476 if (thread.id === THREAD_ID) {
2477 return Object.assign({}, thread, { session: null, title: "Thread alpha" });
2478 }
2479 if (thread.id === secondThreadId) {
2480 return Object.assign({}, thread, { session: null, title: "Thread beta" });
2481 }
2482 return thread;
2483 }),
2484 };
2485 const mounted = await mountChatView({
2486 viewport: DEFAULT_VIEWPORT,
2487 snapshot: snapshotWithTwoThreads,
2488 resolveRpc: (body) => {
2489 if (body._tag === WS_METHODS.gitListBranches) {
2490 return {
2491 isRepo: true,
2492 hasOriginRemote: true,
2493 nextCursor: null,
2494 totalCount: 2,
2495 branches: [
2496 {
2497 name: "main",
2498 current: true,
2499 isDefault: true,
2500 worktreePath: null,
2501 },
2502 {
2503 name: "release/next",
2504 current: false,
2505 isDefault: false,
2506 worktreePath: null,
2507 },
2508 ],
2509 };
2510 }
2511 if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) {
2512 return {
2513 sequence: fixture.snapshot.snapshotSequence + 1,
2514 };
2515 }
2516 return undefined;
2517 },
2518 });
2519
2520 try {
2521 (await waitForButtonByText("Current checkout")).click();
2522 await page.getByText("New worktree", { exact: true }).click();
2523 await page.getByText("From main", { exact: true }).click();
2524 await page.getByText("release/next", { exact: true }).click();
2525
2526 await vi.waitFor(
2527 () => {
2528 expect(findButtonByText("From release/next")).toBeTruthy();
2529 },
2530 { timeout: 8_000, interval: 16 },
2531 );
2532
2533 await mounted.router.navigate({
2534 to: "/$environmentId/$threadId",
2535 params: {
2536 environmentId: LOCAL_ENVIRONMENT_ID,
2537 threadId: secondThreadId,
2538 },
2539 });
2540
2541 await waitForURL(
2542 mounted.router,
2543 (path) => path === serverThreadPath(secondThreadId),
2544 "Route should switch to the second empty server thread.",
2545 );
2546
2547 await vi.waitFor(
2548 () => {
2549 expect(findButtonByText("Current checkout")).toBeTruthy();
2550 expect(findButtonByText("From release/next")).toBeNull();
2551 },
2552 { timeout: 8_000, interval: 16 },
2553 );
2554
2555 (await waitForButtonByText("Current checkout")).click();
2556 await page.getByText("New worktree", { exact: true }).click();
2557
2558 await vi.waitFor(
2559 () => {
2560 expect(findButtonByText("From main")).toBeTruthy();
2561 expect(findButtonByText("From release/next")).toBeNull();
2562 },
2563 { timeout: 8_000, interval: 16 },
2564 );
2565 } finally {
2566 await mounted.cleanup();
2567 }
2568 });
2569
2289 it("shows the send state once bootstrap dispatch is in flight", async () => {2570 it("shows the send state once bootstrap dispatch is in flight", async () => {
2290 useTerminalStateStore.setState({2571 useTerminalStateStore.setState({
2291 terminalStateByThreadKey: {},2572 terminalStateByThreadKey: {},
apps/web/src/components/ChatView.logic.test.ts+12
...@@ -11,6 +11,7 @@ import {...@@ -11,6 +11,7 @@ import {
11 deriveComposerSendState,11 deriveComposerSendState,
12 hasServerAcknowledgedLocalDispatch,12 hasServerAcknowledgedLocalDispatch,
13 reconcileMountedTerminalThreadIds,13 reconcileMountedTerminalThreadIds,
14 resolveSendEnvMode,
14 shouldWriteThreadErrorToCurrentServerThread,15 shouldWriteThreadErrorToCurrentServerThread,
15 waitForStartedServerThread,16 waitForStartedServerThread,
16} from "./ChatView.logic";17} from "./ChatView.logic";
...@@ -82,6 +83,17 @@ describe("buildExpiredTerminalContextToastCopy", () => {...@@ -82,6 +83,17 @@ describe("buildExpiredTerminalContextToastCopy", () => {
82 });83 });
83});84});
8485
86describe("resolveSendEnvMode", () => {
87 it("keeps worktree mode for git repositories", () => {
88 expect(resolveSendEnvMode({ requestedEnvMode: "worktree", isGitRepo: true })).toBe("worktree");
89 });
90
91 it("forces local mode for non-git repositories", () => {
92 expect(resolveSendEnvMode({ requestedEnvMode: "worktree", isGitRepo: false })).toBe("local");
93 expect(resolveSendEnvMode({ requestedEnvMode: "local", isGitRepo: false })).toBe("local");
94 });
95});
96
85describe("reconcileMountedTerminalThreadIds", () => {97describe("reconcileMountedTerminalThreadIds", () => {
86 it("keeps previously mounted open threads and adds the active open thread", () => {98 it("keeps previously mounted open threads and adds the active open thread", () => {
87 expect(99 expect(
apps/web/src/components/ChatView.logic.ts+6-6
...@@ -8,7 +8,6 @@ import {...@@ -8,7 +8,6 @@ import {
8 type TurnId,8 type TurnId,
9} from "@t3tools/contracts";9} from "@t3tools/contracts";
10import { type ChatMessage, type SessionPhase, type Thread, type ThreadSession } from "../types";10import { type ChatMessage, type SessionPhase, type Thread, type ThreadSession } from "../types";
11import { randomUUID } from "~/lib/utils";
12import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore";11import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore";
13import { Schema } from "effect";12import { Schema } from "effect";
14import { selectThreadByRef, useStore } from "../store";13import { selectThreadByRef, useStore } from "../store";
...@@ -17,10 +16,10 @@ import {...@@ -17,10 +16,10 @@ import {
17 stripInlineTerminalContextPlaceholders,16 stripInlineTerminalContextPlaceholders,
18 type TerminalContextDraft,17 type TerminalContextDraft,
19} from "../lib/terminalContext";18} from "../lib/terminalContext";
19import type { DraftThreadEnvMode } from "../composerDraftStore";
2020
21export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project";21export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project";
22export const MAX_HIDDEN_MOUNTED_TERMINAL_THREADS = 10;22export const MAX_HIDDEN_MOUNTED_TERMINAL_THREADS = 10;
23const WORKTREE_BRANCH_PREFIX = "t3code";
2423
25export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String);24export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String);
2625
...@@ -157,10 +156,11 @@ export function readFileAsDataUrl(file: File): Promise<string> {...@@ -157,10 +156,11 @@ export function readFileAsDataUrl(file: File): Promise<string> {
157 });156 });
158}157}
159158
160export function buildTemporaryWorktreeBranchName(): string {159export function resolveSendEnvMode(input: {
161 // Keep the 8-hex suffix shape for backend temporary-branch detection.160 requestedEnvMode: DraftThreadEnvMode;
162 const token = randomUUID().slice(0, 8).toLowerCase();161 isGitRepo: boolean;
163 return `${WORKTREE_BRANCH_PREFIX}/${token}`;162}): DraftThreadEnvMode {
163 return input.isGitRepo ? input.requestedEnvMode : "local";
164}164}
165165
166export function cloneComposerImageForRetry(166export function cloneComposerImageForRetry(
apps/web/src/components/ChatView.tsx+58-8
...@@ -91,6 +91,7 @@ import {...@@ -91,6 +91,7 @@ import {
91import { useTheme } from "../hooks/useTheme";91import { useTheme } from "../hooks/useTheme";
92import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";92import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";
93import { useCommandPaletteStore } from "../commandPaletteStore";93import { useCommandPaletteStore } from "../commandPaletteStore";
94import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git";
94import { BranchToolbar } from "./BranchToolbar";95import { BranchToolbar } from "./BranchToolbar";
95import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings";96import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings";
96import PlanSidebar from "./PlanSidebar";97import PlanSidebar from "./PlanSidebar";
...@@ -143,7 +144,6 @@ import {...@@ -143,7 +144,6 @@ import {
143 MAX_HIDDEN_MOUNTED_TERMINAL_THREADS,144 MAX_HIDDEN_MOUNTED_TERMINAL_THREADS,
144 buildExpiredTerminalContextToastCopy,145 buildExpiredTerminalContextToastCopy,
145 buildLocalDraftThread,146 buildLocalDraftThread,
146 buildTemporaryWorktreeBranchName,
147 collectUserMessageBlobPreviewUrls,147 collectUserMessageBlobPreviewUrls,
148 createLocalDispatchSnapshot,148 createLocalDispatchSnapshot,
149 deriveComposerSendState,149 deriveComposerSendState,
...@@ -156,6 +156,7 @@ import {...@@ -156,6 +156,7 @@ import {
156 deriveLockedProvider,156 deriveLockedProvider,
157 readFileAsDataUrl,157 readFileAsDataUrl,
158 reconcileMountedTerminalThreadIds,158 reconcileMountedTerminalThreadIds,
159 resolveSendEnvMode,
159 revokeBlobPreviewUrl,160 revokeBlobPreviewUrl,
160 revokeUserMessagePreviewUrls,161 revokeUserMessagePreviewUrls,
161 shouldWriteThreadErrorToCurrentServerThread,162 shouldWriteThreadErrorToCurrentServerThread,
...@@ -688,6 +689,9 @@ export default function ChatView(props: ChatViewProps) {...@@ -688,6 +689,9 @@ export default function ChatView(props: ChatViewProps) {
688 const [attachmentPreviewHandoffByMessageId, setAttachmentPreviewHandoffByMessageId] = useState<689 const [attachmentPreviewHandoffByMessageId, setAttachmentPreviewHandoffByMessageId] = useState<
689 Record<string, string[]>690 Record<string, string[]>
690 >({});691 >({});
692 const [pendingServerThreadEnvMode, setPendingServerThreadEnvMode] =
693 useState<DraftThreadEnvMode | null>(null);
694 const [pendingServerThreadBranch, setPendingServerThreadBranch] = useState<string | null>();
691 const [lastInvokedScriptByProjectId, setLastInvokedScriptByProjectId] = useLocalStorage(695 const [lastInvokedScriptByProjectId, setLastInvokedScriptByProjectId] = useLocalStorage(
692 LAST_INVOKED_SCRIPT_BY_PROJECT_KEY,696 LAST_INVOKED_SCRIPT_BY_PROJECT_KEY,
693 {},697 {},
...@@ -2043,11 +2047,42 @@ export default function ChatView(props: ChatViewProps) {...@@ -2043,11 +2047,42 @@ export default function ChatView(props: ChatViewProps) {
2043 }, []);2047 }, []);
20442048
2045 const activeWorktreePath = activeThread?.worktreePath ?? null;2049 const activeWorktreePath = activeThread?.worktreePath ?? null;
2046 const envMode: DraftThreadEnvMode = resolveEffectiveEnvMode({2050 const derivedEnvMode: DraftThreadEnvMode = resolveEffectiveEnvMode({
2047 activeWorktreePath,2051 activeWorktreePath,
2048 hasServerThread: isServerThread,2052 hasServerThread: isServerThread,
2049 draftThreadEnvMode: isLocalDraftThread ? draftThread?.envMode : undefined,2053 draftThreadEnvMode: isLocalDraftThread ? draftThread?.envMode : undefined,
2050 });2054 });
2055 const canOverrideServerThreadEnvMode = Boolean(
2056 isServerThread &&
2057 activeThread &&
2058 activeThread.messages.length === 0 &&
2059 activeThread.worktreePath === null &&
2060 !envLocked,
2061 );
2062 const envMode: DraftThreadEnvMode = canOverrideServerThreadEnvMode
2063 ? (pendingServerThreadEnvMode ?? draftThread?.envMode ?? derivedEnvMode)
2064 : derivedEnvMode;
2065 const activeThreadBranch =
2066 canOverrideServerThreadEnvMode && pendingServerThreadBranch !== undefined
2067 ? pendingServerThreadBranch
2068 : (activeThread?.branch ?? null);
2069 const sendEnvMode = resolveSendEnvMode({
2070 requestedEnvMode: envMode,
2071 isGitRepo,
2072 });
2073
2074 useEffect(() => {
2075 setPendingServerThreadEnvMode(null);
2076 setPendingServerThreadBranch(undefined);
2077 }, [activeThread?.id]);
2078
2079 useEffect(() => {
2080 if (canOverrideServerThreadEnvMode) {
2081 return;
2082 }
2083 setPendingServerThreadEnvMode(null);
2084 setPendingServerThreadBranch(undefined);
2085 }, [canOverrideServerThreadEnvMode]);
20512086
2052 useEffect(() => {2087 useEffect(() => {
2053 if (!activeThreadId) {2088 if (!activeThreadId) {
...@@ -2360,15 +2395,15 @@ export default function ChatView(props: ChatViewProps) {...@@ -2360,15 +2395,15 @@ export default function ChatView(props: ChatViewProps) {
2360 const threadIdForSend = activeThread.id;2395 const threadIdForSend = activeThread.id;
2361 const isFirstMessage = !isServerThread || activeThread.messages.length === 0;2396 const isFirstMessage = !isServerThread || activeThread.messages.length === 0;
2362 const baseBranchForWorktree =2397 const baseBranchForWorktree =
2363 isFirstMessage && envMode === "worktree" && !activeThread.worktreePath2398 isFirstMessage && sendEnvMode === "worktree" && !activeThread.worktreePath
2364 ? activeThread.branch2399 ? activeThreadBranch
2365 : null;2400 : null;
23662401
2367 // In worktree mode, require an explicit base branch so we don't silently2402 // In worktree mode, require an explicit base branch so we don't silently
2368 // fall back to local execution when branch selection is missing.2403 // fall back to local execution when branch selection is missing.
2369 const shouldCreateWorktree =2404 const shouldCreateWorktree =
2370 isFirstMessage && envMode === "worktree" && !activeThread.worktreePath;2405 isFirstMessage && sendEnvMode === "worktree" && !activeThread.worktreePath;
2371 if (shouldCreateWorktree && !activeThread.branch) {2406 if (shouldCreateWorktree && !activeThreadBranch) {
2372 setThreadError(threadIdForSend, "Select a base branch before sending in New worktree mode.");2407 setThreadError(threadIdForSend, "Select a base branch before sending in New worktree mode.");
2373 return;2408 return;
2374 }2409 }
...@@ -2507,7 +2542,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -2507,7 +2542,7 @@ export default function ChatView(props: ChatViewProps) {
2507 modelSelection: threadCreateModelSelection,2542 modelSelection: threadCreateModelSelection,
2508 runtimeMode,2543 runtimeMode,
2509 interactionMode,2544 interactionMode,
2510 branch: activeThread.branch,2545 branch: activeThreadBranch,
2511 worktreePath: activeThread.worktreePath,2546 worktreePath: activeThread.worktreePath,
2512 createdAt: activeThread.createdAt,2547 createdAt: activeThread.createdAt,
2513 },2548 },
...@@ -2959,7 +2994,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -2959,7 +2994,7 @@ export default function ChatView(props: ChatViewProps) {
2959 modelSelection: nextThreadModelSelection,2994 modelSelection: nextThreadModelSelection,
2960 runtimeMode,2995 runtimeMode,
2961 interactionMode: "default",2996 interactionMode: "default",
2962 branch: activeThread.branch,2997 branch: activeThreadBranch,
2963 worktreePath: activeThread.worktreePath,2998 worktreePath: activeThread.worktreePath,
2964 createdAt,2999 createdAt,
2965 })3000 })
...@@ -3018,6 +3053,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -3018,6 +3053,7 @@ export default function ChatView(props: ChatViewProps) {
3018 }, [3053 }, [
3019 activeProject,3054 activeProject,
3020 activeProposedPlan,3055 activeProposedPlan,
3056 activeThreadBranch,
3021 activeThread,3057 activeThread,
3022 beginLocalDispatch,3058 beginLocalDispatch,
3023 isConnecting,3059 isConnecting,
...@@ -3066,6 +3102,11 @@ export default function ChatView(props: ChatViewProps) {...@@ -3066,6 +3102,11 @@ export default function ChatView(props: ChatViewProps) {
3066 );3102 );
3067 const onEnvModeChange = useCallback(3103 const onEnvModeChange = useCallback(
3068 (mode: DraftThreadEnvMode) => {3104 (mode: DraftThreadEnvMode) => {
3105 if (canOverrideServerThreadEnvMode) {
3106 setPendingServerThreadEnvMode(mode);
3107 scheduleComposerFocus();
3108 return;
3109 }
3069 if (isLocalDraftThread) {3110 if (isLocalDraftThread) {
3070 setDraftThreadContext(composerDraftTarget, {3111 setDraftThreadContext(composerDraftTarget, {
3071 envMode: mode,3112 envMode: mode,
...@@ -3075,9 +3116,11 @@ export default function ChatView(props: ChatViewProps) {...@@ -3075,9 +3116,11 @@ export default function ChatView(props: ChatViewProps) {
3075 scheduleComposerFocus();3116 scheduleComposerFocus();
3076 },3117 },
3077 [3118 [
3119 canOverrideServerThreadEnvMode,
3078 composerDraftTarget,3120 composerDraftTarget,
3079 draftThread?.worktreePath,3121 draftThread?.worktreePath,
3080 isLocalDraftThread,3122 isLocalDraftThread,
3123 setPendingServerThreadEnvMode,
3081 scheduleComposerFocus,3124 scheduleComposerFocus,
3082 setDraftThreadContext,3125 setDraftThreadContext,
3083 ],3126 ],
...@@ -3300,6 +3343,13 @@ export default function ChatView(props: ChatViewProps) {...@@ -3300,6 +3343,13 @@ export default function ChatView(props: ChatViewProps) {
3300 threadId={activeThread.id}3343 threadId={activeThread.id}
3301 {...(routeKind === "draft" && draftId ? { draftId } : {})}3344 {...(routeKind === "draft" && draftId ? { draftId } : {})}
3302 onEnvModeChange={onEnvModeChange}3345 onEnvModeChange={onEnvModeChange}
3346 {...(canOverrideServerThreadEnvMode ? { effectiveEnvModeOverride: envMode } : {})}
3347 {...(canOverrideServerThreadEnvMode
3348 ? {
3349 activeThreadBranchOverride: activeThreadBranch,
3350 onActiveThreadBranchOverrideChange: setPendingServerThreadBranch,
3351 }
3352 : {})}
3303 envLocked={envLocked}3353 envLocked={envLocked}
3304 onComposerFocusRequest={scheduleComposerFocus}3354 onComposerFocusRequest={scheduleComposerFocus}
3305 {...(canCheckoutPullRequestIntoThread3355 {...(canCheckoutPullRequestIntoThread
packages/shared/src/git.test.ts+21
...@@ -3,8 +3,11 @@ import { describe, expect, it } from "vitest";...@@ -3,8 +3,11 @@ import { describe, expect, it } from "vitest";
33
4import {4import {
5 applyGitStatusStreamEvent,5 applyGitStatusStreamEvent,
6 buildTemporaryWorktreeBranchName,
7 isTemporaryWorktreeBranch,
6 normalizeGitRemoteUrl,8 normalizeGitRemoteUrl,
7 parseGitHubRepositoryNameWithOwnerFromRemoteUrl,9 parseGitHubRepositoryNameWithOwnerFromRemoteUrl,
10 WORKTREE_BRANCH_PREFIX,
8} from "./git";11} from "./git";
912
10describe("normalizeGitRemoteUrl", () => {13describe("normalizeGitRemoteUrl", () => {
...@@ -50,6 +53,24 @@ describe("parseGitHubRepositoryNameWithOwnerFromRemoteUrl", () => {...@@ -50,6 +53,24 @@ describe("parseGitHubRepositoryNameWithOwnerFromRemoteUrl", () => {
50 });53 });
51});54});
5255
56describe("isTemporaryWorktreeBranch", () => {
57 it("matches the generated temporary worktree branch format", () => {
58 expect(isTemporaryWorktreeBranch(buildTemporaryWorktreeBranchName())).toBe(true);
59 });
60
61 it("matches generated temporary worktree branches", () => {
62 expect(isTemporaryWorktreeBranch(`${WORKTREE_BRANCH_PREFIX}/deadbeef`)).toBe(true);
63 expect(isTemporaryWorktreeBranch(` ${WORKTREE_BRANCH_PREFIX}/deadbeef `)).toBe(true);
64 expect(isTemporaryWorktreeBranch(`${WORKTREE_BRANCH_PREFIX}/DEADBEEF`)).toBe(true);
65 });
66
67 it("rejects non-temporary branch names", () => {
68 expect(isTemporaryWorktreeBranch(`${WORKTREE_BRANCH_PREFIX}/feature/demo`)).toBe(false);
69 expect(isTemporaryWorktreeBranch("main")).toBe(false);
70 expect(isTemporaryWorktreeBranch(`${WORKTREE_BRANCH_PREFIX}/deadbeef-extra`)).toBe(false);
71 });
72});
73
53describe("applyGitStatusStreamEvent", () => {74describe("applyGitStatusStreamEvent", () => {
54 it("treats a remote-only update as a repository when local state is missing", () => {75 it("treats a remote-only update as a repository when local state is missing", () => {
55 const remote: GitStatusRemoteResult = {76 const remote: GitStatusRemoteResult = {
packages/shared/src/git.ts+14
...@@ -6,6 +6,11 @@ import type {...@@ -6,6 +6,11 @@ import type {
6 GitStatusResult,6 GitStatusResult,
7 GitStatusStreamEvent,7 GitStatusStreamEvent,
8} from "@t3tools/contracts";8} from "@t3tools/contracts";
9import * as Effect from "effect/Effect";
10import * as Random from "effect/Random";
11
12export const WORKTREE_BRANCH_PREFIX = "t3code";
13const TEMP_WORKTREE_BRANCH_PATTERN = new RegExp(`^${WORKTREE_BRANCH_PREFIX}\\/[0-9a-f]{8}$`);
914
10/**15/**
11 * Sanitize an arbitrary string into a valid, lowercase git branch fragment.16 * Sanitize an arbitrary string into a valid, lowercase git branch fragment.
...@@ -80,6 +85,15 @@ export function deriveLocalBranchNameFromRemoteRef(branchName: string): string {...@@ -80,6 +85,15 @@ export function deriveLocalBranchNameFromRemoteRef(branchName: string): string {
80 return branchName.slice(firstSeparatorIndex + 1);85 return branchName.slice(firstSeparatorIndex + 1);
81}86}
8287
88export function buildTemporaryWorktreeBranchName(): string {
89 const token = Effect.runSync(Random.nextUUIDv4).replace(/-/g, "").slice(0, 8).toLowerCase();
90 return `${WORKTREE_BRANCH_PREFIX}/${token}`;
91}
92
93export function isTemporaryWorktreeBranch(branch: string): boolean {
94 return TEMP_WORKTREE_BRANCH_PATTERN.test(branch.trim().toLowerCase());
95}
96
83/**97/**
84 * Normalize a git remote URL into a stable comparison key.98 * Normalize a git remote URL into a stable comparison key.
85 */99 */