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 +1 -1
- package/src/chat/ChatPanel.multiplayer.test.tsx +46 -6
- package/src/chat/ChatPanel.tsx +11 -3
- package/src/chat/MessageItem.tsx +7 -1
- package/src/chat/PeerComposers.test.tsx +64 -0
- package/src/chat/PeerComposers.tsx +106 -0
- package/src/chat/PresenceChips.tsx +41 -34
- package/src/chat/QueuedRows.tsx +8 -1
- package/src/chat/SendBox.test.tsx +1 -1
- package/src/chat/SendBox.tsx +6 -0
- package/src/chat/TypingRows.tsx +8 -18
- package/src/chat/index.ts +2 -0
- package/src/chat/personColor.ts +87 -0
- package/src/chat/protocol.ts +5 -0
- package/src/chat/turnStatus.test.ts +12 -0
- package/src/chat/turnStatus.ts +7 -3
- package/src/chat/TypingRows.test.tsx +0 -33
package/package.json
CHANGED
|
@@ -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
|
|
35
|
-
expect(
|
|
36
|
-
expect(
|
|
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
|
|
44
|
-
expect(
|
|
45
|
-
expect(
|
|
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", () => {
|
package/src/chat/ChatPanel.tsx
CHANGED
|
@@ -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 {
|
|
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
|
package/src/chat/MessageItem.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
}
|
package/src/chat/QueuedRows.tsx
CHANGED
|
@@ -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
|
|
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
|
-
// <
|
|
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" } });
|
package/src/chat/SendBox.tsx
CHANGED
|
@@ -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) => (
|
package/src/chat/TypingRows.tsx
CHANGED
|
@@ -1,25 +1,15 @@
|
|
|
1
1
|
import type { PeerDraft } from "./protocol";
|
|
2
|
+
import { PeerComposers } from "./PeerComposers";
|
|
2
3
|
|
|
3
|
-
/**
|
|
4
|
-
*
|
|
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
|
-
<
|
|
9
|
-
{peers
|
|
10
|
-
|
|
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
|
+
}
|
package/src/chat/protocol.ts
CHANGED
|
@@ -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
|
+
});
|
package/src/chat/turnStatus.ts
CHANGED
|
@@ -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:
|
|
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
|
-
});
|