diff --git a/WORKLOG.md b/WORKLOG.md index 4961c5797..31d26eb21 100644 --- a/WORKLOG.md +++ b/WORKLOG.md @@ -20,6 +20,35 @@ New internal helpers `parseListItems` (ok-check + parse-or-`[]`) and `dedupeByNa **Test-suite flake fixed in passing:** `test/nx2/utils/api.test.js`'s outer `beforeEach` did a blanket `localStorage.removeItem('hlx6-upgrade')`. Since `tree.test.js` seeds the same shared-origin key for its own hlx6 tests, and wtr runs test files concurrently (`--concurrent-browsers 4`) with a shared localStorage, this occasionally wiped `tree.test.js`'s seeded entry mid-run, causing intermittent unrelated failures. Removed the clear — every `org`/`site` pair in `api.test.js` already comes from a randomized `uniq()` helper, so the blanket clear was never actually load-bearing. +## 2026-06-22 + +### nx2 chat — plan card UX polish + task-status persistence fixes + +**Task status persistence across tool boundaries** (`chat.js`): +- `TEXT_END` fires after each text segment between tool calls, splitting output into multiple string messages per inter-tool boundary. Task-item directives for step N live in a different message than step N+1, so the previous code (passing only the last message) missed earlier `done` directives. Fixed by concatenating all completed assistant text messages before passing to `mergeTaskItemsFromText`. + +**Plan card renderer fixes** (`renderers.js`): +- Switched `renderSubmitPlanCard` and `renderApprovalCard` from `document.createElement` to Lit `html\`\`` templates; `document.createElement` recreated the element each render, losing `_expanded` state. +- `renderMessageContent`: returns `nothing` for `TASK_ITEM` directives (previously rendered as visible text); filters `nothing` items; returns `nothing` if all items suppressed. +- `renderAssistantMessage`: skips message wrapper when content is `nothing` (eliminates empty `
` gaps in the DOM). +- Removed unused `renderTaskItemDirective` function. + +**Plan card template restructure** (`campaign-plan-card.js`): +- `_expanded` defaults to `true`. +- `showCollapsed = !this._expanded` (was: only collapsed when running AND not expanded). +- Moved title/description out of `.plan-header` into a new `.plan-body` div below the header strip. +- Header strip is now 48px fixed-height with border-bottom, matching Figma spec. + +**CSS fixes** (`campaign-plan-card.css`, `task-item.css`): +- All `--s2-spacing-150` (undefined) replaced with `--s2-spacing-200` (12px). +- All `--s2-spacing-115` replaced with `--s2-spacing-100` (8px). +- `.plan-card` gap: 12px; `.plan-header`: height 48px, border-bottom, padding 12px. +- `.plan-body`: padding 12px 16px, gap 8px. +- `.plan-tasks`: background `--s2-gray-50`, border `1px solid --s2-gray-200`, border-radius 8px, margin with side 16px offset. +- `.plan-btn`: height 24px, padding `0 16px`, weight 400. +- `.plan-btn-primary`: color `--s2-static-white` (was `--s2-gray-25` which flips to near-black in dark mode). +- `task-item.css`: `.task-label` margin-left `--s2-spacing-100`; `:host` gap `--s2-spacing-200`. + ## 2026-07-14 ### nx2/styles/styles.css — pin to light mode diff --git a/nx2/blocks/chat/chat-controller.js b/nx2/blocks/chat/chat-controller.js index 51db2b54f..4f01bd67f 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,18 @@ export default class ChatController { return; } + // Post-execution continuation gate: the tool already finished (its result is shown). + // 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 +389,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 12186c24d..ecd11dc3b 100644 --- a/nx2/blocks/chat/chat.js +++ b/nx2/blocks/chat/chat.js @@ -3,7 +3,7 @@ import { loadStyle, hashChange } from '../../utils/utils.js'; import { readFileAsBase64 } from './utils/stream.js'; import '../shared/menu/menu.js'; import ChatController from './chat-controller.js'; -import { renderMessage, renderApprovalCard } from './renderers.js'; +import { renderMessage, renderApprovalCard, renderContinuationCard } from './renderers.js'; import './welcome/welcome.js'; import './prompts/prompts.js'; import './pills/pills.js'; @@ -243,7 +243,26 @@ class NxChat extends LitElement { return null; } + _pendingContinuation() { + if (!this.toolCards) return null; + for (const [toolCallId, card] of this.toolCards) { + if (card.continuationPending) return { toolCallId, ...card }; + } + return null; + } + _onApprovalKeydown = (e) => { + const continuation = this._pendingContinuation(); + if (continuation) { + if (e.key === 'Escape') { + e.preventDefault(); + this._controller.stopExecution(); + } else if (e.key === 'Enter') { + e.preventDefault(); + this._controller.continueExecution(); + } + return; + } const pending = this._pendingApproval(); if (!pending) return; if (e.key === 'Escape') { @@ -277,7 +296,7 @@ class NxChat extends LitElement { this.shadowRoot.querySelector('.chat-input')?.focus(); } if (changed.has('toolCards')) { - if (this._pendingApproval()) { + if (this._pendingApproval() || this._pendingContinuation()) { document.addEventListener('keydown', this._onApprovalKeydown); } else { document.removeEventListener('keydown', this._onApprovalKeydown); @@ -497,6 +516,24 @@ class NxChat extends LitElement { await this._onFilesSelected(accepted); } + get _taskText() { + const msgs = this.messages ?? []; + const last = msgs.at(-1); + const streamingText = last?.streaming ? last.content : null; + if (streamingText) return streamingText; + // TEXT_END splits output into one string message per inter-tool segment, so + // task-item directives for step N may live in a different message than step N+1. + // Concatenate all assistant text to let mergeTaskItemsFromText find them all. + return msgs + .filter((m) => m.role === ROLE.ASSISTANT && typeof m.content === 'string' && !m.streaming) + .map((m) => m.content) + .join('\n') || null; + } + + _renderMessages() { + return (this.messages ?? []).map((msg) => renderMessage(msg, this.toolCards, this._taskText)); + } + render() { const { view } = this._context ?? {}; const prompts = (this._prompts ?? []) @@ -533,7 +570,7 @@ class NxChat extends LitElement { @nx-show-prompts=${this._openPrompts} >` : nothing} - ${this.messages?.map((msg) => renderMessage(msg, this.toolCards))} + ${this._renderMessages()} ${this.thinking && !this.messages?.at(-1)?.streaming ? html`
Thinking...
` : nothing}
@@ -546,6 +583,11 @@ class NxChat extends LitElement { @mousedown=${(e) => e.preventDefault()} > ${renderApprovalCard(this._pendingApproval(), this._controller.approveToolCall)} + ${renderContinuationCard( + this._pendingContinuation(), + this._controller.continueExecution, + 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/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.js b/nx2/blocks/chat/renderers.js index 3e0620464..e3e9b2950 100644 --- a/nx2/blocks/chat/renderers.js +++ b/nx2/blocks/chat/renderers.js @@ -1,9 +1,18 @@ import { html, nothing } from 'da-lit'; -import { PART_TYPE, ROLE, TOOL_INPUT, TOOL_STATE } from './constants.js'; +import { + 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(); @@ -15,14 +24,46 @@ 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 }) => { + 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); + 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 } = {}) { @@ -36,10 +77,34 @@ function approvalSummary(input, { json = false } = {}) { ?? (json ? JSON.stringify(input, null, 2) : null); } -function renderToolCard(toolCallId, toolCards) { +function renderExitPlanCard(plan, taskText) { + const merged = mergeTaskItemsFromText(plan, taskText); + return html``; +} + +function renderToolCard(toolCallId, toolCards, streamingText) { const card = toolCards?.get(toolCallId); if (!card || card.state === TOOL_STATE.AWAITING_APPROVAL) return nothing; - const { toolName, state, input } = card; + const { + toolName, state, input, output, + } = card; + const shortToolName = mcpToolName(toolName); + if (shortToolName === TOOL_NAME.EXIT_PLAN_MODE) return renderExitPlanCard(input, streamingText); + if (shortToolName === TOOL_NAME.EVALUATE_PAGE) { + // evaluate_page runs without pre-execution approval; INPUT_AVAILABLE precedes the real + // tool-result event, so only OUTPUT_AVAILABLE/OUTPUT_ERROR carry real output. After it + // completes, a continuation prompt (renderContinuationCard) lets the user review + decide. + 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; @@ -53,6 +118,13 @@ function renderToolCard(toolCallId, toolCards) { function renderApprovalCard(pending, onApprove) { if (!pending) return nothing; const { toolCallId, toolName, input } = pending; + const shortToolName = mcpToolName(toolName); + if (shortToolName === TOOL_NAME.EXIT_PLAN_MODE) { + return html` onApprove(toolCallId, true)} + >`; + } const summary = approvalSummary(input); return html`
@@ -73,13 +145,33 @@ function renderApprovalCard(pending, onApprove) { `; } -function renderAssistantMessage(msg, toolCards) { +function renderContinuationCard(pending, onContinue, onStop) { + if (!pending) return nothing; + return html` +
+ Review the results before continuing +
+ + +
+
+ `; +} + +function renderAssistantMessage(msg, toolCards, streamingText) { if (Array.isArray(msg.content)) { return html`${msg.content.map((part) => (part.type === PART_TYPE.TOOL - ? renderToolCard(part.toolCallId, toolCards) + ? renderToolCard(part.toolCallId, toolCards, streamingText) : nothing))}`; } + const content = renderMessageContent(msg.content); + if (content === nothing) return nothing; + const copy = msg.streaming ? nothing : html`