| author | |
| committer | |
| log | 4ab5fca4ca9d8fb12b1442ed46107e15c4275bf2 |
| tree | 9fce1dc02d018a4326cdc5f7d3392fb1da46d1de |
| parent | 69a4fa82155f05637f73b3d2f61a5cdf9366b4b9 |
- 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 merges5 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; | ||
| 62 | 66 | ||
| 63 | // ── Queries ─────────────────────────────────────────────────────────── | 67 | // ── Queries ─────────────────────────────────────────────────────────── |
| 64 | 68 | ||
| ... | @@ -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 = canCreateBranch | 77 | 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 | }; |
| 183 | 188 | ||
| ... | @@ -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; |
| 187 | 192 | ||
| 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 | {activeThreadBranch | 295 | {activeThreadBranch |
| 289 | ? envMode === "worktree" && !activeWorktreePath | 296 | ? effectiveEnvMode === "worktree" && !activeWorktreePath |
| 290 | ? `From ${activeThreadBranch}` | 297 | ? `From ${activeThreadBranch}` |
| 291 | : activeThreadBranch | 298 | : 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"; |
| 114 | import { getAppModelOptions, useAppSettings } from "../appSettings"; | 114 | import { getAppModelOptions, useAppSettings } from "../appSettings"; |
| 115 | import { | 115 | import { |
| 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) { |
| 1386 | 1388 | ||
| 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]); | ||
| 1391 | 1401 | ||
| 1392 | // Auto-resize textarea | 1402 | // 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 | ); |
| 1982 | 1995 | ||
| 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"; |
| 20 | import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/gitReactQuery"; | 20 | import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/gitReactQuery"; |
| 21 | import { serverConfigQueryOptions } from "../lib/serverReactQuery"; | 21 | import { serverConfigQueryOptions } from "../lib/serverReactQuery"; |
| 22 | import { readNativeApi } from "../nativeApi"; | 22 | import { readNativeApi } from "../nativeApi"; |
| 23 | import { useComposerDraftStore } from "../composerDraftStore"; | 23 | import { type DraftThreadEnvMode, useComposerDraftStore } from "../composerDraftStore"; |
| 24 | import { toastManager } from "./ui/toast"; | 24 | import { toastManager } from "./ui/toast"; |
| 25 | import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible"; | 25 | import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible"; |
| 26 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; | 26 | import { 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() { |
| 372 | 375 | ||
| 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 | }); |
| 392 | 397 | ||
| 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 | }; |
| 689 | 697 | ||
| ... | @@ -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]); |
| 695 | 703 | ||
| 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 | }); |
| 278 | 281 | ||
| ... | @@ -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"; |
| 11 | import { createJSONStorage, persist } from "zustand/middleware"; | 11 | import { createJSONStorage, persist } from "zustand/middleware"; |
| 12 | 12 | ||
| 13 | export const COMPOSER_DRAFT_STORAGE_KEY = "t3code:composer-drafts:v1"; | 13 | export const COMPOSER_DRAFT_STORAGE_KEY = "t3code:composer-drafts:v1"; |
| 14 | export type DraftThreadEnvMode = "local" | "worktree"; | ||
| 14 | 15 | ||
| 15 | export interface PersistedComposerImageAttachment { | 16 | export 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 | } |
| 42 | 44 | ||
| 43 | interface PersistedComposerDraftStoreState { | 45 | interface 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 | } |
| 65 | 68 | ||
| 66 | interface ProjectDraftThread extends DraftThreadState { | 69 | interface 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 | } |
| 205 | 210 | ||
| 211 | function 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 | |||
| 206 | function normalizePersistedComposerDraftState(value: unknown): PersistedComposerDraftStoreState { | 221 | function 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 | ? createdAt | 253 | ? 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 === undefined | 485 | 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 === undefined | 489 | 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.createdAt | 550 | ? 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 === undefined | 554 | envMode: |
| 530 | ? existing.worktreePath | 555 | 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 | } |