authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-07-23 18:06:21-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-23 18:06:21-07:00
log2f41c073a2f846e9a96082e76cd1b2c3a7e44c18
treee14e7e0788d50d190bd99e8186962807e5191c6a
parent51672b6ecb9d927d57d0b12dd071ca0d4a34ef36
signature Signed by PGP key B5690EEEBB952194

Stop new threads inheriting checkout/branch from viewed thread (#4411)

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 {
1212 resolveBranchToolbarValue,
1313 resolveLockedWorkspaceLabel,
1414 resolveLocalCheckoutBranchMismatch,
15 resolvePreviousWorktreeLabel,
16 resolvePreviousWorktreeSeed,
1517 shouldIncludeBranchPickerItem,
1618 shouldShowEnvironmentIndicator,
1719} from "./BranchToolbar.logic";
......@@ -19,6 +21,91 @@ import {
1921const localEnvironmentId = EnvironmentId.make("environment-local");
2022const remoteEnvironmentId = EnvironmentId.make("environment-remote");
2123
24describe("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
98describe("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
22109describe("resolveDraftEnvModeAfterBranchChange", () => {
23110 it("switches to local mode when returning from an existing worktree to the main worktree", () => {
24111 expect(
apps/web/src/components/BranchToolbar.logic.ts+48
......@@ -1,5 +1,6 @@
11import type { EnvironmentId, VcsRef, ProjectId } from "@t3tools/contracts";
22import * as Schema from "effect/Schema";
3import { toSortableTimestamp } from "../lib/threadSort";
34export {
45 dedupeRemoteBranchesWithLocalMatches,
56 deriveLocalBranchNameFromRemoteRef,
......@@ -65,6 +66,53 @@ export function resolveLockedWorkspaceLabel(activeWorktreePath: string | null):
6566 return activeWorktreePath ? "Worktree" : "Local checkout";
6667}
6768
69export 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.
79export 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
112export function resolvePreviousWorktreeLabel(seed: PreviousWorktreeSeed): string {
113 return seed.branch ? `Previous worktree (${seed.branch})` : "Previous worktree";
114}
115
68116export function resolveEffectiveEnvMode(input: {
69117 activeWorktreePath: string | null;
70118 hasServerThread: boolean;
apps/web/src/components/BranchToolbar.tsx+63-3
......@@ -6,12 +6,13 @@ import {
66 FolderGit2Icon,
77 FolderGitIcon,
88 FolderIcon,
9 HistoryIcon,
910 MonitorIcon,
1011} from "lucide-react";
11import { memo, useMemo } from "react";
12import { memo, useCallback, useMemo } from "react";
1213
1314import { useComposerDraftStore, type DraftId } from "../composerDraftStore";
14import { useProject, useThread } from "../state/entities";
15import { useProject, useThread, useThreadShellsForProjectRefs } from "../state/entities";
1516import { useIsMobile } from "../hooks/useMediaQuery";
1617import {
1718 type EnvMode,
......@@ -20,6 +21,8 @@ import {
2021 resolveEnvModeLabel,
2122 resolveEffectiveEnvMode,
2223 resolveLockedWorkspaceLabel,
24 resolvePreviousWorktreeLabel,
25 resolvePreviousWorktreeSeed,
2326 shouldShowEnvironmentIndicator,
2427} from "./BranchToolbar.logic";
2528import { BranchToolbarBranchSelector } from "./BranchToolbarBranchSelector";
......@@ -66,6 +69,8 @@ interface MobileRunContextSelectorProps {
6669 effectiveEnvMode: EnvMode;
6770 activeWorktreePath: string | null;
6871 onEnvModeChange: (mode: EnvMode) => void;
72 previousWorktreeLabel: string | null;
73 onUsePreviousWorktree: () => void;
6974}
7075
7176const MobileRunContextSelector = memo(function MobileRunContextSelector({
......@@ -79,6 +84,8 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({
7984 effectiveEnvMode,
8085 activeWorktreePath,
8186 onEnvModeChange,
87 previousWorktreeLabel,
88 onUsePreviousWorktree,
8289}: MobileRunContextSelectorProps) {
8390 const activeEnvironment = useMemo(
8491 () => availableEnvironments?.find((env) => env.environmentId === environmentId) ?? null,
......@@ -166,7 +173,13 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({
166173 <MenuGroupLabel>Workspace</MenuGroupLabel>
167174 <MenuRadioGroup
168175 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 }}
170183 >
171184 <MenuRadioItem disabled={envModeLocked} value="local">
172185 <span className="flex min-w-0 items-center gap-1.5">
......@@ -186,6 +199,14 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({
186199 <span className="min-w-0 truncate">{resolveEnvModeLabel("worktree")}</span>
187200 </span>
188201 </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}
189210 </MenuRadioGroup>
190211 </MenuGroup>
191212 </MenuPopup>
......@@ -217,6 +238,7 @@ export const BranchToolbar = memo(function BranchToolbar({
217238 const draftThread = useComposerDraftStore((store) =>
218239 draftId ? store.getDraftSession(draftId) : store.getDraftThreadByRef(threadRef),
219240 );
241 const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);
220242 const activeProjectRef = serverThread
221243 ? scopeProjectRef(serverThread.environmentId, serverThread.projectId)
222244 : draftThread
......@@ -234,6 +256,40 @@ export const BranchToolbar = memo(function BranchToolbar({
234256 });
235257 const envModeLocked = envLocked || (serverThread !== null && activeWorktreePath !== null);
236258
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
237293 const showEnvironmentPicker = Boolean(
238294 availableEnvironments && availableEnvironments.length > 1 && onEnvironmentChange,
239295 );
......@@ -261,6 +317,8 @@ export const BranchToolbar = memo(function BranchToolbar({
261317 effectiveEnvMode={effectiveEnvMode}
262318 activeWorktreePath={activeWorktreePath}
263319 onEnvModeChange={onEnvModeChange}
320 previousWorktreeLabel={previousWorktreeLabel}
321 onUsePreviousWorktree={onUsePreviousWorktree}
264322 />
265323 ) : (
266324 <div className="flex min-w-0 shrink-0 items-center gap-1">
......@@ -280,6 +338,8 @@ export const BranchToolbar = memo(function BranchToolbar({
280338 effectiveEnvMode={effectiveEnvMode}
281339 activeWorktreePath={activeWorktreePath}
282340 onEnvModeChange={onEnvModeChange}
341 previousWorktreeLabel={previousWorktreeLabel}
342 onUsePreviousWorktree={onUsePreviousWorktree}
283343 />
284344 </div>
285345 )}
apps/web/src/components/BranchToolbarEnvModeSelector.tsx+27-3
......@@ -1,4 +1,4 @@
1import { FolderGit2Icon, FolderGitIcon, FolderIcon } from "lucide-react";
1import { FolderGit2Icon, FolderGitIcon, FolderIcon, HistoryIcon } from "lucide-react";
22import { memo, useMemo } from "react";
33
44import {
......@@ -17,11 +17,15 @@ import {
1717 SelectValue,
1818} from "./ui/select";
1919
20export const PREVIOUS_WORKTREE_SELECT_VALUE = "previous-worktree";
21
2022interface BranchToolbarEnvModeSelectorProps {
2123 envLocked: boolean;
2224 effectiveEnvMode: EnvMode;
2325 activeWorktreePath: string | null;
2426 onEnvModeChange: (mode: EnvMode) => void;
27 previousWorktreeLabel?: string | null;
28 onUsePreviousWorktree?: () => void;
2529}
2630
2731export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSelector({
......@@ -29,13 +33,19 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe
2933 effectiveEnvMode,
3034 activeWorktreePath,
3135 onEnvModeChange,
36 previousWorktreeLabel,
37 onUsePreviousWorktree,
3238}: BranchToolbarEnvModeSelectorProps) {
39 const showPreviousWorktree = Boolean(previousWorktreeLabel && onUsePreviousWorktree);
3340 const envModeItems = useMemo(
3441 () => [
3542 { value: "local", label: resolveCurrentWorkspaceLabel(activeWorktreePath) },
3643 { value: "worktree", label: resolveEnvModeLabel("worktree") },
44 ...(showPreviousWorktree && previousWorktreeLabel
45 ? [{ value: PREVIOUS_WORKTREE_SELECT_VALUE, label: previousWorktreeLabel }]
46 : []),
3747 ],
38 [activeWorktreePath],
48 [activeWorktreePath, previousWorktreeLabel, showPreviousWorktree],
3949 );
4050
4151 if (envLocked) {
......@@ -60,7 +70,13 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe
6070 <Select
6171 modal={false}
6272 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 }}
6480 items={envModeItems}
6581 >
6682 <SelectTrigger variant="ghost" size="xs" className="font-medium" aria-label="Workspace">
......@@ -92,6 +108,14 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe
92108 {resolveEnvModeLabel("worktree")}
93109 </span>
94110 </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}
95119 </SelectGroup>
96120 </SelectPopup>
97121 </Select>
apps/web/src/components/CommandPalette.tsx+3-21
......@@ -58,11 +58,7 @@ import { useAtomCommand } from "../state/use-atom-command";
5858import { useAtomQueryRunner } from "../state/use-atom-query-runner";
5959import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments";
6060import { useProjects, useThreadShells } from "../state/entities";
61import {
62 resolveThreadActionProjectRef,
63 startNewThreadInProjectFromContext,
64 startNewThreadFromContext,
65} from "../lib/chatThreadActions";
61import { resolveThreadActionProjectRef, startNewThreadFromContext } from "../lib/chatThreadActions";
6662import {
6763 appendBrowsePathSegment,
6864 canNavigateUp,
......@@ -837,13 +833,7 @@ function OpenCommandPaletteDialog(props: {
837833 projectRef.environmentId === contextualProjectRef.environmentId &&
838834 projectRef.projectId === contextualProjectRef.projectId,
839835 );
840 await startNewThreadInProjectFromContext(
841 {
842 activeDraftThread,
843 activeThread: activeThread ?? undefined,
844 defaultProjectRef,
845 handleNewThread,
846 },
836 await handleNewThread(
847837 contextualRefBelongsToGroup
848838 ? contextualProjectRef
849839 : scopeProjectRef(project.environmentId, project.id),
......@@ -851,15 +841,7 @@ function OpenCommandPaletteDialog(props: {
851841 },
852842 }),
853843 ),
854 [
855 activeDraftThread,
856 activeThread,
857 contextualProjectRef,
858 defaultProjectRef,
859 handleNewThread,
860 pickerProjects,
861 projectGroupByTargetKey,
862 ],
844 [contextualProjectRef, handleNewThread, pickerProjects, projectGroupByTargetKey],
863845 );
864846
865847 const allThreadItems = useMemo(
apps/web/src/components/Sidebar.logic.test.ts-108
......@@ -14,8 +14,6 @@ import {
1414 isTrailingDoubleClick,
1515 orderItemsByPreferredIds,
1616 resolveProjectStatusIndicator,
17 resolveSidebarNewThreadSeedContext,
18 resolveSidebarNewThreadEnvMode,
1917 resolveSidebarStageBadgeLabel,
2018 resolveThreadRowClassName,
2119 resolveSidebarV2Status,
......@@ -371,112 +369,6 @@ describe("isTrailingDoubleClick", () => {
371369 });
372370});
373371
374describe("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
393describe("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
480372describe("orderItemsByPreferredIds", () => {
481373 it("keeps preferred ids first, skips stale ids, and preserves the relative order of remaining items", () => {
482374 const ordered = orderItemsByPreferredIds({
apps/web/src/components/Sidebar.logic.ts-57
......@@ -18,7 +18,6 @@ export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 100;
1818// Visible sidebar rows are prewarmed into the thread-detail cache so opening a
1919// nearby thread usually reuses an already-hot subscription.
2020export const SIDEBAR_THREAD_PREWARM_LIMIT = 10;
21export type SidebarNewThreadEnvMode = "local" | "worktree";
2221type SidebarProject = {
2322 id: string;
2423 title: string;
......@@ -246,62 +245,6 @@ export function isTrailingDoubleClick(detail: number): boolean {
246245 return detail > 1;
247246}
248247
249export function resolveSidebarNewThreadEnvMode(input: {
250 requestedEnvMode?: SidebarNewThreadEnvMode;
251 defaultEnvMode: SidebarNewThreadEnvMode;
252}): SidebarNewThreadEnvMode {
253 return input.requestedEnvMode ?? input.defaultEnvMode;
254}
255
256export 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
305248export function orderItemsByPreferredIds<TItem, TId>(input: {
306249 items: readonly TItem[];
307250 preferredIds: readonly TId[];
apps/web/src/components/Sidebar.tsx+33-55
......@@ -177,8 +177,6 @@ import {
177177 isContextMenuPointerDown,
178178 isTrailingDoubleClick,
179179 resolveProjectStatusIndicator,
180 resolveSidebarNewThreadSeedContext,
181 resolveSidebarNewThreadEnvMode,
182180 resolveThreadRowClassName,
183181 resolveThreadStatusPill,
184182 orderItemsByPreferredIds,
......@@ -193,7 +191,7 @@ import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
193191import { useIsMobile } from "~/hooks/useMediaQuery";
194192import { CommandDialogTrigger } from "./ui/command";
195193import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings";
196import { primaryServerKeybindingsAtom, primaryServerSettingsAtom } from "../state/server";
194import { primaryServerKeybindingsAtom } from "../state/server";
197195import {
198196 derivePhysicalProjectKey,
199197 deriveProjectGroupingOverrideKey,
......@@ -1103,7 +1101,6 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
11031101 (settings) => settings.confirmThreadArchive,
11041102 );
11051103 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
1106 const primaryServerSettings = useAtomValue(primaryServerSettingsAtom);
11071104 const deleteProject = useAtomCommand(projectEnvironment.delete, {
11081105 reportFailure: false,
11091106 });
......@@ -1875,61 +1872,14 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
18751872
18761873 const createThreadForProjectMember = useCallback(
18771874 (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 });
19181875 if (isMobile) {
19191876 setOpenMobile(false);
19201877 }
19211878 void (async () => {
1879 // No options: branch, worktree, and env mode come from the user's
1880 // configured defaults, never from the currently viewed thread.
19221881 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)),
19331883 );
19341884 if (result._tag === "Failure") {
19351885 const error = squashAtomCommandFailure(result);
......@@ -1943,7 +1893,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
19431893 }
19441894 })();
19451895 },
1946 [handleNewThread, isMobile, primaryServerSettings.defaultThreadEnvMode, router, setOpenMobile],
1896 [handleNewThread, isMobile, setOpenMobile],
19471897 );
19481898
19491899 const handleCreateThreadClick = useCallback(
......@@ -2164,6 +2114,9 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
21642114 thread.worktreePath ?? threadProject?.workspaceRoot ?? project.workspaceRoot ?? null;
21652115 const clicked = await api.contextMenu.show(
21662116 [
2117 ...(thread.branch
2118 ? [{ id: "new-thread-on-branch", label: `New thread on ${thread.branch}` }]
2119 : []),
21672120 { id: "rename", label: "Rename thread" },
21682121 { id: "mark-unread", label: "Mark unread" },
21692122 { id: "copy-path", label: "Copy Path" },
......@@ -2173,6 +2126,30 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
21732126 position,
21742127 );
21752128
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
21762153 if (clicked === "rename") {
21772154 startThreadRename(threadKey, thread.title);
21782155 return;
......@@ -2229,6 +2206,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
22292206 copyPathToClipboard,
22302207 copyThreadIdToClipboard,
22312208 deleteThread,
2209 handleNewThread,
22322210 markThreadUnread,
22332211 memberProjectByScopedKey,
22342212 project.workspaceRoot,
apps/web/src/components/SidebarV2.tsx+31
......@@ -1577,6 +1577,14 @@ export default function SidebarV2() {
15771577 const clicked = await settlePromise(() =>
15781578 api.contextMenu.show(
15791579 [
1580 ...(thread.branch
1581 ? [
1582 {
1583 id: "new-thread-on-branch",
1584 label: `New thread on ${thread.branch}`,
1585 },
1586 ]
1587 : []),
15801588 ...(supportsSettlement
15811589 ? [
15821590 isSettled
......@@ -1593,6 +1601,29 @@ export default function SidebarV2() {
15931601 );
15941602 if (clicked._tag === "Failure") return;
15951603 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 }
15961627 case "settle":
15971628 attemptSettle(threadRef);
15981629 return;
apps/web/src/composerDraftStore.ts+14-3
......@@ -406,6 +406,15 @@ interface ComposerDraftStoreState {
406406 setModelSelection: (
407407 threadRef: ComposerThreadTarget,
408408 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 },
409418 ) => void;
410419 /** Replace the model options for one or more providers in the draft. */
411420 setModelOptions: (
......@@ -2594,7 +2603,7 @@ const composerDraftStore = create<ComposerDraftStoreState>()(
25942603 return { draftsByThreadKey: nextDraftsByThreadKey };
25952604 });
25962605 },
2597 setModelSelection: (threadRef, modelSelection) => {
2606 setModelSelection: (threadRef, modelSelection, opts) => {
25982607 const threadKey = resolveComposerDraftKey(get(), threadRef) ?? "";
25992608 if (threadKey.length === 0) {
26002609 return;
......@@ -2609,8 +2618,10 @@ const composerDraftStore = create<ComposerDraftStoreState>()(
26092618 const nextMap = { ...base.modelSelectionByProvider };
26102619 if (normalized) {
26112620 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.
26142625 nextMap[normalized.instanceId] = normalized as ModelSelection;
26152626 } else {
26162627 // 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() {
5454 },
5555 ): Promise<void> => {
5656 const {
57 getComposerDraft,
5758 getDraftSessionByLogicalProjectKey,
5859 getDraftSession,
5960 getDraftThread,
6061 applyStickyState,
6162 setDraftThreadContext,
6263 setLogicalProjectDraftThreadId,
64 setModelSelection,
6365 } = useComposerDraftStore.getState();
6466 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;
65103 const project = projects.find(
66104 (candidate) =>
67105 candidate.id === projectRef.projectId &&
......@@ -92,25 +130,70 @@ export function useNewThreadHandler() {
92130 : null;
93131 if (reusableStoredDraftThread) {
94132 return (async () => {
95 if (
133 const isDraftAlreadyOpen =
134 currentRouteTarget?.kind === "draft" &&
135 currentRouteTarget.draftId === reusableStoredDraftThread.draftId;
136 const hasExplicitWorkspaceOption =
96137 hasBranchOption ||
97138 hasWorktreePathOption ||
98139 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) {
101169 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 } : {}),
106173 });
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 }
107182 }
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.
108188 setLogicalProjectDraftThreadId(
109189 logicalProjectKey,
110190 projectRef,
111191 reusableStoredDraftThread.draftId,
112192 {
113193 threadId: reusableStoredDraftThread.threadId,
194 ...(workspaceContext ?? {}),
195 ...(carryRuntimeMode ? { runtimeMode: carryRuntimeMode } : {}),
196 ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}),
114197 },
115198 );
116199 if (
......@@ -176,9 +259,18 @@ export function useNewThreadHandler() {
176259 envMode: initialEnvMode,
177260 newWorktreesStartFromOrigin: primaryServerSettings.newWorktreesStartFromOrigin,
178261 }),
179 runtimeMode: DEFAULT_RUNTIME_MODE,
262 runtimeMode: carryRuntimeMode ?? DEFAULT_RUNTIME_MODE,
263 ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}),
180264 });
181265 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 }
182274
183275 await router.navigate({
184276 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";
44import {
55 resolveThreadActionProjectRef,
66 resolveNewDraftStartFromOrigin,
7 startNewLocalThreadFromContext,
87 startNewThreadFromContext,
9 startNewThreadInProjectFromContext,
108 type ChatThreadActionContext,
119} from "./chatThreadActions";
1210
......@@ -40,16 +38,12 @@ describe("chatThreadActions", () => {
4038 ).toBe(false);
4139 });
4240
43 it("prefers the active draft thread project when resolving thread actions", () => {
41 it("prefers the active thread project when resolving thread actions", () => {
4442 const projectRef = resolveThreadActionProjectRef(
4543 createContext({
46 activeDraftThread: {
44 activeThread: {
4745 environmentId: ENVIRONMENT_ID,
4846 projectId: PROJECT_ID,
49 branch: "feature/refactor",
50 worktreePath: "/tmp/worktree",
51 envMode: "worktree",
52 startFromOrigin: true,
5347 },
5448 }),
5549 );
......@@ -57,95 +51,40 @@ describe("chatThreadActions", () => {
5751 expect(projectRef).toEqual(scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID));
5852 });
5953
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", () => {
6155 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(
7456 createContext({
7557 activeDraftThread: {
7658 environmentId: ENVIRONMENT_ID,
7759 projectId: PROJECT_ID,
78 branch: "feature/refactor",
79 worktreePath: "/tmp/worktree",
80 envMode: "worktree",
81 startFromOrigin: true,
8260 },
83 handleNewThread,
8461 }),
8562 );
8663
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));
9465 });
9566
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(
10069 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),
11071 }),
11172 );
11273
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));
11975 });
12076
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 () => {
12278 const handleNewThread = vi.fn<ChatThreadActionContext["handleNewThread"]>(async () => {});
123 const targetProjectRef = scopeProjectRef(ENVIRONMENT_ID, FALLBACK_PROJECT_ID);
12479
125 await startNewThreadInProjectFromContext(
80 const didStart = await startNewThreadFromContext(
12681 createContext({
12782 activeThread: {
12883 environmentId: ENVIRONMENT_ID,
12984 projectId: PROJECT_ID,
130 branch: "feature/refactor",
131 worktreePath: "/tmp/worktree",
13285 },
13386 handleNewThread,
13487 }),
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 }),
14988 );
15089
15190 expect(didStart).toBe(true);
apps/web/src/lib/chatThreadActions.ts+7-51
......@@ -5,13 +5,6 @@ import type { DraftThreadEnvMode } from "../composerDraftStore";
55interface ThreadContextLike {
66 environmentId: EnvironmentId;
77 projectId: ProjectId;
8 branch: string | null;
9 worktreePath: string | null;
10}
11
12interface DraftThreadContextLike extends ThreadContextLike {
13 envMode: DraftThreadEnvMode;
14 startFromOrigin: boolean;
158}
169
1710interface NewThreadHandler {
......@@ -26,10 +19,8 @@ interface NewThreadHandler {
2619 ): Promise<void>;
2720}
2821
29type NewThreadOptions = NonNullable<Parameters<NewThreadHandler>[1]>;
30
3122export interface ChatThreadActionContext {
32 readonly activeDraftThread: DraftThreadContextLike | null;
23 readonly activeDraftThread: ThreadContextLike | null;
3324 readonly activeThread: ThreadContextLike | undefined;
3425 readonly defaultProjectRef: ScopedProjectRef | null;
3526 readonly handleNewThread: NewThreadHandler;
......@@ -57,35 +48,12 @@ export function resolveThreadActionProjectRef(
5748 return context.defaultProjectRef;
5849}
5950
60function 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
74export 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.
8957export async function startNewThreadFromContext(
9058 context: ChatThreadActionContext,
9159): Promise<boolean> {
......@@ -94,18 +62,6 @@ export async function startNewThreadFromContext(
9462 return false;
9563 }
9664
97 await startNewThreadInProjectFromContext(context, projectRef);
98 return true;
99}
100
101export async function startNewLocalThreadFromContext(
102 context: ChatThreadActionContext,
103): Promise<boolean> {
104 const projectRef = resolveThreadActionProjectRef(context);
105 if (!projectRef) {
106 return false;
107 }
108
10965 await context.handleNewThread(projectRef);
11066 return true;
11167}
apps/web/src/routes/_chat.tsx+2-5
......@@ -11,10 +11,7 @@ import { selectProjectGroupingSettings } from "../logicalProject";
1111import { buildSidebarProjectSnapshots } from "../sidebarProjectGrouping";
1212import { dispatchPreviewAction } from "../components/preview/previewActionBus";
1313import { useHandleNewThread } from "../hooks/useHandleNewThread";
14import {
15 startNewLocalThreadFromContext,
16 startNewThreadFromContext,
17} from "../lib/chatThreadActions";
14import { startNewThreadFromContext } from "../lib/chatThreadActions";
1815import { isPreviewFocused } from "../lib/previewFocus";
1916import { isTerminalFocused } from "../lib/terminalFocus";
2017import { resolveShortcutCommand } from "../keybindings";
......@@ -83,7 +80,7 @@ function ChatRouteGlobalShortcuts() {
8380 if (command === "chat.newLocal") {
8481 event.preventDefault();
8582 event.stopPropagation();
86 void startNewLocalThreadFromContext({
83 void startNewThreadFromContext({
8784 activeDraftThread,
8885 activeThread: activeThread ?? undefined,
8986 defaultProjectRef,