11import { useEffect , useMemo , useRef , useState } from 'react' ;
22import type { SuperDoc } from 'superdoc' ;
3- import type { ReviewItem , ReviewSlice } from 'superdoc/ui' ;
4- import { useSuperDocHost , useSuperDocReview , useSuperDocSelection , useSuperDocUI } from 'superdoc/ui/react' ;
3+ import type { CommentsListResult , TrackChangeInfo } from 'superdoc/ui' ;
4+ import {
5+ useSuperDocComments ,
6+ useSuperDocHost ,
7+ useSuperDocSelection ,
8+ useSuperDocTrackChanges ,
9+ useSuperDocUI ,
10+ } from 'superdoc/ui/react' ;
511import { CommentComposer } from './CommentComposer' ;
612
7- type ReviewCommentItem = Extract < ReviewItem , { kind : 'comment' } > ;
8- type ReviewChangeItem = Extract < ReviewItem , { kind : 'change' } > ;
9- type ReviewComment = ReviewCommentItem [ 'comment' ] ;
10- type ReviewChange = ReviewChangeItem [ 'change' ] ;
13+ type CommentItem = CommentsListResult [ 'items' ] [ number ] ;
14+
15+ /**
16+ * Local merged-feed item. The controller exposes comments and tracked
17+ * changes as separate slices (`ui.comments` / `ui.trackChanges`) so
18+ * each consumer can decide whether to merge them. This panel wants the
19+ * Google-Docs-style single feed, so we compose the two locally.
20+ */
21+ type ActivityItem =
22+ | { kind : 'comment' ; id : string ; comment : CommentItem }
23+ | { kind : 'change' ; id : string ; change : TrackChangeInfo } ;
1124
1225interface Props {
1326 /** When true, render the inline composer at the top of the panel. */
@@ -16,19 +29,6 @@ interface Props {
1629 onCloseComposer ( ) : void ;
1730}
1831
19- /**
20- * Single Activity feed merging comments + tracked changes in document
21- * order. Replaces the earlier dual Comments/Review tab split — that
22- * was an internal-tooling convention; consumers want one panel showing
23- * everything that needs attention.
24- *
25- * Active-card highlight is driven by the document selection: clicking
26- * a comment or tracked change in the editor surfaces the matching id
27- * via `ui.selection.activeCommentIds` / `activeChangeIds`, and the
28- * panel highlights that card and scrolls it into view. No separate
29- * event needed — SD-2792 already exposed the active ids on the
30- * selection slice.
31- */
3232interface DecidedChange {
3333 id : string ;
3434 decision : 'accepted' | 'rejected' ;
@@ -37,67 +37,87 @@ interface DecidedChange {
3737 snapshot : { type ?: string ; author ?: string ; authorEmail ?: string ; excerpt ?: string } ;
3838}
3939
40+ /**
41+ * Single Activity feed merging comments + tracked changes in document
42+ * order. Composes `ui.comments.items` and `ui.trackChanges.items` so
43+ * the panel renders one card per row regardless of source.
44+ *
45+ * Active-card highlight is driven by the document selection: clicking
46+ * a comment or tracked change in the editor surfaces the matching id
47+ * via `ui.selection.activeCommentIds` / `activeChangeIds`, and the
48+ * panel highlights that card and scrolls it into view.
49+ */
4050export function ActivitySidebar ( { composeOpen, onCloseComposer } : Props ) {
4151 const ui = useSuperDocUI ( ) ;
42- const review = useSuperDocReview ( ) ;
52+ const comments = useSuperDocComments ( ) ;
53+ const trackChanges = useSuperDocTrackChanges ( ) ;
4354 const selection = useSuperDocSelection ( ) ;
4455
4556 // Track tracked-changes that the user has accepted/rejected. Once
46- // decided, the change leaves the live `ui.review ` feed (the
57+ // decided, the change leaves the live `ui.trackChanges ` feed (the
4758 // tracked-change row in the document is gone — accepted means
4859 // applied, rejected means discarded). To mimic the Google Docs
49- // experience the user asked for , we capture the change snapshot
50- // before calling accept/reject and render it in the Resolved
51- // section as an audit row. State is component-local: refresh wipes
52- // it, which is fine for a demo.
60+ // experience, we capture the change snapshot before calling
61+ // accept/reject and render it in the Resolved section as an audit
62+ // row. State is component-local: refresh wipes it, which is fine
63+ // for a demo.
5364 const [ decidedChanges , setDecidedChanges ] = useState < Map < string , DecidedChange > > ( ( ) => new Map ( ) ) ;
5465
5566 // Track which entity (if any) is currently under the editor cursor.
56- // Multiple ids can be active when marks overlap; the example picks
57- // the first for highlight purposes.
5867 const activeEntityId = useMemo < string | null > ( ( ) => {
5968 if ( selection . activeCommentIds . length > 0 ) return selection . activeCommentIds [ 0 ] ! ;
6069 if ( selection . activeChangeIds . length > 0 ) return selection . activeChangeIds [ 0 ] ! ;
6170 return null ;
6271 } , [ selection . activeCommentIds , selection . activeChangeIds ] ) ;
6372
64- // Partition the live feed into active vs resolved-comment buckets,
65- // and fold reply comments under their parent. Word/Google Docs thread
66- // a comment by `parentCommentId` (DOCX persists this in
73+ // Merge the two slices into a single local feed. Comments are
74+ // emitted in `comments.list()` order, then tracked changes in
75+ // `trackChanges.list()` order. When `TrackChangeInfo.target` lands
76+ // (separate ticket), we'll be able to interleave by document
77+ // position; until then this stable two-bucket ordering matches what
78+ // the controller used to do internally.
79+ const feed = useMemo < ActivityItem [ ] > ( ( ) => {
80+ const items : ActivityItem [ ] = [ ] ;
81+ for ( const c of comments . items ) items . push ( { kind : 'comment' , id : c . id , comment : c } ) ;
82+ for ( const tc of trackChanges . items ) items . push ( { kind : 'change' , id : tc . id , change : tc . change } ) ;
83+ return items ;
84+ } , [ comments . items , trackChanges . items ] ) ;
85+
86+ // Partition the feed into active vs resolved-comment buckets, and
87+ // fold reply comments under their parent. Word/Google Docs thread a
88+ // comment by `parentCommentId` (DOCX persists this in
6789 // commentsExtended.xml as `paraIdParent`). The doc-api surfaces
6890 // `parentCommentId` on each item; we group it here so the sidebar
6991 // renders one card per thread root with its replies stacked under
7092 // it. Replies whose parent is missing (resolved or pruned) fall
7193 // back to top-level so we don't lose them.
7294 const { active, resolvedComments } = useMemo ( ( ) => {
73- const a : ReviewSlice [ 'items' ] = [ ] ;
74- const r : ReviewSlice [ 'items' ] = [ ] ;
95+ const a : ActivityItem [ ] = [ ] ;
96+ const r : ActivityItem [ ] = [ ] ;
7597 const commentRoots = new Set < string > ( ) ;
76- for ( const item of review . items ) {
98+ for ( const item of feed ) {
7799 if ( item . kind === 'comment' ) {
78100 const c = item . comment as { parentCommentId ?: string } ;
79101 if ( ! c . parentCommentId ) commentRoots . add ( item . id ) ;
80102 }
81103 }
82- for ( const item of review . items ) {
104+ for ( const item of feed ) {
83105 const isResolvedComment =
84106 item . kind === 'comment' && ( item . comment as { status ?: string } ) . status === 'resolved' ;
85107 if ( item . kind === 'comment' ) {
86108 const c = item . comment as { parentCommentId ?: string } ;
87- // Reply rows are rendered inline inside the parent card —
88- // skip them at the top level if the parent is also visible.
89109 if ( c . parentCommentId && commentRoots . has ( c . parentCommentId ) ) continue ;
90110 }
91111 if ( isResolvedComment ) r . push ( item ) ;
92112 else a . push ( item ) ;
93113 }
94114 return { active : a , resolvedComments : r } ;
95- } , [ review . items ] ) ;
115+ } , [ feed ] ) ;
96116
97117 // Replies indexed by parent id. Built once per snapshot.
98118 const repliesByParent = useMemo ( ( ) => {
99- const map = new Map < string , ReviewSlice [ 'items' ] > ( ) ;
100- for ( const item of review . items ) {
119+ const map = new Map < string , ActivityItem [ ] > ( ) ;
120+ for ( const item of feed ) {
101121 if ( item . kind !== 'comment' ) continue ;
102122 const c = item . comment as { parentCommentId ?: string } ;
103123 if ( ! c . parentCommentId ) continue ;
@@ -106,19 +126,16 @@ export function ActivitySidebar({ composeOpen, onCloseComposer }: Props) {
106126 map . set ( c . parentCommentId , list ) ;
107127 }
108128 return map ;
109- } , [ review . items ] ) ;
129+ } , [ feed ] ) ;
110130
111131 const decideChange = ( id : string , decision : 'accepted' | 'rejected' ) => {
112132 if ( ! ui ) return ;
113133 // Capture a snapshot from the live feed BEFORE we mutate, since
114134 // accept/reject removes the tracked-change row entirely.
115- const liveItem = review . items . find ( ( it ) => it . id === id ) ;
116- const change =
117- liveItem ?. kind === 'change'
118- ? ( liveItem . change as DecidedChange [ 'snapshot' ] )
119- : null ;
120- if ( decision === 'accepted' ) ui . review . accept ( id ) ;
121- else ui . review . reject ( id ) ;
135+ const liveItem = trackChanges . items . find ( ( it ) => it . id === id ) ;
136+ const change = ( liveItem ?. change ?? null ) as DecidedChange [ 'snapshot' ] | null ;
137+ if ( decision === 'accepted' ) ui . trackChanges . accept ( id ) ;
138+ else ui . trackChanges . reject ( id ) ;
122139 if ( change ) {
123140 setDecidedChanges ( ( prev ) => {
124141 const next = new Map ( prev ) ;
@@ -128,19 +145,15 @@ export function ActivitySidebar({ composeOpen, onCloseComposer }: Props) {
128145 }
129146 } ;
130147
131- // Reconcile `decidedChanges` against the live review feed: when a
132- // tracked change we previously decided reappears in `review.items`
133- // (undo of the accept/reject, collaborator restore, etc.), drop it
134- // from the local decided roll-up. Without this prune, the same
135- // change renders in both the Active and Resolved sections with a
136- // stale "accepted" / "rejected" label.
148+ // Reconcile `decidedChanges` against the live track-changes feed:
149+ // when a tracked change we previously decided reappears in
150+ // `trackChanges.items` (undo of the accept/reject, collaborator
151+ // restore, etc.), drop it from the local decided roll-up.
137152 useEffect ( ( ) => {
138153 setDecidedChanges ( ( prev ) => {
139154 if ( prev . size === 0 ) return prev ;
140155 const liveChangeIds = new Set < string > ( ) ;
141- for ( const item of review . items ) {
142- if ( item . kind === 'change' ) liveChangeIds . add ( item . id ) ;
143- }
156+ for ( const item of trackChanges . items ) liveChangeIds . add ( item . id ) ;
144157 let mutated = false ;
145158 const next = new Map ( prev ) ;
146159 for ( const id of prev . keys ( ) ) {
@@ -151,7 +164,7 @@ export function ActivitySidebar({ composeOpen, onCloseComposer }: Props) {
151164 }
152165 return mutated ? next : prev ;
153166 } ) ;
154- } , [ review . items ] ) ;
167+ } , [ trackChanges . items ] ) ;
155168
156169 // Auto-scroll the matching card into view when the active entity changes.
157170 const containerRef = useRef < HTMLDivElement | null > ( null ) ;
@@ -165,9 +178,6 @@ export function ActivitySidebar({ composeOpen, onCloseComposer }: Props) {
165178 return < div className = "card" > Loading editor…</ div > ;
166179 }
167180
168- // Resolved roll-up: comments resolved in-document + tracked changes
169- // we've decided locally. Sorted by most recently resolved first so
170- // the latest action floats to the top of the resolved section.
171181 const decidedList = [ ...decidedChanges . values ( ) ] . sort ( ( a , b ) => b . decidedAt - a . decidedAt ) ;
172182 const resolvedCount = resolvedComments . length + decidedList . length ;
173183 const empty = active . length === 0 && resolvedCount === 0 && ! composeOpen ;
@@ -196,7 +206,7 @@ export function ActivitySidebar({ composeOpen, onCloseComposer }: Props) {
196206 onDecideChange = { decideChange }
197207 onClick = { ( ) => {
198208 if ( item . kind === 'comment' ) ui . comments . scrollTo ( item . id ) ;
199- else ui . review . scrollTo ( item . id ) ;
209+ else ui . trackChanges . scrollTo ( item . id ) ;
200210 } }
201211 />
202212 ) ) }
@@ -227,10 +237,10 @@ export function ActivitySidebar({ composeOpen, onCloseComposer }: Props) {
227237}
228238
229239interface CardProps {
230- item : ReviewSlice [ 'items' ] [ number ] ;
240+ item : ActivityItem ;
231241 active : boolean ;
232242 resolved : boolean ;
233- replies ?: ReviewSlice [ 'items' ] ;
243+ replies ?: ActivityItem [ ] ;
234244 onClick ( ) : void ;
235245 onDecideChange ( id : string , decision : 'accepted' | 'rejected' ) : void ;
236246}
@@ -256,9 +266,9 @@ function CommentBody({
256266 replies,
257267 ui,
258268} : {
259- comment : ReviewComment ;
269+ comment : CommentItem ;
260270 resolved : boolean ;
261- replies ?: ReviewSlice [ 'items' ] ;
271+ replies ?: ActivityItem [ ] ;
262272 ui : NonNullable < ReturnType < typeof useSuperDocUI > > ;
263273} ) {
264274 const host = useSuperDocHost ( ) as SuperDoc | null ;
@@ -391,7 +401,7 @@ function ChangeBody({
391401 change,
392402 onDecide,
393403} : {
394- change : ReviewChange ;
404+ change : TrackChangeInfo ;
395405 onDecide : ( decision : 'accepted' | 'rejected' ) => void ;
396406} ) {
397407 const kind = change . type === 'insert' ? 'insertion' : change . type === 'delete' ? 'deletion' : 'format' ;
@@ -413,9 +423,9 @@ function ChangeBody({
413423
414424/**
415425 * Resolved-section row for a tracked change the user already
416- * accepted/rejected. The live `ui.review ` feed drops decided changes
417- * (the row is gone from the document either way), so this row is
418- * rendered from the local snapshot we captured before deciding —
426+ * accepted/rejected. The live `ui.trackChanges ` feed drops decided
427+ * changes (the row is gone from the document either way), so this row
428+ * is rendered from the local snapshot we captured before deciding —
419429 * mimicking the Google Docs "Suggestion accepted" trail.
420430 */
421431function DecidedChangeCard ( { entry } : { entry : DecidedChange } ) {
0 commit comments