diff --git a/docs/chat-ui-component.md b/docs/chat-ui-component.md
index 92cf3a2e6..7f93ac82c 100644
--- a/docs/chat-ui-component.md
+++ b/docs/chat-ui-component.md
@@ -183,7 +183,7 @@ Each conversation has a `sessionId` (UUID) stored in IndexedDB alongside its mes
| Event | Bubbles | Detail | Description |
|---|---|---|---|
-| `nx-agent-change` | Yes | — | The agent completed a tool action. Host views can listen to react (e.g. reload the document). Fired once per successful tool-result. |
+| `nx-agent-change` | Yes | `{ scope: 'file' \| 'document', paths: string[] }` | The agent completed a tool action that changed content. `scope: 'file'` means the file tree changed (files created, deleted, moved, or copied); `scope: 'document'` means a document's content was modified. `paths` contains the affected parent folder paths. |
## Skills slash menu
diff --git a/nx2/blocks/chat/chat-controller.js b/nx2/blocks/chat/chat-controller.js
index d5e200370..0daf62148 100644
--- a/nx2/blocks/chat/chat-controller.js
+++ b/nx2/blocks/chat/chat-controller.js
@@ -1,7 +1,7 @@
import { loadIms } from '../../utils/ims.js';
import { AGENT_EVENT, ROLE, TOOL_NAME, TOOL_SCOPE, TOOL_STATE } from './constants.js';
-import { readStream } from './utils.js';
-import { loadMessages, saveMessages, resetSession } from './persistence.js';
+import { readStream } from './utils/stream.js';
+import { loadMessages, saveMessages, resetSession } from './utils/persistence.js';
function affectedFolders(toolName, input) {
const { org, repo } = input ?? {};
diff --git a/nx2/blocks/chat/chat.css b/nx2/blocks/chat/chat.css
index a0b03a041..13ea11ba1 100644
--- a/nx2/blocks/chat/chat.css
+++ b/nx2/blocks/chat/chat.css
@@ -142,6 +142,7 @@
height: 100%;
}
+.directive-toggle-list summary::before,
.tool-card summary::before,
.message .selection-context > summary::before {
content: "";
@@ -192,6 +193,105 @@
}
}
+.directive-checklist ul,
+.directive-toggle-list {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+}
+
+.directive-toggle-list {
+ details {
+ summary {
+ cursor: pointer;
+ display: flex;
+ align-items: flex-start;
+ gap: var(--s2-spacing-75);
+
+ &::-webkit-details-marker {
+ display: none;
+ }
+
+ &::before {
+ margin-top: var(--s2-spacing-75);
+ }
+ }
+
+ &[open] > summary::before {
+ transform: rotate(180deg);
+ }
+
+ &:not(:has(> :not(summary))) > summary {
+ pointer-events: none;
+
+ &::before {
+ display: none;
+ }
+ }
+ }
+}
+
+.directive-checklist {
+ li {
+ display: flex;
+ align-items: flex-start;
+ gap: var(--s2-spacing-75);
+ }
+
+ input[type="checkbox"] {
+ position: relative;
+ width: 14px;
+ height: 14px;
+ flex-shrink: 0;
+ margin-top: var(--s2-spacing-75);
+ appearance: none;
+ border: 2px solid var(--s2-gray-800);
+ border-radius: var(--s2-corner-radius-75);
+ background: var(--s2-gray-75);
+
+ &:disabled {
+ opacity: 0.8;
+ cursor: not-allowed;
+ }
+
+ &:checked {
+ background: var(--s2-gray-800);
+ color: var(--s2-gray-25);
+
+ &::after {
+ content: "";
+ position: absolute;
+ inset: 0;
+ background-color: currentcolor;
+ mask-image: url("https://da.live/img/icons/s2-icon-checkmark-20-n.svg");
+ mask-size: contain;
+ mask-repeat: no-repeat;
+ mask-position: center;
+ }
+ }
+ }
+}
+
+.directive-alert-info,
+.directive-alert-warning,
+.directive-alert-error {
+ padding: var(--s2-spacing-75) var(--s2-spacing-200);
+ border-radius: 12px;
+ margin: var(--s2-spacing-200) 0;
+}
+
+.directive-alert-info {
+ background-color: var(--s2-blue-200);
+}
+
+.directive-alert-warning {
+ background-color: var(--s2-orange-200);
+}
+
+.directive-alert-error {
+ background-color: var(--s2-red-200);
+}
+
.message {
padding: 6px var(--s2-spacing-200);
border-radius: 12px;
diff --git a/nx2/blocks/chat/chat.js b/nx2/blocks/chat/chat.js
index f9bb2fcb6..5c6d9e0b1 100644
--- a/nx2/blocks/chat/chat.js
+++ b/nx2/blocks/chat/chat.js
@@ -1,13 +1,13 @@
import { LitElement, html, nothing } from 'da-lit';
import { loadStyle, hashChange } from '../../utils/utils.js';
-import { readFileAsBase64 } from './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 './welcome/welcome.js';
import './prompts/prompts.js';
import './pills/pills.js';
-import { loadSiteConfig } from './api.js';
+import { loadSiteConfig } from './utils/api.js';
import { ADOBE_AI_GUIDELINES_URL, ADD_MENU_ITEMS, MENU_OPTIONS, ROLE, TOOL_STATE } from './constants.js';
import { getConfig } from '../../scripts/nx.js';
diff --git a/nx2/blocks/chat/pills/pills.js b/nx2/blocks/chat/pills/pills.js
index 50f89f60e..fe7b8d182 100644
--- a/nx2/blocks/chat/pills/pills.js
+++ b/nx2/blocks/chat/pills/pills.js
@@ -1,6 +1,7 @@
import { LitElement, html, nothing } from 'da-lit';
import { loadStyle } from '../../../utils/utils.js';
import { getConfig } from '../../../scripts/nx.js';
+import { fileIconName } from '../utils/icons.js';
const styles = await loadStyle(import.meta.url);
const { codeBase } = getConfig();
@@ -19,9 +20,7 @@ class NxChatPills extends LitElement {
_pillTypeIcon(label, thumbnail) {
if (thumbnail) return html`
`;
- const isImage = label && /\.(png|jpg|jpeg|gif|webp|svg|mp4|webm|mov)$/i.test(label);
- const icon = isImage ? 's2-icon-image-20-n' : 's2-icon-filetext-20-n';
- return html``;
+ return html``;
}
_renderPill({ id, label, thumbnail, type }) {
diff --git a/nx2/blocks/chat/renderers.js b/nx2/blocks/chat/renderers.js
index 6517e9358..ab362e7e6 100644
--- a/nx2/blocks/chat/renderers.js
+++ b/nx2/blocks/chat/renderers.js
@@ -1,16 +1,11 @@
import { html, nothing } from 'da-lit';
import { AGENT_EVENT, ROLE, TOOL_INPUT, TOOL_STATE } from './constants.js';
import { getConfig } from '../../scripts/nx.js';
+import { parseDirectives } from './utils/parse.js';
+import { fileIconName } from './utils/icons.js';
const { codeBase } = getConfig();
-const SELECTION_ICON_NAMES = {
- block: 's2-icon-3d-20-n',
- file: 's2-icon-filetext-20-n',
- image: 's2-icon-image-20-n',
- table: 's2-icon-table-20-n',
-};
-
const { unified, remarkParse } = await import('../../deps/mdast/dist/index.js');
function renderNode(node) {
@@ -48,10 +43,71 @@ function renderNode(node) {
const parser = unified().use(remarkParse);
+function renderChecklistItem(node) {
+ const para = node.children[0];
+ if (para?.type !== 'paragraph') return html`
${node.children.map(renderNode)}`;
+ const first = para.children[0];
+ if (first?.type !== 'text') return html`${renderNode(para)}`;
+
+ const checked = first.value.startsWith('[x] ') || first.value.startsWith('[X] ');
+ const unchecked = first.value.startsWith('[ ] ');
+ if (!checked && !unchecked) return html`${renderNode(para)}`;
+
+ const inline = [
+ { ...first, value: first.value.slice(4) },
+ ...para.children.slice(1),
+ ].map(renderNode);
+ return html`
+ ${inline}
+ `;
+}
+
+function renderToggleList(tree) {
+ const items = [];
+ let i = 0;
+ while (i < tree.children.length) {
+ const node = tree.children[i];
+ if (node.type === 'blockquote') {
+ const summary = node.children.flatMap((c) => (c.children ?? []).map(renderNode));
+ const body = [];
+ while (i + 1 < tree.children.length && tree.children[i + 1].type !== 'blockquote') {
+ i += 1;
+ body.push(renderNode(tree.children[i]));
+ }
+ items.push(html`${summary}
${body} `);
+ } else {
+ items.push(html`${renderNode(node)}`);
+ }
+ i += 1;
+ }
+ return html``;
+}
+
+function renderChecklist(tree) {
+ const inner = tree.children.map((n) => {
+ if (n.type !== 'list') return renderNode(n);
+ return n.ordered
+ ? html`${n.children.map(renderChecklistItem)}
`
+ : html`${n.children.map(renderChecklistItem)}
`;
+ });
+ return html`${inner}
`;
+}
+
+function renderDirective(type, content) {
+ const tree = parser.parse(content);
+ if (type === 'toggle-list') return renderToggleList(tree);
+ if (type === 'checklist') return renderChecklist(tree);
+ return html`${renderNode(tree)}
`;
+}
+
function renderMessageContent(text) {
if (!text) return nothing;
- const tree = parser.parse(text);
- return renderNode(tree);
+ const segments = parseDirectives(text);
+ return segments.map(({ kind, type, content }) => {
+ if (kind === 'directive') return renderDirective(type, content);
+ const tree = parser.parse(content);
+ return renderNode(tree);
+ });
}
function approvalSummary(input) {
@@ -101,14 +157,8 @@ function renderApprovalCard(pending, onApprove) {
`;
}
-// Mirrors entryTypeFromExtension in browse/utils.js — switch to common utils once migrated.
function selectionIcon(blockName) {
- const ext = (blockName ?? '').includes('.') ? blockName.split('.').pop().toLowerCase() : '';
- let name = SELECTION_ICON_NAMES.block;
- if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'mp4', 'webm', 'mov'].includes(ext)) name = SELECTION_ICON_NAMES.image;
- else if (['json', 'xlsx', 'xls', 'csv'].includes(ext)) name = SELECTION_ICON_NAMES.table;
- else if (ext) name = SELECTION_ICON_NAMES.file;
- return html``;
+ return html``;
}
function renderMessage(msg, toolCards) {
diff --git a/nx2/blocks/chat/api.js b/nx2/blocks/chat/utils/api.js
similarity index 85%
rename from nx2/blocks/chat/api.js
rename to nx2/blocks/chat/utils/api.js
index 4ce8f9c77..6c91dff78 100644
--- a/nx2/blocks/chat/api.js
+++ b/nx2/blocks/chat/utils/api.js
@@ -1,5 +1,5 @@
-import { daFetch } from '../../utils/api.js';
-import { DA_ADMIN } from '../../utils/utils.js';
+import { daFetch } from '../../../utils/api.js';
+import { DA_ADMIN } from '../../../utils/utils.js';
export async function loadSiteConfig(org, site) {
try {
diff --git a/nx2/blocks/chat/utils/icons.js b/nx2/blocks/chat/utils/icons.js
new file mode 100644
index 000000000..ec8116ff3
--- /dev/null
+++ b/nx2/blocks/chat/utils/icons.js
@@ -0,0 +1,10 @@
+const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'mp4', 'webm', 'mov']);
+const TABLE_EXTS = new Set(['json', 'xlsx', 'xls', 'csv']);
+
+export function fileIconName(filename) {
+ const ext = (filename ?? '').includes('.') ? filename.split('.').pop().toLowerCase() : '';
+ if (IMAGE_EXTS.has(ext)) return 's2-icon-image-20-n';
+ if (TABLE_EXTS.has(ext)) return 's2-icon-table-20-n';
+ if (ext) return 's2-icon-filetext-20-n';
+ return 's2-icon-3d-20-n';
+}
diff --git a/nx2/blocks/chat/utils/parse.js b/nx2/blocks/chat/utils/parse.js
new file mode 100644
index 000000000..df68d2cb8
--- /dev/null
+++ b/nx2/blocks/chat/utils/parse.js
@@ -0,0 +1,34 @@
+export function parseDirectives(text) {
+ const segments = [];
+ const buf = [];
+ let type = null;
+ let openLine = null;
+
+ for (const line of text.split('\n')) {
+ if (!type && line.startsWith(':::')) {
+ const extracted = line.slice(3).split(' ')[0];
+ if (!extracted) {
+ buf.push(line);
+ } else {
+ const content = buf.splice(0).join('\n');
+ if (content) segments.push({ kind: 'text', content });
+ type = extracted;
+ openLine = line;
+ }
+ } else if (type && line.trimEnd() === ':::') {
+ segments.push({ kind: 'directive', type, content: buf.splice(0).join('\n') });
+ type = null;
+ openLine = null;
+ } else {
+ buf.push(line);
+ }
+ }
+
+ if (openLine) {
+ segments.push({ kind: 'directive', type, content: buf.join('\n') });
+ } else {
+ const tail = buf.join('\n');
+ if (tail) segments.push({ kind: 'text', content: tail });
+ }
+ return segments;
+}
diff --git a/nx2/blocks/chat/persistence.js b/nx2/blocks/chat/utils/persistence.js
similarity index 100%
rename from nx2/blocks/chat/persistence.js
rename to nx2/blocks/chat/utils/persistence.js
diff --git a/nx2/blocks/chat/utils.js b/nx2/blocks/chat/utils/stream.js
similarity index 97%
rename from nx2/blocks/chat/utils.js
rename to nx2/blocks/chat/utils/stream.js
index ab3833cdf..eb107feea 100644
--- a/nx2/blocks/chat/utils.js
+++ b/nx2/blocks/chat/utils/stream.js
@@ -1,4 +1,4 @@
-import { AGENT_EVENT as EVENT, TOOL_SCOPE } from './constants.js';
+import { AGENT_EVENT as EVENT, TOOL_SCOPE } from '../constants.js';
function processEvent(event, streaming, callbacks) {
const { onDelta, onText, onTool } = callbacks;
diff --git a/test/nx2/blocks/chat/utils/parse.test.js b/test/nx2/blocks/chat/utils/parse.test.js
new file mode 100644
index 000000000..79fd42be1
--- /dev/null
+++ b/test/nx2/blocks/chat/utils/parse.test.js
@@ -0,0 +1,94 @@
+import { expect } from '@esm-bundle/chai';
+import { parseDirectives } from '../../../../../nx2/blocks/chat/utils/parse.js';
+
+describe('parseDirectives', () => {
+ describe('plain text', () => {
+ it('returns a single text segment for plain text', () => {
+ const result = parseDirectives('hello world');
+ expect(result).to.deep.equal([{ kind: 'text', content: 'hello world' }]);
+ });
+
+ it('returns empty array for empty string', () => {
+ expect(parseDirectives('')).to.deep.equal([]);
+ });
+ });
+
+ describe('single directive', () => {
+ it('parses a basic directive', () => {
+ const text = ':::checklist\n- [x] Done\n:::';
+ expect(parseDirectives(text)).to.deep.equal([
+ { kind: 'directive', type: 'checklist', content: '- [x] Done' },
+ ]);
+ });
+
+ it('parses a hyphenated type', () => {
+ const text = ':::alert-error\nSomething failed.\n:::';
+ expect(parseDirectives(text)).to.deep.equal([
+ { kind: 'directive', type: 'alert-error', content: 'Something failed.' },
+ ]);
+ });
+ });
+
+ describe('mixed content', () => {
+ it('handles text before a directive', () => {
+ const text = 'Intro text.\n:::list\n- item\n:::';
+ const result = parseDirectives(text);
+ expect(result).to.deep.equal([
+ { kind: 'text', content: 'Intro text.' },
+ { kind: 'directive', type: 'list', content: '- item' },
+ ]);
+ });
+
+ it('handles text after a directive', () => {
+ const text = ':::list\n- item\n:::\nTrailing text.';
+ const result = parseDirectives(text);
+ expect(result).to.deep.equal([
+ { kind: 'directive', type: 'list', content: '- item' },
+ { kind: 'text', content: 'Trailing text.' },
+ ]);
+ });
+
+ it('handles multiple directives', () => {
+ const text = ':::list\n- a\n:::\n:::checklist\n- [x] b\n:::';
+ const result = parseDirectives(text);
+ expect(result).to.deep.equal([
+ { kind: 'directive', type: 'list', content: '- a' },
+ { kind: 'directive', type: 'checklist', content: '- [x] b' },
+ ]);
+ });
+ });
+
+ describe('bare ::: with no type', () => {
+ it('treats ::: with no type as plain text', () => {
+ expect(parseDirectives(':::')).to.deep.equal([{ kind: 'text', content: ':::' }]);
+ });
+
+ it('does not open a directive scope for bare :::', () => {
+ const text = ':::\n:::checklist\n- item\n:::';
+ const result = parseDirectives(text);
+ expect(result).to.deep.equal([
+ { kind: 'text', content: ':::' },
+ { kind: 'directive', type: 'checklist', content: '- item' },
+ ]);
+ });
+ });
+
+ describe('unclosed directive (streaming)', () => {
+ it('renders an unclosed directive as a directive with partial content', () => {
+ const text = ':::checklist\n- [x] partial';
+ const result = parseDirectives(text);
+ expect(result).to.deep.equal([
+ { kind: 'directive', type: 'checklist', content: '- [x] partial' },
+ ]);
+ });
+
+ it('renders completed and unclosed directives in order', () => {
+ const text = ':::list\n- done\n:::\n:::checklist\n- partial';
+ const result = parseDirectives(text);
+ expect(result).to.deep.equal([
+ { kind: 'directive', type: 'list', content: '- done' },
+ { kind: 'directive', type: 'checklist', content: '- partial' },
+ ]);
+ });
+ });
+});
diff --git a/test/nx2/blocks/chat/persistence.test.js b/test/nx2/blocks/chat/utils/persistence.test.js
similarity index 98%
rename from test/nx2/blocks/chat/persistence.test.js
rename to test/nx2/blocks/chat/utils/persistence.test.js
index 1fa304030..c107378f0 100644
--- a/test/nx2/blocks/chat/persistence.test.js
+++ b/test/nx2/blocks/chat/utils/persistence.test.js
@@ -3,7 +3,7 @@ import {
loadMessages,
saveMessages,
resetSession,
-} from '../../../../nx2/blocks/chat/persistence.js';
+} from '../../../../../nx2/blocks/chat/utils/persistence.js';
let counter = 0;
const room = () => {