| 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,6 +12,8 @@ import { |
| 12 | resolveBranchToolbarValue, | 12 | resolveBranchToolbarValue, |
| 13 | resolveLockedWorkspaceLabel, | 13 | resolveLockedWorkspaceLabel, |
| 14 | resolveLocalCheckoutBranchMismatch, | 14 | resolveLocalCheckoutBranchMismatch, |
| 15 | resolvePreviousWorktreeLabel, | ||
| 16 | resolvePreviousWorktreeSeed, | ||
| 15 | shouldIncludeBranchPickerItem, | 17 | shouldIncludeBranchPickerItem, |
| 16 | shouldShowEnvironmentIndicator, | 18 | shouldShowEnvironmentIndicator, |
| 17 | } from "./BranchToolbar.logic"; | 19 | } from "./BranchToolbar.logic"; |
| ... | @@ -19,6 +21,91 @@ import { | ... | @@ -19,6 +21,91 @@ import { |
| 19 | const localEnvironmentId = EnvironmentId.make("environment-local"); | 21 | const localEnvironmentId = EnvironmentId.make("environment-local"); |
| 20 | const remoteEnvironmentId = EnvironmentId.make("environment-remote"); | 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 | describe("resolveDraftEnvModeAfterBranchChange", () => { | 109 | describe("resolveDraftEnvModeAfterBranchChange", () => { |
| 23 | it("switches to local mode when returning from an existing worktree to the main worktree", () => { | 110 | it("switches to local mode when returning from an existing worktree to the main worktree", () => { |
| 24 | expect( | 111 | expect( |
apps/web/src/components/BranchToolbar.logic.ts+48| ... | @@ -1,5 +1,6 @@ | ... | @@ -1,5 +1,6 @@ |
| 1 | import type { EnvironmentId, VcsRef, ProjectId } from "@t3tools/contracts"; | 1 | import type { EnvironmentId, VcsRef, ProjectId } from "@t3tools/contracts"; |
| 2 | import * as Schema from "effect/Schema"; | 2 | import * as Schema from "effect/Schema"; |
| 3 | import { toSortableTimestamp } from "../lib/threadSort"; | ||
| 3 | export { | 4 | export { |
| 4 | dedupeRemoteBranchesWithLocalMatches, | 5 | dedupeRemoteBranchesWithLocalMatches, |
| 5 | deriveLocalBranchNameFromRemoteRef, | 6 | deriveLocalBranchNameFromRemoteRef, |
| ... | @@ -65,6 +66,53 @@ export function resolveLockedWorkspaceLabel(activeWorktreePath: string | null): | ... | @@ -65,6 +66,53 @@ export function resolveLockedWorkspaceLabel(activeWorktreePath: string | null): |
| 65 | return activeWorktreePath ? "Worktree" : "Local checkout"; | 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 | export function resolveEffectiveEnvMode(input: { | 116 | export function resolveEffectiveEnvMode(input: { |
| 69 | activeWorktreePath: string | null; | 117 | activeWorktreePath: string | null; |
| 70 | hasServerThread: boolean; | 118 | hasServerThread: boolean; |
apps/web/src/components/BranchToolbar.tsx+63-3| ... | @@ -6,12 +6,13 @@ import { | ... | @@ -6,12 +6,13 @@ import { |
| 6 | FolderGit2Icon, | 6 | FolderGit2Icon, |
| 7 | FolderGitIcon, | 7 | FolderGitIcon, |
| 8 | FolderIcon, | 8 | FolderIcon, |
| 9 | HistoryIcon, | ||
| 9 | MonitorIcon, | 10 | MonitorIcon, |
| 10 | } from "lucide-react"; | 11 | } from "lucide-react"; |
| 11 | import { memo, useMemo } from "react"; | 12 | import { memo, useCallback, useMemo } from "react"; |
| 12 | 13 | ||
| 13 | import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; | 14 | import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; |
| 14 | import { useProject, useThread } from "../state/entities"; | 15 | import { useProject, useThread, useThreadShellsForProjectRefs } from "../state/entities"; |
| 15 | import { useIsMobile } from "../hooks/useMediaQuery"; | 16 | import { useIsMobile } from "../hooks/useMediaQuery"; |
| 16 | import { | 17 | import { |
| 17 | type EnvMode, | 18 | type EnvMode, |
| ... | @@ -20,6 +21,8 @@ import { | ... | @@ -20,6 +21,8 @@ import { |
| 20 | resolveEnvModeLabel, | 21 | resolveEnvModeLabel, |
| 21 | resolveEffectiveEnvMode, | 22 | resolveEffectiveEnvMode, |
| 22 | resolveLockedWorkspaceLabel, | 23 | resolveLockedWorkspaceLabel, |
| 24 | resolvePreviousWorktreeLabel, | ||
| 25 | resolvePreviousWorktreeSeed, | ||
| 23 | shouldShowEnvironmentIndicator, | 26 | shouldShowEnvironmentIndicator, |
| 24 | } from "./BranchToolbar.logic"; | 27 | } from "./BranchToolbar.logic"; |
| 25 | import { BranchToolbarBranchSelector } from "./BranchToolbarBranchSelector"; | 28 | import { BranchToolbarBranchSelector } from "./BranchToolbarBranchSelector"; |
| ... | @@ -66,6 +69,8 @@ interface MobileRunContextSelectorProps { | ... | @@ -66,6 +69,8 @@ interface MobileRunContextSelectorProps { |
| 66 | effectiveEnvMode: EnvMode; | 69 | effectiveEnvMode: EnvMode; |
| 67 | activeWorktreePath: string | null; | 70 | activeWorktreePath: string | null; |
| 68 | onEnvModeChange: (mode: EnvMode) => void; | 71 | onEnvModeChange: (mode: EnvMode) => void; |
| 72 | previousWorktreeLabel: string | null; | ||
| 73 | onUsePreviousWorktree: () => void; | ||
| 69 | } | 74 | } |
| 70 | 75 | ||
| 71 | const MobileRunContextSelector = memo(function MobileRunContextSelector({ | 76 | const MobileRunContextSelector = memo(function MobileRunContextSelector({ |
| ... | @@ -79,6 +84,8 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ | ... | @@ -79,6 +84,8 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ |
| 79 | effectiveEnvMode, | 84 | effectiveEnvMode, |
| 80 | activeWorktreePath, | 85 | activeWorktreePath, |
| 81 | onEnvModeChange, | 86 | onEnvModeChange, |
| 87 | previousWorktreeLabel, | ||
| 88 | onUsePreviousWorktree, | ||
| 82 | }: MobileRunContextSelectorProps) { | 89 | }: MobileRunContextSelectorProps) { |
| 83 | const activeEnvironment = useMemo( | 90 | const activeEnvironment = useMemo( |
| 84 | () => availableEnvironments?.find((env) => env.environmentId === environmentId) ?? null, | 91 | () => availableEnvironments?.find((env) => env.environmentId === environmentId) ?? null, |
| ... | @@ -166,7 +173,13 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ | ... | @@ -166,7 +173,13 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ |
| 166 | <MenuGroupLabel>Workspace</MenuGroupLabel> | 173 | <MenuGroupLabel>Workspace</MenuGroupLabel> |
| 167 | <MenuRadioGroup | 174 | <MenuRadioGroup |
| 168 | value={effectiveEnvMode} | 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 | <MenuRadioItem disabled={envModeLocked} value="local"> | 184 | <MenuRadioItem disabled={envModeLocked} value="local"> |
| 172 | <span className="flex min-w-0 items-center gap-1.5"> | 185 | <span className="flex min-w-0 items-center gap-1.5"> |
| ... | @@ -186,6 +199,14 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ | ... | @@ -186,6 +199,14 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ |
| 186 | <span className="min-w-0 truncate">{resolveEnvModeLabel("worktree")}</span> | 199 | <span className="min-w-0 truncate">{resolveEnvModeLabel("worktree")}</span> |
| 187 | </span> | 200 | </span> |
| 188 | </MenuRadioItem> | 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 | </MenuRadioGroup> | 210 | </MenuRadioGroup> |
| 190 | </MenuGroup> | 211 | </MenuGroup> |
| 191 | </MenuPopup> | 212 | </MenuPopup> |
| ... | @@ -217,6 +238,7 @@ export const BranchToolbar = memo(function BranchToolbar({ | ... | @@ -217,6 +238,7 @@ export const BranchToolbar = memo(function BranchToolbar({ |
| 217 | const draftThread = useComposerDraftStore((store) => | 238 | const draftThread = useComposerDraftStore((store) => |
| 218 | draftId ? store.getDraftSession(draftId) : store.getDraftThreadByRef(threadRef), | 239 | draftId ? store.getDraftSession(draftId) : store.getDraftThreadByRef(threadRef), |
| 219 | ); | 240 | ); |
| 241 | const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); | ||
| 220 | const activeProjectRef = serverThread | 242 | const activeProjectRef = serverThread |
| 221 | ? scopeProjectRef(serverThread.environmentId, serverThread.projectId) | 243 | ? scopeProjectRef(serverThread.environmentId, serverThread.projectId) |
| 222 | : draftThread | 244 | : draftThread |
| ... | @@ -234,6 +256,40 @@ export const BranchToolbar = memo(function BranchToolbar({ | ... | @@ -234,6 +256,40 @@ export const BranchToolbar = memo(function BranchToolbar({ |
| 234 | }); | 256 | }); |
| 235 | const envModeLocked = envLocked || (serverThread !== null && activeWorktreePath !== null); | 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 | const showEnvironmentPicker = Boolean( | 293 | const showEnvironmentPicker = Boolean( |
| 238 | availableEnvironments && availableEnvironments.length > 1 && onEnvironmentChange, | 294 | availableEnvironments && availableEnvironments.length > 1 && onEnvironmentChange, |
| 239 | ); | 295 | ); |
| ... | @@ -261,6 +317,8 @@ export const BranchToolbar = memo(function BranchToolbar({ | ... | @@ -261,6 +317,8 @@ export const BranchToolbar = memo(function BranchToolbar({ |
| 261 | effectiveEnvMode={effectiveEnvMode} | 317 | effectiveEnvMode={effectiveEnvMode} |
| 262 | activeWorktreePath={activeWorktreePath} | 318 | activeWorktreePath={activeWorktreePath} |
| 263 | onEnvModeChange={onEnvModeChange} | 319 | onEnvModeChange={onEnvModeChange} |
| 320 | previousWorktreeLabel={previousWorktreeLabel} | ||
| 321 | onUsePreviousWorktree={onUsePreviousWorktree} | ||
| 264 | /> | 322 | /> |
| 265 | ) : ( | 323 | ) : ( |
| 266 | <div className="flex min-w-0 shrink-0 items-center gap-1"> | 324 | <div className="flex min-w-0 shrink-0 items-center gap-1"> |
| ... | @@ -280,6 +338,8 @@ export const BranchToolbar = memo(function BranchToolbar({ | ... | @@ -280,6 +338,8 @@ export const BranchToolbar = memo(function BranchToolbar({ |
| 280 | effectiveEnvMode={effectiveEnvMode} | 338 | effectiveEnvMode={effectiveEnvMode} |
| 281 | activeWorktreePath={activeWorktreePath} | 339 | activeWorktreePath={activeWorktreePath} |
| 282 | onEnvModeChange={onEnvModeChange} | 340 | onEnvModeChange={onEnvModeChange} |
| 341 | previousWorktreeLabel={previousWorktreeLabel} | ||
| 342 | onUsePreviousWorktree={onUsePreviousWorktree} | ||
| 283 | /> | 343 | /> |
| 284 | </div> | 344 | </div> |
| 285 | )} | 345 | )} |
apps/web/src/components/BranchToolbarEnvModeSelector.tsx+27-3| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import { FolderGit2Icon, FolderGitIcon, FolderIcon } from "lucide-react"; | 1 | import { FolderGit2Icon, FolderGitIcon, FolderIcon, HistoryIcon } from "lucide-react"; |
| 2 | import { memo, useMemo } from "react"; | 2 | import { memo, useMemo } from "react"; |
| 3 | 3 | ||
| 4 | import { | 4 | import { |
| ... | @@ -17,11 +17,15 @@ import { | ... | @@ -17,11 +17,15 @@ import { |
| 17 | SelectValue, | 17 | SelectValue, |
| 18 | } from "./ui/select"; | 18 | } from "./ui/select"; |
| 19 | 19 | ||
| 20 | export const PREVIOUS_WORKTREE_SELECT_VALUE = "previous-worktree"; | ||
| 21 | |||
| 20 | interface BranchToolbarEnvModeSelectorProps { | 22 | interface BranchToolbarEnvModeSelectorProps { |
| 21 | envLocked: boolean; | 23 | envLocked: boolean; |
| 22 | effectiveEnvMode: EnvMode; | 24 | effectiveEnvMode: EnvMode; |
| 23 | activeWorktreePath: string | null; | 25 | activeWorktreePath: string | null; |
| 24 | onEnvModeChange: (mode: EnvMode) => void; | 26 | onEnvModeChange: (mode: EnvMode) => void; |
| 27 | previousWorktreeLabel?: string | null; | ||
| 28 | onUsePreviousWorktree?: () => void; | ||
| 25 | } | 29 | } |
| 26 | 30 | ||
| 27 | export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSelector({ | 31 | export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSelector({ |
| ... | @@ -29,13 +33,19 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe | ... | @@ -29,13 +33,19 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe |
| 29 | effectiveEnvMode, | 33 | effectiveEnvMode, |
| 30 | activeWorktreePath, | 34 | activeWorktreePath, |
| 31 | onEnvModeChange, | 35 | onEnvModeChange, |
| 36 | previousWorktreeLabel, | ||
| 37 | onUsePreviousWorktree, | ||
| 32 | }: BranchToolbarEnvModeSelectorProps) { | 38 | }: BranchToolbarEnvModeSelectorProps) { |
| 39 | const showPreviousWorktree = Boolean(previousWorktreeLabel && onUsePreviousWorktree); | ||
| 33 | const envModeItems = useMemo( | 40 | const envModeItems = useMemo( |
| 34 | () => [ | 41 | () => [ |
| 35 | { value: "local", label: resolveCurrentWorkspaceLabel(activeWorktreePath) }, | 42 | { value: "local", label: resolveCurrentWorkspaceLabel(activeWorktreePath) }, |
| 36 | { value: "worktree", label: resolveEnvModeLabel("worktree") }, | 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 | if (envLocked) { | 51 | if (envLocked) { |
| ... | @@ -60,7 +70,13 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe | ... | @@ -60,7 +70,13 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe |
| 60 | <Select | 70 | <Select |
| 61 | modal={false} | 71 | modal={false} |
| 62 | value={effectiveEnvMode} | 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 | items={envModeItems} | 80 | items={envModeItems} |
| 65 | > | 81 | > |
| 66 | <SelectTrigger variant="ghost" size="xs" className="font-medium" aria-label="Workspace"> | 82 | <SelectTrigger variant="ghost" size="xs" className="font-medium" aria-label="Workspace"> |
| ... | @@ -92,6 +108,14 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe | ... | @@ -92,6 +108,14 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe |
| 92 | {resolveEnvModeLabel("worktree")} | 108 | {resolveEnvModeLabel("worktree")} |
| 93 | </span> | 109 | </span> |
| 94 | </SelectItem> | 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 | </SelectGroup> | 119 | </SelectGroup> |
| 96 | </SelectPopup> | 120 | </SelectPopup> |
| 97 | </Select> | 121 | </Select> |
apps/web/src/components/CommandPalette.tsx+3-21| ... | @@ -58,11 +58,7 @@ import { useAtomCommand } from "../state/use-atom-command"; | ... | @@ -58,11 +58,7 @@ import { useAtomCommand } from "../state/use-atom-command"; |
| 58 | import { useAtomQueryRunner } from "../state/use-atom-query-runner"; | 58 | import { useAtomQueryRunner } from "../state/use-atom-query-runner"; |
| 59 | import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; | 59 | import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; |
| 60 | import { useProjects, useThreadShells } from "../state/entities"; | 60 | import { useProjects, useThreadShells } from "../state/entities"; |
| 61 | import { | 61 | import { resolveThreadActionProjectRef, startNewThreadFromContext } from "../lib/chatThreadActions"; |
| 62 | resolveThreadActionProjectRef, | ||
| 63 | startNewThreadInProjectFromContext, | ||
| 64 | startNewThreadFromContext, | ||
| 65 | } from "../lib/chatThreadActions"; | ||
| 66 | import { | 62 | import { |
| 67 | appendBrowsePathSegment, | 63 | appendBrowsePathSegment, |
| 68 | canNavigateUp, | 64 | canNavigateUp, |
| ... | @@ -837,13 +833,7 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -837,13 +833,7 @@ function OpenCommandPaletteDialog(props: { |
| 837 | projectRef.environmentId === contextualProjectRef.environmentId && | 833 | projectRef.environmentId === contextualProjectRef.environmentId && |
| 838 | projectRef.projectId === contextualProjectRef.projectId, | 834 | projectRef.projectId === contextualProjectRef.projectId, |
| 839 | ); | 835 | ); |
| 840 | await startNewThreadInProjectFromContext( | 836 | await handleNewThread( |
| 841 | { | ||
| 842 | activeDraftThread, | ||
| 843 | activeThread: activeThread ?? undefined, | ||
| 844 | defaultProjectRef, | ||
| 845 | handleNewThread, | ||
| 846 | }, | ||
| 847 | contextualRefBelongsToGroup | 837 | contextualRefBelongsToGroup |
| 848 | ? contextualProjectRef | 838 | ? contextualProjectRef |
| 849 | : scopeProjectRef(project.environmentId, project.id), | 839 | : scopeProjectRef(project.environmentId, project.id), |
| ... | @@ -851,15 +841,7 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -851,15 +841,7 @@ function OpenCommandPaletteDialog(props: { |
| 851 | }, | 841 | }, |
| 852 | }), | 842 | }), |
| 853 | ), | 843 | ), |
| 854 | [ | 844 | [contextualProjectRef, handleNewThread, pickerProjects, projectGroupByTargetKey], |
| 855 | activeDraftThread, | ||
| 856 | activeThread, | ||
| 857 | contextualProjectRef, | ||
| 858 | defaultProjectRef, | ||
| 859 | handleNewThread, | ||
| 860 | pickerProjects, | ||
| 861 | projectGroupByTargetKey, | ||
| 862 | ], | ||
| 863 | ); | 845 | ); |
| 864 | 846 | ||
| 865 | const allThreadItems = useMemo( | 847 | const allThreadItems = useMemo( |
apps/web/src/components/Sidebar.logic.test.ts-108| ... | @@ -14,8 +14,6 @@ import { | ... | @@ -14,8 +14,6 @@ import { |
| 14 | isTrailingDoubleClick, | 14 | isTrailingDoubleClick, |
| 15 | orderItemsByPreferredIds, | 15 | orderItemsByPreferredIds, |
| 16 | resolveProjectStatusIndicator, | 16 | resolveProjectStatusIndicator, |
| 17 | resolveSidebarNewThreadSeedContext, | ||
| 18 | resolveSidebarNewThreadEnvMode, | ||
| 19 | resolveSidebarStageBadgeLabel, | 17 | resolveSidebarStageBadgeLabel, |
| 20 | resolveThreadRowClassName, | 18 | resolveThreadRowClassName, |
| 21 | resolveSidebarV2Status, | 19 | resolveSidebarV2Status, |
| ... | @@ -371,112 +369,6 @@ describe("isTrailingDoubleClick", () => { | ... | @@ -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 | describe("orderItemsByPreferredIds", () => { | 372 | describe("orderItemsByPreferredIds", () => { |
| 481 | it("keeps preferred ids first, skips stale ids, and preserves the relative order of remaining items", () => { | 373 | it("keeps preferred ids first, skips stale ids, and preserves the relative order of remaining items", () => { |
| 482 | const ordered = orderItemsByPreferredIds({ | 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,7 +18,6 @@ export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 100; |
| 18 | // Visible sidebar rows are prewarmed into the thread-detail cache so opening a | 18 | // Visible sidebar rows are prewarmed into the thread-detail cache so opening a |
| 19 | // nearby thread usually reuses an already-hot subscription. | 19 | // nearby thread usually reuses an already-hot subscription. |
| 20 | export const SIDEBAR_THREAD_PREWARM_LIMIT = 10; | 20 | export const SIDEBAR_THREAD_PREWARM_LIMIT = 10; |
| 21 | export type SidebarNewThreadEnvMode = "local" | "worktree"; | ||
| 22 | type SidebarProject = { | 21 | type SidebarProject = { |
| 23 | id: string; | 22 | id: string; |
| 24 | title: string; | 23 | title: string; |
| ... | @@ -246,62 +245,6 @@ export function isTrailingDoubleClick(detail: number): boolean { | ... | @@ -246,62 +245,6 @@ export function isTrailingDoubleClick(detail: number): boolean { |
| 246 | return detail > 1; | 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 | export function orderItemsByPreferredIds<TItem, TId>(input: { | 248 | export function orderItemsByPreferredIds<TItem, TId>(input: { |
| 306 | items: readonly TItem[]; | 249 | items: readonly TItem[]; |
| 307 | preferredIds: readonly TId[]; | 250 | preferredIds: readonly TId[]; |
apps/web/src/components/Sidebar.tsx+33-55| ... | @@ -177,8 +177,6 @@ import { | ... | @@ -177,8 +177,6 @@ import { |
| 177 | isContextMenuPointerDown, | 177 | isContextMenuPointerDown, |
| 178 | isTrailingDoubleClick, | 178 | isTrailingDoubleClick, |
| 179 | resolveProjectStatusIndicator, | 179 | resolveProjectStatusIndicator, |
| 180 | resolveSidebarNewThreadSeedContext, | ||
| 181 | resolveSidebarNewThreadEnvMode, | ||
| 182 | resolveThreadRowClassName, | 180 | resolveThreadRowClassName, |
| 183 | resolveThreadStatusPill, | 181 | resolveThreadStatusPill, |
| 184 | orderItemsByPreferredIds, | 182 | orderItemsByPreferredIds, |
| ... | @@ -193,7 +191,7 @@ import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; | ... | @@ -193,7 +191,7 @@ import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; |
| 193 | import { useIsMobile } from "~/hooks/useMediaQuery"; | 191 | import { useIsMobile } from "~/hooks/useMediaQuery"; |
| 194 | import { CommandDialogTrigger } from "./ui/command"; | 192 | import { CommandDialogTrigger } from "./ui/command"; |
| 195 | import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings"; | 193 | import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings"; |
| 196 | import { primaryServerKeybindingsAtom, primaryServerSettingsAtom } from "../state/server"; | 194 | import { primaryServerKeybindingsAtom } from "../state/server"; |
| 197 | import { | 195 | import { |
| 198 | derivePhysicalProjectKey, | 196 | derivePhysicalProjectKey, |
| 199 | deriveProjectGroupingOverrideKey, | 197 | deriveProjectGroupingOverrideKey, |
| ... | @@ -1103,7 +1101,6 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec | ... | @@ -1103,7 +1101,6 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 1103 | (settings) => settings.confirmThreadArchive, | 1101 | (settings) => settings.confirmThreadArchive, |
| 1104 | ); | 1102 | ); |
| 1105 | const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); | 1103 | const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); |
| 1106 | const primaryServerSettings = useAtomValue(primaryServerSettingsAtom); | ||
| 1107 | const deleteProject = useAtomCommand(projectEnvironment.delete, { | 1104 | const deleteProject = useAtomCommand(projectEnvironment.delete, { |
| 1108 | reportFailure: false, | 1105 | reportFailure: false, |
| 1109 | }); | 1106 | }); |
| ... | @@ -1875,61 +1872,14 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec | ... | @@ -1875,61 +1872,14 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 1875 | 1872 | ||
| 1876 | const createThreadForProjectMember = useCallback( | 1873 | const createThreadForProjectMember = useCallback( |
| 1877 | (member: SidebarProjectGroupMember) => { | 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 | if (isMobile) { | 1875 | if (isMobile) { |
| 1919 | setOpenMobile(false); | 1876 | setOpenMobile(false); |
| 1920 | } | 1877 | } |
| 1921 | void (async () => { | 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 | const result = await settlePromise(() => | 1881 | const result = await settlePromise(() => |
| 1923 | handleNewThread(scopeProjectRef(member.environmentId, member.id), { | 1882 | 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 | }), | ||
| 1933 | ); | 1883 | ); |
| 1934 | if (result._tag === "Failure") { | 1884 | if (result._tag === "Failure") { |
| 1935 | const error = squashAtomCommandFailure(result); | 1885 | const error = squashAtomCommandFailure(result); |
| ... | @@ -1943,7 +1893,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec | ... | @@ -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 | const handleCreateThreadClick = useCallback( | 1899 | const handleCreateThreadClick = useCallback( |
| ... | @@ -2164,6 +2114,9 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec | ... | @@ -2164,6 +2114,9 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 2164 | thread.worktreePath ?? threadProject?.workspaceRoot ?? project.workspaceRoot ?? null; | 2114 | thread.worktreePath ?? threadProject?.workspaceRoot ?? project.workspaceRoot ?? null; |
| 2165 | const clicked = await api.contextMenu.show( | 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 | { id: "rename", label: "Rename thread" }, | 2120 | { id: "rename", label: "Rename thread" }, |
| 2168 | { id: "mark-unread", label: "Mark unread" }, | 2121 | { id: "mark-unread", label: "Mark unread" }, |
| 2169 | { id: "copy-path", label: "Copy Path" }, | 2122 | { id: "copy-path", label: "Copy Path" }, |
| ... | @@ -2173,6 +2126,30 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec | ... | @@ -2173,6 +2126,30 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 2173 | position, | 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 | if (clicked === "rename") { | 2153 | if (clicked === "rename") { |
| 2177 | startThreadRename(threadKey, thread.title); | 2154 | startThreadRename(threadKey, thread.title); |
| 2178 | return; | 2155 | return; |
| ... | @@ -2229,6 +2206,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec | ... | @@ -2229,6 +2206,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 2229 | copyPathToClipboard, | 2206 | copyPathToClipboard, |
| 2230 | copyThreadIdToClipboard, | 2207 | copyThreadIdToClipboard, |
| 2231 | deleteThread, | 2208 | deleteThread, |
| 2209 | handleNewThread, | ||
| 2232 | markThreadUnread, | 2210 | markThreadUnread, |
| 2233 | memberProjectByScopedKey, | 2211 | memberProjectByScopedKey, |
| 2234 | project.workspaceRoot, | 2212 | project.workspaceRoot, |
apps/web/src/components/SidebarV2.tsx+31| ... | @@ -1577,6 +1577,14 @@ export default function SidebarV2() { | ... | @@ -1577,6 +1577,14 @@ export default function SidebarV2() { |
| 1577 | const clicked = await settlePromise(() => | 1577 | const clicked = await settlePromise(() => |
| 1578 | api.contextMenu.show( | 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 | ...(supportsSettlement | 1588 | ...(supportsSettlement |
| 1581 | ? [ | 1589 | ? [ |
| 1582 | isSettled | 1590 | isSettled |
| ... | @@ -1593,6 +1601,29 @@ export default function SidebarV2() { | ... | @@ -1593,6 +1601,29 @@ export default function SidebarV2() { |
| 1593 | ); | 1601 | ); |
| 1594 | if (clicked._tag === "Failure") return; | 1602 | if (clicked._tag === "Failure") return; |
| 1595 | switch (clicked.value) { | 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 | case "settle": | 1627 | case "settle": |
| 1597 | attemptSettle(threadRef); | 1628 | attemptSettle(threadRef); |
| 1598 | return; | 1629 | return; |
apps/web/src/composerDraftStore.ts+14-3| ... | @@ -406,6 +406,15 @@ interface ComposerDraftStoreState { | ... | @@ -406,6 +406,15 @@ interface ComposerDraftStoreState { |
| 406 | setModelSelection: ( | 406 | setModelSelection: ( |
| 407 | threadRef: ComposerThreadTarget, | 407 | threadRef: ComposerThreadTarget, |
| 408 | modelSelection: ModelSelection | null | undefined, | 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 | ) => void; | 418 | ) => void; |
| 410 | /** Replace the model options for one or more providers in the draft. */ | 419 | /** Replace the model options for one or more providers in the draft. */ |
| 411 | setModelOptions: ( | 420 | setModelOptions: ( |
| ... | @@ -2594,7 +2603,7 @@ const composerDraftStore = create<ComposerDraftStoreState>()( | ... | @@ -2594,7 +2603,7 @@ const composerDraftStore = create<ComposerDraftStoreState>()( |
| 2594 | return { draftsByThreadKey: nextDraftsByThreadKey }; | 2603 | return { draftsByThreadKey: nextDraftsByThreadKey }; |
| 2595 | }); | 2604 | }); |
| 2596 | }, | 2605 | }, |
| 2597 | setModelSelection: (threadRef, modelSelection) => { | 2606 | setModelSelection: (threadRef, modelSelection, opts) => { |
| 2598 | const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; | 2607 | const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; |
| 2599 | if (threadKey.length === 0) { | 2608 | if (threadKey.length === 0) { |
| 2600 | return; | 2609 | return; |
| ... | @@ -2609,8 +2618,10 @@ const composerDraftStore = create<ComposerDraftStoreState>()( | ... | @@ -2609,8 +2618,10 @@ const composerDraftStore = create<ComposerDraftStoreState>()( |
| 2609 | const nextMap = { ...base.modelSelectionByProvider }; | 2618 | const nextMap = { ...base.modelSelectionByProvider }; |
| 2610 | if (normalized) { | 2619 | if (normalized) { |
| 2611 | const current = nextMap[normalized.instanceId]; | 2620 | const current = nextMap[normalized.instanceId]; |
| 2612 | if (normalized.options !== undefined) { | 2621 | if (normalized.options !== undefined || opts?.replaceOptions) { |
| 2613 | // Explicit options provided → use them | 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 | nextMap[normalized.instanceId] = normalized as ModelSelection; | 2625 | nextMap[normalized.instanceId] = normalized as ModelSelection; |
| 2615 | } else { | 2626 | } else { |
| 2616 | // No options in selection → preserve existing options, update provider+model | 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,14 +54,52 @@ export function useNewThreadHandler() { |
| 54 | }, | 54 | }, |
| 55 | ): Promise<void> => { | 55 | ): Promise<void> => { |
| 56 | const { | 56 | const { |
| 57 | getComposerDraft, | ||
| 57 | getDraftSessionByLogicalProjectKey, | 58 | getDraftSessionByLogicalProjectKey, |
| 58 | getDraftSession, | 59 | getDraftSession, |
| 59 | getDraftThread, | 60 | getDraftThread, |
| 60 | applyStickyState, | 61 | applyStickyState, |
| 61 | setDraftThreadContext, | 62 | setDraftThreadContext, |
| 62 | setLogicalProjectDraftThreadId, | 63 | setLogicalProjectDraftThreadId, |
| 64 | setModelSelection, | ||
| 63 | } = useComposerDraftStore.getState(); | 65 | } = useComposerDraftStore.getState(); |
| 64 | const currentRouteTarget = getCurrentRouteTarget(); | 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 | const project = projects.find( | 103 | const project = projects.find( |
| 66 | (candidate) => | 104 | (candidate) => |
| 67 | candidate.id === projectRef.projectId && | 105 | candidate.id === projectRef.projectId && |
| ... | @@ -92,25 +130,70 @@ export function useNewThreadHandler() { | ... | @@ -92,25 +130,70 @@ export function useNewThreadHandler() { |
| 92 | : null; | 130 | : null; |
| 93 | if (reusableStoredDraftThread) { | 131 | if (reusableStoredDraftThread) { |
| 94 | return (async () => { | 132 | return (async () => { |
| 95 | if ( | 133 | const isDraftAlreadyOpen = |
| 134 | currentRouteTarget?.kind === "draft" && | ||
| 135 | currentRouteTarget.draftId === reusableStoredDraftThread.draftId; | ||
| 136 | const hasExplicitWorkspaceOption = | ||
| 96 | hasBranchOption || | 137 | hasBranchOption || |
| 97 | hasWorktreePathOption || | 138 | hasWorktreePathOption || |
| 98 | hasEnvModeOption || | 139 | hasEnvModeOption || |
| 99 | hasStartFromOriginOption | 140 | hasStartFromOriginOption; |
| 100 | ) { | 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 | setDraftThreadContext(reusableStoredDraftThread.draftId, { | 169 | setDraftThreadContext(reusableStoredDraftThread.draftId, { |
| 102 | ...(hasBranchOption ? { branch: options?.branch ?? null } : {}), | 170 | ...workspaceContext, |
| 103 | ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}), | 171 | ...(carryRuntimeMode ? { runtimeMode: carryRuntimeMode } : {}), |
| 104 | ...(hasEnvModeOption ? { envMode: options?.envMode } : {}), | 172 | ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}), |
| 105 | ...(hasStartFromOriginOption ? { startFromOrigin: options?.startFromOrigin } : {}), | ||
| 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 | setLogicalProjectDraftThreadId( | 188 | setLogicalProjectDraftThreadId( |
| 109 | logicalProjectKey, | 189 | logicalProjectKey, |
| 110 | projectRef, | 190 | projectRef, |
| 111 | reusableStoredDraftThread.draftId, | 191 | reusableStoredDraftThread.draftId, |
| 112 | { | 192 | { |
| 113 | threadId: reusableStoredDraftThread.threadId, | 193 | threadId: reusableStoredDraftThread.threadId, |
| 194 | ...(workspaceContext ?? {}), | ||
| 195 | ...(carryRuntimeMode ? { runtimeMode: carryRuntimeMode } : {}), | ||
| 196 | ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}), | ||
| 114 | }, | 197 | }, |
| 115 | ); | 198 | ); |
| 116 | if ( | 199 | if ( |
| ... | @@ -176,9 +259,18 @@ export function useNewThreadHandler() { | ... | @@ -176,9 +259,18 @@ export function useNewThreadHandler() { |
| 176 | envMode: initialEnvMode, | 259 | envMode: initialEnvMode, |
| 177 | newWorktreesStartFromOrigin: primaryServerSettings.newWorktreesStartFromOrigin, | 260 | newWorktreesStartFromOrigin: primaryServerSettings.newWorktreesStartFromOrigin, |
| 178 | }), | 261 | }), |
| 179 | runtimeMode: DEFAULT_RUNTIME_MODE, | 262 | runtimeMode: carryRuntimeMode ?? DEFAULT_RUNTIME_MODE, |
| 263 | ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}), | ||
| 180 | }); | 264 | }); |
| 181 | applyStickyState(draftId); | 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 | await router.navigate({ | 275 | await router.navigate({ |
| 184 | to: "/draft/$draftId", | 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,9 +4,7 @@ import { describe, expect, it, vi } from "vite-plus/test"; |
| 4 | import { | 4 | import { |
| 5 | resolveThreadActionProjectRef, | 5 | resolveThreadActionProjectRef, |
| 6 | resolveNewDraftStartFromOrigin, | 6 | resolveNewDraftStartFromOrigin, |
| 7 | startNewLocalThreadFromContext, | ||
| 8 | startNewThreadFromContext, | 7 | startNewThreadFromContext, |
| 9 | startNewThreadInProjectFromContext, | ||
| 10 | type ChatThreadActionContext, | 8 | type ChatThreadActionContext, |
| 11 | } from "./chatThreadActions"; | 9 | } from "./chatThreadActions"; |
| 12 | 10 | ||
| ... | @@ -40,16 +38,12 @@ describe("chatThreadActions", () => { | ... | @@ -40,16 +38,12 @@ describe("chatThreadActions", () => { |
| 40 | ).toBe(false); | 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 | const projectRef = resolveThreadActionProjectRef( | 42 | const projectRef = resolveThreadActionProjectRef( |
| 45 | createContext({ | 43 | createContext({ |
| 46 | activeDraftThread: { | 44 | activeThread: { |
| 47 | environmentId: ENVIRONMENT_ID, | 45 | environmentId: ENVIRONMENT_ID, |
| 48 | projectId: PROJECT_ID, | 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,95 +51,40 @@ describe("chatThreadActions", () => { |
| 57 | expect(projectRef).toEqual(scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID)); | 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 | const projectRef = resolveThreadActionProjectRef( | 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 | createContext({ | 56 | createContext({ |
| 75 | activeDraftThread: { | 57 | activeDraftThread: { |
| 76 | environmentId: ENVIRONMENT_ID, | 58 | environmentId: ENVIRONMENT_ID, |
| 77 | projectId: PROJECT_ID, | 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); | 64 | expect(projectRef).toEqual(scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID)); |
| 88 | expect(handleNewThread).toHaveBeenCalledWith(scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID), { | ||
| 89 | branch: "feature/refactor", | ||
| 90 | worktreePath: "/tmp/worktree", | ||
| 91 | envMode: "worktree", | ||
| 92 | startFromOrigin: true, | ||
| 93 | }); | ||
| 94 | }); | 65 | }); |
| 95 | 66 | ||
| 96 | it("preserves an explicitly disabled origin base in contextual thread options", async () => { | 67 | it("falls back to the default project ref when there is no active thread context", () => { |
| 97 | const handleNewThread = vi.fn<ChatThreadActionContext["handleNewThread"]>(async () => {}); | 68 | const projectRef = resolveThreadActionProjectRef( |
| 98 | |||
| 99 | await startNewThreadFromContext( | ||
| 100 | createContext({ | 69 | createContext({ |
| 101 | activeDraftThread: { | 70 | defaultProjectRef: scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID), |
| 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, | ||
| 110 | }), | 71 | }), |
| 111 | ); | 72 | ); |
| 112 | 73 | ||
| 113 | expect(handleNewThread).toHaveBeenCalledWith(scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID), { | 74 | expect(projectRef).toEqual(scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID)); |
| 114 | branch: "feature/refactor", | ||
| 115 | worktreePath: "/tmp/worktree", | ||
| 116 | envMode: "worktree", | ||
| 117 | startFromOrigin: false, | ||
| 118 | }); | ||
| 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 | const handleNewThread = vi.fn<ChatThreadActionContext["handleNewThread"]>(async () => {}); | 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 | createContext({ | 81 | createContext({ |
| 127 | activeThread: { | 82 | activeThread: { |
| 128 | environmentId: ENVIRONMENT_ID, | 83 | environmentId: ENVIRONMENT_ID, |
| 129 | projectId: PROJECT_ID, | 84 | projectId: PROJECT_ID, |
| 130 | branch: "feature/refactor", | ||
| 131 | worktreePath: "/tmp/worktree", | ||
| 132 | }, | 85 | }, |
| 133 | handleNewThread, | 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 | expect(didStart).toBe(true); | 90 | expect(didStart).toBe(true); |
apps/web/src/lib/chatThreadActions.ts+7-51| ... | @@ -5,13 +5,6 @@ import type { DraftThreadEnvMode } from "../composerDraftStore"; | ... | @@ -5,13 +5,6 @@ import type { DraftThreadEnvMode } from "../composerDraftStore"; |
| 5 | interface ThreadContextLike { | 5 | interface ThreadContextLike { |
| 6 | environmentId: EnvironmentId; | 6 | environmentId: EnvironmentId; |
| 7 | projectId: ProjectId; | 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 | interface NewThreadHandler { | 10 | interface NewThreadHandler { |
| ... | @@ -26,10 +19,8 @@ interface NewThreadHandler { | ... | @@ -26,10 +19,8 @@ interface NewThreadHandler { |
| 26 | ): Promise<void>; | 19 | ): Promise<void>; |
| 27 | } | 20 | } |
| 28 | 21 | ||
| 29 | type NewThreadOptions = NonNullable<Parameters<NewThreadHandler>[1]>; | ||
| 30 | |||
| 31 | export interface ChatThreadActionContext { | 22 | export interface ChatThreadActionContext { |
| 32 | readonly activeDraftThread: DraftThreadContextLike | null; | 23 | readonly activeDraftThread: ThreadContextLike | null; |
| 33 | readonly activeThread: ThreadContextLike | undefined; | 24 | readonly activeThread: ThreadContextLike | undefined; |
| 34 | readonly defaultProjectRef: ScopedProjectRef | null; | 25 | readonly defaultProjectRef: ScopedProjectRef | null; |
| 35 | readonly handleNewThread: NewThreadHandler; | 26 | readonly handleNewThread: NewThreadHandler; |
| ... | @@ -57,35 +48,12 @@ export function resolveThreadActionProjectRef( | ... | @@ -57,35 +48,12 @@ export function resolveThreadActionProjectRef( |
| 57 | return context.defaultProjectRef; | 48 | return context.defaultProjectRef; |
| 58 | } | 49 | } |
| 59 | 50 | ||
| 60 | function buildContextualThreadOptions(context: ChatThreadActionContext): NewThreadOptions { | 51 | // New threads inherit only the *project* from the current context. Branch, |
| 61 | return { | 52 | // worktree, and env mode always come from the user's configured defaults — |
| 62 | branch: context.activeThread?.branch ?? context.activeDraftThread?.branch ?? null, | 53 | // carrying them over from the viewed thread meant "new thread" silently |
| 63 | worktreePath: | 54 | // reused checkouts and branches. Explicit affordances (branch toolbar's |
| 64 | context.activeThread?.worktreePath ?? context.activeDraftThread?.worktreePath ?? null, | 55 | // "new thread in this worktree") pass those options to handleNewThread |
| 65 | envMode: | 56 | // directly instead. |
| 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 | |||
| 89 | export async function startNewThreadFromContext( | 57 | export async function startNewThreadFromContext( |
| 90 | context: ChatThreadActionContext, | 58 | context: ChatThreadActionContext, |
| 91 | ): Promise<boolean> { | 59 | ): Promise<boolean> { |
| ... | @@ -94,18 +62,6 @@ export async function startNewThreadFromContext( | ... | @@ -94,18 +62,6 @@ export async function startNewThreadFromContext( |
| 94 | return false; | 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 | await context.handleNewThread(projectRef); | 65 | await context.handleNewThread(projectRef); |
| 110 | return true; | 66 | return true; |
| 111 | } | 67 | } |
apps/web/src/routes/_chat.tsx+2-5| ... | @@ -11,10 +11,7 @@ import { selectProjectGroupingSettings } from "../logicalProject"; | ... | @@ -11,10 +11,7 @@ import { selectProjectGroupingSettings } from "../logicalProject"; |
| 11 | import { buildSidebarProjectSnapshots } from "../sidebarProjectGrouping"; | 11 | import { buildSidebarProjectSnapshots } from "../sidebarProjectGrouping"; |
| 12 | import { dispatchPreviewAction } from "../components/preview/previewActionBus"; | 12 | import { dispatchPreviewAction } from "../components/preview/previewActionBus"; |
| 13 | import { useHandleNewThread } from "../hooks/useHandleNewThread"; | 13 | import { useHandleNewThread } from "../hooks/useHandleNewThread"; |
| 14 | import { | 14 | import { startNewThreadFromContext } from "../lib/chatThreadActions"; |
| 15 | startNewLocalThreadFromContext, | ||
| 16 | startNewThreadFromContext, | ||
| 17 | } from "../lib/chatThreadActions"; | ||
| 18 | import { isPreviewFocused } from "../lib/previewFocus"; | 15 | import { isPreviewFocused } from "../lib/previewFocus"; |
| 19 | import { isTerminalFocused } from "../lib/terminalFocus"; | 16 | import { isTerminalFocused } from "../lib/terminalFocus"; |
| 20 | import { resolveShortcutCommand } from "../keybindings"; | 17 | import { resolveShortcutCommand } from "../keybindings"; |
| ... | @@ -83,7 +80,7 @@ function ChatRouteGlobalShortcuts() { | ... | @@ -83,7 +80,7 @@ function ChatRouteGlobalShortcuts() { |
| 83 | if (command === "chat.newLocal") { | 80 | if (command === "chat.newLocal") { |
| 84 | event.preventDefault(); | 81 | event.preventDefault(); |
| 85 | event.stopPropagation(); | 82 | event.stopPropagation(); |
| 86 | void startNewLocalThreadFromContext({ | 83 | void startNewThreadFromContext({ |
| 87 | activeDraftThread, | 84 | activeDraftThread, |
| 88 | activeThread: activeThread ?? undefined, | 85 | activeThread: activeThread ?? undefined, |
| 89 | defaultProjectRef, | 86 | defaultProjectRef, |