canopy-ui 0.12.1 → 0.13.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.12.1",
3
+ "version": "0.13.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -0,0 +1,202 @@
1
+ // @vitest-environment jsdom
2
+ import { cleanup, fireEvent, render, screen } from "@testing-library/react";
3
+ import { afterEach, describe, expect, it } from "vitest";
4
+ import { ChatPanel } from "./ChatPanel";
5
+ import type { SessionState } from "./protocol";
6
+
7
+ afterEach(cleanup);
8
+
9
+ function state(over: Partial<SessionState> = {}): SessionState {
10
+ return {
11
+ messages: [], active_draft: null, participants: [
12
+ { user_id: 1, email: "a@x", display_name: "Alice A", role: "owner", joined_at: null, last_seen_at: null },
13
+ { user_id: 2, email: "b@x", display_name: "Bo B", role: "editor", joined_at: null, last_seen_at: null },
14
+ ], presence_user_ids: [1, 2], current_user_id: 1, ...over,
15
+ } as SessionState;
16
+ }
17
+
18
+ const noop = () => undefined;
19
+ const props = {
20
+ connected: true,
21
+ currentUserId: 1,
22
+ onSend: noop,
23
+ onStop: noop,
24
+ onUpdateDraft: noop,
25
+ onDiscard: noop,
26
+ } as const;
27
+
28
+ // Plain DOM assertions on purpose — this repo does not register
29
+ // @testing-library/jest-dom, so toHaveTextContent and friends are not
30
+ // available here.
31
+ describe("multiplayer composer", () => {
32
+ it("shows a teammate's live text and never locks my box", () => {
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");
37
+ expect((screen.getByTestId("composer") as HTMLTextAreaElement).disabled).toBe(false);
38
+ expect(screen.queryByTestId("coedit-banner")).toBeNull();
39
+ });
40
+
41
+ it("send stays available while the agent is replying", () => {
42
+ const streaming = { id: "a1", turn_index: 2, role: "assistant" as const, content: {}, plaintext: "working", status: "streaming" as const, error_detail: null, started_at: null, completed_at: null, created_at: "" };
43
+ render(<ChatPanel {...props} state={state({ messages: [streaming] })} />);
44
+ expect(screen.getByRole("button", { name: /stop/i })).not.toBeNull();
45
+ expect(screen.getByTestId("send")).not.toBeNull();
46
+ });
47
+
48
+ it("renders a teammate's queued send with their name", () => {
49
+ render(<ChatPanel {...props} state={state({ queued: [{ turn_id: "t", client_id: "c", author: { name: "Bo B", user_id: 2 }, text: "next up", sent_at: "", state: "queued" }] })} />);
50
+ const row = screen.getByTestId("queued-row");
51
+ expect(row.textContent).toContain("Bo B");
52
+ expect(row.textContent).toContain("queued");
53
+ });
54
+
55
+ it("shows exactly one row for the sender's own HTTP/contact send", () => {
56
+ // `noteLocalSend`'s optimistic row and the server's queued-turn
57
+ // projection describe the SAME send once both carry the same client_id —
58
+ // an HTTP send (a contact's, or the widget's first message) has no
59
+ // `author.user_id` to compare against `currentUserId`, so client_id
60
+ // matching (`hideClientIds`) is the only thing that can tell them apart
61
+ // from two different people.
62
+ const mine = {
63
+ id: "local:1", turn_index: 1, role: "user" as const,
64
+ content: { text: "please help", client_id: "abc" }, plaintext: "please help",
65
+ status: "complete" as const, error_detail: null, started_at: null,
66
+ completed_at: null, created_at: "",
67
+ };
68
+ render(<ChatPanel {...props} state={state({
69
+ messages: [mine],
70
+ queued: [{ turn_id: "t", client_id: "abc", author: null, text: "please help", sent_at: "", state: "queued" }],
71
+ })} />);
72
+ expect(screen.queryByTestId("queued-row")).toBeNull();
73
+ expect(screen.getAllByText("please help")).toHaveLength(1);
74
+ });
75
+
76
+ it("labels someone else's message with their name, not mine", () => {
77
+ const mine = { id: "m1", turn_index: 1, role: "user" as const, content: {}, plaintext: "me", status: "complete" as const, error_detail: null, started_at: null, completed_at: null, created_at: "", author: { name: "Alice A", user_id: 1 } };
78
+ const theirs = { ...mine, id: "m2", turn_index: 2, plaintext: "them", author: { name: "Bo B", user_id: 2 } };
79
+ render(<ChatPanel {...props} state={state({ messages: [mine, theirs] })} />);
80
+ expect(screen.getAllByTestId("message-author").map((n) => n.textContent)).toEqual(["Bo B"]);
81
+ });
82
+ });
83
+
84
+ describe("a contact's failed send restores what they typed", () => {
85
+ // A contact's socket is read-only, so their send goes over HTTP
86
+ // (`useSessionSocket.sendChatOverHttp`) and `current_user_id` is null on
87
+ // their connect snapshot (consumers.py: "None for a contact, who has no
88
+ // user id"). Their composer's `active_draft` is the hook's own LOCAL
89
+ // stand-in (`id: "local"`) rather than a server-assigned draft.
90
+ function localDraft(body: string) {
91
+ return {
92
+ id: "local", slot: "next" as const, status: "open" as const, body,
93
+ version: 0, last_editor: 0, last_edit_at: new Date().toISOString(),
94
+ };
95
+ }
96
+
97
+ it("shows the text again once the HTTP send fails", () => {
98
+ const contactProps = { ...props, currentUserId: null };
99
+ const composer = () => screen.getByTestId("composer") as HTMLTextAreaElement;
100
+
101
+ const { rerender } = render(
102
+ <ChatPanel {...contactProps} state={state({
103
+ active_draft: localDraft("please help"), current_user_id: null,
104
+ })} />,
105
+ );
106
+ expect(composer().value).toBe("please help");
107
+
108
+ fireEvent.click(screen.getByTestId("send"));
109
+ expect(composer().value).toBe("");
110
+
111
+ // The send went out — `sendChatOverHttp` clears the hook's local draft to
112
+ // null the instant it fires, well before the HTTP round trip resolves.
113
+ rerender(
114
+ <ChatPanel {...contactProps} state={state({
115
+ active_draft: null, current_user_id: null,
116
+ })} />,
117
+ );
118
+ expect(composer().value).toBe("");
119
+
120
+ // The HTTP send failed — the hook's catch handler re-seeds the SAME local
121
+ // draft with the original text ("Put the words back, so a failed send
122
+ // never costs what was typed.").
123
+ rerender(
124
+ <ChatPanel {...contactProps} state={state({
125
+ active_draft: localDraft("please help"), current_user_id: null,
126
+ })} />,
127
+ );
128
+ expect(composer().value).toBe("please help");
129
+ });
130
+ });
131
+
132
+ describe("a contact viewer (final review C2 / I6)", () => {
133
+ // A widget visitor has no user id: `current_user_id` is null and the
134
+ // snapshot names them by `current_contact_id` instead.
135
+ const contactState = (over: Partial<SessionState> = {}) =>
136
+ state({ current_user_id: null, current_contact_id: 7, ...over });
137
+ const contactProps = { ...props, currentUserId: null };
138
+ const authored = (over: Record<string, unknown>) => ({
139
+ id: "m1", turn_index: 1, role: "user" as const, content: {}, plaintext: "hello",
140
+ status: "complete" as const, error_detail: null, started_at: null, completed_at: null,
141
+ created_at: "", ...over,
142
+ });
143
+
144
+ it("renders the contact's own authored line as theirs: right-aligned, no label", () => {
145
+ render(<ChatPanel {...contactProps} state={contactState({
146
+ messages: [authored({ author: { name: "Beth", contact_id: 7 } })],
147
+ })} />);
148
+ expect(screen.queryByTestId("message-author")).toBeNull();
149
+ const bubble = screen.getByText("hello").closest("div.rounded-2xl") as HTMLElement;
150
+ expect(bubble.className).toContain("ml-auto");
151
+ });
152
+
153
+ it("still labels a member's line for the contact", () => {
154
+ render(<ChatPanel {...contactProps} state={contactState({
155
+ messages: [authored({ author: { name: "Alice A", user_id: 1 } })],
156
+ })} />);
157
+ expect(screen.getAllByTestId("message-author").map((n) => n.textContent)).toEqual(["Alice A"]);
158
+ });
159
+
160
+ it("calls the contact's own queued send 'You'", () => {
161
+ render(<ChatPanel {...contactProps} state={contactState({
162
+ queued: [{ turn_id: "t", client_id: "c", author: { name: "Beth", contact_id: 7 }, text: "later", sent_at: "", state: "queued" }],
163
+ })} />);
164
+ const row = screen.getByTestId("queued-row");
165
+ expect(row.textContent).toContain("You");
166
+ expect(row.textContent).not.toContain("Beth");
167
+ });
168
+
169
+ it("does not double an own send whose client never sent a client_id", () => {
170
+ // An older host (ace-web's sendOverHttp) sends no client_id, so the queued
171
+ // entry cannot be matched by id. Its optimistic row is still on screen.
172
+ const pending = authored({ id: "local:x", plaintext: "please help", status: "complete",
173
+ content: { text: "please help", client_id: "x" } });
174
+ render(<ChatPanel {...contactProps} state={contactState({
175
+ messages: [pending],
176
+ queued: [{ turn_id: "t", client_id: "", author: { name: "Beth", contact_id: 7 }, text: "please help ", sent_at: "", state: "queued" }],
177
+ })} />);
178
+ expect(screen.queryByTestId("queued-row")).toBeNull();
179
+ });
180
+
181
+ it("a peer's identical text is not hidden by my own optimistic row", () => {
182
+ const pending = authored({ id: "local:x", plaintext: "yes", status: "pending",
183
+ content: { text: "yes", client_id: "x" } });
184
+ render(<ChatPanel {...contactProps} state={contactState({
185
+ messages: [pending],
186
+ queued: [{ turn_id: "t", client_id: "", author: { name: "Alice A", user_id: 1 }, text: "yes", sent_at: "", state: "queued" }],
187
+ })} />);
188
+ expect(screen.getByTestId("queued-row").textContent).toContain("Alice A");
189
+ });
190
+ });
191
+
192
+ describe("a peer's bubble (final review m4)", () => {
193
+ it("does not wear the agent's treatment", () => {
194
+ const theirs = { id: "m2", turn_index: 2, role: "user" as const, content: {}, plaintext: "them", status: "complete" as const, error_detail: null, started_at: null, completed_at: null, created_at: "", author: { name: "Bo B", user_id: 2 } };
195
+ const agent = { ...theirs, id: "a1", turn_index: 3, role: "assistant" as const, plaintext: "agent", author: null };
196
+ render(<ChatPanel {...props} state={state({ messages: [theirs, agent] })} />);
197
+ const peer = screen.getByText("them").closest("div.rounded-2xl") as HTMLElement;
198
+ const bot = screen.getByText("agent").closest("div.rounded-2xl") as HTMLElement;
199
+ expect(peer.className).not.toBe(bot.className);
200
+ expect(peer.className).toContain("border");
201
+ });
202
+ });
@@ -1,19 +1,24 @@
1
- import { useEffect, useMemo, useState, type ReactNode } from "react";
1
+ import { useMemo, type ReactNode } from "react";
2
2
 
