Skip to content

Commit b43070b

Browse files
committed
feat: render HTML emails in sandboxed iframe, fix two-PUT reply draft delivery
- Render HTML email bodies in a sandboxed iframe (allow-same-origin) with auto-height and no inner scroll, keeping plain text emails as-is - Add updateSessionPayloadKeepStatus to preserve rewriting state when replyState is "loading", enabling the two-PUT pattern for reply drafts - Fix frontend polling to keep waiting until replyState is "ready" for reply actions instead of stopping on any revision bump - Add no-refetch guidance to SKILL.md: use poll payload data for drafts
1 parent ad051b6 commit b43070b

File tree

4 files changed

+68
-8
lines changed

4 files changed

+68
-8
lines changed

packages/server/src/index.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { dirname, join } from 'path'
88
import { fileURLToPath } from 'url'
99
import { promisify } from 'util'
1010
import { learnFromDeletions, learnFromRewrite, learnFromTrajectoryRevisions, learnFromCodeRejection, learnFromActionRejection, getLearnedPreferences, clearPreferences, deletePreference } from './preference.js'
11-
import { createSession, getSession, listSessions, completeSession, setSessionRewriting, updateSessionPageStatus, updateSessionPayload } from './store.js'
11+
import { createSession, getSession, listSessions, completeSession, setSessionRewriting, updateSessionPageStatus, updateSessionPayload, updateSessionPayloadKeepStatus } from './store.js'
1212
import {
1313
buildMemoryCatalog,
1414
buildMemoryReviewPayload,
@@ -762,10 +762,23 @@ app.put('/api/sessions/:id/payload', (req, res) => {
762762
}
763763
}
764764

765-
updateSessionPayload(req.params.id, finalPayload)
765+
// Check if any email still has replyState "loading" — if so, keep session in
766+
// "rewriting" so the agent can PUT the finished draft in a follow-up call.
767+
let hasLoadingReply = false
768+
if (session.type === 'email_review') {
769+
const inbox = Array.isArray(finalPayload?.inbox) ? finalPayload.inbox as Array<Record<string, unknown>> : []
770+
hasLoadingReply = inbox.some(e => e.replyState === 'loading')
771+
}
772+
773+
if (hasLoadingReply) {
774+
updateSessionPayloadKeepStatus(req.params.id, finalPayload)
775+
} else {
776+
updateSessionPayload(req.params.id, finalPayload)
777+
}
766778
const updated = getSession(req.params.id)
767779
const newRevision = updated?.revision ?? session.revision + 1
768-
console.log(`[agentclick] Session ${session.id} payload updated, back to pending (revision=${newRevision})`)
780+
const newStatus = hasLoadingReply ? 'rewriting' : 'pending'
781+
console.log(`[agentclick] Session ${session.id} payload updated, status=${newStatus} (revision=${newRevision})`)
769782

770783
// Notify main agent that sub-agent completed a rewrite round (fire-and-forget)
771784
if (session.sessionKey) {

packages/server/src/store.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,12 @@ export function updateSessionPayload(id: string, payload: unknown): void {
9999
`).run(JSON.stringify(payload), Date.now(), id)
100100
}
101101

102+
export function updateSessionPayloadKeepStatus(id: string, payload: unknown): void {
103+
db.prepare(`
104+
UPDATE sessions SET payload = ?, updatedAt = ?, revision = revision + 1 WHERE id = ?
105+
`).run(JSON.stringify(payload), Date.now(), id)
106+
}
107+
102108
export function updateSessionPageStatus(id: string, pageStatus: SessionPageStatus): void {
103109
db.prepare(`
104110
UPDATE sessions SET pageStatus = ?, updatedAt = ? WHERE id = ?

packages/web/src/pages/ReviewPage.tsx

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,18 @@ export default function ReviewPage() {
339339
} as InboxPayload
340340
})
341341
if (pendingAgentAction === 'regenerate') resetEditState()
342+
343+
// For reply actions, keep waiting until the target email's replyState is "ready"
344+
if (pendingAgentAction === 'reply' && activeReplyRequestEmailId) {
345+
const serverPayload = data.payload as ReviewSessionPayload
346+
const serverInbox = Array.isArray(serverPayload?.inbox) ? serverPayload.inbox : []
347+
const targetEmail = serverInbox.find((e: EmailItem) => e.id === activeReplyRequestEmailId)
348+
if (targetEmail && targetEmail.replyState !== 'ready') {
349+
// Still loading — don't stop waiting
350+
return
351+
}
352+
}
353+
342354
setWaitingForRewrite(false)
343355
setPendingAgentAction(null)
344356
setActiveReplyRequestEmailId(null)
@@ -1090,9 +1102,37 @@ export default function ReviewPage() {
10901102
</div>
10911103
<div className="p-4 bg-white dark:bg-zinc-900 border border-gray-100 dark:border-zinc-800 rounded-lg">
10921104
<p className="text-xs uppercase tracking-wider text-zinc-400 dark:text-slate-500 mb-2">Full Email</p>
1093-
<p className="text-sm text-zinc-700 dark:text-slate-300 leading-relaxed whitespace-pre-wrap">
1094-
{email.body || email.preview || 'No content available.'}
1095-
</p>
1105+
{(() => {
1106+
const bodyContent = email.body || email.preview || ''
1107+
const hasHtml = /<(html|body|div|table)\b/i.test(bodyContent)
1108+
if (!bodyContent) {
1109+
return <p className="text-sm text-zinc-700 dark:text-slate-300 leading-relaxed">No content available.</p>
1110+
}
1111+
if (hasHtml) {
1112+
return (
1113+
<iframe
1114+
sandbox="allow-same-origin"
1115+
srcDoc={bodyContent}
1116+
referrerPolicy="no-referrer"
1117+
style={{ width: '100%', border: 'none', overflow: 'hidden', minHeight: '120px' }}
1118+
onLoad={(e) => {
1119+
const iframe = e.currentTarget
1120+
try {
1121+
const h = iframe.contentDocument?.body?.scrollHeight
1122+
if (h) {
1123+
iframe.style.height = h + 16 + 'px'
1124+
}
1125+
} catch { /* cross-origin guard */ }
1126+
}}
1127+
/>
1128+
)
1129+
}
1130+
return (
1131+
<p className="text-sm text-zinc-700 dark:text-slate-300 leading-relaxed whitespace-pre-wrap">
1132+
{bodyContent}
1133+
</p>
1134+
)
1135+
})()}
10961136
{email.headers && email.headers.length > 0 && (
10971137
<div className="mt-4 pt-3 border-t border-gray-100 dark:border-zinc-800 space-y-1">
10981138
{email.headers.map(header => (

skills/clickui-email/SKILL.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,7 @@ The UI is not only a final approval screen. The user may:
194194

195195
If the result indicates `requestReplyDraft: true` for an email:
196196
- update the payload quickly so the UI can show that email as loading
197-
- generate the reply draft yourself as the agent
197+
- generate the reply draft yourself as the agent — use the email body already present in the poll response payload (do not re-fetch from Gmail)
198198
- PUT the finished draft back into the same session
199199
- do not create a new session
200200

@@ -286,7 +286,7 @@ curl -s -X PUT "$AGENTCLICK_BASE/api/sessions/${SESSION_ID}/payload" \
286286

287287
Rules:
288288
- Reuse the same `SESSION_ID` for the full interaction.
289-
- If the UI asked for loading first, PUT a fast loading-state update, then PUT the completed draft.
289+
- **Two-PUT pattern for replies:** first PUT a loading-state update (`replyState: "loading"`), then PUT the completed draft (`replyState: "ready"` with `replyDraft`). The server keeps the session in `rewriting` status as long as any email has `replyState: "loading"`, so the second PUT is accepted.
290290
- If PUT fails, fix it before continuing.
291291

292292
## Completion Rules
@@ -322,3 +322,4 @@ Assume the page behaves like this and update payloads accordingly:
322322
- If the environment cannot keep a long blocking wait, poll the session every 10 seconds instead.
323323
- Do not claim a reply came from Gmail or from a background process if the agent generated it.
324324
- Prefer the bundled parallel fetch script for inbox loading, and use direct `gog` calls for one-off follow-up detail when needed.
325+
- The `/wait` and short-poll responses include the full session payload. Use this data to generate reply drafts — do not re-fetch emails from Gmail unless the body is missing.

0 commit comments

Comments
 (0)