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

            Line data    Source code
       1              : import * as React from 'react';
       2              : import * as styles from './agent_view.module.css';
       3              : 
       4              : import { CheckIcon, SymbolIcon, XIcon } from '../ui/foundations/symbol_icon.js';
       5              : import { AnchorAlignment, AnchorSide } from '../ui/foundations/anchored_position.js';
       6              : import { AnchoredOverlay } from '../ui/foundations/anchored_overlay.js';
       7              : import { OverlaySize } from '../ui/foundations/overlay.js';
       8              : import { Button, ButtonSize, ButtonVariant, IconButton } from '../ui/foundations/button.js';
       9              : import { ButtonGroup } from '../ui/foundations/button_group.js';
      10              : import { SegmentedControl, SegmentedControlSize } from '../ui/foundations/segmented_control.js';
      11              : import { TextInput, TextInputValidationStatus } from '../ui/foundations/text_input.js';
      12              : import { AgentConnectionStatus } from './agent_client.js';
      13              : import type { AgentServerInfo } from './agent_protocol.js';
      14              : import { stringifyError } from '../platform/logger/logger.js';
      15              : import { DASHQL_AGENT_PORT } from '../globals.js';
      16              : import { useAgentBridge, type AgentBridge } from './agent_bridge.js';
      17              : 
      18            1 : const UndoIcon = SymbolIcon('undo_16');
      19            1 : const SyncIcon = SymbolIcon('sync_16');
      20              : 
      21              : /// The connection state of the agent server
      22            1 : function statusText(bridge: AgentBridge): [string, string] {
      23            1 :     if (!bridge.supported) return ['unsupported', 'Not reachable from this browser'];
      24            1 :     switch (bridge.status) {
      25            1 :         case AgentConnectionStatus.Connected: return ['connected', 'Connected'];
      26            1 :         case AgentConnectionStatus.Connecting: return ['connecting', 'Looking for the server'];
      27            0 :         case AgentConnectionStatus.Incompatible: return ['incompatible', 'Server version differs, update DashQL or reload'];
      28            1 :         default: return ['off', 'Not connected'];
      29            1 :     }
      30            1 : }
      31              : 
      32              : /// Parse a port, null if it is invalid
      33            1 : function parsePort(text: string): number | null {
      34            1 :     const port = Number(text.trim());
      35            1 :     return Number.isInteger(port) && port > 0 && port < 65536 ? port : null;
      36            1 : }
      37              : 
      38              : /// A setting of the picked harness: its default and the values of the config.json of the agent server
      39            1 : function HarnessChoice(props: { label: string; values: string[]; picked: string | null; onPick: (value: string | null) => void }) {
      40            1 :     if (props.values.length === 0) return null;
      41            1 :     const options = [null, ...props.values];
      42            1 :     return (
      43            1 :         <div className={styles.row}>
      44            1 :             <div className={styles.row_label}>{props.label}</div>
      45            1 :             <div className={styles.row_value}>
      46            1 :                 <SegmentedControl aria-label={props.label} size={SegmentedControlSize.Small} onChange={index => props.onPick(options[index])}>
      47            1 :                     {options.map(value => (
      48            1 :                         <SegmentedControl.Button
      49            1 :                             key={value ?? ''}
      50            1 :                             selected={value === props.picked}
      51            1 :                             data-testid={`agent-settings-${props.label.toLowerCase()}`}
      52            1 :                             data-value={value ?? ''}
      53              :                         >
      54            1 :                             {value ?? 'Default'}
      55            1 :                         </SegmentedControl.Button>
      56            1 :                     ))}
      57            1 :                 </SegmentedControl>
      58            1 :             </div>
      59            1 :         </div>
      60              :     );
      61            1 : }
      62              : 
      63              : /// The agent bridge: the agent server with its port and harness, the sessions with agent access, the turns and
      64              : /// the clients of the server.
      65              : /// The harness commands live in the config.json of the agent server, a page only picks among them and must
      66              : /// not be able to choose the programs that run on the machine. The same holds for the models and efforts of a harness.
      67              : /// The picks are settings of the app.
      68            1 : export function AgentView(props: { onClose: () => void }) {
      69            1 :     const bridge = useAgentBridge();
      70            1 :     const [info, setInfo] = React.useState<AgentServerInfo | null>(null);
      71            1 :     const [error, setError] = React.useState<string | null>(null);
      72            1 :     const [portDraft, setPortDraft] = React.useState<string>(String(bridge?.port ?? DASHQL_AGENT_PORT));
      73            1 :     React.useEffect(() => setPortDraft(String(bridge?.port ?? DASHQL_AGENT_PORT)), [bridge?.port]);
      74              : 
      75              :     // The view shows the agent server even while no session has agent access
      76            1 :     React.useEffect(() => bridge?.hold(), [bridge?.hold]);
      77              : 
      78            1 :     const connected = bridge?.status === AgentConnectionStatus.Connected;
      79            1 :     const refresh = React.useCallback(() => {
      80            1 :         if (bridge == null || !connected) {
      81            1 :             setInfo(null);
      82            1 :             return;
      83            1 :         }
      84            1 :         bridge.info().then(
      85            1 :             next => { setInfo(next); setError(null); },
      86            0 :             e => setError(stringifyError(e)),
      87            1 :         );
      88            1 :     }, [bridge, connected]);
      89            1 :     React.useEffect(refresh, [refresh]);
      90              : 
      91            1 :     if (bridge == null) return null;
      92            1 :     const [status, text] = statusText(bridge);
      93            1 :     const portValue = parsePort(portDraft);
      94            1 :     const pickedHarness = bridge.harness ?? info?.defaultHarness;
      95            1 :     const pickedHarnessInfo = info?.harnesses.find(h => h.name === pickedHarness) ?? null;
      96              : 
      97            1 :     return (
      98            1 :         <div className={styles.settings_root}>
      99            1 :             <div className={styles.header_container}>
     100            1 :                 <div className={styles.header_left_container}>
     101            1 :                     <div className={styles.title}>Agent</div>
     102            1 :                     <div className={styles.header_status} data-testid="agent-settings-status">
     103            1 :                         <span className={styles.dot} data-status={status} />
     104            1 :                         {text}
     105            1 :                     </div>
     106            1 :                 </div>
     107            1 :                 <div className={styles.header_right_container}>
     108            1 :                     <IconButton variant={ButtonVariant.Invisible} aria-label="Refresh" onClick={refresh} disabled={!connected}>
     109            1 :                         <SyncIcon />
     110            1 :                     </IconButton>
     111            1 :                     <IconButton variant={ButtonVariant.Invisible} aria-label="close-overlay" onClick={props.onClose}>
     112            1 :                         <XIcon />
     113            1 :                     </IconButton>
     114            1 :                 </div>
     115            1 :             </div>
     116            1 :             <div className={styles.body}>
     117            1 :                 <section className={styles.section} aria-label="Agent server">
     118            1 :                     <div className={styles.row}>
     119            1 :                         <div className={styles.row_start}>
     120            1 :                             {info != null && (
     121            1 :                                 <SegmentedControl
     122            1 :                                     aria-label="Harness"
     123            1 :                                     size={SegmentedControlSize.Small}
     124            1 :                                     onChange={index => {
     125            1 :                                         const name = info.harnesses[index].name;
     126            1 :                                         bridge.setHarness(name === info.defaultHarness ? null : name);
     127            1 :                                     }}
     128              :                                 >
     129            1 :                                     {info.harnesses.map(harness => (
     130            1 :                                         <SegmentedControl.Button
     131            1 :                                             key={harness.name}
     132            1 :                                             selected={harness.name === pickedHarness}
     133            1 :                                             disabled={harness.program == null}
     134            1 :                                             title={harness.program == null ? `${harness.command[0]} is not installed` : undefined}
     135            1 :                                             data-testid="agent-settings-harness"
     136            1 :                                             data-harness={harness.name}
     137              :                                         >
     138            1 :                                             {harness.name}
     139            1 :                                         </SegmentedControl.Button>
     140            1 :                                     ))}
     141            1 :                                 </SegmentedControl>
     142              :                             )}
     143            1 :                         </div>
     144            1 :                         <div className={styles.row_value}>
     145            1 :                             <label id="agent-setting-port" className={styles.row_detail}>Port</label>
     146            1 :                             <TextInput
     147            1 :                                 className={styles.port_input}
     148            1 :                                 aria-labelledby="agent-setting-port"
     149            1 :                                 title={portValue == null ? 'A port between 1 and 65535' : 'The port of the agent server on 127.0.0.1'}
     150            1 :                                 value={portDraft}
     151            1 :                                 onChange={e => setPortDraft(e.target.value)}
     152            1 :                                 validationStatus={portValue == null ? TextInputValidationStatus.Error : undefined}
     153            1 :                                 aria-invalid={portValue == null}
     154            1 :                                 data-testid="agent-settings-port"
     155            1 :                             />
     156            1 :                             <ButtonGroup>
     157            1 :                                 <IconButton
     158            1 :                                     size={ButtonSize.Small}
     159            1 :                                     aria-label="Apply the port"
     160            1 :                                     disabled={portValue == null || portValue === bridge.port}
     161            1 :                                     onClick={() => bridge.setPort(portValue === DASHQL_AGENT_PORT ? null : portValue)}
     162              :                                 >
     163            1 :                                     <CheckIcon />
     164            1 :                                 </IconButton>
     165            1 :                                 <IconButton
     166            1 :                                     size={ButtonSize.Small}
     167            1 :                                     aria-label="Reset to the default port"
     168            1 :                                     disabled={bridge.port === DASHQL_AGENT_PORT && portDraft === String(DASHQL_AGENT_PORT)}
     169            1 :                                     onClick={() => { setPortDraft(String(DASHQL_AGENT_PORT)); bridge.setPort(null); }}
     170              :                                 >
     171            1 :                                     <UndoIcon />
     172            1 :                                 </IconButton>
     173            1 :                             </ButtonGroup>
     174            1 :                         </div>
     175            1 :                     </div>
     176            1 :                     {pickedHarnessInfo != null && (
     177              :                         // Agent servers that predate models and efforts omit them
     178            1 :                         <>
     179            1 :                             <HarnessChoice label="Model" values={pickedHarnessInfo.models ?? []} picked={bridge.model} onPick={bridge.setModel} />
     180            1 :                             <HarnessChoice label="Effort" values={pickedHarnessInfo.efforts ?? []} picked={bridge.effort} onPick={bridge.setEffort} />
     181            1 :                         </>
     182              :                     )}
     183            1 :                     {error != null && <div className={styles.error}>{error}</div>}
     184            1 :                     {info == null && bridge.supported && (
     185            1 :                         <div className={styles.hint}>
     186            1 :                             Start the agent server with <code>dashql agent serve</code> on this machine.
     187            1 :                         </div>
     188              :                     )}
     189            1 :                 </section>
     190              : 
     191            1 :                 <section className={styles.section} aria-labelledby="agent-section-sessions">
     192            1 :                     <h3 id="agent-section-sessions" className={styles.section_title}>Sessions</h3>
     193            1 :                     {bridge.accessSessions.map(session => (
     194            1 :                         <div key={session.id} className={styles.row} data-testid="agent-settings-session">
     195            1 :                             <div className={styles.row_label}>
     196            1 :                                 {session.name != null && session.name.length > 0 ? session.name : 'Unnamed session'}
     197            1 :                                 <span className={styles.row_detail}>{session.id}</span>
     198            1 :                             </div>
     199            1 :                             <div className={styles.row_value}>
     200            1 :                                 <Button size={ButtonSize.Small} onClick={() => bridge.setAccess(session.id, false)}>Disconnect</Button>
     201            1 :                             </div>
     202            1 :                         </div>
     203            1 :                     ))}
     204            1 :                     {bridge.accessSessions.length === 0 && <div className={styles.hint}>No session has agent access.</div>}
     205            1 :                 </section>
     206              : 
     207            1 :                 {info != null && (
     208            1 :                     <section className={styles.section} aria-labelledby="agent-section-turns">
     209            1 :                         <h3 id="agent-section-turns" className={styles.section_title}>Turns</h3>
     210            1 :                         {info.turns.map(turn => (
     211            1 :                             <div key={turn.turnId} className={styles.row}>
     212            1 :                                 <div className={styles.row_label}>
     213            1 :                                     {[turn.harness, turn.model, turn.effort].filter(part => part != null).join(' ')} ยท {turn.status}
     214            1 :                                     <span className={styles.row_detail}>{turn.state}</span>
     215            1 :                                 </div>
     216            1 :                                 <div className={styles.row_value}>
     217            1 :                                     <Button size={ButtonSize.Small} onClick={() => { void bridge.cancel(turn.session).then(refresh); }}>Stop</Button>
     218            1 :                                 </div>
     219            1 :                             </div>
     220            1 :                         ))}
     221            1 :                         {info.turns.length === 0 && <div className={styles.hint}>No agent is working.</div>}
     222            1 :                     </section>
     223              :                 )}
     224              : 
     225            1 :                 {info != null && (
     226            1 :                     <section className={styles.section} aria-labelledby="agent-section-clients">
     227            1 :                         <h3 id="agent-section-clients" className={styles.section_title}>Clients</h3>
     228            1 :                         {info.clients.map(client => (
     229            1 :                             <div key={client.id} className={styles.row}>
     230            1 :                                 <div className={styles.row_label}>
     231            1 :                                     {client.kind}
     232            1 :                                     <span className={styles.row_detail}>{client.origin}</span>
     233            1 :                                 </div>
     234            1 :                                 <div className={styles.row_value}>
     235            1 :                                     <span className={styles.row_detail}>{client.sessions.length} session{client.sessions.length === 1 ? '' : 's'}</span>
     236            1 :                                 </div>
     237            1 :                             </div>
     238            1 :                         ))}
     239            1 :                     </section>
     240              :                 )}
     241            1 :             </div>
     242            1 :         </div>
     243              :     );
     244            1 : }
     245              : 
     246              : type AgentViewOverlayProps = {
     247              :     isOpen: boolean;
     248              :     onClose: () => void;
     249              :     renderAnchor: (p: object) => React.ReactElement;
     250              :     side?: AnchorSide;
     251              :     align?: AnchorAlignment;
     252              :     anchorOffset?: number;
     253              : }
     254            1 : export function AgentViewOverlay(props: AgentViewOverlayProps) {
     255            1 :     return (
     256            1 :         <AnchoredOverlay
     257            1 :             open={props.isOpen}
     258            1 :             onClose={props.onClose}
     259            1 :             renderAnchor={props.renderAnchor}
     260            1 :             side={props.side}
     261            1 :             align={props.align}
     262            1 :             anchorOffset={props.anchorOffset}
     263            1 :             overlayProps={{
     264            1 :                 width: OverlaySize.L,
     265            1 :                 maxHeight: OverlaySize.L,
     266            1 :             }}
     267              :         >
     268            1 :             <AgentView onClose={props.onClose} />
     269            1 :         </AnchoredOverlay>
     270              :     );
     271            1 : }
        

Generated by: LCOV version 2.0-1