feat(inbox): global thread scroll + close (X) on another agent's draft mirror

- Email posts now render at FULL content height (fitFrame cap raised 1800→20000 guard)
  so there is no per-message internal scroll — the whole thread scrolls as one.
- The live draft mirror ("X rédige une réponse…") gets a close X (closeOtherDraft):
  dismiss another agent's in-progress draft preview; reset on conversation switch so
  a genuinely new draft re-appears.

Verified: a 60-paragraph email renders at 2681px (no inner scrollbar); the mirror
shows a close button that hides it on click.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-06-15 08:09:07 -04:00
parent f519bb3dd6
commit 54f0d271dd

View File

@ -383,7 +383,7 @@
<!-- Brouillon miroir temps réel : ce qu'un AUTRE agent est en train de rédiger (dernier qui écrit gagne) --> <!-- Brouillon miroir temps réel : ce qu'un AUTRE agent est en train de rédiger (dernier qui écrit gagne) -->
<div v-if="otherDraft" class="coord-draft-mirror"> <div v-if="otherDraft" class="coord-draft-mirror">
<div class="coord-draft-head"><q-icon name="edit_note" size="15px" /> <b>{{ shortAgent(otherDraft.agent) }}</b> rédige une réponse en direct</div> <div class="coord-draft-head row items-center no-wrap"><q-icon name="edit_note" size="15px" /> <b class="q-ml-xs">{{ shortAgent(otherDraft.agent) }}</b> <span class="q-ml-xs">rédige une réponse en direct</span><q-space /><q-btn flat dense round size="xs" icon="close" color="indigo-4" @click="closeOtherDraft"><q-tooltip>Fermer cet aperçu</q-tooltip></q-btn></div>
<div class="coord-draft-body" v-html="otherDraft.html || (otherDraft.text || '').replace(/\n/g, '<br>')"></div> <div class="coord-draft-body" v-html="otherDraft.html || (otherDraft.text || '').replace(/\n/g, '<br>')"></div>
</div> </div>
@ -974,7 +974,9 @@ const readers = computed(() => {
return Object.keys(rb).filter(e => e && e !== meEmail.value).map(e => ({ email: e, ...rb[e] })).sort((a, b) => String(b.readAt || '').localeCompare(String(a.readAt || ''))) return Object.keys(rb).filter(e => e && e !== meEmail.value).map(e => ({ email: e, ...rb[e] })).sort((a, b) => String(b.readAt || '').localeCompare(String(a.readAt || '')))
}) })
// Brouillon d'un AUTRE agent sur cette conversation (miroir temps réel ; null si c'est le mien ou aucun). // Brouillon d'un AUTRE agent sur cette conversation (miroir temps réel ; null si c'est le mien ou aucun).
const otherDraft = computed(() => sharedDraft.value[coordToken.value] || null) // handleConvDraft a déjà retiré mon propre onglet (filtre sid) const closedDrafts = ref(new Set()) // miroirs fermés manuellement via le X (réaffichés au changement de fil)
const otherDraft = computed(() => (closedDrafts.value.has(coordToken.value) ? null : (sharedDraft.value[coordToken.value] || null))) // handleConvDraft a déjà retiré mon propre onglet (filtre sid)
function closeOtherDraft () { const s = new Set(closedDrafts.value); s.add(coordToken.value); closedDrafts.value = s }
const coordQueue = computed(() => activeConv.value?.queue || activeDiscussion.value?.queue || '') const coordQueue = computed(() => activeConv.value?.queue || activeDiscussion.value?.queue || '')
// Signature SUGGÉRÉE selon la FILE (département) du fil : la réponse pré-écrite dont mailbox === file. One-tap, jamais forcée (le brouillon est partagé ne pas écraser/diffuser une surprise). // Signature SUGGÉRÉE selon la FILE (département) du fil : la réponse pré-écrite dont mailbox === file. One-tap, jamais forcée (le brouillon est partagé ne pas écraser/diffuser une surprise).
const suggestedSignature = computed(() => { if (!isEmailConv.value) return null; const q = coordQueue.value; return q ? (cannedList.value.find(c => c.html && c.mailbox === q) || null) : null }) const suggestedSignature = computed(() => { if (!isEmailConv.value) return null; const q = coordQueue.value; return q ? (cannedList.value.find(c => c.html && c.mailbox === q) || null) : null })
@ -998,7 +1000,8 @@ function openReply () { replyOpen.value = true; if (coordToken.value) notifyTypi
// Ajuste un iframe courriel à SON contenu (fini le 300px fixe / les blancs). Sûr : sandbox `allow-same-origin` SANS `allow-scripts` le parent lit le DOM pour mesurer, aucun script du courriel ne s'exécute. // Ajuste un iframe courriel à SON contenu (fini le 300px fixe / les blancs). Sûr : sandbox `allow-same-origin` SANS `allow-scripts` le parent lit le DOM pour mesurer, aucun script du courriel ne s'exécute.
function fitFrame (f) { function fitFrame (f) {
if (!f) return if (!f) return
const measure = () => { try { const h = f.contentDocument.body.scrollHeight; if (h > 4) f.style.height = Math.min(Math.max(h + 8, 48), 1800) + 'px' } catch (err) { f.style.height = '300px' } } // Hauteur = TOUT le contenu (pas de plafond aucun scroll interne au message ; c'est le fil entier qui défile globalement). Garde-fou 20000px contre une valeur aberrante.
const measure = () => { try { const h = f.contentDocument.body.scrollHeight; if (h > 4) f.style.height = Math.min(Math.max(h + 8, 48), 20000) + 'px' } catch (err) { f.style.height = '300px' } }
measure(); setTimeout(measure, 80) // @load (ou un déplié) peut précéder la mise en page 2e mesure après stabilisation measure(); setTimeout(measure, 80) // @load (ou un déplié) peut précéder la mise en page 2e mesure après stabilisation
} }
function fitEmailFrame (e) { fitFrame(e.target) } function fitEmailFrame (e) { fitFrame(e.target) }
@ -1076,7 +1079,7 @@ function msgSnippet (m) { const t = String(m.text || '').replace(/^✉️[^\n]*\
function cleanBody (t) { return String(t || '').replace(/^✉️[^\n]*\n+/, '') } // retire l'ancien préfixe sujet collé au corps (double en-tête) function cleanBody (t) { return String(t || '').replace(/^✉️[^\n]*\n+/, '') } // retire l'ancien préfixe sujet collé au corps (double en-tête)
const lastMessageId = computed(() => { const ms = activeDiscussion.value?.messages || []; return ms.length ? ms[ms.length - 1].id : null }) const lastMessageId = computed(() => { const ms = activeDiscussion.value?.messages || []; return ms.length ? ms[ms.length - 1].id : null })
function expandLast () { const ms = activeDiscussion.value?.messages || []; expandedMsgs.value = new Set(ms.length ? [ms[ms.length - 1].id] : []); refitFrames() } function expandLast () { const ms = activeDiscussion.value?.messages || []; expandedMsgs.value = new Set(ms.length ? [ms[ms.length - 1].id] : []); refitFrames() }
watch(coordToken, () => { svcStatus.value = null; expandLast(); replyOpen.value = false; summaryText.value = ''; summaryErr.value = ''; fetchAgents().then(a => { agents.value = a }) }) watch(coordToken, () => { svcStatus.value = null; expandLast(); replyOpen.value = false; summaryText.value = ''; summaryErr.value = ''; closedDrafts.value = new Set(); fetchAgents().then(a => { agents.value = a }) })
watch(() => (activeDiscussion.value?.messages || []).length, (n, o) => { if (n > (o || 0)) { const ms = activeDiscussion.value?.messages || []; if (ms.length) { const s = new Set(expandedMsgs.value); s.add(ms[ms.length - 1].id); expandedMsgs.value = s } } }) watch(() => (activeDiscussion.value?.messages || []).length, (n, o) => { if (n > (o || 0)) { const ms = activeDiscussion.value?.messages || []; if (ms.length) { const s = new Set(expandedMsgs.value); s.add(ms[ms.length - 1].id); expandedMsgs.value = s } } })
const gmailReplyUrl = computed(() => activeConv.value?.gmailReplyUrl || activeDiscussion.value?.gmailReplyUrl || null) const gmailReplyUrl = computed(() => activeConv.value?.gmailReplyUrl || activeDiscussion.value?.gmailReplyUrl || null)
const coordNotes = computed(() => activeConv.value?.notes || []) const coordNotes = computed(() => activeConv.value?.notes || [])