Skip to content

Commit a0de303

Browse files
committed
Refine email review editing and read more
1 parent 90b783b commit a0de303

File tree

2 files changed

+152
-28
lines changed

2 files changed

+152
-28
lines changed

packages/server/src/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -705,6 +705,12 @@ function buildActionSummary(result: Record<string, unknown>): string {
705705
if (result.regenerate) {
706706
lines.push('- User requested full regeneration.')
707707
}
708+
if (result.readMore) {
709+
const requestedCategories = Array.isArray(result.requestedCategories)
710+
? result.requestedCategories.filter((item): item is string => typeof item === 'string')
711+
: []
712+
lines.push(`- User requested more emails${requestedCategories.length > 0 ? ` for categories: ${requestedCategories.join(', ')}` : ''}.`)
713+
}
708714

709715
const intents = (result.selectedIntents ?? []) as Array<{ id: string; accepted: boolean }>
710716
if (intents.length > 0) {

packages/web/src/pages/ReviewPage.tsx

Lines changed: 146 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
5763
interface Action {
5864
type: 'delete' | 'rewrite'
5965
paragraphId: string
@@ -70,6 +76,7 @@ interface SummaryResponse {
7076
}
7177

7278
type ParagraphState = 'normal' | 'deleted' | 'rewriting'
79+
type PendingAgentAction = 'regenerate' | 'readMore' | null
7380

7481
// Keys must match REASON_LABELS in preference.ts
7582
const 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+
170187
export 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

Comments
 (0)