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 : };
|