rift-ds 1.5.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +8 -10
  2. package/bin/rift-ds.mjs +111 -37
  3. package/components/AgentRail/AgentRail.css +2 -2
  4. package/components/AppSidebar/AppSidebar.css +6 -0
  5. package/components/AppSidebar/AppSidebar.d.ts +12 -2
  6. package/components/AppSidebar/AppSidebar.js +11 -1
  7. package/components/Attachment/Attachment.css +14 -0
  8. package/components/Attachment/AttachmentGroup.d.ts +2 -2
  9. package/components/Attachment/AttachmentGroup.js +7 -2
  10. package/components/Composer/Composer.css +88 -4
  11. package/components/Composer/Composer.d.ts +21 -0
  12. package/components/Composer/Composer.js +199 -14
  13. package/components/Mention/Mention.css +195 -0
  14. package/components/Mention/Mention.d.ts +68 -0
  15. package/components/Mention/Mention.js +145 -0
  16. package/components/Mention/mentions.d.ts +81 -0
  17. package/components/Mention/mentions.js +83 -0
  18. package/components/PixelAvatar/PixelAvatar.css +79 -0
  19. package/components/PixelAvatar/PixelAvatar.d.ts +47 -0
  20. package/components/PixelAvatar/PixelAvatar.js +90 -0
  21. package/components/PixelAvatar/pixelInks.d.ts +27 -0
  22. package/components/PixelAvatar/pixelInks.js +45 -0
  23. package/components/PromptSuggestions/PromptSuggestions.css +218 -0
  24. package/components/PromptSuggestions/PromptSuggestions.d.ts +10 -3
  25. package/components/PromptSuggestions/PromptSuggestions.js +26 -3
  26. package/components/ShaderField/ShaderField.d.ts +3 -3
  27. package/components/SourceChip/SourceChip.css +211 -11
  28. package/components/SourceChip/SourceChip.d.ts +33 -10
  29. package/components/SourceChip/SourceChip.js +174 -22
  30. package/components/StatusDot/StatusDot.css +36 -13
  31. package/components/StatusDot/StatusDot.d.ts +16 -4
  32. package/components/StatusDot/StatusDot.js +25 -5
  33. package/components/ThreadPanel/ThreadPanel.css +26 -11
  34. package/components/ThreadPanel/ThreadPanel.d.ts +18 -4
  35. package/components/ThreadPanel/ThreadPanel.js +52 -10
  36. package/components/ThreadTabs/ThreadTabs.css +18 -8
  37. package/components/ThreadTabs/ThreadTabs.d.ts +8 -2
  38. package/components/ThreadTabs/ThreadTabs.js +24 -5
  39. package/components/Widget/Widget.css +207 -0
  40. package/components/Widget/Widget.d.ts +83 -0
  41. package/components/Widget/Widget.js +112 -0
  42. package/components/registry.json +26 -2
  43. package/components/registry.json.d.ts +26 -2
  44. package/components/registry.json.js +1 -1
  45. package/index.d.ts +3 -0
  46. package/index.js +21 -0
  47. package/package.json +5 -1
  48. package/tokens/registry.json +6 -0
  49. package/tokens/registry.json.d.ts +6 -0
  50. package/tokens/registry.json.js +1 -1
  51. package/tokens/tokens-dark.css +9 -0
  52. package/tokens/tokens-light.css +14 -0
@@ -1,30 +1,182 @@
1
+ "use client";
1
2
  import { jsx, jsxs, Fragment } from "react/jsx-runtime";
2
- import React from "react";
3
+ import React, { useState, useRef, useId, useEffect, useLayoutEffect } from "react";
3
4
  import "./SourceChip.css";
4
5
  import "../../fonts/material-symbols.css";
