Skip to content
Star on GitHub
CollaborationComments
Style
Accent
Sound

Built by DesignMe, free to use under the MIT licence.

Expert design and dev support for startups and enterprises.

Book a call

Comments

A design thread where you can @mention the agent and it replies. Free and MIT licensed, as HTML or a typed React component, in two styles with light and dark mode.

$npx designme add comments
Pricing, frame 2
01

Install

Add it to your project with the CLI.

HTML, CSS and JavaScript

$npx designme add comments

React

$npx designme add comments --react

The CLI copies the source into your project, so you own it and can change anything. The HTML version lands in ./designme with the shared core and a demo.html. The React version lands in src/components/designme and needs React 19; it works in Next.js, Vite and Astro.

02

About

What it is for and what it does.

Comments is a thread pinned to one spot in a design, where people reply to each other and can bring the agent in by typing @agent. Each comment is an avatar, a name, a time and the text; the first one starts the thread and the replies sit indented under it on a thin line. A quiet Agree button shows beside the time on hover or keyboard focus. The agent's reply shows a thinking mark first, then its words fade in, and it can end with buttons such as Apply to all that turn into a short note once used. The whole thread can be resolved, which fades it back.

  • When feedback lives on a design or document and the agent should act on it from the thread
  • When people need to @mention teammates and the agent from the same reply field
  • When an agent's suggestion needs a yes or a choice before it changes anything
  • When resolved threads should stay visible but step back
03

Usage

Import it and drive it from your own state.

Import

import { Comments } from '@/components/designme/comments/Comments';

Example

The demo above as a React component, 62 lines you can copy.

'use client';
// The animated preview from designme.dev, rebuilt from the React component: an example of driving it from your own state.
import { useCallback, useRef, useState } from 'react';
import { Comments, type CommentsEntry, type CommentsPerson } from './Comments';
import { useStory, useWait } from '../lib/useScript';

/* ---------- Comments: Maya asks the agent in the thread; it thinks, writes its answer, then offers to apply it ---------- */
const AGENT: CommentsPerson = { name: 'Design agent', initials: 'DA', color: 'var(--dm-av-b)', handle: 'agent' };
const MAYA: CommentsPerson = { name: 'Maya Kim', initials: 'MK', color: 'var(--dm-av-l)', handle: 'maya' };
const JON: CommentsPerson = { name: 'Jon Ortiz', initials: 'JO', color: 'var(--dm-av-m)', handle: 'jon' };
const START: CommentsEntry[] = [
  { id: 'c1', author: MAYA, text: 'The secondary button feels too faint next to the CTA.', time: '2h', agrees: 1 },
  { id: 'c2', author: JON, text: 'Agree. Can we keep it outlined though?', time: '1h' }
];
const ASK = '@agent can you keep it outlined but darker?';

export default function CommentsDemo() {
  const [comments, setComments] = useState(START);
  const [draft, setDraft] = useState('');
  const wait = useWait();
  const next = useRef(3);

  const reply = useCallback(async (q: string) => {
    const id = 'c' + next.current++;
    const set = (patch: Partial<CommentsEntry>) => setComments(cs => cs.map(c => (c.id === id ? { ...c, ...patch } : c)));
    setComments(cs => [...cs, { id, author: AGENT, text: '', time: 'now', agent: true, thinking: true }]);
    await wait(900);
    set({ thinking: false });
    const answer = /outline|border/i.test(q + ' outlined')
      ? 'Kept it outlined and raised the text to #5B6472, which passes AA at 5.9 : 1. Want me to apply it to the other 2 frames?'
      : 'On it. I will post the change here when it is ready.';
    let text = '';
    for (const w of answer.split(' ')) { text += (text ? ' ' : '') + w; set({ text }); await wait(40); }
    set({ actions: [
      { label: 'Apply to all', primary: true, onClick: () => set({ resolution: 'Applied to 3 frames' }) },
      { label: 'Just this one', onClick: () => set({ resolution: 'Applied to frame 2' }) }
    ] });
  }, [wait]);

  const send = useCallback((text: string) => {
    setComments(cs => [...cs, { id: 'c' + next.current++, author: MAYA, text, time: 'now' }]);
    if (/@agent/i.test(text)) reply(text);
  }, [reply]);

  // Maya types the question a letter at a time, then sends it. Clicking, focusing or typing in it takes over, and what she
  // had typed goes
  const ref = useRef<HTMLDivElement>(null);
  useStory(ref, async ({ wait, taken }) => {
    await wait(1000);
    taken(() => setDraft(''));
    for (let i = 1; i <= ASK.length; i++) { setDraft(ASK.slice(0, i)); await wait(28); }
    await wait(300);
    setDraft(''); send(ASK);
  });

  return (
    <div ref={ref} style={{ width: '100%' }}>
      <Comments title="Pricing, frame 2" comments={comments} people={[AGENT, MAYA, JON]} value={draft} onValueChange={setDraft} onSend={send} />
    </div>
  );
}
04

Props

Everything Comments takes.

