Line data Source code
1 : import * as React from 'react';
2 : import * as styles from './session_feed_page.module.css';
3 :
4 : import { LinkIcon, PaperAirplaneIcon, PlugIcon, SquareFillIcon, SymbolIcon, ThreeBarsIcon } from '../../ui/foundations/symbol_icon.js';
5 :
6 : import { ConnectionHealth, type ConnectionState } from '../../connections/connection_state.js';
7 : import {
8 : isEphemeralSession,
9 : scriptConnectionIds,
10 : SELECT_SCRIPT,
11 : sessionConnectionIds,
12 : type SessionScripts,
13 : } from '../scripts/session_scripts.js';
14 : import type { ModifySessionScripts } from '../scripts/session_scripts_registry.js';
15 : import { Button, ButtonSize, ButtonVariant, IconButton } from '../../ui/foundations/button.js';
16 : import { SessionNavigationDrawer } from '../session_navigation_drawer.js';
17 : import { SessionWorkbenchSidebar } from '../session_workbench_sidebar.js';
18 : import { SessionURLShareOverlay } from '../persistence/session_url_share_overlay.js';
19 : import { SessionFileSaveOverlay } from '../persistence/session_file_save_overlay.js';
20 : import { ScriptEditorDialog } from '../scripts/script_editor_dialog.js';
21 : import { SessionFeed, type SessionFeedRevealRequest } from './session_feed.js';
22 : import { Dashboard } from '../dashboard/dashboard.js';
23 : import { DataflowCanvas } from '../dataflow/dataflow_canvas.js';
24 : import { SessionViewMode, useSessionViewMode } from '../session_view_mode.js';
25 : import { SegmentedControl, SegmentedControlSize } from '../../ui/foundations/segmented_control.js';
26 : import { useSessionExecution } from '../scripts/session_execution_provider.js';
27 : import { IndicatorStatus, StatusIndicator } from '../../ui/foundations/status_indicator.js';
28 : import { AnchoredOverlay } from '../../ui/foundations/anchored_overlay.js';
29 : import { AnchorAlignment, AnchorSide } from '../../ui/foundations/anchored_position.js';
30 : import { connectionHealthKey, connectionsToConnect, ExecuteConnectionGuard, SessionConnectionAttentionContext, useSessionConnectionAttention } from '../session_connection_attention.js';
31 : import { SessionConnectionButtons } from './session_connection_buttons.js';
32 : import { useAppFlavor } from '../../config/flavor_provider.js';
33 : import { ConnectionOverviewPage } from '../../connections/connection_overview_page.js';
34 : import { AgentSessionMenu } from '../../agent/agent_session_menu.js';
35 : import type { RenameDraftReferences } from '../scripts/script_rename.js';
36 :
37 1 : const FileZipIcon = SymbolIcon('file_zip_16');
38 1 : const CloseIcon = SymbolIcon('x_16');
39 : // The views of the session view switch, octicons
40 1 : const FeedViewIcon = SymbolIcon('rows_16');
41 1 : const DashboardViewIcon = SymbolIcon('dashboard_16');
42 1 : const DataflowViewIcon = SymbolIcon('workflow_16');
43 : /// The views of the switch, the session first
44 1 : const SESSION_VIEWS: [SessionViewMode, string, typeof FeedViewIcon][] = [
45 1 : [SessionViewMode.Session, 'Feed', FeedViewIcon],
46 1 : [SessionViewMode.Dashboard, 'Dashboard', DashboardViewIcon],
47 1 : [SessionViewMode.Dataflow, 'Dataflow', DataflowViewIcon],
48 1 : ];
49 :
50 : export interface SessionFeedPageDependencies {
51 : SessionFeed: typeof SessionFeed;
52 : ScriptEditorDialog: typeof ScriptEditorDialog;
53 : SessionWorkbenchSidebar: typeof SessionWorkbenchSidebar;
54 : ConnectionOverviewPage: typeof ConnectionOverviewPage;
55 : SessionNavigationDrawer: typeof SessionNavigationDrawer;
56 : ThreeBarsIcon: typeof ThreeBarsIcon;
57 : IconButton: typeof IconButton;
58 : Button: typeof Button;
59 : Dashboard: typeof Dashboard;
60 : DataflowCanvas: typeof DataflowCanvas;
61 : SessionURLShareOverlay: typeof SessionURLShareOverlay;
62 : SessionFileSaveOverlay: typeof SessionFileSaveOverlay;
63 : SessionConnectionButtons: typeof SessionConnectionButtons;
64 : useSessionViewMode: typeof useSessionViewMode;
65 : SegmentedControl: typeof SegmentedControl;
66 : useSessionExecution: typeof useSessionExecution;
67 : }
68 :
69 1 : const DEFAULT_DEPENDENCIES: SessionFeedPageDependencies = {
70 1 : SessionFeed,
71 1 : ScriptEditorDialog,
72 1 : SessionWorkbenchSidebar,
73 1 : ConnectionOverviewPage,
74 1 : SessionNavigationDrawer,
75 1 : ThreeBarsIcon,
76 1 : IconButton,
77 1 : Button,
78 1 : Dashboard,
79 1 : DataflowCanvas,
80 1 : SessionURLShareOverlay,
81 1 : SessionFileSaveOverlay,
82 1 : SessionConnectionButtons,
83 1 : useSessionViewMode,
84 1 : SegmentedControl,
85 1 : useSessionExecution,
86 1 : };
87 :
88 : interface Props {
89 : sessionScripts: SessionScripts;
90 : modifySessionScripts: ModifySessionScripts;
91 : /// The connection that the next script of the session runs on
92 : connection: ConnectionState | null;
93 : /// The connections that the scripts of the session run on
94 : scriptConnections?: ReadonlyMap<string, ConnectionState>;
95 : active: boolean;
96 : dependencies?: SessionFeedPageDependencies;
97 : }
98 :
99 1 : export const SessionFeedPage: React.FC<Props> = (props) => {
100 1 : const dependencies = props.dependencies ?? DEFAULT_DEPENDENCIES;
101 : // The composer of the feed renames the references in its draft, also for renames in the dataflow
102 1 : const draftRenameRef = React.useRef<RenameDraftReferences | null>(null);
103 : // The script that the editor dialog of the dataflow shows. It belongs to the session it was opened in and closes
104 : // when another session is selected or the script is gone.
105 1 : const [details, setDetails] = React.useState<{ sessionId: string; scriptId: number } | null>(null);
106 1 : const showDetails = details != null && details.sessionId === props.sessionScripts.sessionId
107 1 : && props.sessionScripts.scripts[details.scriptId] != null;
108 1 : React.useEffect(() => setDetails(null), [props.sessionScripts.sessionId]);
109 : // The dialog selects its script, the commands of the session (Ctrl+E) run the selected script
110 1 : const inspectScript = React.useCallback((scriptKey: number) => {
111 1 : const target = props.sessionScripts.scripts[scriptKey];
112 1 : if (target?.fileName == null) return;
113 1 : props.modifySessionScripts({ type: SELECT_SCRIPT, value: target.fileName });
114 1 : setDetails({ sessionId: props.sessionScripts.sessionId, scriptId: scriptKey });
115 1 : }, [props.sessionScripts.scripts, props.sessionScripts.sessionId, props.modifySessionScripts]);
116 1 : const closeDetails = React.useCallback(() => setDetails(null), []);
117 1 : const [navigationDrawerOpen, setNavigationDrawerOpen] = React.useState(false);
118 1 : const [overviewConnectionId, setOverviewConnectionId] = React.useState<string | null>(null);
119 1 : const closeOverview = React.useCallback(() => setOverviewConnectionId(null), []);
120 1 : const navigationDrawerTriggerRef = React.useRef<HTMLButtonElement>(null);
121 1 : const shareTriggerRef = React.useRef<HTMLButtonElement>(null);
122 1 : const exportTriggerRef = React.useRef<HTMLButtonElement>(null);
123 1 : const [shareOpen, setShareOpen] = React.useState(false);
124 1 : const [exportOpen, setExportOpen] = React.useState(false);
125 1 : const archiveExport = useAppFlavor().features.sessionArchiveExport;
126 : // A session run takes every connection that a script runs on, they have to be connected first
127 1 : const scriptConnections = props.scriptConnections ?? new Map(props.connection == null ? [] : [[props.connection.connectionId, props.connection]]);
128 1 : const runConnections = scriptConnectionIds(props.sessionScripts).flatMap(connectionId => scriptConnections.get(connectionId) ?? []);
129 : // The bar also shows the connection that the composer drafts the next script on, e.g. the one picked in an empty session
130 1 : const barConnections = sessionConnectionIds(props.sessionScripts).flatMap(connectionId => scriptConnections.get(connectionId) ?? []);
131 1 : const toConnect = connectionsToConnect(runConnections);
132 1 : const disconnected = toConnect.length > 0;
133 1 : const connectionAttention = useSessionConnectionAttention(props.sessionScripts.sessionId, connectionHealthKey(barConnections));
134 1 : const scriptConnectionsIdle = runConnections.length > 0 && runConnections.every(connection =>
135 1 : connection.connectionHealth === ConnectionHealth.ONLINE && (connection.queriesActive?.size ?? 0) === 0);
136 1 : const execution = dependencies.useSessionExecution(props.sessionScripts.sessionId);
137 1 : const run = execution.state;
138 1 : const [executionDetailsOpen, setExecutionDetailsOpen] = React.useState(false);
139 1 : React.useEffect(() => setExecutionDetailsOpen(false), [props.sessionScripts.sessionId]);
140 1 : React.useEffect(() => setOverviewConnectionId(null), [props.sessionScripts.sessionId]);
141 1 : const executeRef = React.useRef<HTMLButtonElement>(null);
142 1 : const progress = run ? `${run.completedScripts}/${run.totalScripts}` : '';
143 1 : const statusText = !run ? '' : run.phase === 'planning' ? 'Planning...'
144 1 : : run.phase === 'stopping' ? `Stopping · ${progress} completed`
145 1 : : run.phase === 'failed' ? `${progress} completed`
146 1 : : run.phase === 'cancelled' ? `Stopped · ${progress} completed`
147 1 : : run.phase === 'succeeded' ? `${progress} succeeded` : `${progress} completed`;
148 1 : const { mode, setMode } = dependencies.useSessionViewMode();
149 : // The greeter shows the feed, its session has nothing to run, show or share until a script promotes it
150 1 : const ephemeral = isEphemeralSession(props.sessionScripts);
151 1 : React.useEffect(() => {
152 1 : if (ephemeral && (mode === SessionViewMode.Dashboard || mode === SessionViewMode.Dataflow)) setMode(SessionViewMode.Session);
153 1 : }, [ephemeral, mode, setMode]);
154 1 : const dashboardActive = mode === SessionViewMode.Dashboard;
155 1 : const dataflowActive = mode === SessionViewMode.Dataflow;
156 1 : const activeView = dashboardActive ? SessionViewMode.Dashboard : dataflowActive ? SessionViewMode.Dataflow : SessionViewMode.Session;
157 1 : const overviewActive = overviewConnectionId != null;
158 1 : const feedActive = props.active && !dashboardActive && !dataflowActive && !overviewActive;
159 : // A script opened from the sidebar shows in the feed
160 1 : const [revealRequest, setRevealRequest] = React.useState<SessionFeedRevealRequest | null>(null);
161 1 : const openScript = React.useCallback((sessionId: string, fileName: string) => {
162 0 : setOverviewConnectionId(null);
163 0 : setDetails(null);
164 0 : if (mode !== SessionViewMode.Session) setMode(SessionViewMode.Session);
165 0 : setRevealRequest({ sessionId, fileName });
166 1 : }, [mode, setMode]);
167 1 : const workbench = (closeAfterSelection: boolean) => (
168 1 : <dependencies.SessionWorkbenchSidebar
169 1 : sessionScripts={props.sessionScripts}
170 1 : closeAfterSelection={closeAfterSelection ? () => setNavigationDrawerOpen(false) : undefined}
171 1 : openOverviewId={overviewConnectionId}
172 1 : onOpenOverview={setOverviewConnectionId}
173 1 : onOpenScript={openScript}
174 1 : />
175 : );
176 :
177 1 : return (
178 1 : <SessionConnectionAttentionContext.Provider value={connectionAttention}>
179 1 : <div className={styles.page}>
180 1 : <header className={styles.view_bar} data-electron-drag-region>
181 1 : <dependencies.IconButton className={styles.mobile_menu_button} ref={navigationDrawerTriggerRef} variant={ButtonVariant.Default} aria-label="Open session workbench" data-testid="open-workbench" onClick={() => setNavigationDrawerOpen(true)}>
182 1 : <dependencies.ThreeBarsIcon />
183 1 : </dependencies.IconButton>
184 : {/* A connection overview is not part of the session, its controls are hidden */}
185 1 : {!overviewActive && <>
186 1 : {!ephemeral && <dependencies.SegmentedControl
187 1 : aria-label="Session view"
188 1 : data-testid="session-view-switch"
189 1 : size={SegmentedControlSize.Small}
190 1 : onChange={(selectedIndex) => {
191 1 : setOverviewConnectionId(null);
192 : // The editor dialog belongs to the dataflow view
193 1 : setDetails(null);
194 1 : setMode(SESSION_VIEWS[selectedIndex]?.[0] ?? SessionViewMode.Session);
195 1 : }}
196 : >
197 1 : {SESSION_VIEWS.map(([view, label, Icon]) => (
198 1 : <dependencies.SegmentedControl.IconButton
199 1 : key={view}
200 1 : className={styles.view_switch_button}
201 1 : aria-label={label}
202 1 : icon={<Icon size={14} />}
203 1 : selected={activeView === view}
204 1 : />
205 1 : ))}
206 1 : </dependencies.SegmentedControl>}
207 1 : {!ephemeral && <div className={styles.execution_controls}>
208 1 : <ExecuteConnectionGuard sessionId={props.sessionScripts.sessionId} blocked={disconnected && !execution.busy}>
209 1 : <dependencies.Button ref={executeRef} size={ButtonSize.Small} variant={ButtonVariant.Invisible}
210 1 : leadingVisual={execution.busy ? SquareFillIcon : disconnected ? PlugIcon : PaperAirplaneIcon}
211 1 : aria-label={execution.busy ? 'Stop session execution' : disconnected ? 'Connect to run' : 'Execute All'}
212 1 : data-testid={execution.busy ? 'stop-execution' : 'execute-all'}
213 1 : data-connect={!execution.busy && disconnected ? '' : undefined}
214 1 : disabled={execution.busy ? run?.phase === 'stopping' : !disconnected && (!scriptConnectionsIdle
215 1 : || execution.runActive || props.sessionScripts.scriptOrder.length === 0)}
216 1 : onClick={() => execution.busy ? execution.stop()
217 : // The connections that are not connected yet pulse in the bar, nothing opens by itself
218 1 : : disconnected ? connectionAttention.request(props.sessionScripts.sessionId)
219 1 : : execution.start(props.modifySessionScripts)}>
220 1 : <span className={styles.action_label}>{execution.busy ? run?.phase === 'stopping' ? 'Stopping' : 'Stop' : disconnected ? 'Connect to run' : 'Execute All'}</span>
221 1 : </dependencies.Button>
222 1 : </ExecuteConnectionGuard>
223 1 : <div className={styles.execution_status} role="status" aria-live="polite" aria-atomic="true" data-testid="execution-status" data-phase={run?.phase}
224 1 : title={run?.error ?? undefined} aria-label={run?.error ? `${statusText}: ${run.error}` : undefined}>
225 1 : {run && <>
226 1 : <AnchoredOverlay
227 1 : open={executionDetailsOpen} onOpen={() => setExecutionDetailsOpen(true)} onClose={() => setExecutionDetailsOpen(false)}
228 1 : side={AnchorSide.OutsideBottom} align={AnchorAlignment.Start} anchorOffset={4}
229 1 : renderAnchor={anchorProps => <dependencies.Button {...anchorProps}
230 1 : size={ButtonSize.Small} variant={ButtonVariant.Invisible} aria-label="Show session execution details" data-testid="execution-details-toggle"
231 1 : leadingVisual={<span className={styles.execution_indicator} aria-hidden="true" data-testid="execution-indicator"><StatusIndicator width="16px" height="16px" status={execution.busy
232 1 : ? IndicatorStatus.Running : run.phase === 'failed' ? IndicatorStatus.Failed
233 1 : : run.phase === 'succeeded' ? IndicatorStatus.Succeeded : IndicatorStatus.Skip} /></span>}>
234 1 : <span className={styles.execution_progress}>{statusText}</span>
235 1 : </dependencies.Button>}>
236 1 : <section className={styles.execution_error_card} role="dialog" aria-label="Session execution details" data-testid="execution-details">
237 1 : <header className={styles.execution_error_header}>
238 1 : <h2 className={styles.execution_details_title}>{run.phase === 'failed' ? 'Execution failed' : 'Session execution'}</h2>
239 1 : <dependencies.IconButton size={ButtonSize.Small} variant={ButtonVariant.Invisible}
240 1 : aria-label="Close session execution details" data-testid="execution-details-close" onClick={() => setExecutionDetailsOpen(false)}>
241 1 : <CloseIcon size={16} />
242 1 : </dependencies.IconButton>
243 1 : </header>
244 1 : <div className={styles.execution_details_body}>
245 1 : <div>{statusText}</div>
246 1 : {run.activeFileName && <div>{run.activeFileName} · statement {run.activeStatementIndex}/{run.activeStatementCount}</div>}
247 1 : <div>{run.completedStatements}/{run.totalStatements} statements completed</div>
248 1 : {run.error && <div className={styles.execution_error_message}>{run.error}</div>}
249 1 : </div>
250 1 : </section>
251 1 : </AnchoredOverlay>
252 1 : </>}
253 1 : </div>
254 1 : </div>}
255 1 : <div className={styles.header_actions}>
256 : {/* The connections of the session, the user sees which one is not connected */}
257 1 : <dependencies.SessionConnectionButtons connections={barConnections} attention={connectionAttention.active} />
258 1 : <AgentSessionMenu sessionId={props.sessionScripts.sessionId} labelClassName={styles.action_label} />
259 1 : {!ephemeral && <dependencies.Button ref={shareTriggerRef} size={ButtonSize.Small} variant={ButtonVariant.Invisible} leadingVisual={LinkIcon} aria-label="Share as URL" data-testid="share-url" aria-expanded={shareOpen} onClick={() => {
260 1 : setExportOpen(false);
261 1 : setShareOpen(value => !value);
262 1 : }}>
263 1 : <span className={styles.action_label}>Share as URL</span>
264 1 : </dependencies.Button>}
265 1 : <dependencies.SessionURLShareOverlay sessionId={props.sessionScripts.sessionId} isOpen={shareOpen} setIsOpen={setShareOpen} anchorRef={shareTriggerRef} />
266 1 : {archiveExport && !ephemeral && (
267 1 : <>
268 1 : <dependencies.Button ref={exportTriggerRef} size={ButtonSize.Small} variant={ButtonVariant.Invisible} leadingVisual={FileZipIcon} aria-label="Export .dashql" data-testid="export-session" aria-expanded={exportOpen} onClick={() => {
269 1 : setShareOpen(false);
270 1 : setExportOpen(value => !value);
271 1 : }}>
272 1 : <span className={styles.action_label}>Export .dashql</span>
273 1 : </dependencies.Button>
274 1 : <dependencies.SessionFileSaveOverlay
275 1 : isOpen={exportOpen}
276 1 : setIsOpen={setExportOpen}
277 1 : anchorRef={exportTriggerRef}
278 1 : sessionScripts={props.sessionScripts}
279 1 : fileName={props.sessionScripts.name ?? props.sessionScripts.sessionMetadata.originalFileName ?? 'session'}
280 1 : />
281 1 : </>
282 : )}
283 1 : </div>
284 1 : </>}
285 1 : </header>
286 1 : <aside className={styles.navigation_sidebar}>
287 1 : {workbench(false)}
288 1 : </aside>
289 1 : <main className={styles.body_container} id="session-body">
290 1 : <div className={styles.content_container}>
291 1 : <div className={feedActive ? styles.feed_layer : styles.feed_layer_hidden}>
292 1 : <dependencies.SessionFeed
293 1 : sessionScripts={props.sessionScripts}
294 1 : modifySessionScripts={props.modifySessionScripts}
295 1 : draftRenameRef={draftRenameRef}
296 1 : active={feedActive}
297 1 : revealRequest={revealRequest}
298 1 : conn={props.connection}
299 1 : scriptConnections={props.scriptConnections}
300 1 : />
301 1 : </div>
302 1 : {overviewConnectionId != null && (
303 1 : <dependencies.ConnectionOverviewPage
304 1 : connectionId={overviewConnectionId}
305 1 : onClose={closeOverview}
306 1 : />
307 : )}
308 1 : {dashboardActive && !overviewActive && (
309 1 : <dependencies.Dashboard
310 1 : sessionScripts={props.sessionScripts}
311 1 : modifySessionScripts={props.modifySessionScripts}
312 1 : scriptConnections={scriptConnections}
313 1 : draftRenameRef={draftRenameRef}
314 1 : />
315 : )}
316 1 : {dataflowActive && !overviewActive && (
317 1 : <div className={styles.dataflow_layout}>
318 1 : <div className={styles.dataflow_canvas}>
319 1 : <dependencies.DataflowCanvas
320 1 : key={props.sessionScripts.sessionId}
321 1 : sessionScripts={props.sessionScripts}
322 1 : modifySessionScripts={props.modifySessionScripts}
323 1 : scriptConnections={scriptConnections}
324 1 : draftRenameRef={draftRenameRef}
325 1 : onInspect={inspectScript}
326 1 : />
327 1 : </div>
328 1 : {showDetails && (
329 1 : <dependencies.ScriptEditorDialog
330 1 : key={details.scriptId}
331 1 : sessionScripts={props.sessionScripts}
332 1 : modifySessionScripts={props.modifySessionScripts}
333 1 : scriptKey={details.scriptId}
334 1 : scriptConnections={scriptConnections}
335 1 : draftRenameRef={draftRenameRef}
336 1 : onNavigateToScript={inspectScript}
337 1 : onClose={closeDetails}
338 1 : />
339 : )}
340 1 : </div>
341 : )}
342 1 : </div>
343 1 : </main>
344 1 : {navigationDrawerOpen && (
345 1 : <dependencies.SessionNavigationDrawer open onClose={() => setNavigationDrawerOpen(false)} returnFocusRef={navigationDrawerTriggerRef}>
346 1 : {workbench(true)}
347 1 : </dependencies.SessionNavigationDrawer>
348 : )}
349 1 : </div>
350 1 : </SessionConnectionAttentionContext.Provider>
351 : );
352 1 : };
|