From 383871a4b0d8290f24d2ce20a5219be65f1ff5aa Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 10:31:45 +0200 Subject: [PATCH 1/2] Update worklog --- WORKLOG.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/WORKLOG.md b/WORKLOG.md index 429b4b1b2..315d3fffd 100644 --- a/WORKLOG.md +++ b/WORKLOG.md @@ -1,5 +1,24 @@ # Worklog +## 2026-08-12 + +### nx2/blocks/chat — port plan/task/governance client onto the #648 AO architecture + +Rebuilt the plan-mode client (originally PR #522, `feat/plan-tasks`, written against the pre-#648 `ChatController` + inline-approval-card design) on top of the #648 "ao support via config" architecture (`ChatBackend` facade + `` + adapter-shaped tool cards). #522 could not be merged mechanically — the two branches independently implemented plan/approval with incompatible structures. This branch (`feat/plan-tasks-on-648`) supersedes #522 and is the matching client for da-agent #49 (`enter_plan_mode`/`exit_plan_mode` + `data-continuation` + `:::task-item`). + +What it delivers, mapped to da-agent #49's server behaviour: +- **Plan card**: `exit_plan_mode` (approval-gated, carries `{title,tasks[]}`) renders inline in the message stream as `` in every state, Run wired to `approveToolCall`. `renderers.js#renderToolCard` special-cases it; `chat-backend.js#_daAgentPendingApproval` excludes `exit_plan_mode` so it isn't also shown as a generic bottom approval popover. +- **Task progress**: `chat.js#_taskText` concatenates all assistant text; `mergeTaskItemsFromText` (utils/directives.js) merges `:::task-item` status into the plan card by label (last-wins) → Run→Running…→Done. +- **Continuation gate**: continuation-gate backend ported into `chat-controller.js` (`AGENT_EVENT.CONTINUATION`, `_continuationPendingIds`, `continuationPending` on derived cards, `continueExecution`/`stopExecution`) and `utils/stream.js` (forward `data-continuation`). `chat-backend.js#_normalize` derives a `{type:'continuation'}` `pendingInteraction`; `` renders a Continue/Stop card (Enter/Esc); `card-renderers.js#renderContinuationCard`. +- **Governance card**: `evaluate_page` output → `` (loading/error/result) via `renderToolCard`. + +Design notes / decisions: +- Kept the da-agent tool-card rendering in `renderers.js` (which #648 preserved for the da-agent path); only `card-renderers.js` gained the neutral `renderContinuationCard`. AO path untouched. +- `renderMessageContent` still renders directive plan/task-list/governance-evaluation cards for parity, but da-agent main only emits `:::task-item` as text (plan comes via the `exit_plan_mode` tool); the directive renderers are harmless if unused. +- Purely additive over `origin/main` (~+2184/−19 in the chat block). 150/150 chat tests pass; ESLint + Stylelint clean. Brought in #522's `messages/*` card components + CSS and `utils/directives.js`/`utils/tool-name.js` verbatim (already tested). + +Related da-agent work (separate repo/PR): `hotfix/disable-evaluate-continuation-gate` temporarily disables governance's `continuationApprovalPatterns` in prod (the pre-this-client `main` had no UI to service `data-continuation`, so it hung the turn). Once this client ships, revert that hotfix to re-enable the gate. + ## 2026-08-07 ### nx2/utils/api.js — remove stage-content.da.live rewrite workaround From f71c6ede590164606191df48cd0c674caf7ddce5 Mon Sep 17 00:00:00 2001 From: Natalia Venditto Date: Wed, 12 Aug 2026 10:31:45 +0200 Subject: [PATCH 2/2] feat(chat): port plan/task/governance client onto #648 architecture Rebuild PR #522's plan-mode client on top of the #648 AO architecture (ChatBackend + nx-chat-interaction + adapter-shaped tool cards); this supersedes #522 and is the client for da-agent #49. - plan card: exit_plan_mode renders inline as nx-campaign-plan-card in every state; Run wired to approveToolCall; excluded from the generic approval popover - task progress: merge :::task-item status into the plan card by label (last-wins) - continuation gate: handle data-continuation in chat-controller/stream; surface as a {type:'continuation'} interaction; Continue/Stop in nx-chat-interaction - governance: evaluate_page output renders as nx-governance-evaluation-card - add messages/* card components + CSS, utils/directives.js, utils/tool-name.js - tests for the new wiring; 150/150 chat tests pass Co-Authored-By: Claude Opus 4.8 --- nx2/blocks/chat/chat-backend.js | 29 +- nx2/blocks/chat/chat-controller.js | 51 ++ nx2/blocks/chat/chat.js | 20 +- nx2/blocks/chat/constants.js | 32 ++ nx2/blocks/chat/interaction/interaction.js | 16 +- .../chat/messages/campaign-plan-card.css | 83 ++++ .../chat/messages/campaign-plan-card.js | 130 ++++++ .../governance-evaluation-card-data.js | 29 ++ .../messages/governance-evaluation-card.css | 400 ++++++++++++++++ .../messages/governance-evaluation-card.js | 240 ++++++++++ nx2/blocks/chat/messages/messages.css | 93 ++++ nx2/blocks/chat/messages/task-item.css | 65 +++ nx2/blocks/chat/messages/task-item.js | 50 ++ nx2/blocks/chat/messages/task-list.css | 9 + nx2/blocks/chat/messages/task-list.js | 40 ++ nx2/blocks/chat/renderers/card-renderers.js | 22 + nx2/blocks/chat/renderers/renderers.js | 110 ++++- nx2/blocks/chat/utils/directives.js | 66 +++ nx2/blocks/chat/utils/stream.js | 8 + nx2/blocks/chat/utils/tool-name.js | 11 + test/nx2/blocks/chat/chat-backend.test.js | 36 ++ test/nx2/blocks/chat/chat-controller.test.js | 33 +- .../governance-evaluation-card-data.test.js | 95 ++++ .../governance-evaluation-card.test.js | 439 ++++++++++++++++++ .../blocks/chat/renderers/renderers.test.js | 71 +++ test/nx2/blocks/chat/utils/tool-name.test.js | 25 + 26 files changed, 2184 insertions(+), 19 deletions(-) create mode 100644 nx2/blocks/chat/messages/campaign-plan-card.css create mode 100644 nx2/blocks/chat/messages/campaign-plan-card.js create mode 100644 nx2/blocks/chat/messages/governance-evaluation-card-data.js create mode 100644 nx2/blocks/chat/messages/governance-evaluation-card.css create mode 100644 nx2/blocks/chat/messages/governance-evaluation-card.js create mode 100644 nx2/blocks/chat/messages/messages.css create mode 100644 nx2/blocks/chat/messages/task-item.css create mode 100644 nx2/blocks/chat/messages/task-item.js create mode 100644 nx2/blocks/chat/messages/task-list.css create mode 100644 nx2/blocks/chat/messages/task-list.js create mode 100644 nx2/blocks/chat/utils/directives.js create mode 100644 nx2/blocks/chat/utils/tool-name.js create mode 100644 test/nx2/blocks/chat/messages/governance-evaluation-card-data.test.js create mode 100644 test/nx2/blocks/chat/messages/governance-evaluation-card.test.js create mode 100644 test/nx2/blocks/chat/utils/tool-name.test.js diff --git a/nx2/blocks/chat/chat-backend.js b/nx2/blocks/chat/chat-backend.js index bdfdcfb5d..4fb2fe51b 100644 --- a/nx2/blocks/chat/chat-backend.js +++ b/nx2/blocks/chat/chat-backend.js @@ -1,6 +1,7 @@ import ChatController from './chat-controller.js'; import ChatControllerAO from './ao/chat-controller-ao.js'; -import { TOOL_INPUT, TOOL_STATE } from './constants.js'; +import { TOOL_INPUT, TOOL_NAME, TOOL_STATE } from './constants.js'; +import { mcpToolName } from './utils/tool-name.js'; // da-agent's own tool-input schema field names (see constants.js's TOOL_INPUT) — used // only here, to compute the approval-popover summary for da-agent's controller, since @@ -49,31 +50,48 @@ export default class ChatBackend { toolCards, pendingApproval, pendingQuestion, pendingPlanApproval, ...rest } = payload; const approval = this._useAo ? pendingApproval : this._daAgentPendingApproval(toolCards); + // Continuation is da-agent-only (its controller sets card.continuationPending); AO has + // no equivalent, so it's never derived when wrapping AO. + const continuation = this._useAo ? null : this._daAgentPendingContinuation(toolCards); const pendingInteraction = this._pendingInteraction( approval, pendingQuestion, pendingPlanApproval, + continuation, ); return { ...rest, toolCards, pendingInteraction }; } - _pendingInteraction(approval, question, plan) { + _pendingInteraction(approval, question, plan, continuation) { if (approval) return { type: 'approval', ...approval }; if (question) return { type: 'question', ...question }; if (plan) return { type: 'plan', ...plan }; + if (continuation) return { type: 'continuation', ...continuation }; return null; } + // exit_plan_mode is intentionally excluded: its plan card renders inline in the message + // stream (renderers.js) with its own Run→approve control, so it must not also surface as + // a generic approval popover here. _daAgentPendingApproval(toolCards) { if (!toolCards) return null; for (const [toolCallId, card] of toolCards) { - if (card.state === TOOL_STATE.AWAITING_APPROVAL) { + if (card.state === TOOL_STATE.AWAITING_APPROVAL + && mcpToolName(card.toolName) !== TOOL_NAME.EXIT_PLAN_MODE) { return { toolCallId, toolName: card.toolName, summary: daAgentApprovalSummary(card.input) }; } } return null; } + _daAgentPendingContinuation(toolCards) { + if (!toolCards) return null; + for (const [toolCallId, card] of toolCards) { + if (card.continuationPending) return { toolCallId }; + } + return null; + } + setContext(context) { this._controller.setContext(context); } @@ -92,6 +110,11 @@ export default class ChatBackend { approveToolCall = (...args) => this._controller.approveToolCall(...args); + // Continuation gate (da-agent only): no-ops when wrapping AO's controller. + continueExecution = () => this._controller.continueExecution?.(); + + stopExecution = () => this._controller.stopExecution?.(); + clear() { return this._controller.clear(); } diff --git a/nx2/blocks/chat/chat-controller.js b/nx2/blocks/chat/chat-controller.js index 51db2b54f..8c4d7a1a2 100644 --- a/nx2/blocks/chat/chat-controller.js +++ b/nx2/blocks/chat/chat-controller.js @@ -154,6 +154,8 @@ export default class ChatController { output: part.output, errorText: part.errorText, approvalRequired: part.approvalRequired, + // Ephemeral, transient continuation prompt (never persisted in message history). + continuationPending: this._continuationPendingIds?.has(part.toolCallId) ?? false, }); }); }); @@ -278,6 +280,19 @@ export default class ChatController { return; } + // Post-execution continuation gate: the tool already finished (its result is shown) + // and the server ended the turn. Flag it as awaiting a Continue/Stop decision. + // Ephemeral (UI-only) — nothing is pushed to _messages, so a reload simply drops the + // prompt while the result persists. + if (type === AGENT_EVENT.CONTINUATION) { + const existing = this._findToolPart(toolCallId); + if (!existing) return; + this._continuationPendingIds ??= new Set(); + this._continuationPendingIds.add(toolCallId); + this._update(); + return; + } + // The agent gates this call behind user approval. Auto-approved tools skip // the queue and join the next batch directly. if (type === AGENT_EVENT.TOOL_APPROVAL_REQUEST) { @@ -375,6 +390,42 @@ export default class ChatController { } }; + /** Clear the ephemeral continuation-pending flags (never persisted). */ + _clearContinuationPending() { + this._continuationPendingIds?.clear(); + } + + // Continuation gate — user chose "Continue": resume the agentic loop. The gated tool's + // result is already persisted for the current turn, so re-streaming replays it to the + // agent (keeping the same turnId) and the model picks up where it left off. + continueExecution = async () => { + this._clearContinuationPending(); + this._thinking = true; + this._update(); + try { + await this._stream(this._pageContextForAgent()); + } catch (err) { + if (err.name !== 'AbortError') { + this._messages = [...this._messages, { role: ROLE.ASSISTANT, content: `Error: ${err.message}` }]; + } + } finally { + this._done(); + } + }; + + // Continuation gate — user chose "Stop": record the decision as a user message and halt. + // No re-stream and no assistant reply — the turn simply ends (code-driven, not the LLM). + stopExecution = async () => { + this._clearContinuationPending(); + this._messages = [ + ...this._messages, + { role: ROLE.USER, content: 'User decided not to continue further.' }, + ]; + this._update(); + const room = await this._getRoom(); + saveMessages(room, this._messages, this._sessionId); + }; + // Prune prior-turn non-gated tool reads to bound payload size, mirroring the // old virtual-message pruning. Approval-gated tool parts are kept across turns // so the agent retains the record of destructive actions it took. diff --git a/nx2/blocks/chat/chat.js b/nx2/blocks/chat/chat.js index 0c9f3d825..9c5fa3bbf 100644 --- a/nx2/blocks/chat/chat.js +++ b/nx2/blocks/chat/chat.js @@ -520,6 +520,17 @@ class NxChat extends LitElement { await this._onFilesSelected(accepted); } + // All assistant text across the turn, concatenated in order, so :::task-item directives + // emitted across separate messages/steps can all be found and merged into the plan card + // (mergeTaskItemsFromText keys by label, last status wins). Includes the in-progress + // streaming message (appended to `messages` by onUpdate), so status updates live-render. + get _taskText() { + return (this.messages ?? []) + .filter((m) => m.role === ROLE.ASSISTANT && typeof m.content === 'string') + .map((m) => m.content) + .join('\n') || null; + } + render() { const { view } = this._context ?? {}; const prompts = (this._prompts ?? []) @@ -565,7 +576,12 @@ class NxChat extends LitElement { return renderUiArtifact(msg.uiArtifact, (p) => this._sendPrompt(p, { autoSend: true })); } if (msg.toolCard) return renderToolCard(msg.toolCard); - return renderMessage(msg, this.toolCards); + return renderMessage(msg, this.toolCards, { + streamingText: this._taskText, + onApprove: (id, approved, always) => ( + this._controller.approveToolCall(id, approved, always) + ), + }); })} ${this.thinking && !this.messages?.at(-1)?.streaming && !this.pendingInteraction ? html`
Thinking...
` : nothing} @@ -586,6 +602,8 @@ class NxChat extends LitElement { .onDeclineQuestion=${() => this._controller.declineQuestion()} .onApprovePlan=${() => this._controller.respondToPlanApproval('approve')} .onRejectPlan=${(feedback) => this._controller.respondToPlanApproval('reject', feedback)} + .onContinue=${() => this._controller.continueExecution()} + .onStop=${() => this._controller.stopExecution()} >
's boundary with the outside world — part of * chat's public surface, see docs/chat-ui-component.md ("Events in"/"Events out"). @@ -118,9 +147,12 @@ export { ADD_MENU_ITEMS, AGENT_EVENT, CHAT_EVENT, + DIRECTIVE_TYPE, MENU_OPTIONS, PART_TYPE, + PLAN_RUN_EVENT, ROLE, + TASK_STATUS, TOOL_INPUT, TOOL_NAME, TOOL_SCOPE, diff --git a/nx2/blocks/chat/interaction/interaction.js b/nx2/blocks/chat/interaction/interaction.js index d235f5138..17bf962d2 100644 --- a/nx2/blocks/chat/interaction/interaction.js +++ b/nx2/blocks/chat/interaction/interaction.js @@ -1,6 +1,6 @@ import { LitElement, nothing } from 'da-lit'; import { loadStyle } from '../../../utils/utils.js'; -import { renderApprovalCard } from '../renderers/card-renderers.js'; +import { renderApprovalCard, renderContinuationCard } from '../renderers/card-renderers.js'; import { renderQuestionCard, renderPlanApprovalCard } from '../ao/ao-renderers.js'; const styles = await loadStyle(import.meta.url); @@ -48,6 +48,16 @@ class NxChatInteraction extends LitElement { } _onKeydown = (e) => { + if (this.pending?.type === 'continuation') { + if (e.key === 'Escape') { + e.preventDefault(); + this.onStop?.(); + } else if (e.key === 'Enter') { + e.preventDefault(); + this.onContinue?.(); + } + return; + } if (this.pending?.type !== 'approval') return; const { toolCallId } = this.pending; if (e.key === 'Escape') { @@ -135,6 +145,10 @@ class NxChatInteraction extends LitElement { }); } + if (pending.type === 'continuation') { + return renderContinuationCard(() => this.onContinue?.(), () => this.onStop?.()); + } + return nothing; } } diff --git a/nx2/blocks/chat/messages/campaign-plan-card.css b/nx2/blocks/chat/messages/campaign-plan-card.css new file mode 100644 index 000000000..1b17ac49a --- /dev/null +++ b/nx2/blocks/chat/messages/campaign-plan-card.css @@ -0,0 +1,83 @@ +/* Shared card shell, header, type-label/icon, chevron, and title live in messages.css. */ + +:host { + display: block; +} + +.plan-card { + gap: var(--s2-spacing-200); +} + +.plan-summary { + display: flex; + flex-direction: column; + gap: var(--s2-spacing-200); + cursor: pointer; + list-style: none; +} + +/* ── Body / description area ── */ + +.plan-body { + display: flex; + flex-direction: column; + gap: var(--s2-spacing-100); + padding: var(--s2-spacing-200) var(--s2-spacing-300); +} + +.plan-type-icon { + mask-image: url("/img/icons/s2-icon-aichat-20-n.svg"); +} + +.plan-description { + font-size: var(--s2-body-size-s); + color: var(--s2-gray-700); + margin: 0; + line-height: 1.4; +} + +.plan-header-actions { + display: flex; + align-items: center; + gap: var(--s2-spacing-75); + flex-shrink: 0; +} + +.plan-chevron-icon { + color: var(--s2-gray-700); +} + +.plan-card[open] .plan-tasks-collapsed { + display: none; +} + +/* ── Task area ── */ + +.plan-tasks { + display: flex; + flex-direction: column; + gap: var(--s2-spacing-200); + padding: var(--s2-spacing-200); + margin: 0 var(--s2-spacing-300) var(--s2-spacing-200); + background-color: var(--s2-gray-50, #f8f8f8); + border-radius: var(--s2-corner-radius-500); + border: 1px solid var(--s2-gray-200, #e1e1e1); +} + +.plan-tasks-header { + font-size: var(--s2-body-size-xs); + color: var(--s2-gray-600); + margin-bottom: var(--s2-spacing-50); +} + +.plan-tasks-progress { + font-size: var(--s2-body-size-xs); + color: var(--s2-gray-600); + font-variant-numeric: tabular-nums; +} + +.plan-task-row { + display: flex; + align-items: center; + gap: var(--s2-spacing-200); +} diff --git a/nx2/blocks/chat/messages/campaign-plan-card.js b/nx2/blocks/chat/messages/campaign-plan-card.js new file mode 100644 index 000000000..bf09bd972 --- /dev/null +++ b/nx2/blocks/chat/messages/campaign-plan-card.js @@ -0,0 +1,130 @@ +import { LitElement, html, nothing } from 'da-lit'; +import { loadStyle } from '../../../utils/utils.js'; +import { getConfig } from '../../../scripts/nx.js'; +import { PLAN_RUN_EVENT, TASK_STATUS } from '../constants.js'; +import './task-item.js'; + +const shared = await loadStyle(new URL('./messages.css', import.meta.url).href); +const buttons = await loadStyle(new URL('../../../styles/buttons.css', import.meta.url).href); +const styles = await loadStyle(import.meta.url); +const { codeBase } = getConfig(); + +const icon = (name, className) => html``; + +/** + * — Content Generation Plan card. + * + * Properties: + * plan {Object} + * title {string} Plan title + * description {string} Short description / subtitle + * tasks {Array<{ id, label, status }>} + * + * Events dispatched (bubbles + composed): + * nx-plan-run — user clicked Run + */ +class NxCampaignPlanCard extends LitElement { + static properties = { + plan: { attribute: false }, + }; + + connectedCallback() { + super.connectedCallback(); + this.shadowRoot.adoptedStyleSheets = [shared, buttons, styles]; + } + + _dispatch(eventName) { + this.dispatchEvent(new CustomEvent(eventName, { + bubbles: true, composed: true, detail: { plan: this.plan }, + })); + } + + _findRunningTask(tasks) { + const runningIdx = tasks.findIndex((t) => t.status === TASK_STATUS.RUNNING); + return runningIdx >= 0 ? { task: tasks[runningIdx], current: runningIdx + 1 } : null; + } + + _renderTasksFull(tasks) { + return html` +
+
${tasks.length} Tasks to execute
+ ${tasks.map((task) => html` +
+ +
+ `)} +
+ `; + } + + _renderTasksCollapsed(runningTask, current, total) { + return html` +
+ ${current}/${total} +
+ +
+
+ `; + } + + render() { + const plan = this.plan ?? {}; + const { title = '', description = '', tasks = [] } = plan; + + const running = this._findRunningTask(tasks); + const isRunning = running !== null; + const isAllDone = tasks.length > 0 && tasks.every((t) => t.status === TASK_STATUS.DONE); + const isDone = !isRunning && isAllDone; + let runBtnLabel = 'Run'; + if (isRunning) runBtnLabel = 'Running...'; + else if (isDone) runBtnLabel = 'Done'; + const runBtnClass = `${isRunning ? 'nx-btn-secondary' : 'nx-btn-primary'} plan-btn-run`; + + return html` +
+ +
+ + + Content Generation Plan + +
+ + ${icon('s2-icon-chevrondown-20-n', 'msg-chevron plan-chevron-icon')} +
+
+ +
+

