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 {...@@ -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 {
19const localEnvironmentId = EnvironmentId.make("environment-local");21const localEnvironmentId = EnvironmentId.make("environment-local");
20const remoteEnvironmentId = EnvironmentId.make("environment-remote");22const 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
22describe("resolveDraftEnvModeAfterBranchChange", () => {109describe("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 @@
1import type { EnvironmentId, VcsRef, ProjectId } from "@t3tools/contracts";1import type { EnvironmentId, VcsRef, ProjectId } from "@t3tools/contracts";
2import * as Schema from "effect/Schema";2import * as Schema from "effect/Schema";
3import { toSortableTimestamp } from "../lib/threadSort";
3export {4export {
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}
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
68export function resolveEffectiveEnvMode(input: {116export 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";
11import { memo, useMemo } from "react";12import { memo, useCallback, useMemo } from "react";
1213
13import { useComposerDraftStore, type DraftId } from "../composerDraftStore";14import { useComposerDraftStore, type DraftId } from "../composerDraftStore";
14import { useProject, useThread } from "../state/entities";15import { useProject, useThread, useThreadShellsForProjectRefs } from "../state/entities";
15import { useIsMobile } from "../hooks/useMediaQuery";16import { useIsMobile } from "../hooks/useMediaQuery";
16import {17import {
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";
25import { BranchToolbarBranchSelector } from "./BranchToolbarBranchSelector";28import { 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}
7075
71const MobileRunContextSelector = memo(function MobileRunContextSelector({76const 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 <MenuRadioGroup174 <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 = serverThread242 const activeProjectRef = serverThread
221 ? scopeProjectRef(serverThread.environmentId, serverThread.projectId)243 ? scopeProjectRef(serverThread.environmentId, serverThread.projectId)
222 : draftThread244 : 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);
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
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 @@
1import { FolderGit2Icon, FolderGitIcon, FolderIcon } from "lucide-react";1import { FolderGit2Icon, FolderGitIcon, FolderIcon, HistoryIcon } from "lucide-react";
2import { memo, useMemo } from "react";2import { memo, useMemo } from "react";
33
4import {4import {
...@@ -17,11 +17,15 @@ import {...@@ -17,11 +17,15 @@ import {
17 SelectValue,17 SelectValue,
18} from "./ui/select";18} from "./ui/select";
1919
20export const PREVIOUS_WORKTREE_SELECT_VALUE = "previous-worktree";
21
20interface BranchToolbarEnvModeSelectorProps {22interface 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}
2630
27export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSelector({31export 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 );
4050
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 <Select70 <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";
58import { useAtomQueryRunner } from "../state/use-atom-query-runner";58import { useAtomQueryRunner } from "../state/use-atom-query-runner";
59import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments";59import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments";
60import { useProjects, useThreadShells } from "../state/entities";60import { useProjects, useThreadShells } from "../state/entities";
61import {61import { resolveThreadActionProjectRef, startNewThreadFromContext } from "../lib/chatThreadActions";
62 resolveThreadActionProjectRef,
63 startNewThreadInProjectFromContext,
64 startNewThreadFromContext,
65} from "../lib/chatThreadActions";
66import {62import {
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 contextualRefBelongsToGroup837 contextualRefBelongsToGroup
848 ? contextualProjectRef838 ? 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 );
864846
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});
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
480describe("orderItemsByPreferredIds", () => {372describe("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 a18// 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.
20export const SIDEBAR_THREAD_PREWARM_LIMIT = 10;20export const SIDEBAR_THREAD_PREWARM_LIMIT = 10;
21export type SidebarNewThreadEnvMode = "local" | "worktree";
22type SidebarProject = {21type 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}
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
305export function orderItemsByPreferredIds<TItem, TId>(input: {248export 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";
193import { useIsMobile } from "~/hooks/useMediaQuery";191import { useIsMobile } from "~/hooks/useMediaQuery";
194import { CommandDialogTrigger } from "./ui/command";192import { CommandDialogTrigger } from "./ui/command";
195import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings";193import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings";
196import { primaryServerKeybindingsAtom, primaryServerSettingsAtom } from "../state/server";194import { primaryServerKeybindingsAtom } from "../state/server";
197import {195import {
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
18751872
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 );
19481898
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 );
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
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 ...(supportsSettlement1588 ...(supportsSettlement
1581 ? [1589 ? [
1582 isSettled1590 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 them2622 // 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+model2627 // 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 hasStartFromOriginOption140 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 }
182274
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";
4import {4import {
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";
1210
...@@ -40,16 +38,12 @@ describe("chatThreadActions", () => {...@@ -40,16 +38,12 @@ describe("chatThreadActions", () => {
40 ).toBe(false);38 ).toBe(false);
41 });39 });
4240
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 });
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", () => {
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 );
8663
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 });
9566
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 );
11273
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 });
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 () => {
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);
12479
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 );
15089
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";
5interface ThreadContextLike {5interface ThreadContextLike {
6 environmentId: EnvironmentId;6 environmentId: EnvironmentId;
7 projectId: ProjectId;7 projectId: ProjectId;
8 branch: string | null;
9 worktreePath: string | null;
10}
11
12interface DraftThreadContextLike extends ThreadContextLike {
13 envMode: DraftThreadEnvMode;
14 startFromOrigin: boolean;
15}8}
169
17interface NewThreadHandler {10interface NewThreadHandler {
...@@ -26,10 +19,8 @@ interface NewThreadHandler {...@@ -26,10 +19,8 @@ interface NewThreadHandler {
26 ): Promise<void>;19 ): Promise<void>;
27}20}
2821
29type NewThreadOptions = NonNullable<Parameters<NewThreadHandler>[1]>;
30
31export interface ChatThreadActionContext {22export 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}
5950
60function 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
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
89export async function startNewThreadFromContext(57export 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 }
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
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";
11import { buildSidebarProjectSnapshots } from "../sidebarProjectGrouping";11import { buildSidebarProjectSnapshots } from "../sidebarProjectGrouping";
12import { dispatchPreviewAction } from "../components/preview/previewActionBus";12import { dispatchPreviewAction } from "../components/preview/previewActionBus";
13import { useHandleNewThread } from "../hooks/useHandleNewThread";13import { useHandleNewThread } from "../hooks/useHandleNewThread";
14import {14import { startNewThreadFromContext } from "../lib/chatThreadActions";
15 startNewLocalThreadFromContext,
16 startNewThreadFromContext,
17} from "../lib/chatThreadActions";
18import { isPreviewFocused } from "../lib/previewFocus";15import { isPreviewFocused } from "../lib/previewFocus";
19import { isTerminalFocused } from "../lib/terminalFocus";16import { isTerminalFocused } from "../lib/terminalFocus";
20import { resolveShortcutCommand } from "../keybindings";17import { 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,