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