@@ -54,6 +54,12 @@ interface InboxPayload {
5454 draft : DraftPayload
5555}
5656
57+ interface ReviewSessionPayload {
58+ inbox ?: EmailItem [ ]
59+ draft ?: DraftPayload
60+ [ key : string ] : unknown
61+ }
62+
5763interface Action {
5864 type : 'delete' | 'rewrite'
5965 paragraphId : string
@@ -70,6 +76,7 @@ interface SummaryResponse {
7076}
7177
7278type ParagraphState = 'normal' | 'deleted' | 'rewriting'
79+ type PendingAgentAction = 'regenerate' | 'readMore' | null
7380
7481// Keys must match REASON_LABELS in preference.ts
7582const REASONS = [
@@ -167,6 +174,16 @@ function fallbackIntentSuggestions(email: EmailItem | null): IntentSuggestion[]
167174 ]
168175}
169176
177+ function mergeInboxEmails ( currentInbox : EmailItem [ ] , incomingInbox : EmailItem [ ] ) : EmailItem [ ] {
178+ const merged = new Map < string , EmailItem > ( )
179+ currentInbox . forEach ( email => merged . set ( email . id , email ) )
180+ incomingInbox . forEach ( email => {
181+ const existing = merged . get ( email . id )
182+ merged . set ( email . id , existing ? { ...existing , ...email } : email )
183+ } )
184+ return Array . from ( merged . values ( ) ) . sort ( ( a , b ) => b . timestamp - a . timestamp )
185+ }
186+
170187export default function ReviewPage ( ) {
171188 const { id } = useParams < { id : string } > ( )
172189 const navigate = useNavigate ( )
@@ -200,6 +217,8 @@ export default function ReviewPage() {
200217 const [ summaryData , setSummaryData ] = useState < SummaryResponse | null > ( null )
201218 const [ summaryLoading , setSummaryLoading ] = useState ( false )
202219 const [ summaryError , setSummaryError ] = useState < string | null > ( null )
220+ const [ filterOpen , setFilterOpen ] = useState ( false )
221+ const [ pendingAgentAction , setPendingAgentAction ] = useState < PendingAgentAction > ( null )
203222
204223 const resetEditState = useCallback ( ( ) => {
205224 setActions ( [ ] )
@@ -237,15 +256,28 @@ export default function ReviewPage() {
237256 const data = await fetch ( `/api/sessions/${ id } ` ) . then ( r => r . json ( ) )
238257 if ( data . revision > revisionRef . current ) {
239258 revisionRef . current = data . revision
240- setPayload ( data . payload as EmailPayload | InboxPayload )
259+ setPayload ( currentPayload => {
260+ if ( pendingAgentAction !== 'readMore' ) return data . payload as EmailPayload | InboxPayload
261+ if ( ! currentPayload || ! ( 'inbox' in currentPayload ) || ! Array . isArray ( currentPayload . inbox ) ) {
262+ return data . payload as EmailPayload | InboxPayload
263+ }
264+ const nextPayload = data . payload as ReviewSessionPayload
265+ if ( ! Array . isArray ( nextPayload . inbox ) ) return data . payload as EmailPayload | InboxPayload
266+ return {
267+ ...nextPayload ,
268+ inbox : mergeInboxEmails ( currentPayload . inbox , nextPayload . inbox ) ,
269+ draft : nextPayload . draft ?? currentPayload . draft ,
270+ } as InboxPayload
271+ } )
241272 resetEditState ( )
242273 setWaitingForRewrite ( false )
274+ setPendingAgentAction ( null )
243275 setRightView ( 'draft' )
244276 }
245277 } catch { /* ignore polling errors */ }
246278 } , 1500 )
247279 return ( ) => clearInterval ( interval )
248- } , [ waitingForRewrite , id , resetEditState ] )
280+ } , [ waitingForRewrite , id , pendingAgentAction , resetEditState ] )
249281
250282 // Cmd+Enter to confirm & send
251283 useEffect ( ( ) => {
@@ -264,7 +296,7 @@ export default function ReviewPage() {
264296 setActions ( a => [ ...a . filter ( x => x . paragraphId !== pid ) , { type : 'delete' , paragraphId : pid , reason : reasonKey } ] )
265297 }
266298
267- const startRewrite = ( pid : string ) => {
299+ const startEdit = ( pid : string ) => {
268300 const sourceParagraph = hasInbox
269301 ? activeDraftForState ?. paragraphs . find ( paragraph => paragraph . id === pid )
270302 : ( payload as EmailPayload | null ) ?. paragraphs ?. find ( paragraph => paragraph . id === pid )
@@ -275,11 +307,22 @@ export default function ReviewPage() {
275307 } ) )
276308 }
277309
310+ const markParagraphForRewrite = ( pid : string ) => {
311+ const existing = actions . find ( action => action . paragraphId === pid && action . type === 'rewrite' )
312+ if ( existing ) {
313+ setActions ( current => current . filter ( action => ! ( action . paragraphId === pid && action . type === 'rewrite' ) ) )
314+ return
315+ }
316+ setActions ( current => [
317+ ...current . filter ( action => action . paragraphId !== pid || action . type !== 'rewrite' ) ,
318+ { type : 'rewrite' , paragraphId : pid , instruction : 'Rewrite this paragraph' } ,
319+ ] )
320+ }
321+
278322 const confirmRewrite = ( pid : string ) => {
279323 const editedText = rewriteInput [ pid ] ?. trim ( )
280324 if ( ! editedText ) return
281325 setStates ( s => ( { ...s , [ pid ] : 'normal' } ) )
282- setActions ( a => [ ...a . filter ( x => x . paragraphId !== pid ) , { type : 'rewrite' , paragraphId : pid , instruction : editedText } ] )
283326 }
284327
285328 const undoParagraph = ( pid : string ) => {
@@ -331,6 +374,37 @@ export default function ReviewPage() {
331374 }
332375 }
333376
377+ const requestMoreEmails = async ( ) => {
378+ if ( ! hasInbox ) return
379+ setSubmitting ( true )
380+ setPendingAgentAction ( 'readMore' )
381+ const result = await fetch ( `/api/sessions/${ id } /complete` , {
382+ method : 'POST' ,
383+ headers : { 'Content-Type' : 'application/json' } ,
384+ body : JSON . stringify ( {
385+ confirmed : false ,
386+ regenerate : true ,
387+ readMore : true ,
388+ requestedCategories : selectedCategories ,
389+ currentlyLoadedEmailIds : ( payload as InboxPayload ) . inbox . map ( email => email . id ) ,
390+ currentlyVisibleEmailCount : ( payload as InboxPayload ) . inbox . filter ( email =>
391+ email . unread !== false &&
392+ ! markedAsRead . includes ( email . id ) &&
393+ ( selectedCategories . length === 0 || selectedCategories . includes ( normalizeCategory ( email . category ) ) )
394+ ) . length ,
395+ } ) ,
396+ } ) . then ( r => r . json ( ) )
397+
398+ if ( result . rewriting ) {
399+ setWaitingForRewrite ( true )
400+ setSubmitting ( false )
401+ return
402+ }
403+
404+ setPendingAgentAction ( null )
405+ setSubmitting ( false )
406+ }
407+
334408 const submit = async ( confirmed : boolean ) => {
335409 setSubmitting ( true )
336410 const hasInbox = payload && 'inbox' in payload && Array . isArray ( ( payload as InboxPayload ) . inbox )
@@ -369,6 +443,7 @@ export default function ReviewPage() {
369443
370444 if ( ! confirmed && result . rewriting ) {
371445 // Regenerate: wait for agent to update payload
446+ setPendingAgentAction ( 'regenerate' )
372447 setWaitingForRewrite ( true )
373448 setSubmitting ( false )
374449 return
@@ -460,6 +535,7 @@ export default function ReviewPage() {
460535 paragraphs . map ( p => {
461536 const state = states [ p . id ] || 'normal'
462537 const action = actions . find ( a => a . paragraphId === p . id )
538+ const rewriteRequested = action ?. type === 'rewrite'
463539 const editedValue = rewriteInput [ p . id ]
464540 const hasEditedValue = typeof editedValue === 'string' && editedValue . trim ( ) . length > 0 && editedValue !== p . content
465541
@@ -509,14 +585,23 @@ export default function ReviewPage() {
509585 < p className = { `text-sm leading-relaxed whitespace-pre-wrap ${ hasEditedValue ? 'text-blue-700 dark:text-blue-200' : 'text-gray-700 dark:text-slate-300' } ` } >
510586 { hasEditedValue ? editedValue : p . content }
511587 </ p >
588+ { rewriteRequested && (
589+ < p className = "mt-2 text-xs text-amber-600 dark:text-amber-300" > Marked for agent rewrite on regenerate.</ p >
590+ ) }
512591 </ div >
513592 < div className = "flex justify-end gap-1" >
514593 < button
515- onClick = { ( ) => startRewrite ( p . id ) }
594+ onClick = { ( ) => markParagraphForRewrite ( p . id ) }
516595 style = { { color : 'var(--c-blue)' } }
517596 className = "text-xs font-medium px-2 py-1 rounded transition-opacity hover:opacity-70 focus:outline-none focus:ring-2 focus:ring-offset-1"
518597 >
519- Rewrite
598+ { rewriteRequested ? 'Unmark Rewrite' : 'Rewrite' }
599+ </ button >
600+ < button
601+ onClick = { ( ) => startEdit ( p . id ) }
602+ className = "text-xs font-medium px-2 py-1 rounded transition-opacity hover:opacity-70 focus:outline-none focus:ring-2 focus:ring-offset-1 text-zinc-500 dark:text-slate-400"
603+ >
604+ Edit
520605 </ button >
521606 < DeleteButton onConfirm = { ( reasonKey ) => deleteParagraph ( p . id , reasonKey ) } />
522607 </ div >
@@ -546,29 +631,50 @@ export default function ReviewPage() {
546631 </ p >
547632 </ div >
548633 < div >
549- < p className = "text-[11px] uppercase tracking-wider text-zinc-400 dark:text-slate-500 mb-2" > Filter Categories</ p >
550- < div className = "flex flex-wrap gap-2" >
551- { GMAIL_CATEGORIES . map ( category => {
552- const selected = selectedCategories . includes ( category )
553- return (
554- < button
555- key = { category }
556- onClick = { ( ) => setSelectedCategories ( current => selected ? current . filter ( value => value !== category ) : [ ...current , category ] ) }
557- className = { `text-xs px-2 py-1 rounded-full border transition-colors ${
558- selected
559- ? `${ categoryBadge ( category ) } border-transparent`
560- : 'border-gray-200 dark:border-zinc-700 text-zinc-500 dark:text-slate-400 bg-white dark:bg-zinc-950'
561- } `}
562- >
563- { category }
564- </ button >
565- )
566- } ) }
567- </ div >
634+ < button
635+ type = "button"
636+ onClick = { ( ) => setFilterOpen ( current => ! current ) }
637+ className = "w-full flex items-center justify-between text-left"
638+ >
639+ < p className = "text-[11px] uppercase tracking-wider text-zinc-400 dark:text-slate-500" > Filter Categories</ p >
640+ < span className = "text-[11px] text-zinc-400 dark:text-slate-500" > { filterOpen ? 'Hide' : 'Show' } </ span >
641+ </ button >
642+ { filterOpen && (
643+ < div className = "flex flex-wrap gap-2 mt-2" >
644+ { GMAIL_CATEGORIES . map ( category => {
645+ const selected = selectedCategories . includes ( category )
646+ return (
647+ < button
648+ key = { category }
649+ onClick = { ( ) => setSelectedCategories ( current => selected ? current . filter ( value => value !== category ) : [ ...current , category ] ) }
650+ className = { `text-xs px-2 py-1 rounded-full border transition-colors ${
651+ selected
652+ ? `${ categoryBadge ( category ) } border-transparent`
653+ : 'border-gray-200 dark:border-zinc-700 text-zinc-500 dark:text-slate-400 bg-white dark:bg-zinc-950'
654+ } `}
655+ >
656+ { category }
657+ </ button >
658+ )
659+ } ) }
660+ </ div >
661+ ) }
568662 </ div >
569663 < p className = "text-[11px] text-zinc-400 dark:text-slate-500" >
570664 Showing { visibleEmails . length } of { filteredEmails . length } filtered unread emails
571665 </ p >
666+ < button
667+ type = "button"
668+ onClick = { requestMoreEmails }
669+ disabled = { submitting || waitingForRewrite }
670+ className = { `w-full text-xs font-medium px-3 py-2 rounded-lg border transition-colors ${
671+ submitting || waitingForRewrite
672+ ? 'opacity-50 cursor-not-allowed border-gray-200 dark:border-zinc-700 text-zinc-400 dark:text-slate-500'
673+ : 'border-blue-200 dark:border-blue-800 text-blue-600 dark:text-blue-300 hover:bg-blue-50 dark:hover:bg-blue-950'
674+ } `}
675+ >
676+ Read More
677+ </ button >
572678 </ div >
573679 { visibleEmails . length === 0 ? (
574680 < p className = "text-sm text-zinc-400 dark:text-slate-500 p-4" > No unread emails.</ p >
@@ -883,7 +989,9 @@ export default function ReviewPage() {
883989 { waitingForRewrite && (
884990 < div className = "mb-6 p-4 bg-blue-50 dark:bg-blue-950 border border-blue-100 dark:border-blue-900 rounded-lg flex items-center gap-3" >
885991 < div className = "w-4 h-4 border-2 border-blue-400 border-t-transparent rounded-full animate-spin shrink-0" />
886- < p className = "text-sm text-blue-600 dark:text-blue-400" > Agent is rewriting the draft...</ p >
992+ < p className = "text-sm text-blue-600 dark:text-blue-400" >
993+ { pendingAgentAction === 'readMore' ? 'Agent is loading more emails...' : 'Agent is rewriting the draft...' }
994+ </ p >
887995 </ div >
888996 ) }
889997
@@ -995,6 +1103,7 @@ export default function ReviewPage() {
9951103 { legacyPayload . paragraphs . map ( p => {
9961104 const state = states [ p . id ] || 'normal'
9971105 const action = actions . find ( a => a . paragraphId === p . id )
1106+ const rewriteRequested = action ?. type === 'rewrite'
9981107 const editedValue = rewriteInput [ p . id ]
9991108 const hasEditedValue = typeof editedValue === 'string' && editedValue . trim ( ) . length > 0 && editedValue !== p . content
10001109
@@ -1042,13 +1151,22 @@ export default function ReviewPage() {
10421151 < p className = { `text-sm leading-relaxed whitespace-pre-wrap ${ hasEditedValue ? 'text-blue-700 dark:text-blue-200' : 'text-gray-700 dark:text-slate-300' } ` } >
10431152 { hasEditedValue ? editedValue : p . content }
10441153 </ p >
1154+ { rewriteRequested && (
1155+ < p className = "mt-2 text-xs text-amber-600 dark:text-amber-300" > Marked for agent rewrite on regenerate.</ p >
1156+ ) }
10451157 </ div >
10461158 < div className = "flex justify-end gap-1" >
10471159 < button
1048- onClick = { ( ) => startRewrite ( p . id ) }
1160+ onClick = { ( ) => markParagraphForRewrite ( p . id ) }
10491161 className = "text-xs text-zinc-400 dark:text-slate-500 hover:text-blue-500 dark:hover:text-blue-400 px-2 py-1 rounded hover:bg-blue-50 dark:hover:bg-blue-950 transition-colors focus:outline-none focus:ring-2 focus:ring-blue-300"
10501162 >
1051- Rewrite
1163+ { rewriteRequested ? 'Unmark Rewrite' : 'Rewrite' }
1164+ </ button >
1165+ < button
1166+ onClick = { ( ) => startEdit ( p . id ) }
1167+ className = "text-xs text-zinc-500 dark:text-slate-400 hover:text-zinc-700 dark:hover:text-slate-200 px-2 py-1 rounded transition-colors focus:outline-none focus:ring-2 focus:ring-zinc-300"
1168+ >
1169+ Edit
10521170 </ button >
10531171 < DeleteButton onConfirm = { ( reasonKey ) => deleteParagraph ( p . id , reasonKey ) } />
10541172 </ div >
0 commit comments