Line data Source code
1 : import * as React from 'react';
2 : import * as styles from './agent_prompt_panel.module.css';
3 :
4 : import { ButtonSize, ButtonVariant, IconButton } from '../ui/foundations/button.js';
5 : import { IndicatorStatus, StatusIndicator } from '../ui/foundations/status_indicator.js';
6 : import { ChevronRightIcon, SquareFillIcon, SymbolIcon } from '../ui/foundations/symbol_icon.js';
7 : import { AgentConnectionStatus } from './agent_client.js';
8 : import type { AgentSessionContext } from './agent_protocol.js';
9 : import { stringifyError } from '../platform/logger/logger.js';
10 : import { useLogger } from '../platform/logger/logger_provider.js';
11 : import type { AgentBridge } from './agent_bridge.js';
12 : import { isAgentAnswering, type AgentQuestion } from './use_agent_questions.js';
13 :
14 1 : const LOG_CTX = 'agent_prompt_panel';
15 :
16 1 : const SparklesFillIcon = SymbolIcon('sparkles_fill_16');
17 1 : const CrossIcon = SymbolIcon('x_16');
18 :
19 : /// A script that the agent wrote
20 : export interface ComposerAgentScript {
21 : scriptKey: number;
22 : /// The display name of the script
23 : scriptName: string;
24 : }
25 :
26 : /// A question of the session that the agent answers or answered
27 : export interface ComposerAgentTurn {
28 : questionId: number;
29 : /// The question with the status, the final message or the error of the agent
30 : question: AgentQuestion;
31 : /// The scripts of the session that the agent wrote
32 : scripts: ComposerAgentScript[];
33 : }
34 :
35 : /// The agent of a session with agent access, as the composer shows it
36 : export interface ComposerAgent {
37 : /// Is the agent server connected?
38 : connected: boolean;
39 : /// The port the page looks for the agent server on
40 : port: number;
41 : /// The harness context of the session, null while unknown
42 : context: AgentSessionContext | null;
43 : /// The questions of the session, oldest first
44 : turns: ComposerAgentTurn[];
45 : /// Stop the agent working on a question
46 : onStop: (questionId: number) => void;
47 : /// Dismiss the final message or the error of a question
48 : onDismiss: (questionId: number) => void;
49 : /// Scroll to a script that the agent wrote
50 : onShowScript: (scriptKey: number) => void;
51 : }
52 :
53 : /// Is the agent working on a question of the session?
54 1 : export function isAgentWorking(agent: ComposerAgent): boolean {
55 1 : return agent.turns.some(turn => isAgentAnswering(turn.question));
56 1 : }
57 :
58 : /// The name of a harness as users know it
59 1 : export function harnessLabel(harness: string): string {
60 1 : switch (harness) {
61 1 : case 'claude': return 'Claude Code';
62 1 : case 'codex': return 'Codex';
63 1 : case 'opencode': return 'opencode';
64 1 : default: return harness;
65 1 : }
66 1 : }
67 :
68 : /// Follow the harness context of a session while the agent server is connected.
69 : /// Fetched again when the refresh key changes, e.g. once a turn finished and the harness session is known.
70 1 : export function useAgentSessionContext(bridge: AgentBridge | null, sessionId: string, refreshKey: number): AgentSessionContext | null {
71 1 : const logger = useLogger();
72 1 : const [context, setContext] = React.useState<AgentSessionContext | null>(null);
73 1 : const connected = bridge != null && bridge.hasAccess(sessionId) && bridge.status === AgentConnectionStatus.Connected;
74 1 : const fetchContext = bridge?.context;
75 1 : React.useEffect(() => {
76 1 : if (!connected || fetchContext == null) {
77 1 : setContext(null);
78 1 : return;
79 1 : }
80 1 : let current = true;
81 1 : fetchContext(sessionId).then(
82 1 : next => { if (current) setContext(next); },
83 0 : e => logger.warn('Reading the agent context failed', { error: stringifyError(e) }, LOG_CTX),
84 1 : );
85 1 : return () => { current = false; };
86 1 : }, [connected, fetchContext, sessionId, refreshKey, logger]);
87 1 : return context;
88 1 : }
89 :
90 : /// The final message of the agent as plain text, without the markers of the markdown that harnesses like to write
91 1 : export function plainAgentNote(note: string): string {
92 1 : return note
93 1 : .replace(/^#{1,6}\s+/gm, '')
94 1 : .replace(/\*\*(.+?)\*\*/g, '$1')
95 1 : .replace(/__(.+?)__/g, '$1')
96 1 : .replace(/`([^`\n]+)`/g, '$1')
97 1 : .replace(/\n{3,}/g, '\n\n')
98 1 : .trim();
99 1 : }
100 :
101 : /// A question of the session with the answer of the agent.
102 : /// A collapsed turn shows the question in one line, an expanded turn shows the question, the status line, the final
103 : /// message and the error in full.
104 1 : const AgentTurnItem: React.FC<{ turn: ComposerAgentTurn; agent: ComposerAgent; expanded: boolean; onToggle: () => void }> = ({ turn, agent, expanded, onToggle }) => {
105 1 : const { questionId, question } = turn;
106 1 : const working = isAgentAnswering(question);
107 1 : const failed = !working && question.error != null;
108 1 : const indicator = working ? IndicatorStatus.Running : (failed ? IndicatorStatus.Failed : IndicatorStatus.Succeeded);
109 1 : const note = React.useMemo(() => (question.note != null ? plainAgentNote(question.note) : null), [question.note]);
110 1 : const questionTextId = React.useId();
111 1 : return (
112 1 : <li
113 1 : className={styles.turn}
114 1 : data-state={working ? 'working' : (failed ? 'failed' : 'done')}
115 1 : data-expanded={expanded ? '' : undefined}
116 1 : data-testid="composer-agent-turn"
117 : >
118 1 : <div className={styles.turn_header}>
119 1 : <button
120 1 : type="button"
121 1 : className={styles.turn_toggle}
122 1 : aria-expanded={expanded}
123 1 : aria-labelledby={questionTextId}
124 1 : data-testid="composer-agent-turn-toggle"
125 1 : onClick={onToggle}
126 : >
127 1 : <ChevronRightIcon size={12} className={styles.turn_chevron} />
128 1 : <span className={styles.turn_indicator}>
129 1 : <StatusIndicator status={indicator} width="12px" height="12px" />
130 1 : </span>
131 1 : </button>
132 : {/* The question is text that users can select and copy, not a part of the button.
133 : A click expands a collapsed turn, the chevron collapses an expanded one. */}
134 1 : <span
135 1 : id={questionTextId}
136 1 : className={styles.turn_question}
137 1 : title={expanded ? undefined : question.question}
138 1 : data-testid="composer-agent-turn-question"
139 1 : onClick={expanded ? undefined : onToggle}
140 : >
141 1 : {question.question}
142 1 : </span>
143 1 : {turn.scripts.map(script => (
144 1 : <button
145 1 : key={script.scriptKey}
146 1 : type="button"
147 1 : className={styles.turn_script}
148 1 : aria-label={`Show the script ${script.scriptName}`}
149 1 : data-testid="composer-agent-script"
150 1 : onClick={() => agent.onShowScript(script.scriptKey)}
151 : >
152 1 : {script.scriptName}
153 1 : </button>
154 1 : ))}
155 1 : {working ? (
156 1 : <IconButton
157 1 : variant={ButtonVariant.Invisible}
158 1 : size={ButtonSize.Small}
159 1 : aria-label="Stop the agent"
160 1 : data-testid="question-stop"
161 1 : onClick={() => agent.onStop(questionId)}
162 : >
163 1 : <SquareFillIcon size={12} />
164 1 : </IconButton>
165 : ) : (
166 1 : <IconButton
167 1 : variant={ButtonVariant.Invisible}
168 1 : size={ButtonSize.Small}
169 1 : aria-label={failed ? 'Dismiss the error' : 'Dismiss the answer'}
170 1 : data-testid="question-dismiss"
171 1 : onClick={() => agent.onDismiss(questionId)}
172 : >
173 1 : <CrossIcon size={14} />
174 1 : </IconButton>
175 : )}
176 1 : </div>
177 1 : {expanded && working && question.status != null && <div className={styles.turn_status} role="status">{question.status}</div>}
178 1 : {expanded && failed && <div className={styles.turn_error} role="alert">{question.error}</div>}
179 1 : {expanded && !working && !failed && note != null && <div className={styles.turn_note} role="note">{note}</div>}
180 1 : </li>
181 : );
182 1 : };
183 :
184 : /// Is a turn expanded unless the user toggled it? The latest turn and the turns the agent works on are.
185 1 : function isTurnExpandedByDefault(turn: ComposerAgentTurn, index: number, turns: readonly ComposerAgentTurn[]): boolean {
186 1 : return index === turns.length - 1 || isAgentAnswering(turn.question);
187 1 : }
188 :
189 : /// The agent on top of the composer: the harness, the connection to the agent server and the questions of the
190 : /// session with their status lines, final messages, errors and the scripts the agent wrote.
191 : /// The questions scroll within a fixed height, each one expands and collapses.
192 : /// The scripts only show the edits of the agent.
193 1 : export const AgentPromptPanel: React.FC<{ agent: ComposerAgent }> = ({ agent }) => {
194 1 : const working = isAgentWorking(agent);
195 : // The turns that the user expanded or collapsed
196 1 : const [toggled, setToggled] = React.useState<ReadonlyMap<number, boolean>>(() => new Map());
197 1 : const toggle = React.useCallback((questionId: number, expanded: boolean) => {
198 1 : setToggled(prev => new Map(prev).set(questionId, expanded));
199 1 : }, []);
200 : // The turns scroll within a fixed height, a new question scrolls them to the bottom
201 1 : const turnsRef = React.useRef<HTMLUListElement>(null);
202 1 : const latestQuestionId = agent.turns.length > 0 ? agent.turns[agent.turns.length - 1].questionId : null;
203 1 : React.useLayoutEffect(() => {
204 1 : const list = turnsRef.current;
205 1 : if (list != null) list.scrollTop = list.scrollHeight;
206 1 : }, [latestQuestionId]);
207 1 : let detail: string;
208 1 : if (!agent.connected) {
209 1 : detail = `looking for the agent server on port ${agent.port}`;
210 1 : } else if (working) {
211 1 : detail = 'working';
212 : } else {
213 1 : detail = agent.context?.harnessSession != null ? 'continues the conversation' : 'starts a new conversation';
214 : }
215 1 : return (
216 1 : <div className={styles.panel} role="region" aria-label="Agent" data-testid="composer-agent">
217 1 : <div className={styles.header}>
218 1 : <SparklesFillIcon size={12} className={styles.header_icon} />
219 1 : <span className={styles.header_title}>{agent.context != null ? harnessLabel(agent.context.harness) : 'Agent'}</span>
220 1 : <span className={styles.header_dot} data-connected={agent.connected ? '' : undefined} aria-hidden="true" />
221 1 : <span className={styles.header_detail} data-testid="composer-agent-detail">{detail}</span>
222 1 : </div>
223 1 : {agent.turns.length > 0 && (
224 1 : <ul ref={turnsRef} className={styles.turns} aria-label="Questions">
225 1 : {agent.turns.map((turn, index, turns) => {
226 1 : const expanded = toggled.get(turn.questionId) ?? isTurnExpandedByDefault(turn, index, turns);
227 1 : return (
228 1 : <AgentTurnItem
229 1 : key={turn.questionId}
230 1 : turn={turn}
231 1 : agent={agent}
232 1 : expanded={expanded}
233 1 : onToggle={() => toggle(turn.questionId, !expanded)}
234 1 : />
235 : );
236 1 : })}
237 1 : </ul>
238 : )}
239 1 : </div>
240 : );
241 1 : };
|