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,
+ )}