From d13aa1e56cb6ccf72322c503dc3d72160999be7e Mon Sep 17 00:00:00 2001 From: Kobe Attias Date: Mon, 21 Sep 2026 21:15:46 -0400 Subject: [PATCH] Share one chat session between the AI Mode overlay and the notebook panel useAIModeChat and AgentChatPanel each carried a copy of the same machinery: create the chat on the first send, queue that first message until the chat is live, keep a draft per chat across switches and failed sends, wire model selection, tell the listing when a turn settles, and word the composer's failure notices. hooks/useChatSession now holds it once, keyed by `transport:chat`, and both surfaces sit on top of it; components/AgentChat/chatNotices.ts holds the copy, with a policy for what a surface calls a chat and who announces a spent budget. Along the way: the queued first message is bound to the chat it was queued for instead of firing on whichever chat is selected next; the "new" draft no longer collides across scopes; the notebook composer now clears on send and restores the text on failure, as the overlay always did; and the overlay's composer takes the panel's fuller busy/stop rules. Transports are handed out by getChatTransport so a scope always gets the same object, and createChat takes a ChatCreateInit ready for the fields a workspace conversation starts with. Verified with type-check, eslint and prettier only. Co-Authored-By: Claude Fable 5.1 --- components/AIMode/ChatPane.tsx | 10 +- components/AIMode/useAIModeChat.ts | 324 +++-------------------- components/AgentChat/chatNotices.ts | 85 ++++++ components/Notebook/AgentChatPanel.tsx | 291 ++++----------------- hooks/useAgentChat.ts | 8 +- hooks/useChatSession.ts | 348 +++++++++++++++++++++++++ services/chatTransport.ts | 29 ++- 7 files changed, 548 insertions(+), 547 deletions(-) create mode 100644 components/AgentChat/chatNotices.ts create mode 100644 hooks/useChatSession.ts diff --git a/components/AIMode/ChatPane.tsx b/components/AIMode/ChatPane.tsx index 4ad066b62..3c4438bfc 100644 --- a/components/AIMode/ChatPane.tsx +++ b/components/AIMode/ChatPane.tsx @@ -40,7 +40,8 @@ export function ChatPane({ documentCard, documentCardExecutionId, }: ChatPaneProps) { - const { chatId, list, chat, modelSelection, draft, setDraft, notice, creatingChat } = state; + const { chatId, list, chat, modelSelection, draft, setDraft, notice, composerBusy, canStop } = + state; const composerRef = useRef(null); // ---- transcript auto-scroll ---- @@ -74,7 +75,7 @@ export function ChatPane({ const startFromCard = useCallback( (message: string) => { state.clearNotice(); - void state.sendText(message); + void state.send(message); }, [state] ); @@ -84,9 +85,6 @@ export function ChatPane({ chatId != null && (chat.access === 'not_found' || chat.access === 'unauthorized'); const composerDisabled = listBlocked || chatUnavailable || (chatId != null && chat.access === 'loading'); - const composerBusy = chat.isBusy || creatingChat; - // Stop must only be offered when there is a turn to cancel server-side. - const canStop = chat.latestExecution != null && chat.isBusy && chat.pendingSend == null; // The listing usually knows the title before the chat itself has loaded, // so a refresh doesn't flash "Untitled" while the transcript is fetched. @@ -104,7 +102,7 @@ export function ChatPane({ textareaRef={composerRef} value={draft} onChange={setDraft} - onSend={state.send} + onSend={() => void state.send()} onStop={state.stop} busy={composerBusy} canStop={canStop} diff --git a/components/AIMode/useAIModeChat.ts b/components/AIMode/useAIModeChat.ts index bb99f66dd..aed21fc42 100644 --- a/components/AIMode/useAIModeChat.ts +++ b/components/AIMode/useAIModeChat.ts @@ -2,101 +2,20 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useAIMode } from './AIModeContext'; -import { assistantChatTransport } from '@/services/chatTransport'; -import { - useAgentChat, - useAgentChatList, - type SendOutcome, - type UseAgentChatListResult, - type UseAgentChatResult, -} from '@/hooks/useAgentChat'; -import { useAgentModelSelection, type AgentModelSelection } from '@/hooks/useAgentModelSelection'; -import { useResearchAI } from '@/hooks/useResearchAI'; -import { canSelectAIModel, formatBudgetReset } from '@/types/researchAI'; +import { getChatTransport } from '@/services/chatTransport'; +import { useAgentChatList, type UseAgentChatListResult } from '@/hooks/useAgentChat'; +import { useChatSession, type ChatSession } from '@/hooks/useChatSession'; +import type { ChatNoticePolicy } from '@/components/AgentChat/chatNotices'; import type { ChatNoteRef, AgentChat } from '@/types/agentChat'; -import type { GenerationRequest } from '@/types/agentModels'; -import type { ComposerNotice } from '@/components/AgentChat/ChatComposer'; /** Matches the chat hook's own poll cadence, so a background turn's spinner clears as fast as the open one. */ const LIST_POLL_INTERVAL_MS = 5000; -interface QueuedMessage { - text: string; - generation: GenerationRequest; -} - -/** - * Composer copy for a failed send. Server `detail` is rendered verbatim - * wherever it exists; the fallbacks only cover bodies without one. A spent - * budget names its reset time when the allowance store knows it. - */ -function noticeFromOutcome( - outcome: SendOutcome & { ok: false }, - budgetResetsAt: string | null -): ComposerNotice { - switch (outcome.reason) { - case 'account_busy': - return { - tone: 'warning', - text: - outcome.detail ?? - 'Another assistant task of yours is still running elsewhere. Wait for it to finish, then try again.', - }; - case 'busy': - return { - tone: 'warning', - text: outcome.detail ?? 'The assistant is still working on a previous message.', - }; - case 'usage_limit': - return { - tone: 'warning', - text: budgetResetsAt - ? `You’ve used today’s assistant budget. It resets at ${formatBudgetReset(budgetResetsAt)}.` - : (outcome.detail ?? 'You’ve used today’s assistant budget. Try again after it resets.'), - }; - case 'model_not_allowed': - return { - tone: 'error', - text: - outcome.detail ?? 'That model isn’t available to you. Pick another one and try again.', - }; - case 'invalid': - return { tone: 'error', text: outcome.detail ?? 'That message can’t be sent.' }; - case 'not_found': - return { tone: 'error', text: 'This conversation is no longer available.' }; - case 'unauthorized': - return { - tone: 'error', - text: outcome.detail ?? 'You don’t have access to the assistant.', - }; - default: - return { - tone: 'error', - text: outcome.detail ?? 'Something went wrong — your message wasn’t sent.', - }; - } -} +const NOTICES: ChatNoticePolicy = { noun: 'conversation', usageLimit: 'inline' }; -export interface AIModeChatState { +export interface AIModeChatState extends ChatSession { readonly chatId: number | null; readonly list: UseAgentChatListResult; - readonly chat: UseAgentChatResult; - readonly modelSelection: AgentModelSelection; - readonly draft: string; - readonly setDraft: (value: string) => void; - readonly notice: ComposerNotice | null; - readonly clearNotice: () => void; - /** A brand-new chat is being created for the first message. */ - readonly creatingChat: boolean; - /** - * Sending would be refused: the allowance is unknown or spent, or a tier - * that picks its model has no catalog yet to pick from. - */ - readonly sendBlocked: boolean; - readonly send: () => Promise; - /** Send given text as the user's message — a starter card, sent as-is. */ - readonly sendText: (text: string) => Promise; - readonly stop: () => Promise; /** Rename any conversation, open or not. */ readonly rename: (chatId: number, title: string) => Promise; /** @@ -123,104 +42,43 @@ export interface AIModeChatState { } /** - * Orchestration for the overlay: the list, the open chat, model selection, - * per-chat drafts, and the send path. A conversation is only created on the - * first send, so abandoned "new conversation" screens leave nothing behind. + * Orchestration for the overlay: the sidebar's list, the open chat's session, + * selection through the URL, renames and deletes. The session itself — the + * chat, its draft and the send path — is `useChatSession`, shared with the + * notebook's panel. */ export function useAIModeChat(): AIModeChatState { const { chatId, selectChat: selectChatInUrl } = useAIMode(); - const transport = useMemo(() => assistantChatTransport(), []); + const transport = getChatTransport({ noteId: null }); const list = useAgentChatList(transport, true); - const [initialChat, setInitialChat] = useState(null); - const chat = useAgentChat({ transport, chatId, enabled: true, initialChat }); + const refreshList = list.refresh; + const onChatCreated = useCallback( + (created: AgentChat) => selectChatInUrl(created.conversation_id), + [selectChatInUrl] + ); + const session = useChatSession({ + transport, + chatId, + enabled: true, + onChatCreated, + onListStale: refreshList, + notices: NOTICES, + }); + const { chat } = session; const chatRef = useRef(chat.chat); chatRef.current = chat.chat; - // User-wide allowances and the model catalog load with the overlay; the - // selection hook reads them from the same store rather than fetching again. - const researchAI = useResearchAI(true); - const hasModelSelection = canSelectAIModel(researchAI.budget?.tier); - const modelSelection = useAgentModelSelection({ - enabled: false, - canSelect: hasModelSelection && researchAI.catalog !== null, - conversationKey: `assistant:${chatId ?? 'new'}`, - locked: - (chat.chat?.executions.length ?? 0) > 0 || - (chat.chat?.messages.length ?? 0) > 0 || - chat.pendingSend !== null, - pinnedRef: chat.pinnedModelRef, - effortPinned: chat.latestExecution != null, - pinnedEffort: chat.latestExecution?.effort ?? null, - }); - // A selectable tier must never submit its first turn without an authoritative - // model; cached budget and catalog data stay usable through refresh failures. - const sendBlocked = - researchAI.budget === null || - researchAI.isSubmissionBlocked() || - (hasModelSelection && modelSelection.model === null); - const getBudgetSnapshot = researchAI.getSnapshot; - const failureNotice = useCallback( - (outcome: SendOutcome & { ok: false }): ComposerNotice => { - const snapshot = getBudgetSnapshot(); - return noticeFromOutcome(outcome, snapshot.budget?.resets_at ?? snapshot.limitResetAt); - }, - [getBudgetSnapshot] - ); - - // ---- drafts (per chat, surviving switches and failed sends) ---- - const draftsRef = useRef(new Map()); - const draftKey = chatId == null ? 'new' : String(chatId); - const [draft, setDraftState] = useState(''); - const [notice, setNotice] = useState(null); - const [queuedMessage, setQueuedMessage] = useState(null); - const [creatingChat, setCreatingChat] = useState(false); - const creationSeqRef = useRef(0); - - const setDraft = useCallback( - (value: string) => { - draftsRef.current.set(draftKey, value); - setDraftState(value); - }, - [draftKey] - ); - - const prevDraftKeyRef = useRef(draftKey); - useEffect(() => { - if (prevDraftKeyRef.current === draftKey) return; - prevDraftKeyRef.current = draftKey; - setDraftState(draftsRef.current.get(draftKey) ?? ''); - setNotice(null); - }, [draftKey]); + const chatIdRef = useRef(chatId); + chatIdRef.current = chatId; // ---- selection ---- - const selectChat = useCallback( - (next: number | null) => { - setInitialChat(null); - selectChatInUrl(next); - }, - [selectChatInUrl] - ); + const selectChat = useCallback((next: number | null) => selectChatInUrl(next), [selectChatInUrl]); const startNewChat = useCallback(() => selectChat(null), [selectChat]); // ---- keep the listing fresh ---- - // Derived titles land after the first turn; previews and spinners change as - // turns settle. Refresh on those transitions of the open chat... - const latestStatus = chat.latestExecution?.status ?? null; - const chatTitle = chat.chat?.title ?? null; - const refreshList = list.refresh; - const prevListSignalRef = useRef<{ status: string | null; title: string | null }>({ - status: null, - title: null, - }); - useEffect(() => { - const prev = prevListSignalRef.current; - const changed = prev.status !== latestStatus || prev.title !== chatTitle; - prevListSignalRef.current = { status: latestStatus, title: chatTitle }; - if (changed) refreshList(); - }, [latestStatus, chatTitle, refreshList]); - - // ...and poll while any other conversation has a turn running, so its row - // spinner clears without the user having to open it. + // The session refreshes it as the open chat's turns settle; poll while any + // other conversation has a turn running, so its row spinner clears without + // the user having to open it. const anyTurnActive = list.chats.some((item) => item.has_active_turn); useEffect(() => { if (!anyTurnActive) return; @@ -247,88 +105,6 @@ export function useAIModeChat(): AIModeChatState { }); }, [loadedChatId, firstNoteId, firstNoteTitle]); - // ---- sending ---- - // Async continuations compare against the live target and discard results - // that raced a chat switch instead of applying them to the new one. - const targetRef = useRef(chatId); - targetRef.current = chatId; - const isCurrentTarget = useCallback((target: number | null) => targetRef.current === target, []); - - const sendText = useCallback( - async (rawText: string) => { - const text = rawText.trim(); - if (!text) return; - setNotice(null); - const target = targetRef.current; - const generation = modelSelection.request; - // The box empties the moment the user sends, as the message is already - // theirs; it only comes back if the send fails and they need to retry. - setDraft(''); - - if (chatId == null) { - const creationSeq = ++creationSeqRef.current; - setCreatingChat(true); - const created = await list.createChat(); - if (creationSeqRef.current === creationSeq) setCreatingChat(false); - if (!isCurrentTarget(target)) return; - if (!created) { - setDraft(text); - setNotice({ - tone: 'error', - text: list.accessDetail ?? 'Couldn’t start a conversation. Please try again.', - }); - return; - } - draftsRef.current.delete('new'); - // A rejected first attempt must retry with the same model and settings. - modelSelection.adoptConversation(`assistant:${created.conversation_id}`, generation); - setInitialChat(created); - selectChatInUrl(created.conversation_id); - setQueuedMessage({ text, generation }); - return; - } - - const outcome = await chat.send(text, generation); - if (!outcome.ok && isCurrentTarget(target)) { - setDraft(text); - setNotice(failureNotice(outcome)); - } - }, - [ - chatId, - list, - chat, - modelSelection.request, - modelSelection.adoptConversation, - setDraft, - isCurrentTarget, - selectChatInUrl, - failureNotice, - ] - ); - - const send = useCallback(() => sendText(draft), [sendText, draft]); - - // Fire the queued first message once the freshly created chat is live. - const sendToChat = chat.send; - useEffect(() => { - if (queuedMessage == null || chatId == null || chat.access !== 'ok') return; - const { text, generation } = queuedMessage; - const target = targetRef.current; - setQueuedMessage(null); - sendToChat(text, generation).then((outcome) => { - if (outcome.ok) return; - if (isCurrentTarget(target)) { - setNotice(failureNotice(outcome)); - setDraft(text); - } else { - draftsRef.current.set(String(target), text); - } - }); - }, [queuedMessage, chatId, chat.access, sendToChat, setDraft, isCurrentTarget, failureNotice]); - - const stop = chat.cancel; - // ---- renames, shown before the server confirms them ---- // A rename is the user's own words; making them wait for the PATCH just // flashes the old title back at them. The override shows at once and is @@ -359,7 +135,7 @@ export function useAIModeChat(): AIModeChatState { async (target: number, title: string): Promise => { setPendingTitle(target, title); let renamed: boolean; - if (target === targetRef.current) { + if (target === chatIdRef.current) { // The open chat's hook keeps its own copy of the title in sync. renamed = await chat.rename(title); } else { @@ -379,7 +155,7 @@ export function useAIModeChat(): AIModeChatState { const notesForChat = useCallback( async (target: number): Promise => { - if (target === targetRef.current && chatRef.current?.conversation_id === target) { + if (target === chatIdRef.current && chatRef.current?.conversation_id === target) { return chatRef.current.notes ?? []; } try { @@ -399,52 +175,22 @@ export function useAIModeChat(): AIModeChatState { } catch { return false; } - draftsRef.current.delete(String(target)); - if (targetRef.current === target) selectChat(null); + if (chatIdRef.current === target) selectChat(null); refreshList(); return true; }, [transport, selectChat, refreshList] ); - const clearNotice = useCallback(() => setNotice(null), []); - - // A spent-budget notice raised before the allowance store had a reset time - // picks it up once the store's post-429 refresh lands. - const budgetResetsAt = researchAI.budget?.resets_at ?? researchAI.limitResetAt ?? null; - useEffect(() => { - if (!budgetResetsAt) return; - setNotice((current) => - current?.tone === 'warning' && - current.text.includes('budget') && - !current.text.includes('resets at') - ? { - tone: 'warning', - text: `You’ve used today’s assistant budget. It resets at ${formatBudgetReset(budgetResetsAt)}.`, - } - : current - ); - }, [budgetResetsAt]); - const note = useMemo(() => { if (chatId == null) return null; return firstNote ?? notesByChat.get(chatId) ?? null; }, [chatId, firstNote, notesByChat]); return { + ...session, chatId, list, - chat, - modelSelection, - draft, - setDraft, - notice, - clearNotice, - creatingChat, - sendBlocked, - send, - sendText, - stop, rename, deleteChat, notesForChat, diff --git a/components/AgentChat/chatNotices.ts b/components/AgentChat/chatNotices.ts new file mode 100644 index 000000000..e4cd01fa5 --- /dev/null +++ b/components/AgentChat/chatNotices.ts @@ -0,0 +1,85 @@ +import type { SendOutcome } from '@/services/notebookChat.service'; +import { formatBudgetReset } from '@/types/researchAI'; +import type { ComposerNotice } from './ChatComposer'; + +export type FailedSend = Extract; + +/** How a surface words its notices and who shows a spent budget. */ +export interface ChatNoticePolicy { + /** What the surface calls a chat: "conversation" in the workspace, "chat" in the notebook. */ + readonly noun: 'conversation' | 'chat'; + /** + * A spent budget is announced by the composer notice, or left to a meter + * rendered beside the composer that already says so. + */ + readonly usageLimit: 'inline' | 'meter'; +} + +/** The spent-budget notice, naming the reset time when it is known. */ +export function budgetSpentNotice(budgetResetsAt: string | null): ComposerNotice { + return { + tone: 'warning', + text: budgetResetsAt + ? `You’ve used today’s assistant budget. It resets at ${formatBudgetReset(budgetResetsAt)}.` + : 'You’ve used today’s assistant budget. Try again after it resets.', + }; +} + +/** A budget notice that went out before the reset time was known. */ +export function isBudgetNoticeWithoutReset(notice: ComposerNotice | null): boolean { + return ( + notice?.tone === 'warning' && + notice.text.includes('budget') && + !notice.text.includes('resets at') + ); +} + +/** + * Composer copy for a failed send. Server `detail` is rendered verbatim + * wherever it exists; the fallbacks only cover bodies without one. + */ +export function noticeFromOutcome( + outcome: FailedSend, + policy: ChatNoticePolicy, + budgetResetsAt: string | null +): ComposerNotice | null { + switch (outcome.reason) { + case 'account_busy': + return { + tone: 'warning', + text: + outcome.detail ?? + 'Another assistant task of yours is still running elsewhere. Wait for it to finish, then try again.', + }; + case 'busy': + return { + tone: 'warning', + text: outcome.detail ?? 'The assistant is still working on a previous message.', + }; + case 'usage_limit': + if (policy.usageLimit === 'meter') return null; + return budgetResetsAt + ? budgetSpentNotice(budgetResetsAt) + : { tone: 'warning', text: outcome.detail ?? budgetSpentNotice(null).text }; + case 'model_not_allowed': + return { + tone: 'error', + text: + outcome.detail ?? 'That model isn’t available to you. Pick another one and try again.', + }; + case 'invalid': + return { tone: 'error', text: outcome.detail ?? 'That message can’t be sent.' }; + case 'not_found': + return { tone: 'error', text: `This ${policy.noun} is no longer available.` }; + case 'unauthorized': + return { + tone: 'error', + text: outcome.detail ?? 'You don’t have access to the assistant.', + }; + default: + return { + tone: 'error', + text: outcome.detail ?? 'Something went wrong — your message wasn’t sent.', + }; + } +} diff --git a/components/Notebook/AgentChatPanel.tsx b/components/Notebook/AgentChatPanel.tsx index 32e1318c0..30516b431 100644 --- a/components/Notebook/AgentChatPanel.tsx +++ b/components/Notebook/AgentChatPanel.tsx @@ -6,14 +6,13 @@ import { Button } from '@/components/ui/Button'; import { Loader } from '@/components/ui/Loader'; import { cn } from '@/utils/styles'; import { useNotebookContext } from '@/contexts/NotebookContext'; -import { useAgentChat, useAgentChatList, type SendOutcome } from '@/hooks/useAgentChat'; -import { notebookChatTransport } from '@/services/chatTransport'; -import { useAgentModelSelection } from '@/hooks/useAgentModelSelection'; +import { useAgentChatList } from '@/hooks/useAgentChat'; +import { useChatSession } from '@/hooks/useChatSession'; +import { getChatTransport } from '@/services/chatTransport'; import { useJumpToLatest } from '@/hooks/useJumpToLatest'; import { MAX_AGENT_CHAT_WIDTH, MIN_AGENT_CHAT_WIDTH } from '@/hooks/useAgentChatWidth'; import { isRfpNote } from '@/types/note'; -import { isActiveExecutionStatus, MAX_CHAT_TITLE_LENGTH, type AgentChat } from '@/types/agentChat'; -import type { GenerationRequest } from '@/types/agentModels'; +import { MAX_CHAT_TITLE_LENGTH, type AgentChat } from '@/types/agentChat'; import { ENDOWMENT_PROMO_BANNER_FEATURE } from '@/app/layouts/components/EndowmentPromoBanner'; import { useDismissableFeature } from '@/hooks/useDismissableFeature'; import { useEditorIsEmpty } from '@/hooks/useEditorIsEmpty'; @@ -21,53 +20,24 @@ import { belowMobileTopBar } from '@/components/Notebook/mobileBarOffsets'; import { NoteReviewControls } from '@/components/Notebook/NoteReview/NoteReviewControls'; import { NoteReviewBanner } from '@/components/Notebook/NoteReview/NoteReviewBanner'; import { useNoteAgentReview } from '@/components/Notebook/NoteReview/useNoteAgentReview'; -import { ChatComposer, type ComposerNotice } from '@/components/AgentChat/ChatComposer'; +import { ChatComposer } from '@/components/AgentChat/ChatComposer'; +import type { ChatNoticePolicy } from '@/components/AgentChat/chatNotices'; import { ChatPicker } from '@/components/AgentChat/ChatPicker'; import { ChatPresets } from '@/components/AgentChat/ChatPresets'; import { ChatSources, collectChatSources } from '@/components/AgentChat/ChatSources'; import { ChatTranscript } from '@/components/AgentChat/ChatTranscript'; import { CreditMeter } from '@/components/AgentChat/CreditMeter'; -import { useResearchAI } from '@/hooks/useResearchAI'; import { canSelectAIModel } from '@/types/researchAI'; import { ModelControls } from '@/components/AgentChat/ModelControls'; import { Logo } from '@/components/ui/Logo'; type PanelTab = 'chat' | 'sources'; -/** - * A first message waiting on the chat it will start, holding the model choice - * it was composed under so a later change can't retarget a send in flight. - */ -interface QueuedMessage { - readonly text: string; - readonly generation: GenerationRequest; -} - /** Pixels per arrow key press while the resize divider has focus. */ const RESIZE_KEY_STEP = 24; -function noticeFromOutcome(outcome: SendOutcome & { ok: false }): ComposerNotice | null { - switch (outcome.reason) { - case 'usage_limit': - // The shared meter owns this notice, including when the allowance resets. - return null; - case 'account_busy': - case 'busy': - return { - tone: 'warning', - text: outcome.detail ?? 'The assistant is still working on a previous message.', - }; - case 'model_not_allowed': - case 'invalid': - return { tone: 'error', text: outcome.detail ?? 'That message can’t be sent.' }; - case 'not_found': - return { tone: 'error', text: 'This chat is no longer available.' }; - case 'unauthorized': - return { tone: 'error', text: 'You no longer have access to the assistant.' }; - default: - return { tone: 'error', text: 'Something went wrong — your message wasn’t sent.' }; - } -} +/** The meter beside the composer already says when the budget is spent. */ +const NOTICES: ChatNoticePolicy = { noun: 'chat', usageLimit: 'meter' }; /** * A running in-note review session, handed to the host so the accept/reject @@ -135,10 +105,6 @@ export function AgentChatPanel({ onReviewChange, }: AgentChatPanelProps) { const { editor, currentNote } = useNotebookContext(); - // This panel stays mounted even when closed: load allowances on notebook open. - const researchAI = useResearchAI(true); - const hasModelSelection = canSelectAIModel(researchAI.budget?.tier); - const canSelectModel = hasModelSelection && researchAI.catalog !== null; // Decide which writing preset the empty chat screen offers, and what it // calls the document: the notebook holds RFPs as well as proposals. const noteIsEmpty = useEditorIsEmpty(editor); @@ -151,9 +117,8 @@ export function AgentChatPanel({ ); const promoBannerVisible = promoStatus === 'checked' && !promoDismissed; - // One transport per note: the hooks reset on its identity, so it is built - // once per note rather than per render. - const transport = useMemo(() => notebookChatTransport(noteId), [noteId]); + // One transport per note: the hooks reset on its identity. + const transport = getChatTransport({ noteId }); const list = useAgentChatList(transport, open); // Null is the new-chat screen, and it is where a page visit starts: the // assistant opens on its own opening moves rather than dropping the reader @@ -161,66 +126,42 @@ export function AgentChatPanel({ // away in the picker, and a selection survives closing the panel — only a // fresh visit or a note switch resets it. const [selectedChatId, setSelectedChatId] = useState(null); - const [initialChat, setInitialChat] = useState(null); + const switchChat = useCallback((nextChatId: number | null) => setSelectedChatId(nextChatId), []); // Network activity is gated on `open`. No keep-alive is needed for turns // that finish while the panel is closed or another chat is selected: the // note version socket below reports agent edits from any chat, and // reopening (or reselecting) refetches the transcript. - const chatState = useAgentChat({ + const refreshList = list.refresh; + const onListStale = useCallback(() => { + if (open) refreshList(); + }, [open, refreshList]); + const onChatCreated = useCallback((created: AgentChat) => { + setSelectedChatId(created.conversation_id); + }, []); + const session = useChatSession({ transport, chatId: selectedChatId, enabled: open, - initialChat, + onChatCreated, + onListStale, + notices: NOTICES, }); - - const switchChat = useCallback((nextChatId: number | null) => { - setSelectedChatId(nextChatId); - setInitialChat(null); - }, []); - - // ---- model selection ---- - // The catalog loads with the panel. A chat that has already run a turn is - // locked to the model it started on, and reports it here; until then the - // API default decides. - const modelSelection = useAgentModelSelection({ - enabled: false, - canSelect: canSelectModel, - conversationKey: `${noteId}:${selectedChatId ?? 'new'}`, - locked: - (chatState.chat?.executions.length ?? 0) > 0 || - (chatState.chat?.messages.length ?? 0) > 0 || - chatState.pendingSend !== null, - pinnedRef: chatState.pinnedModelRef, - effortPinned: chatState.latestExecution != null, - pinnedEffort: chatState.latestExecution?.effort ?? null, - }); - // A selectable tier must never submit its first turn without an authoritative - // model. Cached budget and catalog data remain usable through refresh failures. - const budgetSendDisabled = - researchAI.budget === null || - researchAI.isSubmissionBlocked() || - (hasModelSelection && modelSelection.model === null); - - // ---- drafts (per chat, surviving switches and failed sends) ---- - const draftsRef = useRef(new Map()); - const draftKey = selectedChatId == null ? 'new' : String(selectedChatId); - const [draft, setDraft] = useState(''); - const [notice, setNotice] = useState(null); - /** First message for a chat we just created, sent once the chat is live. */ - const [queuedMessage, setQueuedMessage] = useState(null); - const [creatingChat, setCreatingChat] = useState(false); - /** Identity of the latest creation, so a stale settle can't clear its flag. */ - const creationSeqRef = useRef(0); - - /** Draft writes go through here so the per-chat map stays in sync. */ - const updateDraft = useCallback( - (value: string) => { - draftsRef.current.set(draftKey, value); - setDraft(value); - }, - [draftKey] - ); + const { + chat: chatState, + modelSelection, + researchAI, + draft, + setDraft: updateDraft, + notice, + clearNotice, + sendBlocked: budgetSendDisabled, + composerBusy, + canStop, + send, + } = session; + const hasModelSelection = canSelectAIModel(researchAI.budget?.tier); + const canSelectModel = hasModelSelection && researchAI.catalog !== null; const composerRef = useRef(null); @@ -232,33 +173,19 @@ export function AgentChatPanel({ */ const applyPreset = useCallback( (message: string) => { - setNotice(null); + clearNotice(); updateDraft(message); const textarea = composerRef.current; if (!textarea) return; textarea.focus(); textarea.setSelectionRange(message.length, message.length); }, - [updateDraft] + [clearNotice, updateDraft] ); - const prevDraftKeyRef = useRef(draftKey); - useEffect(() => { - if (prevDraftKeyRef.current === draftKey) return; - prevDraftKeyRef.current = draftKey; - setDraft(draftsRef.current.get(draftKey) ?? ''); - setNotice(null); - }, [draftKey]); - - // ---- reset everything when the note changes ---- + // ---- a note switch starts on the new-chat screen ---- useEffect(() => { setSelectedChatId(null); - setInitialChat(null); - setNotice(null); - setQueuedMessage(null); - setCreatingChat(false); - draftsRef.current.clear(); - setDraft(''); }, [noteId]); // ---- server-side access gate ---- @@ -291,120 +218,6 @@ export function AgentChatPanel({ } }, [open, accessDenied, onUnavailable, researchAI.budgetStatus, researchAI.budget?.tier]); - // ---- keep the listing fresh as the open chat evolves ---- - // Derived titles land after the first turn, previews/spinners change as - // turns settle. Refresh only on actual transitions to avoid extra chatter. - const latestStatus = chatState.latestExecution?.status ?? null; - const chatTitle = chatState.chat?.title ?? null; - const refreshList = list.refresh; - const prevListSignalRef = useRef<{ status: string | null; title: string | null }>({ - status: null, - title: null, - }); - useEffect(() => { - const prev = prevListSignalRef.current; - const changed = prev.status !== latestStatus || prev.title !== chatTitle; - prevListSignalRef.current = { status: latestStatus, title: chatTitle }; - if (open && changed) refreshList(); - }, [open, latestStatus, chatTitle, refreshList]); - - // ---- sending ---- - // Live mirror of the panel's target. Async continuations compare against it - // and discard results that raced a chat or note switch instead of applying - // them to the newly selected chat — the hook guards its own state the same - // way, but the returned outcomes surface here. - const targetRef = useRef<{ noteId: string; chatId: number | null }>({ - noteId, - chatId: selectedChatId, - }); - targetRef.current = { noteId, chatId: selectedChatId }; - const isCurrentTarget = useCallback( - (target: { noteId: string; chatId: number | null }) => - targetRef.current.noteId === target.noteId && targetRef.current.chatId === target.chatId, - [] - ); - - const handleSend = useCallback(async () => { - const text = draft.trim(); - if (!text || budgetSendDisabled || chatState.isBusy || creatingChat || queuedMessage) return; - setNotice(null); - const target = targetRef.current; - // Captured before the awaits: the turn runs on what was selected when the - // user pressed send, not on whatever the picker says by the time it lands. - const generation = modelSelection.request; - - if (selectedChatId == null) { - // Default flow: create untitled, send the first message; the refetch - // after the turn brings the derived title. - const creationSeq = ++creationSeqRef.current; - setCreatingChat(true); - const created = await list.createChat(); - // A newer creation may own the flag by now (the user moved to another - // note and started a chat there) — a stale settle must not unblock its - // composer while that creation is still in flight. - if (creationSeqRef.current === creationSeq) setCreatingChat(false); - // Switched note or picked an existing chat meanwhile — abandon the - // creation instead of yanking the selection to a stale chat. - if (!isCurrentTarget(target)) return; - if (!created) { - setNotice({ tone: 'error', text: 'Couldn’t start a chat. Please try again.' }); - return; - } - draftsRef.current.delete('new'); - // A rejected first attempt must retry with the same model and settings. - modelSelection.adoptConversation(`${noteId}:${created.conversation_id}`, generation); - setInitialChat(created); - setSelectedChatId(created.conversation_id); - setQueuedMessage({ text, generation }); - return; - } - - const outcome = await chatState.send(text, generation); - if (outcome.ok) { - if (isCurrentTarget(target)) { - updateDraft(''); - } else { - // Sent fine, but the user moved on — just retire the sent draft. - draftsRef.current.delete(String(target.chatId)); - } - } else if (isCurrentTarget(target)) { - // Keep the draft on any failure. - setNotice(noticeFromOutcome(outcome)); - } - }, [ - draft, - selectedChatId, - list, - chatState, - modelSelection.request, - modelSelection.adoptConversation, - noteId, - updateDraft, - isCurrentTarget, - budgetSendDisabled, - creatingChat, - queuedMessage, - ]); - - // Fire the queued first message once the freshly created chat is live. - const sendToChat = chatState.send; - useEffect(() => { - if (queuedMessage == null || selectedChatId == null || chatState.access !== 'ok') return; - const { text, generation } = queuedMessage; - const target = targetRef.current; - setQueuedMessage(null); - sendToChat(text, generation).then((outcome) => { - if (outcome.ok) return; - if (isCurrentTarget(target)) { - setNotice(noticeFromOutcome(outcome)); - updateDraft(text); - } else { - // Failed after a switch — keep the unsent text under its own chat. - draftsRef.current.set(String(target.chatId), text); - } - }); - }, [queuedMessage, selectedChatId, chatState.access, sendToChat, updateDraft, isCurrentTarget]); - // ---- rename ---- const [renaming, setRenaming] = useState(false); const [renameValue, setRenameValue] = useState(''); @@ -415,15 +228,17 @@ export function AgentChatPanel({ setRenaming(true); }; + const sessionKeyRef = useRef(session.sessionKey); + sessionKeyRef.current = session.sessionKey; const commitRename = async () => { setRenaming(false); const title = renameValue.trim(); if (!title || title === (chatState.chat?.title ?? '')) return; - const target = targetRef.current; + const key = session.sessionKey; const renamed = await chatState.rename(title); // A rename that raced a switch must not fire its note-bound refresh — the // stale fetch would outrank and replace the current note's listing. - if (renamed && isCurrentTarget(target)) refreshList(); + if (renamed && sessionKeyRef.current === key) refreshList(); }; // A rename left open across a note switch would commit against whichever @@ -481,20 +296,6 @@ export function AgentChatPanel({ }, [chatState.chat, chatState.pendingSend, activeTab, follow]); // ---- derived composer state ---- - // Sending before the catalog lands would run the turn on the server default - // and pin the conversation to it. Keep the draft editable while send waits. - const composerBusy = - chatState.isBusy || - chatState.isFinishing || - creatingChat || - queuedMessage != null || - (canSelectModel && modelSelection.status === 'loading'); - // Stop is only offered once something cancellable exists server-side. While - // the message POST is still in flight or the chat is being created, cancel - // would no-op and the turn would start anyway. - const turnActive = - chatState.latestExecution != null && isActiveExecutionStatus(chatState.latestExecution.status); - const canStop = turnActive || chatState.pendingSend?.executionId != null; const chatAccessible = selectedChatId == null ? list.access === 'ok' : chatState.access === 'ok'; const composerDisabled = accessDenied || !chatAccessible; @@ -643,7 +444,7 @@ export function AgentChatPanel({ refreshList()} titleAction={ @@ -729,7 +530,7 @@ export function AgentChatPanel({ textareaRef={composerRef} value={draft} onChange={updateDraft} - onSend={handleSend} + onSend={() => void send()} onStop={chatState.cancel} busy={composerBusy} canStop={canStop} diff --git a/hooks/useAgentChat.ts b/hooks/useAgentChat.ts index 2a41ba69d..8ee67e13b 100644 --- a/hooks/useAgentChat.ts +++ b/hooks/useAgentChat.ts @@ -9,7 +9,7 @@ import { sendFailureOutcome, type SendOutcome, } from '@/services/notebookChat.service'; -import type { ChatTransport } from '@/services/chatTransport'; +import type { ChatCreateInit, ChatTransport } from '@/services/chatTransport'; import { useAgentChatSocket, type ChatSocketStatus } from '@/hooks/useAgentChatSocket'; import { isChatStreamSocketEvent, @@ -603,7 +603,7 @@ export interface UseAgentChatListResult { /** The server's `detail` copy behind a `hidden` or `error` access state. */ accessDetail: string | null; refresh: () => Promise; - createChat: (title?: string) => Promise; + createChat: (init?: ChatCreateInit) => Promise; } /** @@ -656,11 +656,11 @@ export function useAgentChatList( }, [transport, enabled, refresh]); const createChat = useCallback( - async (title?: string): Promise => { + async (init?: ChatCreateInit): Promise => { if (transport == null) return null; const epoch = epochRef.current; try { - const chat = await transport.createChat(title); + const chat = await transport.createChat(init); if (epoch === epochRef.current) refresh(); return chat; } catch (err) { diff --git a/hooks/useChatSession.ts b/hooks/useChatSession.ts new file mode 100644 index 000000000..538ee6fe7 --- /dev/null +++ b/hooks/useChatSession.ts @@ -0,0 +1,348 @@ +'use client'; + +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { ComposerNotice } from '@/components/AgentChat/ChatComposer'; +import { + budgetSpentNotice, + isBudgetNoticeWithoutReset, + noticeFromOutcome, + type ChatNoticePolicy, + type FailedSend, +} from '@/components/AgentChat/chatNotices'; +import { useAgentChat, type UseAgentChatResult } from '@/hooks/useAgentChat'; +import { useAgentModelSelection, type AgentModelSelection } from '@/hooks/useAgentModelSelection'; +import { useResearchAI } from '@/hooks/useResearchAI'; +import type { ChatCreateInit, ChatTransport } from '@/services/chatTransport'; +import { chatErrorDetail } from '@/services/notebookChat.service'; +import { isActiveExecutionStatus, type AgentChat } from '@/types/agentChat'; +import type { GenerationRequest } from '@/types/agentModels'; +import { canSelectAIModel } from '@/types/researchAI'; + +/** + * A first message waiting on the chat it will start, holding the model choice + * it was composed under so a later change can't retarget a send in flight, + * and the session it belongs to so a switch can't fire it elsewhere. + */ +interface QueuedMessage { + readonly sessionKey: string; + readonly text: string; + readonly generation: GenerationRequest; +} + +export interface UseChatSessionOptions { + /** The surface and scope the chat lives on; must be referentially stable. */ + readonly transport: ChatTransport; + /** The open chat, or null for the new-chat screen. */ + readonly chatId: number | null; + /** False while the surface is closed — suspends fetching and the socket. */ + readonly enabled: boolean; + /** A chat was just created for the first message; the surface selects it. */ + readonly onChatCreated: (chat: AgentChat) => void; + /** The chat's row in a listing may be out of date: a title landed, a turn settled. */ + readonly onListStale?: () => void; + /** Extra fields for the chat the first message creates. */ + readonly getCreateInit?: () => ChatCreateInit | undefined; + readonly notices: ChatNoticePolicy; +} + +export interface ChatSession { + readonly chat: UseAgentChatResult; + readonly modelSelection: AgentModelSelection; + readonly researchAI: ReturnType; + /** `transport:chat`, the identity a draft, a model choice and a send are bound to. */ + readonly sessionKey: string; + readonly draft: string; + readonly setDraft: (value: string) => void; + readonly notice: ComposerNotice | null; + readonly clearNotice: () => void; + /** A brand-new chat is being created for the first message. */ + readonly creatingChat: boolean; + /** + * Sending would be refused: the allowance is unknown or spent, or a tier + * that picks its model has no catalog yet to pick from. + */ + readonly sendBlocked: boolean; + /** The composer should wait: a turn, a send, a creation or the catalog is in flight. */ + readonly composerBusy: boolean; + /** Something cancellable exists server-side. */ + readonly canStop: boolean; + /** Send the given text, or the draft. Creates the chat first when there is none. */ + readonly send: (text?: string) => Promise; + readonly stop: () => Promise; +} + +const sessionKeyFor = (transport: ChatTransport, chatId: number | null) => + `${transport.key}:${chatId ?? 'new'}`; + +/** + * One chat surface's session: the open chat, its model selection, a draft per + * chat that survives switches and failed sends, and the send path. A chat is + * only created on the first send, so an abandoned new-chat screen leaves + * nothing behind. Listing, selection and renames stay with the surface. + */ +export function useChatSession({ + transport, + chatId, + enabled, + onChatCreated, + onListStale, + getCreateInit, + notices, +}: UseChatSessionOptions): ChatSession { + const sessionKey = sessionKeyFor(transport, chatId); + + // The creation response seeds the chat it created, and only that one: a + // later visit to the same chat must fetch, or it would show the empty + // creation state with nothing to move it on. + const [createdChat, setCreatedChat] = useState(null); + const initialChat = createdChat?.conversation_id === chatId ? createdChat : null; + const chat = useAgentChat({ transport, chatId, enabled, initialChat }); + + // User-wide allowances and the model catalog load as soon as the surface + // mounts, open or not, so the composer is ready when it shows; the + // selection hook reads them from the same store rather than fetching again. + const researchAI = useResearchAI(true); + const hasModelSelection = canSelectAIModel(researchAI.budget?.tier); + const canSelectModel = hasModelSelection && researchAI.catalog !== null; + // A chat that has already run a turn is locked to the model it started on, + // and reports it here; until then the API default decides. + const modelSelection = useAgentModelSelection({ + enabled: false, + canSelect: canSelectModel, + conversationKey: sessionKey, + locked: + (chat.chat?.executions.length ?? 0) > 0 || + (chat.chat?.messages.length ?? 0) > 0 || + chat.pendingSend !== null, + pinnedRef: chat.pinnedModelRef, + effortPinned: chat.latestExecution != null, + pinnedEffort: chat.latestExecution?.effort ?? null, + }); + // A selectable tier must never submit its first turn without an authoritative + // model; cached budget and catalog data stay usable through refresh failures. + const sendBlocked = + researchAI.budget === null || + researchAI.isSubmissionBlocked() || + (hasModelSelection && modelSelection.model === null); + + // ---- drafts (per session, surviving switches and failed sends) ---- + const draftsRef = useRef(new Map()); + const [draft, setDraftState] = useState(''); + const [notice, setNotice] = useState(null); + const [queuedMessage, setQueuedMessage] = useState(null); + const [creatingChat, setCreatingChat] = useState(false); + /** Identity of the latest creation, so a stale settle can't clear its flag. */ + const creationSeqRef = useRef(0); + + const setDraft = useCallback( + (value: string) => { + draftsRef.current.set(sessionKey, value); + setDraftState(value); + }, + [sessionKey] + ); + const clearNotice = useCallback(() => setNotice(null), []); + + const prevSessionKeyRef = useRef(sessionKey); + useEffect(() => { + if (prevSessionKeyRef.current === sessionKey) return; + prevSessionKeyRef.current = sessionKey; + setDraftState(draftsRef.current.get(sessionKey) ?? ''); + setNotice(null); + }, [sessionKey]); + + // ---- the listing follows the open chat ---- + // Derived titles land after the first turn; previews and spinners change as + // turns settle. Tell the surface on those transitions only. + const latestStatus = chat.latestExecution?.status ?? null; + const chatTitle = chat.chat?.title ?? null; + const prevListSignalRef = useRef<{ status: string | null; title: string | null }>({ + status: null, + title: null, + }); + useEffect(() => { + const prev = prevListSignalRef.current; + const changed = prev.status !== latestStatus || prev.title !== chatTitle; + prevListSignalRef.current = { status: latestStatus, title: chatTitle }; + if (changed) onListStale?.(); + }, [latestStatus, chatTitle, onListStale]); + + // ---- sending ---- + // Async continuations compare against the live session and discard results + // that raced a switch instead of applying them to the new one. + const sessionKeyRef = useRef(sessionKey); + sessionKeyRef.current = sessionKey; + const isCurrentSession = useCallback((key: string) => sessionKeyRef.current === key, []); + + const getBudgetSnapshot = researchAI.getSnapshot; + const failureNotice = useCallback( + (outcome: FailedSend): ComposerNotice | null => { + const snapshot = getBudgetSnapshot(); + return noticeFromOutcome( + outcome, + notices, + snapshot.budget?.resets_at ?? snapshot.limitResetAt + ); + }, + [getBudgetSnapshot, notices] + ); + + const composerBusy = + chat.isBusy || + chat.isFinishing || + creatingChat || + queuedMessage != null || + (canSelectModel && modelSelection.status === 'loading'); + + const send = useCallback( + async (rawText: string = draft) => { + const text = rawText.trim(); + if (!text || sendBlocked || composerBusy) return; + setNotice(null); + const session = sessionKeyRef.current; + // Captured before the awaits: the turn runs on what was selected when the + // user pressed send, not on whatever the picker says by the time it lands. + const generation = modelSelection.request; + // The box empties the moment the user sends, as the message is already + // theirs; it only comes back if the send fails and they need to retry. + setDraft(''); + + if (chatId == null) { + const creationSeq = ++creationSeqRef.current; + setCreatingChat(true); + let created: AgentChat | null = null; + let failure: string | null = null; + try { + created = await transport.createChat(getCreateInit?.()); + } catch (error) { + failure = chatErrorDetail(error) ?? null; + } + // A newer creation may own the flag by now — a stale settle must not + // unblock its composer while that creation is still in flight. + if (creationSeqRef.current === creationSeq) setCreatingChat(false); + // The listing has a new row, or a refused creation for it to explain. + onListStale?.(); + // Switched away meanwhile — abandon the creation instead of yanking + // the selection to a stale chat. + if (!isCurrentSession(session)) return; + if (!created) { + setDraft(text); + setNotice({ + tone: 'error', + text: failure ?? `Couldn’t start a ${notices.noun}. Please try again.`, + }); + return; + } + draftsRef.current.delete(session); + // A rejected first attempt must retry with the same model and settings. + const createdKey = sessionKeyFor(transport, created.conversation_id); + modelSelection.adoptConversation(createdKey, generation); + setCreatedChat(created); + setQueuedMessage({ sessionKey: createdKey, text, generation }); + onChatCreated(created); + return; + } + + const outcome = await chat.send(text, generation); + if (outcome.ok) { + // Sent fine after a switch: retire the draft under its own chat. + if (!isCurrentSession(session)) draftsRef.current.delete(session); + return; + } + if (isCurrentSession(session)) { + setDraft(text); + setNotice(failureNotice(outcome)); + } else { + draftsRef.current.set(session, text); + } + }, + [ + draft, + sendBlocked, + composerBusy, + chatId, + transport, + chat, + modelSelection.request, + modelSelection.adoptConversation, + setDraft, + isCurrentSession, + getCreateInit, + onListStale, + onChatCreated, + notices.noun, + failureNotice, + ] + ); + + // Fire the queued first message once the freshly created chat is live and + // selected. A message queued for another chat waits for that chat. + const sendToChat = chat.send; + useEffect(() => { + if (queuedMessage == null || queuedMessage.sessionKey !== sessionKey) return; + if (chat.access !== 'ok') return; + const { text, generation } = queuedMessage; + setQueuedMessage(null); + sendToChat(text, generation).then((outcome) => { + if (outcome.ok) return; + if (isCurrentSession(queuedMessage.sessionKey)) { + setNotice(failureNotice(outcome)); + setDraft(text); + } else { + // Failed after a switch — keep the unsent text under its own chat. + draftsRef.current.set(queuedMessage.sessionKey, text); + } + }); + }, [ + queuedMessage, + sessionKey, + chat.access, + sendToChat, + setDraft, + isCurrentSession, + failureNotice, + ]); + + // A queued message whose chat was never selected (the surface moved on + // before the creation settled) must not fire when that chat is opened later + // in some other context: it dies with the transport it was queued on. + useEffect(() => { + setQueuedMessage((current) => + current != null && !current.sessionKey.startsWith(`${transport.key}:`) ? null : current + ); + }, [transport]); + + // A spent-budget notice raised before the allowance store had a reset time + // picks it up once the store's post-429 refresh lands. + const budgetResetsAt = researchAI.budget?.resets_at ?? researchAI.limitResetAt ?? null; + useEffect(() => { + if (!budgetResetsAt || notices.usageLimit !== 'inline') return; + setNotice((current) => + isBudgetNoticeWithoutReset(current) ? budgetSpentNotice(budgetResetsAt) : current + ); + }, [budgetResetsAt, notices.usageLimit]); + + // Stop is only offered once something cancellable exists server-side. While + // the message POST is still in flight or the chat is being created, cancel + // would no-op and the turn would start anyway. + const turnActive = + chat.latestExecution != null && isActiveExecutionStatus(chat.latestExecution.status); + const canStop = turnActive || chat.pendingSend?.executionId != null; + + return { + chat, + modelSelection, + researchAI, + sessionKey, + draft, + setDraft, + notice, + clearNotice, + creatingChat, + sendBlocked, + composerBusy, + canStop, + send, + stop: chat.cancel, + }; +} diff --git a/services/chatTransport.ts b/services/chatTransport.ts index 101439fce..9eae4887e 100644 --- a/services/chatTransport.ts +++ b/services/chatTransport.ts @@ -11,6 +11,11 @@ import type { GenerationRequest } from '@/types/agentModels'; type ChatId = string | number; +/** What a chat starts out knowing, before its first message. */ +export interface ChatCreateInit { + readonly title?: string; +} + /** * Everything the chat hooks need from a backend surface. The notebook chat * (scoped to a note) and the research assistant (no note) share one wire @@ -24,7 +29,7 @@ export interface ChatTransport { /** Identifies the surface + scope; the hooks key their resets on it. */ readonly key: string; listChats(): Promise; - createChat(title?: string): Promise; + createChat(init?: ChatCreateInit): Promise; getChat(chatId: ChatId, options?: { live?: boolean }): Promise; sendMessage( chatId: ChatId, @@ -38,11 +43,29 @@ export interface ChatTransport { deleteChat?(chatId: ChatId, options?: { deleteNotes?: boolean }): Promise; } +const transports = new Map(); + +/** + * The transport for a scope, the same object every time it is asked for: the + * hooks reset on the transport's identity, so a scope must not get a fresh + * one per render. + */ +export function getChatTransport(scope: { noteId: ChatId | null }): ChatTransport { + const key = scope.noteId == null ? 'assistant' : `notebook:${scope.noteId}`; + let transport = transports.get(key); + if (!transport) { + transport = + scope.noteId == null ? assistantChatTransport() : notebookChatTransport(scope.noteId); + transports.set(key, transport); + } + return transport; +} + export function notebookChatTransport(noteId: ChatId): ChatTransport { return { key: `notebook:${noteId}`, listChats: () => NotebookChatService.listChats(noteId), - createChat: (title) => NotebookChatService.createChat(noteId, title), + createChat: (init) => NotebookChatService.createChat(noteId, init?.title), getChat: (chatId, options) => NotebookChatService.getChat(noteId, chatId, options), sendMessage: (chatId, message, generation) => NotebookChatService.sendMessage(noteId, chatId, message, generation), @@ -56,7 +79,7 @@ export function assistantChatTransport(): ChatTransport { return { key: 'assistant', listChats: () => AssistantChatService.listChats(), - createChat: (title) => AssistantChatService.createChat(title), + createChat: (init) => AssistantChatService.createChat(init?.title), getChat: (chatId, options) => AssistantChatService.getChat(chatId, options), sendMessage: (chatId, message, generation) => AssistantChatService.sendMessage(chatId, message, generation),