1import {
2 scopedProjectKey,
3 scopeProjectRef,
4 scopeThreadRef,
5} from "@t3tools/client-runtime/environment";
6import { DEFAULT_SERVER_SETTINGS, type ScopedProjectRef, type ThreadId } from "@t3tools/contracts";
7import { type AnyRouter, useRouter } from "@tanstack/react-router";
8import {
9 composerDraftHasUserContent,
10 markPromotedDraftThreadByRef,
11 type DraftId,
12 type DraftThreadEnvMode,
13 type DraftThreadState,
14 useComposerDraftStore,
15} from "../composerDraftStore";
16import { newDraftId, newThreadId } from "../lib/utils";
17import { orderItemsByPreferredIds } from "../components/Sidebar.logic";
18import {
19 deriveLogicalProjectKeyFromSettings,
20 getProjectOrderKey,
21 selectProjectGroupingSettings,
22} from "@t3tools/client-runtime/state/project-grouping";
23import { resolveNewThreadEnvMode, resolveProjectSettings } from "@t3tools/shared/projectSettings";
24import {
25 readProject,
26 readProjects,
27 readThreadShell,
28 readThreadShells,
29 useProjects,
30 useQuietThreadShell,
31} from "../state/entities";
32import {
33 hasExplicitComposerModelSelection,
34 resolveNewDraftStartFromOrigin,
35 resolveNewThreadModelSelectionOverride,
36} from "../lib/chatThreadActions";
37import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
38import { readT3ProjectFile } from "../lib/t3ProjectFileDefaults";
39import { appAtomRegistry } from "../rpc/atomRegistry";
40import { environmentSummaries } from "../state/presentation";
41import { environmentServerConfigsAtom } from "../state/server";
42import { resolveThreadRouteTarget } from "../threadRoutes";
43import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore";
44import { getClientSettings } from "./useSettings";
45import { getLocalStorageItem, useLocalStorage } from "./useLocalStorage";
46import * as Schema from "effect/Schema";
47import { useAtomValue } from "@effect/atom-react";
48import { defaultProjectFallbackReadyAtom } from "../state/projects";
49import { useThreadRouteTarget } from "./useThreadRouteTarget";
50import { readUnloadedThreadCount } from "../state/olderSettledShelf";
51import { resolveProjectDefaultProviderModelSelection } from "../providerInstances";
52import { resolveNewThreadModelSelection } from "@t3tools/shared/usageLimits";
53
54export const LAST_USED_PROJECT_STORAGE_KEY = "t3code:last-used-project:v1";
55export const lastUsedProjectKeySchema = Schema.NullOr(Schema.String);
56
57export function readLastUsedProjectKey(): string | null {
58 try {
59 return getLocalStorageItem(LAST_USED_PROJECT_STORAGE_KEY, lastUsedProjectKeySchema);
60 } catch {
61 return null;
62 }
63}
64
65interface NewThreadWorkspaceOptions {
66 branch?: string | null;
67 worktreePath?: string | null;
68 envMode?: DraftThreadEnvMode;
69 startFromOrigin?: boolean;
70}
71
72function pickExplicitWorkspaceOptions(options: NewThreadWorkspaceOptions | undefined) {
73 return {
74 ...(options?.branch !== undefined ? { branch: options.branch } : {}),
75 ...(options?.worktreePath !== undefined ? { worktreePath: options.worktreePath } : {}),
76 ...(options?.envMode !== undefined ? { envMode: options.envMode } : {}),
77 ...(options?.startFromOrigin !== undefined ? { startFromOrigin: options.startFromOrigin } : {}),
78 };
79}
80
81export function useNewThreadHandler() {
82 return createNewThreadHandler(useRouter());
83}
84
85export type NewThreadHandler = ReturnType<typeof createNewThreadHandler>;
86
87export function createNewThreadHandler(router: AnyRouter) {
88 const getCurrentRouteTarget = () => {
89 const currentRouteParams = router.state.matches[router.state.matches.length - 1]?.params ?? {};
90 return resolveThreadRouteTarget(currentRouteParams);
91 };
92
93 return (
94 projectRef: ScopedProjectRef,
95 options?: {
96 branch?: string | null;
97 worktreePath?: string | null;
98 envMode?: DraftThreadEnvMode;
99 startFromOrigin?: boolean;
100 replace?: boolean;
101 /** Composer text for the draft, set before it shows. A draft with user text keeps it. */
102 prompt?: string;
103 },
104 ): Promise<{ draftId: DraftId; threadId: ThreadId } | null> => {
105 const projects = readProjects();
106 const projectGroupingSettings = selectProjectGroupingSettings(getClientSettings());
107 // Read at call time; status pushes replace the config map and would recreate every sidebar handler.
108 const environmentServerConfigs = appAtomRegistry.get(environmentServerConfigsAtom);
109 const targetServerSettings =
110 environmentServerConfigs.get(projectRef.environmentId)?.settings ?? DEFAULT_SERVER_SETTINGS;
111 const {
112 getComposerDraft,
113 getDraftSessionByLogicalProjectKey,
114 getDraftSession,
115 getDraftThread,
116 applyStickyState,
117 setDraftThreadContext,
118 setLogicalProjectDraftThreadId,
119 setModelSelection,
120 setPrompt,
121 } = useComposerDraftStore.getState();
122 // Never over text the user wrote: a draft can gain some while this awaits.
123 const seedPrompt = (draftId: DraftId) => {
124 if (options?.prompt === undefined) return;
125 if (composerDraftHasUserContent(getComposerDraft(draftId))) return;
126 setPrompt(draftId, options.prompt);
127 };
128 const requestingRouteHref = router.state.location.href;
129 const routeChangedSinceRequest = () => router.state.location.href !== requestingRouteHref;
130 const currentRouteTarget = getCurrentRouteTarget();
131 const carrySourceShell =
132 currentRouteTarget?.kind === "server" ? readThreadShell(currentRouteTarget.threadRef) : null;
133 const carrySourceDraft =
134 currentRouteTarget?.kind === "draft" ? getDraftSession(currentRouteTarget.draftId) : null;
135 const carrySourceComposer = currentRouteTarget
136 ? getComposerDraft(
137 currentRouteTarget.kind === "server"
138 ? currentRouteTarget.threadRef
139 : currentRouteTarget.draftId,
140 )
141 : null;
142 const composerActiveProvider = carrySourceComposer?.activeProvider ?? null;
143 const composerModelSelection = composerActiveProvider
144 ? (carrySourceComposer?.modelSelectionByProvider[composerActiveProvider] ?? null)
145 : null;
146 const carryModelSelection = composerModelSelection ?? carrySourceShell?.modelSelection ?? null;
147 const carryInteractionMode =
148 carrySourceComposer?.interactionMode ??
149 carrySourceShell?.interactionMode ??
150 carrySourceDraft?.interactionMode ??
151 null;
152 const project = projects.find(
153 (candidate) =>
154 candidate.id === projectRef.projectId &&
155 candidate.environmentId === projectRef.environmentId,
156 );
157 const abandonedSinceRequest = () =>
158 routeChangedSinceRequest() || (project !== undefined && readProject(projectRef) === null);
159 const projectSettings = resolveProjectSettings(
160 targetServerSettings,
161 project?.id ?? null,
162 project,
163 );
164 const configuredDefaultModelSelection = projectSettings.settings.defaultModelSelection ?? null;
165 const fallbackResolution = resolveNewThreadModelSelection({
166 defaultSelection:
167 configuredDefaultModelSelection ??
168 (carryModelSelection || useComposerDraftStore.getState().stickyActiveProvider !== null
169 ? null
170 : resolveProjectDefaultProviderModelSelection(
171 environmentServerConfigs.get(projectRef.environmentId)?.providers ?? [],
172 targetServerSettings,
173 projectRef.projectId,
174 )),
175 fallbackSelection: targetServerSettings.fallbackModelSelection ?? null,
176 providers: environmentServerConfigs.get(projectRef.environmentId)?.providers ?? [],
177 settings: targetServerSettings,
178 projectId: projectRef.projectId,
179 now: Date.now(),
180 });
181 const projectDefaultModelSelection = fallbackResolution.fallback
182 ? fallbackResolution.selection
183 : configuredDefaultModelSelection;
184 const defaultRuntimeMode = projectSettings.settings.defaultRuntimeMode;
185 const resolveModelSelectionOverride = (destinationDraftId: DraftId) =>
186 resolveNewThreadModelSelectionOverride({
187 projectDefaultSelection: projectDefaultModelSelection ?? null,
188 carrySelection: carryModelSelection,
189 carrySourceDraftId:
190 currentRouteTarget?.kind === "draft" ? currentRouteTarget.draftId : null,
191 destinationDraftId,
192 });
193 const resolveDefaultEnvMode = async (): Promise<DraftThreadEnvMode> => {
194 // Environment queries wait for a connection, so reading the project file on an
195 // offline machine would never settle and the draft would never open.
196 const consultProjectFile =
197 project !== undefined &&
198 projectSettings.settings.defaultThreadEnvMode === null &&
199 appAtomRegistry
200 .get(environmentSummaries.connectedEnvironmentIdsAtom)
201 .includes(project.environmentId);
202 const projectFile = consultProjectFile
203 ? await readT3ProjectFile(project.environmentId, project.workspaceRoot)
204 : null;
205 return resolveNewThreadEnvMode({
206 projectSettings: resolveProjectSettings(
207 targetServerSettings,
208 project?.id ?? null,
209 project,
210 projectFile,
211 ),
212 workspaceRoot: project?.workspaceRoot ?? null,
213 newProjectsRoot: environmentServerConfigs.get(projectRef.environmentId)?.newProjectsRoot,
214 projectHasThreads:
215 readThreadShells().some(
216 (thread) =>
217 thread.environmentId === projectRef.environmentId &&
218 thread.projectId === projectRef.projectId,
219 ) ||
220 readUnloadedThreadCount(
221 [{ environmentId: projectRef.environmentId, id: projectRef.projectId }],
222 [],
223 ) > 0,
224 });
225 };
226 const logicalProjectKey = project
227 ? deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings)
228 : scopedProjectKey(projectRef);
229 const hasBranchOption = options?.branch !== undefined;
230 const hasWorktreePathOption = options?.worktreePath !== undefined;
231 const hasEnvModeOption = options?.envMode !== undefined;
232 const hasStartFromOriginOption = options?.startFromOrigin !== undefined;
233 const storedDraftThread = getDraftSessionByLogicalProjectKey(logicalProjectKey);
234 const storedDraftThreadRef = storedDraftThread
235 ? scopeThreadRef(storedDraftThread.environmentId, storedDraftThread.threadId)
236 : null;
237 const reusableStoredDraftThread =
238 storedDraftThread !== null &&
239 storedDraftThread.promotedTo == null &&
240 storedDraftThreadRef !== null &&
241 readThreadShell(storedDraftThreadRef) === null
242 ? storedDraftThread
243 : null;
244 if (storedDraftThreadRef && reusableStoredDraftThread === null) {
245 markPromotedDraftThreadByRef(storedDraftThreadRef);
246 }
247 const emptyStoredDraftThread =
248 reusableStoredDraftThread &&
249 !composerDraftHasUserContent(getComposerDraft(reusableStoredDraftThread.draftId))
250 ? reusableStoredDraftThread
251 : null;
252 const latestActiveDraftThread: DraftThreadState | null = currentRouteTarget
253 ? currentRouteTarget.kind === "server"
254 ? getDraftThread(currentRouteTarget.threadRef)
255 : getDraftSession(currentRouteTarget.draftId)
256 : null;
257 if (emptyStoredDraftThread) {
258 return (async () => {
259 const isDraftAlreadyOpen =
260 currentRouteTarget?.kind === "draft" &&
261 currentRouteTarget.draftId === emptyStoredDraftThread.draftId;
262 const hasExplicitWorkspaceOption =
263 hasBranchOption || hasWorktreePathOption || hasEnvModeOption || hasStartFromOriginOption;
264 let workspaceContext: NewThreadWorkspaceOptions | null = null;
265 if (hasExplicitWorkspaceOption) {
266 workspaceContext = pickExplicitWorkspaceOptions(options);
267 } else if (!isDraftAlreadyOpen) {
268 const defaultEnvMode = await resolveDefaultEnvMode();
269 if (abandonedSinceRequest()) {
270 return null;
271 }
272 // Concurrent calls may change the draft; stale resets, remaps, or navigation could clobber it.
273 const routeTargetNow = getCurrentRouteTarget();
274 const openedMeanwhile =
275 routeTargetNow?.kind === "draft" &&
276 routeTargetNow.draftId === emptyStoredDraftThread.draftId;
277 const promotedMeanwhile =
278 storedDraftThreadRef !== null && readThreadShell(storedDraftThreadRef) !== null;
279 const remappedMeanwhile =
280 getDraftSessionByLogicalProjectKey(logicalProjectKey)?.draftId !==
281 emptyStoredDraftThread.draftId;
282 const investedMeanwhile = composerDraftHasUserContent(
283 getComposerDraft(emptyStoredDraftThread.draftId),
284 );
285 if (openedMeanwhile || promotedMeanwhile || remappedMeanwhile || investedMeanwhile) {
286 return null;
287 }
288 workspaceContext = {
289 branch: null,
290 worktreePath: null,
291 envMode: defaultEnvMode,
292 startFromOrigin: resolveNewDraftStartFromOrigin({
293 envMode: defaultEnvMode,
294 newWorktreesStartFromOrigin: projectSettings.settings.newWorktreesStartFromOrigin,
295 }),
296 };
297 }
298 if (workspaceContext) {
299 setDraftThreadContext(emptyStoredDraftThread.draftId, {
300 ...workspaceContext,
301 ...(!isDraftAlreadyOpen ? { runtimeMode: defaultRuntimeMode } : {}),
302 ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}),
303 });
304 }
305 const storedDraft = getComposerDraft(emptyStoredDraftThread.draftId);
306 const storedDraftHasExplicitModelPick = hasExplicitComposerModelSelection(storedDraft);
307 if (!storedDraftHasExplicitModelPick) {
308 applyStickyState(emptyStoredDraftThread.draftId);
309 const modelSelectionOverride = resolveModelSelectionOverride(
310 emptyStoredDraftThread.draftId,
311 );
312 if (modelSelectionOverride) {
313 setModelSelection(emptyStoredDraftThread.draftId, modelSelectionOverride, {
314 replaceOptions: true,
315 });
316 }
317 }
318 // Remapping physical projects clears workspace context; preserve the branch and worktree.
319 setLogicalProjectDraftThreadId(
320 logicalProjectKey,
321 projectRef,
322 emptyStoredDraftThread.draftId,
323 {
324 threadId: emptyStoredDraftThread.threadId,
325 ...workspaceContext,
326 ...(!isDraftAlreadyOpen ? { runtimeMode: defaultRuntimeMode } : {}),
327 ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}),
328 },
329 );
330 const opened = {
331 draftId: emptyStoredDraftThread.draftId,
332 threadId: emptyStoredDraftThread.threadId,
333 };
334 seedPrompt(emptyStoredDraftThread.draftId);
335 const routeTargetAfterWrites = getCurrentRouteTarget();
336 if (
337 routeTargetAfterWrites?.kind === "draft" &&
338 routeTargetAfterWrites.draftId === emptyStoredDraftThread.draftId
339 ) {
340 return opened;
341 }
342 await router.navigate({
343 to: "/draft/$draftId",
344 params: { draftId: emptyStoredDraftThread.draftId },
345 replace: options?.replace ?? false,
346 });
347 return opened;
348 })();
349 }
350
351 if (
352 latestActiveDraftThread &&
353 currentRouteTarget?.kind === "draft" &&
354 latestActiveDraftThread.logicalProjectKey === logicalProjectKey &&
355 latestActiveDraftThread.promotedTo == null &&
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 seedPrompt(currentRouteTarget.draftId);
374 return Promise.resolve({
375 draftId: currentRouteTarget.draftId,
376 threadId: latestActiveDraftThread.threadId,
377 });
378 }
379
380 const draftId = newDraftId();
381 const threadId = newThreadId();
382 const createdAt = new Date().toISOString();
383 return (async () => {
384 const initialEnvMode = options?.envMode ?? (await resolveDefaultEnvMode());
385 if (abandonedSinceRequest()) {
386 return null;
387 }
388 // Reuse drafts registered while awaiting so their pending navigation cannot lose them.
389 const racedDraft = getDraftSessionByLogicalProjectKey(logicalProjectKey);
390 if (
391 racedDraft &&
392 racedDraft.draftId !== storedDraftThread?.draftId &&
393 readThreadShell(scopeThreadRef(racedDraft.environmentId, racedDraft.threadId)) === null
394 ) {
395 setLogicalProjectDraftThreadId(logicalProjectKey, projectRef, racedDraft.draftId, {
396 threadId: racedDraft.threadId,
397 createdAt: racedDraft.createdAt,
398 runtimeMode: racedDraft.runtimeMode,
399 interactionMode: racedDraft.interactionMode,
400 ...pickExplicitWorkspaceOptions(options),
401 });
402 seedPrompt(racedDraft.draftId);
403 await router.navigate({
404 to: "/draft/$draftId",
405 params: { draftId: racedDraft.draftId },
406 replace: options?.replace ?? false,
407 });
408 return { draftId: racedDraft.draftId, threadId: racedDraft.threadId };
409 }
410 setLogicalProjectDraftThreadId(logicalProjectKey, projectRef, draftId, {
411 threadId,
412 createdAt,
413 branch: options?.branch ?? null,
414 worktreePath: options?.worktreePath ?? null,
415 envMode: initialEnvMode,
416 startFromOrigin:
417 options?.startFromOrigin ??
418 resolveNewDraftStartFromOrigin({
419 envMode: initialEnvMode,
420 newWorktreesStartFromOrigin: projectSettings.settings.newWorktreesStartFromOrigin,
421 }),
422 runtimeMode: defaultRuntimeMode,
423 ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}),
424 });
425 applyStickyState(draftId);
426 const modelSelectionOverride = resolveModelSelectionOverride(draftId);
427 if (modelSelectionOverride) {
428 setModelSelection(draftId, modelSelectionOverride, { replaceOptions: true });
429 }
430 seedPrompt(draftId);
431 await router.navigate({
432 to: "/draft/$draftId",
433 params: { draftId },
434 replace: options?.replace ?? false,
435 });
436 return { draftId, threadId };
437 })();
438 };
439}
440
441export function resolveDefaultProjectRef(
442 projects: ReadonlyArray<EnvironmentProject>,
443 projectOrder: readonly string[],
444 lastUsedProjectKey: string | null,
445 fallbackReady = true,
446): ScopedProjectRef | null {
447 const lastUsed = projects.find(
448 (project) =>
449 project.archivedAt == null &&
450 scopedProjectKey(scopeProjectRef(project.environmentId, project.id)) === lastUsedProjectKey,
451 );
452 if (lastUsed) return scopeProjectRef(lastUsed.environmentId, lastUsed.id);
453 if (!fallbackReady) return null;
454 const [defaultProject] = orderItemsByPreferredIds({
455 items: projects.filter((project) => project.archivedAt == null),
456 preferredIds: projectOrder,
457 getId: getProjectOrderKey,
458 getPreferenceIds: (project) => [
459 getProjectOrderKey(project),
460 legacyProjectCwdPreferenceKey(project.workspaceRoot),
461 ],
462 });
463 return defaultProject ? scopeProjectRef(defaultProject.environmentId, defaultProject.id) : null;
464}
465
466export function useHandleNewThread() {
467 const projectOrder = useUiStateStore((store) => store.projectOrder);
468 const [lastUsedProjectKey] = useLocalStorage(
469 LAST_USED_PROJECT_STORAGE_KEY,
470 null,
471 lastUsedProjectKeySchema,
472 );
473 const defaultProjectFallbackReady = useAtomValue(
474 defaultProjectFallbackReadyAtom(lastUsedProjectKey),
475 );
476 const routeTarget = useThreadRouteTarget();
477 const routeThreadRef = routeTarget?.kind === "server" ? routeTarget.threadRef : null;
478 const routeDraftId = routeTarget?.kind === "draft" ? routeTarget.draftId : null;
479 const activeThread = useQuietThreadShell(routeThreadRef);
480 const getDraftThread = useComposerDraftStore((store) => store.getDraftThread);
481 const activeDraftThread = useComposerDraftStore(() =>
482 routeTarget
483 ? routeTarget.kind === "server"
484 ? getDraftThread(routeTarget.threadRef)
485 : useComposerDraftStore.getState().getDraftSession(routeTarget.draftId)
486 : null,
487 );
488 const projects = useProjects();
489 const handleNewThread = useNewThreadHandler();
490 const defaultProjectRef = resolveDefaultProjectRef(
491 projects,
492 projectOrder,
493 lastUsedProjectKey,
494 defaultProjectFallbackReady,
495 );
496
497 return {
498 activeDraftThread,
499 activeThread,
500 defaultProjectRef,
501 defaultProjectFallbackReady,
502 handleNewThread,
503 routeDraftId,
504 routeThreadRef,
505 };
506}