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 +1 -1
- package/src/chat/ChatPanel.multiplayer.test.tsx +202 -0
- package/src/chat/ChatPanel.tsx +71 -41
- package/src/chat/MessageItem.tsx +38 -3
- package/src/chat/MessageList.tsx +9 -0
- package/src/chat/PresenceChips.tsx +5 -35
- package/src/chat/QueuedRows.tsx +42 -0
- package/src/chat/SendBox.test.tsx +111 -37
- package/src/chat/SendBox.tsx +32 -108
- package/src/chat/TypingRows.tsx +18 -0
- package/src/chat/agui.fixture.json +78 -23
- package/src/chat/agui.ts +6 -1
- package/src/chat/drafts.test.ts +1 -65
- package/src/chat/drafts.ts +0 -20
- package/src/chat/identity.ts +37 -0
- package/src/chat/index.ts +7 -3
- package/src/chat/protocol.ts +77 -5
- package/src/chat/sessionReducer.test.ts +121 -0
- package/src/chat/sessionReducer.ts +61 -20
- package/src/chat/useSessionSocket.http.test.tsx +50 -1
- package/src/chat/useSessionSocket.ts +87 -16
package/package.json
CHANGED
|
@@ -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
|
+
});
|
package/src/chat/ChatPanel.tsx
CHANGED
|
@@ -1,19 +1,24 @@
|
|
|
1
|
-
import {
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
105
|
-
// stays
|
|
106
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
package/src/chat/MessageItem.tsx
CHANGED
|
@@ -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
|
-
?
|
|
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" ? (
|
package/src/chat/MessageList.tsx
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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[]
|
|
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
|
|
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
|
+
}
|