${title}

+ ${description ? html`

${description}

` : nothing} +
+ + ${isRunning + ? this._renderTasksCollapsed(running.task, running.current, tasks.length) + : nothing} +
+ + ${this._renderTasksFull(tasks)} +
+ `; + } +} + +customElements.define('nx-campaign-plan-card', NxCampaignPlanCard); diff --git a/nx2/blocks/chat/messages/governance-evaluation-card-data.js b/nx2/blocks/chat/messages/governance-evaluation-card-data.js new file mode 100644 index 000000000..5671303f6 --- /dev/null +++ b/nx2/blocks/chat/messages/governance-evaluation-card-data.js @@ -0,0 +1,29 @@ +function groupChecksByCategory(evaluations) { + const groups = new Map(); + (evaluations ?? []).forEach((check) => { + const categoryId = check.category_id ?? 'uncategorized'; + const categoryName = check.category ?? 'Uncategorized'; + if (!groups.has(categoryId)) groups.set(categoryId, { categoryId, categoryName, checks: [] }); + groups.get(categoryId).checks.push(check); + }); + return [...groups.values()]; +} + +function sectionSummary(section) { + const successful = section?.successful_checks ?? 0; + const failed = section?.failed_checks ?? 0; + const notApplicable = section?.not_applicable_checks ?? 0; + const error = section?.error_checks ?? 0; + const denominator = successful + failed; + const percent = denominator ? Math.round((successful / denominator) * 100) : 0; + return { + successful, + failed, + notApplicable, + error, + total: successful + failed + notApplicable + error, + percent, + }; +} + +export { groupChecksByCategory, sectionSummary }; diff --git a/nx2/blocks/chat/messages/governance-evaluation-card.css b/nx2/blocks/chat/messages/governance-evaluation-card.css new file mode 100644 index 000000000..6e8e67ab4 --- /dev/null +++ b/nx2/blocks/chat/messages/governance-evaluation-card.css @@ -0,0 +1,400 @@ +/* + * Shared card shell, header, type-label, type-icon base, chevron (+ rotate), + * title, and spinner live in messages.css. + */ + +:host { + display: block; +} + +/* ── Header ── */ + +.ge-header { + cursor: pointer; + list-style: none; +} + +.ge-type-icon { + mask-image: url("/img/icons/s2-icon-checkmarkcircle-20-n.svg"); +} + +.ge-type-icon-error { + mask-image: url("/img/icons/s2-icon-alertdiamondorange-20-n.svg"); +} + +/* ── Body ── */ + +.ge-body { + display: flex; + flex-direction: column; + gap: var(--s2-spacing-200); + padding: var(--s2-spacing-200) var(--s2-spacing-300) var(--s2-spacing-300); +} + +.ge-body.ge-loading { + flex-direction: row; + align-items: center; + gap: var(--s2-spacing-200); +} + +.ge-loading-text { + font-size: var(--s2-body-size-s); + color: var(--s2-gray-600); +} + +.ge-body.ge-error { + flex-direction: row; + align-items: center; + gap: var(--s2-spacing-200); +} + +.ge-error-icon { + flex-shrink: 0; + width: 16px; + height: 16px; + color: var(--s2-red-700, #c9292b); +} + +.ge-error-text { + font-size: var(--s2-body-size-s); + color: var(--s2-gray-800); +} + +.ge-page-url { + font-size: var(--s2-body-size-xs); + color: var(--s2-gray-600); + margin: 0; + word-break: break-all; +} + +/* ── Summary / progress bar (reused at card, text-section, and image-section level) ── */ + +.ge-summary-row { + display: flex; + align-items: center; + justify-content: flex-end; +} + +.ge-passed-badge { + display: flex; + align-items: center; + gap: var(--s2-spacing-50); + font-size: var(--s2-body-size-xs); + color: var(--s2-green-800, #1b7a3e); + font-weight: 500; +} + +.ge-progress-bar { + display: block; + width: 100%; + height: 6px; + border: none; + border-radius: 3px; + overflow: hidden; + margin: var(--s2-spacing-100) 0 var(--s2-spacing-200); + appearance: none; + background: var(--s2-gray-200, #e1e1e1); +} + +.ge-progress-bar::-webkit-progress-bar { + background: var(--s2-gray-200, #e1e1e1); + border-radius: 3px; +} + +.ge-progress-bar::-webkit-progress-value { + background: var(--s2-green-700, #268e49); + border-radius: 3px; +} + +.ge-progress-bar::-moz-progress-bar { + background: var(--s2-green-700, #268e49); + border-radius: 3px; +} + +/* ── Sections (text evaluation / per-image evaluation) ── */ + +.ge-section { + display: flex; + flex-direction: column; + gap: var(--s2-spacing-100); + padding-top: var(--s2-spacing-200); + border-top: 1px solid var(--s2-gray-200, #e1e1e1); +} + +.ge-section-title { + font-size: var(--s2-body-size-s); + font-weight: 600; + color: var(--s2-gray-900); + margin: 0; +} + +.ge-section-empty { + font-size: var(--s2-body-size-s); + color: var(--s2-gray-600); + margin: 0; + font-style: italic; +} + +/* ── Image group (umbrella "Image evaluation") ── */ + +.ge-group-header { + display: flex; + align-items: center; + width: 100%; + gap: var(--s2-spacing-200); + padding: 0; + background: transparent; + cursor: pointer; + text-align: left; + box-sizing: border-box; + border-radius: var(--s2-corner-radius-300); + list-style: none; + + &:hover { + background: var(--s2-gray-75); + } +} + +.ge-group-title-col { + display: flex; + flex-direction: column; + gap: var(--s2-spacing-25, 2px); + flex: 1; +} + +.ge-group-meta { + font-size: var(--s2-body-size-xs); + color: var(--s2-gray-600); +} + +.ge-group-header-right { + display: flex; + align-items: center; + gap: var(--s2-spacing-100); + flex-shrink: 0; +} + +.ge-group-chevron { + width: 14px; + height: 14px; + color: var(--s2-gray-600); + flex-shrink: 0; + transition: transform 0.2s ease; +} + +.ge-image-group[open] .ge-group-chevron { + transform: rotate(180deg); +} + +.ge-image-group .ge-progress-bar { + margin: var(--s2-spacing-100) 0 0; +} + +.ge-image-list { + display: flex; + flex-direction: column; + gap: var(--s2-spacing-300); + margin-top: var(--s2-spacing-200); +} + +.ge-image-section { + display: flex; + flex-direction: column; + gap: var(--s2-spacing-100); +} + +.ge-image-list > .ge-image-section + .ge-image-section { + padding-top: var(--s2-spacing-300); + border-top: 1px solid var(--s2-gray-200, #e1e1e1); +} + +/* ── Image section header ── */ + +.ge-image-header { + display: flex; + align-items: center; + gap: var(--s2-spacing-200); +} + +.ge-image-thumb { + width: 40px; + height: 40px; + object-fit: cover; + border-radius: var(--s2-corner-radius-300); + border: 1px solid var(--s2-gray-200, #e1e1e1); + background: var(--s2-gray-100); +} + +.ge-align-badge { + font-size: var(--s2-body-size-xs); + font-weight: 500; + padding: var(--s2-spacing-50) var(--s2-spacing-100); + border-radius: var(--s2-corner-radius-200, 4px); +} + +.ge-align-badge-pass { + color: var(--s2-green-800, #1b7a3e); + background: var(--s2-green-100, #e3f5e9); +} + +.ge-align-badge-fail { + color: var(--s2-red-700, #c9292b); + background: var(--s2-red-100, #fbe4e4); +} + +/* ── Categories ── */ + +.ge-categories { + display: flex; + flex-direction: column; + gap: var(--s2-spacing-50); + background-color: var(--s2-gray-50, #f8f8f8); + border-radius: var(--s2-corner-radius-500); + border: 1px solid var(--s2-gray-200, #e1e1e1); + overflow: hidden; +} + +.ge-category { + border-bottom: 1px solid var(--s2-gray-200, #e1e1e1); + min-height: var(--s2-spacing-700); + + &:last-child { + border-bottom: none; + } +} + +.ge-cat-header { + display: flex; + align-items: center; + width: 100%; + padding: var(--s2-spacing-400) var(--s2-spacing-300); + background: transparent; + cursor: pointer; + gap: var(--s2-spacing-100); + text-align: left; + box-sizing: border-box; + list-style: none; + + &:hover { + background: var(--s2-gray-75); + } +} + +.ge-cat-name { + flex: 1; + font-size: var(--s2-body-size-s); + color: var(--s2-gray-800); + font-weight: 500; +} + +.ge-cat-summary { + font-size: var(--s2-body-size-xs); + color: var(--s2-gray-600); + white-space: nowrap; +} + +.ge-cat-chevron { + width: 14px; + height: 14px; + color: var(--s2-gray-600); + flex-shrink: 0; + transition: transform 0.2s ease; +} + +.ge-category[open] .ge-cat-chevron { + transform: rotate(180deg); +} + +/* ── Check rows ── */ + +.ge-checks { + list-style: none; + margin: 0; + padding: 0 var(--s2-spacing-200) var(--s2-spacing-200); + display: flex; + flex-direction: column; + gap: var(--s2-spacing-75); +} + +.ge-check-item { + display: flex; + flex-direction: column; +} + +.ge-check-row { + display: flex; + align-items: center; + width: 100%; + gap: var(--s2-spacing-100); + padding: var(--s2-spacing-50) 0; + background: transparent; + cursor: pointer; + text-align: left; + box-sizing: border-box; + list-style: none; + + &:hover { + background: var(--s2-gray-75); + } +} + +.ge-check-icon { + width: 14px; + height: 14px; + flex-shrink: 0; +} + +.ge-check-yes { + color: var(--s2-green-700, #268e49); +} + +.ge-check-no { + color: var(--s2-red-700, #c9292b); +} + +.ge-check-na { + color: var(--s2-gray-500, #909090); +} + +.ge-check-error { + color: var(--s2-orange-700, #b9530f); +} + +.ge-check-label { + flex: 1; + font-size: var(--s2-body-size-xs); + color: var(--s2-gray-700); +} + +.ge-check-chevron { + width: 12px; + height: 12px; + color: var(--s2-gray-500); + flex-shrink: 0; + transition: transform 0.2s ease; +} + +.ge-check-item[open] .ge-check-chevron { + transform: rotate(180deg); +} + +.ge-check-detail { + display: flex; + flex-direction: column; + gap: var(--s2-spacing-100); + padding: 0 0 var(--s2-spacing-100) var(--s2-spacing-400); +} + +.ge-check-detail-block { + margin: 0; + font-size: var(--s2-body-size-xs); + color: var(--s2-gray-700); + line-height: 1.5; +} + +.ge-check-detail-label { + display: block; + font-weight: 600; + color: var(--s2-gray-800); + margin-bottom: var(--s2-spacing-25, 2px); +} diff --git a/nx2/blocks/chat/messages/governance-evaluation-card.js b/nx2/blocks/chat/messages/governance-evaluation-card.js new file mode 100644 index 000000000..beb85f35c --- /dev/null +++ b/nx2/blocks/chat/messages/governance-evaluation-card.js @@ -0,0 +1,240 @@ +import { LitElement, html, nothing } from 'da-lit'; +import { loadStyle } from '../../../utils/utils.js'; +import { getConfig } from '../../../scripts/nx.js'; +import { groupChecksByCategory, sectionSummary } from './governance-evaluation-card-data.js'; + +const shared = await loadStyle(new URL('./messages.css', import.meta.url).href); +const styles = await loadStyle(import.meta.url); +const { codeBase } = getConfig(); + +const ICON_NAMES = { + chevron: 's2-icon-chevrondown-20-n', + check: 's2-icon-checkmark-20-n', + close: 's2-icon-close-20-n', + warning: 's2-icon-alertdiamond-20-n', + na: 's2-icon-circle-20-n', +}; + +const icon = (name, className) => html``; + +class NxGovernanceEvaluationCard extends LitElement { + static properties = { + evaluation: { attribute: false }, + loading: { type: Boolean }, + error: { attribute: false }, + }; + + connectedCallback() { + super.connectedCallback(); + this.shadowRoot.adoptedStyleSheets = [shared, styles]; + } + + _renderCheckIcon(check) { + if (check.error) return icon('warning', 'ge-check-icon ge-check-error'); + if (check.alignment === 'YES') return icon('check', 'ge-check-icon ge-check-yes'); + if (check.alignment === 'NO') return icon('close', 'ge-check-icon ge-check-no'); + return icon('na', 'ge-check-icon ge-check-na'); + } + + _renderSummaryBar(summary) { + return html` + + ${summary.successful}/${summary.successful + summary.failed} passed + + ${summary.percent}% + `; + } + + _renderCheckRow(check) { + return html` +
+ + ${this._renderCheckIcon(check)} + ${check.check_title} + ${icon('chevron', 'ge-check-chevron')} + +
+ ${check.reasoning ? html` +

+ Reasoning + ${check.reasoning} +

+ ` : nothing} + ${check.suggestions ? html` +

+ Suggestion + ${check.suggestions} +

+ ` : nothing} +
+
+ `; + } + + _renderCategory(category) { + const { categoryName, checks } = category; + const aligned = checks.filter((c) => c.alignment === 'YES').length; + + return html` +
+ + ${categoryName} + ${aligned}/${checks.length} aligned + ${icon('chevron', 'ge-cat-chevron')} + +
+ ${checks.map((check) => this._renderCheckRow(check))} +
+
+ `; + } + + _renderCategories(evaluations) { + const groups = groupChecksByCategory(evaluations); + return html` +
+ ${groups.map((category) => this._renderCategory(category))} +
+ `; + } + + _renderTextSection(textEvaluation) { + const evaluations = textEvaluation?.evaluations ?? []; + return html` +
+

