Skip to content

Commit c8ba54f

Browse files
committed
docs(conversation): correct ConversationThread docs and story count
The sentId doc said an id from another conversation moves nothing. Say instead that an id which is not one of the thread's items is ignored, matching the engine guard the Conversation change adds. Also note that an aria-label, like aria-labelledby, overrides labels.log, and document data-thread-parent and data-thread-divider as styling hooks. In the ChatWithThreads story the thread's replies lived in the tab, so the count under the parent never moved after a send. Keep them in the chat and derive the count from them, as the full-chat story does. Since the replies now outlive the tab, reply ids come from the thread's earlier sends, and stopping or closing the tab mid-answer marks the answer sent rather than leaving it streaming.
1 parent f224de2 commit c8ba54f

2 files changed

Lines changed: 66 additions & 22 deletions

File tree

‎src/ui/conversation-thread.tsx‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@ import {
1212
import { ConversationDivider } from "./conversation-divider.js";
1313

1414
export type ConversationThreadLabels = {
15-
/** The log's accessible name when no `aria-labelledby` is given. */
15+
/**
16+
* The log's accessible name when neither `aria-label` nor
17+
* `aria-labelledby` is given.
18+
*/
1619
readonly log: string;
1720
/** The rule between the parent and the replies. */
1821
readonly replies: (count: number) => string;
@@ -44,8 +47,8 @@ export type ConversationThreadProps = Omit<
4447
/**
4548
* The id of the reader's latest reply sent in this thread, as on
4649
* `Conversation`: each change is a send, and the value at mount is
47-
* history. Keep it per thread; an id from another conversation is not
48-
* among these replies and moves nothing.
50+
* history. Keep it per thread: an id that is not one of the thread's
51+
* items, one from another conversation say, is ignored.
4952
*/
5053
readonly sentId?: string | undefined;
5154
/** Defaults to `settle`. */
@@ -71,7 +74,8 @@ export type ConversationThreadProps = Omit<
7174
* answer writes in below it.
7275
*
7376
* `aria-labelledby` or `aria-label` names the log (the tab that shows the
74-
* thread, say); every other prop goes to the root.
77+
* thread, say); every other prop goes to the root. For styling, the parent's
78+
* item carries `data-thread-parent` and the rule `data-thread-divider`.
7579
*/
7680
export function ConversationThread({
7781
parent,

‎stories/chat/conversation-thread.stories.tsx‎

Lines changed: 58 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { FileText, MessageSquare } from "lucide-react";
2-
import { useEffect, useRef, useState } from "react";
2+
import { useCallback, useEffect, useRef, useState } from "react";
33
import type { ReactNode } from "react";
44

55
import { panelTabIds, useTabs } from "../../src/hooks/use-tabs.js";
@@ -111,41 +111,62 @@ const ANSWERS = [
111111
"The report now shows which follow-ups carried over from last week, so nothing gets lost.",
112112
];
113113

114+
type RepliesUpdate = (
115+
current: readonly ConversationMessage[],
116+
) => readonly ConversationMessage[];
117+
114118
/**
115119
* A thread's tab: the replies and the thread's own input. Sending adds the
116120
* reader's reply, which settles at the top, and the answer writes in below.
117-
* Kept mounted while another tab is in front, so the draft stays.
121+
* Kept mounted while another tab is in front, so the draft stays. The
122+
* replies live with the chat, so the count under the parent keeps up.
118123
*/
119124
function ThreadTab({
120125
parent,
121-
initialReplies,
126+
replies,
127+
onRepliesChange,
122128
scrollPolicy,
123129
labelledBy,
124130
focusRequest,
125131
}: {
126132
readonly parent: ConversationMessage;
127-
readonly initialReplies: readonly ConversationMessage[];
133+
readonly replies: readonly ConversationMessage[];
134+
readonly onRepliesChange: (parentId: string, update: RepliesUpdate) => void;
128135
/** The tab that shows the thread, which names its log. */
129136
readonly labelledBy: string;
130137
readonly scrollPolicy?: ConversationScrollPolicy | undefined;
131138
/** Bumped each time the reader opens this thread, to move focus to its input. */
132139
readonly focusRequest: number;
133140
}) {
134-
const [replies, setReplies] = useState(initialReplies);
135141
const input = useRef<HTMLTextAreaElement>(null);
136142
useEffect(() => {
137143
if (focusRequest > 0) input.current?.focus();
138144
}, [focusRequest]);
139145
const [draft, setDraft] = useState("");
140146
const [sentId, setSentId] = useState<string>();
141147
const [status, setStatus] = useState<PromptInputStatus>("ready");
142-
const [sends, setSends] = useState(0);
143148
const stop = useRef<(() => void) | null>(null);
144-
useEffect(() => () => stop.current?.(), []);
149+
const streamingId = useRef<string | null>(null);
150+
// Stops the answer where it stands, so the kept reply doesn't read as
151+
// still streaming once the tab is gone.
152+
const stopAnswer = useCallback(() => {
153+
stop.current?.();
154+
const id = streamingId.current;
155+
if (id === null) return;
156+
streamingId.current = null;
157+
onRepliesChange(parent.id, (current) =>
158+
current.map((reply) =>
159+
reply.id === id ? { ...reply, status: "sent" } : reply,
160+
),
161+
);
162+
}, [onRepliesChange, parent.id]);
163+
useEffect(() => stopAnswer, [stopAnswer]);
145164

146165
const send = () => {
147-
const count = sends + 1;
148-
setSends(count);
166+
// The replies outlive the tab, so the count comes from them.
167+
const count =
168+
replies.filter((reply) => reply.id.startsWith(`${parent.id}-sent-`))
169+
.length + 1;
149170
const sent: ConversationMessage = {
150171
id: `${parent.id}-sent-${count}`,
151172
author: you,
@@ -162,18 +183,22 @@ function ThreadTab({
162183
{ kind: "text", text: ANSWERS[(count - 1) % ANSWERS.length] ?? "" },
163184
],
164185
};
165-
setReplies((current) => [...current, sent]);
186+
onRepliesChange(parent.id, (current) => [...current, sent]);
166187
setSentId(sent.id);
167188
setDraft("");
168189
setStatus("streaming");
190+
streamingId.current = answer.id;
169191
stop.current = playTurn(
170192
answer,
171193
(frame) => {
172-
setReplies((current) => [
194+
onRepliesChange(parent.id, (current) => [
173195
...current.filter((reply) => reply.id !== frame.id),
174196
frame,
175197
]);
176-
if (frame === answer) setStatus("ready");
198+
if (frame === answer) {
199+
streamingId.current = null;
200+
setStatus("ready");
201+
}
177202
},
178203
{ stepMs: 120 },
179204
);
@@ -201,7 +226,7 @@ function ThreadTab({
201226
/>
202227
<PromptInputSubmit
203228
onStop={() => {
204-
stop.current?.();
229+
stopAnswer();
205230
setStatus("ready");
206231
}}
207232
/>
@@ -240,7 +265,8 @@ function Notes() {
240265

241266
/**
242267
* The main conversation beside a panel of tabs. A message's replies open its
243-
* thread as a tab; each thread has its own input.
268+
* thread as a tab; each thread has its own input, and a reply sent there
269+
* updates the count under its parent.
244270
*/
245271
function ChatWithThreads({
246272
messages,
@@ -249,7 +275,7 @@ function ChatWithThreads({
249275
scrollPolicy,
250276
}: {
251277
readonly messages: readonly ConversationMessage[];
252-
/** Each thread's replies, by parent id. */
278+
/** Each thread's replies at first, by parent id. */
253279
readonly threads: Readonly<Record<string, readonly ConversationMessage[]>>;
254280
/** A thread open at first, beside the notes tab. */
255281
readonly openThread?: string;
@@ -261,6 +287,15 @@ function ChatWithThreads({
261287
defaultTabs: opened === undefined ? [] : [NOTES, threadTab(opened)],
262288
defaultValue: opened === undefined ? null : threadTab(opened).value,
263289
});
290+
const [threadReplies, setThreadReplies] = useState(threads);
291+
const updateReplies = useCallback(
292+
(parentId: string, update: RepliesUpdate) =>
293+
setThreadReplies((current) => ({
294+
...current,
295+
[parentId]: update(current[parentId] ?? []),
296+
})),
297+
[],
298+
);
264299
const [closed, setClosed] = useState(false);
265300
const [focusRequests, setFocusRequests] = useState<
266301
Readonly<Record<string, number>>
@@ -275,7 +310,7 @@ function ChatWithThreads({
275310
<Conversation>
276311
<ConversationContent aria-label="Conversation">
277312
{messages.map((message) => {
278-
const replies = threads[message.id];
313+
const replies = threadReplies[message.id];
279314
const tab = threadTab(message);
280315
return (
281316
<ConversationItem key={message.id} itemId={message.id}>
@@ -285,7 +320,11 @@ function ChatWithThreads({
285320
message.replies === undefined ||
286321
replies === undefined ? undefined : (
287322
<MessageReplies
288-
replies={message.replies}
323+
replies={{
324+
count: replies.length,
325+
lastAt:
326+
replies.at(-1)?.at ?? message.replies.lastAt,
327+
}}
289328
now={NOW}
290329
aria-expanded={shown === tab.value}
291330
onClick={() => {
@@ -342,7 +381,8 @@ function ChatWithThreads({
342381
) : (
343382
<ThreadTab
344383
parent={parent}
345-
initialReplies={threads[parent.id] ?? []}
384+
replies={threadReplies[parent.id] ?? []}
385+
onRepliesChange={updateReplies}
346386
scrollPolicy={scrollPolicy}
347387
labelledBy={
348388
panelTabIds(tabsProps.idPrefix, tab.value).tab

0 commit comments

Comments
 (0)