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``; + }); + 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 = () => {