11import { FileText , MessageSquare } from "lucide-react" ;
2- import { useEffect , useRef , useState } from "react" ;
2+ import { useCallback , useEffect , useRef , useState } from "react" ;
33import type { ReactNode } from "react" ;
44
55import { 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 */
119124function 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 */
245271function 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