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