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

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

Generated by: LCOV version 2.0-1