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({
+ 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);
+ });
+});