PropTypeWhat it does
titlerequiredstringWhat the thread is about, like "Pricing, frame 2"
pinReactNodeThe number on the pin, like 1
commentsrequiredCommentsEntry[]The thread: the first comment starts it, and every later one is a reply, indented under it
peopleCommentsPerson[]Who can be @mentioned. Typing @ and the start of a handle opens a menu of them
valuestringThe reply being written. Leave it out to let the field keep it
defaultValuestring
onValueChange(value: string) => void
onSend(text: string) => voidCalled with the reply when it is sent. Add it to `comments` to show it
placeholderstring
resolvedbooleanWhether the thread is resolved, which fades it. Leave it out to let the Resolve button toggle it
defaultResolvedboolean
onResolvedChange(resolved: boolean) => void
agreedstring[]The ids of the comments you agreed with. Leave it out to let the Agree buttons keep it
defaultAgreedstring[]
onAgreedChange(ids: string[]) => void
classNamestring
05

Code

The full source, as the CLI installs it.

Comments.tsxReact · 186 lines
'use client';
import { useId, useState, type KeyboardEvent, type ReactNode } from 'react';
import { cx } from '../lib/cx';
import { useControllable } from '../lib/hooks';
import { Icon } from '../lib/icons';
import { Avatar, avatarInk, enter, Think, Tip } from '../lib/motion';
import '../designme.css';
import './comments.css';

export interface CommentsPerson {
  name: string;
  /** Two letters for the avatar, like "MK" */
  initials: string;
  /** The avatar colour, like 'var(--dm-av-l)' */
  color: string;
  /** What follows the @ to mention them, like "maya". People with a handle show up in the mention menu */
  handle?: string;
}

export interface CommentsAction {
  label: string;
  /** Draws it as the accent button. The others are white with an outline */
  primary?: boolean;
  onClick: () => void;
}

export interface CommentsEntry {
  /** Keeps the comment in place when the list changes */
  id: string;
  author: CommentsPerson;
  /** The comment. A string gets its @mentions highlighted; an agent's reply fades in word by word as it grows */
  text: ReactNode;
  /** Like "2h" or "now" */
  time: string;
  /** Marks the agent's replies: their words fade in as `text` grows */
  agent?: boolean;
  /** Shows the thinking mark instead of the text while the agent works on a reply */
  thinking?: boolean;
  /** Buttons under the comment, like "Apply to all" */
  actions?: CommentsAction[];
  /** Replaces the buttons once one is used, like "Applied to 3 frames" */
  resolution?: string;
  /** How many other people agreed with it. Yours is added on top when you agree */
  agrees?: number;
}

export interface CommentsProps {
  /** What the thread is about, like "Pricing, frame 2" */
  title: string;
  /** The number on the pin, like 1 */
  pin?: ReactNode;
  /** The thread: the first comment starts it, and every later one is a reply, indented under it */
  comments: CommentsEntry[];
  /** Who can be @mentioned. Typing @ and the start of a handle opens a menu of them */
  people?: CommentsPerson[];
  /** The reply being written. Leave it out to let the field keep it */
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  /** Called with the reply when it is sent. Add it to `comments` to show it */
  onSend?: (text: string) => void;
  placeholder?: string;
  /** Whether the thread is resolved, which fades it. Leave it out to let the Resolve button toggle it */
  resolved?: boolean;
  defaultResolved?: boolean;
  onResolvedChange?: (resolved: boolean) => void;
  /** The ids of the comments you agreed with. Leave it out to let the Agree buttons keep it */
  agreed?: string[];
  defaultAgreed?: string[];
  onAgreedChange?: (ids: string[]) => void;
  className?: string;
}

/** Highlights @handles. With people given, only theirs; otherwise any @word */
function mentions(text: string, handles: string[] | null) {
  return text.split(/(@\w+)/).map((part, i) => {
    const hit = part.startsWith('@') && (!handles || handles.includes(part.slice(1).toLowerCase()));
    return hit ? <span key={i} className="dm-m">{part}</span> : part;
  });
}

function Comment({ c, handles, mine, onAgree }: { c: CommentsEntry; handles: string[] | null; mine: boolean; onAgree: () => void }) {
  const e = enter();
  let body: ReactNode = c.text;
  if (c.thinking) body = <Think />;
  else if (c.agent && typeof c.text === 'string') body = c.text.split(' ').map((w, i) => <span key={i} className="dm-w">{w} </span>);
  else if (typeof c.text === 'string') body = mentions(c.text, handles);
  const n = (c.agrees ?? 0) + (mine ? 1 : 0);
  return (
    <div className={cx('dm-cmx-c', c.agent && 'dm-ag', e.className)} style={e.style}>
      <Avatar initials={c.author.initials} color={c.author.color} />
      <div className="dm-b">
        <div className="dm-n">
          <b>{c.author.name}</b><span className="dm-t">{c.time}</span>
          {/* the quiet Agree button shows on hover or keyboard focus, and always on a touch screen */}
          <div className="dm-cmx-tools">
            <Tip label="Agree"><button type="button" className="dm-ib dm-cmx-rx" aria-label="Agree" aria-pressed={mine} onClick={onAgree}><Icon name="up-thumb" size="xs" /></button></Tip>
          </div>
        </div>
        <div className="dm-tx" style={c.thinking ? { color: 'var(--dm-accent-mark, var(--dm-accent))' } : undefined}>{body}</div>
        {n > 0 && (
          <div className="dm-cmx-rs">
            <button type="button" className="dm-cmx-rc" aria-pressed={mine} aria-label={'Agree, ' + n + (n === 1 ? ' person' : ' people') + (mine ? ', including you' : '')} onClick={onAgree}>
              <Icon name="up-thumb" size="xs" /><span className="dm-num">{n}</span>
            </button>
          </div>
        )}
        {(c.resolution || !!c.actions?.length) && (
          <div className={cx('dm-act', e.className)}>
            {c.resolution
              ? <span className="dm-cmx-rv dm-enter"><Icon name="ok" size="xs" />{c.resolution}</span>
              : c.actions!.map((a, i) => <button type="button" key={i} className={cx('dm-pill dm-sm', a.primary ? 'dm-primary' : 'dm-line')} onClick={a.onClick}>{a.label}</button>)}
          </div>
        )}
      </div>
    </div>
  );
}

