Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions WORKLOG.md
Original file line number Diff line number Diff line change
@@ -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 + `<nx-chat-interaction>` + 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 `<nx-campaign-plan-card>` 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`; `<nx-chat-interaction>` renders a Continue/Stop card (Enter/Esc); `card-renderers.js#renderContinuationCard`.
- **Governance card**: `evaluate_page` output → `<nx-governance-evaluation-card>` (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
Expand Down
29 changes: 26 additions & 3 deletions nx2/blocks/chat/chat-backend.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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);
}
Expand All @@ -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();
}
Expand Down
51 changes: 51 additions & 0 deletions nx2/blocks/chat/chat-controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
});
});
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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.
Expand Down
20 changes: 19 additions & 1 deletion nx2/blocks/chat/chat.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? [])
Expand Down Expand Up @@ -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`<div class="chat-thinking">Thinking...</div>` : nothing}
Expand All @@ -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()}
></nx-chat-interaction>
<form class="chat-form" autocomplete="off" @submit=${this._submit}
@dragenter=${this._onDragEnter}
Expand Down
32 changes: 32 additions & 0 deletions nx2/blocks/chat/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ const AGENT_EVENT = {
// Result of an executed tool.
TOOL_OUTPUT_AVAILABLE: 'tool-output-available',
TOOL_OUTPUT_ERROR: 'tool-output-error',
// Transient (UI-only, never persisted) part emitted after a continuation-gated tool
// finishes, so the user can review results and decide whether the agent continues.
CONTINUATION: 'data-continuation',
};

/**
Expand Down Expand Up @@ -69,6 +72,9 @@ const TOOL_NAME = {
CONTENT_MOVE: 'content_move',
CONTENT_UPDATE: 'content_update',
CONTENT_UPLOAD: 'content_upload',
ENTER_PLAN_MODE: 'enter_plan_mode',
EXIT_PLAN_MODE: 'exit_plan_mode',
EVALUATE_PAGE: 'evaluate_page',
};

/**
Expand Down Expand Up @@ -100,6 +106,29 @@ const ROLE = {
TOOL: 'tool',
};

/**
* Directive fence types that map to rich interactive renderer components.
* These are emitted by da-agent inside :::type ... ::: fences in text-delta events.
*/
const DIRECTIVE_TYPE = {
PLAN: 'plan',
TASK_LIST: 'task-list',
TASK_ITEM: 'task-item',
GOVERNANCE_EVALUATION: 'governance-evaluation',
};

/**
* Task status values shared across plan/task-list/task-item components.
* Matches values sent in :::plan / :::task-list / :::task-item directive payloads.
*/
const TASK_STATUS = {
PENDING: 'pending',
RUNNING: 'running',
DONE: 'done',
};

const PLAN_RUN_EVENT = 'nx-plan-run';

/**
* DOM CustomEvent names for <nx-chat>'s boundary with the outside world — part of
* chat's public surface, see docs/chat-ui-component.md ("Events in"/"Events out").
Expand All @@ -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,
Expand Down
16 changes: 15 additions & 1 deletion nx2/blocks/chat/interaction/interaction.js
Original file line number Diff line number Diff line change
@@ -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);
Expand Down Expand Up @@ -48,6 +48,16 @@ class NxChatInteraction extends LitElement {
}

_onKeydown = (e) => {
if (this.pending?.type === 'continuation') {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will require adobe-rnd/da-agent#71 to be reverted right?

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') {
Expand Down Expand Up @@ -135,6 +145,10 @@ class NxChatInteraction extends LitElement {
});
}

if (pending.type === 'continuation') {
return renderContinuationCard(() => this.onContinue?.(), () => this.onStop?.());
}

return nothing;
}
}
Expand Down
83 changes: 83 additions & 0 deletions nx2/blocks/chat/messages/campaign-plan-card.css
Original file line number Diff line number Diff line change
@@ -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);
}
Loading
Loading