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

            Line data    Source code
       1              : import * as React from 'react';
       2              : import * as styles from './session_feed.module.css';
       3              : 
       4              : 
       5              : import type { EditorView } from '@codemirror/view';
       6              : import { useAppConfig } from '../../config/app_config.js';
       7              : import { AgentGreeting } from '../../agent/agent_greeting.js';
       8              : import type { AgentFaceTuning } from '../../agent/agent_face.js';
       9              : 
      10              : import { List } from 'react-window';
      11              : import {
      12              :     DndContext,
      13              :     DragEndEvent,
      14              :     KeyboardSensor,
      15              :     type Modifier,
      16              :     PointerSensor,
      17              :     closestCenter,
      18              :     useSensor,
      19              :     useSensors,
      20              : } from '@dnd-kit/core';
      21              : import {
      22              :     SortableContext,
      23              :     sortableKeyboardCoordinates,
      24              :     verticalListSortingStrategy,
      25              : } from '@dnd-kit/sortable';
      26              : 
      27              : import { ConnectionHealth, ConnectionState, connectionCachesQueryResults } from '../../connections/connection_state.js';
      28              : import { getSelectedScriptRef, getScriptRefs, newScriptConnection, SessionScripts, type ScriptData, SELECT_SCRIPT, CREATE_SCRIPT, DELETE_SCRIPT, REORDER_SCRIPTS, SET_SCRIPT_TEXT, SET_SCRIPT_VIS_SPEC, ACCEPT_PENDING_DIFF, REJECT_PENDING_DIFF, SET_SESSION_ATTACHMENTS } from '../scripts/session_scripts.js';
      29              : import { markSessionAttachmentsChanged } from '../attachments/attachment_mounts.js';
      30              : import { renameAttachment } from '../attachments/attachment_rename.js';
      31              : import { attachmentParameterName, readSessionAttachments, sameSessionAttachments, uniqueAttachmentFileName } from '../attachments/session_attachments.js';
      32              : import { useQueryExecutor } from '../../connections/query_executor.js';
      33              : import { type ModifySessionScripts } from '../scripts/session_scripts_registry.js';
      34              : import { scriptDisplayName } from '../scripts/script_types.js';
      35              : import { checkSessionScriptReferences } from '../scripts/script_reference_reports.js';
      36              : import { sessionTableParameters } from '../scripts/table_parameters_publisher.js';
      37              : import { insertMentionAtCursor, useLastFocusedEditor } from '../editor/editor_focus.js';
      38              : import { DashQLProcessorPlugin } from '../editor/dashql_processor.js';
      39              : import { renameScript, type RenameDraftReferences } from '../scripts/script_rename.js';
      40              : import { type KeyEventHandler, useKeyEvents } from '../../utils/key_events.js';
      41              : import { runSessionScript } from '../scripts/rerun_query.js';
      42              : import { computeScriptResultCacheKey, scriptRunServices } from '../scripts/script_run.js';
      43              : import { useStorageReader, useStorageWriter } from '../../storage/storage_provider.js';
      44              : import { useQueryResultCache } from '../../storage/query_result_cache_provider.js';
      45              : import { groupSessionAttachmentListWrites, REFRESH_SESSION_ATTACHMENTS } from '../../storage/storage_writer.js';
      46              : import { useLogger } from '../../platform/logger/logger_provider.js';
      47              : import { FEED_GAP, revealScrollOffset, useSessionFeedLayout } from './session_feed_layout.js';
      48              : import { ScriptFeedRow, type ScriptFeedRowProps } from './session_feed_row.js';
      49              : import { getKeywordCase } from '../scripts/script_format.js';
      50              : import { reorderFeedEntries } from './session_feed_drag.js';
      51              : import { ATTACHMENT_EXTENSION, isAttachmentFileName, type DataFile } from '../../storage/storage_backend.js';
      52              : import { stringifyError } from '../../platform/logger/logger.js';
      53              : import { SessionDataCard } from './session_data_card.js';
      54              : import { SessionInputStrip } from '../inputs/session_input_strip.js';
      55              : import { VerticalScrollShadows } from '../../ui/foundations/vertical_scroll_shadows.js';
      56              : import {
      57              :     ScriptConnectionPicker,
      58              :     scriptConnectionView,
      59              :     useAttachPendingConnection,
      60              :     usePickPendingConnection,
      61              :     usePickScriptConnection,
      62              :     type ScriptConnectionView,
      63              : } from '../scripts/script_connection_picker.js';
      64              : import { useConnectionLabeler } from '../connection_labels.js';
      65              : import { ButtonSize, ButtonVariant, IconButton } from '../../ui/foundations/button.js';
      66              : import { ChevronDownIcon, ChevronUpIcon, PlusIcon } from '../../ui/foundations/symbol_icon.js';
      67              : import { connectionReadsAttachments } from '../../connections/connector_descriptor.js';
      68              : import { SessionConnectionAttentionContext } from '../session_connection_attention.js';
      69              : import { SessionComposer, SessionComposerVariant } from './session_composer.js';
      70              : import { ComposerInputMode } from '../../agent/input_mode.js';
      71              : import { useAgentBridge } from '../../agent/agent_bridge.js';
      72              : import type { AgentFollowTarget } from '../../agent/use_agent_questions.js';
      73              : import { useComposerAgent } from '../../agent/use_composer_agent.js';
      74              : 
      75              : export interface SessionFeedProps {
      76              :     sessionScripts: SessionScripts;
      77              :     modifySessionScripts: ModifySessionScripts;
      78              :     /// Receives the function that renames the references in the draft of the composer, for renames of other views
      79              :     draftRenameRef?: React.RefObject<RenameDraftReferences | null>;
      80              :     /// The connection that the next script runs on
      81              :     conn: ConnectionState | null;
      82              :     /// The connections that the scripts run on, by default the connection of the next script
      83              :     scriptConnections?: ReadonlyMap<string, ConnectionState>;
      84              :     /// Whether the feed is the visible, interactive layer. The feed stays mounted (just hidden) while
      85              :     /// another view, e.g. a connection overview, is open so it keeps its scroll position and measured row heights;
      86              :     /// while inactive its global key handlers must stand down so Escape/Enter belong to the overlay.
      87              :     active: boolean;
      88              :     /// A script to scroll into view, e.g. one opened from the sidebar. A new request object reveals again.
      89              :     revealRequest?: SessionFeedRevealRequest | null;
      90              :     dependencies?: SessionFeedDependencies;
      91              : }
      92              : 
      93              : /// A script that the feed scrolls into view once its session shows
      94              : export interface SessionFeedRevealRequest {
      95              :     sessionId: string;
      96              :     fileName: string;
      97              : }
      98              : 
      99              : export interface SessionFeedDependencies {
     100              :     useQueryExecutor: typeof useQueryExecutor;
     101              :     useStorageReader: typeof useStorageReader;
     102              :     useStorageWriter: typeof useStorageWriter;
     103              :     usePickScriptConnection: typeof usePickScriptConnection;
     104              :     usePickPendingConnection: typeof usePickPendingConnection;
     105              :     useAttachPendingConnection: typeof useAttachPendingConnection;
     106              :     useKeyEvents: typeof useKeyEvents;
     107              :     runSessionScript: typeof runSessionScript;
     108              :     useAgentBridge: typeof useAgentBridge;
     109              :     SessionComposer: typeof SessionComposer;
     110              :     ScriptFeedRow: typeof ScriptFeedRow;
     111              : }
     112              : 
     113            1 : const DEFAULT_DEPENDENCIES: SessionFeedDependencies = {
     114            1 :     useQueryExecutor,
     115            1 :     useStorageReader,
     116            1 :     useStorageWriter,
     117            1 :     usePickScriptConnection,
     118            1 :     usePickPendingConnection,
     119            1 :     useAttachPendingConnection,
     120            1 :     useKeyEvents,
     121            1 :     runSessionScript,
     122            1 :     useAgentBridge,
     123            1 :     SessionComposer,
     124            1 :     ScriptFeedRow,
     125            1 : };
     126              : 
     127            1 : const LOG_CTX = 'session_feed';
     128            1 : const OVERSCAN_ROW_COUNT = 16;
     129            1 : const FEED_ENTRY_CHROME_HEIGHT = 50;
     130            1 : const FEED_EDITOR_LINE_HEIGHT = 18;
     131            1 : const restrictToVerticalAxis: Modifier = ({ transform }) => ({ ...transform, x: 0 });
     132              : 
     133              : /// The views of the connections that the scripts run on.
     134              : /// The map keeps its identity while the shown fields are unchanged, the memoized feed rows depend on it.
     135            1 : function useScriptConnectionViews(connections: ReadonlyMap<string, ConnectionState>): ReadonlyMap<string, ScriptConnectionView> {
     136            1 :     const labeler = useConnectionLabeler();
     137            1 :     const views = React.useRef<Map<string, ScriptConnectionView>>(new Map());
     138            1 :     const next = new Map<string, ScriptConnectionView>();
     139            1 :     for (const connection of connections.values()) next.set(connection.connectionId, scriptConnectionView(connection, labeler(connection)));
     140            1 :     const prev = views.current;
     141            1 :     const unchanged = prev.size === next.size && [...next].every(([id, view]) => {
     142            1 :         const old = prev.get(id);
     143            1 :         return old != null && old.connectionHealth === view.connectionHealth && old.details === view.details && old.label === view.label;
     144            1 :     });
     145            1 :     if (!unchanged) views.current = next;
     146            1 :     return views.current;
     147            1 : }
     148              : 
     149              : /// The rows are keyed by script, a script inserted above moves the mounted rows of the scripts below instead of
     150              : /// handing their rows to other scripts. The cards keep their editors and their measured heights.
     151            1 : function feedRowKey(index: number, rowProps: ScriptFeedRowProps): React.Key {
     152            1 :     return rowProps.entries[index]?.scriptId ?? `index-${index}`;
     153            1 : }
     154              : 
     155            1 : function estimateFeedEntryHeight(scriptText: string) {
     156            1 :     return FEED_ENTRY_CHROME_HEIGHT + Math.max(1, scriptText.split('\n').length) * FEED_EDITOR_LINE_HEIGHT;
     157            1 : }
     158              : 
     159              : /// The face of the empty session wobbles more and faster than by default, it is the only thing moving on the page
     160            1 : const LANDING_FACE_TUNING: Partial<AgentFaceTuning> = { wobble: 0.03, wobbleSpeed: 2 };
     161              : 
     162            1 : export const SessionFeed: React.FC<SessionFeedProps> = (props) => {
     163            1 :     const dependencies = props.dependencies ?? DEFAULT_DEPENDENCIES;
     164            1 :     const config = useAppConfig();
     165            1 :     const logger = useLogger();
     166            1 :     const scriptDebugMode = config?.settings?.scriptDebugMode ?? false;
     167            1 :     const formattingDebugMode = config?.settings?.formattingDebugMode ?? false;
     168            1 :     const attachmentsVisible = config?.settings?.experimental_file_attachments === true;
     169            1 :     const keywordCase = getKeywordCase(config?.settings);
     170            1 :     const scriptRefs = props.sessionScripts.scriptRefs;
     171            1 :     const scriptOrder = props.sessionScripts.scriptOrder;
     172              :     // The estimated heights only matter until a row is measured.
     173              :     // Don't re-estimate (and copy the text of every script) whenever any script changes.
     174            1 :     const canonicalEntries = React.useMemo(
     175            1 :         () => getScriptRefs(props.sessionScripts).map(entry => ({
     176            1 :             ...entry,
     177            1 :             estimatedHeight: estimateFeedEntryHeight(
     178            1 :                 props.sessionScripts.scripts[entry.scriptId]?.scriptSession.getText() ?? '',
     179            1 :             ),
     180            1 :         })),
     181            1 :         [scriptRefs, scriptOrder],
     182            1 :     );
     183            1 :     const [displayEntries, setDisplayEntries] = React.useState(() => ({
     184            1 :         source: canonicalEntries,
     185            1 :         entries: canonicalEntries,
     186            1 :     }));
     187            1 :     const entries = displayEntries.source === canonicalEntries
     188            1 :         ? displayEntries.entries
     189            1 :         : canonicalEntries;
     190            1 :     React.useLayoutEffect(() => {
     191            1 :         setDisplayEntries({ source: canonicalEntries, entries: canonicalEntries });
     192            1 :     }, [canonicalEntries]);
     193            1 :     const defaultConnections = React.useMemo(
     194            1 :         () => new Map(props.conn == null ? [] : [[props.conn.connectionId, props.conn]]),
     195            1 :         [props.conn],
     196            1 :     );
     197            1 :     const scriptConnections = useScriptConnectionViews(props.scriptConnections ?? defaultConnections);
     198              :     // The connections that cache query results, the cache indicators follow them
     199            1 :     const cachingConnections = [...(props.scriptConnections ?? defaultConnections).values()]
     200            1 :         .filter(connectionCachesQueryResults).map(connection => connection.connectionId).join(',');
     201              :     // The composer drafts the next script, on the connection that the script will run on
     202            1 :     const composerConnection = newScriptConnection(props.sessionScripts, null);
     203            1 :     const storageReader = dependencies.useStorageReader();
     204            1 :     const queryResultCache = useQueryResultCache();
     205            1 :     const storageWriter = dependencies.useStorageWriter();
     206            1 :     const sessionId = props.sessionScripts.sessionId;
     207            1 :     const [dataFiles, setDataFiles] = React.useState<{ sessionId: string; files: DataFile[] } | null>(null);
     208            1 :     const [dataFilesVersion, setDataFilesVersion] = React.useState(0);
     209            1 :     const [attachmentsExpanded, setAttachmentsExpanded] = React.useState(false);
     210            1 :     const [attachmentError, setAttachmentError] = React.useState<string | null>(null);
     211            1 :     const attachmentInputRef = React.useRef<HTMLInputElement>(null);
     212            1 :     const latestAttachments = React.useRef(props.sessionScripts.attachments);
     213            1 :     latestAttachments.current = props.sessionScripts.attachments;
     214            1 :     const latestScriptOrder = React.useRef(props.sessionScripts.scriptOrder);
     215            1 :     latestScriptOrder.current = props.sessionScripts.scriptOrder;
     216              :     // The session file lists the attachments, a changed attachment updates the list and the mount of the attachments
     217            1 :     const refreshDataFiles = React.useCallback(() => {
     218            0 :         markSessionAttachmentsChanged(sessionId);
     219            0 :         setDataFilesVersion(version => version + 1);
     220            0 :         void storageWriter.write(groupSessionAttachmentListWrites(sessionId), { type: REFRESH_SESSION_ATTACHMENTS, value: sessionId });
     221            1 :     }, [sessionId, storageWriter]);
     222            1 :     React.useEffect(() => {
     223            1 :         const cancel = new AbortController();
     224            1 :         storageReader.backend.listDataFiles(sessionId).then(files => {
     225            1 :             if (!cancel.signal.aborted) {
     226              :                 // The scripts read the Parquet attachments as table parameters
     227            1 :                 const known = latestAttachments.current ?? [];
     228            1 :                 if (!sameSessionAttachments(known, readSessionAttachments(files, known))) {
     229            1 :                     void props.modifySessionScripts({ type: SET_SESSION_ATTACHMENTS, value: files });
     230              :                 }
     231            1 :                 setDataFiles({
     232            1 :                     sessionId, files: [...files].sort((a, b) => (
     233            0 :                         a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' })
     234            1 :                     ))
     235            1 :                 });
     236            1 :             }
     237            1 :         }).catch(e => {
     238            0 :             if (!cancel.signal.aborted) {
     239            0 :                 logger.error('Listing session data files failed', { error: stringifyError(e) }, LOG_CTX);
     240              :             }
     241            1 :         });
     242            1 :         return () => cancel.abort();
     243            1 :     }, [sessionId, storageReader.backend, dataFilesVersion, logger, props.modifySessionScripts]);
     244            1 :     const pinnedDataFiles = dataFiles?.sessionId === sessionId ? dataFiles.files : [];
     245            1 :     const attachmentsEnabled = connectionReadsAttachments(props.conn?.details);
     246            1 :     const saveAttachments = React.useCallback(async (files: Iterable<File>) => {
     247            0 :         setAttachmentError(null);
     248              :         // A session attaches Parquet files only, they are table parameters of the scripts
     249            0 :         const parquet = [...files].filter(file => isAttachmentFileName(file.name));
     250            0 :         const refused = [...files].filter(file => !isAttachmentFileName(file.name));
     251            0 :         if (refused.length > 0) {
     252            0 :             setAttachmentError(`Only Parquet files can be attached: ${refused.map(file => file.name).join(', ')}`);
     253              :         }
     254            0 :         if (parquet.length === 0) return;
     255            0 :         try {
     256              :             // An attachment takes no name of a script or another attachment
     257            0 :             const scriptNames = latestScriptOrder.current.map(scriptDisplayName);
     258            0 :             const attachments = [...(latestAttachments.current ?? [])];
     259            0 :             for (const file of parquet) {
     260            0 :                 const fileName = uniqueAttachmentFileName(file.name, scriptNames, attachments);
     261            0 :                 await storageReader.backend.saveDataFile(sessionId, fileName, file);
     262            0 :                 attachments.push(...readSessionAttachments([{ name: fileName, sizeBytes: file.size }]));
     263            0 :             }
     264            0 :             refreshDataFiles();
     265            0 :         } catch (e) {
     266            0 :             setAttachmentError('Adding attachment failed');
     267            0 :             logger.error('Adding session attachment failed', { error: stringifyError(e) }, LOG_CTX);
     268            0 :         }
     269            1 :     }, [logger, sessionId, refreshDataFiles, storageReader.backend]);
     270            1 :     const handleAttachmentInput = React.useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
     271            0 :         if (attachmentsEnabled && event.target.files != null) void saveAttachments(Array.from(event.target.files));
     272            0 :         event.target.value = '';
     273            1 :     }, [saveAttachments]);
     274            1 :     const handleAttachmentDrop = React.useCallback((event: React.DragEvent<HTMLElement>) => {
     275            0 :         event.preventDefault();
     276            0 :         if (props.active && attachmentsEnabled) void saveAttachments(Array.from(event.dataTransfer.files));
     277            1 :     }, [attachmentsEnabled, props.active, saveAttachments]);
     278              : 
     279              :     // The session scripts change with every keystroke and the connection with every query event.
     280              :     // Callbacks read the latest values through a ref to stay stable for the memoized feed rows.
     281            1 :     const latest = React.useRef({ sessionScripts: props.sessionScripts, scriptConnections });
     282            1 :     latest.current = { sessionScripts: props.sessionScripts, scriptConnections };
     283            1 :     const pickScriptConnection = dependencies.usePickScriptConnection(props.sessionScripts.sessionId, props.modifySessionScripts);
     284              :     // New scripts, and the draft of the composer, use the default connection of the session
     285            1 :     const pickPendingConnection = dependencies.usePickPendingConnection(props.modifySessionScripts);
     286            1 :     const attachPendingConnection = dependencies.useAttachPendingConnection(props.sessionScripts.instance, props.modifySessionScripts);
     287              : 
     288            1 :     const pendingCreatedScriptKeyRef = React.useRef<number | null>(null);
     289            1 :     const editorViewsRef = React.useRef(new Map<number, EditorView>());
     290              :     // Presence means collapsed. A query id marks an automatic no-result collapse; null marks a
     291              :     // manual collapse, which must not be reset when a later query starts.
     292            1 :     const [collapsedResults, setCollapsedResults] = React.useState<ReadonlyMap<number, number | null>>(() => new Map());
     293              : 
     294            1 :     const handleFocus = React.useCallback((fileName: string) => {
     295            1 :         props.modifySessionScripts({ type: SELECT_SCRIPT, value: fileName });
     296            1 :     }, [props.modifySessionScripts]);
     297              : 
     298            1 :     const executeQuery = dependencies.useQueryExecutor();
     299              :     // A script runs while its own connection is online
     300            1 :     const isScriptOnline = (scriptData: ScriptData) =>
     301            1 :         latest.current.scriptConnections.get(scriptData.connection.connectionId)?.connectionHealth === ConnectionHealth.ONLINE;
     302              : 
     303              :     // Refresh: run the script and the scripts it references without reading cached results, their results become the
     304              :     // newest files of their keys. Resolves the script by feed file name.
     305            1 :     const handleRerunEntry = React.useCallback((fileName: string) => {
     306            0 :         const { sessionScripts } = latest.current;
     307            0 :         const entry = sessionScripts.scriptRefs[fileName];
     308            0 :         const scriptData = entry != null ? sessionScripts.scripts[entry.scriptId] : undefined;
     309            0 :         if (scriptData == null || !isScriptOnline(scriptData)) {
     310            0 :             return;
     311              :         }
     312            0 :         dependencies.runSessionScript(sessionScripts, scriptData, executeQuery, props.modifySessionScripts, logger, { refresh: true });
     313            1 :     }, [props.modifySessionScripts, executeQuery, logger]);
     314              : 
     315              :     // The cache indicator of a script looks up the key of a run of the script in the cache of the app
     316            1 :     const lookupCachedResult = React.useCallback(async (cacheKey: string) => (await queryResultCache?.lookup(cacheKey))?.cachedAtMs ?? null, [queryResultCache]);
     317              : 
     318              :     // The cache key of a run of a script, the cache indicator of the script checks it. A new callback when connections
     319              :     // start or stop caching, so the indicators check again.
     320            1 :     const computeCacheKey = React.useCallback(async (scriptKey: number) => {
     321            1 :         const { sessionScripts } = latest.current;
     322            1 :         const scriptData = sessionScripts.scripts[scriptKey];
     323            1 :         if (scriptData == null) return null;
     324            1 :         return await computeScriptResultCacheKey(scriptRunServices(sessionScripts.instance), sessionScripts, scriptData);
     325            1 :     }, [cachingConnections]);
     326              : 
     327            1 :     const handleExecuteEntry = React.useCallback((fileName: string) => {
     328            0 :         const { sessionScripts } = latest.current;
     329            0 :         const entry = sessionScripts.scriptRefs[fileName];
     330            0 :         const scriptData = entry != null ? sessionScripts.scripts[entry.scriptId] : undefined;
     331            0 :         if (scriptData == null || !isScriptOnline(scriptData)) return;
     332            0 :         dependencies.runSessionScript(sessionScripts, scriptData, executeQuery, props.modifySessionScripts, logger);
     333            1 :     }, [executeQuery, props.modifySessionScripts, logger]);
     334              : 
     335            1 :     const handleDelete = React.useCallback((fileName: string) => {
     336            0 :         props.modifySessionScripts({ type: DELETE_SCRIPT, value: fileName });
     337            1 :     }, [props.modifySessionScripts]);
     338              : 
     339              :     // A rename rewrites the references in the other scripts and in the draft of the composer
     340            1 :     const ownDraftRenameRef = React.useRef<RenameDraftReferences | null>(null);
     341            1 :     const composerRenameScriptRef = props.draftRenameRef ?? ownDraftRenameRef;
     342            1 :     const handleRename = React.useCallback((oldFileName: string, newFileName: string) => {
     343            0 :         void renameScript(props.sessionScripts, props.modifySessionScripts, oldFileName, newFileName, composerRenameScriptRef.current);
     344            1 :     }, [props.sessionScripts, props.modifySessionScripts, composerRenameScriptRef]);
     345              : 
     346              :     // Move a script one position up/down within the session by swapping it with its neighbour in the
     347              :     // feed order and dispatching the new full order to REORDER_SCRIPTS.
     348            1 :     const moveScript = React.useCallback((fileName: string, delta: number) => {
     349            0 :         const order = [...latest.current.sessionScripts.scriptOrder];
     350            0 :         const from = order.indexOf(fileName);
     351            0 :         const to = from + delta;
     352            0 :         if (from < 0 || to < 0 || to >= order.length) return;
     353            0 :         [order[from], order[to]] = [order[to], order[from]];
     354            0 :         props.modifySessionScripts({
     355            0 :             type: REORDER_SCRIPTS,
     356            0 :             value: order,
     357            0 :         });
     358            1 :     }, [props.modifySessionScripts]);
     359            1 :     const handleMoveUp = React.useCallback((fileName: string) => moveScript(fileName, -1), [moveScript]);
     360            1 :     const handleMoveDown = React.useCallback((fileName: string) => moveScript(fileName, 1), [moveScript]);
     361            1 :     const dndSensors = useSensors(
     362            1 :         useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
     363            1 :         useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
     364            1 :     );
     365            1 :     const handleDragEnd = React.useCallback((event: DragEndEvent) => {
     366            0 :         const { active, over } = event;
     367            0 :         if (over == null || active.id === over.id) return;
     368            0 :         const reordered = reorderFeedEntries(entries, active.id, over.id);
     369            0 :         if (reordered == null) return;
     370              :         // Keep the dropped order visible while REORDER_SCRIPTS updates the order of the session.
     371              :         // Stable script ids let React and dnd-kit retain card identity without forcing a render while
     372              :         // dnd-kit still has the active drag transform applied.
     373            0 :         setDisplayEntries({ source: canonicalEntries, entries: reordered });
     374            0 :         props.modifySessionScripts({
     375            0 :             type: REORDER_SCRIPTS,
     376            0 :             value: reordered.map(entry => entry.fileName),
     377            0 :         });
     378            1 :     }, [entries, props.modifySessionScripts, canonicalEntries]);
     379              : 
     380            1 :     const handleFormat = React.useCallback((scriptKey: number, text: string) => {
     381            0 :         props.modifySessionScripts({
     382            0 :             type: SET_SCRIPT_TEXT,
     383            0 :             value: { scriptKey, text, withDiff: true },
     384            0 :         });
     385            1 :     }, [props.modifySessionScripts]);
     386              : 
     387              :     // A snippet replaces the text of a script, a script with text shows the replacement as a diff to accept or reject
     388            1 :     const handleApplySnippet = React.useCallback((scriptKey: number, text: string) => {
     389            1 :         const priorText = props.sessionScripts.scripts[scriptKey]?.scriptSession.getText() ?? '';
     390            1 :         props.modifySessionScripts({
     391            1 :             type: SET_SCRIPT_TEXT,
     392            1 :             value: { scriptKey, text, withDiff: priorText.trim().length > 0 },
     393            1 :         });
     394            1 :     }, [props.modifySessionScripts, props.sessionScripts.scripts]);
     395              : 
     396              :     // The chart builder of a card stages every edit of its spec for the `@vis vegalite` tag as a diff to accept or reject
     397            1 :     const handleSetVisSpec = React.useCallback((scriptKey: number, spec: string) => {
     398            0 :         props.modifySessionScripts({ type: SET_SCRIPT_VIS_SPEC, value: { scriptKey, spec } });
     399            1 :     }, [props.modifySessionScripts]);
     400              : 
     401              :     // Accept / reject a staged rewrite from the feed. Accept keeps the new text; reject restores the
     402              :     // prior text and re-analyzes.
     403            1 :     const handleAcceptDiff = React.useCallback((scriptKey: number) => {
     404            0 :         props.modifySessionScripts({ type: ACCEPT_PENDING_DIFF, value: scriptKey });
     405            1 :     }, [props.modifySessionScripts]);
     406            1 :     const handleRejectDiff = React.useCallback((scriptKey: number) => {
     407            1 :         props.modifySessionScripts({ type: REJECT_PENDING_DIFF, value: scriptKey });
     408            1 :     }, [props.modifySessionScripts]);
     409            1 :     const handleToggleResultExpanded = React.useCallback((scriptKey: number) => {
     410            0 :         setCollapsedResults(current => {
     411            0 :             const next = new Map(current);
     412            0 :             if (next.has(scriptKey)) next.delete(scriptKey);
     413            0 :             else next.set(scriptKey, null);
     414            0 :             return next;
     415            0 :         });
     416            1 :     }, []);
     417            1 :     const handleAutoCollapseResult = React.useCallback((scriptKey: number, queryId: number) => {
     418            0 :         setCollapsedResults(current => {
     419            0 :             if (current.has(scriptKey)) return current;
     420            0 :             const next = new Map(current);
     421            0 :             next.set(scriptKey, queryId);
     422            0 :             return next;
     423            0 :         });
     424            1 :     }, []);
     425            1 :     const handleResetAutoCollapsedResult = React.useCallback((scriptKey: number, queryId: number | null) => {
     426            1 :         setCollapsedResults(current => {
     427            1 :             const autoCollapsedQueryId = current.get(scriptKey);
     428            1 :             if (autoCollapsedQueryId == null || autoCollapsedQueryId === queryId) return current;
     429            1 :             const next = new Map(current);
     430            1 :             next.delete(scriptKey);
     431            1 :             return next;
     432            1 :         });
     433            1 :     }, []);
     434              : 
     435            1 :     const [composerView, setComposerView] = React.useState<EditorView | null>(null);
     436            1 :     const composerSubmitRef = React.useRef<(() => void) | null>(null);
     437              :     // The mention button of a script references it in the question of the composer
     438            1 :     const composerTargetScriptRef = React.useRef<((name: string) => void) | null>(null);
     439              :     // A mention of a script goes to the cursor of the script editor that had the focus last, other than the editor of
     440              :     // the script itself, or into the composer
     441            1 :     const lastFocusedEditor = useLastFocusedEditor();
     442              :     // The mention of a table parameter, a script that is not `fileName` reads it
     443            1 :     const handleMention = React.useCallback((name: string, fileName: string | null) => {
     444            0 :         const view = lastFocusedEditor();
     445            0 :         if (view != null && view !== composerView) {
     446            0 :             const scriptKey = view.state.field(DashQLProcessorPlugin, false)?.scriptKey;
     447            0 :             const target = scriptKey != null ? props.sessionScripts.scripts[scriptKey] : undefined;
     448            0 :             if (target != null && target.fileName !== fileName) {
     449            0 :                 insertMentionAtCursor(view, `:${props.sessionScripts.instance.quoteIdentifier(name)}`);
     450            0 :                 return;
     451            0 :             }
     452            0 :         }
     453            0 :         composerTargetScriptRef.current?.(name);
     454            1 :     }, [lastFocusedEditor, composerView, props.sessionScripts.scripts, props.sessionScripts.instance]);
     455            1 :     const handleTargetScript = React.useCallback((fileName: string) => handleMention(scriptDisplayName(fileName), fileName), [handleMention]);
     456            1 :     const handleMentionAttachment = React.useCallback((name: string) => handleMention(name, null), [handleMention]);
     457              :     // An attachment renames like a script, the scripts and the draft that read it follow
     458            1 :     const handleRenameAttachment = React.useCallback((fileName: string, name: string) => {
     459            0 :         void renameAttachment(latest.current.sessionScripts, props.modifySessionScripts, storageReader.backend, fileName, name, composerRenameScriptRef.current)
     460            0 :             .then(renamed => { if (renamed != null) refreshDataFiles(); })
     461            0 :             .catch(e => {
     462            0 :                 setAttachmentError('Renaming attachment failed');
     463            0 :                 logger.error('Renaming session attachment failed', { error: stringifyError(e) }, LOG_CTX);
     464            0 :             });
     465            1 :     }, [props.modifySessionScripts, storageReader.backend, composerRenameScriptRef, refreshDataFiles, logger]);
     466            1 :     const scriptNames = React.useMemo(() => props.sessionScripts.scriptOrder.map(scriptDisplayName), [props.sessionScripts.scriptOrder]);
     467              : 
     468              :     // The feed stays mounted (just hidden) while another view, e.g. a connection overview, is open, so its global
     469              :     // key handlers would otherwise keep firing behind the overlay. Gate them all on the active flag.
     470            1 :     const feedActive = props.active;
     471              :     // A modal dialog keeps Enter and Escape, e.g. Escape closes the chart builder instead of rejecting the diff that
     472              :     // the builder stages behind it
     473            1 :     const modalDialogOpen = () => document.querySelector('[aria-modal="true"]') != null;
     474            1 :     const keyHandlers = React.useMemo<KeyEventHandler[]>(() => [
     475            1 :         {
     476              :             // Plain Enter, while browsing the feed with nothing focused, accepts a staged
     477              :             // rewrite (matching the status bar's "Accept ⏎" hint). It intentionally does nothing for
     478              :             // ordinary scripts.
     479            1 :             key: 'Enter',
     480            1 :             ctrlKey: false,
     481            1 :             capture: true,
     482            1 :             callback: (event: KeyboardEvent) => {
     483            1 :                 if (!feedActive || modalDialogOpen()) {
     484            1 :                     return;
     485              :                 }
     486            0 :                 const active = document.activeElement as HTMLElement | null;
     487            0 :                 if (active && active !== document.body && active !== document.documentElement) {
     488            0 :                     return;
     489              :                 }
     490            0 :                 const sessionScripts = latest.current.sessionScripts;
     491            0 :                 const focused = getSelectedScriptRef(sessionScripts);
     492            0 :                 const focusedScript = focused != null ? sessionScripts.scripts[focused.scriptId] : null;
     493            1 :                 if (focusedScript?.pendingDiff != null) {
     494            0 :                     event.preventDefault();
     495            0 :                     handleAcceptDiff(focusedScript.scriptKey);
     496            0 :                     return;
     497            0 :                 }
     498            1 :             },
     499            1 :         },
     500            1 :         {
     501              :             // Escape, while browsing the feed with nothing focused, rejects a staged rewrite on the
     502              :             // focused entry (matching the status bar's "Reject ⎋" hint). Same focus guard as Enter so the
     503              :             // An inline editor, rename input, or open completion dropdown keeps Escape when focused.
     504            1 :             key: 'Escape',
     505            1 :             ctrlKey: false,
     506            1 :             capture: true,
     507            1 :             callback: (event: KeyboardEvent) => {
     508            1 :                 if (!feedActive || modalDialogOpen()) {
     509            1 :                     return;
     510              :                 }
     511            1 :                 const active = document.activeElement as HTMLElement | null;
     512            1 :                 if (active && active !== document.body && active !== document.documentElement) {
     513            1 :                     return;
     514              :                 }
     515            1 :                 const sessionScripts = latest.current.sessionScripts;
     516            1 :                 const focused = getSelectedScriptRef(sessionScripts);
     517            1 :                 const focusedScript = focused != null ? sessionScripts.scripts[focused.scriptId] : null;
     518            1 :                 if (focusedScript?.pendingDiff != null) {
     519            1 :                     event.preventDefault();
     520            1 :                     handleRejectDiff(focusedScript.scriptKey);
     521            1 :                 }
     522            1 :             },
     523            1 :         },
     524            1 :         {
     525              :             // Ctrl+E executes the selected script globally. While the composer has focus, it sends the
     526              :             // draft instead of running a script the user isn't looking at.
     527            1 :             key: 'e',
     528            1 :             ctrlKey: true,
     529            1 :             capture: true,
     530            1 :             callback: (event: KeyboardEvent) => {
     531            1 :                 if (!feedActive || composerView == null || !composerView.hasFocus) {
     532            1 :                     return;
     533              :                 }
     534            1 :                 event.preventDefault();
     535            1 :                 event.stopPropagation();
     536            1 :                 composerSubmitRef.current?.();
     537            1 :             },
     538            1 :         },
     539            1 :     ], [feedActive, composerView, handleAcceptDiff, handleRejectDiff]);
     540            1 :     dependencies.useKeyEvents(keyHandlers);
     541              : 
     542            1 :     const feedLayout = useSessionFeedLayout(entries);
     543              : 
     544            1 :     const handleEditorView = React.useCallback((scriptKey: number, view: EditorView) => {
     545            1 :         editorViewsRef.current.set(scriptKey, view);
     546            1 :         if (pendingCreatedScriptKeyRef.current !== scriptKey) return;
     547            1 :         pendingCreatedScriptKeyRef.current = null;
     548            1 :         requestAnimationFrame(() => view.focus());
     549            1 :     }, []);
     550              : 
     551              :     // The composer appends its draft as a new script and runs it right away.
     552              :     // The composer keeps a SQL draft while its connection is not connected, a script that the agent writes
     553              :     // on a connection that is not connected is only created and the connection asks for attention.
     554            1 :     const connectionAttention = React.useContext(SessionConnectionAttentionContext);
     555            1 :     const pendingScrollToEndRef = React.useRef(false);
     556            1 :     const appendScript = React.useCallback(async (text: string) => {
     557            1 :         const result = props.modifySessionScripts({ type: CREATE_SCRIPT, value: { index: null, text } });
     558            1 :         if (!result) return null;
     559            1 :         pendingScrollToEndRef.current = true;
     560            1 :         const next = await result;
     561            1 :         if (next == null) return null;
     562            1 :         const created = getSelectedScriptRef(next);
     563            1 :         const scriptData = created != null ? next.scripts[created.scriptId] : undefined;
     564            1 :         return scriptData != null ? { next, scriptData } : null;
     565            1 :     }, [props.modifySessionScripts]);
     566            1 :     const runAppendedScript = React.useCallback((next: SessionScripts, scriptData: ScriptData) => {
     567              :         // The agent may write the script on another connection than the one of the composer
     568            1 :         if (!isScriptOnline(scriptData)) {
     569            1 :             connectionAttention?.request(next.sessionId);
     570            1 :             return;
     571            1 :         }
     572            1 :         dependencies.runSessionScript(next, scriptData, executeQuery, props.modifySessionScripts, logger);
     573            1 :     }, [connectionAttention, executeQuery, logger, props.modifySessionScripts]);
     574              : 
     575              :     // Scroll a script into view.
     576              :     // A script with results that does not fit shows its results, once its row is mounted and the results are measured.
     577              :     // A created script has no entry until the next render.
     578            1 :     const revealScript = React.useCallback((scriptKey: number, target: AgentFollowTarget) => {
     579            1 :         const index = entries.findIndex(entry => entry.scriptId === scriptKey);
     580            1 :         const element = feedLayout.listRef.current?.element;
     581            1 :         if (index < 0 || element == null) return;
     582            1 :         const [cardTop, bottom] = feedLayout.getEntrySpan(index);
     583              :         // The first script also shows the space above the feed, the add button above the script sits in it
     584            1 :         let top = index === 0 ? 0 : cardTop;
     585            1 :         const viewportHeight = element.clientHeight;
     586            1 :         if (target === 'result' && bottom - cardTop > viewportHeight) {
     587            1 :             const item = element.querySelector(`[data-script-key="${scriptKey}"]`);
     588            1 :             const result = item?.querySelector('[data-feed-result]');
     589            1 :             if (item != null && result != null) {
     590            1 :                 top = cardTop + result.getBoundingClientRect().top - item.getBoundingClientRect().top;
     591              :             }
     592            1 :         }
     593            1 :         const offset = revealScrollOffset(top, bottom, element.scrollTop, viewportHeight);
     594            1 :         if (offset != null) element.scrollTo({ top: offset });
     595            1 :     }, [entries, feedLayout.listRef, feedLayout.getEntrySpan]);
     596              : 
     597              :     // The feed follows the agent to the scripts it edits and runs in a question of the composer, also while the
     598              :     // agent still works. The followed script stays in view as it is measured and its results arrive.
     599              :     // Following stops once the user scrolls, clicks or types in the feed, the user does not want to be yanked around.
     600            1 :     const followAgentRef = React.useRef(false);
     601            1 :     const followedRef = React.useRef<{ scriptKey: number; target: AgentFollowTarget } | null>(null);
     602            1 :     const stopFollowingAgent = React.useCallback(() => {
     603            1 :         followAgentRef.current = false;
     604            1 :         followedRef.current = null;
     605            1 :     }, []);
     606            1 :     const [followRequest, setFollowRequest] = React.useState(0);
     607            1 :     const followAgentScript = React.useCallback((scriptKey: number, target: AgentFollowTarget) => {
     608            1 :         if (!followAgentRef.current) return;
     609            1 :         followedRef.current = { scriptKey, target };
     610            1 :         setFollowRequest(n => n + 1);
     611            1 :     }, []);
     612            1 :     React.useEffect(() => {
     613            1 :         const followed = followedRef.current;
     614            1 :         if (followed == null) return;
     615            1 :         revealScript(followed.scriptKey, followed.target);
     616              :         // A row that the scroll mounts can show its results in the next frame
     617            1 :         const frame = requestAnimationFrame(() => {
     618            1 :             if (followedRef.current === followed) revealScript(followed.scriptKey, followed.target);
     619            1 :         });
     620            1 :         return () => cancelAnimationFrame(frame);
     621            1 :     }, [revealScript, followRequest, feedLayout.rowHeightsVersion]);
     622              : 
     623              :     // A requested script stays in view like a followed one, until the user scrolls, clicks or types in the feed.
     624              :     // The request waits until the feed shows the session of the script.
     625            1 :     const revealedRef = React.useRef<SessionFeedRevealRequest | null>(null);
     626            1 :     const revealRequest = props.revealRequest ?? null;
     627            1 :     React.useEffect(() => {
     628            1 :         if (revealRequest == null || revealRequest === revealedRef.current || !props.active) return;
     629            1 :         if (revealRequest.sessionId !== props.sessionScripts.sessionId) return;
     630            1 :         const scriptKey = props.sessionScripts.scriptRefs[revealRequest.fileName]?.scriptId;
     631            1 :         if (scriptKey == null) return;
     632            1 :         revealedRef.current = revealRequest;
     633            1 :         followAgentRef.current = false;
     634            1 :         followedRef.current = { scriptKey, target: 'script' };
     635            1 :         setFollowRequest(n => n + 1);
     636            1 :     }, [revealRequest, props.active, props.sessionScripts.sessionId, props.sessionScripts.scriptRefs]);
     637              : 
     638            1 :     const handleCreate = React.useCallback((index: number) => {
     639            1 :         const result = props.modifySessionScripts({ type: CREATE_SCRIPT, value: { index } });
     640            1 :         if (!result) return;
     641            1 :         void result.then(next => {
     642            1 :             if (next == null) return;
     643            1 :             const created = getSelectedScriptRef(next);
     644            1 :             if (created == null) return;
     645              :             // The created script stays in view like a followed one, until the user scrolls, clicks or types in the feed
     646            1 :             followAgentRef.current = false;
     647            1 :             followedRef.current = { scriptKey: created.scriptId, target: 'script' };
     648            1 :             setFollowRequest(n => n + 1);
     649            1 :             const view = editorViewsRef.current.get(created.scriptId);
     650            1 :             if (view != null) {
     651            0 :                 view.focus();
     652              :             } else {
     653            1 :                 pendingCreatedScriptKeyRef.current = created.scriptId;
     654              :             }
     655            1 :         });
     656            1 :     }, [props.modifySessionScripts]);
     657              : 
     658              :     // The agent answers a question in the scripts of the session
     659            1 :     const agent = dependencies.useAgentBridge();
     660            1 :     const handleShowAgentScript = React.useCallback((scriptKey: number) => {
     661            0 :         const scriptData = latest.current.sessionScripts.scripts[scriptKey];
     662            0 :         if (scriptData == null) return;
     663            0 :         handleFocus(scriptData.fileName);
     664            0 :         revealScript(scriptKey, 'script');
     665            1 :     }, [handleFocus, revealScript]);
     666            1 :     const composerAgent = useComposerAgent({
     667            1 :         agent,
     668            1 :         sessionScripts: props.sessionScripts,
     669            1 :         runScript: runAppendedScript,
     670            1 :         followScript: followAgentScript,
     671            1 :         showScript: handleShowAgentScript,
     672            1 :     });
     673              : 
     674            1 :     const handleComposerSend = React.useCallback((text: string, mode: ComposerInputMode) => {
     675            1 :         if (mode === ComposerInputMode.Ai) {
     676            1 :             followAgentRef.current = true;
     677            1 :             void composerAgent.ask(text);
     678            1 :             return;
     679            1 :         }
     680              :         // The appended script takes over the feed
     681            1 :         stopFollowingAgent();
     682            1 :         logger.debug('Running the composer draft as SQL', { characters: String(text.length) }, LOG_CTX);
     683            1 :         void appendScript(text).then(appended => {
     684            1 :             if (appended != null) runAppendedScript(appended.next, appended.scriptData);
     685            1 :         });
     686            1 :     }, [composerAgent.ask, appendScript, runAppendedScript, stopFollowingAgent, logger]);
     687            1 :     React.useEffect(() => {
     688            1 :         if (!pendingScrollToEndRef.current || entries.length === 0) return;
     689            1 :         pendingScrollToEndRef.current = false;
     690            1 :         feedLayout.listRef.current?.scrollToRow({ index: entries.length - 1, align: 'end' });
     691            1 :     }, [entries.length]);
     692              : 
     693              :     // The checked script references of every script, the check runs again only when a script changes
     694            1 :     const scriptReferences = React.useMemo(() => checkSessionScriptReferences(props.sessionScripts), [props.sessionScripts]);
     695            1 :     const rowProps = React.useMemo<ScriptFeedRowProps>(() => ({
     696            1 :         sessionId: props.sessionScripts.sessionId,
     697            1 :         scriptReferences,
     698            1 :         connections: scriptConnections,
     699            1 :         computeCacheKey,
     700            1 :         lookupCachedResult,
     701            1 :         entries,
     702            1 :         storageReader: storageReader,
     703            1 :         scripts: props.sessionScripts.scripts,
     704            1 :         scriptDebugMode,
     705            1 :         formattingDebugMode,
     706            1 :         keywordCase,
     707            1 :         focusedFileName: props.sessionScripts.scriptFocus.fileName,
     708            1 :         active: props.active,
     709            1 :         onFocus: handleFocus,
     710            1 :         onDelete: handleDelete,
     711            1 :         onRename: handleRename,
     712            1 :         onPickConnection: pickScriptConnection,
     713            1 :         onMoveUp: handleMoveUp,
     714            1 :         onMoveDown: handleMoveDown,
     715            1 :         onExecute: handleExecuteEntry,
     716            1 :         onRerun: handleRerunEntry,
     717            1 :         onFormat: handleFormat,
     718            1 :         onApplySnippet: handleApplySnippet,
     719            1 :         onSetVisSpec: handleSetVisSpec,
     720            1 :         canTarget: true,
     721            1 :         onTarget: handleTargetScript,
     722            1 :         onAcceptDiff: handleAcceptDiff,
     723            1 :         onRejectDiff: handleRejectDiff,
     724            1 :         collapsedResults,
     725            1 :         onToggleResultExpanded: handleToggleResultExpanded,
     726            1 :         onAutoCollapseResult: handleAutoCollapseResult,
     727            1 :         onResetAutoCollapsedResult: handleResetAutoCollapsedResult,
     728            1 :         onCreate: handleCreate,
     729            1 :         onEditorView: handleEditorView,
     730            1 :         onRowHeightChange: feedLayout.rowHeights.setRowHeight,
     731            1 :     }), [entries, scriptReferences, props.active, scriptConnections, props.sessionScripts.scripts, props.sessionScripts.scriptFocus.fileName, computeCacheKey, lookupCachedResult, scriptDebugMode, formattingDebugMode, keywordCase, handleFocus, handleDelete, handleRename, pickScriptConnection, handleMoveUp, handleMoveDown, handleExecuteEntry, handleRerunEntry, handleFormat, handleApplySnippet, handleSetVisSpec, handleTargetScript, handleAcceptDiff, handleRejectDiff, collapsedResults, handleToggleResultExpanded, handleAutoCollapseResult, handleResetAutoCollapsedResult, handleCreate, handleEditorView, feedLayout.rowHeights.setRowHeight]);
     732              : 
     733            1 :     const getScrollElement = React.useCallback(() => feedLayout.listRef.current?.element ?? null, [feedLayout.listRef]);
     734              : 
     735              :     // An empty session greets the user with the composer in place of the feed.
     736              :     // The composer stays at the same position in the tree so it keeps focus when the first script lands.
     737            1 :     const landing = entries.length === 0;
     738              :     // The agent turns grow the composer, the greeting stays hidden while they are shown.
     739              :     // Sending clears the draft before the agent writes the first script, the turn keeps the greeting away.
     740            1 :     const greetingHidden = (composerAgent.agent?.turns.length ?? 0) > 0;
     741              :     // While the user writes, the face watches the caret of the composer
     742            1 :     const composerCaret = React.useCallback(() => {
     743            1 :         const head = composerView?.state?.selection?.main.head;
     744            1 :         if (composerView == null || head == null || !composerView.hasFocus) return null;
     745            1 :         const coords = composerView.coordsAtPos(head);
     746            1 :         return coords == null ? null : { x: coords.left, y: (coords.top + coords.bottom) / 2 };
     747            1 :     }, [composerView]);
     748              :     // The face gets excited about every keystroke in the composer, a ref keeps the feed from rendering per key
     749            1 :     const lastComposerInput = React.useRef<number | null>(null);
     750            1 :     React.useEffect(() => {
     751            1 :         const dom = composerView?.contentDOM;
     752            1 :         if (dom == null || typeof dom.addEventListener !== 'function') return;
     753            1 :         const onKeyDown = (event: KeyboardEvent) => {
     754            1 :             if (event.key.length === 1 || event.key === 'Backspace' || event.key === 'Delete' || event.key === 'Enter') {
     755            1 :                 lastComposerInput.current = performance.now();
     756              :             }
     757            1 :         };
     758            1 :         dom.addEventListener('keydown', onKeyDown);
     759            1 :         return () => dom.removeEventListener('keydown', onKeyDown);
     760            1 :     }, [composerView]);
     761            1 :     const composerLastInput = React.useCallback(() => lastComposerInput.current, []);
     762            1 :     const landingFace = React.useMemo(() => ({
     763            1 :         className: styles.feed_landing_face,
     764            1 :         'data-testid': 'feed-landing-face',
     765            1 :         label: 'DashQL agent',
     766            1 :         paused: greetingHidden,
     767            1 :         tuning: LANDING_FACE_TUNING,
     768            1 :         gaze: { pointer: true, target: composerCaret },
     769            1 :         lastInput: composerLastInput,
     770            1 :     }), [greetingHidden, composerCaret, composerLastInput]);
     771            1 :     return (
     772            1 :         <div
     773            1 :             className={styles.feed_body_container}
     774            1 :             data-landing={landing ? '' : undefined}
     775            1 :             style={{ '--feed-scrollbar-inset': `${feedLayout.listScrollbarInset}px` } as React.CSSProperties}
     776              :         >
     777              :             {/* The inputs and the attachments sit on a bar above the feed */}
     778            1 :             <SessionInputStrip
     779            1 :                 className={styles.feed_top_strip}
     780            1 :                 sessionScripts={props.sessionScripts}
     781            1 :                 modifySessionScripts={props.modifySessionScripts}
     782            1 :                 leading={attachmentsVisible ? (
     783            1 :                     <button type="button" className={styles.feed_data_chip}
     784            1 :                         aria-expanded={attachmentsExpanded}
     785            1 :                         aria-controls="session-attachments"
     786            1 :                         onClick={() => setAttachmentsExpanded(expanded => !expanded)}>
     787              :                         Attached Files
     788            1 :                         <span className={styles.feed_data_count}>{pinnedDataFiles.length}</span>
     789            1 :                         {attachmentsExpanded ? <ChevronUpIcon size={14} /> : <ChevronDownIcon size={14} />}
     790            1 :                     </button>
     791            1 :                 ) : undefined}
     792            1 :                 below={attachmentsVisible && (attachmentsExpanded || attachmentError != null) ? (
     793            1 :                     <section className={styles.feed_data_section} aria-label="Session attachments">
     794            1 :                         <input ref={attachmentInputRef} className={styles.feed_data_input} type="file" multiple accept={ATTACHMENT_EXTENSION}
     795            1 :                             onChange={handleAttachmentInput} aria-label="Add session attachments" disabled={!attachmentsEnabled} />
     796            1 :                         {attachmentsExpanded && (
     797            1 :                             <div id="session-attachments" className={styles.feed_data_drawer}
     798            1 :                                 onDragOver={event => event.preventDefault()} onDrop={handleAttachmentDrop}>
     799            1 :                                 <IconButton variant={ButtonVariant.Invisible} size={ButtonSize.Small}
     800            1 :                                     aria-label="Add session attachments" disabled={!attachmentsEnabled}
     801            1 :                                     onClick={event => {
     802            0 :                                         event.stopPropagation();
     803            0 :                                         attachmentInputRef.current?.click();
     804            0 :                                     }}>
     805            1 :                                     <PlusIcon size={16} />
     806            1 :                                 </IconButton>
     807            1 :                                 <div className={styles.feed_data_cards}>
     808            1 :                                     {pinnedDataFiles.map(file => (
     809            0 :                                         <SessionDataCard key={`${sessionId}/${file.name}`} sessionId={sessionId}
     810            0 :                                             file={file} conn={props.conn} storageReader={storageReader} onRemoved={refreshDataFiles}
     811            0 :                                             onMention={handleMentionAttachment}
     812            0 :                                             onRename={name => handleRenameAttachment(file.name, name)}
     813            0 :                                             collides={scriptNames.some(name => name.toLowerCase() === attachmentParameterName(file.name)?.toLowerCase())} />
     814            1 :                                     ))}
     815            1 :                                     {pinnedDataFiles.length === 0 && <div className={styles.feed_data_empty}>Drop files here or add attachments.</div>}
     816            1 :                                 </div>
     817            1 :                             </div>
     818              :                         )}
     819            1 :                         {attachmentError != null && <div className={styles.feed_data_error} role="alert">{attachmentError}</div>}
     820            1 :                     </section>
     821            1 :                 ) : undefined}
     822            1 :             />
     823            1 :             {landing && (
     824            1 :                 <div className={styles.feed_landing_header} data-hidden={greetingHidden ? '' : undefined}>
     825            1 :                     <AgentGreeting
     826            1 :                         face={landingFace}
     827            1 :                         questionClassName={styles.feed_landing_question}
     828            1 :                         questionTestId="feed-landing-question" />
     829            1 :                 </div>
     830              :             )}
     831              :             {/* Hidden rather than unmounted on the landing view, the size observer binds to the element once */}
     832            1 :             <div className={styles.feed_list_container} ref={feedLayout.listContainerRef} hidden={landing} data-testid="feed-list"
     833            1 :                 onWheel={stopFollowingAgent} onTouchMove={stopFollowingAgent} onPointerDown={stopFollowingAgent} onKeyDown={stopFollowingAgent}>
     834            1 :                 <DndContext
     835            1 :                     sensors={dndSensors}
     836            1 :                     collisionDetection={closestCenter}
     837            1 :                     modifiers={[restrictToVerticalAxis]}
     838            1 :                     onDragEnd={handleDragEnd}
     839              :                 >
     840            1 :                     <SortableContext items={entries.map(entry => entry.scriptId)} strategy={verticalListSortingStrategy}>
     841            1 :                         <List
     842            1 :                             key={props.sessionScripts.sessionId}
     843            1 :                             listRef={feedLayout.listRef}
     844            1 :                             style={{
     845            1 :                                 width: feedLayout.listWidth,
     846            1 :                                 height: feedLayout.listHeight,
     847            1 :                                 overflowX: 'hidden',
     848            1 :                                 scrollbarGutter: 'stable',
     849            1 :                                 '--feed-scrollbar-inset': `${feedLayout.listScrollbarInset}px`,
     850            1 :                                 '--feed-gap': `${FEED_GAP}px`,
     851            1 :                             } as React.CSSProperties}
     852            1 :                             rowCount={entries.length}
     853            1 :                             overscanCount={OVERSCAN_ROW_COUNT}
     854            1 :                             rowHeight={feedLayout.rowHeights}
     855            1 :                             rowKey={feedRowKey}
     856            1 :                             rowComponent={dependencies.ScriptFeedRow}
     857            1 :                             rowProps={rowProps}
     858            1 :                         />
     859            1 :                     </SortableContext>
     860            1 :                 </DndContext>
     861            1 :                 {props.active && (
     862            1 :                     <VerticalScrollShadows
     863            1 :                         getScrollElement={getScrollElement}
     864            1 :                         prominent
     865            1 :                         refreshKey={`${props.sessionScripts.sessionId}:${entries.length}:${feedLayout.rowHeightsVersion}`}
     866            1 :                     />
     867              :                 )}
     868            1 :             </div>
     869            1 :             <div className={styles.feed_composer_section}>
     870            1 :                 <div className={styles.feed_composer_body}>
     871            1 :                     {composerConnection != null && <dependencies.SessionComposer
     872            1 :                         key={props.sessionScripts.sessionId}
     873            1 :                         instance={props.sessionScripts.instance}
     874            1 :                         catalog={composerConnection.catalog}
     875            1 :                         variant={landing ? SessionComposerVariant.Hero : SessionComposerVariant.Docked}
     876            1 :                         keywordCase={keywordCase}
     877            1 :                         aiAvailable={composerAgent.available}
     878            1 :                         agent={composerAgent.agent}
     879            1 :                         onSend={handleComposerSend}
     880            1 :                         setView={setComposerView}
     881            1 :                         submitRef={composerSubmitRef}
     882            1 :                         scriptNames={scriptNames}
     883            1 :                         targetScriptRef={composerTargetScriptRef}
     884            1 :                         renameScriptRef={composerRenameScriptRef}
     885            1 :                         tableParameters={sessionTableParameters(props.sessionScripts)}
     886            1 :                         snippetConnector={composerConnection.connectorInfo.connectorType}
     887            1 :                         offlineSessionId={scriptConnections.get(composerConnection.connectionId)?.connectionHealth === ConnectionHealth.ONLINE
     888            1 :                             ? null : props.sessionScripts.sessionId}
     889            1 :                         connectionPicker={(
     890            1 :                             <ScriptConnectionPicker
     891            1 :                                 sessionId={props.sessionScripts.sessionId}
     892            1 :                                 scriptName="new"
     893            1 :                                 connection={scriptConnections.get(composerConnection.connectionId) ?? null}
     894            1 :                                 disabled={false}
     895            1 :                                 onPick={pickPendingConnection}
     896            1 :                                 onAttach={attachPendingConnection}
     897            1 :                                 testId="composer-connection-picker"
     898            1 :                             />
     899              :                         )}
     900            1 :                     />}
     901            1 :                 </div>
     902            1 :             </div>
     903            1 :         </div>
     904              :     );
     905            1 : };
        

Generated by: LCOV version 2.0-1