/** A comment thread on a design, where you can @mention the agent and it replies. */
export function Comments({ title, pin = 1, comments, people, value, defaultValue = '', onValueChange, onSend, placeholder = 'Reply, or @agent to ask', resolved, defaultResolved = false, onResolvedChange, agreed, defaultAgreed = [], onAgreedChange, className }: CommentsProps) {
  const [draft, setDraft] = useControllable(value, defaultValue, onValueChange);
  const [done, setDone] = useControllable(resolved, defaultResolved, onResolvedChange);
  const [yes, setYes] = useControllable(agreed, defaultAgreed, onAgreedChange);
  const handles = people ? people.flatMap(p => (p.handle ? [p.handle.toLowerCase()] : [])) : null;
  const agree = (id: string) => setYes(yes.includes(id) ? yes.filter(x => x !== id) : [...yes, id]);

  // typing @ and the start of a handle offers the people it could be; Escape puts the menu away until the text changes
  const [dismissed, setDismissed] = useState<string | null>(null);
  const typed = draft.match(/@(\w*)$/);
  const matches = typed ? (people ?? []).filter(p => p.handle?.toLowerCase().startsWith(typed[1].toLowerCase())) : [];
  const menuOpen = matches.length > 0 && dismissed !== draft;
  const pick = (p: CommentsPerson) => setDraft(draft.replace(/@\w*$/, '@' + p.handle + ' '));
  // the highlighted person: the up and down arrows move it and wrap, and it starts on the first whenever the list changes
  const [hl, setHl] = useState({ at: 0, for: '' });
  const at = hl.for === draft ? Math.min(hl.at, matches.length - 1) : 0;
  const id = useId();

  const send = () => { const v = draft.trim(); if (!v) return; setDraft(''); onSend?.(v); };
  const key = (e: KeyboardEvent<HTMLInputElement>) => {
    if (menuOpen && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) { e.preventDefault(); setHl({ at: (at + (e.key === 'ArrowDown' ? 1 : -1) + matches.length) % matches.length, for: draft }); return; }
    if (e.key === 'Enter') { e.preventDefault(); if (menuOpen) pick(matches[at]); else send(); }
    if (e.key === 'Escape') setDismissed(draft);
  };

  const [head, ...replies] = comments;
  const one = (c: CommentsEntry) => <Comment key={c.id} c={c} handles={handles} mine={yes.includes(c.id)} onAgree={() => agree(c.id)} />;
  const status = done ? 'Resolved' : replies.length ? replies.length + (replies.length === 1 ? ' reply' : ' replies') : '';

  return (
    <div data-dm="" className={cx('dm-cmx dm-panel', done && 'dm-done', className)}>
      <div className="dm-cmx-h">
        <span className="dm-cmx-pin" aria-hidden="true">{pin}</span>
        <b>{title}</b>
        <span className="dm-cmx-st dm-num">{status}</span>
        <Tip label={done ? 'Reopen thread' : 'Resolve thread'}>
          <button type="button" className="dm-ib dm-cmx-res" aria-label="Resolve thread" aria-pressed={done} onClick={() => setDone(!done)}>
            <Icon name={done ? 'undo' : 'check'} size="xs" />
          </button>
        </Tip>
      </div>
      <div className="dm-cmx-th" aria-live="polite">
        {head && one(head)}
        {replies.length > 0 && <div className="dm-cmx-rp" role="group" aria-label="Replies">{replies.map(one)}</div>}
      </div>
      <div className="dm-cmx-in">
        <div className="dm-cmx-box">
          <input placeholder={placeholder} aria-label="Reply" spellCheck={false} autoComplete="off" value={draft} onChange={e => setDraft(e.target.value)} onKeyDown={key}
            role="combobox" aria-expanded={menuOpen} aria-autocomplete="list" aria-controls={`${id}-men`} aria-activedescendant={menuOpen ? `${id}-${at}` : undefined} />
          <button type="button" className="dm-cmx-go" aria-label="Send" disabled={!draft.trim()} onClick={send}><Icon name="up" size="xs" /></button>
        </div>
        <div className={cx('dm-pop dm-cmx-men', menuOpen && 'dm-open')} role="listbox" id={`${id}-men`} aria-label="Mention" inert={!menuOpen}>
          {matches.map((p, i) => (
            // mousedown, so the field keeps its focus
            <button type="button" key={p.handle} id={`${id}-${i}`} tabIndex={-1} className={cx('dm-mi', i === at && 'dm-hl')} role="option" aria-selected={i === at}
              onMouseEnter={() => setHl({ at: i, for: draft })} onMouseDown={e => { e.preventDefault(); pick(p); }}>
              <span className="dm-av" style={{ background: p.color, color: avatarInk(p.color), width: 20, height: 20, fontSize: 9 }} aria-hidden="true">{p.initials}</span>
              <span>{p.name}<span className="dm-d">@{p.handle}</span></span>
            </button>
          ))}
        </div>
      </div>
    </div>
  );
}
comments.cssReact · 61 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Comments ---------- */
/* a thread on one spot of a design: the first comment, then its replies indented on a thin line. Each comment is an avatar,
   a name, a time and the text; its Agree button shows on hover or keyboard focus (always, on a touch screen) */
