LCOV - code coverage report
Current view: top level - dashql-app/src/session/feed - session_composer.tsx Coverage Total Hit
Test: DashQL 915828b Lines: 88.3 % 171 151
Test Date: 2026-11-06 19:30:32

            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 type { EditorView } from '@codemirror/view';
       6              : 
       7              : import { ButtonSize, ButtonVariant, IconButton } from '../../ui/foundations/button.js';
       8              : import { SegmentedControl, SegmentedControlSize } from '../../ui/foundations/segmented_control.js';
       9              : import { CodeIcon, PaperAirplaneIcon, SymbolIcon } from '../../ui/foundations/symbol_icon.js';
      10              : import { PromptEditor } from '../editor/prompt_editor.js';
      11              : import { DashQLCompletionAbortEffect } from '../editor/dashql_processor.js';
      12              : import { insertMentionAtCursor } from '../editor/editor_focus.js';
      13              : import { ComposerInputMode } from '../../agent/input_mode.js';
      14              : import { useComposerInputMode } from '../../agent/use_composer_input_mode.js';
      15              : import { findScriptReferences, renderScriptReference, SCRIPT_REFERENCE_PREFIX } from '../../agent/script_references.js';
      16              : import { SnippetsOverlay } from '../snippets/snippets_overlay.js';
      17              : import { AgentPromptPanel, isAgentWorking, type ComposerAgent } from '../../agent/agent_prompt_panel.js';
      18              : import { ExecuteConnectionGuard, SessionConnectionAttentionContext } from '../session_connection_attention.js';
      19              : import type { ConnectorType } from '../../connections/connector_info.js';
      20              : import type { SnippetRegistry } from '../snippets/snippet_registry.js';
      21              : 
      22            1 : const SparklesFillIcon = SymbolIcon('sparkles_fill_16');
      23              : 
      24              : /// A composer without session scripts
      25            1 : const NO_SCRIPT_NAMES: readonly string[] = [];
      26              : 
      27              : /// Where the composer is shown
      28            1 : export enum SessionComposerVariant {
      29              :     /// Docked below the feed of a session with scripts
      30            1 :     Docked,
      31              :     /// Centered on the landing view of an empty session
      32            1 :     Hero,
      33            1 : }
      34              : 
      35              : export interface SessionComposerProps {
      36              :     /// The DashQL instance that creates the draft session
      37              :     instance: core.DashQL;
      38              :     /// The connection catalog the draft is analyzed and completed against
      39              :     catalog: core.DashQLCatalog;
      40              :     /// Where the composer is shown
      41              :     variant: SessionComposerVariant;
      42              :     /// The keyword case for completions
      43              :     keywordCase: core.buffers.formatting.KeywordCase;
      44              :     /// Can the session ask the agent? Without agent access, the composer only takes SQL.
      45              :     aiAvailable: boolean;
      46              :     /// The agent of a session with agent access, shown on top of the prompt
      47              :     agent?: ComposerAgent | null;
      48              :     /// Called with the draft and its input mode when the user sends it, the draft is cleared before
      49              :     onSend: (text: string, mode: ComposerInputMode) => void;
      50              :     /// Called with whether the draft is blank, once on mount and whenever that changes
      51              :     onDraftEmptyChange?: (empty: boolean) => void;
      52              :     /// Receives the editor view once it is mounted
      53              :     setView?: (view: EditorView | null) => void;
      54              :     /// Receives the send function of the composer, e.g. for global key bindings
      55              :     submitRef?: React.RefObject<(() => void) | null>;
      56              :     /// The display names of the session scripts that a question can reference
      57              :     scriptNames?: readonly string[];
      58              :     /// Receives the function that references a session script in a question, by its display name
      59              :     targetScriptRef?: React.RefObject<((name: string) => void) | null>;
      60              :     /// The results of the session scripts, the table parameters of the draft read them
      61              :     tableParameters?: core.DashQLTableParameters | null;
      62              :     /// Receives the function that renames the references to a session script in the draft, by display names
      63              :     renameScriptRef?: React.RefObject<((from: string, to: string) => void) | null>;
      64              :     /// Picks the connection that the draft runs on
      65              :     connectionPicker?: React.ReactNode;
      66              :     /// The session of the draft, set while the connection of the draft is not connected.
      67              :     /// A SQL draft is not sent then, the connections of the session ask for attention instead.
      68              :     offlineSessionId?: string | null;
      69              :     /// The connector of the draft, the composer offers the snippets that run on it
      70              :     snippetConnector?: ConnectorType;
      71              :     /// The snippets to browse, the bundled ones by default
      72              :     snippetRegistry?: SnippetRegistry;
      73              : }
      74              : 
      75              : /// A change of the text of a draft
      76              : type DraftChange = { from: number; to: number; insert: string };
      77              : 
      78              : /// The changes that rename the references to a script in a SQL draft: the table parameters of its analysis that find
      79              : /// the script among the names before the rename, like the planner does. String literals and comments keep their text.
      80              : /// `names` are the display names of the session scripts, before or after the rename.
      81            0 : export function renameSqlDraftReferences(session: core.DashQLScriptSession, names: readonly string[], from: string, to: string, instance: core.DashQL): DraftChange[] {
      82            0 :     const before = names.map(name => name === to ? from : name);
      83            0 :     if (!before.includes(from)) before.push(from);
      84            0 :     const renamed = before.indexOf(from);
      85            0 :     const insert = `${SCRIPT_REFERENCE_PREFIX}${instance.quoteIdentifier(to)}`;
      86            0 :     return session.findScriptReferences(before)
      87            0 :         .filter(reference => reference.script === renamed)
      88            0 :         .map(reference => ({ from: reference.from, to: reference.to, insert }));
      89            0 : }
      90              : 
      91              : /// The changes that rename the references to a script in a question, `:from` becomes `:to`
      92            0 : export function renameQuestionReferences(text: string, from: string, to: string): DraftChange[] {
      93            0 :     return findScriptReferences([from], text).map(reference => ({ from: reference.from, to: reference.to, insert: renderScriptReference(to) }));
      94            0 : }
      95              : 
      96              : /// Takes the draft out of a composer editor and clears it, returns null when the draft is blank
      97            1 : export function takeComposerDraft(view: EditorView): string | null {
      98            1 :     const text = view.state.doc.toString();
      99            1 :     if (text.trim().length === 0) {
     100            0 :         return null;
     101              :     }
     102              :     // Drop an open completion together with the text it points into
     103            1 :     view.dispatch({
     104            1 :         changes: { from: 0, to: view.state.doc.length, insert: '' },
     105            1 :         effects: DashQLCompletionAbortEffect.of(null),
     106            1 :     });
     107            1 :     return text;
     108            1 : }
     109              : 
     110              : /// A chat-like input that turns its draft into a new session script.
     111              : ///
     112              : /// The draft is either SQL or a question in natural language that the agent answers in SQL,
     113              : /// see useComposerInputMode.
     114              : ///
     115              : /// The draft lives in its own script session on the connection catalog. It provides completion and tells SQL
     116              : /// from natural language, but it is not a session script: it is neither persisted nor loaded into the catalog.
     117            1 : export const SessionComposer: React.FC<SessionComposerProps> = (props) => {
     118              :     // The draft session is created while rendering: the editor binds a new session in a layout effect,
     119              :     // before the effect below destroys the previous one.
     120            1 :     const scriptSession = React.useMemo(() => props.instance.createScriptSession(props.catalog), [props.instance, props.catalog]);
     121            1 :     React.useEffect(() => () => scriptSession.destroy(), [scriptSession]);
     122              :     // The draft reads the results of the session scripts, its next analysis completes their names after `:`.
     123              :     // Attaching the current results is cheap, the results change with any session script.
     124            1 :     React.useEffect(() => {
     125            1 :         props.tableParameters?.attach(scriptSession);
     126            1 :     });
     127              : 
     128            1 :     const [view, setView] = React.useState<EditorView | null>(null);
     129            1 :     const [empty, setEmpty] = React.useState(true);
     130            1 :     const handleView = React.useCallback((next: EditorView | null) => {
     131            1 :         setView(next);
     132            1 :         props.setView?.(next);
     133            1 :     }, [props.setView]);
     134              : 
     135              :     // The draft is SQL or natural language
     136            1 :     const scriptNames = props.scriptNames ?? NO_SCRIPT_NAMES;
     137            1 :     const input = useComposerInputMode(props.catalog, props.aiAvailable, scriptNames);
     138            1 :     const handleTextChange = React.useCallback((text: string) => {
     139            1 :         setEmpty(text.trim().length === 0);
     140            1 :         input.onTextChange(text);
     141            1 :     }, [input.onTextChange]);
     142            1 :     React.useEffect(() => props.onDraftEmptyChange?.(empty), [empty, props.onDraftEmptyChange]);
     143            1 :     const handleModeChange = React.useCallback((index: number) => {
     144            1 :         input.pick(index === 0 ? ComposerInputMode.Sql : ComposerInputMode.Ai);
     145            1 :     }, [input.pick]);
     146              : 
     147              :     // The connection of a SQL draft has to be connected first, the draft stays until it is
     148            1 :     const connectionAttention = React.useContext(SessionConnectionAttentionContext);
     149            1 :     const offlineSessionId = props.offlineSessionId ?? null;
     150            1 :     const send = React.useCallback(() => {
     151            1 :         if (view == null) return;
     152            1 :         const mode = input.resolveSendMode();
     153            1 :         if (mode === ComposerInputMode.Sql && offlineSessionId != null) {
     154            1 :             connectionAttention?.request(offlineSessionId);
     155            1 :             return;
     156            1 :         }
     157            1 :         const text = takeComposerDraft(view);
     158            1 :         if (text != null) {
     159            1 :             props.onSend(text, mode);
     160              :         }
     161            1 :     }, [props.onSend, view, input.resolveSendMode, offlineSessionId, connectionAttention]);
     162              :     // A snippet replaces the draft right away, there is no script yet whose text could be compared
     163            1 :     const applySnippet = React.useCallback((text: string) => {
     164            1 :         if (view == null) return;
     165            1 :         view.dispatch({
     166            1 :             changes: { from: 0, to: view.state.doc.length, insert: text },
     167            1 :             selection: { anchor: text.length },
     168            1 :             effects: DashQLCompletionAbortEffect.of(null),
     169            1 :         });
     170            1 :         view.focus();
     171            1 :     }, [view]);
     172            1 :     const handleSendClick = React.useCallback(() => {
     173            1 :         send();
     174            1 :         view?.focus();
     175            1 :     }, [send, view]);
     176            1 :     if (props.submitRef) {
     177            1 :         props.submitRef.current = send;
     178              :     }
     179              :     // A script reference turns the draft into a question if the agent is available, a script that the question
     180              :     // references already is not repeated. Without the agent, the reference goes into the SQL of the draft.
     181            1 :     const targetScript = React.useCallback((name: string) => {
     182            1 :         if (view == null) return;
     183            1 :         if (props.aiAvailable) {
     184            1 :             input.pick(ComposerInputMode.Ai);
     185            1 :             if (findScriptReferences([name], view.state.doc.toString()).length === 0) {
     186            1 :                 insertMentionAtCursor(view, renderScriptReference(name));
     187              :             }
     188            1 :         } else {
     189            0 :             insertMentionAtCursor(view, `:${props.instance.quoteIdentifier(name)}`);
     190              :         }
     191            1 :         view.focus();
     192            1 :     }, [view, props.aiAvailable, props.instance, input.pick]);
     193            1 :     if (props.targetScriptRef) {
     194            1 :         props.targetScriptRef.current = targetScript;
     195              :     }
     196              :     // The references of the draft follow a renamed script. A question has no syntax tree, SQL is renamed through its
     197              :     // analysis.
     198            1 :     const renameScript = React.useCallback((from: string, to: string) => {
     199            0 :         if (view == null) return;
     200            0 :         const changes = input.mode === ComposerInputMode.Ai
     201            0 :             ? renameQuestionReferences(view.state.doc.toString(), from, to)
     202            0 :             : renameSqlDraftReferences(scriptSession, scriptNames, from, to, props.instance);
     203            0 :         if (changes.length > 0) {
     204            0 :             view.dispatch({ changes, effects: DashQLCompletionAbortEffect.of(null), userEvent: 'input' });
     205              :         }
     206            1 :     }, [view, input.mode, scriptSession, scriptNames, props.instance]);
     207            1 :     if (props.renameScriptRef) {
     208            1 :         props.renameScriptRef.current = renameScript;
     209              :     }
     210              : 
     211              :     // The landing view is all about the prompt, focus it right away
     212            1 :     const hero = props.variant === SessionComposerVariant.Hero;
     213            1 :     React.useEffect(() => {
     214            1 :         if (hero) view?.focus();
     215            1 :     }, [view]);
     216              : 
     217            1 :     const naturalLanguage = input.mode === ComposerInputMode.Ai;
     218            1 :     const blockedSessionId = naturalLanguage ? null : offlineSessionId;
     219            1 :     const blocked = blockedSessionId != null;
     220              :     // A blocked button stays clickable, it pulses the connections that are not connected
     221            1 :     const sendButton = (
     222            1 :         <IconButton
     223            1 :             variant={hero && !blocked ? ButtonVariant.Primary : ButtonVariant.Default}
     224            1 :             size={ButtonSize.Small}
     225            1 :             aria-label={naturalLanguage ? 'Ask the agent' : blocked ? 'Connect to run' : 'Run query'}
     226            1 :             data-testid="composer-send"
     227            1 :             data-connect={blocked ? '' : undefined}
     228            1 :             disabled={empty && !blocked}
     229            1 :             onClick={handleSendClick}
     230              :         >
     231            1 :             <PaperAirplaneIcon />
     232            1 :         </IconButton>
     233              :     );
     234            1 :     const agent = props.agent ?? null;
     235            1 :     const placeholder = naturalLanguage
     236            1 :         ? 'Ask a question about your data, Shift+Enter for a new line'
     237            1 :         : (hero ? 'select * from ...' : 'Write a query, press Enter to run it and Shift+Enter for a new line');
     238            1 :     return (
     239            1 :         <div
     240            1 :             className={styles.composer}
     241            1 :             data-variant={hero ? 'hero' : 'docked'}
     242            1 :             data-agent={agent == null ? undefined : (isAgentWorking(agent) ? 'working' : 'ready')}
     243              :         >
     244            1 :             {agent != null && <AgentPromptPanel agent={agent} />}
     245            1 :             <PromptEditor
     246            1 :                 className={styles.composer_editor}
     247            1 :                 scriptSession={scriptSession}
     248            1 :                 placeholder={placeholder}
     249            1 :                 keywordCase={props.keywordCase}
     250            1 :                 assist={input.assist}
     251            1 :                 onSubmit={send}
     252            1 :                 onTextChange={handleTextChange}
     253            1 :                 onEditorUpdate={input.onEditorUpdate}
     254            1 :                 setView={handleView}
     255            1 :             />
     256            1 :             <div className={styles.composer_action_bar}>
     257            1 :                 <SegmentedControl aria-label="Input mode" size={SegmentedControlSize.Small} onChange={handleModeChange}>
     258            1 :                     <SegmentedControl.Button leadingVisual={CodeIcon} selected={!naturalLanguage} data-testid="composer-mode" data-mode="sql">
     259              :                         SQL
     260            1 :                     </SegmentedControl.Button>
     261            1 :                     <SegmentedControl.Button
     262            1 :                         leadingVisual={SparklesFillIcon}
     263            1 :                         selected={naturalLanguage}
     264            1 :                         disabled={!props.aiAvailable}
     265            1 :                         data-testid="composer-mode"
     266            1 :                         data-mode="ai"
     267            1 :                         title={props.aiAvailable ? undefined : 'Start an agent session in the header and connect the agent server to ask questions'}
     268            1 :                     >
     269              :                         AI
     270            1 :                     </SegmentedControl.Button>
     271            1 :                 </SegmentedControl>
     272            1 :                 <div className={styles.composer_trailing_actions}>
     273            1 :                     {props.connectionPicker}
     274            1 :                     {props.snippetConnector != null && (
     275            1 :                         <SnippetsOverlay
     276            1 :                             scriptName="the prompt"
     277            1 :                             connectorType={props.snippetConnector}
     278            1 :                             disabled={view == null}
     279            1 :                             onApply={applySnippet}
     280            1 :                             registry={props.snippetRegistry}
     281            1 :                             testId="composer-snippets"
     282            1 :                         />
     283              :                     )}
     284            1 :                     {blockedSessionId != null
     285            1 :                         ? <ExecuteConnectionGuard sessionId={blockedSessionId} blocked>{sendButton}</ExecuteConnectionGuard>
     286            1 :                         : sendButton}
     287            1 :                 </div>
     288            1 :             </div>
     289            1 :         </div>
     290              :     );
     291            1 : };
        

Generated by: LCOV version 2.0-1