diff --git a/client/src/features/office/components/OfficeChatPanel.jsx b/client/src/features/office/components/OfficeChatPanel.jsx index 8f83709c3..9c4588604 100644 --- a/client/src/features/office/components/OfficeChatPanel.jsx +++ b/client/src/features/office/components/OfficeChatPanel.jsx @@ -108,13 +108,17 @@ function OfficeChatPanel({ authData, selectedApp, setSelectedApp, onLogout }) { // separately by ChatInput). const emailContextText = useMemo(() => { const currentBodyText = mailSnapshot.includeBody ? mailSnapshot.ctx?.bodyText || '' : ''; + const currentSelectedText = mailSnapshot.useSelection + ? mailSnapshot.ctx?.selectedText || '' + : ''; return combineUserTextWithEmailContext({ userText: '', currentBodyText, + currentSelectedText, currentItemId: mailSnapshot.ctx?.itemId, pinned: pinnedEmails }); - }, [mailSnapshot.includeBody, mailSnapshot.ctx, pinnedEmails]); + }, [mailSnapshot.includeBody, mailSnapshot.useSelection, mailSnapshot.ctx, pinnedEmails]); // Attachment text for the live token estimate. The adapter extracts document // attachments (current email + pinned emails) into fileData at send time and @@ -330,7 +334,7 @@ function OfficeChatPanel({ authData, selectedApp, setSelectedApp, onLogout }) { }, []); const submitMessage = useCallback( - (messageText, overrides = {}) => { + async (messageText, overrides = {}) => { const text = (messageText ?? '').trim(); if (!text && !selectedApp?.allowEmptyContent) return; @@ -353,13 +357,14 @@ function OfficeChatPanel({ authData, selectedApp, setSelectedApp, onLogout }) { params.pinnedEmails = pinnedEmails; // Mail context snapshot — the user can drop individual attachments - // and toggle the body off via OfficeContextStrip / its embedded - // OfficeMailContextBanner before send. Forwarding the edited + // and toggle the body/selection off via OfficeContextStrip / its + // embedded OfficeMailContextBanner before send. Forwarding the edited // snapshot here avoids a second host.readMessageContext() round-trip - // inside the adapter and ensures the user's removals (and body - // opt-out) are honored. Null falls back to the adapter's own fetch - // (extension side panel, no-context routes). - const snapshotOverride = mailSnapshot.buildSnapshotOverride(); + // inside the adapter and ensures the user's removals (and body/ + // selection opt-out) are honored. Null falls back to the adapter's + // own fetch (extension side panel, no-context routes). Async because + // it re-reads the current text selection fresh right before send. + const snapshotOverride = await mailSnapshot.buildSnapshotOverride(); if (snapshotOverride) params.hostContextOverride = snapshotOverride; // Resend can pass a `selectedFile` override to bypass async state updates; @@ -634,6 +639,8 @@ function OfficeChatPanel({ authData, selectedApp, setSelectedApp, onLogout }) { onRestoreAttachments={mailSnapshot.restoreAttachments} includeBody={mailSnapshot.includeBody} onToggleBody={mailSnapshot.setIncludeBody} + useSelection={mailSnapshot.useSelection} + onToggleSelection={mailSnapshot.setUseSelection} pinned={pinnedEmails} onUnpin={handleUnpin} onClearPinned={handleClearPinned} diff --git a/client/src/features/office/components/chat/OfficeContextStrip.jsx b/client/src/features/office/components/chat/OfficeContextStrip.jsx index f9709c032..928529de4 100644 --- a/client/src/features/office/components/chat/OfficeContextStrip.jsx +++ b/client/src/features/office/components/chat/OfficeContextStrip.jsx @@ -35,6 +35,8 @@ function OfficeContextStrip({ onRestoreAttachments, includeBody, onToggleBody, + useSelection, + onToggleSelection, // Pinned-emails toolbar pinned, onUnpin, @@ -58,6 +60,8 @@ function OfficeContextStrip({ const pinnedList = Array.isArray(pinned) ? pinned : []; const hasBody = Boolean(ctx?.bodyText && ctx.bodyText.trim().length> 0); + const hasSelection = Boolean(ctx?.selectedText && ctx.selectedText.trim().length> 0); + const usingSelection = hasSelection && useSelection !== false; const hasAttachments = attachments.length> 0; // Pinned emails and pin controls are mail-only — they don't apply to a // single calendar item, so we suppress them when the user is on an @@ -149,7 +153,7 @@ function OfficeContextStrip({ // Nothing to surface: no email context, nothing pinned, no pin buttons. // Keep the strip out of the DOM entirely so the chat input flushes up. - if (!hasBody && !hasAttachments && !hasPinned && !hasPinControls) { + if (!hasBody && !hasSelection && !hasAttachments && !hasPinned && !hasPinControls) { return null; } @@ -162,7 +166,9 @@ function OfficeContextStrip({ // Build the always-visible summary line — same content in collapsed and // expanded states so users always see what's queued at a glance. const summaryParts = []; - if (hasBody) { + if (usingSelection) { + summaryParts.push('Using selection'); + } else if (hasBody) { summaryParts.push(includeBody !== false ? 'Email body' : 'Body excluded'); } if (hasAttachments) { @@ -183,7 +189,7 @@ function OfficeContextStrip({ // Hide the header subject when the current email is empty (e.g. only // pinned items, or no live context at all) so the row doesn't read // "Current email" with no real backing. - const headerTitle = hasBody || hasAttachments ? subject : 'Email context'; + const headerTitle = hasBody || hasSelection || hasAttachments ? subject : 'Email context'; return (
@@ -233,7 +239,7 @@ function OfficeContextStrip({ {expanded && (
- {(hasBody || hasAttachments) && ( + {(hasBody || hasSelection || hasAttachments) && ( )} diff --git a/client/src/features/office/components/chat/OfficeMailContextBanner.jsx b/client/src/features/office/components/chat/OfficeMailContextBanner.jsx index 56cd17cb0..b97d44e2a 100644 --- a/client/src/features/office/components/chat/OfficeMailContextBanner.jsx +++ b/client/src/features/office/components/chat/OfficeMailContextBanner.jsx @@ -51,6 +51,8 @@ function OfficeMailContextBanner({ onRestoreAttachments, includeBody, onToggleBody, + useSelection, + onToggleSelection, embedded = false }) { const attachments = useMemo( @@ -64,6 +66,9 @@ function OfficeMailContextBanner({ const removedCount = removedAttachmentIds?.size || 0; const hasBody = Boolean(ctx?.bodyText && ctx.bodyText.trim().length> 0); + const selectedText = (ctx?.selectedText || '').trim(); + const hasSelection = selectedText.length> 0; + const usingSelection = hasSelection && useSelection !== false; const hasAttachments = attachments.length> 0; // Older Outlook hosts (pre-Mailbox 1.8) fail every attachment fetch with // the same "API not available" error — show one explanation instead of @@ -99,13 +104,13 @@ function OfficeMailContextBanner({ ); } - if (!hasBody && !hasAttachments) { + if (!hasBody && !hasSelection && !hasAttachments) { return null; } const subject = (ctx?.subject || '').trim() || 'Current email'; - const bodyPreview = shortenBody(ctx?.bodyText); - const bodySent = includeBody !== false && hasBody; + const bodyPreview = usingSelection ? shortenBody(selectedText) : shortenBody(ctx?.bodyText); + const bodySent = usingSelection || (includeBody !== false && hasBody); // The outer container is only emitted in standalone mode — when this // banner lives inside OfficeContextStrip the strip already wraps the @@ -129,8 +134,8 @@ function OfficeMailContextBanner({
)} - {/* Email body card */} - {hasBody && ( + {/* Email body / selection card */} + {(hasBody || hasSelection) && (
{subject}
- +
+ {hasSelection && ( + + )} + {!usingSelection && hasBody && ( + + )} +
+ {usingSelection && ( +
+ Using selected text ({selectedText.length} chars) +
+ )} {bodyPreview && (
)} - {!bodySent && ( + {!bodySent && hasBody && (
Email body will not be sent.
)}
diff --git a/client/src/features/office/hooks/useOfficeChatAdapter.js b/client/src/features/office/hooks/useOfficeChatAdapter.js index 6256e3e2b..ff9c62b88 100644 --- a/client/src/features/office/hooks/useOfficeChatAdapter.js +++ b/client/src/features/office/hooks/useOfficeChatAdapter.js @@ -113,6 +113,7 @@ function useOfficeChatAdapter({ appId, chatId, onMessageComplete }) { : combineUserTextWithEmailContext({ userText: apiMessage.content, currentBodyText: ctx.bodyText, + currentSelectedText: ctx.selectedText, currentItemId: ctx.itemId ?? null, pinned: pinnedEmails }); diff --git a/client/src/features/office/hooks/useOutlookMailContextSnapshot.js b/client/src/features/office/hooks/useOutlookMailContextSnapshot.js index cc83c8eaa..562d41eaa 100644 --- a/client/src/features/office/hooks/useOutlookMailContextSnapshot.js +++ b/client/src/features/office/hooks/useOutlookMailContextSnapshot.js @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useEmbeddedHost } from '../contexts/EmbeddedHostContext'; +import { fetchCurrentSelectedText } from '../utilities/outlookMailContext'; /** * Maintains a live, user-editable snapshot of the current host mail context @@ -14,8 +15,10 @@ import { useEmbeddedHost } from '../contexts/EmbeddedHostContext'; * - Tracks per-message edits: a set of attachment ids the user removed via * the banner. The set resets on item change. * - `buildSnapshotOverride()` returns a copy of the live ctx with removed - * attachments stripped — chat adapter accepts this as `hostContextOverride` - * in params, skipping its own `readMessageContext()` call. + * attachments stripped and `selectedText` freshly re-read (unless the user + * toggled selection off) — chat adapter accepts this as + * `hostContextOverride` in params, skipping its own `readMessageContext()` + * call. * - `confirmSent()` resets removals after a successful send so the next * message starts from a clean snapshot of the same email. */ @@ -29,6 +32,11 @@ export function useOutlookMailContextSnapshot() { // plumbing (issue #1467) — the OfficeMailContextBanner owns this state now // and the contextToggles mechanism is no longer used in the Outlook host. const [includeBody, setIncludeBody] = useState(true); + // Per-email opt-out for using the highlighted selection instead of the + // full body. Defaults to true (prefer the selection whenever one exists) + // and resets on ItemChanged alongside `includeBody`, mirroring the same + // per-email lifetime. + const [useSelection, setUseSelection] = useState(true); // Bumped by ItemChanged so the chat panel can reset its edit state too. const [generation, setGeneration] = useState(0); // Monotonic sequence for context loads. A single click in Outlook fires @@ -63,6 +71,7 @@ export function useOutlookMailContextSnapshot() { function onItemChange() { setRemovedAttachmentIds(new Set()); setIncludeBody(true); + setUseSelection(true); setGeneration(g => g + 1); // Supersede any in-flight load right away and show the loading state, // but debounce the actual read: the second event of the double @@ -114,18 +123,35 @@ export function useOutlookMailContextSnapshot() { * mode without an item, etc.) so the adapter can fall back to its own * `host.readMessageContext()` call. Honors the "Include body" checkbox in * the banner by clearing `bodyText` when the user has opted out. + * + * Async because the selection is re-read fresh right before send: Office.js + * has no "selection changed" event, so the snapshot's `selectedText` (last + * refreshed on ItemChanged) can be stale the moment the user changes their + * highlight without navigating away. When the user has toggled selection + * off (`useSelection === false`), `selectedText` is cleared so + * `combineUserTextWithEmailContext` falls back to the body/includeBody + * behavior instead. */ - const buildSnapshotOverride = useCallback(() => { + const buildSnapshotOverride = useCallback(async () => { if (!state.ctx) return null; const filtered = { ...state.ctx }; if (!includeBody) { filtered.bodyText = null; } + if (useSelection && filtered.itemKind !== 'appointment') { + try { + filtered.selectedText = await fetchCurrentSelectedText(); + } catch { + filtered.selectedText = null; + } + } else { + filtered.selectedText = null; + } if (removedAttachmentIds.size> 0 && Array.isArray(filtered.attachments)) { filtered.attachments = filtered.attachments.filter(a => !removedAttachmentIds.has(a?.id)); } return filtered; - }, [state.ctx, removedAttachmentIds, includeBody]); + }, [state.ctx, removedAttachmentIds, includeBody, useSelection]); const visibleAttachments = useMemo(() => { const list = Array.isArray(state.ctx?.attachments) ? state.ctx.attachments : []; @@ -145,6 +171,8 @@ export function useOutlookMailContextSnapshot() { buildSnapshotOverride, includeBody, setIncludeBody, + useSelection, + setUseSelection, generation }; } diff --git a/client/src/features/office/utilities/buildChatApiMessages.js b/client/src/features/office/utilities/buildChatApiMessages.js index 5adb21060..a5b7e18b3 100644 --- a/client/src/features/office/utilities/buildChatApiMessages.js +++ b/client/src/features/office/utilities/buildChatApiMessages.js @@ -385,6 +385,11 @@ function formatPinnedEmail(p, idx) { * @param {string|null} args.currentBodyText Body of Office.context.mailbox.item * (already stripped by the user's * context-toggle if they turned it off). + * @param {string|null} [args.currentSelectedText] Text the user has highlighted in the + * current email, if any. Takes precedence + * over `currentBodyText` when non-empty — + * the user only cares about the selection, + * not the rest of a possibly-long thread. * @param {string|null} [args.currentItemId] itemId of the current Outlook item — * used to dedupe against pinned[]. * @param {Array<{subject?: string, bodyText?: string|null, itemId?: string|null}>} [args.pinned] @@ -395,12 +400,16 @@ function formatPinnedEmail(p, idx) { export function combineUserTextWithEmailContext({ userText, currentBodyText, + currentSelectedText, currentItemId, pinned }) { + const selected = (currentSelectedText || '').trim(); + const effectiveBodyText = selected || currentBodyText; + const list = Array.isArray(pinned) ? pinned : []; if (list.length === 0) { - return combineUserTextWithEmailBody(userText, currentBodyText); + return combineUserTextWithEmailBody(userText, effectiveBodyText); } const u = (userText || '').trim(); @@ -423,7 +432,7 @@ export function combineUserTextWithEmailContext({ segments.push(`--- Pinned emails (${dedupedPinned.length}) ---\n${pinnedBlock}`); } - const currentBody = (currentBodyText || '').trim(); + const currentBody = (effectiveBodyText || '').trim(); if (currentBody) { segments.push(`--- Current email ---\n${currentBody}`); } diff --git a/client/src/features/office/utilities/outlookMailContext.js b/client/src/features/office/utilities/outlookMailContext.js index c7a1168db..a3d584645 100644 --- a/client/src/features/office/utilities/outlookMailContext.js +++ b/client/src/features/office/utilities/outlookMailContext.js @@ -92,6 +92,26 @@ function getBodyTextAsync(item) { }); } +// Selection-not-supported (older Mailbox requirement sets, compose mode +// quirks, or simply nothing highlighted) is a soft fallback to the full +// body, not an error — this never rejects. +function getSelectedDataAsync(item) { + return new Promise(resolve => { + if (!item || typeof item.getSelectedDataAsync !== 'function') { + resolve(null); + return; + } + item.getSelectedDataAsync(Office.CoercionType.Text, result => { + if (result.status === Office.AsyncResultStatus.Failed) { + resolve(null); + return; + } + const text = result.value?.data; + resolve(typeof text === 'string' && text.trim() ? text : null); + }); + }); +} + // Exported so the review banner can recognize this specific failure and // collapse it into a single explanatory line instead of repeating it once // per attachment (see issue #1451). @@ -229,6 +249,11 @@ async function readMailSnapshot(item, itemId) { bodyText = await getBodyTextAsync(item); } catch {} + let selectedText = null; + try { + selectedText = await getSelectedDataAsync(item); + } catch {} + let subject = null; try { subject = await getSubjectAsync(item); @@ -280,12 +305,33 @@ async function readMailSnapshot(item, itemId) { subject, itemId, bodyText, + selectedText, attachments }, aborted }; } +/** + * Re-read just the current item's text selection, bypassing the cached + * snapshot. Office.js has no "selection changed" event for the reading pane + * or compose body, so a snapshot taken on mount goes stale the moment the + * user changes their highlight without switching emails. Called right + * before send so the acceptance criterion "selection is refreshed when the + * user re-runs" holds even then. Resolves `null` (never throws) when + * there's no mail item, no selection API, or nothing selected. + */ +export async function fetchCurrentSelectedText() { + return withMailboxLock(async () => { + if (!isOutlookMailItemAvailable()) return null; + try { + return await getSelectedDataAsync(Office.context.mailbox.item); + } catch { + return null; + } + }); +} + function getSelectedItemsAsync() { return new Promise((resolve, reject) => { if ( diff --git a/docs/releases/5.5.0/features.md b/docs/releases/5.5.0/features.md index 735bc15a5..af91045f4 100644 --- a/docs/releases/5.5.0/features.md +++ b/docs/releases/5.5.0/features.md @@ -461,6 +461,20 @@ field) and the multimodal audio-upload path (which sends audio to a chat LLM). **Before using:** add or enable a transcription model under **Admin → Models** (model type "Transcription"), set its realtime URL, then enable transcription on the desired app. +## Outlook Add-in: Use Highlighted Text Instead of the Whole Email + +Highlighting text in the reading pane before opening the taskpane now sends just that selection to +the model, instead of always including the full email body — useful for trimming long threads down +to the one paragraph that matters. + +- The context banner shows "Using selected text (N chars)" with a one-click "Use full email + instead" toggle, and the reverse toggle to switch back to the selection. +- With no selection, behavior is unchanged: the full email body is used as before. +- The selection is re-read immediately before sending, so a highlight made after the taskpane + loaded is never sent stale. +- Applies to the current email only — pinned/multi-selected emails and meeting (calendar) items + still use their full body. + ## No More Silent Empty Answers from Gemini (Web Search Off) Chatting with a Gemini model while web search is turned off (for example the **Web Chat** app) could diff --git a/tests/unit/client/office-build-chat-api-messages.test.jsx b/tests/unit/client/office-build-chat-api-messages.test.jsx index ed4a49b63..3309d7253 100644 --- a/tests/unit/client/office-build-chat-api-messages.test.jsx +++ b/tests/unit/client/office-build-chat-api-messages.test.jsx @@ -27,7 +27,8 @@ const { buildImageDataFromMailAttachments, buildFileDataFromMailAttachments, collectAttachmentsForSend, - formatFileDataAsPromptText + formatFileDataAsPromptText, + combineUserTextWithEmailContext } = require('../../../client/src/features/office/utilities/buildChatApiMessages'); // JSDom doesn't implement createObjectURL by default — stub it so the @@ -414,6 +415,55 @@ describe('collectAttachmentsForSend', () => { }); }); +describe('combineUserTextWithEmailContext — selection precedence (issue #1448)', () => { + test('uses the selection instead of the full body when both are present', () => { + const result = combineUserTextWithEmailContext({ + userText: 'Summarize this', + currentBodyText: 'The full long email thread body goes here.', + currentSelectedText: 'Just this one highlighted paragraph.', + currentItemId: 'item-1', + pinned: [] + }); + expect(result).toContain('Just this one highlighted paragraph.'); + expect(result).not.toContain('The full long email thread body goes here.'); + }); + + test('falls back to the full body when there is no selection', () => { + const result = combineUserTextWithEmailContext({ + userText: 'Summarize this', + currentBodyText: 'The full long email thread body goes here.', + currentSelectedText: null, + currentItemId: 'item-1', + pinned: [] + }); + expect(result).toContain('The full long email thread body goes here.'); + }); + + test('falls back to the full body when the selection is whitespace-only', () => { + const result = combineUserTextWithEmailContext({ + userText: 'Summarize this', + currentBodyText: 'The full long email thread body goes here.', + currentSelectedText: ' ', + currentItemId: 'item-1', + pinned: [] + }); + expect(result).toContain('The full long email thread body goes here.'); + }); + + test('selection wins over the current body even alongside pinned emails', () => { + const result = combineUserTextWithEmailContext({ + userText: 'Summarize this', + currentBodyText: 'The full long email thread body goes here.', + currentSelectedText: 'Just this one highlighted paragraph.', + currentItemId: 'item-1', + pinned: [{ subject: 'Older thread', bodyText: 'Older content', itemId: 'item-2' }] + }); + expect(result).toContain('Just this one highlighted paragraph.'); + expect(result).toContain('Older content'); + expect(result).not.toContain('The full long email thread body goes here.'); + }); +}); + describe('formatFileDataAsPromptText', () => { // The live token estimate must count attachment text the same way the // server stitches it into the prompt (RequestBuilder's diff --git a/tests/unit/client/outlook-mail-context.test.jsx b/tests/unit/client/outlook-mail-context.test.jsx index 26ab70d66..b591472ed 100644 --- a/tests/unit/client/outlook-mail-context.test.jsx +++ b/tests/unit/client/outlook-mail-context.test.jsx @@ -19,7 +19,8 @@ import '@testing-library/jest-dom'; const { fetchCurrentMailContext, - fetchSelectedItemsContext + fetchSelectedItemsContext, + fetchCurrentSelectedText } = require('../../../client/src/features/office/utilities/outlookMailContext'); const SUCCEEDED = 'succeeded'; @@ -41,7 +42,15 @@ function installOfficeMock() { * proxy the call went through — foreign ids fail with the canonical * InvalidAttachmentId message. */ -function makeMailItem({ itemId, subject, bodyText, attachments = [], onBodyRead }) { +function makeMailItem({ + itemId, + subject, + bodyText, + attachments = [], + onBodyRead, + selectedText, + selectedDataFails = false +}) { const item = { itemId, itemType: 'message', @@ -62,6 +71,15 @@ function makeMailItem({ itemId, subject, bodyText, attachments = [], onBodyRead }, 0); } }, + getSelectedDataAsync: (_coercion, cb) => { + setTimeout(() => { + if (selectedDataFails) { + cb({ status: FAILED, error: { message: 'getSelectedDataAsync failed' } }); + return; + } + cb({ status: SUCCEEDED, value: { data: selectedText ?? '' } }); + }, 0); + }, getAttachmentContentAsync: (id, cb) => { setTimeout(() => { const live = global.Office.context.mailbox.item; @@ -253,6 +271,77 @@ describe('fetchCurrentMailContext', () => { }); }); +describe('fetchCurrentMailContext — text selection (issue #1448)', () => { + test('includes the highlighted selection in the snapshot', async () => { + Office.context.mailbox.item = makeMailItem({ + itemId: 'A', + subject: 'Mail A', + bodyText: 'the full email body', + selectedText: 'just this highlighted sentence' + }); + + const ctx = await fetchCurrentMailContext(); + + expect(ctx.selectedText).toBe('just this highlighted sentence'); + }); + + test('resolves selectedText to null when nothing is highlighted', async () => { + Office.context.mailbox.item = makeMailItem({ + itemId: 'A', + subject: 'Mail A', + bodyText: 'the full email body', + selectedText: '' + }); + + const ctx = await fetchCurrentMailContext(); + + expect(ctx.selectedText).toBeNull(); + }); + + test('resolves selectedText to null (never throws) when getSelectedDataAsync fails', async () => { + Office.context.mailbox.item = makeMailItem({ + itemId: 'A', + subject: 'Mail A', + bodyText: 'the full email body', + selectedDataFails: true + }); + + const ctx = await fetchCurrentMailContext(); + + expect(ctx.available).toBe(true); + expect(ctx.selectedText).toBeNull(); + }); + + test('resolves selectedText to null on hosts without the getSelectedDataAsync API', async () => { + const item = makeMailItem({ itemId: 'A', subject: 'Mail A', bodyText: 'the full email body' }); + delete item.getSelectedDataAsync; + Office.context.mailbox.item = item; + + const ctx = await fetchCurrentMailContext(); + + expect(ctx.selectedText).toBeNull(); + }); +}); + +describe('fetchCurrentSelectedText', () => { + test('re-reads the live selection directly, bypassing any cached snapshot', async () => { + Office.context.mailbox.item = makeMailItem({ + itemId: 'A', + subject: 'Mail A', + bodyText: 'body', + selectedText: 'fresh highlight' + }); + + await expect(fetchCurrentSelectedText()).resolves.toBe('fresh highlight'); + }); + + test('resolves null when there is no mail item available', async () => { + Office.context.mailbox.item = null; + + await expect(fetchCurrentSelectedText()).resolves.toBeNull(); + }); +}); + describe('fetchSelectedItemsContext', () => { function installMultiSelectMocks({ stubs, loadedBodies = {}, unloadFailures = {} }) { const calls = { load: [], unload: [] }; diff --git a/tests/unit/client/use-outlook-mail-context-snapshot.test.jsx b/tests/unit/client/use-outlook-mail-context-snapshot.test.jsx index 782d18e22..f5aa4c343 100644 --- a/tests/unit/client/use-outlook-mail-context-snapshot.test.jsx +++ b/tests/unit/client/use-outlook-mail-context-snapshot.test.jsx @@ -18,6 +18,11 @@ jest.mock('../../../client/src/features/office/contexts/EmbeddedHostContext', () useEmbeddedHost: () => mockHostImpl })); +const mockFetchCurrentSelectedText = jest.fn(); +jest.mock('../../../client/src/features/office/utilities/outlookMailContext', () => ({ + fetchCurrentSelectedText: (...args) => mockFetchCurrentSelectedText(...args) +})); + const useOutlookMailContextSnapshot = require('../../../client/src/features/office/hooks/useOutlookMailContextSnapshot').default; @@ -35,6 +40,7 @@ function dispatchItemChanged() { beforeEach(() => { jest.useFakeTimers(); + mockFetchCurrentSelectedText.mockReset(); }); afterEach(() => { @@ -170,3 +176,82 @@ test('per-email edits (removed attachments, include-body) reset on item change', expect(result.current.includeBody).toBe(true); expect(result.current.generation).toBe(generationBefore + 1); }); + +describe('buildSnapshotOverride — selection refresh (issue #1448)', () => { + test('re-reads the selection fresh at send time instead of trusting the cached snapshot', async () => { + mockHostImpl = { + kind: 'office', + readMessageContext: jest.fn(async () => ({ + available: true, + itemId: 'A', + subject: 'Mail A', + bodyText: 'full body', + selectedText: 'stale highlight from banner render', + attachments: [] + })) + }; + mockFetchCurrentSelectedText.mockResolvedValue('fresh highlight at send time'); + + const { result } = renderHook(() => useOutlookMailContextSnapshot()); + await act(async () => {}); + + const override = await result.current.buildSnapshotOverride(); + expect(override.selectedText).toBe('fresh highlight at send time'); + expect(mockFetchCurrentSelectedText).toHaveBeenCalledTimes(1); + }); + + test('clears selectedText once the user toggles "use selection" off', async () => { + mockHostImpl = { + kind: 'office', + readMessageContext: jest.fn(async () => ({ + available: true, + itemId: 'A', + subject: 'Mail A', + bodyText: 'full body', + selectedText: 'highlighted text', + attachments: [] + })) + }; + mockFetchCurrentSelectedText.mockResolvedValue('highlighted text'); + + const { result } = renderHook(() => useOutlookMailContextSnapshot()); + await act(async () => {}); + + act(() => { + result.current.setUseSelection(false); + }); + + const override = await result.current.buildSnapshotOverride(); + expect(override.selectedText).toBeNull(); + expect(mockFetchCurrentSelectedText).not.toHaveBeenCalled(); + }); + + test('defaults useSelection to true and resets it on item change', async () => { + mockHostImpl = { + kind: 'office', + readMessageContext: jest.fn(async () => ({ + available: true, + itemId: 'A', + subject: 'Mail A', + bodyText: 'full body', + selectedText: 'highlighted text', + attachments: [] + })) + }; + mockFetchCurrentSelectedText.mockResolvedValue('highlighted text'); + + const { result } = renderHook(() => useOutlookMailContextSnapshot()); + await act(async () => {}); + expect(result.current.useSelection).toBe(true); + + act(() => { + result.current.setUseSelection(false); + }); + expect(result.current.useSelection).toBe(false); + + await act(async () => { + dispatchItemChanged(); + }); + expect(result.current.useSelection).toBe(true); + }); +});

AltStyle によって変換されたページ (->オリジナル) /