Line data Source code
1 : import * as React from 'react';
2 : import * as styles from './agent_session_menu.module.css';
3 :
4 : import * as ActionList from '../ui/foundations/action_list.js';
5 : import { AnchorAlignment, AnchorSide } from '../ui/foundations/anchored_position.js';
6 : import { AnchoredOverlay } from '../ui/foundations/anchored_overlay.js';
7 : import { Button, ButtonSize, ButtonVariant } from '../ui/foundations/button.js';
8 : import { SymbolIcon } from '../ui/foundations/symbol_icon.js';
9 : import { AgentConnectionStatus } from './agent_client.js';
10 : import { useLogger } from '../platform/logger/logger_provider.js';
11 : import { stringifyError } from '../platform/logger/logger.js';
12 : import { useAgentBridge, type AgentBridge } from './agent_bridge.js';
13 :
14 1 : const LOG_CTX = 'agent_session_menu';
15 :
16 1 : const SparklesFillIcon = SymbolIcon('sparkles_fill_16');
17 1 : const PlugIcon = SymbolIcon('plug_16');
18 1 : const SyncIcon = SymbolIcon('sync_16');
19 1 : const CopyIcon = SymbolIcon('copy_16');
20 :
21 : /// The state of the agent bridge of a session, as the menu shows it
22 1 : export enum AgentAccessState {
23 : /// The browser cannot reach the agent server
24 1 : Unsupported = 'unsupported',
25 : /// The session has no agent access
26 1 : Off = 'off',
27 : /// The session has agent access, the page looks for the agent server
28 1 : Connecting = 'connecting',
29 : /// The session has agent access, the agent server is connected
30 1 : Connected = 'connected',
31 1 : }
32 :
33 1 : export function agentAccessState(bridge: AgentBridge, sessionId: string): AgentAccessState {
34 1 : if (!bridge.supported) return AgentAccessState.Unsupported;
35 1 : if (!bridge.hasAccess(sessionId)) return AgentAccessState.Off;
36 1 : return bridge.status === AgentConnectionStatus.Connected ? AgentAccessState.Connected : AgentAccessState.Connecting;
37 1 : }
38 :
39 0 : function quoteShellArg(arg: string): string {
40 0 : return /^[A-Za-z0-9_@%+=:,./-]+$/.test(arg) ? arg : `'${arg.split("'").join("'\\''")}'`;
41 0 : }
42 :
43 : interface Props {
44 : sessionId: string;
45 : /// The class of the button label, the header hides it on narrow screens
46 : labelClassName?: string;
47 : }
48 :
49 : /// The agent access of a session: "Start Session" turns it on right away, "Control Session" turns it off,
50 : /// forgets the agent context or continues in a terminal
51 1 : export const AgentSessionMenu: React.FC<Props> = (props: Props) => {
52 1 : const bridge = useAgentBridge();
53 1 : const logger = useLogger();
54 1 : const [open, setOpen] = React.useState(false);
55 1 : const [notice, setNotice] = React.useState<string | null>(null);
56 1 : const triggerRef = React.useRef<HTMLButtonElement>(null);
57 1 : if (bridge == null) return null;
58 1 : const state = agentAccessState(bridge, props.sessionId);
59 :
60 1 : const close = () => setOpen(false);
61 1 : const resetContext = () => {
62 0 : bridge.reset(props.sessionId).then(
63 0 : () => setNotice('The next question starts a new agent session'),
64 0 : e => setNotice(`Resetting failed: ${stringifyError(e)}`),
65 0 : );
66 0 : };
67 1 : const copyTerminalCommand = () => {
68 0 : bridge.context(props.sessionId).then(context => {
69 0 : if (context.terminalCommand == null) {
70 0 : setNotice('The agent has no session to continue yet');
71 0 : return;
72 0 : }
73 0 : const command = `cd ${quoteShellArg(context.folder)} && ${context.terminalCommand.map(quoteShellArg).join(' ')}`;
74 0 : return navigator.clipboard.writeText(command).then(() => setNotice(`Copied: ${command}`));
75 0 : }).catch(e => {
76 0 : logger.warn('Cannot copy the terminal command', { error: stringifyError(e) }, LOG_CTX);
77 0 : setNotice(`Copying failed: ${stringifyError(e)}`);
78 0 : });
79 0 : };
80 :
81 1 : if (state === AgentAccessState.Unsupported || state === AgentAccessState.Off) {
82 1 : return (
83 1 : <Button
84 1 : variant={ButtonVariant.Invisible}
85 1 : size={ButtonSize.Small}
86 1 : leadingVisual={SparklesFillIcon}
87 1 : aria-label="Start Session"
88 1 : title={state === AgentAccessState.Unsupported
89 1 : ? 'Safari cannot reach the local agent server. Use Chrome, Edge, Firefox or the DashQL desktop app.'
90 1 : : 'Let agents on this machine read and edit the scripts of this session and run its queries'}
91 1 : disabled={state === AgentAccessState.Unsupported}
92 1 : data-testid="agent-access"
93 1 : data-state={state}
94 1 : onClick={() => bridge.setAccess(props.sessionId, true)}
95 : >
96 1 : <span className={props.labelClassName}>Start Session</span>
97 1 : </Button>
98 : );
99 : }
100 :
101 1 : return (
102 1 : <AnchoredOverlay
103 1 : open={open}
104 1 : onOpen={() => { setNotice(null); setOpen(true); }}
105 1 : onClose={close}
106 1 : side={AnchorSide.OutsideBottom}
107 1 : align={AnchorAlignment.End}
108 1 : anchorOffset={4}
109 1 : anchorRef={triggerRef}
110 1 : returnFocusRef={triggerRef}
111 1 : renderAnchor={(anchorProps) => (
112 1 : <Button
113 1 : {...anchorProps}
114 1 : ref={triggerRef}
115 1 : variant={ButtonVariant.Invisible}
116 1 : size={ButtonSize.Small}
117 1 : leadingVisual={SparklesFillIcon}
118 1 : aria-label="Control Session"
119 1 : data-testid="agent-access"
120 1 : data-state={state}
121 : >
122 1 : <span className={props.labelClassName}>Control Session</span>
123 1 : </Button>
124 : )}
125 : >
126 1 : <div className={styles.menu} role="dialog" aria-label="Control agent session">
127 1 : {notice == null && bridge.status === AgentConnectionStatus.Incompatible && (
128 1 : <div className={styles.notice}>The agent server runs another version. Update DashQL or reload the page.</div>
129 : )}
130 1 : {notice != null && <div className={styles.notice}>{notice}</div>}
131 1 : <ActionList.List aria-label="Agent actions">
132 1 : <ActionList.ListItem
133 1 : data-testid="agent-menu-item"
134 1 : data-action="disconnect"
135 1 : onClick={() => { close(); bridge.setAccess(props.sessionId, false); }}
136 : >
137 1 : <ActionList.Leading><PlugIcon size={16} /></ActionList.Leading>
138 1 : <ActionList.ItemText>Disconnect the Session</ActionList.ItemText>
139 1 : </ActionList.ListItem>
140 1 : <ActionList.ListItem
141 1 : data-testid="agent-menu-item"
142 1 : data-action="reset"
143 1 : disabled={state !== AgentAccessState.Connected}
144 1 : onClick={resetContext}
145 : >
146 1 : <ActionList.Leading><SyncIcon size={16} /></ActionList.Leading>
147 1 : <ActionList.ItemText>Reset the Session</ActionList.ItemText>
148 1 : </ActionList.ListItem>
149 1 : <ActionList.ListItem
150 1 : data-testid="agent-menu-item"
151 1 : data-action="terminal"
152 1 : disabled={state !== AgentAccessState.Connected}
153 1 : onClick={copyTerminalCommand}
154 : >
155 1 : <ActionList.Leading><CopyIcon size={16} /></ActionList.Leading>
156 1 : <ActionList.ItemText>Continue in terminal</ActionList.ItemText>
157 1 : </ActionList.ListItem>
158 1 : </ActionList.List>
159 1 : </div>
160 1 : </AnchoredOverlay>
161 : );
162 1 : };
|