From 3f7a030377b0ab56e49d17605898dca6cd63b58d Mon Sep 17 00:00:00 2001 From: louispaulb Date: Mon, 15 Jun 2026 07:29:34 -0400 Subject: [PATCH] feat(inbox): email height fits content + per-message reply button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Email messages now size to their own content (fitEmailFrame): iframe sandbox gains allow-same-origin (NO allow-scripts — scripts stay blocked, parent just measures body.scrollHeight) and the height is set on @load + re-measured on expand. Removes the fixed 300px frame and the "fill empty space" stretch (no more big whitespace). msg-body is now v-if (renders on expand) so the measure runs while visible + lazier. - Reply arrow in each CUSTOMER message header → opens + focuses the composer without scrolling to the bottom button. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/shared/ConversationPanel.vue | 29 ++++++++++++------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/apps/ops/src/components/shared/ConversationPanel.vue b/apps/ops/src/components/shared/ConversationPanel.vue index 9c701cc..4a6dd95 100644 --- a/apps/ops/src/components/shared/ConversationPanel.vue +++ b/apps/ops/src/components/shared/ConversationPanel.vue @@ -327,27 +327,28 @@
-
+
{{ msgWho(msg) }} + Répondre à ce message {{ formatTime(msg.ts) }}
{{ msgSnippet(msg) }}
-
+
@@ -993,6 +994,16 @@ const presence = computed(() => { const replyOpen = ref(false) const composeEl = ref(null) function openReply () { replyOpen.value = true; if (coordToken.value) notifyTyping(coordToken.value); nextTick(() => { try { composeEl.value?.querySelector('textarea, [contenteditable=true]')?.focus() } catch (e) { /* */ } }) } +// Ajuste la hauteur de l'iframe courriel à SON contenu (plus de 300px fixe / blancs). Sûr : sandbox `allow-same-origin` SANS `allow-scripts` → le parent lit le DOM pour mesurer, mais 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) { + 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' } } + 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) } +// Re-mesure les courriels VISIBLES (le @load se produit pendant que le message est replié/display:none → hauteur 0 ; il faut re-mesurer au dépliage). +function refitFrames () { nextTick(() => { const root = messagesEl.value; if (root) root.querySelectorAll('.email-frame').forEach(f => { if (f.offsetParent !== null) fitFrame(f) }) }) } // ── Résumé IA du fil (« Summarise this email » de Gmail) — pour l'agent, ne modifie rien ── const summaryText = ref('') const summaryBusy = ref(false) @@ -1058,13 +1069,13 @@ async function removeAssistant (email) { if (!coordToken.value) return; try { aw // Accordéon des messages (style Gmail) : ancien replié, dernier déplié, nouvel entrant déplié. const expandedMsgs = ref(new Set()) function isExpanded (m) { return expandedMsgs.value.has(m.id) } -function toggleMsg (m) { const s = new Set(expandedMsgs.value); s.has(m.id) ? s.delete(m.id) : s.add(m.id); expandedMsgs.value = s } +function toggleMsg (m) { const s = new Set(expandedMsgs.value); s.has(m.id) ? s.delete(m.id) : s.add(m.id); expandedMsgs.value = s; refitFrames() } function msgWho (m) { return m.from === 'agent' ? (m.via === 'ai' ? 'IA' : 'Nous') : (activeDiscussion.value?.customerName || activeDiscussion.value?.email || 'Client') } function msgIcon (m) { return m.html ? 'mail' : m.via === 'sms' ? 'sms' : m.via === 'ai' ? 'smart_toy' : m.media ? 'image' : 'chat' } function msgSnippet (m) { const t = String(m.text || '').replace(/^✉️[^\n]*\n+/, '').replace(/\s+/g, ' ').trim(); return t.slice(0, 64) || (m.media ? '[image]' : m.html ? '[courriel]' : '') } 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 }) -function expandLast () { const ms = activeDiscussion.value?.messages || []; expandedMsgs.value = new Set(ms.length ? [ms[ms.length - 1].id] : []) } +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(() => (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) @@ -1434,7 +1445,7 @@ function formatDate (dateStr) { .email-card-agent { border-color: #c7d2fe; background: #f5f7ff; } .email-card-head { display: flex; align-items: center; padding: 2px 0; background: transparent; } .email-card-agent .email-card-head { background: #eef2ff; } -.email-frame { width: 100%; height: 300px; border: 0; background: #fff; display: block; } +.email-frame { width: 100%; height: auto; min-height: 48px; border: 0; background: #fff; display: block; } .pay-card { margin: 6px 8px; padding: 8px 10px; border: 1px solid #bbf7d0; background: #f0fdf4; border-radius: 8px; } .pay-card-no { border-color: #e2e8f0; background: #f8fafc; } .email-editor { border: 1px solid #e2e8f0; border-radius: 8px; } @@ -1534,10 +1545,6 @@ function formatDate (dateStr) { /* Plein écran : colonne flex qui remplit la hauteur ; le fil défile, le composeur reste en bas */ .conv-fullpage { display: flex; flex-direction: column; width: 100%; background: #fff; min-height: 0; } .conv-fullpage .conv-messages { flex: 1 1 auto; overflow-y: auto; height: auto; min-height: 0; } -/* Le DERNIER message (déplié) s'étire pour occuper l'espace libre — le courriel remplit, le composeur reste accessible en bas */ -.conv-fullpage .conv-msg.msg-fill { flex: 1 1 auto; display: flex; flex-direction: column; min-height: 280px; } -.conv-fullpage .msg-fill .msg-body { flex: 1 1 auto; display: flex; min-height: 0; } -.conv-fullpage .msg-fill .email-card { flex: 1 1 auto; display: flex; flex-direction: column; } -.conv-fullpage .msg-fill .email-frame { flex: 1 1 auto; height: auto; } +/* Chaque courriel s'ajuste à SON contenu (fitEmailFrame) — plus de hauteur fixe ni d'étirement du dernier message. */ .conv-loading { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; height: 50vh; color: #94a3b8; font-size: 0.9rem; }