Open History to see the last commit
1import type { ModelSelection, RuntimeMode, ScopedThreadRef } from "@t3tools/contracts";
2import { scopeThreadRef } from "@t3tools/client-runtime/environment";
3import { deriveThreadTitleSeed } from "@t3tools/client-runtime/operations";
4import {
5 type AtomCommandResult,
6 isAtomCommandInterrupted,
7 squashAtomCommandFailure,
8} from "@t3tools/client-runtime/state/runtime";
9import { useRef } from "react";
10
11import { newMessageId, newThreadId } from "~/lib/utils";
12import { useComposerDraftStore } from "../../composerDraftStore";
13import { useStableCallback } from "../../hooks/useStableCallback";
14import { useRightPanelStore } from "../../rightPanelStore";
15import { readThreadShell, waitForThreadShell } from "../../state/entities";
16import { threadEnvironment } from "../../state/threads";
17import { useAtomCommand } from "../../state/use-atom-command";
18import { useOrchestrationCommand } from "../../state/use-orchestration-command";
19
20/**
21 * Starts side chats from `threadRef`, or offers none when it is null. `open` forks the
22 * thread's latest stable point, sends `prompt` there with `readSettings()`, the parent
23 * composer's settings, and shows the tab. It resolves false when the side chat did not
24 * start, or another one is still starting.
25 */
26export function useSideChat(input: {
27 readonly threadRef: ScopedThreadRef | null;
28 readonly readSettings: () => {
29 readonly runtimeMode: RuntimeMode;
30 readonly modelSelection: ModelSelection | undefined;
31 };
32 readonly onError: (message: string) => void;
33}) {
34 const pendingRef = useRef(false);
35 const forkSideChat = useAtomCommand(threadEnvironment.forkSideChat, { reportFailure: false });
36 const deleteThread = useAtomCommand(threadEnvironment.delete, { reportFailure: false });
37 const setThreadRuntimeMode = useOrchestrationCommand(threadEnvironment.setRuntimeMode, {
38 reportFailure: false,
39 });
40 const startThreadTurn = useOrchestrationCommand(threadEnvironment.startTurn, {
41 reportFailure: false,
42 });
43 const reportFailure = (
44 failure: Extract<AtomCommandResult<unknown, unknown>, { readonly _tag: "Failure" }>,
45 fallback: string,
46 ) => {
47 if (isAtomCommandInterrupted(failure)) return;
48 const error = squashAtomCommandFailure(failure);
49 input.onError(error instanceof Error ? error.message : fallback);
50 };
51 const start = async (threadRef: ScopedThreadRef, prompt: string | undefined) => {
52 const { runtimeMode, modelSelection } = input.readSettings();
53 const targetThreadId = newThreadId();
54 const fork = await forkSideChat({
55 environmentId: threadRef.environmentId,
56 input: { sourceThreadId: threadRef.threadId, targetThreadId, title: "Side chat" },
57 });
58 if (fork._tag === "Failure") {
59 reportFailure(fork, "Could not start a side chat.");
60 return false;
61 }
62 // Drops the empty side chat a failed start leaves behind; one that got its message stays.
63 const discard = () =>
64 void deleteThread({
65 environmentId: threadRef.environmentId,
66 input: { threadId: targetThreadId, onlyIfUnused: true },
67 });
68 const targetRef = scopeThreadRef(threadRef.environmentId, targetThreadId);
69 if (!(await waitForThreadShell(targetRef))) {
70 input.onError("The side chat was created, but it did not reach this client.");
71 discard();
72 return false;
73 }
74 const sideChat = readThreadShell(targetRef);
75 if (sideChat === null) {
76 input.onError("The side chat is no longer available.");
77 discard();
78 return false;
79 }
80 if (runtimeMode !== sideChat.runtimeMode) {
81 const mode = await setThreadRuntimeMode({
82 environmentId: threadRef.environmentId,
83 input: { threadId: targetThreadId, runtimeMode, createdAt: new Date().toISOString() },
84 });
85 if (mode._tag === "Failure") {
86 reportFailure(mode, "Could not set the side chat's permission mode.");
87 discard();
88 return false;
89 }
90 }
91 const text = prompt?.trim();
92 // Send before showing the tab: closing a tab whose side chat has no run deletes it.
93 if (text) {
94 const turn = await startThreadTurn({
95 environmentId: threadRef.environmentId,
96 input: {
97 threadId: targetThreadId,
98 message: { messageId: newMessageId(), role: "user", text, attachments: [] },
99 ...(modelSelection === undefined ? {} : { modelSelection }),
100 titleSeed: deriveThreadTitleSeed({ text, attachments: [] }),
101 runtimeMode,
102 interactionMode: "default",
103 createdAt: new Date().toISOString(),
104 },
105 });
106 if (turn._tag === "Failure") {
107 reportFailure(turn, "Could not send the side chat message.");
108 discard();
109 return false;
110 }
111 } else if (modelSelection !== undefined) {
112 useComposerDraftStore.getState().setModelSelection(targetRef, modelSelection);
113 }
114 useRightPanelStore.getState().openSideChat(threadRef, targetThreadId);
115 return true;
116 };
117 const open = async (prompt?: string) => {
118 const { threadRef } = input;
119 if (!threadRef || pendingRef.current) return false;
120 pendingRef.current = true;
121 return start(threadRef, prompt).finally(() => {
122 pendingRef.current = false;
123 });
124 };
125 return { available: input.threadRef !== null, open: useStableCallback(open) };
126}