| author | |
| committer | |
| log | 801b83e9372cd5ccd7803c3137db7ba24f7bf897 |
| tree | fb6429e904f2333d1e208f6a192db8ca42632675 |
| parent | f2205bdc4297ddc4b854bc10bdcc0234113e102e |
| signature | Signed by PGP key B5690EEEBB952194 |
12 files changed, 446 insertions(+), 33 deletions(-)
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+1-6| ... | ... | @@ -11,6 +11,7 @@ import { |
| 11 | 11 | type RuntimeMode, |
| 12 | 12 | type TurnId, |
| 13 | 13 | } from "@t3tools/contracts"; |
| 14 | import { isTemporaryWorktreeBranch, WORKTREE_BRANCH_PREFIX } from "@t3tools/shared/git"; | |
| 14 | 15 | import { Cache, Cause, Duration, Effect, Equal, Layer, Option, Schema, Stream } from "effect"; |
| 15 | 16 | import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; |
| 16 | 17 | |
| ... | ... | @@ -72,8 +73,6 @@ const serverCommandId = (tag: string): CommandId => |
| 72 | 73 | const HANDLED_TURN_START_KEY_MAX = 10_000; |
| 73 | 74 | const HANDLED_TURN_START_KEY_TTL = Duration.minutes(30); |
| 74 | 75 | const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; |
| 75 | const WORKTREE_BRANCH_PREFIX = "t3code"; | |
| 76 | const TEMP_WORKTREE_BRANCH_PATTERN = new RegExp(`^${WORKTREE_BRANCH_PREFIX}\\/[0-9a-f]{8}$`); | |
| 77 | 76 | const DEFAULT_THREAD_TITLE = "New thread"; |
| 78 | 77 | |
| 79 | 78 | function canReplaceThreadTitle(currentTitle: string, titleSeed?: string): boolean { |
| ... | ... | @@ -119,10 +118,6 @@ function stalePendingRequestDetail( |
| 119 | 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 | } |
| 121 | 120 | |
| 122 | function isTemporaryWorktreeBranch(branch: string): boolean { | |
| 123 | return TEMP_WORKTREE_BRANCH_PATTERN.test(branch.trim().toLowerCase()); | |
| 124 | } | |
| 125 | ||
| 126 | 121 | function buildGeneratedWorktreeBranchName(raw: string): string { |
| 127 | 122 | const normalized = raw |
| 128 | 123 | .trim() |
apps/web/src/components/BranchToolbar.logic.test.ts+11| ... | ... | @@ -10,6 +10,7 @@ import { |
| 10 | 10 | resolveEffectiveEnvMode, |
| 11 | 11 | resolveEnvModeLabel, |
| 12 | 12 | resolveBranchToolbarValue, |
| 13 | resolveLockedWorkspaceLabel, | |
| 13 | 14 | shouldIncludeBranchPickerItem, |
| 14 | 15 | } from "./BranchToolbar.logic"; |
| 15 | 16 | |
| ... | ... | @@ -157,6 +158,16 @@ describe("resolveCurrentWorkspaceLabel", () => { |
| 157 | 158 | }); |
| 158 | 159 | }); |
| 159 | 160 | |
| 161 | describe("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 | ||
| 160 | 171 | describe("deriveLocalBranchNameFromRemoteRef", () => { |
| 161 | 172 | it("strips the remote prefix from a remote ref", () => { |
| 162 | 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 | 50 | return activeWorktreePath ? "Current worktree" : resolveEnvModeLabel("local"); |
| 51 | 51 | } |
| 52 | 52 | |
| 53 | export function resolveLockedWorkspaceLabel(activeWorktreePath: string | null): string { | |
| 54 | return activeWorktreePath ? "Worktree" : "Local checkout"; | |
| 55 | } | |
| 56 | ||
| 53 | 57 | export function resolveEffectiveEnvMode(input: { |
| 54 | 58 | activeWorktreePath: string | null; |
| 55 | 59 | hasServerThread: boolean; |
apps/web/src/components/BranchToolbar.tsx+16-5| ... | ... | @@ -20,6 +20,9 @@ interface BranchToolbarProps { |
| 20 | 20 | threadId: ThreadId; |
| 21 | 21 | draftId?: DraftId; |
| 22 | 22 | onEnvModeChange: (mode: EnvMode) => void; |
| 23 | effectiveEnvModeOverride?: EnvMode; | |
| 24 | activeThreadBranchOverride?: string | null; | |
| 25 | onActiveThreadBranchOverrideChange?: (branch: string | null) => void; | |
| 23 | 26 | envLocked: boolean; |
| 24 | 27 | onCheckoutPullRequestRequest?: (reference: string) => void; |
| 25 | 28 | onComposerFocusRequest?: () => void; |
| ... | ... | @@ -32,6 +35,9 @@ export const BranchToolbar = memo(function BranchToolbar({ |
| 32 | 35 | threadId, |
| 33 | 36 | draftId, |
| 34 | 37 | onEnvModeChange, |
| 38 | effectiveEnvModeOverride, | |
| 39 | activeThreadBranchOverride, | |
| 40 | onActiveThreadBranchOverrideChange, | |
| 35 | 41 | envLocked, |
| 36 | 42 | onCheckoutPullRequestRequest, |
| 37 | 43 | onComposerFocusRequest, |
| ... | ... | @@ -59,11 +65,13 @@ export const BranchToolbar = memo(function BranchToolbar({ |
| 59 | 65 | const activeProject = useStore(activeProjectSelector); |
| 60 | 66 | const hasActiveThread = serverThread !== undefined || draftThread !== null; |
| 61 | 67 | 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 | }); | |
| 67 | 75 | const envModeLocked = envLocked || (serverThread !== undefined && activeWorktreePath !== null); |
| 68 | 76 | |
| 69 | 77 | const showEnvironmentPicker = |
| ... | ... | @@ -98,6 +106,9 @@ export const BranchToolbar = memo(function BranchToolbar({ |
| 98 | 106 | threadId={threadId} |
| 99 | 107 | {...(draftId ? { draftId } : {})} |
| 100 | 108 | envLocked={envLocked} |
| 109 | {...(effectiveEnvModeOverride ? { effectiveEnvModeOverride } : {})} | |
| 110 | {...(activeThreadBranchOverride !== undefined ? { activeThreadBranchOverride } : {})} | |
| 111 | {...(onActiveThreadBranchOverrideChange ? { onActiveThreadBranchOverrideChange } : {})} | |
| 101 | 112 | {...(onCheckoutPullRequestRequest ? { onCheckoutPullRequestRequest } : {})} |
| 102 | 113 | {...(onComposerFocusRequest ? { onComposerFocusRequest } : {})} |
| 103 | 114 | /> |
apps/web/src/components/BranchToolbarBranchSelector.tsx+19-6| ... | ... | @@ -49,6 +49,9 @@ interface BranchToolbarBranchSelectorProps { |
| 49 | 49 | threadId: ThreadId; |
| 50 | 50 | draftId?: DraftId; |
| 51 | 51 | envLocked: boolean; |
| 52 | effectiveEnvModeOverride?: "local" | "worktree"; | |
| 53 | activeThreadBranchOverride?: string | null; | |
| 54 | onActiveThreadBranchOverrideChange?: (branch: string | null) => void; | |
| 52 | 55 | onCheckoutPullRequestRequest?: (reference: string) => void; |
| 53 | 56 | onComposerFocusRequest?: () => void; |
| 54 | 57 | } |
| ... | ... | @@ -77,6 +80,9 @@ export function BranchToolbarBranchSelector({ |
| 77 | 80 | threadId, |
| 78 | 81 | draftId, |
| 79 | 82 | envLocked, |
| 83 | effectiveEnvModeOverride, | |
| 84 | activeThreadBranchOverride, | |
| 85 | onActiveThreadBranchOverrideChange, | |
| 80 | 86 | onCheckoutPullRequestRequest, |
| 81 | 87 | onComposerFocusRequest, |
| 82 | 88 | }: BranchToolbarBranchSelectorProps) { |
| ... | ... | @@ -108,16 +114,21 @@ export function BranchToolbarBranchSelector({ |
| 108 | 114 | const activeProject = useStore(activeProjectSelector); |
| 109 | 115 | |
| 110 | 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 | 121 | const activeWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; |
| 113 | 122 | const activeProjectCwd = activeProject?.cwd ?? null; |
| 114 | 123 | const branchCwd = activeWorktreePath ?? activeProjectCwd; |
| 115 | 124 | 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 | }); | |
| 121 | 132 | |
| 122 | 133 | // --------------------------------------------------------------------------- |
| 123 | 134 | // Thread branch mutation (colocated — only this component calls it) |
| ... | ... | @@ -146,6 +157,7 @@ export function BranchToolbarBranchSelector({ |
| 146 | 157 | }); |
| 147 | 158 | } |
| 148 | 159 | if (hasServerThread) { |
| 160 | onActiveThreadBranchOverrideChange?.(branch); | |
| 149 | 161 | setThreadBranchAction(threadRef, branch, worktreePath); |
| 150 | 162 | return; |
| 151 | 163 | } |
| ... | ... | @@ -167,6 +179,7 @@ export function BranchToolbarBranchSelector({ |
| 167 | 179 | serverSession, |
| 168 | 180 | activeWorktreePath, |
| 169 | 181 | hasServerThread, |
| 182 | onActiveThreadBranchOverrideChange, | |
| 170 | 183 | setThreadBranchAction, |
| 171 | 184 | setDraftThreadContext, |
| 172 | 185 | draftId, |
apps/web/src/components/BranchToolbarEnvModeSelector.tsx+3-2| ... | ... | @@ -4,6 +4,7 @@ import { memo, useMemo } from "react"; |
| 4 | 4 | import { |
| 5 | 5 | resolveCurrentWorkspaceLabel, |
| 6 | 6 | resolveEnvModeLabel, |
| 7 | resolveLockedWorkspaceLabel, | |
| 7 | 8 | type EnvMode, |
| 8 | 9 | } from "./BranchToolbar.logic"; |
| 9 | 10 | import { |
| ... | ... | @@ -43,12 +44,12 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe |
| 43 | 44 | {activeWorktreePath ? ( |
| 44 | 45 | <> |
| 45 | 46 | <FolderGitIcon className="size-3" /> |
| 46 | {resolveCurrentWorkspaceLabel(activeWorktreePath)} | |
| 47 | {resolveLockedWorkspaceLabel(activeWorktreePath)} | |
| 47 | 48 | </> |
| 48 | 49 | ) : ( |
| 49 | 50 | <> |
| 50 | 51 | <FolderIcon className="size-3" /> |
| 51 | {resolveCurrentWorkspaceLabel(activeWorktreePath)} | |
| 52 | {resolveLockedWorkspaceLabel(activeWorktreePath)} | |
| 52 | 53 | </> |
| 53 | 54 | )} |
| 54 | 55 | </span> |
apps/web/src/components/ChatView.browser.tsx+281| ... | ... | @@ -2286,6 +2286,287 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 2286 | 2286 | } |
| 2287 | 2287 | }); |
| 2288 | 2288 | |
| 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 | 2570 | it("shows the send state once bootstrap dispatch is in flight", async () => { |
| 2290 | 2571 | useTerminalStateStore.setState({ |
| 2291 | 2572 | terminalStateByThreadKey: {}, |
apps/web/src/components/ChatView.logic.test.ts+12| ... | ... | @@ -11,6 +11,7 @@ import { |
| 11 | 11 | deriveComposerSendState, |
| 12 | 12 | hasServerAcknowledgedLocalDispatch, |
| 13 | 13 | reconcileMountedTerminalThreadIds, |
| 14 | resolveSendEnvMode, | |
| 14 | 15 | shouldWriteThreadErrorToCurrentServerThread, |
| 15 | 16 | waitForStartedServerThread, |
| 16 | 17 | } from "./ChatView.logic"; |
| ... | ... | @@ -82,6 +83,17 @@ describe("buildExpiredTerminalContextToastCopy", () => { |
| 82 | 83 | }); |
| 83 | 84 | }); |
| 84 | 85 | |
| 86 | describe("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 | ||
| 85 | 97 | describe("reconcileMountedTerminalThreadIds", () => { |
| 86 | 98 | it("keeps previously mounted open threads and adds the active open thread", () => { |
| 87 | 99 | expect( |
apps/web/src/components/ChatView.logic.ts+6-6| ... | ... | @@ -8,7 +8,6 @@ import { |
| 8 | 8 | type TurnId, |
| 9 | 9 | } from "@t3tools/contracts"; |
| 10 | 10 | import { type ChatMessage, type SessionPhase, type Thread, type ThreadSession } from "../types"; |
| 11 | import { randomUUID } from "~/lib/utils"; | |
| 12 | 11 | import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore"; |
| 13 | 12 | import { Schema } from "effect"; |
| 14 | 13 | import { selectThreadByRef, useStore } from "../store"; |
| ... | ... | @@ -17,10 +16,10 @@ import { |
| 17 | 16 | stripInlineTerminalContextPlaceholders, |
| 18 | 17 | type TerminalContextDraft, |
| 19 | 18 | } from "../lib/terminalContext"; |
| 19 | import type { DraftThreadEnvMode } from "../composerDraftStore"; | |
| 20 | 20 | |
| 21 | 21 | export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project"; |
| 22 | 22 | export const MAX_HIDDEN_MOUNTED_TERMINAL_THREADS = 10; |
| 23 | const WORKTREE_BRANCH_PREFIX = "t3code"; | |
| 24 | 23 | |
| 25 | 24 | export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String); |
| 26 | 25 | |
| ... | ... | @@ -157,10 +156,11 @@ export function readFileAsDataUrl(file: File): Promise<string> { |
| 157 | 156 | }); |
| 158 | 157 | } |
| 159 | 158 | |
| 160 | export 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}`; | |
| 159 | export function resolveSendEnvMode(input: { | |
| 160 | requestedEnvMode: DraftThreadEnvMode; | |
| 161 | isGitRepo: boolean; | |
| 162 | }): DraftThreadEnvMode { | |
| 163 | return input.isGitRepo ? input.requestedEnvMode : "local"; | |
| 164 | 164 | } |
| 165 | 165 | |
| 166 | 166 | export function cloneComposerImageForRetry( |
apps/web/src/components/ChatView.tsx+58-8| ... | ... | @@ -91,6 +91,7 @@ import { |
| 91 | 91 | import { useTheme } from "../hooks/useTheme"; |
| 92 | 92 | import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; |
| 93 | 93 | import { useCommandPaletteStore } from "../commandPaletteStore"; |
| 94 | import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; | |
| 94 | 95 | import { BranchToolbar } from "./BranchToolbar"; |
| 95 | 96 | import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; |
| 96 | 97 | import PlanSidebar from "./PlanSidebar"; |
| ... | ... | @@ -143,7 +144,6 @@ import { |
| 143 | 144 | MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, |
| 144 | 145 | buildExpiredTerminalContextToastCopy, |
| 145 | 146 | buildLocalDraftThread, |
| 146 | buildTemporaryWorktreeBranchName, | |
| 147 | 147 | collectUserMessageBlobPreviewUrls, |
| 148 | 148 | createLocalDispatchSnapshot, |
| 149 | 149 | deriveComposerSendState, |
| ... | ... | @@ -156,6 +156,7 @@ import { |
| 156 | 156 | deriveLockedProvider, |
| 157 | 157 | readFileAsDataUrl, |
| 158 | 158 | reconcileMountedTerminalThreadIds, |
| 159 | resolveSendEnvMode, | |
| 159 | 160 | revokeBlobPreviewUrl, |
| 160 | 161 | revokeUserMessagePreviewUrls, |
| 161 | 162 | shouldWriteThreadErrorToCurrentServerThread, |
| ... | ... | @@ -688,6 +689,9 @@ export default function ChatView(props: ChatViewProps) { |
| 688 | 689 | const [attachmentPreviewHandoffByMessageId, setAttachmentPreviewHandoffByMessageId] = useState< |
| 689 | 690 | Record<string, string[]> |
| 690 | 691 | >({}); |
| 692 | const [pendingServerThreadEnvMode, setPendingServerThreadEnvMode] = | |
| 693 | useState<DraftThreadEnvMode | null>(null); | |
| 694 | const [pendingServerThreadBranch, setPendingServerThreadBranch] = useState<string | null>(); | |
| 691 | 695 | const [lastInvokedScriptByProjectId, setLastInvokedScriptByProjectId] = useLocalStorage( |
| 692 | 696 | LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, |
| 693 | 697 | {}, |
| ... | ... | @@ -2043,11 +2047,42 @@ export default function ChatView(props: ChatViewProps) { |
| 2043 | 2047 | }, []); |
| 2044 | 2048 | |
| 2045 | 2049 | const activeWorktreePath = activeThread?.worktreePath ?? null; |
| 2046 | const envMode: DraftThreadEnvMode = resolveEffectiveEnvMode({ | |
| 2050 | const derivedEnvMode: DraftThreadEnvMode = resolveEffectiveEnvMode({ | |
| 2047 | 2051 | activeWorktreePath, |
| 2048 | 2052 | hasServerThread: isServerThread, |
| 2049 | 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]); | |
| 2051 | 2086 | |
| 2052 | 2087 | useEffect(() => { |
| 2053 | 2088 | if (!activeThreadId) { |
| ... | ... | @@ -2360,15 +2395,15 @@ export default function ChatView(props: ChatViewProps) { |
| 2360 | 2395 | const threadIdForSend = activeThread.id; |
| 2361 | 2396 | const isFirstMessage = !isServerThread || activeThread.messages.length === 0; |
| 2362 | 2397 | const baseBranchForWorktree = |
| 2363 | isFirstMessage && envMode === "worktree" && !activeThread.worktreePath | |
| 2364 | ? activeThread.branch | |
| 2398 | isFirstMessage && sendEnvMode === "worktree" && !activeThread.worktreePath | |
| 2399 | ? activeThreadBranch | |
| 2365 | 2400 | : null; |
| 2366 | 2401 | |
| 2367 | 2402 | // In worktree mode, require an explicit base branch so we don't silently |
| 2368 | 2403 | // fall back to local execution when branch selection is missing. |
| 2369 | 2404 | const shouldCreateWorktree = |
| 2370 | isFirstMessage && envMode === "worktree" && !activeThread.worktreePath; | |
| 2371 | if (shouldCreateWorktree && !activeThread.branch) { | |
| 2405 | isFirstMessage && sendEnvMode === "worktree" && !activeThread.worktreePath; | |
| 2406 | if (shouldCreateWorktree && !activeThreadBranch) { | |
| 2372 | 2407 | setThreadError(threadIdForSend, "Select a base branch before sending in New worktree mode."); |
| 2373 | 2408 | return; |
| 2374 | 2409 | } |
| ... | ... | @@ -2507,7 +2542,7 @@ export default function ChatView(props: ChatViewProps) { |
| 2507 | 2542 | modelSelection: threadCreateModelSelection, |
| 2508 | 2543 | runtimeMode, |
| 2509 | 2544 | interactionMode, |
| 2510 | branch: activeThread.branch, | |
| 2545 | branch: activeThreadBranch, | |
| 2511 | 2546 | worktreePath: activeThread.worktreePath, |
| 2512 | 2547 | createdAt: activeThread.createdAt, |
| 2513 | 2548 | }, |
| ... | ... | @@ -2959,7 +2994,7 @@ export default function ChatView(props: ChatViewProps) { |
| 2959 | 2994 | modelSelection: nextThreadModelSelection, |
| 2960 | 2995 | runtimeMode, |
| 2961 | 2996 | interactionMode: "default", |
| 2962 | branch: activeThread.branch, | |
| 2997 | branch: activeThreadBranch, | |
| 2963 | 2998 | worktreePath: activeThread.worktreePath, |
| 2964 | 2999 | createdAt, |
| 2965 | 3000 | }) |
| ... | ... | @@ -3018,6 +3053,7 @@ export default function ChatView(props: ChatViewProps) { |
| 3018 | 3053 | }, [ |
| 3019 | 3054 | activeProject, |
| 3020 | 3055 | activeProposedPlan, |
| 3056 | activeThreadBranch, | |
| 3021 | 3057 | activeThread, |
| 3022 | 3058 | beginLocalDispatch, |
| 3023 | 3059 | isConnecting, |
| ... | ... | @@ -3066,6 +3102,11 @@ export default function ChatView(props: ChatViewProps) { |
| 3066 | 3102 | ); |
| 3067 | 3103 | const onEnvModeChange = useCallback( |
| 3068 | 3104 | (mode: DraftThreadEnvMode) => { |
| 3105 | if (canOverrideServerThreadEnvMode) { | |
| 3106 | setPendingServerThreadEnvMode(mode); | |
| 3107 | scheduleComposerFocus(); | |
| 3108 | return; | |
| 3109 | } | |
| 3069 | 3110 | if (isLocalDraftThread) { |
| 3070 | 3111 | setDraftThreadContext(composerDraftTarget, { |
| 3071 | 3112 | envMode: mode, |
| ... | ... | @@ -3075,9 +3116,11 @@ export default function ChatView(props: ChatViewProps) { |
| 3075 | 3116 | scheduleComposerFocus(); |
| 3076 | 3117 | }, |
| 3077 | 3118 | [ |
| 3119 | canOverrideServerThreadEnvMode, | |
| 3078 | 3120 | composerDraftTarget, |
| 3079 | 3121 | draftThread?.worktreePath, |
| 3080 | 3122 | isLocalDraftThread, |
| 3123 | setPendingServerThreadEnvMode, | |
| 3081 | 3124 | scheduleComposerFocus, |
| 3082 | 3125 | setDraftThreadContext, |
| 3083 | 3126 | ], |
| ... | ... | @@ -3300,6 +3343,13 @@ export default function ChatView(props: ChatViewProps) { |
| 3300 | 3343 | threadId={activeThread.id} |
| 3301 | 3344 | {...(routeKind === "draft" && draftId ? { draftId } : {})} |
| 3302 | 3345 | onEnvModeChange={onEnvModeChange} |
| 3346 | {...(canOverrideServerThreadEnvMode ? { effectiveEnvModeOverride: envMode } : {})} | |
| 3347 | {...(canOverrideServerThreadEnvMode | |
| 3348 | ? { | |
| 3349 | activeThreadBranchOverride: activeThreadBranch, | |
| 3350 | onActiveThreadBranchOverrideChange: setPendingServerThreadBranch, | |
| 3351 | } | |
| 3352 | : {})} | |
| 3303 | 3353 | envLocked={envLocked} |
| 3304 | 3354 | onComposerFocusRequest={scheduleComposerFocus} |
| 3305 | 3355 | {...(canCheckoutPullRequestIntoThread |
packages/shared/src/git.test.ts+21| ... | ... | @@ -3,8 +3,11 @@ import { describe, expect, it } from "vitest"; |
| 3 | 3 | |
| 4 | 4 | import { |
| 5 | 5 | applyGitStatusStreamEvent, |
| 6 | buildTemporaryWorktreeBranchName, | |
| 7 | isTemporaryWorktreeBranch, | |
| 6 | 8 | normalizeGitRemoteUrl, |
| 7 | 9 | parseGitHubRepositoryNameWithOwnerFromRemoteUrl, |
| 10 | WORKTREE_BRANCH_PREFIX, | |
| 8 | 11 | } from "./git"; |
| 9 | 12 | |
| 10 | 13 | describe("normalizeGitRemoteUrl", () => { |
| ... | ... | @@ -50,6 +53,24 @@ describe("parseGitHubRepositoryNameWithOwnerFromRemoteUrl", () => { |
| 50 | 53 | }); |
| 51 | 54 | }); |
| 52 | 55 | |
| 56 | describe("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 | ||
| 53 | 74 | describe("applyGitStatusStreamEvent", () => { |
| 54 | 75 | it("treats a remote-only update as a repository when local state is missing", () => { |
| 55 | 76 | const remote: GitStatusRemoteResult = { |
packages/shared/src/git.ts+14| ... | ... | @@ -6,6 +6,11 @@ import type { |
| 6 | 6 | GitStatusResult, |
| 7 | 7 | GitStatusStreamEvent, |
| 8 | 8 | } from "@t3tools/contracts"; |
| 9 | import * as Effect from "effect/Effect"; | |
| 10 | import * as Random from "effect/Random"; | |
| 11 | ||
| 12 | export const WORKTREE_BRANCH_PREFIX = "t3code"; | |
| 13 | const TEMP_WORKTREE_BRANCH_PATTERN = new RegExp(`^${WORKTREE_BRANCH_PREFIX}\\/[0-9a-f]{8}$`); | |
| 9 | 14 | |
| 10 | 15 | /** |
| 11 | 16 | * Sanitize an arbitrary string into a valid, lowercase git branch fragment. |
| ... | ... | @@ -80,6 +85,15 @@ export function deriveLocalBranchNameFromRemoteRef(branchName: string): string { |
| 80 | 85 | return branchName.slice(firstSeparatorIndex + 1); |
| 81 | 86 | } |
| 82 | 87 | |
| 88 | export function buildTemporaryWorktreeBranchName(): string { | |
| 89 | const token = Effect.runSync(Random.nextUUIDv4).replace(/-/g, "").slice(0, 8).toLowerCase(); | |
| 90 | return `${WORKTREE_BRANCH_PREFIX}/${token}`; | |
| 91 | } | |
| 92 | ||
| 93 | export function isTemporaryWorktreeBranch(branch: string): boolean { | |
| 94 | return TEMP_WORKTREE_BRANCH_PATTERN.test(branch.trim().toLowerCase()); | |
| 95 | } | |
| 96 | ||
| 83 | 97 | /** |
| 84 | 98 | * Normalize a git remote URL into a stable comparison key. |
| 85 | 99 | */ |