authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-01 22:26:54-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-01 22:36:35-08:00
log4ab5fca4ca9d8fb12b1442ed46107e15c4275bf2
tree9fce1dc02d018a4326cdc5f7d3392fb1da46d1de
parent69a4fa82155f05637f73b3d2f61a5cdf9366b4b9

Preserve draft env mode across thread and branch context updates

- Track and persist `envMode` in composer draft thread state - Keep worktree mode for local drafts even when `worktreePath` is null - Update branch toolbar/chat/sidebar to use effective draft env mode - Add tests for env mode persistence and undefined option merges

5 files changed, 111 insertions(+), 27 deletions(-)

apps/web/src/components/BranchToolbar.tsx+14-7
......@@ -59,6 +59,10 @@ export default function BranchToolbar({
5959 const activeWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null;
6060 const branchCwd = activeWorktreePath ?? activeProject?.cwd ?? null;
6161 const hasServerThread = serverThread !== undefined;
62 const effectiveEnvMode: "local" | "worktree" =
63 activeWorktreePath || (!hasServerThread && draftThread?.envMode === "worktree")
64 ? "worktree"
65 : envMode;
6266
6367 // ── Queries ───────────────────────────────────────────────────────────
6468
......@@ -68,7 +72,7 @@ export default function BranchToolbar({
6872 const branchNames = branches.map((branch) => branch.name);
6973 const branchByName = new Map(branches.map((branch) => [branch.name, branch]));
7074 const trimmedBranchQuery = branchQuery.trim();
71 const canCreateBranch = envMode === "local" && trimmedBranchQuery.length > 0;
75 const canCreateBranch = effectiveEnvMode === "local" && trimmedBranchQuery.length > 0;
7276 const hasExactBranchMatch = branchByName.has(trimmedBranchQuery);
7377 const createBranchItemValue = canCreateBranch
7478 ? `__create_new_branch__:${trimmedBranchQuery}`
......@@ -93,7 +97,7 @@ export default function BranchToolbar({
9397 const syncedBranch = deriveSyncedLocalBranch({
9498 activeThreadId,
9599 activeWorktreePath,
96 envMode,
100 envMode: effectiveEnvMode,
97101 activeThreadBranch,
98102 queryBranches,
99103 });
......@@ -127,7 +131,7 @@ export default function BranchToolbar({
127131 activeWorktreePath,
128132 activeThreadBranch,
129133 queryBranches,
130 envMode,
134 effectiveEnvMode,
131135 dispatch,
132136 hasServerThread,
133137 setDraftThreadContext,
......@@ -178,6 +182,7 @@ export default function BranchToolbar({
178182 setDraftThreadContext(threadId, {
179183 branch,
180184 worktreePath,
185 envMode: effectiveEnvMode,
181186 });
182187 };
183188
......@@ -186,7 +191,7 @@ export default function BranchToolbar({
186191 if (!api || !activeThreadId || !branchCwd) return;
187192
188193 // For new worktree mode, selecting a branch picks the base branch.
189 if (envMode === "worktree" && !envLocked && !activeWorktreePath) {
194 if (effectiveEnvMode === "worktree" && !envLocked && !activeWorktreePath) {
190195 setThreadError(null);
191196 setThreadBranch(branch.name, null);
192197 setIsBranchMenuOpen(false);
......@@ -265,9 +270,11 @@ export default function BranchToolbar({
265270 variant="ghost"
266271 className="text-muted-foreground/70 hover:text-foreground/80"
267272 size="xs"
268 onClick={() => onEnvModeChange(envMode === "local" ? "worktree" : "local")}
273 onClick={() =>
274 onEnvModeChange(effectiveEnvMode === "local" ? "worktree" : "local")
275 }
269276 >
270 {envMode === "worktree" ? "New worktree" : "Local"}
277 {effectiveEnvMode === "worktree" ? "New worktree" : "Local"}
271278 </Button>
272279 )}
273280 </div>
......@@ -286,7 +293,7 @@ export default function BranchToolbar({
286293 >
287294 <span className="max-w-[240px] truncate">
288295 {activeThreadBranch
289 ? envMode === "worktree" && !activeWorktreePath
296 ? effectiveEnvMode === "worktree" && !activeWorktreePath
290297 ? `From ${activeThreadBranch}`
291298 : activeThreadBranch
292299 : "Select branch"}
apps/web/src/components/ChatView.tsx+18-5
......@@ -114,6 +114,7 @@ import { readNativeApi } from "~/nativeApi";
114114import { getAppModelOptions, useAppSettings } from "../appSettings";
115115import {
116116 type ComposerImageAttachment,
117 type DraftThreadEnvMode,
117118 type DraftThreadState,
118119 type PersistedComposerImageAttachment,
119120 useComposerDraftStore,
......@@ -465,6 +466,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
465466 );
466467 const clearComposerDraftContent = useComposerDraftStore((store) => store.clearComposerContent);
467468 const clearDraftThread = useComposerDraftStore((store) => store.clearDraftThread);
469 const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);
468470 const draftThread = useComposerDraftStore((store) => store.draftThreadsByThreadId[threadId] ?? null);
469471 const promptRef = useRef(prompt);
470472 const [isDragOverComposer, setIsDragOverComposer] = useState(false);
......@@ -476,7 +478,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
476478 const [sendPhase, setSendPhase] = useState<SendPhase>("idle");
477479 const [isConnecting, _setIsConnecting] = useState(false);
478480 const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false);
479 const [envMode, setEnvMode] = useState<"local" | "worktree">("local");
481 const [envMode, setEnvMode] = useState<DraftThreadEnvMode>("local");
480482 const [isSwitchingRuntimeMode, setIsSwitchingRuntimeMode] = useState(false);
481483 const [respondingRequestIds, setRespondingRequestIds] = useState<ApprovalRequestId[]>([]);
482484 const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>({});
......@@ -1386,8 +1388,16 @@ export default function ChatView({ threadId }: ChatViewProps) {
13861388
13871389 useEffect(() => {
13881390 if (!activeThread?.id) return;
1389 setEnvMode(activeWorktreePath ? "worktree" : "local");
1390 }, [activeThread?.id, activeWorktreePath]);
1391 if (activeWorktreePath) {
1392 setEnvMode("worktree");
1393 return;
1394 }
1395 if (isLocalDraftThread) {
1396 setEnvMode(draftThread?.envMode ?? "local");
1397 return;
1398 }
1399 setEnvMode("local");
1400 }, [activeThread?.id, activeWorktreePath, draftThread?.envMode, isLocalDraftThread]);
13911401
13921402 // Auto-resize textarea
13931403 useEffect(() => {
......@@ -1973,11 +1983,14 @@ export default function ChatView({ threadId }: ChatViewProps) {
19731983 [scheduleComposerFocus, setComposerDraftEffort, threadId],
19741984 );
19751985 const onEnvModeChange = useCallback(
1976 (mode: "local" | "worktree") => {
1986 (mode: DraftThreadEnvMode) => {
19771987 setEnvMode(mode);
1988 if (isLocalDraftThread) {
1989 setDraftThreadContext(threadId, { envMode: mode });
1990 }
19781991 scheduleComposerFocus();
19791992 },
1980 [scheduleComposerFocus],
1993 [isLocalDraftThread, scheduleComposerFocus, setDraftThreadContext, threadId],
19811994 );
19821995
19831996 const applyPromptReplacement = useCallback(
apps/web/src/components/Sidebar.tsx+12-4
......@@ -20,7 +20,7 @@ import { derivePendingApprovals } from "../session-logic";
2020import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/gitReactQuery";
2121import { serverConfigQueryOptions } from "../lib/serverReactQuery";
2222import { readNativeApi } from "../nativeApi";
23import { useComposerDraftStore } from "../composerDraftStore";
23import { type DraftThreadEnvMode, useComposerDraftStore } from "../composerDraftStore";
2424import { toastManager } from "./ui/toast";
2525import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible";
2626import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
......@@ -345,17 +345,20 @@ export default function Sidebar() {
345345 options?: {
346346 branch?: string | null;
347347 worktreePath?: string | null;
348 envMode?: DraftThreadEnvMode;
348349 },
349350 ): Promise<void> => {
350351 const hasBranchOption = options?.branch !== undefined;
351352 const hasWorktreePathOption = options?.worktreePath !== undefined;
353 const hasEnvModeOption = options?.envMode !== undefined;
352354 const storedDraftThread = getDraftThreadByProjectId(projectId);
353355 if (storedDraftThread) {
354356 return (async () => {
355 if (hasBranchOption || hasWorktreePathOption) {
357 if (hasBranchOption || hasWorktreePathOption || hasEnvModeOption) {
356358 setDraftThreadContext(storedDraftThread.threadId, {
357359 ...(hasBranchOption ? { branch: options?.branch ?? null } : {}),
358360 ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}),
361 ...(hasEnvModeOption ? { envMode: options?.envMode } : {}),
359362 });
360363 }
361364 setProjectDraftThreadId(projectId, storedDraftThread.threadId);
......@@ -372,10 +375,11 @@ export default function Sidebar() {
372375
373376 const activeDraftThread = routeThreadId ? getDraftThread(routeThreadId) : null;
374377 if (activeDraftThread && routeThreadId && activeDraftThread.projectId === projectId) {
375 if (hasBranchOption || hasWorktreePathOption) {
378 if (hasBranchOption || hasWorktreePathOption || hasEnvModeOption) {
376379 setDraftThreadContext(routeThreadId, {
377380 ...(hasBranchOption ? { branch: options?.branch ?? null } : {}),
378381 ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}),
382 ...(hasEnvModeOption ? { envMode: options?.envMode } : {}),
379383 });
380384 }
381385 setProjectDraftThreadId(projectId, routeThreadId);
......@@ -388,6 +392,7 @@ export default function Sidebar() {
388392 createdAt,
389393 branch: options?.branch ?? null,
390394 worktreePath: options?.worktreePath ?? null,
395 envMode: options?.envMode ?? "local",
391396 });
392397
393398 await navigate({
......@@ -684,6 +689,9 @@ export default function Sidebar() {
684689 void handleNewThread(projectId, {
685690 branch: activeThread?.branch ?? activeDraftThread?.branch ?? null,
686691 worktreePath: activeThread?.worktreePath ?? activeDraftThread?.worktreePath ?? null,
692 envMode:
693 activeDraftThread?.envMode ??
694 (activeThread?.worktreePath ? "worktree" : "local"),
687695 });
688696 };
689697
......@@ -691,7 +699,7 @@ export default function Sidebar() {
691699 return () => {
692700 window.removeEventListener("keydown", onWindowKeyDown);
693701 };
694 }, [handleNewThread, keybindings, routeThreadId, state.projects, state.threads]);
702 }, [getDraftThread, handleNewThread, keybindings, routeThreadId, state.projects, state.threads]);
695703
696704 const onCreateThreadClick = () => {
697705 if (state.projects.length === 0) {
apps/web/src/composerDraftStore.test.ts+30
......@@ -183,12 +183,14 @@ describe("composerDraftStore project draft thread mapping", () => {
183183 projectId,
184184 branch: "feature/test",
185185 worktreePath: "/tmp/worktree-test",
186 envMode: "worktree",
186187 createdAt: "2026-01-01T00:00:00.000Z",
187188 });
188189 expect(useComposerDraftStore.getState().getDraftThread(threadId)).toEqual({
189190 projectId,
190191 branch: "feature/test",
191192 worktreePath: "/tmp/worktree-test",
193 envMode: "worktree",
192194 createdAt: "2026-01-01T00:00:00.000Z",
193195 });
194196 });
......@@ -273,6 +275,7 @@ describe("composerDraftStore project draft thread mapping", () => {
273275 projectId,
274276 branch: "feature/next",
275277 worktreePath: "/tmp/feature-next",
278 envMode: "worktree",
276279 });
277280 });
278281
......@@ -295,6 +298,33 @@ describe("composerDraftStore project draft thread mapping", () => {
295298 projectId,
296299 branch: "main",
297300 worktreePath: "/tmp/main-worktree",
301 envMode: "worktree",
302 });
303 });
304
305 it("preserves worktree env mode without a worktree path", () => {
306 const store = useComposerDraftStore.getState();
307 store.setProjectDraftThreadId(projectId, threadId, {
308 branch: "feature/base",
309 worktreePath: null,
310 envMode: "worktree",
311 });
312 const runtimeUndefinedOptions = {
313 branch: undefined,
314 worktreePath: undefined,
315 envMode: undefined,
316 } as unknown as {
317 branch?: string | null;
318 worktreePath?: string | null;
319 envMode?: "local" | "worktree";
320 };
321 store.setProjectDraftThreadId(projectId, threadId, runtimeUndefinedOptions);
322
323 expect(useComposerDraftStore.getState().getDraftThread(threadId)).toMatchObject({
324 projectId,
325 branch: "feature/base",
326 worktreePath: null,
327 envMode: "worktree",
298328 });
299329 });
300330});
apps/web/src/composerDraftStore.ts+37-11
......@@ -11,6 +11,7 @@ import { create } from "zustand";
1111import { createJSONStorage, persist } from "zustand/middleware";
1212
1313export const COMPOSER_DRAFT_STORAGE_KEY = "t3code:composer-drafts:v1";
14export type DraftThreadEnvMode = "local" | "worktree";
1415
1516export interface PersistedComposerImageAttachment {
1617 id: string;
......@@ -38,6 +39,7 @@ interface PersistedDraftThreadState {
3839 createdAt: string;
3940 branch: string | null;
4041 worktreePath: string | null;
42 envMode: DraftThreadEnvMode;
4143}
4244
4345interface PersistedComposerDraftStoreState {
......@@ -61,6 +63,7 @@ export interface DraftThreadState {
6163 createdAt: string;
6264 branch: string | null;
6365 worktreePath: string | null;
66 envMode: DraftThreadEnvMode;
6467}
6568
6669interface ProjectDraftThread extends DraftThreadState {
......@@ -80,6 +83,7 @@ interface ComposerDraftStoreState {
8083 branch?: string | null;
8184 worktreePath?: string | null;
8285 createdAt?: string;
86 envMode?: DraftThreadEnvMode;
8387 },
8488 ) => void;
8589 setDraftThreadContext: (
......@@ -89,6 +93,7 @@ interface ComposerDraftStoreState {
8993 worktreePath?: string | null;
9094 projectId?: ProjectId;
9195 createdAt?: string;
96 envMode?: DraftThreadEnvMode;
9297 },
9398 ) => void;
9499 clearProjectDraftThreadId: (projectId: ProjectId) => void;
......@@ -203,6 +208,16 @@ function normalizePersistedAttachment(value: unknown): PersistedComposerImageAtt
203208 };
204209}
205210
211function normalizeDraftThreadEnvMode(
212 value: unknown,
213 fallbackWorktreePath: string | null,
214): DraftThreadEnvMode {
215 if (value === "local" || value === "worktree") {
216 return value;
217 }
218 return fallbackWorktreePath ? "worktree" : "local";
219}
220
206221function normalizePersistedComposerDraftState(value: unknown): PersistedComposerDraftStoreState {
207222 if (!value || typeof value !== "object") {
208223 return EMPTY_PERSISTED_DRAFT_STORE_STATE;
......@@ -227,6 +242,7 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer
227242 const createdAt = candidateDraftThread.createdAt;
228243 const branch = candidateDraftThread.branch;
229244 const worktreePath = candidateDraftThread.worktreePath;
245 const normalizedWorktreePath = typeof worktreePath === "string" ? worktreePath : null;
230246 if (typeof projectId !== "string" || projectId.length === 0) {
231247 continue;
232248 }
......@@ -237,7 +253,8 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer
237253 ? createdAt
238254 : new Date().toISOString(),
239255 branch: typeof branch === "string" ? branch : null,
240 worktreePath: typeof worktreePath === "string" ? worktreePath : null,
256 worktreePath: normalizedWorktreePath,
257 envMode: normalizeDraftThreadEnvMode(candidateDraftThread.envMode, normalizedWorktreePath),
241258 };
242259 }
243260 }
......@@ -263,6 +280,7 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer
263280 createdAt: new Date().toISOString(),
264281 branch: null,
265282 worktreePath: null,
283 envMode: "local",
266284 };
267285 } else if (draftThreadsByThreadId[threadId as ThreadId]?.projectId !== projectId) {
268286 draftThreadsByThreadId[threadId as ThreadId] = {
......@@ -456,6 +474,10 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
456474 set((state) => {
457475 const existingThread = state.draftThreadsByThreadId[threadId];
458476 const previousThreadIdForProject = state.projectDraftThreadIdByProjectId[projectId];
477 const nextWorktreePath =
478 options?.worktreePath === undefined
479 ? (existingThread?.worktreePath ?? null)
480 : (options.worktreePath ?? null);
459481 const nextDraftThread: DraftThreadState = {
460482 projectId,
461483 createdAt: options?.createdAt ?? existingThread?.createdAt ?? new Date().toISOString(),
......@@ -463,10 +485,10 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
463485 options?.branch === undefined
464486 ? (existingThread?.branch ?? null)
465487 : (options.branch ?? null),
466 worktreePath:
467 options?.worktreePath === undefined
468 ? (existingThread?.worktreePath ?? null)
469 : (options.worktreePath ?? null),
488 worktreePath: nextWorktreePath,
489 envMode:
490 options?.envMode ??
491 (nextWorktreePath ? "worktree" : (existingThread?.envMode ?? "local")),
470492 };
471493 const hasSameProjectMapping = previousThreadIdForProject === threadId;
472494 const hasSameDraftThread =
......@@ -474,7 +496,8 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
474496 existingThread.projectId === nextDraftThread.projectId &&
475497 existingThread.createdAt === nextDraftThread.createdAt &&
476498 existingThread.branch === nextDraftThread.branch &&
477 existingThread.worktreePath === nextDraftThread.worktreePath;
499 existingThread.worktreePath === nextDraftThread.worktreePath &&
500 existingThread.envMode === nextDraftThread.envMode;
478501 if (hasSameProjectMapping && hasSameDraftThread) {
479502 return state;
480503 }
......@@ -518,6 +541,8 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
518541 if (nextProjectId.length === 0) {
519542 return state;
520543 }
544 const nextWorktreePath =
545 options.worktreePath === undefined ? existing.worktreePath : (options.worktreePath ?? null);
521546 const nextDraftThread: DraftThreadState = {
522547 projectId: nextProjectId,
523548 createdAt:
......@@ -525,16 +550,17 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
525550 ? existing.createdAt
526551 : options.createdAt || existing.createdAt,
527552 branch: options.branch === undefined ? existing.branch : (options.branch ?? null),
528 worktreePath:
529 options.worktreePath === undefined
530 ? existing.worktreePath
531 : (options.worktreePath ?? null),
553 worktreePath: nextWorktreePath,
554 envMode:
555 options.envMode ??
556 (nextWorktreePath ? "worktree" : (existing.envMode ?? "local")),
532557 };
533558 const isUnchanged =
534559 nextDraftThread.projectId === existing.projectId &&
535560 nextDraftThread.createdAt === existing.createdAt &&
536561 nextDraftThread.branch === existing.branch &&
537 nextDraftThread.worktreePath === existing.worktreePath;
562 nextDraftThread.worktreePath === existing.worktreePath &&
563 nextDraftThread.envMode === existing.envMode;
538564 if (isUnchanged) {
539565 return state;
540566 }