LCOV - code coverage report
Current view: top level - dashql-app/src/session/feed - session_composer.tsx Coverage Total Hit
Test: DashQL d805a4b Lines: 89.5 % 219 196
Test Date: 2026-11-09 20:22:13

            Line data    Source code
       1              : import * as React from 'react';
       2              : import * as core from '@ankoh/dashql-core';
       3              : import * as styles from './session_composer.module.css';
       4              : 
       5              : import { createPortal } from 'react-dom';
       6              : import type { EditorView } from '@codemirror/view';
       7              : 
       8              : import { ButtonSize, ButtonVariant, IconButton } from '../../ui/foundations/button.js';
       9              : import { SegmentedControl, SegmentedControlSize } from '../../ui/foundations/segmented_control.js';
      10              : import { CodeIcon, PaperAirplaneIcon, SymbolIcon } from '../../ui/foundations/symbol_icon.js';
      11              : import { PromptEditor } from '../editor/prompt_editor.js';
      12              : import { DashQLCompletionAbortEffect } from '../editor/dashql_processor.js';
      13              : import { insertMentionAtCursor } from '../editor/editor_focus.js';
      14              : import { ComposerInputMode } from '../../agent/input_mode.js';
      15              : import { useComposerInputMode } from '../../agent/use_composer_input_mode.js';
      16              : import { findScriptReferences, renderScriptReference, SCRIPT_REFERENCE_PREFIX } from '../../agent/script_references.js';
      17              : import { SnippetsOverlay } from '../snippets/snippets_overlay.js';
      18              : import { AgentPromptPanel, isAgentWorking, type ComposerAgent } from '../../agent/agent_prompt_panel.js';
      19              : import { ExecuteConnectionGuard, SessionConnectionAttentionContext } from '../session_connection_attention.js';
      20              : import { SessionInputCard } from '../inputs/session_input_card.js';
      21              : import { collectDraftInputs, sameInputs, sessionInputLiteral, type SessionInput } from '../inputs/session_inputs.js';
      22              : import { textChange } from '../scripts/session_scripts.js';
      23              : import type { ConnectorType } from '../../connections/connector_info.js';
      24              : import type { SnippetRegistry } from '../snippets/snippet_registry.js';
      25              : 
      26            1 : const SparklesFillIcon = SymbolIcon('sparkles_fill_16');
      27              : 
      28              : /// A composer without session scripts
      29            1 : const NO_SCRIPT_NAMES: readonly string[] = [];
      30              : 
      31              : /// A draft without inputs
      32            1 : const NO_INPUTS: SessionInput[] = [];
      33              : 
      34              : /// The parts of the prompt that handle a click themselves: the editor places its cursor, the controls act
      35            1 : const PROMPT_CONTROLS = '.cm-editor, button, a, input, select, textarea, [role="button"]';
      36              : 
      37              : /// Where the composer is shown
      38            1 : export enum SessionComposerVariant {
      39              :     /// Docked below the feed of a session with scripts
      40            1 :     Docked,
      41              :     /// Centered on the landing view of an empty session
      42            1 :     Hero,
      43            1 : }
      44              : 
      45              : export interface SessionComposerProps {
      46              :     /// The DashQL instance that creates the draft session
      47              :     instance: core.DashQL;
      48              :     /// The connection catalog the draft is analyzed and completed against
      49              :     catalog: core.DashQLCatalog;
      50              :     /// Where the composer is shown
      51              :     variant: SessionComposerVariant;
      52              :     /// The keyword case for completions
      53              :     keywordCase: core.buffers.formatting.KeywordCase;
      54              :     /// Can the session ask the agent? Without agent access, the composer only takes SQL.
      55              :     aiAvailable: boolean;
      56              :     /// The agent of a session with agent access, shown on top of the prompt
      57              :     agent?: ComposerAgent | null;
      58              :     /// Called with the draft and its input mode when the user sends it, the draft is cleared before
      59              :     onSend: (text: string, mode: ComposerInputMode) => void;
      60              :     /// Called with whether the draft is blank, once on mount and whenever that changes
      61              :     onDraftEmptyChange?: (empty: boolean) => void;
      62              :     /// The element that shows the cards of the inputs of a SQL draft, the composer shows no cards without it
      63              :     inputsContainer?: HTMLElement | null;
      64              :     /// Receives the editor view once it is mounted
      65              :     setView?: (view: EditorView | null) => void;
      66              :     /// Receives the send function of the composer, e.g. for global key bindings
      67              :     submitRef?: React.RefObject<(() => void) | null>;
      68              :     /// The display names of the session scripts that a question can reference
      69              :     scriptNames?: readonly string[];
      70              :     /// Receives the function that references a session script in a question, by its display name
      71              :     targetScriptRef?: React.RefObject<((name: string) => void) | null>;
      72              :     /// The results of the session scripts, the table parameters of the draft read them
      73              :     tableParameters?: core.DashQLTableParameters | null;
      74              :     /// Receives the function that renames the references to a session script in the draft, by display names
      75              :     renameScriptRef?: React.RefObject<((from: string, to: string) => void) | null>;
      76              :     /// Picks the connection that the draft runs on
      77              :     connectionPicker?: React.ReactNode;
      78              :     /// The session of the draft, set while the connection of the draft is not connected.
      79              :     /// A SQL draft is not sent then, the connections of the session ask for attention instead.
      80              :     offlineSessionId?: string | null;
      81              :     /// The connector of the draft, the composer offers the snippets that run on it
      82              :     snippetConnector?: ConnectorType;
      83              :     /// The snippets to browse, the bundled ones by default
      84              :     snippetRegistry?: SnippetRegistry;
      85              : }
      86              : 
      87              : /// A change of the text of a draft
      88              : type DraftChange = { from: number; to: number; insert: string };
      89              : 
      90              : /// The changes that rename the references to a script in a SQL draft: the table parameters of its analysis that find
      91              : /// the script among the names before the rename, like the planner does. String literals and comments keep their text.
      92              : /// `names` are the display names of the session scripts, before or after the rename.
      93            0 : export function renameSqlDraftReferences(session: core.DashQLScriptSession, names: readonly string[], from: string, to: string, instance: core.DashQL): DraftChange[] {
      94            0 :     const before = names.map(name => name === to ? from : name);
      95            0 :     if (!before.includes(from)) before.push(from);
      96            0 :     const renamed = before.indexOf(from);
      97            0 :     const insert = `${SCRIPT_REFERENCE_PREFIX}${instance.quoteIdentifier(to)}`;
      98            0 :     return session.findScriptReferences(before)
      99            0 :         .filter(reference => reference.script === renamed)
     100            0 :         .map(reference => ({ from: reference.from, to: reference.to, insert }));
     101            0 : }
     102              : 
     103              : /// The changes that rename the references to a script in a question, `:from` becomes `:to`
     104            0 : export function renameQuestionReferences(text: string, from: string, to: string): DraftChange[] {
     105            0 :     return findScriptReferences([from], text).map(reference => ({ from: reference.from, to: reference.to, insert: renderScriptReference(to) }));
     106            0 : }
     107              : 
     108              : /// Takes the draft out of a composer editor and clears it, returns null when the draft is blank
     109            1 : export function takeComposerDraft(view: EditorView): string | null {
     110            1 :     const text = view.state.doc.toString();
     111            1 :     if (text.trim().length === 0) {
     112            0 :         return null;
     113              :     }
     114              :     // Drop an open completion together with the text it points into
     115            1 :     view.dispatch({
     116            1 :         changes: { from: 0, to: view.state.doc.length, insert: '' },
     117            1 :         effects: DashQLCompletionAbortEffect.of(null),
     118            1 :     });
     119            1 :     return text;
     120            1 : }
     121              : 
     122              : /// A chat-like input that turns its draft into a new session script.
     123              : ///
     124              : /// The draft is either SQL or a question in natural language that the agent answers in SQL,
     125              : /// see useComposerInputMode.
     126              : ///
     127              : /// The draft lives in its own script session on the connection catalog. It provides completion and tells SQL
     128              : /// from natural language, but it is not a session script: it is neither persisted nor loaded into the catalog.
     129            1 : export const SessionComposer: React.FC<SessionComposerProps> = (props) => {
     130              :     // The draft session is created while rendering: the editor binds a new session in a layout effect,
     131              :     // before the effect below destroys the previous one.
     132            1 :     const scriptSession = React.useMemo(() => props.instance.createScriptSession(props.catalog), [props.instance, props.catalog]);
     133            1 :     React.useEffect(() => () => scriptSession.destroy(), [scriptSession]);
     134              :     // The draft reads the results of the session scripts, its next analysis completes their names after `:`.
     135              :     // Attaching the current results is cheap, the results change with any session script.
     136            1 :     React.useEffect(() => {
     137            1 :         props.tableParameters?.attach(scriptSession);
     138            1 :     });
     139              : 
     140            1 :     const [view, setView] = React.useState<EditorView | null>(null);
     141            1 :     const [empty, setEmpty] = React.useState(true);
     142            1 :     const handleView = React.useCallback((next: EditorView | null) => {
     143            1 :         setView(next);
     144            1 :         props.setView?.(next);
     145            1 :     }, [props.setView]);
     146              : 
     147              :     // The draft is SQL or natural language
     148            1 :     const scriptNames = props.scriptNames ?? NO_SCRIPT_NAMES;
     149            1 :     const input = useComposerInputMode(props.catalog, props.aiAvailable, scriptNames);
     150            1 :     const handleTextChange = React.useCallback((text: string) => {
     151            1 :         setEmpty(text.trim().length === 0);
     152            1 :         input.onTextChange(text);
     153            1 :     }, [input.onTextChange]);
     154            1 :     React.useEffect(() => props.onDraftEmptyChange?.(empty), [empty, props.onDraftEmptyChange]);
     155              :     // The inputs of the draft follow its analysis, a cursor move changes nothing
     156            1 :     const [draftInputs, setDraftInputs] = React.useState(NO_INPUTS);
     157            1 :     const handleEditorUpdate = React.useCallback((update: core.buffers.editor.EditorUpdateT) => {
     158            1 :         input.onEditorUpdate(update);
     159            1 :         if (!update.analysisUpdated) return;
     160            1 :         const next = collectDraftInputs(props.instance, scriptSession, props.catalog);
     161            1 :         setDraftInputs(previous => sameInputs(previous, next) ? previous : next);
     162            1 :     }, [input.onEditorUpdate, props.instance, props.catalog, scriptSession]);
     163              :     // The value of an input of the draft is a `@param` tag of the draft like in a script, it goes along when the draft
     164              :     // is sent. Nothing runs before that.
     165            1 :     const literalOfDraftInput = React.useCallback((draftInput: SessionInput, value: string) => sessionInputLiteral(props.instance, draftInput.type, value), [props.instance]);
     166            1 :     const commitDraftInput = React.useCallback((name: string, literal: string) => {
     167            1 :         if (view == null) return;
     168            1 :         const from = view.state.doc.toString();
     169            1 :         let to: string;
     170            1 :         try {
     171            1 :             to = scriptSession.rewriteParamTag(name, literal);
     172            1 :         } catch (e) {
     173            0 :             console.warn('cannot write the value of an input of the draft', e);
     174            0 :             return;
     175            0 :         }
     176            1 :         if (to === from) return;
     177            1 :         view.dispatch({ changes: textChange(from, to), effects: DashQLCompletionAbortEffect.of(null) });
     178            1 :     }, [view, scriptSession]);
     179            1 :     const handleModeChange = React.useCallback((index: number) => {
     180            1 :         input.pick(index === 0 ? ComposerInputMode.Sql : ComposerInputMode.Ai);
     181            1 :     }, [input.pick]);
     182              : 
     183              :     // The connection of a SQL draft has to be connected first, the draft stays until it is
     184            1 :     const connectionAttention = React.useContext(SessionConnectionAttentionContext);
     185            1 :     const offlineSessionId = props.offlineSessionId ?? null;
     186            1 :     const inputsRef = React.useRef<HTMLDivElement>(null);
     187            1 :     const send = React.useCallback(() => {
     188            1 :         if (view == null) return;
     189            1 :         const mode = input.resolveSendMode();
     190            1 :         if (mode === ComposerInputMode.Sql && offlineSessionId != null) {
     191            1 :             connectionAttention?.request(offlineSessionId);
     192            1 :             return;
     193            1 :         }
     194              :         // A card writes a typed value when it loses the focus, the value goes along
     195            1 :         const active = document.activeElement;
     196            1 :         if (active instanceof HTMLElement && inputsRef.current?.contains(active)) active.blur();
     197            1 :         const text = takeComposerDraft(view);
     198            1 :         if (text != null) {
     199            1 :             props.onSend(text, mode);
     200              :         }
     201            1 :     }, [props.onSend, view, input.resolveSendMode, offlineSessionId, connectionAttention]);
     202              :     // A snippet replaces the draft right away, there is no script yet whose text could be compared
     203            1 :     const applySnippet = React.useCallback((text: string) => {
     204            1 :         if (view == null) return;
     205            1 :         view.dispatch({
     206            1 :             changes: { from: 0, to: view.state.doc.length, insert: text },
     207            1 :             selection: { anchor: text.length },
     208            1 :             effects: DashQLCompletionAbortEffect.of(null),
     209            1 :         });
     210            1 :         view.focus();
     211            1 :     }, [view]);
     212            1 :     const handleSendClick = React.useCallback(() => {
     213            1 :         send();
     214            1 :         view?.focus();
     215            1 :     }, [send, view]);
     216              :     // The prompt looks like one input, a click on its background focuses the editor and keeps the cursor where it is
     217            1 :     const handlePromptMouseDown = React.useCallback((event: React.MouseEvent<HTMLDivElement>) => {
     218            1 :         const target = event.target;
     219            1 :         if (view == null || event.button !== 0 || !(target instanceof Element)) return;
     220              :         // The overlays of the controls are portals, their events still bubble through here
     221            1 :         if (!event.currentTarget.contains(target)) return;
     222            1 :         if (target.closest(PROMPT_CONTROLS) != null) {
     223              :             // A card writes a typed value when it loses the focus. The tag can grow the draft and move the control away
     224              :             // from under the pointer before the button is released, which would lose the click: the card keeps the focus.
     225            1 :             if (target.closest('.cm-editor') == null && inputsRef.current?.contains(document.activeElement)) event.preventDefault();
     226            1 :             return;
     227            1 :         }
     228              :         // The browser would move the focus to the body after this event
     229            1 :         event.preventDefault();
     230            1 :         view.focus();
     231            1 :     }, [view]);
     232            1 :     if (props.submitRef) {
     233            1 :         props.submitRef.current = send;
     234              :     }
     235              :     // A script reference turns the draft into a question if the agent is available, a script that the question
     236              :     // references already is not repeated. Without the agent, the reference goes into the SQL of the draft.
     237            1 :     const targetScript = React.useCallback((name: string) => {
     238            1 :         if (view == null) return;
     239            1 :         if (props.aiAvailable) {
     240            1 :             input.pick(ComposerInputMode.Ai);
     241            1 :             if (findScriptReferences([name], view.state.doc.toString()).length === 0) {
     242            1 :                 insertMentionAtCursor(view, renderScriptReference(name));
     243              :             }
     244            1 :         } else {
     245            0 :             insertMentionAtCursor(view, `:${props.instance.quoteIdentifier(name)}`);
     246              :         }
     247            1 :         view.focus();
     248            1 :     }, [view, props.aiAvailable, props.instance, input.pick]);
     249            1 :     if (props.targetScriptRef) {
     250            1 :         props.targetScriptRef.current = targetScript;
     251              :     }
     252              :     // The references of the draft follow a renamed script. A question has no syntax tree, SQL is renamed through its
     253              :     // analysis.
     254            1 :     const renameScript = React.useCallback((from: string, to: string) => {
     255            0 :         if (view == null) return;
     256            0 :         const changes = input.mode === ComposerInputMode.Ai
     257            0 :             ? renameQuestionReferences(view.state.doc.toString(), from, to)
     258            0 :             : renameSqlDraftReferences(scriptSession, scriptNames, from, to, props.instance);
     259            0 :         if (changes.length > 0) {
     260            0 :             view.dispatch({ changes, effects: DashQLCompletionAbortEffect.of(null), userEvent: 'input' });
     261              :         }
     262            1 :     }, [view, input.mode, scriptSession, scriptNames, props.instance]);
     263            1 :     if (props.renameScriptRef) {
     264            1 :         props.renameScriptRef.current = renameScript;
     265              :     }
     266              : 
     267              :     // The landing view is all about the prompt, focus it right away
     268            1 :     const hero = props.variant === SessionComposerVariant.Hero;
     269            1 :     React.useEffect(() => {
     270            1 :         if (hero) view?.focus();
     271            1 :     }, [view]);
     272              : 
     273            1 :     const naturalLanguage = input.mode === ComposerInputMode.Ai;
     274              :     // Only SQL has inputs, `:name` in a question references a script
     275            1 :     const inputsContainer = naturalLanguage ? null : props.inputsContainer ?? null;
     276            1 :     const blockedSessionId = naturalLanguage ? null : offlineSessionId;
     277            1 :     const blocked = blockedSessionId != null;
     278              :     // A blocked button stays clickable, it pulses the connections that are not connected
     279            1 :     const sendButton = (
     280            1 :         <IconButton
     281            1 :             variant={hero && !blocked ? ButtonVariant.Primary : ButtonVariant.Default}
     282            1 :             size={ButtonSize.Small}
     283            1 :             aria-label={naturalLanguage ? 'Ask the agent' : blocked ? 'Connect to run' : 'Run query'}
     284            1 :             data-testid="composer-send"
     285            1 :             data-connect={blocked ? '' : undefined}
     286            1 :             disabled={empty && !blocked}
     287            1 :             onClick={handleSendClick}
     288              :         >
     289            1 :             <PaperAirplaneIcon />
     290            1 :         </IconButton>
     291              :     );
     292            1 :     const agent = props.agent ?? null;
     293            1 :     const placeholder = naturalLanguage
     294            1 :         ? 'Ask a question about your data, Shift+Enter for a new line'
     295            1 :         : (hero ? 'select * from ...' : 'Write a query, press Enter to run it and Shift+Enter for a new line');
     296            1 :     return (
     297            1 :         <div
     298            1 :             className={styles.composer}
     299            1 :             data-variant={hero ? 'hero' : 'docked'}
     300            1 :             data-agent={agent == null ? undefined : (isAgentWorking(agent) ? 'working' : 'ready')}
     301              :         >
     302            1 :             {inputsContainer != null && draftInputs.length > 0 && createPortal(
     303            1 :                 <div ref={inputsRef} className={styles.composer_inputs} role="group" aria-label="Inputs of the prompt" data-testid="composer-inputs">
     304            1 :                     {draftInputs.map(draftInput => (
     305            1 :                         <SessionInputCard key={draftInput.name} input={draftInput} literalOf={literalOfDraftInput} onCommit={commitDraftInput} />
     306            1 :                     ))}
     307            1 :                 </div>,
     308            1 :                 inputsContainer,
     309            1 :             )}
     310            1 :             {agent != null && <AgentPromptPanel agent={agent} />}
     311            1 :             <div className={styles.composer_prompt} onMouseDown={handlePromptMouseDown}>
     312            1 :                 <PromptEditor
     313            1 :                     className={styles.composer_editor}
     314            1 :                     scriptSession={scriptSession}
     315            1 :                     placeholder={placeholder}
     316            1 :                     keywordCase={props.keywordCase}
     317            1 :                     assist={input.assist}
     318            1 :                     onSubmit={send}
     319            1 :                     onTextChange={handleTextChange}
     320            1 :                     onEditorUpdate={handleEditorUpdate}
     321            1 :                     setView={handleView}
     322            1 :                 />
     323            1 :                 <div className={styles.composer_action_bar}>
     324            1 :                     <SegmentedControl aria-label="Input mode" size={SegmentedControlSize.Small} onChange={handleModeChange}>
     325            1 :                         <SegmentedControl.Button leadingVisual={CodeIcon} selected={!naturalLanguage} data-testid="composer-mode" data-mode="sql">
     326              :                             SQL
     327            1 :                         </SegmentedControl.Button>
     328            1 :                         <SegmentedControl.Button
     329            1 :                             leadingVisual={SparklesFillIcon}
     330            1 :                             selected={naturalLanguage}
     331            1 :                             disabled={!props.aiAvailable}
     332            1 :                             data-testid="composer-mode"
     333            1 :                             data-mode="ai"
     334            1 :                             title={props.aiAvailable ? undefined : 'Enable the agent in the header and connect the agent server to ask questions'}
     335            1 :                         >
     336              :                             AI
     337            1 :                         </SegmentedControl.Button>
     338            1 :                     </SegmentedControl>
     339            1 :                     <div className={styles.composer_trailing_actions}>
     340            1 :                         {props.connectionPicker}
     341            1 :                         {props.snippetConnector != null && (
     342            1 :                             <SnippetsOverlay
     343            1 :                                 scriptName="the prompt"
     344            1 :                                 connectorType={props.snippetConnector}
     345            1 :                                 disabled={view == null}
     346            1 :                                 onApply={applySnippet}
     347            1 :                                 registry={props.snippetRegistry}
     348            1 :                                 testId="composer-snippets"
     349            1 :                             />
     350              :                         )}
     351            1 :                         {blockedSessionId != null
     352            1 :                             ? <ExecuteConnectionGuard sessionId={blockedSessionId} blocked>{sendButton}</ExecuteConnectionGuard>
     353            1 :                             : sendButton}
     354            1 :                     </div>
     355            1 :                 </div>
     356            1 :             </div>
     357            1 :         </div>
     358              :     );
     359            1 : };
        

Generated by: LCOV version 2.0-1