1import { scopeProjectRef } from "@t3tools/client-runtime/environment";
2import type {
3 EnvironmentId,
4 ModelSelection,
5 ProjectId,
6 ScopedProjectRef,
7} from "@t3tools/contracts";
8import type { ComposerThreadDraftState, DraftThreadEnvMode } from "../composerDraftStore";
9
10type ComposerModelSelectionState = Pick<
11 ComposerThreadDraftState,
12 "activeProvider" | "modelSelectionByProvider" | "modelSelectionExplicit"
13>;
14
15interface ThreadContextLike {
16 environmentId: EnvironmentId;
17 projectId: ProjectId;
18}
19
20interface NewThreadHandler {
21 (
22 projectRef: ScopedProjectRef,
23 options?: {
24 branch?: string | null;
25 worktreePath?: string | null;
26 envMode?: DraftThreadEnvMode;
27 startFromOrigin?: boolean;
28 },
29 // The opened draft's identity, which most callers have no use for.
30 ): Promise<unknown>;
31}
32
33export interface ChatThreadActionContext {
34 readonly activeDraftThread: ThreadContextLike | null;
35 readonly activeThread: ThreadContextLike | undefined;
36 readonly defaultProjectRef: ScopedProjectRef | null;
37 readonly handleNewThread: NewThreadHandler;
38}
39
40export function resolveNewDraftStartFromOrigin(input: {
41 envMode: DraftThreadEnvMode;
42 newWorktreesStartFromOrigin: boolean;
43}): boolean {
44 return input.envMode === "worktree" && input.newWorktreesStartFromOrigin;
45}
46
47export function resolveNewThreadModelSelectionOverride(input: {
48 readonly projectDefaultSelection: ModelSelection | null;
49 readonly carrySelection: ModelSelection | null;
50 readonly carrySourceDraftId: string | null;
51 readonly destinationDraftId: string;
52}): ModelSelection | null {
53 return (
54 input.projectDefaultSelection ??
55 (input.carrySourceDraftId === input.destinationDraftId ? null : input.carrySelection)
56 );
57}
58
59export function hasExplicitComposerModelSelection(
60 draft: ComposerModelSelectionState | null | undefined,
61): boolean {
62 const activeProvider = draft?.activeProvider;
63 return (
64 draft?.modelSelectionExplicit === true &&
65 activeProvider !== null &&
66 activeProvider !== undefined &&
67 draft.modelSelectionByProvider[activeProvider] !== undefined
68 );
69}
70
71export function resolveThreadActionProjectRef(
72 context: ChatThreadActionContext,
73): ScopedProjectRef | null {
74 if (context.activeThread) {
75 return scopeProjectRef(context.activeThread.environmentId, context.activeThread.projectId);
76 }
77 if (context.activeDraftThread) {
78 return scopeProjectRef(
79 context.activeDraftThread.environmentId,
80 context.activeDraftThread.projectId,
81 );
82 }
83 return context.defaultProjectRef;
84}
85
86// New threads inherit only the *project* from the current context. Branch,
87// worktree, and env mode always come from the user's configured defaults —
88// carrying them over from the viewed thread meant "new thread" silently
89// reused checkouts and branches. Explicit affordances (branch toolbar's
90// "new thread in this worktree") pass those options to handleNewThread
91// directly instead.
92export async function startNewThreadFromContext(
93 context: ChatThreadActionContext,
94): Promise<boolean> {
95 const projectRef = resolveThreadActionProjectRef(context);
96 if (!projectRef) {
97 return false;
98 }
99
100 await context.handleNewThread(projectRef);
101 return true;
102}