| author | |
| committer | |
| log | 2f41c073a2f846e9a96082e76cd1b2c3a7e44c18 |
| tree | e14e7e0788d50d190bd99e8186962807e5191c6a |
| parent | 51672b6ecb9d927d57d0b12dd071ca0d4a34ef36 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>14 files changed, 425 insertions(+), 385 deletions(-)
apps/web/src/components/BranchToolbar.logic.test.ts+87| ... | ... | @@ -12,6 +12,8 @@ import { |
| 12 | 12 | resolveBranchToolbarValue, |
| 13 | 13 | resolveLockedWorkspaceLabel, |
| 14 | 14 | resolveLocalCheckoutBranchMismatch, |
| 15 | resolvePreviousWorktreeLabel, | |
| 16 | resolvePreviousWorktreeSeed, | |
| 15 | 17 | shouldIncludeBranchPickerItem, |
| 16 | 18 | shouldShowEnvironmentIndicator, |
| 17 | 19 | } from "./BranchToolbar.logic"; |
| ... | ... | @@ -19,6 +21,91 @@ import { |
| 19 | 21 | const localEnvironmentId = EnvironmentId.make("environment-local"); |
| 20 | 22 | const remoteEnvironmentId = EnvironmentId.make("environment-remote"); |
| 21 | 23 | |
| 24 | describe("resolvePreviousWorktreeSeed", () => { | |
| 25 | it("picks the most recently updated worktree thread", () => { | |
| 26 | expect( | |
| 27 | resolvePreviousWorktreeSeed({ | |
| 28 | threads: [ | |
| 29 | { | |
| 30 | branch: "t3/older", | |
| 31 | worktreePath: "/repo/.t3/worktrees/older", | |
| 32 | updatedAt: "2026-07-20T00:00:00.000Z", | |
| 33 | }, | |
| 34 | { | |
| 35 | branch: "t3/newer", | |
| 36 | worktreePath: "/repo/.t3/worktrees/newer", | |
| 37 | updatedAt: "2026-07-22T00:00:00.000Z", | |
| 38 | }, | |
| 39 | { branch: "main", worktreePath: null, updatedAt: "2026-07-23T00:00:00.000Z" }, | |
| 40 | ], | |
| 41 | currentWorktreePath: null, | |
| 42 | }), | |
| 43 | ).toEqual({ branch: "t3/newer", worktreePath: "/repo/.t3/worktrees/newer" }); | |
| 44 | }); | |
| 45 | ||
| 46 | it("skips the worktree the composer already points at", () => { | |
| 47 | expect( | |
| 48 | resolvePreviousWorktreeSeed({ | |
| 49 | threads: [ | |
| 50 | { | |
| 51 | branch: "t3/current", | |
| 52 | worktreePath: "/repo/.t3/worktrees/current", | |
| 53 | updatedAt: "2026-07-22T00:00:00.000Z", | |
| 54 | }, | |
| 55 | ], | |
| 56 | currentWorktreePath: "/repo/.t3/worktrees/current", | |
| 57 | }), | |
| 58 | ).toBeNull(); | |
| 59 | }); | |
| 60 | ||
| 61 | it("returns null when no thread has a worktree", () => { | |
| 62 | expect( | |
| 63 | resolvePreviousWorktreeSeed({ | |
| 64 | threads: [{ branch: "main", worktreePath: null, updatedAt: "2026-07-22T00:00:00.000Z" }], | |
| 65 | currentWorktreePath: null, | |
| 66 | }), | |
| 67 | ).toBeNull(); | |
| 68 | }); | |
| 69 | ||
| 70 | it("ignores archived threads and threads with unparseable timestamps", () => { | |
| 71 | expect( | |
| 72 | resolvePreviousWorktreeSeed({ | |
| 73 | threads: [ | |
| 74 | { | |
| 75 | branch: "t3/archived", | |
| 76 | worktreePath: "/repo/.t3/worktrees/archived", | |
| 77 | updatedAt: "2026-07-23T00:00:00.000Z", | |
| 78 | archivedAt: "2026-07-23T01:00:00.000Z", | |
| 79 | }, | |
| 80 | { | |
| 81 | branch: "t3/garbage-timestamp", | |
| 82 | worktreePath: "/repo/.t3/worktrees/garbage", | |
| 83 | updatedAt: "not-a-date", | |
| 84 | }, | |
| 85 | { | |
| 86 | branch: "t3/live", | |
| 87 | worktreePath: "/repo/.t3/worktrees/live", | |
| 88 | updatedAt: "2026-07-21T00:00:00.000Z", | |
| 89 | archivedAt: null, | |
| 90 | }, | |
| 91 | ], | |
| 92 | currentWorktreePath: null, | |
| 93 | }), | |
| 94 | ).toEqual({ branch: "t3/live", worktreePath: "/repo/.t3/worktrees/live" }); | |
| 95 | }); | |
| 96 | }); | |
| 97 | ||
| 98 | describe("resolvePreviousWorktreeLabel", () => { | |
| 99 | it("includes the branch when known", () => { | |
| 100 | expect(resolvePreviousWorktreeLabel({ branch: "t3/fix-thing", worktreePath: "/wt" })).toBe( | |
| 101 | "Previous worktree (t3/fix-thing)", | |
| 102 | ); | |
| 103 | expect(resolvePreviousWorktreeLabel({ branch: null, worktreePath: "/wt" })).toBe( | |
| 104 | "Previous worktree", | |
| 105 | ); | |
| 106 | }); | |
| 107 | }); | |
| 108 | ||
| 22 | 109 | describe("resolveDraftEnvModeAfterBranchChange", () => { |
| 23 | 110 | it("switches to local mode when returning from an existing worktree to the main worktree", () => { |
| 24 | 111 | expect( |
apps/web/src/components/BranchToolbar.logic.ts+48| ... | ... | @@ -1,5 +1,6 @@ |
| 1 | 1 | import type { EnvironmentId, VcsRef, ProjectId } from "@t3tools/contracts"; |
| 2 | 2 | import * as Schema from "effect/Schema"; |
| 3 | import { toSortableTimestamp } from "../lib/threadSort"; | |
| 3 | 4 | export { |
| 4 | 5 | dedupeRemoteBranchesWithLocalMatches, |
| 5 | 6 | deriveLocalBranchNameFromRemoteRef, |
| ... | ... | @@ -65,6 +66,53 @@ export function resolveLockedWorkspaceLabel(activeWorktreePath: string | null): |
| 65 | 66 | return activeWorktreePath ? "Worktree" : "Local checkout"; |
| 66 | 67 | } |
| 67 | 68 | |
| 69 | export interface PreviousWorktreeSeed { | |
| 70 | branch: string | null; | |
| 71 | worktreePath: string; | |
| 72 | } | |
| 73 | ||
| 74 | // The most recently touched worktree in the project that the composer isn't | |
| 75 | // already pointing at. Backs the "Previous worktree" entry in the workspace | |
| 76 | // selector so a follow-up thread can hop back into the worktree you just | |
| 77 | // worked in without hunting for its branch. Archived threads don't compete — | |
| 78 | // the rest of the UI hides them, so their worktrees shouldn't resurface here. | |
| 79 | export function resolvePreviousWorktreeSeed(input: { | |
| 80 | threads: ReadonlyArray<{ | |
| 81 | branch: string | null; | |
| 82 | worktreePath: string | null; | |
| 83 | updatedAt: string; | |
| 84 | archivedAt?: string | null; | |
| 85 | }>; | |
| 86 | currentWorktreePath: string | null; | |
| 87 | }): PreviousWorktreeSeed | null { | |
| 88 | let latest: { branch: string | null; worktreePath: string; updatedAt: number } | null = null; | |
| 89 | for (const thread of input.threads) { | |
| 90 | if ( | |
| 91 | !thread.worktreePath || | |
| 92 | thread.worktreePath === input.currentWorktreePath || | |
| 93 | (thread.archivedAt ?? null) !== null | |
| 94 | ) { | |
| 95 | continue; | |
| 96 | } | |
| 97 | const updatedAt = toSortableTimestamp(thread.updatedAt); | |
| 98 | if (updatedAt === null) { | |
| 99 | continue; | |
| 100 | } | |
| 101 | if (latest === null || updatedAt > latest.updatedAt) { | |
| 102 | latest = { | |
| 103 | branch: thread.branch, | |
| 104 | worktreePath: thread.worktreePath, | |
| 105 | updatedAt, | |
| 106 | }; | |
| 107 | } | |
| 108 | } | |
| 109 | return latest === null ? null : { branch: latest.branch, worktreePath: latest.worktreePath }; | |
| 110 | } | |
| 111 | ||
| 112 | export function resolvePreviousWorktreeLabel(seed: PreviousWorktreeSeed): string { | |
| 113 | return seed.branch ? `Previous worktree (${seed.branch})` : "Previous worktree"; | |
| 114 | } | |
| 115 | ||
| 68 | 116 | export function resolveEffectiveEnvMode(input: { |
| 69 | 117 | activeWorktreePath: string | null; |
| 70 | 118 | hasServerThread: boolean; |
apps/web/src/components/BranchToolbar.tsx+63-3| ... | ... | @@ -6,12 +6,13 @@ import { |
| 6 | 6 | FolderGit2Icon, |
| 7 | 7 | FolderGitIcon, |
| 8 | 8 | FolderIcon, |
| 9 | HistoryIcon, | |
| 9 | 10 | MonitorIcon, |
| 10 | 11 | } from "lucide-react"; |
| 11 | import { memo, useMemo } from "react"; | |
| 12 | import { memo, useCallback, useMemo } from "react"; | |
| 12 | 13 | |
| 13 | 14 | import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; |
| 14 | import { useProject, useThread } from "../state/entities"; | |
| 15 | import { useProject, useThread, useThreadShellsForProjectRefs } from "../state/entities"; | |
| 15 | 16 | import { useIsMobile } from "../hooks/useMediaQuery"; |
| 16 | 17 | import { |
| 17 | 18 | type EnvMode, |
| ... | ... | @@ -20,6 +21,8 @@ import { |
| 20 | 21 | resolveEnvModeLabel, |
| 21 | 22 | resolveEffectiveEnvMode, |
| 22 | 23 | resolveLockedWorkspaceLabel, |
| 24 | resolvePreviousWorktreeLabel, | |
| 25 | resolvePreviousWorktreeSeed, | |
| 23 | 26 | shouldShowEnvironmentIndicator, |
| 24 | 27 | } from "./BranchToolbar.logic"; |
| 25 | 28 | import { BranchToolbarBranchSelector } from "./BranchToolbarBranchSelector"; |
| ... | ... | @@ -66,6 +69,8 @@ interface MobileRunContextSelectorProps { |
| 66 | 69 | effectiveEnvMode: EnvMode; |
| 67 | 70 | activeWorktreePath: string | null; |
| 68 | 71 | onEnvModeChange: (mode: EnvMode) => void; |
| 72 | previousWorktreeLabel: string | null; | |
| 73 | onUsePreviousWorktree: () => void; | |
| 69 | 74 | } |
| 70 | 75 | |
| 71 | 76 | const MobileRunContextSelector = memo(function MobileRunContextSelector({ |
| ... | ... | @@ -79,6 +84,8 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ |
| 79 | 84 | effectiveEnvMode, |
| 80 | 85 | activeWorktreePath, |
| 81 | 86 | onEnvModeChange, |
| 87 | previousWorktreeLabel, | |
| 88 | onUsePreviousWorktree, | |
| 82 | 89 | }: MobileRunContextSelectorProps) { |
| 83 | 90 | const activeEnvironment = useMemo( |
| 84 | 91 | () => availableEnvironments?.find((env) => env.environmentId === environmentId) ?? null, |
| ... | ... | @@ -166,7 +173,13 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ |
| 166 | 173 | <MenuGroupLabel>Workspace</MenuGroupLabel> |
| 167 | 174 | <MenuRadioGroup |
| 168 | 175 | value={effectiveEnvMode} |
| 169 | onValueChange={(value) => onEnvModeChange(value as EnvMode)} | |
| 176 | onValueChange={(value) => { | |
| 177 | if (value === "previous-worktree") { | |
| 178 | onUsePreviousWorktree(); | |
| 179 | return; | |
| 180 | } | |
| 181 | onEnvModeChange(value as EnvMode); | |
| 182 | }} | |
| 170 | 183 | > |
| 171 | 184 | <MenuRadioItem disabled={envModeLocked} value="local"> |
| 172 | 185 | <span className="flex min-w-0 items-center gap-1.5"> |
| ... | ... | @@ -186,6 +199,14 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ |
| 186 | 199 | <span className="min-w-0 truncate">{resolveEnvModeLabel("worktree")}</span> |
| 187 | 200 | </span> |
| 188 | 201 | </MenuRadioItem> |
| 202 | {previousWorktreeLabel ? ( | |
| 203 | <MenuRadioItem disabled={envModeLocked} value="previous-worktree"> | |
| 204 | <span className="flex min-w-0 items-center gap-1.5"> | |
| 205 | <HistoryIcon className="size-3" /> | |
| 206 | <span className="min-w-0 truncate">{previousWorktreeLabel}</span> | |
| 207 | </span> | |
| 208 | </MenuRadioItem> | |
| 209 | ) : null} | |
| 189 | 210 | </MenuRadioGroup> |
| 190 | 211 | </MenuGroup> |
| 191 | 212 | </MenuPopup> |
| ... | ... | @@ -217,6 +238,7 @@ export const BranchToolbar = memo(function BranchToolbar({ |
| 217 | 238 | const draftThread = useComposerDraftStore((store) => |
| 218 | 239 | draftId ? store.getDraftSession(draftId) : store.getDraftThreadByRef(threadRef), |
| 219 | 240 | ); |
| 241 | const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); | |
| 220 | 242 | const activeProjectRef = serverThread |
| 221 | 243 | ? scopeProjectRef(serverThread.environmentId, serverThread.projectId) |
| 222 | 244 | : draftThread |
| ... | ... | @@ -234,6 +256,40 @@ export const BranchToolbar = memo(function BranchToolbar({ |
| 234 | 256 | }); |
| 235 | 257 | const envModeLocked = envLocked || (serverThread !== null && activeWorktreePath !== null); |
| 236 | 258 | |
| 259 | // "Previous worktree" hops a draft into the most recently active worktree | |
| 260 | // of this project — the "keep going where I just was" follow-up flow. Only | |
| 261 | // drafts can hop; started server threads have their workspace pinned. | |
| 262 | const canUsePreviousWorktree = draftThread !== null && serverThread === null && !envModeLocked; | |
| 263 | const projectRefsForWorktreeLookup = useMemo( | |
| 264 | () => (canUsePreviousWorktree && activeProjectRef ? [activeProjectRef] : []), | |
| 265 | [canUsePreviousWorktree, activeProjectRef], | |
| 266 | ); | |
| 267 | const projectThreads = useThreadShellsForProjectRefs(projectRefsForWorktreeLookup); | |
| 268 | const previousWorktreeSeed = useMemo( | |
| 269 | () => | |
| 270 | canUsePreviousWorktree | |
| 271 | ? resolvePreviousWorktreeSeed({ | |
| 272 | threads: projectThreads, | |
| 273 | currentWorktreePath: activeWorktreePath, | |
| 274 | }) | |
| 275 | : null, | |
| 276 | [activeWorktreePath, canUsePreviousWorktree, projectThreads], | |
| 277 | ); | |
| 278 | const previousWorktreeLabel = previousWorktreeSeed | |
| 279 | ? resolvePreviousWorktreeLabel(previousWorktreeSeed) | |
| 280 | : null; | |
| 281 | const onUsePreviousWorktree = useCallback(() => { | |
| 282 | if (!previousWorktreeSeed || !activeProjectRef) return; | |
| 283 | // Same shape the branch selector writes when picking a branch that | |
| 284 | // already lives in a worktree: point the draft at the existing tree. | |
| 285 | setDraftThreadContext(draftId ?? threadRef, { | |
| 286 | branch: previousWorktreeSeed.branch, | |
| 287 | worktreePath: previousWorktreeSeed.worktreePath, | |
| 288 | envMode: "worktree", | |
| 289 | projectRef: activeProjectRef, | |
| 290 | }); | |
| 291 | }, [activeProjectRef, draftId, previousWorktreeSeed, setDraftThreadContext, threadRef]); | |
| 292 | ||
| 237 | 293 | const showEnvironmentPicker = Boolean( |
| 238 | 294 | availableEnvironments && availableEnvironments.length > 1 && onEnvironmentChange, |
| 239 | 295 | ); |
| ... | ... | @@ -261,6 +317,8 @@ export const BranchToolbar = memo(function BranchToolbar({ |
| 261 | 317 | effectiveEnvMode={effectiveEnvMode} |
| 262 | 318 | activeWorktreePath={activeWorktreePath} |
| 263 | 319 | onEnvModeChange={onEnvModeChange} |
| 320 | previousWorktreeLabel={previousWorktreeLabel} | |
| 321 | onUsePreviousWorktree={onUsePreviousWorktree} | |
| 264 | 322 | /> |
| 265 | 323 | ) : ( |
| 266 | 324 | <div className="flex min-w-0 shrink-0 items-center gap-1"> |
| ... | ... | @@ -280,6 +338,8 @@ export const BranchToolbar = memo(function BranchToolbar({ |
| 280 | 338 | effectiveEnvMode={effectiveEnvMode} |
| 281 | 339 | activeWorktreePath={activeWorktreePath} |
| 282 | 340 | onEnvModeChange={onEnvModeChange} |
| 341 | previousWorktreeLabel={previousWorktreeLabel} | |
| 342 | onUsePreviousWorktree={onUsePreviousWorktree} | |
| 283 | 343 | /> |
| 284 | 344 | </div> |
| 285 | 345 | )} |
apps/web/src/components/BranchToolbarEnvModeSelector.tsx+27-3| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import { FolderGit2Icon, FolderGitIcon, FolderIcon } from "lucide-react"; | |
| 1 | import { FolderGit2Icon, FolderGitIcon, FolderIcon, HistoryIcon } from "lucide-react"; | |
| 2 | 2 | import { memo, useMemo } from "react"; |
| 3 | 3 | |
| 4 | 4 | import { |
| ... | ... | @@ -17,11 +17,15 @@ import { |
| 17 | 17 | SelectValue, |
| 18 | 18 | } from "./ui/select"; |
| 19 | 19 | |
| 20 | export const PREVIOUS_WORKTREE_SELECT_VALUE = "previous-worktree"; | |
| 21 | ||
| 20 | 22 | interface BranchToolbarEnvModeSelectorProps { |
| 21 | 23 | envLocked: boolean; |
| 22 | 24 | effectiveEnvMode: EnvMode; |
| 23 | 25 | activeWorktreePath: string | null; |
| 24 | 26 | onEnvModeChange: (mode: EnvMode) => void; |
| 27 | previousWorktreeLabel?: string | null; | |
| 28 | onUsePreviousWorktree?: () => void; | |
| 25 | 29 | } |
| 26 | 30 | |
| 27 | 31 | export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSelector({ |
| ... | ... | @@ -29,13 +33,19 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe |
| 29 | 33 | effectiveEnvMode, |
| 30 | 34 | activeWorktreePath, |
| 31 | 35 | onEnvModeChange, |
| 36 | previousWorktreeLabel, | |
| 37 | onUsePreviousWorktree, | |
| 32 | 38 | }: BranchToolbarEnvModeSelectorProps) { |
| 39 | const showPreviousWorktree = Boolean(previousWorktreeLabel && onUsePreviousWorktree); | |
| 33 | 40 | const envModeItems = useMemo( |
| 34 | 41 | () => [ |
| 35 | 42 | { value: "local", label: resolveCurrentWorkspaceLabel(activeWorktreePath) }, |
| 36 | 43 | { value: "worktree", label: resolveEnvModeLabel("worktree") }, |
| 44 | ...(showPreviousWorktree && previousWorktreeLabel | |
| 45 | ? [{ value: PREVIOUS_WORKTREE_SELECT_VALUE, label: previousWorktreeLabel }] | |
| 46 | : []), | |
| 37 | 47 | ], |
| 38 | [activeWorktreePath], | |
| 48 | [activeWorktreePath, previousWorktreeLabel, showPreviousWorktree], | |
| 39 | 49 | ); |
| 40 | 50 | |
| 41 | 51 | if (envLocked) { |
| ... | ... | @@ -60,7 +70,13 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe |
| 60 | 70 | <Select |
| 61 | 71 | modal={false} |
| 62 | 72 | value={effectiveEnvMode} |
| 63 | onValueChange={(value) => onEnvModeChange(value as EnvMode)} | |
| 73 | onValueChange={(value: string | null) => { | |
| 74 | if (value === PREVIOUS_WORKTREE_SELECT_VALUE) { | |
| 75 | onUsePreviousWorktree?.(); | |
| 76 | return; | |
| 77 | } | |
| 78 | onEnvModeChange(value as EnvMode); | |
| 79 | }} | |
| 64 | 80 | items={envModeItems} |
| 65 | 81 | > |
| 66 | 82 | <SelectTrigger variant="ghost" size="xs" className="font-medium" aria-label="Workspace"> |
| ... | ... | @@ -92,6 +108,14 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe |
| 92 | 108 | {resolveEnvModeLabel("worktree")} |
| 93 | 109 | </span> |
| 94 | 110 | </SelectItem> |
| 111 | {showPreviousWorktree && previousWorktreeLabel ? ( | |
| 112 | <SelectItem value={PREVIOUS_WORKTREE_SELECT_VALUE}> | |
| 113 | <span className="inline-flex items-center gap-1.5"> | |
| 114 | <HistoryIcon className="size-3" /> | |
| 115 | {previousWorktreeLabel} | |
| 116 | </span> | |
| 117 | </SelectItem> | |
| 118 | ) : null} | |
| 95 | 119 | </SelectGroup> |
| 96 | 120 | </SelectPopup> |
| 97 | 121 | </Select> |
apps/web/src/components/CommandPalette.tsx+3-21| ... | ... | @@ -58,11 +58,7 @@ import { useAtomCommand } from "../state/use-atom-command"; |
| 58 | 58 | import { useAtomQueryRunner } from "../state/use-atom-query-runner"; |
| 59 | 59 | import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; |
| 60 | 60 | import { useProjects, useThreadShells } from "../state/entities"; |
| 61 | import { | |
| 62 | resolveThreadActionProjectRef, | |
| 63 | startNewThreadInProjectFromContext, | |
| 64 | startNewThreadFromContext, | |
| 65 | } from "../lib/chatThreadActions"; | |
| 61 | import { resolveThreadActionProjectRef, startNewThreadFromContext } from "../lib/chatThreadActions"; | |
| 66 | 62 | import { |
| 67 | 63 | appendBrowsePathSegment, |
| 68 | 64 | canNavigateUp, |
| ... | ... | @@ -837,13 +833,7 @@ function OpenCommandPaletteDialog(props: { |
| 837 | 833 | projectRef.environmentId === contextualProjectRef.environmentId && |
| 838 | 834 | projectRef.projectId === contextualProjectRef.projectId, |
| 839 | 835 | ); |
| 840 | await startNewThreadInProjectFromContext( | |
| 841 | { | |
| 842 | activeDraftThread, | |
| 843 | activeThread: activeThread ?? undefined, | |
| 844 | defaultProjectRef, | |
| 845 | handleNewThread, | |
| 846 | }, | |
| 836 | await handleNewThread( | |
| 847 | 837 | contextualRefBelongsToGroup |
| 848 | 838 | ? contextualProjectRef |
| 849 | 839 | : scopeProjectRef(project.environmentId, project.id), |
| ... | ... | @@ -851,15 +841,7 @@ function OpenCommandPaletteDialog(props: { |
| 851 | 841 | }, |
| 852 | 842 | }), |
| 853 | 843 | ), |
| 854 | [ | |
| 855 | activeDraftThread, | |
| 856 | activeThread, | |
| 857 | contextualProjectRef, | |
| 858 | defaultProjectRef, | |
| 859 | handleNewThread, | |
| 860 | pickerProjects, | |
| 861 | projectGroupByTargetKey, | |
| 862 | ], | |
| 844 | [contextualProjectRef, handleNewThread, pickerProjects, projectGroupByTargetKey], | |
| 863 | 845 | ); |
| 864 | 846 | |
| 865 | 847 | const allThreadItems = useMemo( |
apps/web/src/components/Sidebar.logic.test.ts-108| ... | ... | @@ -14,8 +14,6 @@ import { |
| 14 | 14 | isTrailingDoubleClick, |
| 15 | 15 | orderItemsByPreferredIds, |
| 16 | 16 | resolveProjectStatusIndicator, |
| 17 | resolveSidebarNewThreadSeedContext, | |
| 18 | resolveSidebarNewThreadEnvMode, | |
| 19 | 17 | resolveSidebarStageBadgeLabel, |
| 20 | 18 | resolveThreadRowClassName, |
| 21 | 19 | resolveSidebarV2Status, |
| ... | ... | @@ -371,112 +369,6 @@ describe("isTrailingDoubleClick", () => { |
| 371 | 369 | }); |
| 372 | 370 | }); |
| 373 | 371 | |
| 374 | describe("resolveSidebarNewThreadEnvMode", () => { | |
| 375 | it("uses the app default when the caller does not request a specific mode", () => { | |
| 376 | expect( | |
| 377 | resolveSidebarNewThreadEnvMode({ | |
| 378 | defaultEnvMode: "worktree", | |
| 379 | }), | |
| 380 | ).toBe("worktree"); | |
| 381 | }); | |
| 382 | ||
| 383 | it("preserves an explicit requested mode over the app default", () => { | |
| 384 | expect( | |
| 385 | resolveSidebarNewThreadEnvMode({ | |
| 386 | requestedEnvMode: "local", | |
| 387 | defaultEnvMode: "worktree", | |
| 388 | }), | |
| 389 | ).toBe("local"); | |
| 390 | }); | |
| 391 | }); | |
| 392 | ||
| 393 | describe("resolveSidebarNewThreadSeedContext", () => { | |
| 394 | it("prefers the default worktree mode over active thread context", () => { | |
| 395 | expect( | |
| 396 | resolveSidebarNewThreadSeedContext({ | |
| 397 | projectId: "project-1", | |
| 398 | defaultEnvMode: "worktree", | |
| 399 | activeThread: { | |
| 400 | projectId: "project-1", | |
| 401 | branch: "feature/existing", | |
| 402 | worktreePath: "/repo/.t3/worktrees/existing", | |
| 403 | }, | |
| 404 | activeDraftThread: { | |
| 405 | projectId: "project-1", | |
| 406 | branch: "feature/draft", | |
| 407 | worktreePath: "/repo/.t3/worktrees/draft", | |
| 408 | envMode: "worktree", | |
| 409 | startFromOrigin: true, | |
| 410 | }, | |
| 411 | }), | |
| 412 | ).toEqual({ | |
| 413 | envMode: "worktree", | |
| 414 | }); | |
| 415 | }); | |
| 416 | ||
| 417 | it("inherits the active server thread context when creating a new thread in the same project", () => { | |
| 418 | expect( | |
| 419 | resolveSidebarNewThreadSeedContext({ | |
| 420 | projectId: "project-1", | |
| 421 | defaultEnvMode: "local", | |
| 422 | activeThread: { | |
| 423 | projectId: "project-1", | |
| 424 | branch: "effect-atom", | |
| 425 | worktreePath: null, | |
| 426 | }, | |
| 427 | activeDraftThread: null, | |
| 428 | }), | |
| 429 | ).toEqual({ | |
| 430 | branch: "effect-atom", | |
| 431 | worktreePath: null, | |
| 432 | envMode: "local", | |
| 433 | }); | |
| 434 | }); | |
| 435 | ||
| 436 | it("prefers the active draft thread context when it matches the target project", () => { | |
| 437 | expect( | |
| 438 | resolveSidebarNewThreadSeedContext({ | |
| 439 | projectId: "project-1", | |
| 440 | defaultEnvMode: "local", | |
| 441 | activeThread: { | |
| 442 | projectId: "project-1", | |
| 443 | branch: "effect-atom", | |
| 444 | worktreePath: null, | |
| 445 | }, | |
| 446 | activeDraftThread: { | |
| 447 | projectId: "project-1", | |
| 448 | branch: "feature/new-draft", | |
| 449 | worktreePath: "/repo/worktree", | |
| 450 | envMode: "worktree", | |
| 451 | startFromOrigin: true, | |
| 452 | }, | |
| 453 | }), | |
| 454 | ).toEqual({ | |
| 455 | branch: "feature/new-draft", | |
| 456 | worktreePath: "/repo/worktree", | |
| 457 | envMode: "worktree", | |
| 458 | startFromOrigin: true, | |
| 459 | }); | |
| 460 | }); | |
| 461 | ||
| 462 | it("falls back to the default env mode when there is no matching active thread context", () => { | |
| 463 | expect( | |
| 464 | resolveSidebarNewThreadSeedContext({ | |
| 465 | projectId: "project-2", | |
| 466 | defaultEnvMode: "worktree", | |
| 467 | activeThread: { | |
| 468 | projectId: "project-1", | |
| 469 | branch: "effect-atom", | |
| 470 | worktreePath: null, | |
| 471 | }, | |
| 472 | activeDraftThread: null, | |
| 473 | }), | |
| 474 | ).toEqual({ | |
| 475 | envMode: "worktree", | |
| 476 | }); | |
| 477 | }); | |
| 478 | }); | |
| 479 | ||
| 480 | 372 | describe("orderItemsByPreferredIds", () => { |
| 481 | 373 | it("keeps preferred ids first, skips stale ids, and preserves the relative order of remaining items", () => { |
| 482 | 374 | const ordered = orderItemsByPreferredIds({ |
apps/web/src/components/Sidebar.logic.ts-57| ... | ... | @@ -18,7 +18,6 @@ export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 100; |
| 18 | 18 | // Visible sidebar rows are prewarmed into the thread-detail cache so opening a |
| 19 | 19 | // nearby thread usually reuses an already-hot subscription. |
| 20 | 20 | export const SIDEBAR_THREAD_PREWARM_LIMIT = 10; |
| 21 | export type SidebarNewThreadEnvMode = "local" | "worktree"; | |
| 22 | 21 | type SidebarProject = { |
| 23 | 22 | id: string; |
| 24 | 23 | title: string; |
| ... | ... | @@ -246,62 +245,6 @@ export function isTrailingDoubleClick(detail: number): boolean { |
| 246 | 245 | return detail > 1; |
| 247 | 246 | } |
| 248 | 247 | |
| 249 | export function resolveSidebarNewThreadEnvMode(input: { | |
| 250 | requestedEnvMode?: SidebarNewThreadEnvMode; | |
| 251 | defaultEnvMode: SidebarNewThreadEnvMode; | |
| 252 | }): SidebarNewThreadEnvMode { | |
| 253 | return input.requestedEnvMode ?? input.defaultEnvMode; | |
| 254 | } | |
| 255 | ||
| 256 | export function resolveSidebarNewThreadSeedContext(input: { | |
| 257 | projectId: string; | |
| 258 | defaultEnvMode: SidebarNewThreadEnvMode; | |
| 259 | activeThread?: { | |
| 260 | projectId: string; | |
| 261 | branch: string | null; | |
| 262 | worktreePath: string | null; | |
| 263 | } | null; | |
| 264 | activeDraftThread?: { | |
| 265 | projectId: string; | |
| 266 | branch: string | null; | |
| 267 | worktreePath: string | null; | |
| 268 | envMode: SidebarNewThreadEnvMode; | |
| 269 | startFromOrigin: boolean; | |
| 270 | } | null; | |
| 271 | }): { | |
| 272 | branch?: string | null; | |
| 273 | worktreePath?: string | null; | |
| 274 | envMode: SidebarNewThreadEnvMode; | |
| 275 | startFromOrigin?: boolean; | |
| 276 | } { | |
| 277 | if (input.defaultEnvMode === "worktree") { | |
| 278 | return { | |
| 279 | envMode: "worktree", | |
| 280 | }; | |
| 281 | } | |
| 282 | ||
| 283 | if (input.activeDraftThread?.projectId === input.projectId) { | |
| 284 | return { | |
| 285 | branch: input.activeDraftThread.branch, | |
| 286 | worktreePath: input.activeDraftThread.worktreePath, | |
| 287 | envMode: input.activeDraftThread.envMode, | |
| 288 | startFromOrigin: input.activeDraftThread.startFromOrigin, | |
| 289 | }; | |
| 290 | } | |
| 291 | ||
| 292 | if (input.activeThread?.projectId === input.projectId) { | |
| 293 | return { | |
| 294 | branch: input.activeThread.branch, | |
| 295 | worktreePath: input.activeThread.worktreePath, | |
| 296 | envMode: input.activeThread.worktreePath ? "worktree" : "local", | |
| 297 | }; | |
| 298 | } | |
| 299 | ||
| 300 | return { | |
| 301 | envMode: input.defaultEnvMode, | |
| 302 | }; | |
| 303 | } | |
| 304 | ||
| 305 | 248 | export function orderItemsByPreferredIds<TItem, TId>(input: { |
| 306 | 249 | items: readonly TItem[]; |
| 307 | 250 | preferredIds: readonly TId[]; |
apps/web/src/components/Sidebar.tsx+33-55| ... | ... | @@ -177,8 +177,6 @@ import { |
| 177 | 177 | isContextMenuPointerDown, |
| 178 | 178 | isTrailingDoubleClick, |
| 179 | 179 | resolveProjectStatusIndicator, |
| 180 | resolveSidebarNewThreadSeedContext, | |
| 181 | resolveSidebarNewThreadEnvMode, | |
| 182 | 180 | resolveThreadRowClassName, |
| 183 | 181 | resolveThreadStatusPill, |
| 184 | 182 | orderItemsByPreferredIds, |
| ... | ... | @@ -193,7 +191,7 @@ import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; |
| 193 | 191 | import { useIsMobile } from "~/hooks/useMediaQuery"; |
| 194 | 192 | import { CommandDialogTrigger } from "./ui/command"; |
| 195 | 193 | import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings"; |
| 196 | import { primaryServerKeybindingsAtom, primaryServerSettingsAtom } from "../state/server"; | |
| 194 | import { primaryServerKeybindingsAtom } from "../state/server"; | |
| 197 | 195 | import { |
| 198 | 196 | derivePhysicalProjectKey, |
| 199 | 197 | deriveProjectGroupingOverrideKey, |
| ... | ... | @@ -1103,7 +1101,6 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 1103 | 1101 | (settings) => settings.confirmThreadArchive, |
| 1104 | 1102 | ); |
| 1105 | 1103 | const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); |
| 1106 | const primaryServerSettings = useAtomValue(primaryServerSettingsAtom); | |
| 1107 | 1104 | const deleteProject = useAtomCommand(projectEnvironment.delete, { |
| 1108 | 1105 | reportFailure: false, |
| 1109 | 1106 | }); |
| ... | ... | @@ -1875,61 +1872,14 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 1875 | 1872 | |
| 1876 | 1873 | const createThreadForProjectMember = useCallback( |
| 1877 | 1874 | (member: SidebarProjectGroupMember) => { |
| 1878 | const currentRouteParams = | |
| 1879 | router.state.matches[router.state.matches.length - 1]?.params ?? {}; | |
| 1880 | const currentRouteTarget = resolveThreadRouteTarget(currentRouteParams); | |
| 1881 | const currentActiveThread = | |
| 1882 | currentRouteTarget?.kind === "server" | |
| 1883 | ? readThreadShell(currentRouteTarget.threadRef) | |
| 1884 | : null; | |
| 1885 | const draftStore = useComposerDraftStore.getState(); | |
| 1886 | const currentActiveDraftThread = | |
| 1887 | currentRouteTarget?.kind === "server" | |
| 1888 | ? (draftStore.getDraftThread(currentRouteTarget.threadRef) ?? null) | |
| 1889 | : currentRouteTarget?.kind === "draft" | |
| 1890 | ? (draftStore.getDraftSession(currentRouteTarget.draftId) ?? null) | |
| 1891 | : null; | |
| 1892 | const seedContext = resolveSidebarNewThreadSeedContext({ | |
| 1893 | projectId: member.id, | |
| 1894 | // The default env mode is a user preference stored on the primary | |
| 1895 | // environment's settings.json; remote environments never carry it. | |
| 1896 | defaultEnvMode: resolveSidebarNewThreadEnvMode({ | |
| 1897 | defaultEnvMode: primaryServerSettings.defaultThreadEnvMode, | |
| 1898 | }), | |
| 1899 | activeThread: | |
| 1900 | currentActiveThread && currentActiveThread.projectId === member.id | |
| 1901 | ? { | |
| 1902 | projectId: currentActiveThread.projectId, | |
| 1903 | branch: currentActiveThread.branch, | |
| 1904 | worktreePath: currentActiveThread.worktreePath, | |
| 1905 | } | |
| 1906 | : null, | |
| 1907 | activeDraftThread: | |
| 1908 | currentActiveDraftThread && currentActiveDraftThread.projectId === member.id | |
| 1909 | ? { | |
| 1910 | projectId: currentActiveDraftThread.projectId, | |
| 1911 | branch: currentActiveDraftThread.branch, | |
| 1912 | worktreePath: currentActiveDraftThread.worktreePath, | |
| 1913 | envMode: currentActiveDraftThread.envMode, | |
| 1914 | startFromOrigin: currentActiveDraftThread.startFromOrigin, | |
| 1915 | } | |
| 1916 | : null, | |
| 1917 | }); | |
| 1918 | 1875 | if (isMobile) { |
| 1919 | 1876 | setOpenMobile(false); |
| 1920 | 1877 | } |
| 1921 | 1878 | void (async () => { |
| 1879 | // No options: branch, worktree, and env mode come from the user's | |
| 1880 | // configured defaults, never from the currently viewed thread. | |
| 1922 | 1881 | const result = await settlePromise(() => |
| 1923 | handleNewThread(scopeProjectRef(member.environmentId, member.id), { | |
| 1924 | ...(seedContext.branch !== undefined ? { branch: seedContext.branch } : {}), | |
| 1925 | ...(seedContext.worktreePath !== undefined | |
| 1926 | ? { worktreePath: seedContext.worktreePath } | |
| 1927 | : {}), | |
| 1928 | envMode: seedContext.envMode, | |
| 1929 | ...(seedContext.startFromOrigin !== undefined | |
| 1930 | ? { startFromOrigin: seedContext.startFromOrigin } | |
| 1931 | : {}), | |
| 1932 | }), | |
| 1882 | handleNewThread(scopeProjectRef(member.environmentId, member.id)), | |
| 1933 | 1883 | ); |
| 1934 | 1884 | if (result._tag === "Failure") { |
| 1935 | 1885 | const error = squashAtomCommandFailure(result); |
| ... | ... | @@ -1943,7 +1893,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 1943 | 1893 | } |
| 1944 | 1894 | })(); |
| 1945 | 1895 | }, |
| 1946 | [handleNewThread, isMobile, primaryServerSettings.defaultThreadEnvMode, router, setOpenMobile], | |
| 1896 | [handleNewThread, isMobile, setOpenMobile], | |
| 1947 | 1897 | ); |
| 1948 | 1898 | |
| 1949 | 1899 | const handleCreateThreadClick = useCallback( |
| ... | ... | @@ -2164,6 +2114,9 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 2164 | 2114 | thread.worktreePath ?? threadProject?.workspaceRoot ?? project.workspaceRoot ?? null; |
| 2165 | 2115 | const clicked = await api.contextMenu.show( |
| 2166 | 2116 | [ |
| 2117 | ...(thread.branch | |
| 2118 | ? [{ id: "new-thread-on-branch", label: `New thread on ${thread.branch}` }] | |
| 2119 | : []), | |
| 2167 | 2120 | { id: "rename", label: "Rename thread" }, |
| 2168 | 2121 | { id: "mark-unread", label: "Mark unread" }, |
| 2169 | 2122 | { id: "copy-path", label: "Copy Path" }, |
| ... | ... | @@ -2173,6 +2126,30 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 2173 | 2126 | position, |
| 2174 | 2127 | ); |
| 2175 | 2128 | |
| 2129 | if (clicked === "new-thread-on-branch") { | |
| 2130 | // Explicit branch carry-over: reuse the thread's worktree when it | |
| 2131 | // has one, otherwise its branch on the local checkout. | |
| 2132 | const result = await settlePromise(() => | |
| 2133 | handleNewThread(scopeProjectRef(thread.environmentId, thread.projectId), { | |
| 2134 | branch: thread.branch, | |
| 2135 | worktreePath: thread.worktreePath, | |
| 2136 | envMode: thread.worktreePath ? "worktree" : "local", | |
| 2137 | startFromOrigin: false, | |
| 2138 | }), | |
| 2139 | ); | |
| 2140 | if (result._tag === "Failure") { | |
| 2141 | const error = squashAtomCommandFailure(result); | |
| 2142 | toastManager.add( | |
| 2143 | stackedThreadToast({ | |
| 2144 | type: "error", | |
| 2145 | title: "Could not create thread", | |
| 2146 | description: error instanceof Error ? error.message : "An error occurred.", | |
| 2147 | }), | |
| 2148 | ); | |
| 2149 | } | |
| 2150 | return; | |
| 2151 | } | |
| 2152 | ||
| 2176 | 2153 | if (clicked === "rename") { |
| 2177 | 2154 | startThreadRename(threadKey, thread.title); |
| 2178 | 2155 | return; |
| ... | ... | @@ -2229,6 +2206,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 2229 | 2206 | copyPathToClipboard, |
| 2230 | 2207 | copyThreadIdToClipboard, |
| 2231 | 2208 | deleteThread, |
| 2209 | handleNewThread, | |
| 2232 | 2210 | markThreadUnread, |
| 2233 | 2211 | memberProjectByScopedKey, |
| 2234 | 2212 | project.workspaceRoot, |
apps/web/src/components/SidebarV2.tsx+31| ... | ... | @@ -1577,6 +1577,14 @@ export default function SidebarV2() { |
| 1577 | 1577 | const clicked = await settlePromise(() => |
| 1578 | 1578 | api.contextMenu.show( |
| 1579 | 1579 | [ |
| 1580 | ...(thread.branch | |
| 1581 | ? [ | |
| 1582 | { | |
| 1583 | id: "new-thread-on-branch", | |
| 1584 | label: `New thread on ${thread.branch}`, | |
| 1585 | }, | |
| 1586 | ] | |
| 1587 | : []), | |
| 1580 | 1588 | ...(supportsSettlement |
| 1581 | 1589 | ? [ |
| 1582 | 1590 | isSettled |
| ... | ... | @@ -1593,6 +1601,29 @@ export default function SidebarV2() { |
| 1593 | 1601 | ); |
| 1594 | 1602 | if (clicked._tag === "Failure") return; |
| 1595 | 1603 | switch (clicked.value) { |
| 1604 | case "new-thread-on-branch": { | |
| 1605 | // Explicit branch carry-over: reuse the thread's worktree when it | |
| 1606 | // has one, otherwise its branch on the local checkout. | |
| 1607 | const result = await settlePromise(() => | |
| 1608 | handleNewThreadRef.current(scopeProjectRef(thread.environmentId, thread.projectId), { | |
| 1609 | branch: thread.branch, | |
| 1610 | worktreePath: thread.worktreePath, | |
| 1611 | envMode: thread.worktreePath ? "worktree" : "local", | |
| 1612 | startFromOrigin: false, | |
| 1613 | }), | |
| 1614 | ); | |
| 1615 | if (result._tag === "Failure") { | |
| 1616 | const error = squashAtomCommandFailure(result); | |
| 1617 | toastManager.add( | |
| 1618 | stackedThreadToast({ | |
| 1619 | type: "error", | |
| 1620 | title: "Could not create thread", | |
| 1621 | description: error instanceof Error ? error.message : "An error occurred.", | |
| 1622 | }), | |
| 1623 | ); | |
| 1624 | } | |
| 1625 | return; | |
| 1626 | } | |
| 1596 | 1627 | case "settle": |
| 1597 | 1628 | attemptSettle(threadRef); |
| 1598 | 1629 | return; |
apps/web/src/composerDraftStore.ts+14-3| ... | ... | @@ -406,6 +406,15 @@ interface ComposerDraftStoreState { |
| 406 | 406 | setModelSelection: ( |
| 407 | 407 | threadRef: ComposerThreadTarget, |
| 408 | 408 | modelSelection: ModelSelection | null | undefined, |
| 409 | opts?: { | |
| 410 | /** | |
| 411 | * Replace the stored entry outright instead of preserving its | |
| 412 | * existing options when the incoming selection has none. Used when | |
| 413 | * the selection is a complete snapshot (e.g. carried from another | |
| 414 | * thread) rather than a model-only change. | |
| 415 | */ | |
| 416 | replaceOptions?: boolean; | |
| 417 | }, | |
| 409 | 418 | ) => void; |
| 410 | 419 | /** Replace the model options for one or more providers in the draft. */ |
| 411 | 420 | setModelOptions: ( |
| ... | ... | @@ -2594,7 +2603,7 @@ const composerDraftStore = create<ComposerDraftStoreState>()( |
| 2594 | 2603 | return { draftsByThreadKey: nextDraftsByThreadKey }; |
| 2595 | 2604 | }); |
| 2596 | 2605 | }, |
| 2597 | setModelSelection: (threadRef, modelSelection) => { | |
| 2606 | setModelSelection: (threadRef, modelSelection, opts) => { | |
| 2598 | 2607 | const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; |
| 2599 | 2608 | if (threadKey.length === 0) { |
| 2600 | 2609 | return; |
| ... | ... | @@ -2609,8 +2618,10 @@ const composerDraftStore = create<ComposerDraftStoreState>()( |
| 2609 | 2618 | const nextMap = { ...base.modelSelectionByProvider }; |
| 2610 | 2619 | if (normalized) { |
| 2611 | 2620 | const current = nextMap[normalized.instanceId]; |
| 2612 | if (normalized.options !== undefined) { | |
| 2613 | // Explicit options provided → use them | |
| 2621 | if (normalized.options !== undefined || opts?.replaceOptions) { | |
| 2622 | // Explicit options provided (or the caller passed a complete | |
| 2623 | // snapshot whose absent options mean "no options") → use the | |
| 2624 | // selection as-is. | |
| 2614 | 2625 | nextMap[normalized.instanceId] = normalized as ModelSelection; |
| 2615 | 2626 | } else { |
| 2616 | 2627 | // No options in selection → preserve existing options, update provider+model |
apps/web/src/hooks/useHandleNewThread.ts+100-8| ... | ... | @@ -54,14 +54,52 @@ export function useNewThreadHandler() { |
| 54 | 54 | }, |
| 55 | 55 | ): Promise<void> => { |
| 56 | 56 | const { |
| 57 | getComposerDraft, | |
| 57 | 58 | getDraftSessionByLogicalProjectKey, |
| 58 | 59 | getDraftSession, |
| 59 | 60 | getDraftThread, |
| 60 | 61 | applyStickyState, |
| 61 | 62 | setDraftThreadContext, |
| 62 | 63 | setLogicalProjectDraftThreadId, |
| 64 | setModelSelection, | |
| 63 | 65 | } = useComposerDraftStore.getState(); |
| 64 | 66 | const currentRouteTarget = getCurrentRouteTarget(); |
| 67 | // A new thread carries the user's *working mode* from the thread being | |
| 68 | // viewed: model (including options like reasoning effort and context | |
| 69 | // window), permission mode, and interaction mode. Branch, worktree, and | |
| 70 | // env mode never carry implicitly — those come from the configured | |
| 71 | // defaults unless the caller passes them explicitly. | |
| 72 | const carrySourceShell = | |
| 73 | currentRouteTarget?.kind === "server" | |
| 74 | ? readThreadShell(currentRouteTarget.threadRef) | |
| 75 | : null; | |
| 76 | const carrySourceDraft = | |
| 77 | currentRouteTarget?.kind === "draft" ? getDraftSession(currentRouteTarget.draftId) : null; | |
| 78 | // Composer overrides win over the persisted thread state — they are | |
| 79 | // what the user currently sees in the composer controls. | |
| 80 | const carrySourceComposer = currentRouteTarget | |
| 81 | ? getComposerDraft( | |
| 82 | currentRouteTarget.kind === "server" | |
| 83 | ? currentRouteTarget.threadRef | |
| 84 | : currentRouteTarget.draftId, | |
| 85 | ) | |
| 86 | : null; | |
| 87 | const composerActiveProvider = carrySourceComposer?.activeProvider ?? null; | |
| 88 | const composerModelSelection = composerActiveProvider | |
| 89 | ? (carrySourceComposer?.modelSelectionByProvider[composerActiveProvider] ?? null) | |
| 90 | : null; | |
| 91 | const carryModelSelection = | |
| 92 | composerModelSelection ?? carrySourceShell?.modelSelection ?? null; | |
| 93 | const carryRuntimeMode = | |
| 94 | carrySourceComposer?.runtimeMode ?? | |
| 95 | carrySourceShell?.runtimeMode ?? | |
| 96 | carrySourceDraft?.runtimeMode ?? | |
| 97 | null; | |
| 98 | const carryInteractionMode = | |
| 99 | carrySourceComposer?.interactionMode ?? | |
| 100 | carrySourceShell?.interactionMode ?? | |
| 101 | carrySourceDraft?.interactionMode ?? | |
| 102 | null; | |
| 65 | 103 | const project = projects.find( |
| 66 | 104 | (candidate) => |
| 67 | 105 | candidate.id === projectRef.projectId && |
| ... | ... | @@ -92,25 +130,70 @@ export function useNewThreadHandler() { |
| 92 | 130 | : null; |
| 93 | 131 | if (reusableStoredDraftThread) { |
| 94 | 132 | return (async () => { |
| 95 | if ( | |
| 133 | const isDraftAlreadyOpen = | |
| 134 | currentRouteTarget?.kind === "draft" && | |
| 135 | currentRouteTarget.draftId === reusableStoredDraftThread.draftId; | |
| 136 | const hasExplicitWorkspaceOption = | |
| 96 | 137 | hasBranchOption || |
| 97 | 138 | hasWorktreePathOption || |
| 98 | 139 | hasEnvModeOption || |
| 99 | hasStartFromOriginOption | |
| 100 | ) { | |
| 140 | hasStartFromOriginOption; | |
| 141 | // Resurrecting a stored draft must not resurrect its stale context: | |
| 142 | // explicit workspace options win outright; otherwise the env context | |
| 143 | // resets to the configured defaults so drafts seeded before a | |
| 144 | // defaults change (or by the old carry-over behavior) stop landing | |
| 145 | // on "current checkout" branches forever. Composer text is | |
| 146 | // preserved. When the draft is already open and no options were | |
| 147 | // passed, leave it alone entirely — the user may have just picked a | |
| 148 | // branch in the composer. | |
| 149 | const defaultEnvMode = primaryServerSettings.defaultThreadEnvMode; | |
| 150 | const workspaceContext = hasExplicitWorkspaceOption | |
| 151 | ? { | |
| 152 | ...(hasBranchOption ? { branch: options?.branch ?? null } : {}), | |
| 153 | ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}), | |
| 154 | ...(hasEnvModeOption ? { envMode: options?.envMode } : {}), | |
| 155 | ...(hasStartFromOriginOption ? { startFromOrigin: options?.startFromOrigin } : {}), | |
| 156 | } | |
| 157 | : isDraftAlreadyOpen | |
| 158 | ? null | |
| 159 | : { | |
| 160 | branch: null, | |
| 161 | worktreePath: null, | |
| 162 | envMode: defaultEnvMode, | |
| 163 | startFromOrigin: resolveNewDraftStartFromOrigin({ | |
| 164 | envMode: defaultEnvMode, | |
| 165 | newWorktreesStartFromOrigin: primaryServerSettings.newWorktreesStartFromOrigin, | |
| 166 | }), | |
| 167 | }; | |
| 168 | if (workspaceContext) { | |
| 101 | 169 | setDraftThreadContext(reusableStoredDraftThread.draftId, { |
| 102 | ...(hasBranchOption ? { branch: options?.branch ?? null } : {}), | |
| 103 | ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}), | |
| 104 | ...(hasEnvModeOption ? { envMode: options?.envMode } : {}), | |
| 105 | ...(hasStartFromOriginOption ? { startFromOrigin: options?.startFromOrigin } : {}), | |
| 170 | ...workspaceContext, | |
| 171 | ...(carryRuntimeMode ? { runtimeMode: carryRuntimeMode } : {}), | |
| 172 | ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}), | |
| 106 | 173 | }); |
| 174 | if (carryModelSelection) { | |
| 175 | // The carried selection is a complete snapshot of the viewed | |
| 176 | // thread's model state: absent options mean "no options", not | |
| 177 | // "keep the stale draft's options". | |
| 178 | setModelSelection(reusableStoredDraftThread.draftId, carryModelSelection, { | |
| 179 | replaceOptions: true, | |
| 180 | }); | |
| 181 | } | |
| 107 | 182 | } |
| 183 | // The workspace context must also ride along here: when projectRef | |
| 184 | // targets a different physical member of the logical project, | |
| 185 | // createDraftThreadState treats the remap as a project change and | |
| 186 | // would otherwise wipe branch/worktree and force "local" mode, | |
| 187 | // undoing the write above. | |
| 108 | 188 | setLogicalProjectDraftThreadId( |
| 109 | 189 | logicalProjectKey, |
| 110 | 190 | projectRef, |
| 111 | 191 | reusableStoredDraftThread.draftId, |
| 112 | 192 | { |
| 113 | 193 | threadId: reusableStoredDraftThread.threadId, |
| 194 | ...(workspaceContext ?? {}), | |
| 195 | ...(carryRuntimeMode ? { runtimeMode: carryRuntimeMode } : {}), | |
| 196 | ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}), | |
| 114 | 197 | }, |
| 115 | 198 | ); |
| 116 | 199 | if ( |
| ... | ... | @@ -176,9 +259,18 @@ export function useNewThreadHandler() { |
| 176 | 259 | envMode: initialEnvMode, |
| 177 | 260 | newWorktreesStartFromOrigin: primaryServerSettings.newWorktreesStartFromOrigin, |
| 178 | 261 | }), |
| 179 | runtimeMode: DEFAULT_RUNTIME_MODE, | |
| 262 | runtimeMode: carryRuntimeMode ?? DEFAULT_RUNTIME_MODE, | |
| 263 | ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}), | |
| 180 | 264 | }); |
| 181 | 265 | applyStickyState(draftId); |
| 266 | if (carryModelSelection) { | |
| 267 | // After sticky state so the viewed thread's exact selection | |
| 268 | // (model + options like effort and context window) wins over the | |
| 269 | // globally sticky one. replaceOptions: the carried selection is a | |
| 270 | // complete snapshot — absent options mean "no options", not "keep | |
| 271 | // whatever sticky state just wrote". | |
| 272 | setModelSelection(draftId, carryModelSelection, { replaceOptions: true }); | |
| 273 | } | |
| 182 | 274 | |
| 183 | 275 | await router.navigate({ |
| 184 | 276 | to: "/draft/$draftId", |
apps/web/src/lib/chatThreadActions.test.ts+10-71| ... | ... | @@ -4,9 +4,7 @@ import { describe, expect, it, vi } from "vite-plus/test"; |
| 4 | 4 | import { |
| 5 | 5 | resolveThreadActionProjectRef, |
| 6 | 6 | resolveNewDraftStartFromOrigin, |
| 7 | startNewLocalThreadFromContext, | |
| 8 | 7 | startNewThreadFromContext, |
| 9 | startNewThreadInProjectFromContext, | |
| 10 | 8 | type ChatThreadActionContext, |
| 11 | 9 | } from "./chatThreadActions"; |
| 12 | 10 | |
| ... | ... | @@ -40,16 +38,12 @@ describe("chatThreadActions", () => { |
| 40 | 38 | ).toBe(false); |
| 41 | 39 | }); |
| 42 | 40 | |
| 43 | it("prefers the active draft thread project when resolving thread actions", () => { | |
| 41 | it("prefers the active thread project when resolving thread actions", () => { | |
| 44 | 42 | const projectRef = resolveThreadActionProjectRef( |
| 45 | 43 | createContext({ |
| 46 | activeDraftThread: { | |
| 44 | activeThread: { | |
| 47 | 45 | environmentId: ENVIRONMENT_ID, |
| 48 | 46 | projectId: PROJECT_ID, |
| 49 | branch: "feature/refactor", | |
| 50 | worktreePath: "/tmp/worktree", | |
| 51 | envMode: "worktree", | |
| 52 | startFromOrigin: true, | |
| 53 | 47 | }, |
| 54 | 48 | }), |
| 55 | 49 | ); |
| ... | ... | @@ -57,95 +51,40 @@ describe("chatThreadActions", () => { |
| 57 | 51 | expect(projectRef).toEqual(scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID)); |
| 58 | 52 | }); |
| 59 | 53 | |
| 60 | it("falls back to the default project ref when there is no active thread context", () => { | |
| 54 | it("falls back to the active draft thread project when there is no active thread", () => { | |
| 61 | 55 | const projectRef = resolveThreadActionProjectRef( |
| 62 | createContext({ | |
| 63 | defaultProjectRef: scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID), | |
| 64 | }), | |
| 65 | ); | |
| 66 | ||
| 67 | expect(projectRef).toEqual(scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID)); | |
| 68 | }); | |
| 69 | ||
| 70 | it("starts a contextual new thread from the active draft thread", async () => { | |
| 71 | const handleNewThread = vi.fn<ChatThreadActionContext["handleNewThread"]>(async () => {}); | |
| 72 | ||
| 73 | const didStart = await startNewThreadFromContext( | |
| 74 | 56 | createContext({ |
| 75 | 57 | activeDraftThread: { |
| 76 | 58 | environmentId: ENVIRONMENT_ID, |
| 77 | 59 | projectId: PROJECT_ID, |
| 78 | branch: "feature/refactor", | |
| 79 | worktreePath: "/tmp/worktree", | |
| 80 | envMode: "worktree", | |
| 81 | startFromOrigin: true, | |
| 82 | 60 | }, |
| 83 | handleNewThread, | |
| 84 | 61 | }), |
| 85 | 62 | ); |
| 86 | 63 | |
| 87 | expect(didStart).toBe(true); | |
| 88 | expect(handleNewThread).toHaveBeenCalledWith(scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID), { | |
| 89 | branch: "feature/refactor", | |
| 90 | worktreePath: "/tmp/worktree", | |
| 91 | envMode: "worktree", | |
| 92 | startFromOrigin: true, | |
| 93 | }); | |
| 64 | expect(projectRef).toEqual(scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID)); | |
| 94 | 65 | }); |
| 95 | 66 | |
| 96 | it("preserves an explicitly disabled origin base in contextual thread options", async () => { | |
| 97 | const handleNewThread = vi.fn<ChatThreadActionContext["handleNewThread"]>(async () => {}); | |
| 98 | ||
| 99 | await startNewThreadFromContext( | |
| 67 | it("falls back to the default project ref when there is no active thread context", () => { | |
| 68 | const projectRef = resolveThreadActionProjectRef( | |
| 100 | 69 | createContext({ |
| 101 | activeDraftThread: { | |
| 102 | environmentId: ENVIRONMENT_ID, | |
| 103 | projectId: PROJECT_ID, | |
| 104 | branch: "feature/refactor", | |
| 105 | worktreePath: "/tmp/worktree", | |
| 106 | envMode: "worktree", | |
| 107 | startFromOrigin: false, | |
| 108 | }, | |
| 109 | handleNewThread, | |
| 70 | defaultProjectRef: scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID), | |
| 110 | 71 | }), |
| 111 | 72 | ); |
| 112 | 73 | |
| 113 | expect(handleNewThread).toHaveBeenCalledWith(scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID), { | |
| 114 | branch: "feature/refactor", | |
| 115 | worktreePath: "/tmp/worktree", | |
| 116 | envMode: "worktree", | |
| 117 | startFromOrigin: false, | |
| 118 | }); | |
| 74 | expect(projectRef).toEqual(scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID)); | |
| 119 | 75 | }); |
| 120 | 76 | |
| 121 | it("does not carry branch or worktree context into a different project", async () => { | |
| 77 | it("inherits only the project from context, never branch or worktree state", async () => { | |
| 122 | 78 | const handleNewThread = vi.fn<ChatThreadActionContext["handleNewThread"]>(async () => {}); |
| 123 | const targetProjectRef = scopeProjectRef(ENVIRONMENT_ID, FALLBACK_PROJECT_ID); | |
| 124 | 79 | |
| 125 | await startNewThreadInProjectFromContext( | |
| 80 | const didStart = await startNewThreadFromContext( | |
| 126 | 81 | createContext({ |
| 127 | 82 | activeThread: { |
| 128 | 83 | environmentId: ENVIRONMENT_ID, |
| 129 | 84 | projectId: PROJECT_ID, |
| 130 | branch: "feature/refactor", | |
| 131 | worktreePath: "/tmp/worktree", | |
| 132 | 85 | }, |
| 133 | 86 | handleNewThread, |
| 134 | 87 | }), |
| 135 | targetProjectRef, | |
| 136 | ); | |
| 137 | ||
| 138 | expect(handleNewThread).toHaveBeenCalledWith(targetProjectRef); | |
| 139 | }); | |
| 140 | ||
| 141 | it("delegates the target environment defaults to the new-thread handler", async () => { | |
| 142 | const handleNewThread = vi.fn<ChatThreadActionContext["handleNewThread"]>(async () => {}); | |
| 143 | ||
| 144 | const didStart = await startNewLocalThreadFromContext( | |
| 145 | createContext({ | |
| 146 | defaultProjectRef: scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID), | |
| 147 | handleNewThread, | |
| 148 | }), | |
| 149 | 88 | ); |
| 150 | 89 | |
| 151 | 90 | expect(didStart).toBe(true); |
apps/web/src/lib/chatThreadActions.ts+7-51| ... | ... | @@ -5,13 +5,6 @@ import type { DraftThreadEnvMode } from "../composerDraftStore"; |
| 5 | 5 | interface ThreadContextLike { |
| 6 | 6 | environmentId: EnvironmentId; |
| 7 | 7 | projectId: ProjectId; |
| 8 | branch: string | null; | |
| 9 | worktreePath: string | null; | |
| 10 | } | |
| 11 | ||
| 12 | interface DraftThreadContextLike extends ThreadContextLike { | |
| 13 | envMode: DraftThreadEnvMode; | |
| 14 | startFromOrigin: boolean; | |
| 15 | 8 | } |
| 16 | 9 | |
| 17 | 10 | interface NewThreadHandler { |
| ... | ... | @@ -26,10 +19,8 @@ interface NewThreadHandler { |
| 26 | 19 | ): Promise<void>; |
| 27 | 20 | } |
| 28 | 21 | |
| 29 | type NewThreadOptions = NonNullable<Parameters<NewThreadHandler>[1]>; | |
| 30 | ||
| 31 | 22 | export interface ChatThreadActionContext { |
| 32 | readonly activeDraftThread: DraftThreadContextLike | null; | |
| 23 | readonly activeDraftThread: ThreadContextLike | null; | |
| 33 | 24 | readonly activeThread: ThreadContextLike | undefined; |
| 34 | 25 | readonly defaultProjectRef: ScopedProjectRef | null; |
| 35 | 26 | readonly handleNewThread: NewThreadHandler; |
| ... | ... | @@ -57,35 +48,12 @@ export function resolveThreadActionProjectRef( |
| 57 | 48 | return context.defaultProjectRef; |
| 58 | 49 | } |
| 59 | 50 | |
| 60 | function buildContextualThreadOptions(context: ChatThreadActionContext): NewThreadOptions { | |
| 61 | return { | |
| 62 | branch: context.activeThread?.branch ?? context.activeDraftThread?.branch ?? null, | |
| 63 | worktreePath: | |
| 64 | context.activeThread?.worktreePath ?? context.activeDraftThread?.worktreePath ?? null, | |
| 65 | envMode: | |
| 66 | context.activeDraftThread?.envMode ?? | |
| 67 | (context.activeThread?.worktreePath ? "worktree" : "local"), | |
| 68 | ...(context.activeDraftThread | |
| 69 | ? { startFromOrigin: context.activeDraftThread.startFromOrigin } | |
| 70 | : {}), | |
| 71 | }; | |
| 72 | } | |
| 73 | ||
| 74 | export async function startNewThreadInProjectFromContext( | |
| 75 | context: ChatThreadActionContext, | |
| 76 | projectRef: ScopedProjectRef, | |
| 77 | ): Promise<void> { | |
| 78 | const contextualProjectRef = resolveThreadActionProjectRef(context); | |
| 79 | const matchesContext = | |
| 80 | contextualProjectRef?.environmentId === projectRef.environmentId && | |
| 81 | contextualProjectRef.projectId === projectRef.projectId; | |
| 82 | if (!matchesContext) { | |
| 83 | await context.handleNewThread(projectRef); | |
| 84 | return; | |
| 85 | } | |
| 86 | await context.handleNewThread(projectRef, buildContextualThreadOptions(context)); | |
| 87 | } | |
| 88 | ||
| 51 | // New threads inherit only the *project* from the current context. Branch, | |
| 52 | // worktree, and env mode always come from the user's configured defaults — | |
| 53 | // carrying them over from the viewed thread meant "new thread" silently | |
| 54 | // reused checkouts and branches. Explicit affordances (branch toolbar's | |
| 55 | // "new thread in this worktree") pass those options to handleNewThread | |
| 56 | // directly instead. | |
| 89 | 57 | export async function startNewThreadFromContext( |
| 90 | 58 | context: ChatThreadActionContext, |
| 91 | 59 | ): Promise<boolean> { |
| ... | ... | @@ -94,18 +62,6 @@ export async function startNewThreadFromContext( |
| 94 | 62 | return false; |
| 95 | 63 | } |
| 96 | 64 | |
| 97 | await startNewThreadInProjectFromContext(context, projectRef); | |
| 98 | return true; | |
| 99 | } | |
| 100 | ||
| 101 | export async function startNewLocalThreadFromContext( | |
| 102 | context: ChatThreadActionContext, | |
| 103 | ): Promise<boolean> { | |
| 104 | const projectRef = resolveThreadActionProjectRef(context); | |
| 105 | if (!projectRef) { | |
| 106 | return false; | |
| 107 | } | |
| 108 | ||
| 109 | 65 | await context.handleNewThread(projectRef); |
| 110 | 66 | return true; |
| 111 | 67 | } |
apps/web/src/routes/_chat.tsx+2-5| ... | ... | @@ -11,10 +11,7 @@ import { selectProjectGroupingSettings } from "../logicalProject"; |
| 11 | 11 | import { buildSidebarProjectSnapshots } from "../sidebarProjectGrouping"; |
| 12 | 12 | import { dispatchPreviewAction } from "../components/preview/previewActionBus"; |
| 13 | 13 | import { useHandleNewThread } from "../hooks/useHandleNewThread"; |
| 14 | import { | |
| 15 | startNewLocalThreadFromContext, | |
| 16 | startNewThreadFromContext, | |
| 17 | } from "../lib/chatThreadActions"; | |
| 14 | import { startNewThreadFromContext } from "../lib/chatThreadActions"; | |
| 18 | 15 | import { isPreviewFocused } from "../lib/previewFocus"; |
| 19 | 16 | import { isTerminalFocused } from "../lib/terminalFocus"; |
| 20 | 17 | import { resolveShortcutCommand } from "../keybindings"; |
| ... | ... | @@ -83,7 +80,7 @@ function ChatRouteGlobalShortcuts() { |
| 83 | 80 | if (command === "chat.newLocal") { |
| 84 | 81 | event.preventDefault(); |
| 85 | 82 | event.stopPropagation(); |
| 86 | void startNewLocalThreadFromContext({ | |
| 83 | void startNewThreadFromContext({ | |
| 87 | 84 | activeDraftThread, |
| 88 | 85 | activeThread: activeThread ?? undefined, |
| 89 | 86 | defaultProjectRef, |