Text evaluation

+ ${evaluations.length ? html` + ${this._renderSummaryBar(sectionSummary(textEvaluation))} + ${this._renderCategories(evaluations)} + ` : html`

No text evaluation available.

`} +
+ `; + } + + _renderImageGroup(imageEvaluations) { + if (!imageEvaluations.length) return nothing; + const count = imageEvaluations.length; + const aggregate = imageEvaluations.reduce((acc, img) => { + const summary = sectionSummary(img); + return { + successful: acc.successful + summary.successful, + failed: acc.failed + summary.failed, + }; + }, { successful: 0, failed: 0 }); + const imageSummary = sectionSummary({ + successful_checks: aggregate.successful, + failed_checks: aggregate.failed, + }); + + return html` +
+ + + Image evaluations + ${count} image${count === 1 ? '' : 's'} evaluated + + + ${imageSummary.successful}/${imageSummary.successful + imageSummary.failed} passed + ${icon('chevron', 'ge-group-chevron')} + + + ${imageSummary.percent}% +
+ ${imageEvaluations.map((img) => this._renderImageSection(img))} +
+
+ `; + } + + _renderImageSection(imageEvaluation) { + const { source, overall_aligned: overallAligned, evaluations = [] } = imageEvaluation; + const badgeClass = `ge-align-badge ${overallAligned ? 'ge-align-badge-pass' : 'ge-align-badge-fail'}`; + return html` +
+
+ + ${overallAligned ? 'Aligned' : 'Not aligned'} +
+ ${evaluations.length ? html` + ${this._renderSummaryBar(sectionSummary(imageEvaluation))} + ${this._renderCategories(evaluations)} + ` : html`

No checks available for this image.

`} +
+ `; + } + + render() { + if (this.error) { + return html` +
+
+ + + Governance Page Evaluation + +
+
+ ${icon('warning', 'ge-error-icon')} + ${this.error} +
+
+ `; + } + + if (this.loading) { + return html` +
+
+ + + Governance Page Evaluation + +
+
+ + Evaluating page… +
+
+ `; + } + + const evaluation = this.evaluation ?? {}; + const { + brand_name: brandName = '', pageUrl = '', text_evaluation: textEvaluation, image_evaluations: imageEvaluations = [], + } = evaluation; + + const sections = [textEvaluation, ...imageEvaluations].filter(Boolean); + const aggregate = sections.reduce((acc, section) => { + const summary = sectionSummary(section); + return { + successful: acc.successful + summary.successful, + failed: acc.failed + summary.failed, + }; + }, { successful: 0, failed: 0 }); + const aggregateSummary = sectionSummary({ + successful_checks: aggregate.successful, + failed_checks: aggregate.failed, + }); + + return html` +
+ + + + Governance Page Evaluation + + ${icon('chevron', 'msg-chevron ge-chevron-icon')} + +
+

