LCOV - code coverage report
Current view: top level - dashql-app/src/agent - agent_session_menu.tsx Coverage Total Hit
Test: DashQL 915828b Lines: 82.9 % 117 97
Test Date: 2026-11-06 19:30:32

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

Generated by: LCOV version 2.0-1