1import { useAtomValue } from "@effect/atom-react";
2import {
3 scopedProjectKey,
4 scopeProjectRef,
5 scopeThreadRef,
6} from "@t3tools/client-runtime/environment";
7import { DEFAULT_SERVER_SETTINGS, type ScopedProjectRef, type ThreadId } from "@t3tools/contracts";
8import { useParams, useRouter } from "@tanstack/react-router";
9import { useCallback, useMemo } from "react";
10import {
11 composerDraftHasUserContent,
12 markPromotedDraftThreadByRef,
13 type DraftId,
14 type DraftThreadEnvMode,
15 type DraftThreadState,
16 useComposerDraftStore,
17} from "../composerDraftStore";
18import { newDraftId, newThreadId } from "../lib/utils";
19import { orderItemsByPreferredIds } from "../components/Sidebar.logic";
20import {
21 deriveLogicalProjectKeyFromSettings,
22 getProjectOrderKey,
23 selectProjectGroupingSettings,
24} from "../logicalProject";
25import { resolveNewThreadEnvMode, resolveProjectSettings } from "@t3tools/shared/projectSettings";
26import {
27 readProject,
28 readProjects,
29 readThreadShell,
30 readThreadShells,
31 useProjects,
32 useThreadShell,
33} from "../state/entities";
34import {
35 hasExplicitComposerModelSelection,
36 resolveNewDraftStartFromOrigin,
37 resolveNewThreadModelSelectionOverride,
38} from "../lib/chatThreadActions";
39import { readT3ProjectFile } from "../lib/t3ProjectFileDefaults";
40import { environmentServerConfigsAtom } from "../state/server";
41import { resolveThreadRouteTarget } from "../threadRoutes";
42import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore";
43import { useClientSettings } from "./useSettings";
44
45interface NewThreadWorkspaceOptions {
46 branch?: string | null;
47 worktreePath?: string | null;
48 envMode?: DraftThreadEnvMode;
49 startFromOrigin?: boolean;
50}
51
52// The workspace options the caller passed explicitly, shaped for the draft
53// store: absent keys stay absent so they never overwrite existing draft
54// state. Every reuse path applies exactly this set.
55function pickExplicitWorkspaceOptions(options: NewThreadWorkspaceOptions | undefined) {
56 return {
57 ...(options?.branch !== undefined ? { branch: options.branch } : {}),
58 ...(options?.worktreePath !== undefined ? { worktreePath: options.worktreePath } : {}),
59 ...(options?.envMode !== undefined ? { envMode: options.envMode } : {}),
60 ...(options?.startFromOrigin !== undefined ? { startFromOrigin: options.startFromOrigin } : {}),
61 };
62}
63
64export function useNewThreadHandler() {
65 const environmentServerConfigs = useAtomValue(environmentServerConfigsAtom);
66 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
67 const router = useRouter();
68 const getCurrentRouteTarget = useCallback(() => {
69 const currentRouteParams = router.state.matches[router.state.matches.length - 1]?.params ?? {};
70 return resolveThreadRouteTarget(currentRouteParams);
71 }, [router]);
72
73 return useCallback(
74 (
75 projectRef: ScopedProjectRef,
76 options?: {
77 branch?: string | null;
78 worktreePath?: string | null;
79 envMode?: DraftThreadEnvMode;
80 startFromOrigin?: boolean;
81 replace?: boolean;
82 },
83 // Which draft the thread ended up in, so a caller that has something to put in it — a
84 // prepared checkout, a task to write — addresses that one rather than looking the project
85 // up again and finding whichever draft it happens to hold.
86 ): Promise<{ draftId: DraftId; threadId: ThreadId } | null> => {
87 const projects = readProjects();
88 const targetServerSettings =
89 environmentServerConfigs.get(projectRef.environmentId)?.settings ?? DEFAULT_SERVER_SETTINGS;
90 const {
91 getComposerDraft,
92 getDraftSessionByLogicalProjectKey,
93 getDraftSession,
94 getDraftThread,
95 applyStickyState,
96 setDraftThreadContext,
97 setLogicalProjectDraftThreadId,
98 setModelSelection,
99 } = useComposerDraftStore.getState();
100 const requestingRouteHref = router.state.location.href;
101 const routeChangedSinceRequest = () => router.state.location.href !== requestingRouteHref;
102 const currentRouteTarget = getCurrentRouteTarget();
103 // A new thread carries the user's working mode from the thread being
104 // viewed. The target project's configured model still wins; interaction
105 // mode carries independently. Permissions, branch, worktree, and env mode
106 // come from configured defaults unless the caller passes them explicitly.
107 const carrySourceShell =
108 currentRouteTarget?.kind === "server"
109 ? readThreadShell(currentRouteTarget.threadRef)
110 : null;
111 const carrySourceDraft =
112 currentRouteTarget?.kind === "draft" ? getDraftSession(currentRouteTarget.draftId) : null;
113 // Composer overrides win over the persisted thread state — they are
114 // what the user currently sees in the composer controls.
115 const carrySourceComposer = currentRouteTarget
116 ? getComposerDraft(
117 currentRouteTarget.kind === "server"
118 ? currentRouteTarget.threadRef
119 : currentRouteTarget.draftId,
120 )
121 : null;
122 const composerActiveProvider = carrySourceComposer?.activeProvider ?? null;
123 const composerModelSelection = composerActiveProvider
124 ? (carrySourceComposer?.modelSelectionByProvider[composerActiveProvider] ?? null)
125 : null;
126 const carryModelSelection =
127 composerModelSelection ?? carrySourceShell?.modelSelection ?? null;
128 const carryInteractionMode =
129 carrySourceComposer?.interactionMode ??
130 carrySourceShell?.interactionMode ??
131 carrySourceDraft?.interactionMode ??
132 null;
133 const project = projects.find(
134 (candidate) =>
135 candidate.id === projectRef.projectId &&
136 candidate.environmentId === projectRef.environmentId,
137 );
138 // Removal can land while the project file is read; a draft opened
139 // after that belongs to a project that no longer exists.
140 const abandonedSinceRequest = () =>
141 routeChangedSinceRequest() || (project !== undefined && readProject(projectRef) === null);
142 // The resolver applies project overrides and, until the server has
143 // folded them, the aggregate's own legacy fields.
144 const projectSettings = resolveProjectSettings(
145 targetServerSettings,
146 project?.id ?? null,
147 project,
148 );
149 const projectDefaultModelSelection = projectSettings.settings.defaultModelSelection;
150 const defaultRuntimeMode = projectSettings.settings.defaultRuntimeMode;
151 const resolveModelSelectionOverride = (destinationDraftId: DraftId) =>
152 resolveNewThreadModelSelectionOverride({
153 projectDefaultSelection: projectDefaultModelSelection ?? null,
154 carrySelection: carryModelSelection,
155 carrySourceDraftId:
156 currentRouteTarget?.kind === "draft" ? currentRouteTarget.draftId : null,
157 destinationDraftId,
158 });
159 // The shared resolver owns the priority order. The t3.json read is
160 // skipped entirely when a higher-priority source decides, and its
161 // query atom caches per project after the first call.
162 const resolveDefaultEnvMode = async (): Promise<DraftThreadEnvMode> => {
163 const consultProjectFile =
164 project !== undefined && projectSettings.settings.defaultThreadEnvMode === null;
165 const projectFile = consultProjectFile
166 ? await readT3ProjectFile(project.environmentId, project.workspaceRoot)
167 : null;
168 return resolveNewThreadEnvMode({
169 projectSettings: resolveProjectSettings(
170 targetServerSettings,
171 project?.id ?? null,
172 project,
173 projectFile,
174 ),
175 workspaceRoot: project?.workspaceRoot ?? null,
176 newProjectsRoot: environmentServerConfigs.get(projectRef.environmentId)?.newProjectsRoot,
177 projectHasThreads: readThreadShells().some(
178 (thread) =>
179 thread.environmentId === projectRef.environmentId &&
180 thread.projectId === projectRef.projectId,
181 ),
182 });
183 };
184 const logicalProjectKey = project
185 ? deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings)
186 : scopedProjectKey(projectRef);
187 const hasBranchOption = options?.branch !== undefined;
188 const hasWorktreePathOption = options?.worktreePath !== undefined;
189 const hasEnvModeOption = options?.envMode !== undefined;
190 const hasStartFromOriginOption = options?.startFromOrigin !== undefined;
191 const storedDraftThread = getDraftSessionByLogicalProjectKey(logicalProjectKey);
192 const storedDraftThreadRef = storedDraftThread
193 ? scopeThreadRef(storedDraftThread.environmentId, storedDraftThread.threadId)
194 : null;
195 const reusableStoredDraftThread =
196 storedDraftThread !== null &&
197 storedDraftThread.promotedTo == null &&
198 storedDraftThreadRef !== null &&
199 readThreadShell(storedDraftThreadRef) === null
200 ? storedDraftThread
201 : null;
202 if (storedDraftThreadRef && reusableStoredDraftThread === null) {
203 markPromotedDraftThreadByRef(storedDraftThreadRef);
204 }
205 // New-thread surfaces (button, hotkeys, "/" landing, palette) only
206 // ever reuse a draft the user has NOT invested in. A draft with typed
207 // text or attachments is work in progress: it stays alive where it is
208 // (reachable from the sidebar draft rows) and this request mints a
209 // fresh draft instead — the remap in the store preserves invested
210 // drafts rather than deleting them.
211 const emptyStoredDraftThread =
212 reusableStoredDraftThread &&
213 !composerDraftHasUserContent(getComposerDraft(reusableStoredDraftThread.draftId))
214 ? reusableStoredDraftThread
215 : null;
216 const latestActiveDraftThread: DraftThreadState | null = currentRouteTarget
217 ? currentRouteTarget.kind === "server"
218 ? getDraftThread(currentRouteTarget.threadRef)
219 : getDraftSession(currentRouteTarget.draftId)
220 : null;
221 if (emptyStoredDraftThread) {
222 return (async () => {
223 const isDraftAlreadyOpen =
224 currentRouteTarget?.kind === "draft" &&
225 currentRouteTarget.draftId === emptyStoredDraftThread.draftId;
226 const hasExplicitWorkspaceOption =
227 hasBranchOption ||
228 hasWorktreePathOption ||
229 hasEnvModeOption ||
230 hasStartFromOriginOption;
231 // Resurrecting an empty stored draft must not resurrect its stale
232 // context: explicit workspace options win outright; otherwise the
233 // env context resets to the configured defaults so drafts seeded
234 // before a defaults change (or by the old carry-over behavior) stop
235 // landing on "current checkout" branches forever. When the draft is
236 // already open and no options were passed, leave its workspace
237 // context alone entirely — the user may have just picked a branch
238 // in the composer. Model selection has its own explicit-pick rule
239 // below and does not follow this guard.
240 let workspaceContext: NewThreadWorkspaceOptions | null = null;
241 if (hasExplicitWorkspaceOption) {
242 workspaceContext = pickExplicitWorkspaceOptions(options);
243 } else if (!isDraftAlreadyOpen) {
244 const defaultEnvMode = await resolveDefaultEnvMode();
245 if (abandonedSinceRequest()) {
246 return null;
247 }
248 // The await yields. If the draft was opened (a concurrent
249 // invocation's navigation landed), promoted to a real thread,
250 // remapped away (a concurrent invocation registered a fresh
251 // draft — remapping back would evict the winner and let the
252 // store GC it), or gained content (no longer a reusable empty
253 // draft) in the meantime, this invocation is a stale loser:
254 // resetting context, remapping, or navigating would all clobber
255 // state written after the snapshot above. Bail out entirely —
256 // the winner already did this work.
257 const routeTargetNow = getCurrentRouteTarget();
258 const openedMeanwhile =
259 routeTargetNow?.kind === "draft" &&
260 routeTargetNow.draftId === emptyStoredDraftThread.draftId;
261 const promotedMeanwhile =
262 storedDraftThreadRef !== null && readThreadShell(storedDraftThreadRef) !== null;
263 const remappedMeanwhile =
264 getDraftSessionByLogicalProjectKey(logicalProjectKey)?.draftId !==
265 emptyStoredDraftThread.draftId;
266 const investedMeanwhile = composerDraftHasUserContent(
267 getComposerDraft(emptyStoredDraftThread.draftId),
268 );
269 if (openedMeanwhile || promotedMeanwhile || remappedMeanwhile || investedMeanwhile) {
270 return null;
271 }
272 workspaceContext = {
273 branch: null,
274 worktreePath: null,
275 envMode: defaultEnvMode,
276 startFromOrigin: resolveNewDraftStartFromOrigin({
277 envMode: defaultEnvMode,
278 newWorktreesStartFromOrigin: projectSettings.settings.newWorktreesStartFromOrigin,
279 }),
280 };
281 }
282 if (workspaceContext) {
283 setDraftThreadContext(emptyStoredDraftThread.draftId, {
284 ...workspaceContext,
285 ...(!isDraftAlreadyOpen ? { runtimeMode: defaultRuntimeMode } : {}),
286 ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}),
287 });
288 }
289 // Model intent: an explicit human pick always stands. Seeds and
290 // legacy entries alike re-resolve here — sticky first, mirroring
291 // the mint-fresh path, then the project default or carried
292 // selection on top. This runs even when the draft is already open:
293 // without it, a changed pin could never reach the draft the user
294 // is looking at, because explicit picks are the only thing the
295 // flag protects.
296 const storedDraft = getComposerDraft(emptyStoredDraftThread.draftId);
297 const storedDraftHasExplicitModelPick = hasExplicitComposerModelSelection(storedDraft);
298 if (!storedDraftHasExplicitModelPick) {
299 applyStickyState(emptyStoredDraftThread.draftId);
300 const modelSelectionOverride = resolveModelSelectionOverride(
301 emptyStoredDraftThread.draftId,
302 );
303 if (modelSelectionOverride) {
304 // This is a complete snapshot: absent options mean "no options",
305 // not "keep the stale draft's options".
306 setModelSelection(emptyStoredDraftThread.draftId, modelSelectionOverride, {
307 replaceOptions: true,
308 });
309 }
310 }
311 // The workspace context must also ride along here: when projectRef
312 // targets a different physical member of the logical project,
313 // createDraftThreadState treats the remap as a project change and
314 // would otherwise wipe branch/worktree, undoing the write above.
315 setLogicalProjectDraftThreadId(
316 logicalProjectKey,
317 projectRef,
318 emptyStoredDraftThread.draftId,
319 {
320 threadId: emptyStoredDraftThread.threadId,
321 ...workspaceContext,
322 ...(!isDraftAlreadyOpen ? { runtimeMode: defaultRuntimeMode } : {}),
323 ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}),
324 },
325 );
326 const opened = {
327 draftId: emptyStoredDraftThread.draftId,
328 threadId: emptyStoredDraftThread.threadId,
329 };
330 // Re-read the route: the snapshot from before the await is stale
331 // once a concurrent invocation's navigation lands, and navigating
332 // again would push a duplicate history entry.
333 const routeTargetAfterWrites = getCurrentRouteTarget();
334 if (
335 routeTargetAfterWrites?.kind === "draft" &&
336 routeTargetAfterWrites.draftId === emptyStoredDraftThread.draftId
337 ) {
338 return opened;
339 }
340 await router.navigate({
341 to: "/draft/$draftId",
342 params: { draftId: emptyStoredDraftThread.draftId },
343 replace: options?.replace ?? false,
344 });
345 return opened;
346 })();
347 }
348
349 if (
350 latestActiveDraftThread &&
351 currentRouteTarget?.kind === "draft" &&
352 latestActiveDraftThread.logicalProjectKey === logicalProjectKey &&
353 latestActiveDraftThread.promotedTo == null &&
354 // Same content rule as above: a new-thread request while viewing an
355 // invested draft mints a fresh one instead of repurposing it.
356 !composerDraftHasUserContent(getComposerDraft(currentRouteTarget.draftId))
357 ) {
358 if (
359 hasBranchOption ||
360 hasWorktreePathOption ||
361 hasEnvModeOption ||
362 hasStartFromOriginOption
363 ) {
364 setDraftThreadContext(currentRouteTarget.draftId, pickExplicitWorkspaceOptions(options));
365 }
366 setLogicalProjectDraftThreadId(logicalProjectKey, projectRef, currentRouteTarget.draftId, {
367 threadId: latestActiveDraftThread.threadId,
368 createdAt: latestActiveDraftThread.createdAt,
369 runtimeMode: latestActiveDraftThread.runtimeMode,
370 interactionMode: latestActiveDraftThread.interactionMode,
371 ...pickExplicitWorkspaceOptions(options),
372 });
373 return Promise.resolve({
374 draftId: currentRouteTarget.draftId,
375 threadId: latestActiveDraftThread.threadId,
376 });
377 }
378
379 const draftId = newDraftId();
380 const threadId = newThreadId();
381 const createdAt = new Date().toISOString();
382 return (async () => {
383 const initialEnvMode = options?.envMode ?? (await resolveDefaultEnvMode());
384 if (abandonedSinceRequest()) {
385 return null;
386 }
387 // The await yields, so a concurrent invocation may have registered a
388 // draft for this logical project in the meantime. Registering ours
389 // too would evict that draft while its navigation is in flight —
390 // reuse the winner instead, like the synchronous path above does.
391 const racedDraft = getDraftSessionByLogicalProjectKey(logicalProjectKey);
392 if (
393 racedDraft &&
394 // Only a draft REGISTERED during the await counts as a raced
395 // winner. An invested draft this invocation deliberately declined
396 // to reuse is still mapped at this point — reusing it here would
397 // silently undo mint-fresh semantics.
398 racedDraft.draftId !== storedDraftThread?.draftId &&
399 readThreadShell(scopeThreadRef(racedDraft.environmentId, racedDraft.threadId)) === null
400 ) {
401 // Same remap the reuse paths above perform: point the draft at the
402 // caller's project member and apply explicit workspace options if
403 // the caller passed any. Without explicit options the winner's
404 // context stands untouched — the winner's navigation is landing,
405 // which is the isDraftAlreadyOpen "leave it alone" case. Writing
406 // this invocation's defaults here instead would clobber the
407 // winner's explicit picks and could pair its worktreePath with a
408 // contradictory envMode.
409 setLogicalProjectDraftThreadId(logicalProjectKey, projectRef, racedDraft.draftId, {
410 threadId: racedDraft.threadId,
411 createdAt: racedDraft.createdAt,
412 runtimeMode: racedDraft.runtimeMode,
413 interactionMode: racedDraft.interactionMode,
414 ...pickExplicitWorkspaceOptions(options),
415 });
416 await router.navigate({
417 to: "/draft/$draftId",
418 params: { draftId: racedDraft.draftId },
419 replace: options?.replace ?? false,
420 });
421 return { draftId: racedDraft.draftId, threadId: racedDraft.threadId };
422 }
423 setLogicalProjectDraftThreadId(logicalProjectKey, projectRef, draftId, {
424 threadId,
425 createdAt,
426 branch: options?.branch ?? null,
427 worktreePath: options?.worktreePath ?? null,
428 envMode: initialEnvMode,
429 startFromOrigin:
430 options?.startFromOrigin ??
431 resolveNewDraftStartFromOrigin({
432 envMode: initialEnvMode,
433 newWorktreesStartFromOrigin: projectSettings.settings.newWorktreesStartFromOrigin,
434 }),
435 runtimeMode: defaultRuntimeMode,
436 ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}),
437 });
438 applyStickyState(draftId);
439 const modelSelectionOverride = resolveModelSelectionOverride(draftId);
440 if (modelSelectionOverride) {
441 // Project defaults and carried selections both outrank global sticky
442 // state. The project default wins when both are present.
443 setModelSelection(draftId, modelSelectionOverride, { replaceOptions: true });
444 }
445 await router.navigate({
446 to: "/draft/$draftId",
447 params: { draftId },
448 replace: options?.replace ?? false,
449 });
450 return { draftId, threadId };
451 })();
452 },
453 [environmentServerConfigs, getCurrentRouteTarget, projectGroupingSettings, router],
454 );
455}
456
457export function useHandleNewThread() {
458 const projectOrder = useUiStateStore((store) => store.projectOrder);
459 const routeTarget = useParams({
460 strict: false,
461 select: (params) => resolveThreadRouteTarget(params),
462 });
463 const routeThreadRef = routeTarget?.kind === "server" ? routeTarget.threadRef : null;
464 const routeDraftId = routeTarget?.kind === "draft" ? routeTarget.draftId : null;
465 const activeThread = useThreadShell(routeThreadRef);
466 const getDraftThread = useComposerDraftStore((store) => store.getDraftThread);
467 const activeDraftThread = useComposerDraftStore(() =>
468 routeTarget
469 ? routeTarget.kind === "server"
470 ? getDraftThread(routeTarget.threadRef)
471 : useComposerDraftStore.getState().getDraftSession(routeTarget.draftId)
472 : null,
473 );
474 const projects = useProjects();
475 const orderedProjects = useMemo(() => {
476 return orderItemsByPreferredIds({
477 items: projects,
478 preferredIds: projectOrder,
479 getId: getProjectOrderKey,
480 getPreferenceIds: (project) => [
481 getProjectOrderKey(project),
482 legacyProjectCwdPreferenceKey(project.workspaceRoot),
483 ],
484 });
485 }, [projectOrder, projects]);
486 const handleNewThread = useNewThreadHandler();
487
488 return {
489 activeDraftThread,
490 activeThread,
491 defaultProjectRef: orderedProjects[0]
492 ? scopeProjectRef(orderedProjects[0].environmentId, orderedProjects[0].id)
493 : null,
494 handleNewThread,
495 routeDraftId,
496 routeThreadRef,
497 };
498}