3
3
  import type { SessionState } 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
+ import { isMine } from "./identity";
9
+ import { QueuedRows } from "./QueuedRows";
10
+ import { TypingRows } from "./TypingRows";
8
11
  import { SendBox, type PendingAttachment } from "./SendBox";
9
- import { isDraftIdle, msUntilDraftIdle, type DraftStorage } from "./drafts";
12
+ import { type DraftStorage } from "./drafts";
10
13
  import { agentHasFloor as computeAgentHasFloor, pendingLabel as computePendingLabel, turnNotice } from "./turnStatus";
11
14
  import { useStickyBottom } from "./useStickyBottom";
12
15
 
13
16
  export interface ChatPanelProps {
14
17
  state: SessionState;
15
18
  connected: boolean;
16
- currentUserId: number;
19
+ /** The viewer's user id — null for a contact (a widget visitor), who is
20
+ * identified by `state.current_contact_id` instead. */
21
+ currentUserId: number | null;
17
22
  onSend: () => void;
18
23
  onStop: (messageId: string | null) => void;
19
24
  /** A send is outstanding but no reply has begun — the turn is queued,
@@ -24,7 +29,11 @@ export interface ChatPanelProps {
24
29
  onAttach?: (files: File[]) => void;
25
30
  onRemoveAttachment?: (id: string) => void;
26
31
  onUpdateDraft: (body: string) => void;
27
- onTakeOver: () => void;
32
+ /** @deprecated ignored since 0.13 — everyone has their own draft now, so
33
+ * there is no lock left to take over. Kept optional so a host still
34
+ * wiring `useSessionSocket().takeOverDraft` through (e.g. ace-web) keeps
35
+ * compiling across the upgrade. */
36
+ onTakeOver?: () => void;
28
37
  onDiscard: () => void;
29
38
  renderMarkdown?: RenderMarkdown;
30
39
  /** Optional banner rendered above the composer. */
@@ -59,7 +68,6 @@ export function ChatPanel({
59
68
  onAttach,
60
69
  onRemoveAttachment,
61
70
  onUpdateDraft,
62
- onTakeOver,
63
71
  onDiscard,
64
72
  renderMarkdown,
65
73
  banner,
@@ -74,36 +82,13 @@ export function ChatPanel({
74
82
  // it wired without an unused-var error; a future toolbar can surface it.
75
83
  void onDiscard;
76
84
 
77
- // Force a re-render when the draft lock transitions from live to idle so
78
- // PresenceChips' amber-highlight updates at T+2s without waiting for some
79
- // unrelated event to arrive.
80
- const [, forceIdleTick] = useState(0);
81
- useEffect(() => {
82
- const draft = state.active_draft;
83
- if (!draft) return;
84
- const remaining = msUntilDraftIdle(draft);
85
- if (remaining === 0) return;
86
- const t = window.setTimeout(() => forceIdleTick((n) => n + 1), remaining + 10);
87
- return () => window.clearTimeout(t);
88
- }, [state.active_draft?.last_edit_at, state.active_draft]);
89
-
90
- const holderId = state.active_draft?.last_editor ?? null;
91
- const holderIsPresent =
92
- holderId != null && state.presence_user_ids.includes(holderId);
93
- // The holder's NAME, for the composer. SendBox has ids and no roster, so
94
- // without this the one place a person actually looks — the box their
95
- // teammate's words are appearing in — could only say "Another teammate",
96
- // while the name sat in a chip at the far corner of the screen.
97
- const holderName =
98
- holderId != null && holderId !== currentUserId
99
- ? (state.participants.find((p) => p.user_id === holderId)?.display_name ?? null)
100
- : null;
101
-
102
85
  // A turn is "in flight" from the moment the assistant row appears
103
86
  // (status=pending/streaming) until chat.stream_complete flips it to
104
- // complete. Treat pending AND streaming as in-flight so the send button
105
- // stays locked out and the stop button is reachable during the "waiting
106
- // for first token" window.
87
+ // complete. Treat pending AND streaming as in-flight so the Stop button
88
+ // stays reachable during the "waiting for first token" window and SendBox
89
+ // knows to show its "sent after the current reply" placeholder — Send
90
+ // itself is never locked out by this any more (0.13): a send made while the
91
+ // agent is replying is QUEUED, not blocked, and <QueuedRows> shows it landed.
107
92
  const inFlightMessage = useMemo(
108
93
  () =>
109
94
  state.messages.find(
@@ -154,9 +139,47 @@ export function ChatPanel({
154
139
  const messages = state.messages;
155
140
  const lastMessageLen =
156
141
  messages.length > 0 ? messages[messages.length - 1].plaintext.length : 0;
157
- const scrollDep = `${messages.length}:${lastMessageLen}:${showPendingReply}`;
142
+ const queuedCount = state.queued?.length ?? 0;
143
+ const scrollDep = `${messages.length}:${lastMessageLen}:${showPendingReply}:${queuedCount}`;
158
144
  const { containerRef, onScroll } = useStickyBottom(scrollDep);
159
145
 
146
+ // Your own optimistic sends already render in the transcript (as a
147
+ // "pending" user row keyed by client_id) the instant you press send, so a
148
+ // queued row for the SAME send would show it twice — once as your bubble,
149
+ // once as a dashed "queued" placeholder. hideClientIds is every client_id
150
+ // already on screen in `messages`.
151
+ const hideClientIds = useMemo(
152
+ () =>
153
+ new Set(
154
+ state.messages
155
+ .map((m) => m.content?.client_id)
156
+ .filter((c): c is string => typeof c === "string"),
157
+ ),
158
+ [state.messages],
159
+ );
160
+
161
+ // Who is looking, for "is this line mine": a member by user id, a contact
162
+ // (the widget) by the snapshot's contact id.
163
+ const currentContactId = state.current_contact_id ?? null;
164
+
165
+ // The text of your own sends still waiting to be confirmed — for a client
166
+ // that sends no client_id (an older host's HTTP send), the only way to tell
167
+ // its queued entry from a second copy of the same line.
168
+ const ownPendingTexts = useMemo(
169
+ () =>
170
+ new Set(
171
+ state.messages
172
+ .filter(
173
+ (m) =>
174
+ m.role === "user" &&
175
+ (m.status === "pending" || m.id.startsWith("local:")) &&
176
+ (!m.author || isMine(m.author, currentUserId, currentContactId)),
177
+ )
178
+ .map((m) => m.plaintext.trim()),
179
+ ),
180
+ [state.messages, currentUserId, currentContactId],
181
+ );
182
+
160
183
  return (
161
184
  <div className="flex h-full flex-col">
162
185
  <div className="flex items-center gap-3 border-b border-border bg-background px-3 py-1.5 text-xs">
@@ -165,8 +188,6 @@ export function ChatPanel({
165
188
  <PresenceChips
166
189
  participants={state.participants}
167
190
  presenceUserIds={state.presence_user_ids}
168
- draftHolderId={holderId}
169
- draftHolderIdle={isDraftIdle(state.active_draft)}
170
191
  currentUserId={currentUserId}
171
192
  />
172
193
  </div>
@@ -179,28 +200,37 @@ export function ChatPanel({
179
200
  header, i.e. unreachable. Pinning keeps "Load earlier"/"Load full"
180
201
  always visible. */}
181
202
  {historySlot}
182
- <div ref={containerRef} onScroll={onScroll} className="flex-1 overflow-y-auto">
203
+ {/* overflow-x-hidden: `overflow-y: auto` alone computes overflow-x to
204
+ auto, so anything wider than the column made the transcript scroll
205
+ sideways into empty space. Wide content scrolls inside its own box. */}
206
+ <div ref={containerRef} onScroll={onScroll} className="flex-1 overflow-x-hidden overflow-y-auto">
183
207
  <MessageList
184
208
  messages={state.messages}
185
209
  emptyState={emptyState}
186
210
  renderMarkdown={renderMarkdown}
187
211
  pendingReply={showPendingReply}
188
212
  pendingLabel={pendingLabel}
213
+ currentUserId={currentUserId}
214
+ currentContactId={currentContactId}
215
+ />
216
+ <QueuedRows
217
+ queued={state.queued ?? []}
218
+ hideClientIds={hideClientIds}
219
+ currentUserId={currentUserId}
220
+ currentContactId={currentContactId}
221
+ ownPendingTexts={ownPendingTexts}
189
222
  />
190
223
  </div>
224
+ <TypingRows peers={state.peer_drafts ?? []} />
191
225
  <SendBox
192
226
  draft={state.active_draft}
193
227
  connected={connected}
194
- currentUserId={currentUserId}
195
- holderIsPresent={holderIsPresent}
196
- holderName={holderName}
197
228
  isStreaming={inFlightMessage != null || awaitingReply}
198
229
  streamingMessageId={inFlightMessage?.id ?? null}
199
230
  onUpdate={onUpdateDraft}
200
231
  onSend={onSend}
201
232
  onStop={onStop}
202
233
  stopState={state.stopState}
203
- onTakeOver={onTakeOver}
204
234
  banner={
205
235
  notice ? (
206
236
  <p
@@ -1,7 +1,8 @@
1
1
  import type { ReactNode } from "react";
2
2
  import { AlertTriangle, ChevronRight, OctagonX } from "lucide-react";
3
3
 
4
- import type { Message } from "./protocol";
4
+ import type { Message, MessageAuthor } from "./protocol";
5
+ import { isMine } from "./identity";
5
6
  import { ToolCallPair } from "./ToolCallPair";
6
7
 
7
8
  /** How to render assistant/system markdown. Injected by the app so the kit
@@ -19,6 +20,23 @@ interface Props {
19
20
  * duplicate the rendering logic per row. */
20
21
  forceToolOpen?: boolean;
21
22
  renderMarkdown?: RenderMarkdown;
23
+ /** Who is looking, so a `user` row can tell "me" from "someone else" —
24
+ * every row persisted before authorship shipped has `author == null` and
25
+ * must keep rendering exactly as it always has (right-aligned, no label).
26
+ * Null for a contact viewer, who is named by `currentContactId`. */
27
+ currentUserId?: number | null;
28
+ /** The viewer when it is a contact (a widget visitor); null for a member. */
29
+ currentContactId?: number | null;
30
+ }
31
+
32
+ /** Someone else's line, or null when it's mine (or nobody recorded). A row
33
+ * with no `author` predates authorship tracking and is never "someone
34
+ * else's" — it renders exactly as it always did. */
35
+ function otherAuthorOf(
36
+ message: Message, currentUserId?: number | null, currentContactId?: number | null,
37
+ ): MessageAuthor | null {
38
+ if (message.role !== "user" || !message.author) return null;
39
+ return isMine(message.author, currentUserId, currentContactId) ? null : message.author;
22
40
  }
23
41
 
24
42
  /** Count visible lines for the "▸ System context (N lines)" header. */
@@ -68,6 +86,8 @@ export function MessageItem({
68
86
  message,
69
87
  forceToolOpen,
70
88
  renderMarkdown = plainText,
89
+ currentUserId,
90
+ currentContactId,
71
91
  }: Props) {
72
92
  const text = message.plaintext;
73
93
  const isStreaming = message.status === "streaming";
@@ -109,9 +129,14 @@ export function MessageItem({
109
129
  );
110
130
  }
111
131
 
132
+ const otherAuthor = otherAuthorOf(message, currentUserId, currentContactId);
133
+ // A teammate's line sits on the agent's side of the column, so it needs its
134
+ // own treatment — in the agent's `bg-muted` it read as the agent talking.
112
135
  const bubbleClass =
113
136
  message.role === "user"
114
- ? "ml-auto bg-primary text-primary-foreground"
137
+ ? otherAuthor
138
+ ? "mr-auto border border-border bg-card text-foreground"
139
+ : "ml-auto bg-primary text-primary-foreground"
115
140
  : "mr-auto bg-muted text-foreground";
116
141
  // Hold the "Thinking…" treatment through the gap between
117
142
  // chat.stream_start (status flips to "streaming") and the first
@@ -119,10 +144,20 @@ export function MessageItem({
119
144
  const showThinking =
120
145
  (isPending || isStreaming) && message.role === "assistant" && !text;
121
146
  return (
147
+ // `overflow-wrap:anywhere` so one unbreakable token — a long path, a URL,
148
+ // inline code — wraps inside the bubble. Without it the token overflowed
149
+ // `max-w-[80%]` and made the whole transcript scroll sideways into empty
150
+ // space on a phone (2026-09-24). Tables and code blocks keep their own
151
+ // horizontal scroll; this only affects running text.
122
152
  <div
123
- className={`my-2 max-w-[80%] rounded-2xl px-4 py-2 ${bubbleClass}`}
153
+ className={`my-2 min-w-0 max-w-[80%] rounded-2xl px-4 py-2 [overflow-wrap:anywhere] ${bubbleClass}`}
124
154
  aria-live={isStreaming || isPending ? "polite" : undefined}
125
155
  >
156
+ {otherAuthor && (
157
+ <div data-testid="message-author" className="mb-0.5 text-[11px] font-medium text-muted-foreground">
158
+ {otherAuthor.name}
159
+ </div>
160
+ )}
126
161
  {showThinking ? (
127
162
  <ThinkingIndicator />
128
163
  ) : message.role === "assistant" ? (
@@ -28,6 +28,11 @@ interface Props {
28
28
  /** Wording for that bubble — the caller knows whether the turn is still
29
29
  * queued for a runner or the agent is actually working. */
30
30
  pendingLabel?: string;
31
+ /** Threaded to MessageItem so a `user` row can tell "me" from "someone
32
+ * else" for the authored-bubble treatment. */
33
+ currentUserId?: number | null;
34
+ /** The viewer when it is a contact; see MessageItem. */
35
+ currentContactId?: number | null;
31
36
  }
32
37
 
33
38
  // Show the bulk expand/collapse toolbar once a session has more than this
@@ -42,6 +47,8 @@ export function MessageList({
42
47
  renderMarkdown,
43
48
  pendingReply = false,
44
49
  pendingLabel,
50
+ currentUserId,
51
+ currentContactId,
45
52
  }: Props) {
46
53
  const paired = useMemo(() => pairToolMessages(messages), [messages]);
47
54
  // Collapse back-to-back tool calls into one row. An agent mid-task emits long
@@ -156,6 +163,8 @@ export function MessageList({
156
163
  message={row.message}
157
164
  forceToolOpen={forceToolOpen}
158
165
  renderMarkdown={renderMarkdown}
166
+ currentUserId={currentUserId}
167
+ currentContactId={currentContactId}
159
168
  />
160
169
  );
161
170
  })}
@@ -3,8 +3,6 @@ import type { Participant } from "./protocol";
3
3
  interface Props {
4
4
  participants: Participant[];
5
5
  presenceUserIds: number[];
6
- draftHolderId: number | null;
7
- draftHolderIdle: boolean;
8
6
  /** Who is looking. Required so the row can exclude YOU.
9
7
  *
10
8
  * Without it this rendered your own chip and the empty state said "nobody
@@ -13,7 +11,7 @@ interface Props {
13
11
  * you saw one chip, which is indistinguishable from being watched by a
14
12
  * stranger. ChatPanel had `currentUserId` the whole time and never passed
15
13
  * it. Caught by the first two-browser e2e this surface ever had. */
16
- currentUserId: number;
14
+ currentUserId: number | null;
17
15
  }
18
16
 
19
17
  /** A stable colour per person, so two people are told apart at a glance.
@@ -40,8 +38,6 @@ const MAX_FACES = 4;
40
38
  export function PresenceChips({
41
39
  participants,
42
40
  presenceUserIds,
43
- draftHolderId,
44
- draftHolderIdle,
45
41
  currentUserId,
46
42
  }: Props) {
47
43
  const present = participants.filter(
@@ -59,50 +55,28 @@ export function PresenceChips({
59
55
  );
60
56
  }
61
57
 
62
- const editor = present.find(
63
- (p) => p.user_id === draftHolderId && !draftHolderIdle,
64
- );
65
58
  const faces = present.slice(0, MAX_FACES);
66
59
  const overflow = present.length - faces.length;
67
60
 
68
61
  return (
69
62
  <div className="flex items-center gap-2" data-testid="presence-chips">
70
- {/* The one thing worth WORDS rather than a ring: somebody is typing into
71
- the box you share, and the old UI said so only in a `title` tooltip —
72
- invisible on touch, invisible to a screen reader, and invisible to
73
- anyone not hovering the exact 28px circle. */}
74
- {editor && (
75
- <span
76
- className="hidden items-center gap-1 text-xs text-muted-foreground sm:flex"
77
- data-testid="presence-editing-label"
78
- >
79
- <span className="relative flex h-1.5 w-1.5" aria-hidden="true">
80
- <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-primary opacity-60" />
81
- <span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-primary" />
82
- </span>
83
- {firstName(editor.display_name)} is typing…
84
- </span>
85
- )}
86
63
  <ul
87
64
  className="flex items-center -space-x-1.5"
88
- aria-label={describe(present, editor)}
65
+ aria-label={describe(present)}
89
66
  data-testid="presence-list"
90
67
  >
91
68
  {faces.map((p) => {
92
- const isEditor = p.user_id === editor?.user_id;
93
69
  return (
94
70
  <li
95
71
  key={p.user_id}
96
72
  data-testid="presence-chip"
97
73
  data-user-id={p.user_id}
98
- data-editing={isEditor ? "true" : "false"}
99
- title={p.display_name + (isEditor ? " — editing…" : "")}
74
+ title={p.display_name}
100
75
  className={[
101
76
  "flex h-7 w-7 items-center justify-center rounded-full",
102
77
  "text-[11px] font-semibold ring-2 ring-background",
103
78
  "transition-transform hover:z-10 hover:scale-110",
104
79
  colorFor(p.user_id),
105
- isEditor ? "z-10 !ring-primary" : "",
106
80
  ].join(" ")}
107
81
  >
108
82
  {initials(p.display_name)}
@@ -125,14 +99,10 @@ export function PresenceChips({
125
99
 
126
100
  /** The accessible name for the row — the same fact the faces carry, in words.
127
101
  * A row of coloured circles is meaningless without this. */
128
- function describe(present: Participant[], editor?: Participant): string {
102
+ function describe(present: Participant[]): string {
129
103
  const names = present.map((p) => p.display_name).join(", ");
130
104
  const who = present.length === 1 ? "1 other person here" : `${present.length} other people here`;
131
- return editor ? `${who}: ${names}. ${editor.display_name} is editing.` : `${who}: ${names}`;
132
- }
133
-
134
- function firstName(name: string): string {
135
- return name.trim().split(/\s+/)[0] || name;
105
+ return `${who}: ${names}`;
136
106
  }
137
107
 
138
108
  function initials(name: string): string {
@@ -0,0 +1,42 @@
1
+ import type { QueuedMessage } from "./protocol";
2
+ import { isMine } from "./identity";
3
+
4
+ /** Sends that have not reached the agent yet, in send order, for everyone.
5
+ * Your own are skipped when your optimistic echo already shows them — by
6
+ * client_id (`hideClientIds`), or, for a client that never sent one (an older
7
+ * host's HTTP send), by the text of your own still-unconfirmed row
8
+ * (`ownPendingTexts`) — so a line never appears twice. */
9
+ export function QueuedRows({
10
+ queued, hideClientIds, currentUserId, currentContactId, ownPendingTexts,
11
+ }: {
12
+ queued: QueuedMessage[];
13
+ hideClientIds: Set<string>;
14
+ currentUserId: number | null;
15
+ currentContactId?: number | null;
16
+ /** Trimmed text of the viewer's own optimistic rows not yet confirmed. */
17
+ ownPendingTexts?: Set<string>;
18
+ }) {
19
+ const rows = queued.filter((q) => {
20
+ if (q.client_id && hideClientIds.has(q.client_id)) return false;
21
+ const mine = isMine(q.author, currentUserId, currentContactId);
22
+ return !(mine && ownPendingTexts?.has(q.text.trim()));
23
+ });
24
+ if (rows.length === 0) return null;
25
+ return (
26
+ <div className="px-3">
27
+ {rows.map((q) => {
28
+ const mine = isMine(q.author, currentUserId, currentContactId);
29
+ return (
30
+ <div key={q.turn_id} data-testid="queued-row"
31
+ 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
+ <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>{q.state === "queued" ? "queued" : "sending to agent…"}</span>
35
+ </div>
36
+ <div className="whitespace-pre-wrap text-foreground-secondary [overflow-wrap:anywhere]">{q.text}</div>
37
+ </div>
38
+ );
39
+ })}
40
+ </div>
41
+ );
42
+ }