${brandName || 'Brand evaluation'}

+ ${pageUrl ? html`

${pageUrl}

` : nothing} + ${this._renderSummaryBar(aggregateSummary)} + ${this._renderTextSection(textEvaluation)} + ${this._renderImageGroup(imageEvaluations)} +
+
+ `; + } +} + +customElements.define('nx-governance-evaluation-card', NxGovernanceEvaluationCard); diff --git a/nx2/blocks/chat/messages/messages.css b/nx2/blocks/chat/messages/messages.css new file mode 100644 index 000000000..d4759abb5 --- /dev/null +++ b/nx2/blocks/chat/messages/messages.css @@ -0,0 +1,93 @@ +/* + * Shared styles for chat message components (cards, task list). + * Adopted alongside each component's own stylesheet via + * `adoptedStyleSheets = [shared, styles]`, so per-component rules win on conflicts. + * Holds the patterns duplicated across campaign-plan-card, governance-evaluation-card, + * task-item, and task-list. Feature-scoped — not part of the shared style guide. + */ + +:host { + font-family: var(--s2-font-family); +} + +/* ── Card shell ── */ + +.msg-card { + background: var(--s2-gray-25); + border-radius: var(--s2-corner-radius-400); + box-shadow: 0 1px 4px 0 color-mix(in srgb, var(--s2-gray-800) 12%, transparent); + overflow: hidden; + display: flex; + flex-direction: column; + margin-top: var(--s2-spacing-200); + margin-bottom: var(--s2-spacing-75); +} + +/* ── Header strip (48px, border-bottom) ── */ + +.msg-card-header { + display: flex; + align-items: center; + justify-content: space-between; + height: 48px; + padding: var(--s2-spacing-200); + border-bottom: 1px solid var(--s2-gray-200, #e1e1e1); + box-sizing: border-box; +} + +.msg-type-label { + font-size: var(--s2-body-size-xs); + color: var(--s2-gray-600); + display: flex; + align-items: center; + gap: var(--s2-spacing-75); +} + +.msg-type-icon { + width: 14px; + height: 14px; + display: inline-block; + flex-shrink: 0; + background-color: currentcolor; + mask-size: contain; + mask-repeat: no-repeat; + mask-position: center; +} + +.msg-chevron { + width: 16px; + height: 16px; + display: block; + transition: transform 0.2s ease; +} + +[open] .msg-chevron { + transform: rotate(180deg); +} + +/* ── Title ── */ + +.msg-title { + font-size: var(--s2-font-size-200, 18px); + font-weight: 700; + line-height: var(--s2-line-height-200, 1.3); + color: var(--s2-gray-900); + margin: 0; +} + +/* ── Spinner ── */ + +.msg-spinner { + flex-shrink: 0; + width: 16px; + height: 16px; + border-radius: 50%; + border: 2px solid var(--s2-gray-300); + border-top-color: var(--s2-gray-700); + box-sizing: border-box; + animation: msg-spin 0.8s linear infinite; +} + +@keyframes msg-spin { + to { transform: rotate(360deg); } +} diff --git a/nx2/blocks/chat/messages/task-item.css b/nx2/blocks/chat/messages/task-item.css new file mode 100644 index 000000000..d18308524 --- /dev/null +++ b/nx2/blocks/chat/messages/task-item.css @@ -0,0 +1,65 @@ +:host { + display: flex; + align-items: center; + gap: var(--s2-spacing-200); + font-size: var(--s2-body-size-s); + color: var(--s2-gray-800); + min-width: 0; +} + +.task-icon { + flex-shrink: 0; + width: 16px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; +} + +/* Pending: dashed circle */ +.task-icon-pending { + width: 16px; + height: 16px; + border-radius: 50%; + border: 1.5px dashed var(--s2-gray-500); + box-sizing: border-box; +} + +/* Done: filled checkmark circle */ +.task-icon-done { + width: 16px; + height: 16px; + border-radius: 50%; + background-color: var(--s2-gray-800); + box-sizing: border-box; + position: relative; +} + +.task-icon-done::after { + content: ""; + position: absolute; + inset: 0; + background-color: var(--s2-gray-25); + mask-image: url("/img/icons/s2-icon-checkmark-20-n.svg"); + mask-size: 65%; + mask-repeat: no-repeat; + mask-position: center; +} + +.task-label { + flex: 1; + min-width: 0; +} + +/* truncate mode — applied when the `truncate` attribute is present */ +:host([truncate]) .task-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.task-label-done { + color: var(--s2-gray-500); + text-decoration: line-through; +} + diff --git a/nx2/blocks/chat/messages/task-item.js b/nx2/blocks/chat/messages/task-item.js new file mode 100644 index 000000000..1fcd592b8 --- /dev/null +++ b/nx2/blocks/chat/messages/task-item.js @@ -0,0 +1,50 @@ +import { LitElement, html } from 'da-lit'; +import { loadStyle } from '../../../utils/utils.js'; +import { TASK_STATUS } from '../constants.js'; + +const shared = await loadStyle(new URL('./messages.css', import.meta.url).href); +const styles = await loadStyle(import.meta.url); + +/** + * — single task row with icon + label. + * + * Attributes / properties: + * status {string} 'pending' | 'running' | 'done' + * label {string} Task description text + */ +class NxTaskItem extends LitElement { + static properties = { + status: { type: String }, + label: { type: String }, + /** When present, label is clamped to a single line with ellipsis. */ + truncate: { type: Boolean, reflect: true }, + }; + + connectedCallback() { + super.connectedCallback(); + this.shadowRoot.adoptedStyleSheets = [shared, styles]; + } + + _renderIcon() { + const { status = TASK_STATUS.PENDING } = this; + if (status === TASK_STATUS.RUNNING) { + return html``; + } + if (status === TASK_STATUS.DONE) { + return html``; + } + return html``; + } + + render() { + const { status = TASK_STATUS.PENDING, label = '' } = this; + const isDone = status === TASK_STATUS.DONE; + + return html` + ${this._renderIcon()} + ${label} + `; + } +} + +customElements.define('nx-task-item', NxTaskItem); diff --git a/nx2/blocks/chat/messages/task-list.css b/nx2/blocks/chat/messages/task-list.css new file mode 100644 index 000000000..c63e71132 --- /dev/null +++ b/nx2/blocks/chat/messages/task-list.css @@ -0,0 +1,9 @@ +:host { + display: flex; + flex-direction: column; + gap: var(--s2-spacing-100); +} + +nx-task-item { + display: flex; +} diff --git a/nx2/blocks/chat/messages/task-list.js b/nx2/blocks/chat/messages/task-list.js new file mode 100644 index 000000000..a38f98d43 --- /dev/null +++ b/nx2/blocks/chat/messages/task-list.js @@ -0,0 +1,40 @@ +import { LitElement, html } from 'da-lit'; +import { loadStyle } from '../../../utils/utils.js'; +import { TASK_STATUS } from '../constants.js'; +import './task-item.js'; + +const shared = await loadStyle(new URL('./messages.css', import.meta.url).href); +const styles = await loadStyle(import.meta.url); + +/** + * — flat list of task items without a card wrapper. + * Used when the agent streams a task list outside of a full campaign plan. + * + * Properties: + * tasks {Array<{ id, label, status }>} + */ +class NxTaskList extends LitElement { + static properties = { + tasks: { attribute: false }, + }; + + connectedCallback() { + super.connectedCallback(); + this.shadowRoot.adoptedStyleSheets = [shared, styles]; + } + + render() { + const tasks = this.tasks ?? []; + return html` + ${tasks.map((task) => html` + + `)} + `; + } +} + +customElements.define('nx-task-list', NxTaskList); diff --git a/nx2/blocks/chat/renderers/card-renderers.js b/nx2/blocks/chat/renderers/card-renderers.js index e09ee65ba..f3452635f 100644 --- a/nx2/blocks/chat/renderers/card-renderers.js +++ b/nx2/blocks/chat/renderers/card-renderers.js @@ -40,6 +40,28 @@ export function renderToolCard(card) { * @param {{ toolCallId: string, toolName: string, summary: string|null }|null} pending * @param {(toolCallId: string, approved: boolean, always?: boolean) => void} onApprove */ +/** + * Post-execution continuation gate: a tool has finished and the agent paused for the user + * to review its result before continuing. Backend-neutral (only da-agent produces it today). + * @param {() => void} onContinue + * @param {() => void} onStop + */ +export function renderContinuationCard(onContinue, onStop) { + return html` +
+ Review the results before continuing +
+ + +
+
+ `; +} + export function renderApprovalCard(pending, onApprove) { if (!pending) return nothing; const { toolCallId, toolName, summary } = pending; diff --git a/nx2/blocks/chat/renderers/renderers.js b/nx2/blocks/chat/renderers/renderers.js index 2b47aae62..d0e9aa739 100644 --- a/nx2/blocks/chat/renderers/renderers.js +++ b/nx2/blocks/chat/renderers/renderers.js @@ -1,11 +1,18 @@ import { html, nothing } from 'da-lit'; import { - PART_TYPE, ROLE, TOOL_INPUT, TOOL_STATE, + DIRECTIVE_TYPE, PART_TYPE, ROLE, TOOL_INPUT, TOOL_NAME, TOOL_STATE, } from '../constants.js'; import { getConfig } from '../../../scripts/nx.js'; import { parseDirectives } from '../utils/parse.js'; +import { + parseDirectiveJSON, parseToolOutput, mergeTaskItemsFromText, mergeTaskItemsIntoPlan, +} from '../utils/directives.js'; import { pillIconName } from '../utils/icons.js'; import { linkifyBareUrls, sanitizeLinks } from '../utils/links.js'; +import { mcpToolName } from '../utils/tool-name.js'; +import '../messages/campaign-plan-card.js'; +import '../messages/governance-evaluation-card.js'; +import '../messages/task-list.js'; const { codeBase } = getConfig(); @@ -17,14 +24,49 @@ function toDOM(hast) { return hastToDom(sanitizeLinks(linkifyBareUrls(hast)), { fragment: true }); } +function renderPlanDirective(content) { + const plan = parseDirectiveJSON(content); + if (!plan) return html`
`; + return html``; +} + +function renderTaskListDirective(content) { + const data = parseDirectiveJSON(content); + if (!data) return html`
`; + return html``; +} + +function renderGovernanceEvaluationDirective(content) { + const evaluation = parseDirectiveJSON(content); + if (!evaluation) return html`
`; + return html``; +} + function renderMessageContent(text) { if (!text) return nothing; - return parseDirectives(text).map(({ kind, type, content }) => { + // Fold standalone :::task-item directives into a preceding :::plan payload (if any) so + // task status renders inside the plan card rather than as loose fragments. + const directives = mergeTaskItemsIntoPlan(parseDirectives(text)); + + const items = directives.map(({ kind, type, content }) => { + if (kind === 'directive') { + if (type === DIRECTIVE_TYPE.PLAN) return renderPlanDirective(content); + if (type === DIRECTIVE_TYPE.TASK_LIST) return renderTaskListDirective(content); + // task-item directives drive plan/exit_plan card status; never render standalone. + if (type === DIRECTIVE_TYPE.TASK_ITEM) return nothing; + if (type === DIRECTIVE_TYPE.GOVERNANCE_EVALUATION) { + return renderGovernanceEvaluationDirective(content); + } + if (!content) return nothing; + const dom = toDOM(mdast2hast(parser.parse(content))); + return html`
${dom}
`; + } if (!content) return nothing; - const dom = toDOM(mdast2hast(parser.parse(content))); - return kind === 'directive' ? html`
${dom}
` : dom; - }); + return toDOM(mdast2hast(parser.parse(content))); + }).filter((item) => item !== nothing); + + return items.length ? items : nothing; } function approvalSummary(input, { json = false } = {}) { @@ -38,10 +80,47 @@ function approvalSummary(input, { json = false } = {}) { ?? (json ? JSON.stringify(input, null, 2) : null); } -function renderToolCard(toolCallId, toolCards) { +// exit_plan_mode is a single stateful card living in the message stream across its whole +// lifecycle: awaiting approval (Run enabled → approves the tool call), then executing +// (task statuses merged from :::task-item directives in assistant text → Running/Done). +function renderExitPlanCard(plan, taskText, onRun) { + const merged = mergeTaskItemsFromText(plan, taskText); + return html``; +} + +function renderToolCard(toolCallId, toolCards, { streamingText, onApprove } = {}) { const card = toolCards?.get(toolCallId); - if (!card || card.state === TOOL_STATE.AWAITING_APPROVAL) return nothing; - const { toolName, state, input } = card; + if (!card) return nothing; + const { + toolName, state, input, output, + } = card; + const shortToolName = mcpToolName(toolName); + + // exit_plan_mode renders its plan card in every state (including AWAITING_APPROVAL), + // with Run wired to approve the tool call — so it is not gated by the bottom approval UI. + if (shortToolName === TOOL_NAME.EXIT_PLAN_MODE) { + return renderExitPlanCard(input, streamingText, () => onApprove?.(toolCallId, true)); + } + + // Every other tool: approval is surfaced by at the bottom, so the + // in-stream card stays hidden until the tool has actually run. + if (state === TOOL_STATE.AWAITING_APPROVAL) return nothing; + + if (shortToolName === TOOL_NAME.EVALUATE_PAGE) { + // evaluate_page runs without pre-execution approval; only OUTPUT_AVAILABLE/OUTPUT_ERROR + // carry a real report. Before then, show the card in a loading state. + const isError = state === TOOL_STATE.OUTPUT_ERROR; + const parsedOutput = parseToolOutput(output); + const errorMessage = isError + ? (typeof parsedOutput?.error === 'string' && parsedOutput.error) || 'Page evaluation failed.' + : undefined; + return html``; + } + const detail = approvalSummary(input, { json: true }); const failed = state === TOOL_STATE.OUTPUT_ERROR || state === TOOL_STATE.REJECTED; const status = failed ? html`${state}` : nothing; @@ -75,13 +154,18 @@ function renderApprovalCard(pending, onApprove) { `; } -function renderAssistantMessage(msg, toolCards) { +function renderAssistantMessage(msg, toolCards, opts) { if (Array.isArray(msg.content)) { return html`${msg.content.map((part) => (part.type === PART_TYPE.TOOL - ? renderToolCard(part.toolCallId, toolCards) + ? renderToolCard(part.toolCallId, toolCards, opts) : nothing))}`; } + // A message that is only :::task-item directives (merged into a plan card elsewhere) + // renders as nothing — skip the empty bubble + copy button. + const content = renderMessageContent(msg.content); + if (content === nothing) return nothing; + const copy = msg.streaming ? nothing : html`