.dm-cmx{padding:var(--dm-s-4);display:flex;flex-direction:column;gap:var(--dm-s-4);min-height:340px}
/* on a card the thread keeps the room its last reply takes, so it stays centred while replies arrive */
.dm-stage-in>.dm-cmx{min-height:inherit}
.dm-cmx-h{display:flex;align-items:center;gap:var(--dm-s-2);font-size:13.5px;margin:calc(-1 * var(--dm-s-1)) calc(-1 * var(--dm-s-1)) 0 0}
.dm-cmx-h b{font-weight:500}
.dm-cmx-st{flex:1;font-size:12px;color:var(--dm-muted)}
.dm-cmx-pin{width:20px;height:20px;border-radius:50% 50% 50% var(--dm-r-xs);background:var(--dm-accent);color:var(--dm-on-accent);display:grid;place-items:center;font-size:11px;font-weight:500;flex:none;transition:background var(--dm-t)}
.dm-cmx.dm-done .dm-cmx-pin{background:var(--dm-ok);color:var(--dm-surface)}
.dm-cmx-res{width:28px;height:28px;color:var(--dm-muted)}
.dm-cmx-th{display:flex;flex-direction:column;gap:var(--dm-s-4);flex:1}
.dm-cmx-rp{display:flex;flex-direction:column;gap:var(--dm-s-4);margin-left:var(--dm-s-3);padding-left:calc(var(--dm-s-5) + 1px);position:relative}
/* the replies' line starts just below the first comment's avatar, centred under it, and runs down beside every reply */
.dm-cmx-th>.dm-cmx-c:first-child{position:relative}
.dm-cmx-th>.dm-cmx-c:first-child:has(+ .dm-cmx-rp)::before,.dm-cmx-rp::before{content:'';position:absolute;width:1px;background:var(--dm-rail);pointer-events:none}
.dm-cmx-th>.dm-cmx-c:first-child:has(+ .dm-cmx-rp)::before{left:calc(var(--dm-s-3) - .5px);top:calc(24px + var(--dm-s-1));bottom:calc(-1 * var(--dm-s-4))}
.dm-cmx-rp::before{left:-.5px;top:0;bottom:0}
.dm-cmx-c{display:flex;gap:var(--dm-s-3);min-width:0}
.dm-cmx-c .dm-av{width:24px;height:24px;font-size:10px}
.dm-cmx-c .dm-b{flex:1;display:flex;flex-direction:column;min-width:0}
.dm-cmx-c .dm-n{display:flex;align-items:center;gap:var(--dm-s-2);height:24px;font-size:13px}
.dm-cmx-c .dm-n b{font-weight:500;white-space:nowrap}
.dm-cmx-c .dm-n .dm-t{font-size:12px;color:var(--dm-muted)}
.dm-cmx-c .dm-tx{font-size:13.5px;line-height:1.5;color:var(--dm-fg);overflow-wrap:anywhere}
.dm-cmx-c .dm-m{color:var(--dm-accent-fg,var(--dm-accent));background:var(--dm-sel);border-radius:var(--dm-r-xs);padding:0 2px}
.dm-cmx-c.dm-ag .dm-tx .dm-w{animation:dm-cmw .4s var(--dm-ease) both}
@keyframes dm-cmw{from{opacity:0;filter:blur(3px)}to{opacity:1;filter:none}}
.dm-cmx-c .dm-act{display:flex;flex-wrap:wrap;gap:var(--dm-s-2);margin-top:var(--dm-s-2)}
/* the quiet Agree button beside the time */
.dm-cmx-tools{margin-left:auto;display:flex;gap:var(--dm-s-1);transition:opacity var(--dm-t-fast)}
.dm-cmx-tools .dm-ib{width:26px;height:26px;color:var(--dm-muted)}
.dm-cmx-tools .dm-ib[aria-pressed="true"]{color:var(--dm-fg)}
@media (hover:hover) and (pointer:fine){.dm-cmx-tools{opacity:0}}
.dm-cmx-c .dm-n:focus-within .dm-cmx-tools{opacity:1}
@media (hover:hover) and (pointer:fine){.dm-cmx-c:hover .dm-cmx-tools{opacity:1}}
/* how many agreed: a small chip under the text, a step stronger once you have */
.dm-cmx-rs{display:flex;margin-top:var(--dm-s-2)}
.dm-cmx-rs:has(.dm-cmx-rc[hidden]){display:none}
.dm-cmx-rc{display:inline-flex;align-items:center;gap:var(--dm-s-1);height:22px;padding:0 var(--dm-s-2);border-radius:var(--dm-r-pill);font-size:12px;color:var(--dm-fg2);background:var(--dm-surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-line) 50%,transparent);transition:box-shadow var(--dm-t-fast),color var(--dm-t-fast)}
@media (hover:hover) and (pointer:fine){.dm-cmx-rc:hover{box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-rail) 50%,transparent)}}
.dm-cmx-rc[aria-pressed="true"]{color:var(--dm-fg);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-faint) 50%,transparent)}
/* the reply field: a dimmed edge at rest, one step stronger while it has the focus, and no ring */
.dm-cmx-in{position:relative}
.dm-cmx-box{display:flex;align-items:center;gap:6px;height:var(--dm-field-h);padding:0 5px 0 var(--dm-s-3);border-radius:var(--dm-r-field);background:var(--dm-surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-field-line) 50%,transparent);transition:box-shadow var(--dm-t-fast)}
@supports (corner-shape:squircle){.dm-cmx-box{corner-shape:squircle;border-radius:var(--dm-r-field-sq)}}
.dm-cmx-box:has(input:focus){box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-field-line-strong) 50%,transparent)}
.dm-cmx-box input{flex:1;min-width:0;border:0;outline:0;background:transparent;font:inherit;font-size:13.5px;color:var(--dm-fg)}
.dm-cmx-box input::placeholder{color:var(--dm-muted)}
.dm-cmx-go{width:30px;height:30px;border-radius:50%;background:var(--dm-accent);color:var(--dm-on-accent);display:grid;place-items:center;flex:none;transition:background var(--dm-t-fast),transform .15s}
@media (hover:hover) and (pointer:fine){.dm-cmx-go:hover{background:var(--dm-accent-2)}}
.dm-cmx-go:active{transform:scale(.94)}
.dm-cmx-go[disabled]{opacity:1;cursor:not-allowed;background:var(--dm-dis-hatch),var(--dm-dis-fill);color:var(--dm-dis);box-shadow:var(--dm-soft-ring);transform:none}
@media (hover:hover) and (pointer:fine){.dm-cmx-go[disabled]:hover{opacity:1;cursor:not-allowed;background:var(--dm-dis-hatch),var(--dm-dis-fill);color:var(--dm-dis);box-shadow:var(--dm-soft-ring);transform:none}}
.dm-cmx-men{bottom:46px;left:0;min-width:210px}
.dm-cmx.dm-done .dm-cmx-th,.dm-cmx.dm-done .dm-cmx-in{opacity:.45;pointer-events:none;transition:opacity .4s}
.dm-cmx-rv{font-size:12.5px;color:var(--dm-ok);display:flex;align-items:center;gap:6px}
:root:not([data-style="soft"]) .dm-cmx-c .dm-m:not(:where([data-style="soft"],[data-style="soft"] *)){box-shadow:inset 0 -1px 0 var(--dm-accent-mark)}
comments.htmlHTML · 10 lines
<div class="card" data-c="comments">
  <div class="stage-in">
    <div class="cmx panel">
      <div class="cmx-h"><span class="cmx-pin" aria-hidden="true">1</span><b>Pricing, frame 2</b><span class="cmx-st num"></span><button class="ib cmx-res" aria-label="Resolve thread" aria-pressed="false" data-tip="Resolve thread"><svg class="i xs"><use href="#i-check"/></svg></button></div>
      <div class="cmx-th" aria-live="polite"></div>
      <div class="cmx-in"><div class="cmx-box"><input placeholder="Reply, or @agent to ask" aria-label="Reply" spellcheck="false" autocomplete="off"><button class="cmx-go" aria-label="Send" disabled><svg class="i xs"><use href="#i-up"/></svg></button></div><div class="pop cmx-men" role="listbox"></div></div>
    </div>
  </div>
