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 {
1111 type RuntimeMode,
1212 type TurnId,
1313} from "@t3tools/contracts";
14import { isTemporaryWorktreeBranch, WORKTREE_BRANCH_PREFIX } from "@t3tools/shared/git";
1415import { Cache, Cause, Duration, Effect, Equal, Layer, Option, Schema, Stream } from "effect";
1516import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker";
1617
......@@ -72,8 +73,6 @@ const serverCommandId = (tag: string): CommandId =>
7273const HANDLED_TURN_START_KEY_MAX = 10_000;
7374const HANDLED_TURN_START_KEY_TTL = Duration.minutes(30);
7475const 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}$`);
7776const DEFAULT_THREAD_TITLE = "New thread";
7877
7978function canReplaceThreadTitle(currentTitle: string, titleSeed?: string): boolean {
......@@ -119,10 +118,6 @@ function stalePendingRequestDetail(
119118 return `Stale pending ${requestKind} request: ${requestId}. Provider callback state does not survive app restarts or recovered sessions. Restart the turn to continue.`;
120119}
121120
122function isTemporaryWorktreeBranch(branch: string): boolean {
123 return TEMP_WORKTREE_BRANCH_PATTERN.test(branch.trim().toLowerCase());
124}
125
126121function buildGeneratedWorktreeBranchName(raw: string): string {
127122 const normalized = raw
128123 .trim()
apps/web/src/components/BranchToolbar.logic.test.ts+11
......@@ -10,6 +10,7 @@ import {
1010 resolveEffectiveEnvMode,
1111 resolveEnvModeLabel,
1212 resolveBranchToolbarValue,
13 resolveLockedWorkspaceLabel,
1314 shouldIncludeBranchPickerItem,
1415} from "./BranchToolbar.logic";
1516
......@@ -157,6 +158,16 @@ describe("resolveCurrentWorkspaceLabel", () => {
157158 });
158159});
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
160171describe("deriveLocalBranchNameFromRemoteRef", () => {
161172 it("strips the remote prefix from a remote ref", () => {
162173 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):
5050 return activeWorktreePath ? "Current worktree" : resolveEnvModeLabel("local");
5151}
5252
53export function resolveLockedWorkspaceLabel(activeWorktreePath: string | null): string {
54 return activeWorktreePath ? "Worktree" : "Local checkout";
55}
56
5357export function resolveEffectiveEnvMode(input: {
5458 activeWorktreePath: string | null;
5559 hasServerThread: boolean;
apps/web/src/components/BranchToolbar.tsx+16-5
......@@ -20,6 +20,9 @@ interface BranchToolbarProps {
2020 threadId: ThreadId;
2121 draftId?: DraftId;
2222 onEnvModeChange: (mode: EnvMode) => void;
23 effectiveEnvModeOverride?: EnvMode;
24 activeThreadBranchOverride?: string | null;
25 onActiveThreadBranchOverrideChange?: (branch: string | null) => void;
2326 envLocked: boolean;
2427 onCheckoutPullRequestRequest?: (reference: string) => void;
2528 onComposerFocusRequest?: () => void;
......@@ -32,6 +35,9 @@ export const BranchToolbar = memo(function BranchToolbar({
3235 threadId,
3336 draftId,
3437 onEnvModeChange,
38 effectiveEnvModeOverride,
39 activeThreadBranchOverride,
40 onActiveThreadBranchOverrideChange,
3541 envLocked,
3642 onCheckoutPullRequestRequest,
3743 onComposerFocusRequest,
......@@ -59,11 +65,13 @@ export const BranchToolbar = memo(function BranchToolbar({
5965 const activeProject = useStore(activeProjectSelector);
6066 const hasActiveThread = serverThread !== undefined || draftThread !== null;
6167 const activeWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null;
62 const effectiveEnvMode = resolveEffectiveEnvMode({
63 activeWorktreePath,
64 hasServerThread: serverThread !== undefined,
65 draftThreadEnvMode: draftThread?.envMode,
66 });
68 const effectiveEnvMode =
69 effectiveEnvModeOverride ??
70 resolveEffectiveEnvMode({
71 activeWorktreePath,
72 hasServerThread: serverThread !== undefined,
73 draftThreadEnvMode: draftThread?.envMode,
74 });
6775 const envModeLocked = envLocked || (serverThread !== undefined && activeWorktreePath !== null);
6876
6977 const showEnvironmentPicker =
......@@ -98,6 +106,9 @@ export const BranchToolbar = memo(function BranchToolbar({
98106 threadId={threadId}
99107 {...(draftId ? { draftId } : {})}
100108 envLocked={envLocked}
109 {...(effectiveEnvModeOverride ? { effectiveEnvModeOverride } : {})}
110 {...(activeThreadBranchOverride !== undefined ? { activeThreadBranchOverride } : {})}
111 {...(onActiveThreadBranchOverrideChange ? { onActiveThreadBranchOverrideChange } : {})}
101112 {...(onCheckoutPullRequestRequest ? { onCheckoutPullRequestRequest } : {})}
102113 {...(onComposerFocusRequest ? { onComposerFocusRequest } : {})}
103114 />
apps/web/src/components/BranchToolbarBranchSelector.tsx+19-6
......@@ -49,6 +49,9 @@ interface BranchToolbarBranchSelectorProps {
4949 threadId: ThreadId;
5050 draftId?: DraftId;
5151 envLocked: boolean;
52 effectiveEnvModeOverride?: "local" | "worktree";
53 activeThreadBranchOverride?: string | null;
54 onActiveThreadBranchOverrideChange?: (branch: string | null) => void;
5255 onCheckoutPullRequestRequest?: (reference: string) => void;
5356 onComposerFocusRequest?: () => void;
5457}
......@@ -77,6 +80,9 @@ export function BranchToolbarBranchSelector({
7780 threadId,
7881 draftId,
7982 envLocked,
83 effectiveEnvModeOverride,
84 activeThreadBranchOverride,
85 onActiveThreadBranchOverrideChange,
8086 onCheckoutPullRequestRequest,
8187 onComposerFocusRequest,
8288}: BranchToolbarBranchSelectorProps) {
......@@ -108,16 +114,21 @@ export function BranchToolbarBranchSelector({
108114 const activeProject = useStore(activeProjectSelector);
109115
110116 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);
112121 const activeWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null;
113122 const activeProjectCwd = activeProject?.cwd ?? null;
114123 const branchCwd = activeWorktreePath ?? activeProjectCwd;
115124 const hasServerThread = serverThread !== undefined;
116 const effectiveEnvMode = resolveEffectiveEnvMode({
117 activeWorktreePath,
118 hasServerThread,
119 draftThreadEnvMode: draftThread?.envMode,
120 });
125 const effectiveEnvMode =
126 effectiveEnvModeOverride ??
127 resolveEffectiveEnvMode({
128 activeWorktreePath,
129 hasServerThread,
130 draftThreadEnvMode: draftThread?.envMode,
131 });
121132
122133 // ---------------------------------------------------------------------------
123134 // Thread branch mutation (colocated — only this component calls it)
......@@ -146,6 +157,7 @@ export function BranchToolbarBranchSelector({
146157 });
147158 }
148159 if (hasServerThread) {
160 onActiveThreadBranchOverrideChange?.(branch);
149161 setThreadBranchAction(threadRef, branch, worktreePath);
150162 return;
151163 }
......@@ -167,6 +179,7 @@ export function BranchToolbarBranchSelector({
167179 serverSession,
168180 activeWorktreePath,
169181 hasServerThread,
182 onActiveThreadBranchOverrideChange,
170183 setThreadBranchAction,
171184 setDraftThreadContext,
172185 draftId,
apps/web/src/components/BranchToolbarEnvModeSelector.tsx+3-2
......@@ -4,6 +4,7 @@ import { memo, useMemo } from "react";
44import {
55 resolveCurrentWorkspaceLabel,
66 resolveEnvModeLabel,
7 resolveLockedWorkspaceLabel,
78 type EnvMode,
89} from "./BranchToolbar.logic";
910import {
......@@ -43,12 +44,12 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe
4344 {activeWorktreePath ? (
4445 <>
4546 <FolderGitIcon className="size-3" />
46 {resolveCurrentWorkspaceLabel(activeWorktreePath)}
47 {resolveLockedWorkspaceLabel(activeWorktreePath)}
4748 </>
4849 ) : (
4950 <>
5051 <FolderIcon className="size-3" />
51 {resolveCurrentWorkspaceLabel(activeWorktreePath)}
52 {resolveLockedWorkspaceLabel(activeWorktreePath)}
5253 </>
5354 )}
5455 </span>
apps/web/src/components/ChatView.browser.tsx+281
......@@ -2286,6 +2286,287 @@ describe("ChatView timeline estimator parity (full app)", () => {
22862286 }
22872287 });
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
22892570 it("shows the send state once bootstrap dispatch is in flight", async () => {
22902571 useTerminalStateStore.setState({
22912572 terminalStateByThreadKey: {},
apps/web/src/components/ChatView.logic.test.ts+12
......@@ -11,6 +11,7 @@ import {
1111 deriveComposerSendState,
1212 hasServerAcknowledgedLocalDispatch,
1313 reconcileMountedTerminalThreadIds,
14 resolveSendEnvMode,
1415 shouldWriteThreadErrorToCurrentServerThread,
1516 waitForStartedServerThread,
1617} from "./ChatView.logic";
......@@ -82,6 +83,17 @@ describe("buildExpiredTerminalContextToastCopy", () => {
8283 });
8384});
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
8597describe("reconcileMountedTerminalThreadIds", () => {
8698 it("keeps previously mounted open threads and adds the active open thread", () => {
8799 expect(
apps/web/src/components/ChatView.logic.ts+6-6
......@@ -8,7 +8,6 @@ import {
88 type TurnId,
99} from "@t3tools/contracts";
1010import { type ChatMessage, type SessionPhase, type Thread, type ThreadSession } from "../types";
11import { randomUUID } from "~/lib/utils";
1211import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore";
1312import { Schema } from "effect";
1413import { selectThreadByRef, useStore } from "../store";
......@@ -17,10 +16,10 @@ import {
1716 stripInlineTerminalContextPlaceholders,
1817 type TerminalContextDraft,
1918} from "../lib/terminalContext";
19import type { DraftThreadEnvMode } from "../composerDraftStore";
2020
2121export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project";
2222export const MAX_HIDDEN_MOUNTED_TERMINAL_THREADS = 10;
23const WORKTREE_BRANCH_PREFIX = "t3code";
2423
2524export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String);
2625
......@@ -157,10 +156,11 @@ export function readFileAsDataUrl(file: File): Promise<string> {
157156 });
158157}
159158
160export function buildTemporaryWorktreeBranchName(): string {
161 // Keep the 8-hex suffix shape for backend temporary-branch detection.
162 const token = randomUUID().slice(0, 8).toLowerCase();
163 return `${WORKTREE_BRANCH_PREFIX}/${token}`;
159export function resolveSendEnvMode(input: {
160 requestedEnvMode: DraftThreadEnvMode;
161 isGitRepo: boolean;
162}): DraftThreadEnvMode {
163 return input.isGitRepo ? input.requestedEnvMode : "local";
164164}
165165
166166export function cloneComposerImageForRetry(
apps/web/src/components/ChatView.tsx+58-8
......@@ -91,6 +91,7 @@ import {
9191import { useTheme } from "../hooks/useTheme";
9292import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";
9393import { useCommandPaletteStore } from "../commandPaletteStore";
94import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git";
9495import { BranchToolbar } from "./BranchToolbar";
9596import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings";
9697import PlanSidebar from "./PlanSidebar";
......@@ -143,7 +144,6 @@ import {
143144 MAX_HIDDEN_MOUNTED_TERMINAL_THREADS,
144145 buildExpiredTerminalContextToastCopy,
145146 buildLocalDraftThread,
146 buildTemporaryWorktreeBranchName,
147147 collectUserMessageBlobPreviewUrls,
148148 createLocalDispatchSnapshot,
149149 deriveComposerSendState,
......@@ -156,6 +156,7 @@ import {
156156 deriveLockedProvider,
157157 readFileAsDataUrl,
158158 reconcileMountedTerminalThreadIds,
159 resolveSendEnvMode,
159160 revokeBlobPreviewUrl,
160161 revokeUserMessagePreviewUrls,
161162 shouldWriteThreadErrorToCurrentServerThread,
......@@ -688,6 +689,9 @@ export default function ChatView(props: ChatViewProps) {
688689 const [attachmentPreviewHandoffByMessageId, setAttachmentPreviewHandoffByMessageId] = useState<
689690 Record<string, string[]>
690691 >({});
692 const [pendingServerThreadEnvMode, setPendingServerThreadEnvMode] =
693 useState<DraftThreadEnvMode | null>(null);
694 const [pendingServerThreadBranch, setPendingServerThreadBranch] = useState<string | null>();
691695 const [lastInvokedScriptByProjectId, setLastInvokedScriptByProjectId] = useLocalStorage(
692696 LAST_INVOKED_SCRIPT_BY_PROJECT_KEY,
693697 {},
......@@ -2043,11 +2047,42 @@ export default function ChatView(props: ChatViewProps) {
20432047 }, []);
20442048
20452049 const activeWorktreePath = activeThread?.worktreePath ?? null;
2046 const envMode: DraftThreadEnvMode = resolveEffectiveEnvMode({
2050 const derivedEnvMode: DraftThreadEnvMode = resolveEffectiveEnvMode({
20472051 activeWorktreePath,
20482052 hasServerThread: isServerThread,
20492053 draftThreadEnvMode: isLocalDraftThread ? draftThread?.envMode : undefined,
20502054 });
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
20522087 useEffect(() => {
20532088 if (!activeThreadId) {
......@@ -2360,15 +2395,15 @@ export default function ChatView(props: ChatViewProps) {
23602395 const threadIdForSend = activeThread.id;
23612396 const isFirstMessage = !isServerThread || activeThread.messages.length === 0;
23622397 const baseBranchForWorktree =
2363 isFirstMessage && envMode === "worktree" && !activeThread.worktreePath
2364 ? activeThread.branch
2398 isFirstMessage && sendEnvMode === "worktree" && !activeThread.worktreePath
2399 ? activeThreadBranch
23652400 : null;
23662401
23672402 // In worktree mode, require an explicit base branch so we don't silently
23682403 // fall back to local execution when branch selection is missing.
23692404 const shouldCreateWorktree =
2370 isFirstMessage && envMode === "worktree" && !activeThread.worktreePath;
2371 if (shouldCreateWorktree && !activeThread.branch) {
2405 isFirstMessage && sendEnvMode === "worktree" && !activeThread.worktreePath;
2406 if (shouldCreateWorktree && !activeThreadBranch) {
23722407 setThreadError(threadIdForSend, "Select a base branch before sending in New worktree mode.");
23732408 return;
23742409 }
......@@ -2507,7 +2542,7 @@ export default function ChatView(props: ChatViewProps) {
25072542 modelSelection: threadCreateModelSelection,
25082543 runtimeMode,
25092544 interactionMode,
2510 branch: activeThread.branch,
2545 branch: activeThreadBranch,
25112546 worktreePath: activeThread.worktreePath,
25122547 createdAt: activeThread.createdAt,
25132548 },
......@@ -2959,7 +2994,7 @@ export default function ChatView(props: ChatViewProps) {
29592994 modelSelection: nextThreadModelSelection,
29602995 runtimeMode,
29612996 interactionMode: "default",
2962 branch: activeThread.branch,
2997 branch: activeThreadBranch,
29632998 worktreePath: activeThread.worktreePath,
29642999 createdAt,
29653000 })
......@@ -3018,6 +3053,7 @@ export default function ChatView(props: ChatViewProps) {
30183053 }, [
30193054 activeProject,
30203055 activeProposedPlan,
3056 activeThreadBranch,
30213057 activeThread,
30223058 beginLocalDispatch,
30233059 isConnecting,
......@@ -3066,6 +3102,11 @@ export default function ChatView(props: ChatViewProps) {
30663102 );
30673103 const onEnvModeChange = useCallback(
30683104 (mode: DraftThreadEnvMode) => {
3105 if (canOverrideServerThreadEnvMode) {
3106 setPendingServerThreadEnvMode(mode);
3107 scheduleComposerFocus();
3108 return;
3109 }
30693110 if (isLocalDraftThread) {
30703111 setDraftThreadContext(composerDraftTarget, {
30713112 envMode: mode,
......@@ -3075,9 +3116,11 @@ export default function ChatView(props: ChatViewProps) {
30753116 scheduleComposerFocus();
30763117 },
30773118 [
3119 canOverrideServerThreadEnvMode,
30783120 composerDraftTarget,
30793121 draftThread?.worktreePath,
30803122 isLocalDraftThread,
3123 setPendingServerThreadEnvMode,
30813124 scheduleComposerFocus,
30823125 setDraftThreadContext,
30833126 ],
......@@ -3300,6 +3343,13 @@ export default function ChatView(props: ChatViewProps) {
33003343 threadId={activeThread.id}
33013344 {...(routeKind === "draft" && draftId ? { draftId } : {})}
33023345 onEnvModeChange={onEnvModeChange}
3346 {...(canOverrideServerThreadEnvMode ? { effectiveEnvModeOverride: envMode } : {})}
3347 {...(canOverrideServerThreadEnvMode
3348 ? {
3349 activeThreadBranchOverride: activeThreadBranch,
3350 onActiveThreadBranchOverrideChange: setPendingServerThreadBranch,
3351 }
3352 : {})}
33033353 envLocked={envLocked}
33043354 onComposerFocusRequest={scheduleComposerFocus}
33053355 {...(canCheckoutPullRequestIntoThread
packages/shared/src/git.test.ts+21
......@@ -3,8 +3,11 @@ import { describe, expect, it } from "vitest";
33
44import {
55 applyGitStatusStreamEvent,
6 buildTemporaryWorktreeBranchName,
7 isTemporaryWorktreeBranch,
68 normalizeGitRemoteUrl,
79 parseGitHubRepositoryNameWithOwnerFromRemoteUrl,
10 WORKTREE_BRANCH_PREFIX,
811} from "./git";
912
1013describe("normalizeGitRemoteUrl", () => {
......@@ -50,6 +53,24 @@ describe("parseGitHubRepositoryNameWithOwnerFromRemoteUrl", () => {
5053 });
5154});
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
5374describe("applyGitStatusStreamEvent", () => {
5475 it("treats a remote-only update as a repository when local state is missing", () => {
5576 const remote: GitStatusRemoteResult = {
packages/shared/src/git.ts+14
......@@ -6,6 +6,11 @@ import type {
66 GitStatusResult,
77 GitStatusStreamEvent,
88} 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
1015/**
1116 * Sanitize an arbitrary string into a valid, lowercase git branch fragment.
......@@ -80,6 +85,15 @@ export function deriveLocalBranchNameFromRemoteRef(branchName: string): string {
8085 return branchName.slice(firstSeparatorIndex + 1);
8186}
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
8397/**
8498 * Normalize a git remote URL into a stable comparison key.
8599 */