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({...@@ -59,6 +59,10 @@ export default function BranchToolbar({
59 const activeWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null;59 const activeWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null;
60 const branchCwd = activeWorktreePath ?? activeProject?.cwd ?? null;60 const branchCwd = activeWorktreePath ?? activeProject?.cwd ?? null;
61 const hasServerThread = serverThread !== undefined;61 const hasServerThread = serverThread !== undefined;
62 const effectiveEnvMode: "local" | "worktree" =
63 activeWorktreePath || (!hasServerThread && draftThread?.envMode === "worktree")
64 ? "worktree"
65 : envMode;
6266
63 // ── Queries ───────────────────────────────────────────────────────────67 // ── Queries ───────────────────────────────────────────────────────────
6468
...@@ -68,7 +72,7 @@ export default function BranchToolbar({...@@ -68,7 +72,7 @@ export default function BranchToolbar({
68 const branchNames = branches.map((branch) => branch.name);72 const branchNames = branches.map((branch) => branch.name);
69 const branchByName = new Map(branches.map((branch) => [branch.name, branch]));73 const branchByName = new Map(branches.map((branch) => [branch.name, branch]));
70 const trimmedBranchQuery = branchQuery.trim();74 const trimmedBranchQuery = branchQuery.trim();
71 const canCreateBranch = envMode === "local" && trimmedBranchQuery.length > 0;75 const canCreateBranch = effectiveEnvMode === "local" && trimmedBranchQuery.length > 0;
72 const hasExactBranchMatch = branchByName.has(trimmedBranchQuery);76 const hasExactBranchMatch = branchByName.has(trimmedBranchQuery);
73 const createBranchItemValue = canCreateBranch77 const createBranchItemValue = canCreateBranch
74 ? `__create_new_branch__:${trimmedBranchQuery}`78 ? `__create_new_branch__:${trimmedBranchQuery}`
...@@ -93,7 +97,7 @@ export default function BranchToolbar({...@@ -93,7 +97,7 @@ export default function BranchToolbar({
93 const syncedBranch = deriveSyncedLocalBranch({97 const syncedBranch = deriveSyncedLocalBranch({
94 activeThreadId,98 activeThreadId,
95 activeWorktreePath,99 activeWorktreePath,
96 envMode,100 envMode: effectiveEnvMode,
97 activeThreadBranch,101 activeThreadBranch,
98 queryBranches,102 queryBranches,
99 });103 });
...@@ -127,7 +131,7 @@ export default function BranchToolbar({...@@ -127,7 +131,7 @@ export default function BranchToolbar({
127 activeWorktreePath,131 activeWorktreePath,
128 activeThreadBranch,132 activeThreadBranch,
129 queryBranches,133 queryBranches,
130 envMode,134 effectiveEnvMode,
131 dispatch,135 dispatch,
132 hasServerThread,136 hasServerThread,
133 setDraftThreadContext,137 setDraftThreadContext,
...@@ -178,6 +182,7 @@ export default function BranchToolbar({...@@ -178,6 +182,7 @@ export default function BranchToolbar({
178 setDraftThreadContext(threadId, {182 setDraftThreadContext(threadId, {
179 branch,183 branch,
180 worktreePath,184 worktreePath,
185 envMode: effectiveEnvMode,
181 });186 });
182 };187 };
183188
...@@ -186,7 +191,7 @@ export default function BranchToolbar({...@@ -186,7 +191,7 @@ export default function BranchToolbar({
186 if (!api || !activeThreadId || !branchCwd) return;191 if (!api || !activeThreadId || !branchCwd) return;
187192
188 // For new worktree mode, selecting a branch picks the base branch.193 // For new worktree mode, selecting a branch picks the base branch.
189 if (envMode === "worktree" && !envLocked && !activeWorktreePath) {194 if (effectiveEnvMode === "worktree" && !envLocked && !activeWorktreePath) {
190 setThreadError(null);195 setThreadError(null);
191 setThreadBranch(branch.name, null);196 setThreadBranch(branch.name, null);
192 setIsBranchMenuOpen(false);197 setIsBranchMenuOpen(false);
...@@ -265,9 +270,11 @@ export default function BranchToolbar({...@@ -265,9 +270,11 @@ export default function BranchToolbar({
265 variant="ghost"270 variant="ghost"
266 className="text-muted-foreground/70 hover:text-foreground/80"271 className="text-muted-foreground/70 hover:text-foreground/80"
267 size="xs"272 size="xs"
268 onClick={() => onEnvModeChange(envMode === "local" ? "worktree" : "local")}273 onClick={() =>
274 onEnvModeChange(effectiveEnvMode === "local" ? "worktree" : "local")
275 }
269 >276 >
270 {envMode === "worktree" ? "New worktree" : "Local"}277 {effectiveEnvMode === "worktree" ? "New worktree" : "Local"}
271 </Button>278 </Button>
272 )}279 )}
273 </div>280 </div>
...@@ -286,7 +293,7 @@ export default function BranchToolbar({...@@ -286,7 +293,7 @@ export default function BranchToolbar({
286 >293 >
287 <span className="max-w-[240px] truncate">294 <span className="max-w-[240px] truncate">
288 {activeThreadBranch295 {activeThreadBranch
289 ? envMode === "worktree" && !activeWorktreePath296 ? effectiveEnvMode === "worktree" && !activeWorktreePath
290 ? `From ${activeThreadBranch}`297 ? `From ${activeThreadBranch}`
291 : activeThreadBranch298 : activeThreadBranch
292 : "Select branch"}299 : "Select branch"}
apps/web/src/components/ChatView.tsx+18-5
...@@ -114,6 +114,7 @@ import { readNativeApi } from "~/nativeApi";...@@ -114,6 +114,7 @@ import { readNativeApi } from "~/nativeApi";
114import { getAppModelOptions, useAppSettings } from "../appSettings";114import { getAppModelOptions, useAppSettings } from "../appSettings";
115import {115import {
116 type ComposerImageAttachment,116 type ComposerImageAttachment,
117 type DraftThreadEnvMode,
117 type DraftThreadState,118 type DraftThreadState,
118 type PersistedComposerImageAttachment,119 type PersistedComposerImageAttachment,
119 useComposerDraftStore,120 useComposerDraftStore,
...@@ -465,6 +466,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -465,6 +466,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
465 );466 );
466 const clearComposerDraftContent = useComposerDraftStore((store) => store.clearComposerContent);467 const clearComposerDraftContent = useComposerDraftStore((store) => store.clearComposerContent);
467 const clearDraftThread = useComposerDraftStore((store) => store.clearDraftThread);468 const clearDraftThread = useComposerDraftStore((store) => store.clearDraftThread);
469 const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);
468 const draftThread = useComposerDraftStore((store) => store.draftThreadsByThreadId[threadId] ?? null);470 const draftThread = useComposerDraftStore((store) => store.draftThreadsByThreadId[threadId] ?? null);
469 const promptRef = useRef(prompt);471 const promptRef = useRef(prompt);
470 const [isDragOverComposer, setIsDragOverComposer] = useState(false);472 const [isDragOverComposer, setIsDragOverComposer] = useState(false);
...@@ -476,7 +478,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -476,7 +478,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
476 const [sendPhase, setSendPhase] = useState<SendPhase>("idle");478 const [sendPhase, setSendPhase] = useState<SendPhase>("idle");
477 const [isConnecting, _setIsConnecting] = useState(false);479 const [isConnecting, _setIsConnecting] = useState(false);
478 const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false);480 const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false);
479 const [envMode, setEnvMode] = useState<"local" | "worktree">("local");481 const [envMode, setEnvMode] = useState<DraftThreadEnvMode>("local");
480 const [isSwitchingRuntimeMode, setIsSwitchingRuntimeMode] = useState(false);482 const [isSwitchingRuntimeMode, setIsSwitchingRuntimeMode] = useState(false);
481 const [respondingRequestIds, setRespondingRequestIds] = useState<ApprovalRequestId[]>([]);483 const [respondingRequestIds, setRespondingRequestIds] = useState<ApprovalRequestId[]>([]);
482 const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>({});484 const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>({});
...@@ -1386,8 +1388,16 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1386,8 +1388,16 @@ export default function ChatView({ threadId }: ChatViewProps) {
13861388
1387 useEffect(() => {1389 useEffect(() => {
1388 if (!activeThread?.id) return;1390 if (!activeThread?.id) return;
1389 setEnvMode(activeWorktreePath ? "worktree" : "local");1391 if (activeWorktreePath) {
1390 }, [activeThread?.id, 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
1392 // Auto-resize textarea1402 // Auto-resize textarea
1393 useEffect(() => {1403 useEffect(() => {
...@@ -1973,11 +1983,14 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1973,11 +1983,14 @@ export default function ChatView({ threadId }: ChatViewProps) {
1973 [scheduleComposerFocus, setComposerDraftEffort, threadId],1983 [scheduleComposerFocus, setComposerDraftEffort, threadId],
1974 );1984 );
1975 const onEnvModeChange = useCallback(1985 const onEnvModeChange = useCallback(
1976 (mode: "local" | "worktree") => {1986 (mode: DraftThreadEnvMode) => {
1977 setEnvMode(mode);1987 setEnvMode(mode);
1988 if (isLocalDraftThread) {
1989 setDraftThreadContext(threadId, { envMode: mode });
1990 }
1978 scheduleComposerFocus();1991 scheduleComposerFocus();
1979 },1992 },
1980 [scheduleComposerFocus],1993 [isLocalDraftThread, scheduleComposerFocus, setDraftThreadContext, threadId],
1981 );1994 );
19821995
1983 const applyPromptReplacement = useCallback(1996 const applyPromptReplacement = useCallback(
apps/web/src/components/Sidebar.tsx+12-4
...@@ -20,7 +20,7 @@ import { derivePendingApprovals } from "../session-logic";...@@ -20,7 +20,7 @@ import { derivePendingApprovals } from "../session-logic";
20import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/gitReactQuery";20import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/gitReactQuery";
21import { serverConfigQueryOptions } from "../lib/serverReactQuery";21import { serverConfigQueryOptions } from "../lib/serverReactQuery";
22import { readNativeApi } from "../nativeApi";22import { readNativeApi } from "../nativeApi";
23import { useComposerDraftStore } from "../composerDraftStore";23import { type DraftThreadEnvMode, useComposerDraftStore } from "../composerDraftStore";
24import { toastManager } from "./ui/toast";24import { toastManager } from "./ui/toast";
25import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible";25import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible";
26import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";26import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
...@@ -345,17 +345,20 @@ export default function Sidebar() {...@@ -345,17 +345,20 @@ export default function Sidebar() {
345 options?: {345 options?: {
346 branch?: string | null;346 branch?: string | null;
347 worktreePath?: string | null;347 worktreePath?: string | null;
348 envMode?: DraftThreadEnvMode;
348 },349 },
349 ): Promise<void> => {350 ): Promise<void> => {
350 const hasBranchOption = options?.branch !== undefined;351 const hasBranchOption = options?.branch !== undefined;
351 const hasWorktreePathOption = options?.worktreePath !== undefined;352 const hasWorktreePathOption = options?.worktreePath !== undefined;
353 const hasEnvModeOption = options?.envMode !== undefined;
352 const storedDraftThread = getDraftThreadByProjectId(projectId);354 const storedDraftThread = getDraftThreadByProjectId(projectId);
353 if (storedDraftThread) {355 if (storedDraftThread) {
354 return (async () => {356 return (async () => {
355 if (hasBranchOption || hasWorktreePathOption) {357 if (hasBranchOption || hasWorktreePathOption || hasEnvModeOption) {
356 setDraftThreadContext(storedDraftThread.threadId, {358 setDraftThreadContext(storedDraftThread.threadId, {
357 ...(hasBranchOption ? { branch: options?.branch ?? null } : {}),359 ...(hasBranchOption ? { branch: options?.branch ?? null } : {}),
358 ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}),360 ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}),
361 ...(hasEnvModeOption ? { envMode: options?.envMode } : {}),
359 });362 });
360 }363 }
361 setProjectDraftThreadId(projectId, storedDraftThread.threadId);364 setProjectDraftThreadId(projectId, storedDraftThread.threadId);
...@@ -372,10 +375,11 @@ export default function Sidebar() {...@@ -372,10 +375,11 @@ export default function Sidebar() {
372375
373 const activeDraftThread = routeThreadId ? getDraftThread(routeThreadId) : null;376 const activeDraftThread = routeThreadId ? getDraftThread(routeThreadId) : null;
374 if (activeDraftThread && routeThreadId && activeDraftThread.projectId === projectId) {377 if (activeDraftThread && routeThreadId && activeDraftThread.projectId === projectId) {
375 if (hasBranchOption || hasWorktreePathOption) {378 if (hasBranchOption || hasWorktreePathOption || hasEnvModeOption) {
376 setDraftThreadContext(routeThreadId, {379 setDraftThreadContext(routeThreadId, {
377 ...(hasBranchOption ? { branch: options?.branch ?? null } : {}),380 ...(hasBranchOption ? { branch: options?.branch ?? null } : {}),
378 ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}),381 ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}),
382 ...(hasEnvModeOption ? { envMode: options?.envMode } : {}),
379 });383 });
380 }384 }
381 setProjectDraftThreadId(projectId, routeThreadId);385 setProjectDraftThreadId(projectId, routeThreadId);
...@@ -388,6 +392,7 @@ export default function Sidebar() {...@@ -388,6 +392,7 @@ export default function Sidebar() {
388 createdAt,392 createdAt,
389 branch: options?.branch ?? null,393 branch: options?.branch ?? null,
390 worktreePath: options?.worktreePath ?? null,394 worktreePath: options?.worktreePath ?? null,
395 envMode: options?.envMode ?? "local",
391 });396 });
392397
393 await navigate({398 await navigate({
...@@ -684,6 +689,9 @@ export default function Sidebar() {...@@ -684,6 +689,9 @@ export default function Sidebar() {
684 void handleNewThread(projectId, {689 void handleNewThread(projectId, {
685 branch: activeThread?.branch ?? activeDraftThread?.branch ?? null,690 branch: activeThread?.branch ?? activeDraftThread?.branch ?? null,
686 worktreePath: activeThread?.worktreePath ?? activeDraftThread?.worktreePath ?? null,691 worktreePath: activeThread?.worktreePath ?? activeDraftThread?.worktreePath ?? null,
692 envMode:
693 activeDraftThread?.envMode ??
694 (activeThread?.worktreePath ? "worktree" : "local"),
687 });695 });
688 };696 };
689697
...@@ -691,7 +699,7 @@ export default function Sidebar() {...@@ -691,7 +699,7 @@ export default function Sidebar() {
691 return () => {699 return () => {
692 window.removeEventListener("keydown", onWindowKeyDown);700 window.removeEventListener("keydown", onWindowKeyDown);
693 };701 };
694 }, [handleNewThread, keybindings, routeThreadId, state.projects, state.threads]);702 }, [getDraftThread, handleNewThread, keybindings, routeThreadId, state.projects, state.threads]);
695703
696 const onCreateThreadClick = () => {704 const onCreateThreadClick = () => {
697 if (state.projects.length === 0) {705 if (state.projects.length === 0) {
apps/web/src/composerDraftStore.test.ts+30
...@@ -183,12 +183,14 @@ describe("composerDraftStore project draft thread mapping", () => {...@@ -183,12 +183,14 @@ describe("composerDraftStore project draft thread mapping", () => {
183 projectId,183 projectId,
184 branch: "feature/test",184 branch: "feature/test",
185 worktreePath: "/tmp/worktree-test",185 worktreePath: "/tmp/worktree-test",
186 envMode: "worktree",
186 createdAt: "2026-01-01T00:00:00.000Z",187 createdAt: "2026-01-01T00:00:00.000Z",
187 });188 });
188 expect(useComposerDraftStore.getState().getDraftThread(threadId)).toEqual({189 expect(useComposerDraftStore.getState().getDraftThread(threadId)).toEqual({
189 projectId,190 projectId,
190 branch: "feature/test",191 branch: "feature/test",
191 worktreePath: "/tmp/worktree-test",192 worktreePath: "/tmp/worktree-test",
193 envMode: "worktree",
192 createdAt: "2026-01-01T00:00:00.000Z",194 createdAt: "2026-01-01T00:00:00.000Z",
193 });195 });
194 });196 });
...@@ -273,6 +275,7 @@ describe("composerDraftStore project draft thread mapping", () => {...@@ -273,6 +275,7 @@ describe("composerDraftStore project draft thread mapping", () => {
273 projectId,275 projectId,
274 branch: "feature/next",276 branch: "feature/next",
275 worktreePath: "/tmp/feature-next",277 worktreePath: "/tmp/feature-next",
278 envMode: "worktree",
276 });279 });
277 });280 });
278281
...@@ -295,6 +298,33 @@ describe("composerDraftStore project draft thread mapping", () => {...@@ -295,6 +298,33 @@ describe("composerDraftStore project draft thread mapping", () => {
295 projectId,298 projectId,
296 branch: "main",299 branch: "main",
297 worktreePath: "/tmp/main-worktree",300 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",
298 });328 });
299 });329 });
300});330});
apps/web/src/composerDraftStore.ts+37-11
...@@ -11,6 +11,7 @@ import { create } from "zustand";...@@ -11,6 +11,7 @@ import { create } from "zustand";
11import { createJSONStorage, persist } from "zustand/middleware";11import { createJSONStorage, persist } from "zustand/middleware";
1212
13export const COMPOSER_DRAFT_STORAGE_KEY = "t3code:composer-drafts:v1";13export const COMPOSER_DRAFT_STORAGE_KEY = "t3code:composer-drafts:v1";
14export type DraftThreadEnvMode = "local" | "worktree";
1415
15export interface PersistedComposerImageAttachment {16export interface PersistedComposerImageAttachment {
16 id: string;17 id: string;
...@@ -38,6 +39,7 @@ interface PersistedDraftThreadState {...@@ -38,6 +39,7 @@ interface PersistedDraftThreadState {
38 createdAt: string;39 createdAt: string;
39 branch: string | null;40 branch: string | null;
40 worktreePath: string | null;41 worktreePath: string | null;
42 envMode: DraftThreadEnvMode;
41}43}
4244
43interface PersistedComposerDraftStoreState {45interface PersistedComposerDraftStoreState {
...@@ -61,6 +63,7 @@ export interface DraftThreadState {...@@ -61,6 +63,7 @@ export interface DraftThreadState {
61 createdAt: string;63 createdAt: string;
62 branch: string | null;64 branch: string | null;
63 worktreePath: string | null;65 worktreePath: string | null;
66 envMode: DraftThreadEnvMode;
64}67}
6568
66interface ProjectDraftThread extends DraftThreadState {69interface ProjectDraftThread extends DraftThreadState {
...@@ -80,6 +83,7 @@ interface ComposerDraftStoreState {...@@ -80,6 +83,7 @@ interface ComposerDraftStoreState {
80 branch?: string | null;83 branch?: string | null;
81 worktreePath?: string | null;84 worktreePath?: string | null;
82 createdAt?: string;85 createdAt?: string;
86 envMode?: DraftThreadEnvMode;
83 },87 },
84 ) => void;88 ) => void;
85 setDraftThreadContext: (89 setDraftThreadContext: (
...@@ -89,6 +93,7 @@ interface ComposerDraftStoreState {...@@ -89,6 +93,7 @@ interface ComposerDraftStoreState {
89 worktreePath?: string | null;93 worktreePath?: string | null;
90 projectId?: ProjectId;94 projectId?: ProjectId;
91 createdAt?: string;95 createdAt?: string;
96 envMode?: DraftThreadEnvMode;
92 },97 },
93 ) => void;98 ) => void;
94 clearProjectDraftThreadId: (projectId: ProjectId) => void;99 clearProjectDraftThreadId: (projectId: ProjectId) => void;
...@@ -203,6 +208,16 @@ function normalizePersistedAttachment(value: unknown): PersistedComposerImageAtt...@@ -203,6 +208,16 @@ function normalizePersistedAttachment(value: unknown): PersistedComposerImageAtt
203 };208 };
204}209}
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
206function normalizePersistedComposerDraftState(value: unknown): PersistedComposerDraftStoreState {221function normalizePersistedComposerDraftState(value: unknown): PersistedComposerDraftStoreState {
207 if (!value || typeof value !== "object") {222 if (!value || typeof value !== "object") {
208 return EMPTY_PERSISTED_DRAFT_STORE_STATE;223 return EMPTY_PERSISTED_DRAFT_STORE_STATE;
...@@ -227,6 +242,7 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer...@@ -227,6 +242,7 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer
227 const createdAt = candidateDraftThread.createdAt;242 const createdAt = candidateDraftThread.createdAt;
228 const branch = candidateDraftThread.branch;243 const branch = candidateDraftThread.branch;
229 const worktreePath = candidateDraftThread.worktreePath;244 const worktreePath = candidateDraftThread.worktreePath;
245 const normalizedWorktreePath = typeof worktreePath === "string" ? worktreePath : null;
230 if (typeof projectId !== "string" || projectId.length === 0) {246 if (typeof projectId !== "string" || projectId.length === 0) {
231 continue;247 continue;
232 }248 }
...@@ -237,7 +253,8 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer...@@ -237,7 +253,8 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer
237 ? createdAt253 ? createdAt
238 : new Date().toISOString(),254 : new Date().toISOString(),
239 branch: typeof branch === "string" ? branch : null,255 branch: typeof branch === "string" ? branch : null,
240 worktreePath: typeof worktreePath === "string" ? worktreePath : null,256 worktreePath: normalizedWorktreePath,
257 envMode: normalizeDraftThreadEnvMode(candidateDraftThread.envMode, normalizedWorktreePath),
241 };258 };
242 }259 }
243 }260 }
...@@ -263,6 +280,7 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer...@@ -263,6 +280,7 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer
263 createdAt: new Date().toISOString(),280 createdAt: new Date().toISOString(),
264 branch: null,281 branch: null,
265 worktreePath: null,282 worktreePath: null,
283 envMode: "local",
266 };284 };
267 } else if (draftThreadsByThreadId[threadId as ThreadId]?.projectId !== projectId) {285 } else if (draftThreadsByThreadId[threadId as ThreadId]?.projectId !== projectId) {
268 draftThreadsByThreadId[threadId as ThreadId] = {286 draftThreadsByThreadId[threadId as ThreadId] = {
...@@ -456,6 +474,10 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(...@@ -456,6 +474,10 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
456 set((state) => {474 set((state) => {
457 const existingThread = state.draftThreadsByThreadId[threadId];475 const existingThread = state.draftThreadsByThreadId[threadId];
458 const previousThreadIdForProject = state.projectDraftThreadIdByProjectId[projectId];476 const previousThreadIdForProject = state.projectDraftThreadIdByProjectId[projectId];
477 const nextWorktreePath =
478 options?.worktreePath === undefined
479 ? (existingThread?.worktreePath ?? null)
480 : (options.worktreePath ?? null);
459 const nextDraftThread: DraftThreadState = {481 const nextDraftThread: DraftThreadState = {
460 projectId,482 projectId,
461 createdAt: options?.createdAt ?? existingThread?.createdAt ?? new Date().toISOString(),483 createdAt: options?.createdAt ?? existingThread?.createdAt ?? new Date().toISOString(),
...@@ -463,10 +485,10 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(...@@ -463,10 +485,10 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
463 options?.branch === undefined485 options?.branch === undefined
464 ? (existingThread?.branch ?? null)486 ? (existingThread?.branch ?? null)
465 : (options.branch ?? null),487 : (options.branch ?? null),
466 worktreePath:488 worktreePath: nextWorktreePath,
467 options?.worktreePath === undefined489 envMode:
468 ? (existingThread?.worktreePath ?? null)490 options?.envMode ??
469 : (options.worktreePath ?? null),491 (nextWorktreePath ? "worktree" : (existingThread?.envMode ?? "local")),
470 };492 };
471 const hasSameProjectMapping = previousThreadIdForProject === threadId;493 const hasSameProjectMapping = previousThreadIdForProject === threadId;
472 const hasSameDraftThread =494 const hasSameDraftThread =
...@@ -474,7 +496,8 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(...@@ -474,7 +496,8 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
474 existingThread.projectId === nextDraftThread.projectId &&496 existingThread.projectId === nextDraftThread.projectId &&
475 existingThread.createdAt === nextDraftThread.createdAt &&497 existingThread.createdAt === nextDraftThread.createdAt &&
476 existingThread.branch === nextDraftThread.branch &&498 existingThread.branch === nextDraftThread.branch &&
477 existingThread.worktreePath === nextDraftThread.worktreePath;499 existingThread.worktreePath === nextDraftThread.worktreePath &&
500 existingThread.envMode === nextDraftThread.envMode;
478 if (hasSameProjectMapping && hasSameDraftThread) {501 if (hasSameProjectMapping && hasSameDraftThread) {
479 return state;502 return state;
480 }503 }
...@@ -518,6 +541,8 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(...@@ -518,6 +541,8 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
518 if (nextProjectId.length === 0) {541 if (nextProjectId.length === 0) {
519 return state;542 return state;
520 }543 }
544 const nextWorktreePath =
545 options.worktreePath === undefined ? existing.worktreePath : (options.worktreePath ?? null);
521 const nextDraftThread: DraftThreadState = {546 const nextDraftThread: DraftThreadState = {
522 projectId: nextProjectId,547 projectId: nextProjectId,
523 createdAt:548 createdAt:
...@@ -525,16 +550,17 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(...@@ -525,16 +550,17 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
525 ? existing.createdAt550 ? existing.createdAt
526 : options.createdAt || existing.createdAt,551 : options.createdAt || existing.createdAt,
527 branch: options.branch === undefined ? existing.branch : (options.branch ?? null),552 branch: options.branch === undefined ? existing.branch : (options.branch ?? null),
528 worktreePath:553 worktreePath: nextWorktreePath,
529 options.worktreePath === undefined554 envMode:
530 ? existing.worktreePath555 options.envMode ??
531 : (options.worktreePath ?? null),556 (nextWorktreePath ? "worktree" : (existing.envMode ?? "local")),
532 };557 };
533 const isUnchanged =558 const isUnchanged =
534 nextDraftThread.projectId === existing.projectId &&559 nextDraftThread.projectId === existing.projectId &&
535 nextDraftThread.createdAt === existing.createdAt &&560 nextDraftThread.createdAt === existing.createdAt &&
536 nextDraftThread.branch === existing.branch &&561 nextDraftThread.branch === existing.branch &&
537 nextDraftThread.worktreePath === existing.worktreePath;562 nextDraftThread.worktreePath === existing.worktreePath &&
563 nextDraftThread.envMode === existing.envMode;
538 if (isUnchanged) {564 if (isUnchanged) {
539 return state;565 return state;
540 }566 }