@@ -14,6 +14,7 @@ import {
1414 Layout ,
1515 Menu ,
1616 Modal ,
17+ Popconfirm ,
1718 Select ,
1819 Space ,
1920 Switch ,
@@ -51,8 +52,12 @@ import {
5152 getThreadRootDate ,
5253 markChatAsReadIfUnseen ,
5354} from "./utils" ;
54- import { ALL_THREADS_KEY , useThreadList } from "./threads" ;
55- import type { ThreadListItem } from "./threads" ;
55+ import {
56+ ALL_THREADS_KEY ,
57+ groupThreadsByRecency ,
58+ useThreadList ,
59+ } from "./threads" ;
60+ import type { ThreadListItem , ThreadSection } from "./threads" ;
5661
5762const FILTER_RECENT_NONE = {
5863 value : 0 ,
@@ -118,19 +123,32 @@ const THREAD_ITEM_LABEL_STYLE: React.CSSProperties = {
118123 pointerEvents : "none" ,
119124} as const ;
120125
126+ const THREAD_SECTION_HEADER_STYLE : React . CSSProperties = {
127+ display : "flex" ,
128+ alignItems : "center" ,
129+ justifyContent : "space-between" ,
130+ padding : "0 20px 6px" ,
131+ color : COLORS . GRAY_D ,
132+ } as const ;
133+
121134export type ThreadMeta = ThreadListItem & {
122135 displayLabel : string ;
123136 hasCustomName : boolean ;
124137 readCount : number ;
125138 unreadCount : number ;
126139 isAI : boolean ;
140+ isPinned : boolean ;
127141} ;
128142
129143function stripHtml ( value : string ) : string {
130144 if ( ! value ) return "" ;
131145 return value . replace ( / < [ ^ > ] * > / g, "" ) ;
132146}
133147
148+ interface ThreadSectionWithUnread extends ThreadSection < ThreadMeta > {
149+ unreadCount : number ;
150+ }
151+
134152export interface ChatPanelProps {
135153 actions : ChatActions ;
136154 project_id : string ;
@@ -223,6 +241,12 @@ export function ChatPanel({
223241 ) ?. trim ( ) ;
224242 const hasCustomName = ! ! storedName ;
225243 const displayLabel = storedName || thread . label ;
244+ const pinValue = rootMessage ?. get ( "pin" ) ;
245+ const isPinned =
246+ pinValue === true ||
247+ pinValue === "true" ||
248+ pinValue === 1 ||
249+ pinValue === "1" ;
226250 const readField =
227251 account_id && rootMessage
228252 ? rootMessage . get ( `read-${ account_id } ` )
@@ -255,10 +279,22 @@ export function ChatPanel({
255279 readCount,
256280 unreadCount,
257281 isAI : ! ! isAI ,
282+ isPinned,
258283 } ;
259284 } ) ;
260285 } , [ rawThreads , account_id , actions ] ) ;
261286
287+ const threadSections = useMemo < ThreadSectionWithUnread [ ] > ( ( ) => {
288+ const grouped = groupThreadsByRecency ( threads ) ;
289+ return grouped . map ( ( section ) => ( {
290+ ...section ,
291+ unreadCount : section . threads . reduce (
292+ ( sum , thread ) => sum + thread . unreadCount ,
293+ 0 ,
294+ ) ,
295+ } ) ) ;
296+ } , [ threads ] ) ;
297+
262298 useEffect ( ( ) => {
263299 if (
264300 storedThreadFromDesc != null &&
@@ -461,7 +497,7 @@ export function ChatPanel({
461497 >
462498 < Icon name = { isAI ? "robot" : "users" } style = { { color : "#888" } } />
463499 < div style = { THREAD_ITEM_LABEL_STYLE } > { plainLabel } </ div >
464- { unreadCount > 0 && (
500+ { unreadCount > 0 && ! isHovered && (
465501 < Badge
466502 count = { unreadCount }
467503 size = "small"
@@ -490,83 +526,100 @@ export function ChatPanel({
490526 } ;
491527 } ;
492528
493- const renderThreadSection = (
494- title : string ,
495- icon : "users" | "robot" ,
496- list : ThreadMeta [ ] ,
529+ const renderUnreadBadge = (
530+ count : number ,
531+ section : ThreadSectionWithUnread ,
497532 ) => {
498- const unreadTotal = list . reduce (
499- ( sum , thread ) => sum + thread . unreadCount ,
500- 0 ,
533+ if ( count <= 0 ) {
534+ return null ;
535+ }
536+ const badge = (
537+ < Badge
538+ count = { count }
539+ size = "small"
540+ style = { {
541+ backgroundColor : COLORS . GRAY_L0 ,
542+ color : COLORS . GRAY_D ,
543+ } }
544+ />
545+ ) ;
546+ if ( ! actions ?. markThreadRead ) {
547+ return badge ;
548+ }
549+ return (
550+ < Popconfirm
551+ title = "Mark all read?"
552+ description = "Mark every chat in this section as read."
553+ okText = "Mark read"
554+ cancelText = "Cancel"
555+ placement = "left"
556+ onConfirm = { ( e ) => {
557+ e ?. stopPropagation ?.( ) ;
558+ handleMarkSectionRead ( section ) ;
559+ } }
560+ >
561+ < span
562+ onClick = { ( e ) => e . stopPropagation ( ) }
563+ style = { { cursor : "pointer" , display : "inline-flex" } }
564+ >
565+ { badge }
566+ </ span >
567+ </ Popconfirm >
501568 ) ;
569+ } ;
570+
571+ const renderThreadSection = ( section : ThreadSectionWithUnread ) => {
572+ const { title, threads : list , unreadCount, key } = section ;
573+ if ( ! list || list . length === 0 ) {
574+ return null ;
575+ }
502576 const items = list . map ( renderThreadRow ) ;
503577 return (
504- < div style = { { marginBottom : "15px" } } >
505- < div
578+ < div key = { key } style = { { marginBottom : "18px" } } >
579+ < div style = { THREAD_SECTION_HEADER_STYLE } >
580+ < span style = { { fontWeight : 600 } } > { title } </ span >
581+ { renderUnreadBadge ( unreadCount , section ) }
582+ </ div >
583+ < Menu
584+ mode = "inline"
585+ selectedKeys = { selectedThreadKey ? [ selectedThreadKey ] : [ ] }
586+ onClick = { ( { key : menuKey } ) => {
587+ setAllowAutoSelectThread ( true ) ;
588+ setSelectedThreadKey ( String ( menuKey ) ) ;
589+ if ( isCompact ) {
590+ setSidebarVisible ( false ) ;
591+ }
592+ } }
593+ items = { items }
506594 style = { {
507- display : "flex" ,
508- alignItems : "center" ,
509- justifyContent : "space-between" ,
510- marginBottom : "6px" ,
595+ border : "none" ,
596+ background : "transparent" ,
597+ padding : "0 10px" ,
511598 } }
512- >
513- < div style = { { display : "flex" , alignItems : "center" , gap : "6px" } } >
514- < Icon name = { icon } />
515- < span style = { { fontWeight : 600 } } > { title } </ span >
516- </ div >
517- { unreadTotal > 0 && (
518- < Badge
519- count = { unreadTotal }
520- size = "small"
521- style = { {
522- backgroundColor : COLORS . GRAY_L0 ,
523- color : COLORS . GRAY_D ,
524- } }
525- />
526- ) }
527- </ div >
528- { list . length === 0 ? (
529- < div style = { { color : "#999" , fontSize : "12px" , marginLeft : "4px" } } >
530- No chats
531- </ div >
532- ) : (
533- < Menu
534- mode = "inline"
535- selectedKeys = { selectedThreadKey ? [ selectedThreadKey ] : [ ] }
536- onClick = { ( { key } ) => {
537- setAllowAutoSelectThread ( true ) ;
538- setSelectedThreadKey ( String ( key ) ) ;
539- if ( isCompact ) {
540- setSidebarVisible ( false ) ;
541- }
542- } }
543- items = { items }
544- style = { {
545- border : "none" ,
546- background : "transparent" ,
547- padding : 0 ,
548- maxHeight : "28vh" ,
549- overflowY : "auto" ,
550- } }
551- />
552- ) }
599+ />
553600 </ div >
554601 ) ;
555602 } ;
556603
557- const humanThreads = useMemo (
558- ( ) => threads . filter ( ( thread ) => ! thread . isAI ) ,
559- [ threads ] ,
560- ) ;
561- const aiThreads = useMemo (
562- ( ) => threads . filter ( ( thread ) => thread . isAI ) ,
563- [ threads ] ,
564- ) ;
565604 const totalUnread = useMemo (
566- ( ) => threads . reduce ( ( sum , thread ) => sum + thread . unreadCount , 0 ) ,
567- [ threads ] ,
605+ ( ) => threadSections . reduce ( ( sum , section ) => sum + section . unreadCount , 0 ) ,
606+ [ threadSections ] ,
568607 ) ;
569608
609+ const handleMarkSectionRead = ( section : ThreadSectionWithUnread ) : void => {
610+ if ( ! actions ?. markThreadRead ) return ;
611+ const v : { key : string ; messageCount : number } [ ] = [ ] ;
612+ for ( const thread of section . threads ) {
613+ if ( thread . unreadCount > 0 ) {
614+ v . push ( { key : thread . key , messageCount : thread . messageCount } ) ;
615+ }
616+ }
617+ for ( let i = 0 ; i < v . length ; i ++ ) {
618+ const { key, messageCount } = v [ i ] ;
619+ actions . markThreadRead ( key , messageCount , i == v . length - 1 ) ;
620+ }
621+ } ;
622+
570623 const renderSidebarContent = ( ) => (
571624 < >
572625 < div style = { THREAD_SIDEBAR_HEADER } >
@@ -622,8 +675,13 @@ export function ChatPanel({
622675 </ >
623676 ) }
624677 </ div >
625- { renderThreadSection ( "Humans" , "users" , humanThreads ) }
626- { renderThreadSection ( "AI" , "robot" , aiThreads ) }
678+ { threadSections . length === 0 ? (
679+ < div style = { { color : "#999" , fontSize : "12px" , padding : "0 20px" } } >
680+ No chats yet.
681+ </ div >
682+ ) : (
683+ threadSections . map ( ( section ) => renderThreadSection ( section ) )
684+ ) }
627685 </ >
628686 ) ;
629687
0 commit comments