Skip to content

Commit 5f49e87

Browse files
committed
Add email monitor stop signals and folded drafts
1 parent ae3faae commit 5f49e87

File tree

4 files changed

+182
-79
lines changed

4 files changed

+182
-79
lines changed

packages/server/src/index.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -456,7 +456,12 @@ app.post('/api/sessions/:id/page-status', (req, res) => {
456456
if (!['opened', 'active', 'hidden', 'submitted'].includes(state)) {
457457
return res.status(400).json({ error: 'Invalid page status state' })
458458
}
459-
const pageStatus = { state: state as 'opened' | 'active' | 'hidden' | 'submitted', updatedAt: Date.now() }
459+
const pageStatus = {
460+
state: state as 'opened' | 'active' | 'hidden' | 'submitted',
461+
updatedAt: Date.now(),
462+
stopMonitoring: req.body?.stopMonitoring === true,
463+
reason: typeof req.body?.reason === 'string' ? req.body.reason : undefined,
464+
}
460465
updateSessionPageStatus(req.params.id, pageStatus)
461466
res.json({ ok: true, pageStatus })
462467
})
@@ -747,6 +752,13 @@ function buildActionSummary(result: Record<string, unknown>): string {
747752
if (bcc.length > 0) lines.push(`- Added Bcc: ${bcc.join(', ')}`)
748753
}
749754

755+
const pageStatus = result.pageStatus && typeof result.pageStatus === 'object'
756+
? result.pageStatus as Record<string, unknown>
757+
: null
758+
if (pageStatus?.stopMonitoring === true) {
759+
lines.push(`- User requested monitor stop${typeof pageStatus.reason === 'string' ? ` (${pageStatus.reason})` : ''}.`)
760+
}
761+
750762
return lines.join('\n')
751763
}
752764