</div>
comments.cssHTML · 60 lines
/* ---------- Comments ---------- */
/* a thread on one spot of a design: the first comment, then its replies indented on a thin line. Each comment is an avatar,
   a name, a time and the text; its Agree button shows on hover or keyboard focus (always, on a touch screen) */
.cmx{padding:var(--s-4);display:flex;flex-direction:column;gap:var(--s-4);min-height:340px}
/* on a card the thread keeps the room its last reply takes, so it stays centred while replies arrive */
.stage-in>.cmx{min-height:inherit}
.cmx-h{display:flex;align-items:center;gap:var(--s-2);font-size:13.5px;margin:calc(-1 * var(--s-1)) calc(-1 * var(--s-1)) 0 0}
.cmx-h b{font-weight:500}
.cmx-st{flex:1;font-size:12px;color:var(--muted)}
.cmx-pin{width:20px;height:20px;border-radius:50% 50% 50% var(--r-xs);background:var(--accent);color:var(--on-accent);display:grid;place-items:center;font-size:11px;font-weight:500;flex:none;transition:background var(--t)}
.cmx.done .cmx-pin{background:var(--ok);color:var(--surface)}
.cmx-res{width:28px;height:28px;color:var(--muted)}
.cmx-th{display:flex;flex-direction:column;gap:var(--s-4);flex:1}
.cmx-rp{display:flex;flex-direction:column;gap:var(--s-4);margin-left:var(--s-3);padding-left:calc(var(--s-5) + 1px);position:relative}
/* the replies' line starts just below the first comment's avatar, centred under it, and runs down beside every reply */
.cmx-th>.cmx-c:first-child{position:relative}
.cmx-th>.cmx-c:first-child:has(+ .cmx-rp)::before,.cmx-rp::before{content:'';position:absolute;width:1px;background:var(--rail);pointer-events:none}
.cmx-th>.cmx-c:first-child:has(+ .cmx-rp)::before{left:calc(var(--s-3) - .5px);top:calc(24px + var(--s-1));bottom:calc(-1 * var(--s-4))}
.cmx-rp::before{left:-.5px;top:0;bottom:0}
.cmx-c{display:flex;gap:var(--s-3);min-width:0}
.cmx-c .av{width:24px;height:24px;font-size:10px}
.cmx-c .b{flex:1;display:flex;flex-direction:column;min-width:0}
.cmx-c .n{display:flex;align-items:center;gap:var(--s-2);height:24px;font-size:13px}
.cmx-c .n b{font-weight:500;white-space:nowrap}
.cmx-c .n .t{font-size:12px;color:var(--muted)}
.cmx-c .tx{font-size:13.5px;line-height:1.5;color:var(--fg);overflow-wrap:anywhere}
.cmx-c .m{color:var(--accent-fg,var(--accent));background:var(--sel);border-radius:var(--r-xs);padding:0 2px}
.cmx-c.ag .tx .w{animation:cmw .4s var(--ease) both}
@keyframes cmw{from{opacity:0;filter:blur(3px)}to{opacity:1;filter:none}}
.cmx-c .act{display:flex;flex-wrap:wrap;gap:var(--s-2);margin-top:var(--s-2)}
/* the quiet Agree button beside the time */
.cmx-tools{margin-left:auto;display:flex;gap:var(--s-1);transition:opacity var(--t-fast)}
.cmx-tools .ib{width:26px;height:26px;color:var(--muted)}
.cmx-tools .ib[aria-pressed="true"]{color:var(--fg)}
@media (hover:hover) and (pointer:fine){.cmx-tools{opacity:0}}
.cmx-c .n:focus-within .cmx-tools{opacity:1}
@media (hover:hover) and (pointer:fine){.cmx-c:hover .cmx-tools{opacity:1}}
/* how many agreed: a small chip under the text, a step stronger once you have */
.cmx-rs{display:flex;margin-top:var(--s-2)}
.cmx-rs:has(.cmx-rc[hidden]){display:none}
.cmx-rc{display:inline-flex;align-items:center;gap:var(--s-1);height:22px;padding:0 var(--s-2);border-radius:var(--r-pill);font-size:12px;color:var(--fg2);background:var(--surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--line) 50%,transparent);transition:box-shadow var(--t-fast),color var(--t-fast)}
@media (hover:hover) and (pointer:fine){.cmx-rc:hover{box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--rail) 50%,transparent)}}
.cmx-rc[aria-pressed="true"]{color:var(--fg);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--faint) 50%,transparent)}
/* the reply field: a dimmed edge at rest, one step stronger while it has the focus, and no ring */
.cmx-in{position:relative}
.cmx-box{display:flex;align-items:center;gap:6px;height:var(--field-h);padding:0 5px 0 var(--s-3);border-radius:var(--r-field);background:var(--surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--field-line) 50%,transparent);transition:box-shadow var(--t-fast)}
@supports (corner-shape:squircle){.cmx-box{corner-shape:squircle;border-radius:var(--r-field-sq)}}
.cmx-box:has(input:focus){box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--field-line-strong) 50%,transparent)}
.cmx-box input{flex:1;min-width:0;border:0;outline:0;background:transparent;font:inherit;font-size:13.5px;color:var(--fg)}
.cmx-box input::placeholder{color:var(--muted)}
.cmx-go{width:30px;height:30px;border-radius:50%;background:var(--accent);color:var(--on-accent);display:grid;place-items:center;flex:none;transition:background var(--t-fast),transform .15s}
@media (hover:hover) and (pointer:fine){.cmx-go:hover{background:var(--accent-2)}}
.cmx-go:active{transform:scale(.94)}
.cmx-go[disabled]{opacity:1;cursor:not-allowed;background:var(--dis-hatch),var(--dis-fill);color:var(--dis);box-shadow:var(--soft-ring);transform:none}
@media (hover:hover) and (pointer:fine){.cmx-go[disabled]:hover{opacity:1;cursor:not-allowed;background:var(--dis-hatch),var(--dis-fill);color:var(--dis);box-shadow:var(--soft-ring);transform:none}}
.cmx-men{bottom:46px;left:0;min-width:210px}
.cmx.done .cmx-th,.cmx.done .cmx-in{opacity:.45;pointer-events:none;transition:opacity .4s}
.cmx-rv{font-size:12.5px;color:var(--ok);display:flex;align-items:center;gap:6px}
:root:not([data-style="soft"]) .cmx-c .m:not(:where([data-style="soft"],[data-style="soft"] *)){box-shadow:inset 0 -1px 0 var(--accent-mark)}
comments.jsHTML · 62 lines
DM.def('comments', (r, c) => {
  r.innerHTML = r.innerHTML.replace('{check}', I('check','xs')).replace('{up}', I('up','xs'));
  const root = $('.cmx', r), th = $('.cmx-th', r), inp = $('.cmx-box input', r), go = $('.cmx-go', r), men = $('.cmx-men', r), st = $('.cmx-st', r), rs = $('.cmx-res', r);
  const P = { MK:['Maya Kim','var(--av-l)'], JO:['Jon Ortiz','var(--av-m)'], DA:['Design agent','var(--av-b)'] };
  const esc = t => t.replace(/[&<>"]/g, ch => ({ '&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;' })[ch]);
  const fmt = t => esc(t).replace(/@(agent|maya|jon)\b/gi, m => `<span class="m">${m}</span>`);
  // the first comment starts the thread; every later one is a reply, indented on a thin line under it
  let rp = null;
  const count = () => { const n = rp ? rp.children.length : 0; st.textContent = root.classList.contains('done') ? 'Resolved' : n ? n + (n === 1 ? ' reply' : ' replies') : ''; };
  function add(who, t, when, ag, agreed = 0){
    const el = html(`<div class="cmx-c${ag ? ' ag' : ''}">${AV(who, P[who][1])}<div class="b"><div class="n"><b>${P[who][0]}</b><span class="t">${when}</span><div class="cmx-tools"><button class="ib cmx-rx" aria-label="Agree" aria-pressed="false" data-tip="Agree">${I('up-thumb','xs')}</button></div></div><div class="tx">${ag ? '' : fmt(t)}</div><div class="cmx-rs"><button class="cmx-rc" aria-pressed="false" hidden>${I('up-thumb','xs')}<span class="num"></span></button></div></div></div>`);
    el._agree = { n:agreed, mine:false }; agree(el);
    $('.cmx-rx', el).onclick = () => agree(el, true); $('.cmx-rc', el).onclick = () => agree(el, true);
    if (!th.firstElementChild) th.appendChild(el);
    else { if (!rp){ rp = html('<div class="cmx-rp" role="group" aria-label="Replies"></div>'); th.appendChild(rp); } rp.appendChild(el); }
    enter(el); count(); return el;
  }
  // Agree: the button by the time and the chip under the text both toggle yours, and the chip counts everyone's
  function agree(el, flip){
    const a = el._agree; if (flip) a.mine = !a.mine;
    const n = a.n + (a.mine ? 1 : 0), b = $('.cmx-rx', el), chip = $('.cmx-rc', el), had = document.activeElement === chip;
    b.setAttribute('aria-pressed', a.mine); chip.setAttribute('aria-pressed', a.mine);
    chip.setAttribute('aria-label', 'Agree, ' + n + (n === 1 ? ' person' : ' people') + (a.mine ? ', including you' : ''));
    $('.num', chip).textContent = n; chip.hidden = !n;
    if (had && !n) b.focus({ preventScroll:true });
  }
  add('MK', 'The secondary button feels too faint next to the CTA.', '2h', false, 1);
  add('JO', 'Agree. Can we keep it outlined though?', '1h');
  async function agent(q){
    const el = add('DA', '', 'now', true), tx = $('.tx', el); tx.innerHTML = '<span class="think on" style="color:var(--accent-mark,var(--accent))"><i></i><i></i><i></i><i></i></span>'; await c.wait(900); tx.innerHTML = '';
    const a = /outline|border/i.test(q + ' outlined') ? 'Kept it outlined and raised the text to #5B6472, which passes AA at 5.9 : 1. Want me to apply it to the other 2 frames?' : 'On it. I will post the change here when it is ready.';
    for (const w of a.split(' ')){ tx.insertAdjacentHTML('beforeend', `<span class="w">${w} </span>`); await c.wait(40); }
    const act = html(`<div class="act"><button class="pill primary sm">Apply to all</button><button class="pill line sm">Just this one</button></div>`); $('.b', el).appendChild(act); enter(act);
    $$('button', act).forEach((b, i) => b.onclick = () => { act.innerHTML = `<span class="cmx-rv">${I('ok','xs')}${i ? 'Applied to frame 2' : 'Applied to 3 frames'}</span>`; enter(act.firstChild); });
  }
  function send(){ const v = inp.value.trim(); if (!v) return; add('MK', v, 'now'); inp.value = ''; go.disabled = true; shut(); if (/@agent/i.test(v)) c.run(() => agent(v)); }
  const opts = [['DA','agent','Design agent'],['MK','maya','Maya Kim'],['JO','jon','Jon Ortiz']];
  // the reply box drives the list of people to mention: it is a combobox, and the highlighted row is its active option
  men.id = 'cmx-m-' + Math.random().toString(36).slice(2, 7); men.setAttribute('aria-label', 'Mention');
  inp.setAttribute('role', 'combobox'); inp.setAttribute('aria-autocomplete', 'list'); inp.setAttribute('aria-expanded', 'false'); inp.setAttribute('aria-controls', men.id);
  let hl = 0;
  const mens = () => $$('.mi', men);
  const light = k => { const all = mens(); if (!all.length){ inp.removeAttribute('aria-activedescendant'); return; } hl = (k + all.length) % all.length; all.forEach((b, j) => { b.classList.toggle('hl', j === hl); b.setAttribute('aria-selected', j === hl); }); inp.setAttribute('aria-activedescendant', all[hl].id); };
  const shut = () => { men.classList.remove('open'); inp.setAttribute('aria-expanded', 'false'); inp.removeAttribute('aria-activedescendant'); };
  inp.oninput = () => { go.disabled = !inp.value.trim(); const m = inp.value.match(/@(\w*)$/);
    if (m){ const list = opts.filter(o => o[1].startsWith(m[1].toLowerCase())); men.innerHTML = list.map(o => `<button class="mi" role="option" tabindex="-1" id="${men.id}-${o[1]}" data-h="${o[1]}">${AV(o[0], P[o[0]][1]).replace('class="av" style="', 'class="av" style="width:20px;height:20px;font-size:9px;')}<span>${o[2]}<span class="d">@${o[1]}</span></span></button>`).join(''); men.classList.toggle('open', list.length > 0); inp.setAttribute('aria-expanded', list.length > 0); light(0);
      $$('.mi', men).forEach(b => b.onmousedown = e => { e.preventDefault(); inp.value = inp.value.replace(/@\w*$/, '@' + b.dataset.h + ' '); shut(); go.disabled = false; inp.focus({ preventScroll:true }); }); }
    else shut(); };
  // with the mention list open, the up and down arrows move through it and wrap, Enter picks, and Escape closes it
  inp.onkeydown = e => {
    const open = men.classList.contains('open') && mens().length;
    if (open && (e.key === 'ArrowDown' || e.key === 'ArrowUp')){ e.preventDefault(); light(hl + (e.key === 'ArrowDown' ? 1 : -1)); return; }
    if (e.key === 'Enter'){ const f = mens()[hl]; if (open && f){ f.onmousedown(e); } else send(); }
    if (e.key === 'Escape') shut(); };
  go.onclick = send;
  // Resolve fades the thread back and turns the pin green; pressed again it reopens
  rs.onclick = () => { const d = root.classList.toggle('done'); hideTip(); rs.innerHTML = I(d ? 'undo' : 'check', 'xs'); rs.setAttribute('aria-pressed', d); rs.dataset.tip = d ? 'Reopen thread' : 'Resolve thread'; count(); };
  // the preview asks the agent in the thread on its own; clicking, focusing or typing in it takes over, and what it had
  // typed goes
  c.auto(1000, () => c.run(async () => { await c.type(inp, '@agent can you keep it outlined but darker?', { ms:28, each:() => inp.oninput(), keep:true }); shut(); await c.step(300); send(); }));
});
06

Questions

What people ask about it.

How do I stream the agent's reply into the thread?
Add a comment with agent: true and thinking: true, then set thinking to false and keep updating its text as tokens arrive. Each word fades in as it is added.
Does the component call my agent when someone types @agent?
No. onSend gives you the reply text, and you decide what happens. The example checks for @agent, adds the person's comment, then adds the agent's reply.
How do I let the person approve the agent's change?
Give the agent's comment actions, such as Apply to all and Just this one. In each onClick, apply the change and set resolution to a short line that replaces the buttons.
How do I save who agreed with a comment?
Pass agreed with the ids of the comments the current person agreed with, and update it in onAgreedChange. Put everyone else's count in each comment's agrees.
07

More in Collaboration

Threads, inboxes and boards for teams working alongside an agent.

NotificationsAn inbox where agent updates and mentions arrive live.
BannerAn announcement on a tint of its tone, with its own line drawing.
Invite to collaborateAdd teammates by email as chips, set their role, send.
ReactionsQuick reactions on an agent update, with counts that pop.
Profile menuThe account menu behind an avatar: switch workspace, theme, shortcuts and sign out.
Context switcherThe organisation, project and environment you are in, each part a searchable switcher.
Filter menuAdd filters from a menu, see them as chips, and watch the count change.
Ticket listSupport tickets the agent has triaged, with suggested replies.
All 10 in Collaboration →