canopy-ui 0.14.1 → 0.15.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "canopy-ui",
3
- "version": "0.14.1",
3
+ "version": "0.15.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -31,18 +31,58 @@ const props = {
31
31
  describe("multiplayer composer", () => {
32
32
  it("shows a teammate's live text and never locks my box", () => {
33
33
  render(<ChatPanel {...props} state={state({ peer_drafts: [{ author: { id: 2, name: "Bo B" }, body: "thinking out loud", at: null }] })} />);
34
- const row = screen.getByTestId("typing-row");
35
- expect(row.textContent).toContain("Bo B");
36
- expect(row.textContent).toContain("thinking out loud");
34
+ const box = screen.getByTestId("peer-composer");
35
+ expect(box.textContent).toContain("Bo B is typing");
36
+ expect(box.textContent).toContain("thinking out loud");
37
37
  expect((screen.getByTestId("composer") as HTMLTextAreaElement).disabled).toBe(false);
38
38
  expect(screen.queryByTestId("coedit-banner")).toBeNull();
39
39
  });
40
40
 
41
41
  it("shows 'is typing…' with no words when a teammate withholds them", () => {
42
42
  render(<ChatPanel {...props} state={state({ peer_drafts: [{ author: { id: 2, name: "Bo B" }, body: "", at: null, typing: true }] })} />);
43
- const row = screen.getByTestId("typing-row");
44
- expect(row.textContent).toContain("Bo B");
45
- expect(row.textContent).toContain("is typing…");
43
+ const box = screen.getByTestId("peer-composer");
44
+ expect(box.textContent).toContain("Bo B is typing");
45
+ expect(screen.getByTestId("peer-typing-indicator")).toBeTruthy();
46
+ });
47
+
48
+ it("draws the teammate's box inside my composer, directly above my textarea", () => {
49
+ render(<ChatPanel {...props} state={state({ peer_drafts: [{ author: { id: 2, name: "Bo B" }, body: "hi", at: null }] })} />);
50
+ const box = screen.getByTestId("peer-composer");
51
+ const composer = screen.getByTestId("composer");
52
+ expect(box.compareDocumentPosition(composer) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
53
+ // Same container as the textarea, so it gets the composer's width.
54
+ expect(box.closest("ul")?.parentElement).toBe(composer.parentElement);
55
+ });
56
+
57
+ it("marks the typing person's presence chip, and only theirs", () => {
58
+ render(<ChatPanel {...props} state={state({ peer_drafts: [{ author: { id: 2, name: "Bo B" }, body: "hi", at: null }] })} />);
59
+ const chip = screen.getByTestId("presence-chip");
60
+ expect(chip.getAttribute("data-typing")).toBe("true");
61
+ expect(chip.getAttribute("title")).toBe("Bo B is typing");
62
+ expect(screen.getByTestId("presence-list").getAttribute("aria-label")).toContain("Bo B (typing)");
63
+ });
64
+
65
+ it("the chip goes quiet once the teammate stops", () => {
66
+ render(<ChatPanel {...props} state={state({ peer_drafts: [] })} />);
67
+ expect(screen.getByTestId("presence-chip").getAttribute("data-typing")).toBeNull();
68
+ expect(screen.queryByTestId("presence-typing-badge")).toBeNull();
69
+ });
70
+
71
+ it("gives a teammate one colour: chip, box edge and message label agree", () => {
72
+ const theirs = {
73
+ id: "m2", turn_index: 2, role: "user" as const, content: {}, plaintext: "them",
74
+ status: "complete" as const, error_detail: null, started_at: null, completed_at: null,
75
+ created_at: "", author: { name: "Bo B", user_id: 2 },
76
+ };
77
+ render(<ChatPanel {...props} state={state({
78
+ messages: [theirs],
79
+ peer_drafts: [{ author: { id: 2, name: "Bo B" }, body: "hi", at: null }],
80
+ })} />);
81
+ const hue = (cls: string) => cls.match(/(sky|emerald|violet|amber|rose|teal)-/)?.[1];
82
+ const chipHue = hue(screen.getByTestId("presence-chip").className);
83
+ expect(chipHue).toBeTruthy();
84
+ expect(hue(screen.getByTestId("peer-composer").className)).toBe(chipHue);
85
+ expect(hue(screen.getByTestId("message-author").className)).toBe(chipHue);
46
86
  });
47
87
 
48
88
  it("send stays available while the agent is replying", () => {
@@ -1,13 +1,13 @@
1
1
  import { useMemo, type ReactNode } from "react";
2
2
 
3
- import type { SessionState, TypingVisibility } from "./protocol";
3
+ import type { PeerDraft, SessionState, TypingVisibility } from "./protocol";
4
4
  import type { RenderMarkdown } from "./MessageItem";
5
5
  import { ConnectionStatus } from "./ConnectionStatus";
6
6
  import { MessageList } from "./MessageList";
7
7
  import { PresenceChips } from "./PresenceChips";
8
8
  import { isMine } from "./identity";
9
9
  import { QueuedRows } from "./QueuedRows";
10
- import { TypingRows } from "./TypingRows";
10
+ import { PeerComposers } from "./PeerComposers";
11
11
  import { SendBox, type PendingAttachment } from "./SendBox";
12
12
  import { type DraftStorage } from "./drafts";
13
13
  import { agentHasFloor as computeAgentHasFloor, pendingLabel as computePendingLabel, turnNotice } from "./turnStatus";
@@ -62,6 +62,8 @@ export interface ChatPanelProps {
62
62
  * NO data fetching, NO WebSocket, NO CLI-auth. The container (e.g. canopy's
63
63
  * ChatPage) wires `useSessionSocket` returns into these props.
64
64
  */
65
+ const EMPTY_PEERS: PeerDraft[] = [];
66
+
65
67
  export function ChatPanel({
66
68
  state,
67
69
  connected,
@@ -189,6 +191,11 @@ export function ChatPanel({
189
191
  [state.messages, currentUserId, currentContactId],
190
192
  );
191
193
 
194
+ // Everyone else's live drafts. The same list draws their boxes above the
195
+ // composer and marks their chips in the header, so the two never disagree.
196
+ const peerDrafts = state.peer_drafts ?? EMPTY_PEERS;
197
+ const typingUserIds = useMemo(() => peerDrafts.map((p) => p.author.id), [peerDrafts]);
198
+
192
199
  return (
193
200
  <div className="flex h-full flex-col">
194
201
  <div className="flex items-center gap-3 border-b border-border bg-background px-3 py-1.5 text-xs">
@@ -198,6 +205,7 @@ export function ChatPanel({
198
205
  participants={state.participants}
199
206
  presenceUserIds={state.presence_user_ids}
200
207
  currentUserId={currentUserId}
208
+ typingUserIds={typingUserIds}
201
209
  />
202
210
  </div>
203
211
  </div>
@@ -230,7 +238,6 @@ export function ChatPanel({
230
238
  ownPendingTexts={ownPendingTexts}
231
239
  />
232
240
  </div>
233
- <TypingRows peers={state.peer_drafts ?? []} />
234
241
  <SendBox
235
242
  draft={state.active_draft}
236
243
  connected={connected}
@@ -240,6 +247,7 @@ export function ChatPanel({
240
247
  onSend={onSend}
241
248
  onStop={onStop}
242
249
  stopState={state.stopState}
250
+ peers={<PeerComposers peers={peerDrafts} />}
243
251
  banner={
244
252
  notice ? (
245
253
  <p
@@ -3,6 +3,7 @@ import { AlertTriangle, ChevronRight, OctagonX } from "lucide-react";
3
3
 
4
4
  import type { Message, MessageAuthor } from "./protocol";
5
5
  import { isMine } from "./identity";
6
+ import { authorColor } from "./personColor";
6
7
  import { ToolCallPair } from "./ToolCallPair";
7
8
 
8
9
  /** How to render assistant/system markdown. Injected by the app so the kit
@@ -154,7 +155,12 @@ export function MessageItem({
154
155
  aria-live={isStreaming || isPending ? "polite" : undefined}
155
156
  >
156
157
  {otherAuthor && (
157
- <div data-testid="message-author" className="mb-0.5 text-[11px] font-medium text-muted-foreground">
158
+ // In their colour — the same one as their presence chip and their live
159
+ // draft box — so a teammate's lines are attributable at a glance.
160
+ <div
161
+ data-testid="message-author"
162
+ className={`mb-0.5 text-[11px] font-semibold ${authorColor(otherAuthor).text}`}
163
+ >
158
164
  {otherAuthor.name}
159
165
  </div>
160
166
  )}
@@ -0,0 +1,64 @@
1
+ // @vitest-environment jsdom
2
+ import { cleanup, render, screen } from "@testing-library/react";
3
+ import { afterEach, describe, expect, it } from "vitest";
4
+
5
+ import { PeerComposers } from "./PeerComposers";
6
+ import { TypingRows } from "./TypingRows";
7
+ import { personColor } from "./personColor";
8
+ import type { PeerDraft } from "./protocol";
9
+
10
+ afterEach(cleanup);
11
+
12
+ const bo: PeerDraft = { author: { id: 2, name: "Bo Banda" }, body: "hello there", at: null };
13
+
14
+ describe("PeerComposers", () => {
15
+ it("renders nothing when nobody is typing", () => {
16
+ const { container } = render(<PeerComposers peers={[]} />);
17
+ expect(container.firstChild).toBeNull();
18
+ });
19
+
20
+ it("draws a box with the person's avatar, name and live words", () => {
21
+ render(<PeerComposers peers={[bo]} />);
22
+ const box = screen.getByTestId("peer-composer");
23
+ // The name is always shown — colour is never the only cue.
24
+ expect(box.textContent).toContain("Bo Banda is typing");
25
+ expect(box.textContent).toContain("BB");
26
+ expect(screen.getByTestId("peer-composer-text").textContent).toContain("hello there");
27
+ expect(box.className).toContain(personColor(2).edge);
28
+ });
29
+
30
+ it("ends the live words with a caret that only blinks when motion is allowed", () => {
31
+ render(<PeerComposers peers={[bo]} />);
32
+ const caret = screen.getByTestId("peer-caret");
33
+ expect(caret.getAttribute("aria-hidden")).toBe("true");
34
+ expect(caret.className).toContain("motion-safe:animate-pulse");
35
+ expect(caret.className).not.toMatch(/(^|\s)animate-/);
36
+ });
37
+
38
+ it("wraps the words instead of truncating them to one line", () => {
39
+ render(<PeerComposers peers={[bo]} />);
40
+ const text = screen.getByTestId("peer-composer-text");
41
+ expect(text.className).toContain("whitespace-pre-wrap");
42
+ expect(text.className).not.toContain("truncate");
43
+ });
44
+
45
+ it("shows a typing indicator, not words, when the body is withheld", () => {
46
+ render(<PeerComposers peers={[{ ...bo, body: "", typing: true }]} />);
47
+ expect(screen.getByTestId("peer-composer").textContent).toContain("Bo Banda is typing");
48
+ expect(screen.getByTestId("peer-typing-indicator")).toBeTruthy();
49
+ expect(screen.queryByTestId("peer-composer-text")).toBeNull();
50
+ });
51
+
52
+ it("stacks several people newest-edit-last and stays a polite live region", () => {
53
+ const robin: PeerDraft = { author: { id: 3, name: "Robin Sharma" }, body: "and me", at: null };
54
+ render(<PeerComposers peers={[bo, robin]} />);
55
+ const boxes = screen.getAllByTestId("peer-composer");
56
+ expect(boxes.map((b) => b.getAttribute("data-user-id"))).toEqual(["2", "3"]);
57
+ expect(screen.getByTestId("peer-composers").getAttribute("aria-live")).toBe("polite");
58
+ });
59
+
60
+ it("keeps the deprecated TypingRows export rendering the same boxes", () => {
61
+ render(<TypingRows peers={[bo]} />);
62
+ expect(screen.getByTestId("peer-composer").textContent).toContain("hello there");
63
+ });
64
+ });
@@ -0,0 +1,106 @@
1
+ import { useLayoutEffect, useRef, useState } from "react";
2
+
3
+ import type { PeerDraft } from "./protocol";
4
+ import { initials, personColor } from "./personColor";
5
+
6
+ /** Everyone else's box, live — the multiplayer half of the composer.
7
+ *
8
+ * Each person typing gets a read-only copy of a composer, stacked directly
9
+ * above your own and drawn with the same width and corners, so what you see is
10
+ * "another person writing", not a status line. It used to be one row of grey
11
+ * italic `text-xs`, truncated to a single line — in a live test it was easy to
12
+ * miss entirely and read like the app reporting on itself.
13
+ *
14
+ * Newest edit last (the order `peer_drafts` arrives in). A person in `typing`
15
+ * mode shares the fact, not the words: their box shows a typing indicator
16
+ * instead of text. Motion (the caret blink, the dots) is `motion-safe` only;
17
+ * under reduced motion the caret and dots are still drawn, just still. */
18
+ export function PeerComposers({ peers }: { peers: PeerDraft[] }) {
19
+ if (peers.length === 0) return null;
20
+ return (
21
+ <ul className="mb-2 space-y-1.5" aria-live="polite" data-testid="peer-composers">
22
+ {peers.map((p) => (
23
+ <PeerComposer key={p.author.id} peer={p} />
24
+ ))}
25
+ </ul>
26
+ );
27
+ }
28
+
29
+ /** How tall a live draft may grow before it caps: ~3 lines of `text-sm`. */
30
+ const MAX_BODY = "max-h-[3.75rem]";
31
+
32
+ function PeerComposer({ peer }: { peer: PeerDraft }) {
33
+ const color = personColor(peer.author.id);
34
+ const withheld = peer.body === "";
35
+ const bodyRef = useRef<HTMLDivElement>(null);
36
+ const textRef = useRef<HTMLParagraphElement>(null);
37
+ const [overflowing, setOverflowing] = useState(false);
38
+
39
+ // The box is anchored to the BOTTOM of the text: the newest words are the
40
+ // ones being written, so a long draft shows its tail and fades out at the
41
+ // top, rather than freezing on its first three lines while the person keeps
42
+ // typing below them.
43
+ useLayoutEffect(() => {
44
+ const box = bodyRef.current;
45
+ const text = textRef.current;
46
+ if (!box || !text) return setOverflowing(false);
47
+ setOverflowing(text.scrollHeight > box.clientHeight + 1);
48
+ }, [peer.body]);
49
+
50
+ return (
51
+ <li
52
+ data-testid="peer-composer"
53
+ data-user-id={peer.author.id}
54
+ className={`rounded-md border border-l-4 border-input ${color.edge} bg-card px-2 py-1.5 shadow-sm`}
55
+ >
56
+ <div className="flex min-w-0 items-center gap-1.5 text-xs">
57
+ <span
58
+ aria-hidden="true"
59
+ className={`flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[9px] font-semibold ${color.avatar}`}
60
+ >
61
+ {initials(peer.author.name)}
62
+ </span>
63
+ <span className={`truncate font-semibold ${color.text}`}>{peer.author.name}</span>
64
+ {/* A real space, so the text (and a screen reader) says "Robin Sharma
65
+ is typing" — flex `gap` is only visual. */}
66
+ {" "}
67
+ <span className="shrink-0 text-muted-foreground">is typing</span>
68
+ </div>
69
+ {withheld ? (
70
+ <div className="mt-1 flex h-5 items-center pl-6" data-testid="peer-typing-indicator">
71
+ <TypingDots />
72
+ </div>
73
+ ) : (
74
+ <div
75
+ ref={bodyRef}
76
+ className={`mt-1 flex ${MAX_BODY} flex-col justify-end overflow-hidden ${
77
+ overflowing ? "[mask-image:linear-gradient(to_bottom,transparent,black_1.25rem)]" : ""
78
+ }`}
79
+ >
80
+ <p
81
+ ref={textRef}
82
+ data-testid="peer-composer-text"
83
+ className="whitespace-pre-wrap text-sm text-foreground [overflow-wrap:anywhere]"
84
+ >
85
+ {peer.body}
86
+ <span
87
+ aria-hidden="true"
88
+ data-testid="peer-caret"
89
+ className={`ml-px inline-block h-[1.1em] w-0.5 translate-y-[0.2em] rounded-sm bg-current ${color.text} motion-safe:animate-pulse`}
90
+ />
91
+ </p>
92
+ </div>
93
+ )}
94
+ </li>
95
+ );
96
+ }
97
+
98
+ function TypingDots() {
99
+ return (
100
+ <span className="inline-flex gap-1 text-muted-foreground" aria-hidden="true">
101
+ <span className="h-1.5 w-1.5 rounded-full bg-current motion-safe:animate-bounce motion-safe:[animation-delay:-0.3s]" />
102
+ <span className="h-1.5 w-1.5 rounded-full bg-current motion-safe:animate-bounce motion-safe:[animation-delay:-0.15s]" />
103
+ <span className="h-1.5 w-1.5 rounded-full bg-current motion-safe:animate-bounce" />
104
+ </span>
105
+ );
106
+ }
@@ -1,4 +1,7 @@
1
+ import { PenLine } from "lucide-react";
2
+
1
3
  import type { Participant } from "./protocol";
4
+ import { initials, personColor } from "./personColor";
2
5
 
3
6
  interface Props {
4
7
  participants: Participant[];
@@ -12,25 +15,13 @@ interface Props {
12
15
  * stranger. ChatPanel had `currentUserId` the whole time and never passed
13
16
  * it. Caught by the first two-browser e2e this surface ever had. */
14
17
  currentUserId: number | null;
18
+ /** Who is typing right now (their live draft is up). Their chip gets a
19
+ * solid ring in their colour, a pulse, and a pen badge, so it reads from
20
+ * the header even while you are looking at the transcript. Optional — an
21
+ * older caller simply gets no typing signal on the chips. */
22
+ typingUserIds?: number[];
15
23
  }
16
24
 
17
- /** A stable colour per person, so two people are told apart at a glance.
18
- *
19
- * Every chip used to be `bg-muted`, which meant identity rested entirely on
20
- * two initials — and initials collide constantly on a small team (two J.K.s
21
- * render identically). Hue is derived from the user id so a given person is
22
- * the same colour in every session, for everyone, with no state to keep. */
23
- const PALETTE = [
24
- "bg-sky-500/20 text-sky-700 dark:text-sky-200 ring-sky-500/40",
25
- "bg-emerald-500/20 text-emerald-700 dark:text-emerald-200 ring-emerald-500/40",
26
- "bg-violet-500/20 text-violet-700 dark:text-violet-200 ring-violet-500/40",
27
- "bg-amber-500/20 text-amber-700 dark:text-amber-200 ring-amber-500/40",
28
- "bg-rose-500/20 text-rose-700 dark:text-rose-200 ring-rose-500/40",
29
- "bg-teal-500/20 text-teal-700 dark:text-teal-200 ring-teal-500/40",
30
- ];
31
-
32
- const colorFor = (userId: number) => PALETTE[Math.abs(userId) % PALETTE.length];
33
-
34
25
  /** How many faces before collapsing into "+N". Beyond a handful the row stops
35
26
  * being a glance and starts being a list, and it shares a thin header bar. */
36
27
  const MAX_FACES = 4;
@@ -39,6 +30,7 @@ export function PresenceChips({
39
30
  participants,
40
31
  presenceUserIds,
41
32
  currentUserId,
33
+ typingUserIds = [],
42
34
  }: Props) {
43
35
  const present = participants.filter(
44
36
  (p) => presenceUserIds.includes(p.user_id) && p.user_id !== currentUserId,
@@ -62,24 +54,47 @@ export function PresenceChips({
62
54
  <div className="flex items-center gap-2" data-testid="presence-chips">
63
55
  <ul
64
56
  className="flex items-center -space-x-1.5"
65
- aria-label={describe(present)}
57
+ aria-label={describe(present, typingUserIds)}
66
58
  data-testid="presence-list"
67
59
  >
68
60
  {faces.map((p) => {
61
+ const color = personColor(p.user_id);
62
+ const typing = typingUserIds.includes(p.user_id);
69
63
  return (
70
64
  <li
71
65
  key={p.user_id}
72
66
  data-testid="presence-chip"
73
67
  data-user-id={p.user_id}
74
- title={p.display_name}
68
+ data-typing={typing ? "true" : undefined}
69
+ title={typing ? `${p.display_name} is typing` : p.display_name}
75
70
  className={[
76
- "flex h-7 w-7 items-center justify-center rounded-full",
77
- "text-[11px] font-semibold ring-2 ring-background",
71
+ "relative flex h-7 w-7 items-center justify-center rounded-full",
72
+ "text-[11px] font-semibold ring-2",
78
73
  "transition-transform hover:z-10 hover:scale-110",
79
- colorFor(p.user_id),
74
+ color.avatar,
75
+ // Typing: a solid ring in their colour, lifted above its
76
+ // neighbours so the overlap never hides it.
77
+ typing ? `z-10 ${color.ring}` : "ring-background",
80
78
  ].join(" ")}
81
79
  >
82
80
  {initials(p.display_name)}
81
+ {typing && (
82
+ <>
83
+ {/* The pulse is decoration; reduced motion drops it and
84
+ the solid ring + badge still say "typing". */}
85
+ <span
86
+ aria-hidden="true"
87
+ className={`pointer-events-none absolute inset-0 rounded-full ring-2 ${color.ring} opacity-60 motion-safe:animate-ping`}
88
+ />
89
+ <span
90
+ aria-hidden="true"
91
+ data-testid="presence-typing-badge"
92
+ className="absolute -bottom-1 -right-1 flex h-3.5 w-3.5 items-center justify-center rounded-full bg-background text-foreground ring-1 ring-border"
93
+ >
94
+ <PenLine className="h-2.5 w-2.5" />
95
+ </span>
96
+ </>
97
+ )}
83
98
  </li>
84
99
  );
85
100
  })}
@@ -99,18 +114,10 @@ export function PresenceChips({
99
114
 
100
115
  /** The accessible name for the row — the same fact the faces carry, in words.
101
116
  * A row of coloured circles is meaningless without this. */
102
- function describe(present: Participant[]): string {
103
- const names = present.map((p) => p.display_name).join(", ");
117
+ function describe(present: Participant[], typingUserIds: number[]): string {
118
+ const names = present
119
+ .map((p) => (typingUserIds.includes(p.user_id) ? `${p.display_name} (typing)` : p.display_name))
120
+ .join(", ");
104
121
  const who = present.length === 1 ? "1 other person here" : `${present.length} other people here`;
105
122
  return `${who}: ${names}`;
106
123
  }
107
-
108
- function initials(name: string): string {
109
- return name
110
- .split(" ")
111
- .map((w) => w[0])
112
- .filter(Boolean)
113
- .join("")
114
- .slice(0, 2)
115
- .toUpperCase();
116
- }
@@ -1,5 +1,6 @@
1
1
  import type { QueuedMessage } from "./protocol";
2
2
  import { isMine } from "./identity";
3
+ import { authorColor } from "./personColor";
3
4
 
4
5
  /** Sends that have not reached the agent yet, in send order, for everyone.
5
6
  * Your own are skipped when your optimistic echo already shows them — by
@@ -30,7 +31,13 @@ export function QueuedRows({
30
31
  <div key={q.turn_id} data-testid="queued-row"
31
32
  className={`my-2 max-w-[80%] rounded-2xl border border-dashed border-border px-4 py-2 text-sm ${mine ? "ml-auto" : "mr-auto"}`}>
32
33
  <div className="mb-0.5 flex gap-2 text-[11px] text-muted-foreground">
33
- <span className="font-medium text-foreground">{mine ? "You" : q.author?.name ?? "Someone"}</span>
34
+ <span
35
+ className={`font-medium ${
36
+ !mine && q.author ? authorColor(q.author).text : "text-foreground"
37
+ }`}
38
+ >
39
+ {mine ? "You" : q.author?.name ?? "Someone"}
40
+ </span>
34
41
  <span>{q.state === "queued" ? "queued" : "sending to agent…"}</span>
35
42
  </div>
36
43
  <div className="whitespace-pre-wrap text-foreground-secondary [overflow-wrap:anywhere]">{q.text}</div>
@@ -99,7 +99,7 @@ describe("SendBox — local-first composer", () => {
99
99
  it("never adopts another editor's body — this box is only ever your own draft now", () => {
100
100
  // Everyone gets their own draft (`SessionState.active_draft` is the
101
101
  // CALLER's own); a teammate's live text arrives via `peer_drafts` /
102
- // <TypingRows> instead, never through this component's `draft` prop. So a
102
+ // <PeerComposers> instead, never through this component's `draft` prop. So a
103
103
  // `last_editor` that isn't you must NOT overwrite what you're typing.
104
104
  const { textarea, rerender } = setup();
105
105
  fireEvent.change(textarea(), { target: { value: "my own words" } });
@@ -47,6 +47,10 @@ interface Props {
47
47
  /** Optional app-supplied banner rendered above the composer (e.g. an
48
48
  * imported-session note). The kit itself has no CLI-auth banners. */
49
49
  banner?: ReactNode;
50
+ /** Everyone else's live drafts (`PeerComposers`), drawn inside this box
51
+ * directly above your own textarea so the two read as the same kind of
52
+ * thing — people writing — at the same width. */
53
+ peers?: ReactNode;
50
54
  /** When set, sending is disabled and this reason is shown as a hint. */
51
55
  disabledReason?: string;
52
56
  /** Files staged for the next send. Omit to hide attaching entirely — the kit
@@ -83,6 +87,7 @@ export function SendBox({
83
87
  onStop,
84
88
  stopState,
85
89
  banner,
90
+ peers,
86
91
  disabledReason,
87
92
  attachments,
88
93
  onAttach,
@@ -247,6 +252,7 @@ export function SendBox({
247
252
  >
248
253
  {banner}
249
254
  <div className={`p-2 ${dragging ? "bg-primary/5 ring-1 ring-inset ring-primary/40" : ""}`}>
255
+ {peers}
250
256
  {staged.length > 0 && (
251
257
  <ul className="mb-1.5 flex flex-wrap gap-1.5" data-testid="attachment-chips">
252
258
  {staged.map((a) => (
@@ -1,25 +1,15 @@
1
1
  import type { PeerDraft } from "./protocol";
2
+ import { PeerComposers } from "./PeerComposers";
2
3
 
3
- /** Everyone else's box, live — the multiplayer half of the composer. One row
4
- * per person, newest edit last, right above your own box. */
4
+ /** @deprecated Use `PeerComposers` inside `SendBox` (its `peers` slot), which
5
+ * is what `ChatPanel` does. Kept so a consumer that mounts it on its own above
6
+ * a composer keeps working: it is the same peer boxes, padded the way the old
7
+ * row strip was. */
5
8
  export function TypingRows({ peers }: { peers: PeerDraft[] }) {
6
9
  if (peers.length === 0) return null;
7
10
  return (
8
- <ul className="space-y-1 border-t border-border bg-background px-3 py-2" aria-live="polite">
9
- {peers.map((p) =>
10
- p.body === "" ? (
11
- <li key={p.author.id} data-testid="typing-row" className="flex min-w-0 gap-2 text-xs">
12
- <span className="shrink-0 font-medium text-foreground">{p.author.name}</span>
13
- <span className="shrink-0 text-muted-foreground">is typing…</span>
14
- </li>
15
- ) : (
16
- <li key={p.author.id} data-testid="typing-row" className="flex min-w-0 gap-2 text-xs">
17
- <span className="shrink-0 font-medium text-foreground">{p.author.name}</span>
18
- <span className="shrink-0 text-muted-foreground">is typing:</span>
19
- <span className="min-w-0 truncate italic text-foreground-secondary">{p.body}</span>
20
- </li>
21
- ),
22
- )}
23
- </ul>
11
+ <div className="border-t border-border bg-background px-2 pt-2">
12
+ <PeerComposers peers={peers} />
13
+ </div>
24
14
  );
25
15
  }
package/src/chat/index.ts CHANGED
@@ -82,6 +82,8 @@ export { ToolCallPair } from "./ToolCallPair";
82
82
  export { SendBox, type PendingAttachment } from "./SendBox";
83
83
  export { PresenceChips } from "./PresenceChips";
84
84
  export { TypingRows } from "./TypingRows";
85
+ export { PeerComposers } from "./PeerComposers";
86
+ export { personColor, authorColor, initials, type PersonColor } from "./personColor";
85
87
  export { QueuedRows } from "./QueuedRows";
86
88
  export { isMine } from "./identity";
87
89
  export { ConnectionStatus } from "./ConnectionStatus";
@@ -0,0 +1,87 @@
1
+ /** One colour per person, used everywhere that person appears — their presence
2
+ * chip, the name on their lines, and the box their live draft is drawn in —
3
+ * so "the violet one" is the same somebody across the whole chat.
4
+ *
5
+ * Every chip used to be `bg-muted`, which meant identity rested entirely on
6
+ * two initials — and initials collide constantly on a small team (two J.K.s
7
+ * render identically). Hue is derived from the id so a given person is the
8
+ * same colour in every session, for everyone, with no state to keep.
9
+ *
10
+ * Colour is never the ONLY cue: every surface that uses one also shows the
11
+ * person's name (or carries it as the accessible name). Each class is written
12
+ * out in full because Tailwind finds classes by scanning source text — a
13
+ * class assembled from parts is never generated. */
14
+ export interface PersonColor {
15
+ /** Initials avatar / presence chip: tinted fill + readable text. */
16
+ avatar: string;
17
+ /** The person's name set in their colour (message labels, box headers). */
18
+ text: string;
19
+ /** The accent edge of their live-draft box. */
20
+ edge: string;
21
+ /** A solid ring, for "this person is typing right now". */
22
+ ring: string;
23
+ }
24
+
25
+ const PALETTE: PersonColor[] = [
26
+ {
27
+ avatar: "bg-sky-500/20 text-sky-700 dark:text-sky-200",
28
+ text: "text-sky-700 dark:text-sky-300",
29
+ edge: "border-l-sky-500",
30
+ ring: "ring-sky-500",
31
+ },
32
+ {
33
+ avatar: "bg-emerald-500/20 text-emerald-700 dark:text-emerald-200",
34
+ text: "text-emerald-700 dark:text-emerald-300",
35
+ edge: "border-l-emerald-500",
36
+ ring: "ring-emerald-500",
37
+ },
38
+ {
39
+ avatar: "bg-violet-500/20 text-violet-700 dark:text-violet-200",
40
+ text: "text-violet-700 dark:text-violet-300",
41
+ edge: "border-l-violet-500",
42
+ ring: "ring-violet-500",
43
+ },
44
+ {
45
+ avatar: "bg-amber-500/20 text-amber-700 dark:text-amber-200",
46
+ text: "text-amber-700 dark:text-amber-300",
47
+ edge: "border-l-amber-500",
48
+ ring: "ring-amber-500",
49
+ },
50
+ {
51
+ avatar: "bg-rose-500/20 text-rose-700 dark:text-rose-200",
52
+ text: "text-rose-700 dark:text-rose-300",
53
+ edge: "border-l-rose-500",
54
+ ring: "ring-rose-500",
55
+ },
56
+ {
57
+ avatar: "bg-teal-500/20 text-teal-700 dark:text-teal-200",
58
+ text: "text-teal-700 dark:text-teal-300",
59
+ edge: "border-l-teal-500",
60
+ ring: "ring-teal-500",
61
+ },
62
+ ];
63
+
64
+ /** The colour for a canopy user id. Same id, same colour, everywhere. */
65
+ export function personColor(userId: number): PersonColor {
66
+ return PALETTE[Math.abs(userId) % PALETTE.length];
67
+ }
68
+
69
+ /** The colour for a message author — a canopy user or a contact. A contact
70
+ * (widget visitor) has no user id; its contact id is a different number
71
+ * space, so it is offset to avoid always landing on the colour of the user
72
+ * with the same id. The name beside it is what actually identifies them. */
73
+ export function authorColor(author: { user_id?: number; contact_id?: number }): PersonColor {
74
+ if (author.user_id != null) return personColor(author.user_id);
75
+ return personColor((author.contact_id ?? 0) + 3);
76
+ }
77
+
78
+ /** "Robin Sharma" → "RS". */
79
+ export function initials(name: string): string {
80
+ return name
81
+ .split(" ")
82
+ .map((w) => w[0])
83
+ .filter(Boolean)
84
+ .join("")
85
+ .slice(0, 2)
86
+ .toUpperCase();
87
+ }
@@ -216,6 +216,11 @@ export interface TurnStatus {
216
216
  /** When a settled turn finished — lets the kit drop a failure the
217
217
  * conversation has since moved past. Absent on an older server. */
218
218
  finished_at?: string | null;
219
+ /** What the conversation's host requires of the runner (e.g. `zdr`). Only
220
+ * set while queued, and only when the requirement is what blocks the turn
221
+ * (some runner would take it without one) — so render it as the reason.
222
+ * Absent on an older server. */
223
+ requires?: string[];
219
224
  /** Nothing more will happen without someone asking for it. */
220
225
  settled: boolean;
221
226
  /** Nothing is moving and only a person can change that. The one question a
@@ -186,3 +186,15 @@ describe("turnNotice", () => {
186
186
  expect(n?.tone).toBe("warn");
187
187
  });
188
188
  });
189
+
190
+ describe("turnNotice, when the conversation requires a runner flag", () => {
191
+ it("names the missing requirement when unrouted", () => {
192
+ const n = turnNotice(status({ state: "unrouted", stuck: true, runners: [], claimed_by: null, agent_slug: "ace", requires: ["zdr"] }));
193
+ expect(n?.text).toContain("ZDR runner");
194
+ });
195
+
196
+ it("names it when its runners are offline", () => {
197
+ const n = turnNotice(status({ state: "waiting_runner", stuck: true, runners: ["cloud-1"], claimed_by: null, requires: ["zdr"] }));
198
+ expect(n?.text).toContain("ZDR");
199
+ });
200
+ });
@@ -114,14 +114,16 @@ export function turnNotice(
114
114
  ? { name: status.cloud_runner, id: status.cloud_runner_id }
115
115
  : null;
116
116
  const runners = status.runners.join(", ");
117
+ const need = status.requires?.length ? status.requires.map((r) => r.toUpperCase()).join(", ") : "";
117
118
  switch (status.state) {
118
119
  case "waiting_runner":
119
120
  return {
120
121
  tone: "warn",
121
122
  // Names the box, because the fix is physical: go and open it.
122
- text: runners
123
+ text: (runners
123
124
  ? `Queued — ${runners} is offline, so nothing is working on this yet. It runs when the runner is back.`
124
- : "Queued — its runner is offline, so nothing is working on this yet.",
125
+ : "Queued — its runner is offline, so nothing is working on this yet.")
126
+ + (need ? ` It needs a ${need} runner.` : ""),
125
127
  offer,
126
128
  };
127
129
  case "unrouted":
@@ -129,7 +131,9 @@ export function turnNotice(
129
131
  tone: "error",
130
132
  // The one state waiting cannot fix, and it has to READ differently
131
133
  // from the one waiting can.
132
- text: status.agent_slug
134
+ text: need
135
+ ? `Queued — this conversation needs a ${need} runner, and none of ${status.agent_slug ?? "its agent"}'s runners is declared ${need}.`
136
+ : status.agent_slug
133
137
  ? `Queued, but no runner is set up to run ${status.agent_slug} — nothing will pick this up until its routing is fixed.`
134
138
  : "Queued, but no runner is set up to run this — nothing will pick it up until its routing is fixed.",
135
139
  offer,
@@ -1,33 +0,0 @@
1
- // @vitest-environment jsdom
2
- import { cleanup, render, screen } from "@testing-library/react";
3
- import { afterEach, describe, expect, it } from "vitest";
4
-
5
- import { TypingRows } from "./TypingRows";
6
- import type { PeerDraft } from "./protocol";
7
-
8
- afterEach(cleanup);
9
-
10
- describe("TypingRows", () => {
11
- it("renders nothing when nobody is typing", () => {
12
- const { container } = render(<TypingRows peers={[]} />);
13
- expect(container.firstChild).toBeNull();
14
- });
15
-
16
- it("shows a peer's live words when a body is present", () => {
17
- const peers: PeerDraft[] = [{ author: { id: 2, name: "Bo B" }, body: "hello there", at: null }];
18
- render(<TypingRows peers={peers} />);
19
- const row = screen.getByTestId("typing-row");
20
- expect(row.textContent).toContain("Bo B");
21
- expect(row.textContent).toContain("hello there");
22
- expect(row.textContent).toContain("is typing:");
23
- });
24
-
25
- it("renders 'is typing…' with no words when the body is withheld", () => {
26
- const peers: PeerDraft[] = [{ author: { id: 2, name: "Bo B" }, body: "", at: null, typing: true }];
27
- render(<TypingRows peers={peers} />);
28
- const row = screen.getByTestId("typing-row");
29
- expect(row.textContent).toContain("Bo B");
30
- expect(row.textContent).toContain("is typing…");
31
- expect(row.textContent).not.toContain("is typing:");
32
- });
33
- });