packages/server/src/store.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ try {
3939
export interface SessionPageStatus {
4040
state: 'created' | 'opened' | 'active' | 'hidden' | 'submitted'
4141
updatedAt: number
42+
stopMonitoring?: boolean
43+
reason?: string
4244
}
4345

4446
export interface Session {

packages/web/src/pages/ReviewPage.tsx

Lines changed: 139 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ interface SummaryResponse {
7979

8080
type ParagraphState = 'normal' | 'deleted' | 'rewriting'
8181
type PendingAgentAction = 'regenerate' | 'readMore' | 'reply' | null
82+
type PageStatusState = 'opened' | 'active' | 'hidden' | 'submitted'
8283

8384
// Keys must match REASON_LABELS in preference.ts
8485
const REASONS = [
@@ -230,6 +231,7 @@ export default function ReviewPage() {
230231
const [filterOpen, setFilterOpen] = useState(false)
231232
const [pendingAgentAction, setPendingAgentAction] = useState<PendingAgentAction>(null)
232233
const [activeReplyRequestEmailId, setActiveReplyRequestEmailId] = useState<string | null>(null)
234+
const [replyDraftOpen, setReplyDraftOpen] = useState(false)
233235

234236
const resetEditState = useCallback(() => {
235237
setActions([])
@@ -247,6 +249,25 @@ export default function ReviewPage() {
247249
setSubmitting(false)
248250
}, [])
249251

252+
const postPageStatus = useCallback(async (
253+
state: PageStatusState,
254+
options?: { stopMonitoring?: boolean; reason?: string },
255+
) => {
256+
try {
257+
await fetch(`/api/sessions/${id}/page-status`, {
258+
method: 'POST',
259+
headers: { 'Content-Type': 'application/json' },
260+
body: JSON.stringify({
261+
state,
262+
stopMonitoring: options?.stopMonitoring === true,
263+
reason: options?.reason,
264+
}),
265+
})
266+
} catch {
267+
// ignore page status failures
268+
}
269+
}, [id])
270+
250271
useEffect(() => {
251272
fetch(`/api/sessions/${id}`)
252273
.then(r => r.json())
@@ -259,6 +280,22 @@ export default function ReviewPage() {
259280
.catch(() => { setError(true); setLoading(false) })
260281
}, [id])
261282

283+
useEffect(() => {
284+
void postPageStatus('opened')
285+
const activeTimer = window.setInterval(() => {
286+
void postPageStatus(document.visibilityState === 'visible' ? 'active' : 'hidden')
287+
}, 10000)
288+
const onVisibility = () => { void postPageStatus(document.visibilityState === 'visible' ? 'active' : 'hidden') }
289+
const onBeforeUnload = () => { void postPageStatus('hidden', { reason: 'page_unload' }) }
290+
document.addEventListener('visibilitychange', onVisibility)
291+
window.addEventListener('beforeunload', onBeforeUnload)
292+
return () => {
293+
window.clearInterval(activeTimer)
294+
document.removeEventListener('visibilitychange', onVisibility)
295+
window.removeEventListener('beforeunload', onBeforeUnload)
296+
}
297+
}, [postPageStatus])
298+
262299
// Poll for payload updates while waiting for agent rewrite
263300
useEffect(() => {
264301
if (!waitingForRewrite) return
@@ -553,6 +590,11 @@ export default function ReviewPage() {
553590
}
554591
}
555592

593+
const stopMonitoringAndLeave = async () => {
594+
await postPageStatus('hidden', { stopMonitoring: true, reason: 'user_left_email_review' })
595+
navigate('/')
596+
}
597+
556598
const hasInbox = payload && 'inbox' in payload && Array.isArray((payload as InboxPayload).inbox)
557599
const inboxPayloadForState = hasInbox ? payload as InboxPayload : null
558600
const selectedInboxEmail = inboxPayloadForState && selectedEmailId
@@ -717,7 +759,15 @@ export default function ReviewPage() {
717759
{/* Left Panel — Inbox List */}
718760
<div className="w-full md:w-72 md:shrink-0 bg-white dark:bg-zinc-900 border-b md:border-b-0 md:border-r border-gray-100 dark:border-zinc-800 overflow-y-auto max-h-[30vh] md:max-h-full">
719761
{!isCompleted && (
720-
<button onClick={() => navigate('/')} className="text-sm text-zinc-400 dark:text-slate-500 hover:text-zinc-600 dark:hover:text-slate-300 transition-colors p-4 block border-b border-gray-50 dark:border-zinc-800 w-full text-left">← Back</button>
762+
<div className="p-4 border-b border-gray-50 dark:border-zinc-800 space-y-2">
763+
<button onClick={stopMonitoringAndLeave} className="text-sm text-zinc-400 dark:text-slate-500 hover:text-zinc-600 dark:hover:text-slate-300 transition-colors block w-full text-left">← Back</button>
764+
<button
765+
onClick={stopMonitoringAndLeave}
766+
className="w-full text-xs font-medium px-3 py-2 rounded-lg border border-red-200 dark:border-red-900 text-red-600 dark:text-red-300 hover:bg-red-50 dark:hover:bg-red-950 transition-colors"
767+
>
768+
Stop Agent Monitor
769+
</button>
770+
</div>
721771
)}
722772
<div className="p-4 border-b border-gray-50 dark:border-zinc-800 space-y-3">
723773
<div>
@@ -1025,98 +1075,111 @@ export default function ReviewPage() {
10251075

10261076
{activeDraft && (
10271077
<>
1028-
<div className="mb-6 p-4 bg-white dark:bg-zinc-900 border border-gray-100 dark:border-zinc-800 rounded-lg space-y-3">
1078+
<div className="mb-6 p-4 bg-white dark:bg-zinc-900 border border-gray-100 dark:border-zinc-800 rounded-lg">
10291079
<div className="flex items-center justify-between">
10301080
<p className="text-xs text-zinc-400 dark:text-slate-500 uppercase tracking-wider">Reply Draft</p>
1031-
{replyIsReady && (
1032-
<span className="text-xs font-medium text-blue-600 dark:text-blue-300">Draft ready</span>
1033-
)}
1034-
</div>
1035-
<div>
1036-
<label className="block text-xs text-zinc-500 dark:text-slate-400 mb-1">Reply To</label>
1037-
<div className="w-full text-sm border border-gray-200 dark:border-zinc-700 rounded px-3 py-2 bg-zinc-50 dark:bg-zinc-950 text-zinc-500 dark:text-slate-400">
1038-
{activeDraft.replyTo}
1039-
</div>
1040-
</div>
1041-
<div>
1042-
<label className="block text-xs text-zinc-500 dark:text-slate-400 mb-1">To</label>
1043-
<div className="w-full text-sm border border-gray-200 dark:border-zinc-700 rounded px-3 py-2 bg-zinc-50 dark:bg-zinc-950 text-zinc-500 dark:text-slate-400">
1044-
{draftTo}
1045-
</div>
1046-
</div>
1047-
<div>
1048-
<label className="block text-xs text-zinc-500 dark:text-slate-400 mb-1">Subject</label>
1049-
<input
1050-
type="text"
1051-
value={draftSubject}
1052-
onChange={e => setDraftSubject(e.target.value)}
1053-
className="w-full text-sm border border-gray-200 dark:border-zinc-700 rounded px-3 py-2 bg-white dark:bg-zinc-950 text-zinc-700 dark:text-slate-300 focus:outline-none focus:ring-2 focus:ring-blue-300"
1054-
/>
1055-
</div>
1056-
<div>
1057-
<label className="block text-xs text-zinc-500 dark:text-slate-400 mb-1">Add Recipient</label>
1058-
<div className="flex gap-2">
1059-
<button
1060-
type="button"
1061-
onClick={() => setNewRecipientType(type => type === 'cc' ? 'bcc' : 'cc')}
1062-
className="px-3 py-2 text-sm rounded border border-gray-200 dark:border-zinc-700 text-zinc-600 dark:text-slate-300"
1063-
title="Toggle recipient type"
1064-
>
1065-
{newRecipientType.toUpperCase()} +
1066-
</button>
1067-
<input
1068-
type="text"
1069-
value={newRecipientValue}
1070-
onChange={e => setNewRecipientValue(e.target.value)}
1071-
placeholder={newRecipientType === 'cc' ? 'Add Cc address' : 'Add Bcc address'}
1072-
className="flex-1 text-sm border border-gray-200 dark:border-zinc-700 rounded px-3 py-2 bg-white dark:bg-zinc-950 text-zinc-700 dark:text-slate-300 focus:outline-none focus:ring-2 focus:ring-blue-300"
1073-
/>
1081+
<div className="flex items-center gap-3">
1082+
{replyIsReady && (
1083+
<span className="text-xs font-medium text-blue-600 dark:text-blue-300">Draft ready</span>
1084+
)}
10741085
<button
10751086
type="button"
1076-
onClick={() => {
1077-
if (newRecipientType === 'cc') setDraftCc(current => addUniqueAddress(current, newRecipientValue))
1078-
else setDraftBcc(current => addUniqueAddress(current, newRecipientValue))
1079-
setNewRecipientValue('')
1080-
}}
1081-
className="px-3 py-2 text-sm rounded border border-blue-200 dark:border-blue-800 text-blue-600 dark:text-blue-300"
1082-
title="Add recipient"
1087+
onClick={() => setReplyDraftOpen(current => !current)}
1088+
className="text-xs text-zinc-500 dark:text-slate-400 hover:text-zinc-700 dark:hover:text-slate-200"
10831089
>
1084-
+
1090+
{replyDraftOpen ? 'Fold' : 'Unfold'}
10851091
</button>
10861092
</div>
10871093
</div>
1088-
{draftCc.length > 0 && (
1089-
<div>
1090-
<label className="block text-xs text-zinc-500 dark:text-slate-400 mb-1">Cc</label>
1091-
<div className="flex flex-wrap gap-2">
1092-
{draftCc.map(email => (
1094+
{replyDraftOpen && (
1095+
<div className="space-y-3 mt-3">
1096+
<div>
1097+
<label className="block text-xs text-zinc-500 dark:text-slate-400 mb-1">Reply To</label>
1098+
<div className="w-full text-sm border border-gray-200 dark:border-zinc-700 rounded px-3 py-2 bg-zinc-50 dark:bg-zinc-950 text-zinc-500 dark:text-slate-400">
1099+
{activeDraft.replyTo}
1100+
</div>
1101+
</div>
1102+
<div>
1103+
<label className="block text-xs text-zinc-500 dark:text-slate-400 mb-1">To</label>
1104+
<div className="w-full text-sm border border-gray-200 dark:border-zinc-700 rounded px-3 py-2 bg-zinc-50 dark:bg-zinc-950 text-zinc-500 dark:text-slate-400">
1105+
{draftTo}
1106+
</div>
1107+
</div>
1108+
<div>
1109+
<label className="block text-xs text-zinc-500 dark:text-slate-400 mb-1">Subject</label>
1110+
<input
1111+
type="text"
1112+
value={draftSubject}
1113+
onChange={e => setDraftSubject(e.target.value)}
1114+
className="w-full text-sm border border-gray-200 dark:border-zinc-700 rounded px-3 py-2 bg-white dark:bg-zinc-950 text-zinc-700 dark:text-slate-300 focus:outline-none focus:ring-2 focus:ring-blue-300"
1115+
/>
1116+
</div>
1117+
<div>
1118+
<label className="block text-xs text-zinc-500 dark:text-slate-400 mb-1">Add Recipient</label>
1119+
<div className="flex gap-2">
10931120
<button
1094-
key={`cc-${email}`}
10951121
type="button"
1096-
onClick={() => setDraftCc(current => current.filter(value => value !== email))}
1097-
className="text-xs px-2 py-1 rounded-full border border-sky-200 dark:border-sky-800 bg-sky-50 dark:bg-sky-950 text-sky-700 dark:text-sky-300"
1122+
onClick={() => setNewRecipientType(type => type === 'cc' ? 'bcc' : 'cc')}
1123+
className="px-3 py-2 text-sm rounded border border-gray-200 dark:border-zinc-700 text-zinc-600 dark:text-slate-300"
1124+
title="Toggle recipient type"
10981125
>
1099-
{email} ×
1126+
{newRecipientType.toUpperCase()} +
11001127
</button>
1101-
))}
1102-
</div>
1103-
</div>
1104-
)}
1105-
{draftBcc.length > 0 && (
1106-
<div>
1107-
<label className="block text-xs text-zinc-500 dark:text-slate-400 mb-1">Bcc</label>
1108-
<div className="flex flex-wrap gap-2">
1109-
{draftBcc.map(email => (
1128+
<input
1129+
type="text"
1130+
value={newRecipientValue}
1131+
onChange={e => setNewRecipientValue(e.target.value)}
1132+
placeholder={newRecipientType === 'cc' ? 'Add Cc address' : 'Add Bcc address'}
1133+
className="flex-1 text-sm border border-gray-200 dark:border-zinc-700 rounded px-3 py-2 bg-white dark:bg-zinc-950 text-zinc-700 dark:text-slate-300 focus:outline-none focus:ring-2 focus:ring-blue-300"
1134+
/>
11101135
<button
1111-
key={`bcc-${email}`}
11121136
type="button"
1113-
onClick={() => setDraftBcc(current => current.filter(value => value !== email))}
1114-
className="text-xs px-2 py-1 rounded-full border border-violet-200 dark:border-violet-800 bg-violet-50 dark:bg-violet-950 text-violet-700 dark:text-violet-300"
1137+
onClick={() => {
1138+
if (newRecipientType === 'cc') setDraftCc(current => addUniqueAddress(current, newRecipientValue))
1139+
else setDraftBcc(current => addUniqueAddress(current, newRecipientValue))
1140+
setNewRecipientValue('')
1141+
}}
1142+
className="px-3 py-2 text-sm rounded border border-blue-200 dark:border-blue-800 text-blue-600 dark:text-blue-300"
1143+
title="Add recipient"
11151144
>
1116-
{email} ×
1145+
+
11171146
</button>
1118-
))}
1147+
</div>
11191148
</div>
1149+
{draftCc.length > 0 && (
1150+
<div>
1151+
<label className="block text-xs text-zinc-500 dark:text-slate-400 mb-1">Cc</label>
1152+
<div className="flex flex-wrap gap-2">
1153+
{draftCc.map(email => (
1154+
<button
1155+
key={`cc-${email}`}
1156+
type="button"
1157+
onClick={() => setDraftCc(current => current.filter(value => value !== email))}
1158+
className="text-xs px-2 py-1 rounded-full border border-sky-200 dark:border-sky-800 bg-sky-50 dark:bg-sky-950 text-sky-700 dark:text-sky-300"
1159+
>
1160+
{email} ×
1161+
</button>
1162+
))}
1163+
</div>
1164+
</div>
1165+
)}
1166+
{draftBcc.length > 0 && (
1167+
<div>
1168+
<label className="block text-xs text-zinc-500 dark:text-slate-400 mb-1">Bcc</label>
1169+
<div className="flex flex-wrap gap-2">
1170+
{draftBcc.map(email => (
1171+
<button
1172+
key={`bcc-${email}`}
1173+
type="button"
1174+
onClick={() => setDraftBcc(current => current.filter(value => value !== email))}
1175+
className="text-xs px-2 py-1 rounded-full border border-violet-200 dark:border-violet-800 bg-violet-50 dark:bg-violet-950 text-violet-700 dark:text-violet-300"
1176+
>
1177+
{email} ×
1178+
</button>
1179+
))}
1180+
</div>
1181+
</div>
1182+
)}
11201183
</div>
11211184
)}
11221185
</div>

0 commit comments

Comments
 (0)