Skip to content
Open
2 changes: 1 addition & 1 deletion docs/chat-ui-component.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions nx2/blocks/chat/chat-controller.js
Original file line number Diff line number Diff line change
@@ -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 ?? {};
Expand Down
100 changes: 100 additions & 0 deletions nx2/blocks/chat/chat.css
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@
height: 100%;
}

.directive-toggle-list summary::before,
.tool-card summary::before,
.message .selection-context > summary::before {
content: "";
Expand Down Expand Up @@ -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");
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

can we set the URL via a JS custom property (--checkmark-icon: url(${...})) using codeBase, the same way inline SVG refs are handled instead of hardcoding the CDN url?

Copy link
Copy Markdown
Contributor Author

@sharanyavinod sharanyavinod Jun 1, 2026

Choose a reason for hiding this comment

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

We could, but mask-image on a pseudo-element is purely decorative styling with no functional JS dependency. Adding a custom property would couple js and css just to avoid a stable CDN URL and the trade-off didnt feel worth it here.

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;
Expand Down
4 changes: 2 additions & 2 deletions nx2/blocks/chat/chat.js
Original file line number Diff line number Diff line change
@@ -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';

Expand Down
5 changes: 2 additions & 3 deletions nx2/blocks/chat/pills/pills.js
Original file line number Diff line number Diff line change
@@ -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();
Expand All @@ -19,9 +20,7 @@ class NxChatPills extends LitElement {

_pillTypeIcon(label, thumbnail) {
if (thumbnail) return html`<img class="pill-thumbnail" src=${thumbnail} alt="" aria-hidden="true">`;
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`<svg class="pill-type-icon" viewBox="0 0 20 20" aria-hidden="true"><use href="${codeBase}/img/icons/${icon}.svg#icon"></use></svg>`;
return html`<svg class="pill-type-icon" viewBox="0 0 20 20" aria-hidden="true"><use href="${codeBase}/img/icons/${fileIconName(label)}.svg#icon"></use></svg>`;
}

_renderPill({ id, label, thumbnail, type }) {
Expand Down
82 changes: 66 additions & 16 deletions nx2/blocks/chat/renderers.js
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down Expand Up @@ -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`<li>${node.children.map(renderNode)}</li>`;
const first = para.children[0];
if (first?.type !== 'text') return html`<li>${renderNode(para)}</li>`;

const checked = first.value.startsWith('[x] ') || first.value.startsWith('[X] ');
const unchecked = first.value.startsWith('[ ] ');
if (!checked && !unchecked) return html`<li>${renderNode(para)}</li>`;

const inline = [
{ ...first, value: first.value.slice(4) },
...para.children.slice(1),
].map(renderNode);
return html`<li class="${checked ? 'checked' : 'unchecked'}">
<input type="checkbox" ?checked=${checked} disabled><span>${inline}</span>
</li>`;
}

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`<li><details><summary>${summary}</summary>${body}</details></li>`);
} else {
items.push(html`<li>${renderNode(node)}</li>`);
}
i += 1;
}
return html`<ul class="directive directive-toggle-list">${items}</ul>`;
}

function renderChecklist(tree) {
const inner = tree.children.map((n) => {
if (n.type !== 'list') return renderNode(n);
return n.ordered
? html`<ol>${n.children.map(renderChecklistItem)}</ol>`
: html`<ul>${n.children.map(renderChecklistItem)}</ul>`;
});
return html`<div class="directive directive-checklist">${inner}</div>`;
}

function renderDirective(type, content) {
const tree = parser.parse(content);
if (type === 'toggle-list') return renderToggleList(tree);
if (type === 'checklist') return renderChecklist(tree);
return html`<div class="directive directive-${type}">${renderNode(tree)}</div>`;
}

function renderMessageContent(text) {
if (!text) return nothing;
const tree = parser.parse(text);
return renderNode(tree);
const segments = parseDirectives(text);
Comment thread
sharanyavinod marked this conversation as resolved.
return segments.map(({ kind, type, content }) => {
if (kind === 'directive') return renderDirective(type, content);
const tree = parser.parse(content);
return renderNode(tree);
});
}

function approvalSummary(input) {
Expand Down Expand Up @@ -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`<svg class="selection-icon" viewBox="0 0 20 20" aria-hidden="true"><use href="${codeBase}/img/icons/${name}.svg#icon"></use></svg>`;
return html`<svg class="selection-icon" viewBox="0 0 20 20" aria-hidden="true"><use href="${codeBase}/img/icons/${fileIconName(blockName)}.svg#icon"></use></svg>`;
}

function renderMessage(msg, toolCards) {
Expand Down
4 changes: 2 additions & 2 deletions nx2/blocks/chat/api.js → nx2/blocks/chat/utils/api.js
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
10 changes: 10 additions & 0 deletions nx2/blocks/chat/utils/icons.js
Original file line number Diff line number Diff line change
@@ -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) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

the old code had a comment saying this mirrors entryTypeFromExtension in browse/utils.js. we removed the comment but the duplication is still there. why not move fileIconName to nx2/utils/ and delete browse/utils.js's version, or at least import from there? two parallel implementations will diverge.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Browse doesnt exist anymore - the comment is a remnant from when we had custom browse block similar to chat.

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';
}
34 changes: 34 additions & 0 deletions nx2/blocks/chat/utils/parse.js
Original file line number Diff line number Diff line change
@@ -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;
}
File renamed without changes.
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
Loading
Loading