'use client'; /** * WorkbenchChat — the Pro workbench conversation, and (until the first page * lands) the whole surface. * * The visual system is the OpenPBL kimi-chat vocabulary adapted to a * single-agent surface; see `chat/workbench-chat.css` for the token mapping * and `chat/chat-timeline.tsx` for the layout rules. * * The composer never closes: the session is a conversation, and a run ending * (succeeded/failed/cancelled) just means the agent is listening again. * Every submit goes to `POST /messages`, steered into a live run, or * driving a new run on an idle session. An `ask_user` question card's answer is * the same submit with the option's label as its text — the answer protocol is * "the next user message" and nothing more. While a question is UNANSWERED the * composer is TAKEN OVER by its form (`chat/question-form.tsx`): the run ended on * `ask_user`, so there is nothing to type into until it is answered, and its * transcript row is at the bottom of a long log and easy to scroll past. The * dismiss control hands the box back — the question stays open, the form just stops holding it * (`dismissedQuestionKey` below), and the transcript card offers the way back. * While a run is live the composer * shows STOP (POST cancel) instead of send; that ends the run, never the * session. Enter still barges in. * Send/cancel failures surface as a sonner toast, not inline chrome. */ import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'; import { ArrowDown, Loader2, Send, Square } from 'lucide-react'; import { toast } from 'sonner'; import { useI18n } from '@/lib/hooks/use-i18n'; import { WORKBENCH_CHAT_MIN_PX } from '@/lib/edit/contain-box'; import { cn } from '@/lib/utils/cn'; import { useElementRefsForSession, useElementRefsOwnerLifecycle, useElementRefsStore, } from '@/lib/store/element-refs'; import { useCourseRefsForSession, useCourseRefsOwnerLifecycle, useCourseRefsStore, } from '@/lib/store/course-refs'; import type { ElementRef } from '@/lib/workbench/element-refs'; import { makeCourseRef, type CourseRef } from '@/lib/workbench/course-refs'; import { orderCourseMentionCandidates, replaceCourseMention, type CourseMentionCandidate, } from '@/lib/workbench/course-mention'; import { useWorkbenchCourseNavigation, useWorkbenchDraftConversation, } from '@/lib/workbench/panel-context'; import { ComposerTextarea } from './composer-input'; import { ComposerPillRow } from './composer-pill'; import { ElementRefPills } from './element-ref-pills'; import { CourseRefPills } from './course-ref-pills'; import { CourseMentionMenu } from './course-mention-menu'; import { composerImagesFromClipboard, composerImagesFromDrop, composerTransferHasImages, } from '@/lib/workbench/composer-image-transfer'; import { cancelWorkbenchSession, postWorkbenchMessage, recoverTerminalCancelStatus, useWorkbenchStore, type ChatNode, type PlannedPage, type SessionStatus, type WorkbenchMaterial, } from '@/lib/workbench/session-store'; import { AtSignButton, AttachButton, MaterialChips, SkillButton, SkillSlashMenu, useComposerMaterials, type AgentSkillInfo, } from './compose-extras'; import { insertSkillHandle, seedSlashQuery } from '@/lib/workbench/composer-skills'; import { useSkillHandleBackspace } from './use-skill-handle-backspace'; import { resolveComposerMenu } from '@/lib/workbench/composer-menus'; import { isComposerLive, shouldPromptForRefInstruction, shouldDropPendingSend, shouldDropPendingStop, } from './chat/composer-send-state'; import { COMPOSER_SEND_ARIA_KEYSHORTCUTS, shouldSendComposerKey, } from '@/lib/workbench/composer-keys'; import { useDoubleEscapeStop } from './chat/composer-escape'; import { useWorkbenchAutoscroll } from './chat/autoscroll'; import { chatColumn, composerLayout, wbStyles as styles } from './chat/chat-styles'; import { ChatTimeline } from './chat/chat-timeline'; import { pendingQuestion } from './chat/question-card-state'; import { QuestionForm } from './chat/question-form'; import { composerTakeover } from './chat/question-form-state'; import { shouldShowWorkbenchEmptyState, WorkbenchChatEmptyState } from './chat/empty-state'; import { invalidateAgentSkills } from '@/lib/workbench/agent-skills'; import { settleSentElementRefs } from '@/lib/workbench/element-ref-send-result'; import { settleSentCourseRefs } from '@/lib/workbench/course-ref-send-result'; const NO_ELEMENT_REFS: ElementRef[] = []; const NO_COURSE_REFS: CourseRef[] = []; export function WorkbenchChat({ hosted = false, adjacentPanelOpen = false, }: { /** * Rendered inside the workspace's conversation pane, which supplies the * header, the collapse and the way back to everything else. The chat then * carries none of its own navigation — a second back arrow floating over * the transcript would point somewhere the navigation tree already is. */ hosted?: boolean; /** * Actual workspace layout, supplied by the shell. The attached conversation * fold briefly starts from its initial `panelOpen` value whenever Chat * changes, so a hosted chat must not use that value to size its transcript. */ adjacentPanelOpen?: boolean; }) { const { t } = useI18n(); const sessionId = useWorkbenchStore((s) => s.sessionId); /** * No conversation yet — the workspace is showing an empty composer and the * session will be minted by the first message (see * `lib/workbench/first-message-session`). Everything below treats this as a * conversation that simply has no id yet: the composer is live, drafts are * staged against `composerOwnerId`, and `submit` routes through `start`. */ const draftConversation = useWorkbenchDraftConversation(); const composerOwnerId = sessionId ?? draftConversation?.ownerKey ?? null; const canSend = sessionId !== null || draftConversation !== null; const chat = useWorkbenchStore((s) => s.chat); const replaying = useWorkbenchStore((s) => s.replaying); const status = useWorkbenchStore((s) => s.status); const plan = useWorkbenchStore((s) => s.plan); const panelOpen = useWorkbenchStore((s) => s.panelOpen); const effectivePanelOpen = hosted ? adjacentPanelOpen : panelOpen; /** * Is this pane waiting for a conversation's log? * * NOT the store's raw `replaying`. A pane holding a DRAFT conversation has no * session yet — the first message creates it — so there is nothing to catch up * to, and the stream hook that would eventually clear `replaying` never runs * without a session id. * * The `sessionId === null` term covers a deep link's first render, before the * store has attached to the id already present in workspace state. */ const catchingUp = !draftConversation && (replaying || sessionId === null); /** * Switching conversations rebuilds the attached fold from its event log. The * old implementation replaced the whole transcript with a blank, centred * spinner for that interval; when chat owned the wide column this read as a * full-screen flash. Keep the last settled transcript painted but inert until * the next fold is ready. An initial deep link still gets the ordinary loader * because there is no previous content to retain. */ const settledTranscriptRef = useRef<{ readonly chat: ChatNode[]; readonly plan: PlannedPage[]; } | null>(null); useEffect(() => { if (catchingUp) return; settledTranscriptRef.current = { chat, plan }; }, [catchingUp, chat, plan]); const retainedTranscript = catchingUp ? settledTranscriptRef.current : null; const displayedChat = retainedTranscript?.chat ?? chat; const displayedPlan = retainedTranscript?.plan ?? plan; const [draft, setDraft] = useState(''); /** * Where the caret is in the box. * * Both triggers read the token AROUND it (`lib/workbench/composer-tokens`), so * this is half of what decides whether a menu is open — which is why it is state * and not a ref: it has to re-render the menu. `ComposerTextarea` reports it on * every way it can move, and the two paths that move it programmatically (a * skill insertion, an `@` token spliced out) set it themselves. */ const [caret, setCaret] = useState(0); /** * Put text in the box, caret at its end — the ONE door for a write that replaces * the whole draft (cleared on send, restored when the send is refused). * * The pair has to move together: `caret` is what both triggers read the token * around, so a draft swapped in under a caret that belongs to the previous one * can open a menu the user never asked for — a restored `/handle …` message with * the caret still at 0 would pop the skill menu open over the failure toast. */ const replaceDraft = useCallback((text: string) => { setDraft(text); setCaret(text.length); }, []); /** * The draft each popover was last dismissed on. Two of them because the two * menus are dismissed independently; both feed the ONE rule that decides which * is open (`resolveComposerMenu`). * * `slashDismissed` exists because splicing an `@` token out can leave a bare * `/handle` behind — pick a course out of `/stage-design @course` and the draft * becomes `/stage-design`, a live slash query — and finishing with one menu must * never open the other. */ const [slashDismissed, setSlashDismissed] = useState(null); /** * The draft an Escape closed the `@` menu on. Typing anything else reopens it, * which is the same "not now, and only for this exact query" the skill menu's * own dismissal has — a permanent per-session mute would be a setting nobody * asked for and nobody could find again. */ const [mentionDismissed, setMentionDismissed] = useState(null); const [busy, setBusy] = useState(false); /** * Where the caret goes after a skill handle is written into the draft, applied * once the controlled textarea has rendered the new value. A ref, not state: * it is a one-shot instruction to the DOM, not something anything renders. */ const pendingCaret = useRef(null); // Optimistic send: the composer must show STOP the instant send is pressed, // not when the runner's next claim scan surfaces `session_resumed` in the // fold. `pendingSend` bridges that gap; `sendStartedStatus` is the fold // status at send time, and the effect below drops the flag the moment the // fold moves off it (the runner confirmed, or a terminal `session_end` // settled the send) — or the POST failure path clears it directly. const [pendingSend, setPendingSend] = useState(false); const sendStartedStatus = useRef(null); /** * The one question whose form the user waved off, by node key. Local and * deliberately shallow: it is a "not now", not an answer, so nothing about it * is persisted and nothing about the question changes. Keying it by node means * a NEW question takes the composer over again on its own — there is no flag to * remember to reset. */ const [dismissedQuestionKey, setDismissedQuestionKey] = useState(null); const composerRef = useRef(null); // The mirror flag for stop: `POST /cancel` answers 202 ("asked"), and the run // only actually ends when the loop notices between steps — after the tool in // flight returns, which can be tens of seconds. Without this the button just // sat there and stopping read as broken. const [pendingStop, setPendingStop] = useState(false); // Durable material assets can be attached mid-conversation too. const materials = useComposerMaterials(); // Slide elements the user pointed at on the canvas (the edit dock's lasso). // The staging lives in its own store because the picker is on the other side // of the workspace; this surface only reads it and clears it on send. useElementRefsOwnerLifecycle(composerOwnerId); const elementRefs = useElementRefsForSession(composerOwnerId); // The courses the user named with `@` for THIS turn. Same owner-fenced draft // discipline as the element refs above, and the same lifecycle owner: this // component. The classroom pane never writes it — the whole point of the // mention is that the agent is told, not that it infers from what is open. useCourseRefsOwnerLifecycle(composerOwnerId); const courseRefs = useCourseRefsForSession(composerOwnerId); const refreshedCreateCalls = useRef(new Set()); // The durable completed tool node is the source of truth: replay and a live // stream take the same path, and each tool call invalidates the shared // registry at most once even though several composer surfaces consume it. useEffect(() => { for (const node of chat) { if ( node.kind !== 'tool' || node.toolName !== 'create_skill' || node.toolState !== 'done' || !node.toolDetails || refreshedCreateCalls.current.has(node.key) ) { continue; } refreshedCreateCalls.current.add(node.key); void invalidateAgentSkills().catch(() => { // The durable success card remains the receipt. Menus expose their own // retry state if this refresh fails. }); } }, [chat]); const { scrollRef, contentRef, isNearBottom, scrollToBottom } = useWorkbenchAutoscroll(displayedChat); // The transcript's column centers inside the scroll viewport, whose content box // is narrower than the footer's by however much the scrollbar reserves — 0 with // overlay scrollbars, ~15px classic. The footer pads its right by that MEASURED // width, so both columns center inside boxes of the same width and their left // edges are equal at every pane width. Measured, never assumed: guessing a // scrollbar width is exactly the class of numeric fix that already failed here. const [scrollbarWidth, setScrollbarWidth] = useState(0); useEffect(() => { const el = scrollRef.current; if (!el) return; const readScrollbar = () => setScrollbarWidth(el.offsetWidth - el.clientWidth); readScrollbar(); const observer = new ResizeObserver(readScrollbar); observer.observe(el); return () => observer.disconnect(); }, [scrollRef, catchingUp, retainedTranscript]); // How to put a classroom on screen. There is deliberately no derived list of // "the classrooms this conversation is involved with" any more: a classroom is // picked per answer, and what an answer touched is a row in the transcript // (`lib/workbench/run-courses`), not a standing property of the chat. const navigation = useWorkbenchCourseNavigation(); /** * The classroom picker: one menu, ONE open state, two ways in. * * Typing `@` opens it on the token in the draft (`mention`); the `@` BUTTON * (between attach and skill) opens it with no token at all (`mentionOpen`). * Both feed `mentionMenuOpen`, and every way out goes through `closeMention` * — Escape, a press outside the menu, picking a row, sending, and switching * conversation. * * Closing NEVER touches the draft: `mentionDismissed` remembers the text the * menu was dismissed on, so the same `@` token does not immediately reopen it, * and typing anything else brings it back. */ const [mentionOpen, setMentionOpen] = useState(false); const { menu: openMenu, slash, mention, } = resolveComposerMenu({ draft, caret, slashDismissedOn: slashDismissed, mentionDismissedOn: mentionDismissed, courseMenuRequested: mentionOpen, // The `@` keystroke and the `@` button hold the same bar: no courses to // name means no menu at all, not an empty one. courseMenuAvailable: (navigation?.courseOptions?.length ?? 0) > 0, }); const mentionQuery = mention?.query ?? null; const mentionMenuOpen = openMenu === 'course'; const mentionMenuId = useId(); const untitledCourse = t('workspace.untitledCourse'); const mentionCandidates = useMemo( () => !mentionMenuOpen ? [] : orderCourseMentionCandidates({ query: mentionQuery ?? '', activeCourseId: navigation?.activeCourseId ?? null, courses: navigation?.courseOptions ?? [], referencedIds: courseRefs.map((ref) => ref.stageId), untitled: untitledCourse, }), [ courseRefs, mentionMenuOpen, mentionQuery, navigation?.activeCourseId, navigation?.courseOptions, untitledCourse, ], ); const closeMention = useCallback(() => { setMentionOpen(false); // The draft is untouched — this only stops the token in it from reopening // the menu that was just dismissed. setMentionDismissed(draft); composerRef.current?.focus(); }, [draft]); /** * The `@` button's way in. Focus FIRST: the picker's keyboard contract * (↑/↓/Enter) lives on the textarea, so the button has to hand the keys over * to it. */ const openMention = useCallback(() => { composerRef.current?.focus(); setMentionDismissed(null); setMentionOpen(true); }, []); /** * A pick names the course for THIS turn, and that is all it does. The trigger * text is not part of the sentence, so it goes; a menu opened from the button * has no trigger to remove and leaves the draft alone. */ const pickMention = useCallback( (candidate: CourseMentionCandidate) => { const ref = makeCourseRef(candidate.stageId, candidate.title); if (ref) useCourseRefsStore.getState().add(ref); // The splice can land mid-sentence now, so the caret goes back where the // token was rather than wherever a shrinking controlled value leaves it. const removal = mention ? replaceCourseMention(draft, mention) : null; const next = removal?.draft ?? draft; if (removal) { pendingCaret.current = removal.caret; setCaret(removal.caret); setDraft(removal.draft); } setMentionDismissed(null); setMentionOpen(false); // The other direction of the same rule: what the splice leaves behind may // be a live `/handle`, and finishing with one menu must not open another. setSlashDismissed(next); }, [draft, mention], ); /** * A `/` query takes the composer's one popover slot, so the button's request is * spent rather than merely hidden — otherwise it would resurface the instant the * query ended. */ useEffect(() => { if (slash !== null) setMentionOpen(false); }, [slash]); /** * A different conversation (or this surface unmounting) leaves no menu behind. * Without this the picker would still be hanging over a composer whose owner, * candidates and draft all just changed. */ useEffect(() => { setMentionOpen(false); return () => setMentionOpen(false); }, [composerOwnerId]); const live = isComposerLive({ status, pendingSend }); // The agent's open question, if it is waiting on one. Derived from the same // fold the timeline renders, so the composer's form and the transcript's card // can never disagree about what was asked or whether it still needs an answer. const pending = pendingQuestion(chat); // The composer IS the question while one waits, unless this exact question's // form was dismissed. One derived value, so the swap below and the card's way // back cannot describe different states. const takeover = composerTakeover({ pending, dismissedKey: dismissedQuestionKey }); // Pure-derived: the idle placeholder owns the rail only while it is truly // empty — no messages, not catching up, no run live. It disappears the // instant a bubble lands or a send flips the composer to STOP. const showEmptyState = shouldShowWorkbenchEmptyState({ chat, catchingUp, live }); // Where the composer sits, and therefore how much of the viewport the // transcript gets. See `composerLayout`. const layout = composerLayout(takeover !== null); // Is anything attached to the next message? The context block inside the input // box exists only when it has something to hold, so an empty composer is the // same height it always was. A loaded skill is NOT one of these things: it is // text in the box, not something staged beside it. const hasComposerContext = materials.materials.length > 0 || materials.uploading.length > 0 || materials.failed.length > 0 || elementRefs.length > 0 || courseRefs.length > 0; /** * A skill handle was written into the draft (picked from the `/` menu, opened * by typing one or by the skill button): put the caret after it so the user * can keep typing. After the controlled value has landed, hence an effect on * `draft` rather than a write inside the handler. */ useEffect(() => { const caret = pendingCaret.current; if (caret === null) return; pendingCaret.current = null; const textarea = composerRef.current; if (!textarea) return; textarea.focus(); textarea.setSelectionRange(caret, caret); }, [draft]); /** * Write a skill handle into the draft, and leave NO menu open behind it — * neither the `/` menu it was picked from (the inserted handle ends the query) * nor the `@` menu (whose open state must not survive the slash query that hid * it). Both dismissals are recorded against the resulting draft, so nothing * reopens until the user types something new. */ /** Backspace deletes a whole `/handle`; see the hook for the IME guard. */ const onSkillHandleBackspace = useSkillHandleBackspace(setDraft, (next) => { pendingCaret.current = next; setCaret(next); }); const loadSkill = useCallback((skill: AgentSkillInfo) => { // The live selection, not the tracked state: the element is the freshest // answer either way. const at = composerRef.current?.selectionStart ?? null; setDraft((current) => { const next = insertSkillHandle(current, skill.name, at ?? current.length); pendingCaret.current = next.caret; setCaret(next.caret); setMentionOpen(false); setMentionDismissed(next.draft); setSlashDismissed(next.draft); return next.draft; }); }, []); /** * The skill button's way in: seed a `/` token at the caret and the menu * machinery (`resolveComposerMenu`) opens the slash menu over the input with * the full list — the same flow typing `/` has, minus the typing. An * Esc-dismissed query reopens; an active one is already open, so the click is * spent. Focus first: the menu's keyboard contract (↑/↓/Enter) lives on the * textarea. */ const openSkillMenu = useCallback(() => { composerRef.current?.focus(); // An Esc-dismissed query reopens (the menu's Escape records the dismissal // against the draft); an undismissed live one is already open, so the // click is spent. Both are decided by the token, not by the menu's // visibility — clear first, then look. setSlashDismissed(null); if (slash !== null) return; // seedSlashQuery normalizes word-end/word-internal carets (lead space) so // the seeded `/` is a real query token — the naive splice glued it onto // the preceding word and the menu never opened. const seed = seedSlashQuery(draft, composerRef.current?.selectionStart ?? draft.length); if (!seed) return; pendingCaret.current = seed.caret; setCaret(seed.caret); setDraft(seed.draft); }, [draft, slash]); /** The slash menu's one exit, mirroring `closeMention`: record the draft the * dismissal landed on, so the same token does not immediately reopen it. */ const closeSkillMenu = useCallback(() => { setSlashDismissed(draft); }, [draft]); useEffect(() => { if (shouldDropPendingSend({ pendingSend, status, startedStatus: sendStartedStatus.current })) { sendStartedStatus.current = null; setPendingSend(false); } }, [pendingSend, status]); /** * The composer coming back — waved off OR answered — puts the caret where the * user's next keystroke should land. Guarded on the transition rather than the * state, so a page that simply has no question does not steal focus on mount. */ const formWasOpen = useRef(false); useEffect(() => { if (formWasOpen.current && !takeover) composerRef.current?.focus(); formWasOpen.current = takeover !== null; }, [takeover]); /** * The form takes real height out of the timeline's viewport (it is in the * layout, not floating over it), so the tail of the transcript has to be * re-anchored when it opens — otherwise the last thing the agent said before * handing over sits just above the fold. A hand-over is the one agent event * that earns a scroll, for the same reason a send does: it is now the user's * move, and the question is about what was just said. */ const takeoverKey = takeover?.key ?? null; const scrollToBottomRef = useRef(scrollToBottom); scrollToBottomRef.current = scrollToBottom; useEffect(() => { if (takeoverKey) void scrollToBottomRef.current(); }, [takeoverKey]); // The event stream is what lifts the stopping label: whatever terminal status // lands (cancelled normally, or succeeded/failed if the run beat the cancel to // the finish line) means the run is no longer live, and the composer goes back // to being a composer. // being a composer. useEffect(() => { if (shouldDropPendingStop({ pendingStop, status })) setPendingStop(false); }, [pendingStop, status]); const stop = useCallback(async () => { if (!sessionId || busy || pendingStop) return; setBusy(true); try { await cancelWorkbenchSession(sessionId); // 202: the runner has been ASKED. The terminal state still arrives through // the event stream, so the button says stopping until it does — silence // for tens of seconds is exactly what made stopping feel ignored. setPendingStop(true); } catch (err) { if (recoverTerminalCancelStatus(sessionId, err)) { // The terminal event may have been fenced before this recovery path. // The helper verifies that this cancel still belongs to the attached // session before updating the fold, and invents no event id. return; } // Nothing was accepted, so the button springs back to a pressable STOP. toast.error(err instanceof Error ? err.message : t('workbench.chat.stopFailed')); } finally { setBusy(false); } }, [busy, pendingStop, sessionId, t]); useDoubleEscapeStop({ generating: live && !busy && !pendingStop && !!sessionId, onStop: () => { void stop(); }, }); /** * The one way a user message leaves this surface — typed into the composer or * clicked on an `ask_user` question card. Everything that makes a send a send * lives here: the optimistic STOP flip (the fold's status cannot move until * the runner's next claim scan), the busy guard against a double submit, and * the failure toast. Returns whether the message was accepted, so a caller * can decide what to restore. */ const submit = useCallback( async ( text: string, attachments: WorkbenchMaterial[], refs: readonly ElementRef[] = [], courses: readonly CourseRef[] = [], ): Promise<{ accepted: boolean; elementRefsAccepted: boolean; courseRefsAccepted: boolean; }> => { if (!sessionId && !draftConversation) { return { accepted: false, elementRefsAccepted: false, courseRefsAccepted: false }; } if (busy) { return { accepted: false, elementRefsAccepted: false, courseRefsAccepted: false }; } setBusy(true); // Optimistic: flip the composer to STOP right now. The fold's status only // moves once the runner claims the requeued row (`session_resumed`) — up // to a claim-scan interval later — and waiting for that reads as a dead // send button. The effect above drops the flag the moment an // authoritative event moves the status; a failed POST clears it here. sendStartedStatus.current = status; setPendingSend(true); try { // No session yet: the first message is what creates one, and the // workspace navigates to it. Everything staged rides along — the draft // path posts an ordinary message, so refs and materials are not a // special case. const result = sessionId ? { accepted: true, ...(await postWorkbenchMessage(sessionId, text, attachments, refs, courses)), } : await draftConversation!.start({ text, materials: attachments, elementRefs: refs, courseRefs: courses, }); if (!result.accepted) { sendStartedStatus.current = null; setPendingSend(false); return { accepted: false, elementRefsAccepted: false, courseRefsAccepted: false }; } return { accepted: true, elementRefsAccepted: result.elementRefsAccepted, courseRefsAccepted: result.courseRefsAccepted, }; } catch (err) { sendStartedStatus.current = null; setPendingSend(false); toast.error(err instanceof Error ? err.message : t('workbench.chat.sendFailed')); return { accepted: false, elementRefsAccepted: false, courseRefsAccepted: false }; } finally { setBusy(false); } }, [busy, draftConversation, sessionId, status, t], ); /** * A question card's answer. The SAME send path as typing it out, so the answer * is an ordinary user message on an ordinary optimistic send — which is the * whole answer protocol `ask_user` has (no correlation id; see * `LIFECYCLE.userQuestion`). */ const answerQuestion = useCallback( async (text: string): Promise => { const answer = text.trim(); if (!answer) return false; return (await submit(answer, [])).accepted; }, [submit], ); async function send() { // What is in the box IS the message — skill handles included. There is // nothing to assemble here. const text = draft.trim(); if ( (!text && materials.materials.length === 0) || busy || !canSend || materials.busy || // A click on the send button must respect an open menu exactly as Enter // does (onKeyDown returns early) — sending a half-typed `/query` is the // alternative. openMenu !== null ) { return; } const selectedMaterials = materials.materials; // Snapshot the staged element references: a reference is part of THIS // message, and the picker stays usable while the POST is in flight. const refsState = useElementRefsStore.getState(); const selectedRefs = refsState.ownerSessionId === composerOwnerId ? refsState.refs : NO_ELEMENT_REFS; // Same snapshot rule for the named courses. const coursesState = useCourseRefsStore.getState(); const selectedCourses = coursesState.ownerSessionId === composerOwnerId ? coursesState.refs : NO_COURSE_REFS; // Clear first so Enter does not freeze the box on the in-flight POST. replaceDraft(''); // Both dismissal memories go with the draft they named. setSlashDismissed(null); setMentionDismissed(null); setMentionOpen(false); const result = await submit(text, selectedMaterials, selectedRefs, selectedCourses); if (result.accepted) { materials.clear(); // Remove only this request's snapshot. Picks added while POST was pending // belong to the next message and must survive; an owner change meanwhile // also fences this completion from session B's draft. On the draft path the // owner key is the draft's, and the switch to the real session clears it // anyway — settling by the same key keeps the two paths one path. settleSentElementRefs({ sessionId: composerOwnerId!, sent: selectedRefs, elementRefsAccepted: result.elementRefsAccepted, warnUnsupported: () => toast.warning(t('workbench.chat.elementRefsNotAccepted')), }); settleSentCourseRefs({ sessionId: composerOwnerId!, sent: selectedCourses, courseRefsAccepted: result.courseRefsAccepted, warnUnsupported: () => toast.warning(t('workspace.courseMention.notAccepted')), }); } else { replaceDraft(text); } } // The transcript and the composer share ONE column class — see `chatColumn`. // The scroll viewport stays full pane width so the scrollbar sits on the // pane's edge, and the footer pads its right by the MEASURED scrollbar width // (`scrollbarWidth` above), so both copies of the column center inside boxes // of the same width: one shared class + one measured compensation, no pair of // numbers to keep equal by hand. const column = chatColumn(!effectivePanelOpen); return (
{/* Nothing the user has not read may sit under the composer: `composerLayout` is where that rule lives, and `data-composer` is it, visible. */}
{catchingUp && !retainedTranscript ? (
) : (
{displayedChat.length > 0 ? ( setDismissedQuestionKey(null)} takenOverQuestionKey={takeoverKey} t={t} /> ) : showEmptyState ? ( ) : null}
)} {retainedTranscript ? (
) : null} {!catchingUp && !isNearBottom ? ( ) : null}
); }