5
- const SourceChip = React.forwardRef(
6
- ({ title, index, icon, href, className = "", ...rest }, ref) => {
7
- const baseClass = "ds-source-chip";
8
- const classes = [baseClass, href ? `${baseClass}--link` : "", className].filter(Boolean).join(" ");
9
- const leading = index !== void 0 ? /* @__PURE__ */ jsx("span", { className: `${baseClass}__index`, children: index }) : icon ? /* @__PURE__ */ jsx("span", { className: `${baseClass}__icon`, "aria-hidden": "true", children: typeof icon === "string" ? /* @__PURE__ */ jsx("span", { className: "material-symbols-rounded", children: icon }) : icon }) : null;
10
- const content = /* @__PURE__ */ jsxs(Fragment, { children: [
11
- leading,
12
- /* @__PURE__ */ jsx("span", { className: `${baseClass}__title`, children: title })
13
- ] });
14
- if (href) {
15
- return /* @__PURE__ */ jsx("a", { ...rest, ref, className: classes, href, children: content });
6
+ import { MOTION_HOVER_SHOW_DELAY_MS, MOTION_HOVER_HIDE_DELAY_MS } from "../../tokens/motion.js";
7
+ const PREVIEW_EDGE_MARGIN = 8;
8
+ let closeOpenPreview = null;
9
+ function clipBounds(el) {
10
+ const bounds = { top: 0, left: 0, right: window.innerWidth, bottom: window.innerHeight };
11
+ for (let node = el.parentElement; node; node = node.parentElement) {
12
+ const { overflowX, overflowY } = getComputedStyle(node);
13
+ const rect = node.getBoundingClientRect();
14
+ if (overflowX !== "visible") {
15
+ bounds.left = Math.max(bounds.left, rect.left);
16
+ bounds.right = Math.min(bounds.right, rect.right);
17
+ }
18
+ if (overflowY !== "visible") {
19
+ bounds.top = Math.max(bounds.top, rect.top);
20
+ bounds.bottom = Math.min(bounds.bottom, rect.bottom);
16
21
  }
17
- return /* @__PURE__ */ jsx(
18
- "span",
19
- {
20
- ...rest,
21
- ref,
22
- className: classes,
23
- children: content
24
- }
25
- );
26
22
  }
27
- );
23
+ return bounds;
24
+ }
25
+ const SourceChip = React.forwardRef(({ title, index, icon = "language", href, excerpt, source, logo, meta, className = "", ...rest }, ref) => {
26
+ const baseClass = "ds-source-chip";
27
+ const hasPreview = Boolean(excerpt);
28
+ const [visible, setVisible] = useState(false);
29
+ const showTimeoutRef = useRef(void 0);
30
+ const hideTimeoutRef = useRef(void 0);
31
+ const hostRef = useRef(null);
32
+ const panelRef = useRef(null);
33
+ const panelId = useId();
34
+ const clearTimers = () => {
35
+ clearTimeout(showTimeoutRef.current);
36
+ clearTimeout(hideTimeoutRef.current);
37
+ };
38
+ const show = () => {
39
+ clearTimeout(hideTimeoutRef.current);
40
+ showTimeoutRef.current = setTimeout(() => setVisible(true), MOTION_HOVER_SHOW_DELAY_MS);
41
+ };
42
+ const hide = () => {
43
+ clearTimeout(showTimeoutRef.current);
44
+ hideTimeoutRef.current = setTimeout(() => setVisible(false), MOTION_HOVER_HIDE_DELAY_MS);
45
+ };
46
+ const showNow = () => {
47
+ clearTimers();
48
+ setVisible(true);
49
+ };
50
+ useEffect(() => clearTimers, []);
51
+ useEffect(() => {
52
+ if (!visible) return;
53
+ const close = () => {
54
+ clearTimers();
55
+ setVisible(false);
56
+ };
57
+ closeOpenPreview?.();
58
+ closeOpenPreview = close;
59
+ const onKeyDown = (e) => {
60
+ if (e.key === "Escape") close();
61
+ };
62
+ const onPointerDown = (e) => {
63
+ if (!hostRef.current?.contains(e.target)) close();
64
+ };
65
+ document.addEventListener("keydown", onKeyDown);
66
+ document.addEventListener("pointerdown", onPointerDown);
67
+ return () => {
68
+ if (closeOpenPreview === close) closeOpenPreview = null;
69
+ document.removeEventListener("keydown", onKeyDown);
70
+ document.removeEventListener("pointerdown", onPointerDown);
71
+ };
72
+ }, [visible]);
73
+ useLayoutEffect(() => {
74
+ const host = hostRef.current;
75
+ const panel = panelRef.current;
76
+ if (!visible || !host || !panel) return;
77
+ const bounds = clipBounds(host);
78
+ const anchor = host.getBoundingClientRect();
79
+ const width = panel.offsetWidth;
80
+ const height = panel.offsetHeight;
81
+ const roomAbove = anchor.top - bounds.top - PREVIEW_EDGE_MARGIN;
82
+ const roomBelow = bounds.bottom - anchor.bottom - PREVIEW_EDGE_MARGIN;
83
+ const side = roomAbove >= height || roomAbove >= roomBelow ? "top" : "bottom";
84
+ const minLeft = bounds.left + PREVIEW_EDGE_MARGIN;
85
+ const maxLeft = bounds.right - PREVIEW_EDGE_MARGIN - width;
86
+ const left = Math.max(minLeft, Math.min(anchor.left, maxLeft));
87
+ panel.dataset.side = side;
88
+ panel.style.setProperty("--ds-source-chip-shift", `${Math.round(left - anchor.left)}px`);
89
+ }, [visible]);
90
+ const element = href ? "a" : hasPreview ? "button" : "span";
91
+ const classes = [
92
+ baseClass,
93
+ href ? `${baseClass}--link` : "",
94
+ element !== "span" ? `${baseClass}--interactive` : "",
95
+ visible ? `${baseClass}--open` : "",
96
+ className
97
+ ].filter(Boolean).join(" ");
98
+ const logoMark = logo ? typeof logo === "string" ? /* @__PURE__ */ jsx("img", { src: logo, alt: "" }) : logo : null;
99
+ const leading = index !== void 0 ? /* @__PURE__ */ jsx("span", { className: `${baseClass}__index`, children: /* @__PURE__ */ jsx("span", { className: `${baseClass}__numeral`, children: index }) }) : logoMark ? /* @__PURE__ */ jsx("span", { className: `${baseClass}__logo`, "aria-hidden": "true", children: logoMark }) : icon ? /* @__PURE__ */ jsx("span", { className: `${baseClass}__icon`, "aria-hidden": "true", children: typeof icon === "string" ? /* @__PURE__ */ jsx("span", { className: "material-symbols-rounded", children: icon }) : icon }) : null;
100
+ const content = /* @__PURE__ */ jsxs(Fragment, { children: [
101
+ leading,
102
+ /* @__PURE__ */ jsx("span", { className: `${baseClass}__title`, children: title })
103
+ ] });
104
+ const describedBy = hasPreview ? panelId : void 0;
105
+ const chip = element === "a" ? /* @__PURE__ */ jsx(
106
+ "a",
107
+ {
108
+ "aria-describedby": describedBy,
109
+ ...rest,
110
+ ref,
111
+ className: classes,
112
+ href,
113
+ children: content
114
+ }
115
+ ) : element === "button" ? /* @__PURE__ */ jsx(
116
+ "button",
117
+ {
118
+ type: "button",
119
+ "aria-describedby": describedBy,
120
+ ...rest,
121
+ ref,
122
+ className: classes,
123
+ children: content
124
+ }
125
+ ) : /* @__PURE__ */ jsx(
126
+ "span",
127
+ {
128
+ ...rest,
129
+ ref,
130
+ className: classes,
131
+ children: content
132
+ }
133
+ );
134
+ if (!hasPreview) return chip;
135
+ const handleBlur = (event) => {
136
+ if (!event.currentTarget.contains(event.relatedTarget)) hide();
137
+ };
138
+ return /* @__PURE__ */ jsxs(
139
+ "span",
140
+ {
141
+ ref: hostRef,
142
+ className: `${baseClass}-host`,
143
+ onMouseEnter: show,
144
+ onMouseLeave: hide,
145
+ onFocus: show,
146
+ onBlur: handleBlur,
147
+ onClick: showNow,
148
+ children: [
149
+ chip,
150
+ /* @__PURE__ */ jsxs(
151
+ "span",
152
+ {
153
+ ref: panelRef,
154
+ id: panelId,
155
+ role: "tooltip",
156
+ className: `${baseClass}__preview${visible ? ` ${baseClass}__preview--visible` : ""}`,
157
+ children: [
158
+ (logo || source || meta || href) && /* @__PURE__ */ jsxs("span", { className: `${baseClass}__preview-top`, children: [
159
+ logoMark && /* @__PURE__ */ jsx("span", { className: `${baseClass}__logo`, "aria-hidden": "true", children: logoMark }),
160
+ source && /* @__PURE__ */ jsx("span", { className: `${baseClass}__preview-source`, children: source }),
161
+ meta && /* @__PURE__ */ jsx("span", { className: `${baseClass}__preview-meta`, children: meta }),
162
+ href && /* @__PURE__ */ jsx(
163
+ "span",
164
+ {
165
+ className: `${baseClass}__preview-open material-symbols-rounded`,
166
+ "aria-hidden": "true",
167
+ children: "arrow_outward"
168
+ }
169
+ )
170
+ ] }),
171
+ /* @__PURE__ */ jsx("span", { className: `${baseClass}__preview-title`, children: title }),
172
+ /* @__PURE__ */ jsx("span", { className: `${baseClass}__preview-excerpt`, children: excerpt })
173
+ ]
174
+ }
175
+ )
176
+ ]
177
+ }
178
+ );
179
+ });
28
180
  SourceChip.displayName = "SourceChip";
29
181
  export {
30
182
  SourceChip
@@ -1,7 +1,7 @@
1
1
  /* ============================================
2
2
  STATUS DOT COMPONENT
3
3
  Bare presence/status indicator with optional
4
- label and live pulse
4
+ label, hollow outline and live pulse
5
5
  ============================================ */
6
6
 
7
7
  /* Base */
@@ -16,6 +16,7 @@
16
16
  position: relative;
17
17
  flex-shrink: 0;
18
18
  border-radius: var(--radius-pill);
19
+ background-color: var(--ds-status-dot-color);
19
20
  }
20
21
 
21
22
  .ds-status-dot__label {
@@ -29,7 +30,14 @@
29
30
  }
30
31
 
31
32
  /* Sizes — the dot rides the icon scale at half-diameter, so it can never
32
- drift from the glyphs it sits beside. */
33
+ drift from the glyphs it sits beside. `xs` is the one step off it: the
34
+ list-row mark sits on the gap scale, because no icon token is that small
35
+ and 8px matches a title's cap height. */
36
+
37
+ .ds-status-dot--xs .ds-status-dot__indicator {
38
+ width: var(--gap-200); /* 8px */
39
+ height: var(--gap-200);
40
+ }
33
41
 
34
42
  .ds-status-dot--sm .ds-status-dot__indicator {
35
43
  width: calc(var(--icon-size-500) / 2); /* 10px */
@@ -49,24 +57,39 @@
49
57
  /* Variants — the plain-surface icon steps, tuned to hold on bare
50
58
  containers where the status text tints wash out. */
51
59
 
52
- .ds-status-dot--info .ds-status-dot__indicator {
53
- background-color: var(--color-status-info-icon);
60
+ .ds-status-dot--info {
61
+ --ds-status-dot-color: var(--color-status-info-icon);
62
+ }
63
+
64
+ .ds-status-dot--positive {
65
+ --ds-status-dot-color: var(--color-status-positive-icon);
54
66
  }
55
67
 
56
- .ds-status-dot--positive .ds-status-dot__indicator {
57
- background-color: var(--color-status-positive-icon);
68
+ .ds-status-dot--warning {
69
+ --ds-status-dot-color: var(--color-status-warning-icon);
58
70
  }
59
71
 
60
- .ds-status-dot--warning .ds-status-dot__indicator {
61
- background-color: var(--color-status-warning-icon);
72
+ .ds-status-dot--error {
73
+ --ds-status-dot-color: var(--color-status-error-icon);
62
74
  }
63
75
 
64
- .ds-status-dot--error .ds-status-dot__indicator {
65
- background-color: var(--color-status-error-icon);
76
+ .ds-status-dot--neutral {
77
+ --ds-status-dot-color: var(--color-status-neutral-icon);
78
+ }
79
+
80
+ /* Outline — the hollow dot: the role's colour drawn as an inset ring, so
81
+ the mark keeps its box and its seat while reading as "nothing here yet".
82
+ The two small sizes take the hairline; the larger two the heavier step,
83
+ so the ring keeps its proportion to the diameter. */
84
+
85
+ .ds-status-dot--outline .ds-status-dot__indicator {
86
+ background-color: transparent;
87
+ box-shadow: inset 0 0 0 var(--border-025) var(--ds-status-dot-color);
66
88
  }
67
89
 
68
- .ds-status-dot--neutral .ds-status-dot__indicator {
69
- background-color: var(--color-status-neutral-icon);
90
+ .ds-status-dot--outline.ds-status-dot--md .ds-status-dot__indicator,
91
+ .ds-status-dot--outline.ds-status-dot--lg .ds-status-dot__indicator {
92
+ box-shadow: inset 0 0 0 var(--border-050) var(--ds-status-dot-color);
70
93
  }
71
94
 
72
95
  /* Pulse — a ring of the dot's own colour swelling out and fading, on the
@@ -77,7 +100,7 @@
77
100
  position: absolute;
78
101
  inset: 0;
79
102
  border-radius: var(--radius-pill);
80
- background-color: inherit;
103
+ background-color: var(--ds-status-dot-color);
81
104
  animation: ds-status-dot-pulse var(--motion-duration-loop-shimmer) var(--motion-ease-standard)
82
105
  infinite;
83
106
  }
@@ -3,8 +3,14 @@ import { default as React } from 'react';
3
3
  type StatusDotOwnProps = {
4
4
  /** Status role the dot carries — coloured through the plain-surface `--color-status-*-icon` steps. */
5
5
  variant?: 'info' | 'positive' | 'warning' | 'error' | 'neutral';
6
- /** Dot diameter, derived from the icon scale (half of `--icon-size-500/md/lg`). */
7
- size?: 'sm' | 'md' | 'lg';
6
+ /** Dot diameter, derived from the icon scale (half of `--icon-size-500/600/800`); `xs` is the 8px list-row mark, sized on the gap scale. */
7
+ size?: 'xs' | 'sm' | 'md' | 'lg';
8
+ /**
9
+ * Draws the dot hollow: a ring in the role's colour around an empty
10
+ * centre, for a settled or empty state that still holds its seat beside
11
+ * filled dots.
12
+ */
13
+ outline?: boolean;
8
14
  /**
9
15
  * Radiates a repeating ring from the dot for a live state — recording,
10
16
  * online now, deploy in flight. The ring stills under reduced motion.
@@ -16,6 +22,12 @@ type StatusDotOwnProps = {
16
22
  * a colour alone announces nothing.
17
23
  */
18
24
  label?: string;
25
+ /**
26
+ * Renders the dot as decoration: no `role="status"`, hidden from assistive
27
+ * technology. For a dot repeated down a list, where the host announces the
28
+ * state in the row's own text and a live region per row would be noise.
29
+ */
30
+ decorative?: boolean;
19
31
  /** Additional CSS classes */
20
32
  className?: string;
21
33
  };
@@ -23,8 +35,8 @@ export interface StatusDotProps extends StatusDotOwnProps, Omit<React.ComponentP
23
35
  }
24
36
  /**
25
37
  * StatusDot — the bare presence/status indicator: a small dot in one of the
26
- * five status roles, with an optional visible label and an optional live
27
- * pulse. Where Badge carries a text label on a tinted fill, StatusDot is the
38
+ * five status roles, filled or hollow, with an optional visible label and
39
+ * an optional live pulse. Where Badge carries a text label on a tinted fill, StatusDot is the
28
40
  * mark alone — for table rows, avatars, nav items, and anywhere a full badge
29
41
  * is too loud.
30
42
  *
@@ -2,19 +2,39 @@ import { jsxs, jsx } from "react/jsx-runtime";
2
2
  import React from "react";
3
3
  import "./StatusDot.css";
4
4
  const StatusDot = React.forwardRef(
5
- ({ variant = "neutral", size = "md", pulse = false, label, className = "", ...rest }, ref) => {
5
+ ({
6
+ variant = "neutral",
7
+ size = "md",
8
+ outline = false,
9
+ pulse = false,
10
+ label,
11
+ decorative = false,
12
+ className = "",
13
+ ...rest
14
+ }, ref) => {
6
15
  const baseClass = "ds-status-dot";
7
16
  const classes = [
8
17
  baseClass,
9
18
  `${baseClass}--${variant}`,
10
19
  `${baseClass}--${size}`,
20
+ outline && `${baseClass}--outline`,
11
21
  pulse && `${baseClass}--pulse`,
12
22
  className
13
23
  ].filter(Boolean).join(" ");
14
- return /* @__PURE__ */ jsxs("span", { ...rest, ref, className: classes, role: "status", children: [
15
- /* @__PURE__ */ jsx("span", { className: `${baseClass}__indicator`, "aria-hidden": "true" }),
16
- label && /* @__PURE__ */ jsx("span", { className: `${baseClass}__label`, children: label })
17
- ] });
24
+ return /* @__PURE__ */ jsxs(
25
+ "span",
26
+ {
27
+ ...rest,
28
+ ref,
29
+ className: classes,
30
+ role: decorative ? void 0 : "status",
31
+ "aria-hidden": decorative ? true : rest["aria-hidden"],
32
+ children: [
33
+ /* @__PURE__ */ jsx("span", { className: `${baseClass}__indicator`, "aria-hidden": "true" }),
34
+ label && /* @__PURE__ */ jsx("span", { className: `${baseClass}__label`, children: label })
35
+ ]
36
+ }
37
+ );
18
38
  }
19
39
  );
20
40
  StatusDot.displayName = "StatusDot";
@@ -396,6 +396,12 @@
396
396
  flex-shrink: 0;
397
397
  }
398
398
 
399
+ /* A generated avatar in the icon seat: a block-level sprite, so the seat
400
+ lays it out as a box rather than on the text baseline. */
401
+ .ds-thread-panel__project-icon--avatar {
402
+ display: inline-flex;
403
+ }
404
+
399
405
  .ds-thread-panel__project-label {
400
406
  flex: 1;
401
407
  min-width: 0;
@@ -525,15 +531,24 @@
525
531
  row-gap: 0;
526
532
  }
527
533
 
528
- /* The unread dot: a small status disc leading the title. Sized on the gap
529
- scale — there is no icon token this small, and 8px matches the title's
530
- cap height. */
531
- .ds-thread-panel__unread {
534
+ /* The status dot: a StatusDot at its 8px list-row size leading the title.
535
+ The panel seats one on every row or on none, so titles share an edge. */
536
+ .ds-thread-panel__status {
532
537
  flex: none;
533
- width: var(--gap-200);
534
- height: var(--gap-200);
535
- border-radius: var(--radius-pill);
536
- background-color: var(--color-status-info-icon);
538
+ }
539
+
540
+ /* The status read aloud: the dot is decorative, so its state rides in the
541
+ row's text for assistive technology only. */
542
+ .ds-thread-panel__status-label {
543
+ position: absolute;
544
+ width: 1px;
545
+ height: 1px;
546
+ padding: 0;
547
+ margin: -1px;
548
+ overflow: hidden;
549
+ clip: rect(0, 0, 0, 0);
550
+ white-space: nowrap;
551
+ border: 0;
537
552
  }
538
553
 
539
554
  .ds-thread-panel__title {
@@ -610,8 +625,8 @@
610
625
  }
611
626
 
612
627
  /* The description line: the caption face on the full second line, fading
613
- at the trailing edge like the title above it. Indented past the unread
614
- dot's column so both lines share a left edge.
628
+ at the trailing edge like the title above it. Indented past the status
629
+ dot's column, when the panel has one, so both lines share a left edge.
615
630
  ds-allow(color): mask alpha keywords (black/transparent), not palette colours */
616
631
  .ds-thread-panel__description {
617
632
  flex-basis: 100%;
@@ -635,7 +650,7 @@
635
650
  );
636
651
  }
637
652
 
638
- .ds-thread-panel__thread--unread .ds-thread-panel__description {
653
+ .ds-thread-panel--dotted .ds-thread-panel__description {
639
654
  /* The dot's width plus the row's column gap. */
640
655
  margin-left: calc(var(--gap-200) + var(--gap-200));
641
656
  }
@@ -9,6 +9,14 @@ export interface ThreadPanelThreadAction {
9
9
  /** Destructive styling (the error text colour) for delete-like actions. */
10
10
  destructive?: boolean;
11
11
  }
12
+ /**
13
+ * What a thread's leading dot reports. Fill says whether the thread wants
14
+ * the reader, colour says what it wants, motion says it is live right now:
15
+ * `idle` is a hollow neutral ring, `working` a pulsing neutral dot, and
16
+ * `unread`, `waiting` and `error` solid dots in the info, warning and error
17
+ * roles.
18
+ */
19
+ export type ThreadPanelThreadStatus = 'idle' | 'working' | 'unread' | 'waiting' | 'error';
12
20
  export interface ThreadPanelThread {
13
21
  /** Stable identifier: `activeThreadId` matches against it and `onThreadSelect` reports it. */
14
22
  id: string;
@@ -24,7 +32,9 @@ export interface ThreadPanelThread {
24
32
  actions?: ThreadPanelThreadAction[];
25
33
  /** Quiet second line under the title in the caption face — a repo, an environment, a one-line summary. Overflows under the same trailing mask as the title. */
26
34
  description?: string;
27
- /** The thread holds unseen activity: a small status dot leads the row. The dot is decorative — put the state in `meta` or `description` when it must be read aloud. */
35
+ /** The thread's state, drawn as the dot leading the row: `idle` (nothing new, nothing running), `working` (an agent is running now), `unread` (finished, not yet seen), `waiting` (blocked on the reader) or `error` (stopped on a failure). Once any thread in the panel carries a status every row draws a dot, the rest reading `idle`, so the titles share one left edge. The dot is decorative; every state but `idle` is also read aloud through `statusLabels`. */
36
+ status?: ThreadPanelThreadStatus;
37
+ /** Legacy boolean for `status: 'unread'`, ignored when `status` is set. @deprecated Use `status: 'unread'` instead. */
28
38
  unread?: boolean;
29
39
  /** Trailing icon before the meta — Material Symbol name (string) or custom element (ReactNode), e.g. `cloud` for a session that lives remotely. */
30
40
  icon?: string | React.ReactNode;
@@ -52,8 +62,10 @@ export interface ThreadPanelProject {
52
62
  id: string;
53
63
  /** The project's name. */
54
64
  label: string;
55
- /** Leading icon on the row — Material Symbol name (string) or custom element (ReactNode). Defaults to `folder`. */
65
+ /** Leading icon on the row — Material Symbol name (string) or custom element (ReactNode). Omit it and the row draws the project's generated PixelAvatar, which is the default mark. */
56
66
  icon?: string | React.ReactNode;
67
+ /** Seed for the generated PixelAvatar a project wears when it has no `icon`. Defaults to `label`; pass a stable id so a renamed project keeps its character. The panel spreads the inks across its projects, so neighbours differ in colour as well as shape. */
68
+ pixelAvatar?: string;
57
69
  /** Small trailing annotation in the caption face, e.g. how recently the project was touched. */
58
70
  meta?: string;
59
71
  /** Optional href — the row renders as an `<a>` instead of a `<button>`. */
@@ -79,6 +91,8 @@ type ThreadPanelOwnProps = {
79
91
  threadActions?: ThreadPanelThreadAction[];
80
92
  /** Fires with the thread's id and the chosen action's id. */
81
93
  onThreadAction?: (threadId: string, actionId: string) => void;
94
+ /** Spoken text for each thread status, read after the row's title; the dot itself is decorative. Merged over the English defaults (Working, Unread, Needs input, Failed). `idle` is silent unless given a label here. */
95
+ statusLabels?: Partial<Record<ThreadPanelThreadStatus, string>>;
82
96
  /** Accessible name for a row's menu trigger; the thread's title is appended after it. */
83
97
  threadMenuLabel?: string;
84
98
  /** Id of the thread being renamed: its row swaps to an inline text field, prefilled with the title and selected. The host owns the state, like everything else. */
@@ -148,8 +162,8 @@ export interface ThreadPanelProps extends ThreadPanelOwnProps, Omit<React.Compon
148
162
  * control rows, an optional projects section with its own new-project
149
163
  * button, the grouped thread history (the one region that scrolls), and a
150
164
  * footer for the profile row and host furniture like a theme toggle.
151
- * Thread rows scale from a bare title to the full detail anatomy: an
152
- * unread dot, a quiet description line, a trailing glyph, a pin marker,
165
+ * Thread rows scale from a bare title to the full detail anatomy: a
166
+ * leading status dot, a quiet description line, a trailing glyph, a pin marker,
153
167
  * and the meta caption.
154
168
  *
155
169
  * Fully controlled and stateless: the host owns the active thread, the
@@ -3,9 +3,25 @@ import React from "react";
3
3
  import { Avatar } from "../Avatar/Avatar.js";
4
4
  import { DropdownMenu } from "../DropdownMenu/DropdownMenu.js";
5
5
  import { Kbd } from "../Kbd/Kbd.js";
6
+ import { PixelAvatar } from "../PixelAvatar/PixelAvatar.js";
7
+ import { StatusDot } from "../StatusDot/StatusDot.js";
6
8
  import "./ThreadPanel.css";
7
9
  import "../../fonts/material-symbols.css";
10
+ import { distinctPixelInks } from "../PixelAvatar/pixelInks.js";
8
11
  const baseClass = "ds-thread-panel";
12
+ const STATUS_DOTS = {
13
+ idle: { variant: "neutral", outline: true },
14
+ working: { variant: "neutral", pulse: true },
15
+ unread: { variant: "info" },
16
+ waiting: { variant: "warning" },
17
+ error: { variant: "error" }
18
+ };
19
+ const DEFAULT_STATUS_LABELS = {
20
+ working: "Working",
21
+ unread: "Unread",
22
+ waiting: "Needs input",
23
+ error: "Failed"
24
+ };
9
25
  function ActionRow({
10
26
  className,
11
27
  href,
@@ -96,6 +112,7 @@ const ThreadPanel = React.forwardRef(
96
112
  onThreadSelect,
97
113
  threadActions,
98
114
  onThreadAction,
115
+ statusLabels,
99
116
  threadMenuLabel = "Thread options",
100
117
  renamingThreadId,
101
118
  onThreadRename,
@@ -128,12 +145,20 @@ const ThreadPanel = React.forwardRef(
128
145
  className = "",
129
146
  ...rest
130
147
  }, ref) => {
148
+ const dotted = groups.some(
149
+ (group) => group.threads.some((thread) => thread.status || thread.unread)
150
+ );
151
+ const labels = { ...DEFAULT_STATUS_LABELS, ...statusLabels };
131
152
  const classes = [
132
153
  baseClass,
133
154
  expanded && `${baseClass}--expanded`,
155
+ dotted && `${baseClass}--dotted`,
134
156
  className
135
157
  ].filter(Boolean).join(" ");
136
158
  const iconClass = "material-symbols-rounded";
159
+ const projectInks = distinctPixelInks(
160
+ (projects ?? []).map((project) => project.pixelAvatar ?? project.label)
161
+ );
137
162
  const showHeader = Boolean(logo || logoText || onExpandedChange);
138
163
  const showNew = Boolean(newThreadLabel || onNewThread || newThreadHref);
139
164
  const showFooter = Boolean(footerSlot || profile);
@@ -252,7 +277,7 @@ const ThreadPanel = React.forwardRef(
252
277
  }
253
278
  )
254
279
  ] }),
255
- /* @__PURE__ */ jsx("ul", { className: `${baseClass}__list`, "aria-label": projectsLabel, children: projects.map((project) => {
280
+ /* @__PURE__ */ jsx("ul", { className: `${baseClass}__list`, "aria-label": projectsLabel, children: projects.map((project, projectIndex) => {
256
281
  const active = project.id === activeProjectId;
257
282
  return /* @__PURE__ */ jsx("li", { className: `${baseClass}__row`, children: /* @__PURE__ */ jsxs(
258
283
  ActionRow,
@@ -266,12 +291,25 @@ const ThreadPanel = React.forwardRef(
266
291
  ariaCurrent: active ? project.href ? "page" : "true" : void 0,
267
292
  title: project.label,
268
293
  children: [
269
- /* @__PURE__ */ jsx(
294
+ project.icon == null ? /* @__PURE__ */ jsx(
270
295
  "span",
271
296
  {
272
- className: `${baseClass}__project-icon${typeof project.icon === "string" || project.icon == null ? ` ${iconClass}` : ""}`,
297
+ className: `${baseClass}__project-icon ${baseClass}__project-icon--avatar`,
273
298
  "aria-hidden": "true",
274
- children: project.icon ?? "folder"
299
+ children: /* @__PURE__ */ jsx(
300
+ PixelAvatar,
301
+ {
302
+ name: project.pixelAvatar ?? project.label,
303
+ ink: projectInks[projectIndex]
304
+ }
305
+ )
306
+ }
307
+ ) : /* @__PURE__ */ jsx(
308
+ "span",
309
+ {
310
+ className: `${baseClass}__project-icon${typeof project.icon === "string" ? ` ${iconClass}` : ""}`,
311
+ "aria-hidden": "true",
312
+ children: project.icon
275
313
  }
276
314
  ),
277
315
  /* @__PURE__ */ jsx("span", { className: `${baseClass}__project-label`, children: project.label }),
@@ -300,6 +338,8 @@ const ThreadPanel = React.forwardRef(
300
338
  const showMenu = Boolean(
301
339
  actions && actions.length > 0 && onThreadAction && !thread.pending
302
340
  );
341
+ const status = dotted ? thread.pending ? "working" : thread.status ?? (thread.unread ? "unread" : "idle") : void 0;
342
+ const statusLabel = status && !thread.pending ? labels[status] : void 0;
303
343
  const renaming = Boolean(
304
344
  thread.id === renamingThreadId && onThreadRename
305
345
  );
@@ -326,19 +366,20 @@ const ThreadPanel = React.forwardRef(
326
366
  className: [
327
367
  `${baseClass}__thread`,
328
368
  active && `${baseClass}__thread--active`,
329
- thread.description && !thread.pending && `${baseClass}__thread--detailed`,
330
- thread.unread && `${baseClass}__thread--unread`
369
+ thread.description && !thread.pending && `${baseClass}__thread--detailed`
331
370
  ].filter(Boolean).join(" "),
332
371
  href: thread.href,
333
372
  onClick: onThreadSelect ? () => onThreadSelect(thread.id) : void 0,
334
373
  ariaCurrent: active ? thread.href ? "page" : "true" : void 0,
335
374
  title: thread.title,
336
375
  children: [
337
- thread.unread && !thread.pending && /* @__PURE__ */ jsx(
338
- "span",
376
+ status && /* @__PURE__ */ jsx(
377
+ StatusDot,
339
378
  {
340
- className: `${baseClass}__unread`,
341
- "aria-hidden": "true"
379
+ className: `${baseClass}__status`,
380
+ size: "xs",
381
+ decorative: true,
382
+ ...STATUS_DOTS[status]
342
383
  }
343
384
  ),
344
385
  thread.pending ? (
@@ -359,6 +400,7 @@ const ThreadPanel = React.forwardRef(
359
400
  }
360
401
  )
361
402
  ) : /* @__PURE__ */ jsx("span", { className: `${baseClass}__title`, children: thread.title }),
403
+ statusLabel && /* @__PURE__ */ jsx("span", { className: `${baseClass}__status-label`, children: statusLabel }),
362
404
  !thread.pending && (thread.icon || thread.pinned || thread.meta) && /* @__PURE__ */ jsxs("span", { className: `${baseClass}__trail`, children: [
363
405
  thread.icon && /* @__PURE__ */ jsx(
364
406
  "span",