Skip to content

Navigation Menu

Sign in
Sign up

[Outlook] Use selected email text as input instead of full body #2039

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
manzke wants to merge 1 commit into main
base: main
Choose a base branch
Loading
from claude/charming-fermat-cbzy96
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 15 additions & 8 deletions client/src/features/office/components/OfficeChatPanel.jsx
View file Open in desktop
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;

Expand All @@ -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;
Expand Down Expand Up @@ -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}
Expand Down
16 changes: 12 additions & 4 deletions client/src/features/office/components/chat/OfficeContextStrip.jsx
View file Open in desktop
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ function OfficeContextStrip({
onRestoreAttachments,
includeBody,
onToggleBody,
useSelection,
onToggleSelection,
// Pinned-emails toolbar
pinned,
onUnpin,
Expand All @@ -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
Expand Down Expand Up @@ -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;
}

Expand All @@ -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) {
Expand All @@ -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 (
<div className="mx-3 mt-2 mb-1 rounded-lg border border-slate-200 bg-white shadow-sm">
Expand Down Expand Up @@ -233,7 +239,7 @@ function OfficeContextStrip({

{expanded && (
<div className="border-t border-slate-100">
{(hasBody || hasAttachments) && (
{(hasBody || hasSelection || hasAttachments) && (
<OfficeMailContextBanner
ctx={ctx}
loading={false}
Expand All @@ -243,6 +249,8 @@ function OfficeContextStrip({
onRestoreAttachments={onRestoreAttachments}
includeBody={includeBody}
onToggleBody={onToggleBody}
useSelection={useSelection}
onToggleSelection={onToggleSelection}
embedded
/>
)}
Expand Down
View file Open in desktop
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ function OfficeMailContextBanner({
onRestoreAttachments,
includeBody,
onToggleBody,
useSelection,
onToggleSelection,
embedded = false
}) {
const attachments = useMemo(
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -129,8 +134,8 @@ function OfficeMailContextBanner({
</div>
)}

{/* Email body card */}
{hasBody && (
{/* Email body / selection card */}
{(hasBody || hasSelection) && (
<div
className={`flex items-start gap-2 px-3 py-2 ${
hasAttachments ? 'border-b border-slate-100' : ''
Expand All @@ -144,16 +149,36 @@ function OfficeMailContextBanner({
<div className="text-sm font-medium text-slate-900 truncate" title={subject}>
{subject}
</div>
<label className="flex items-center gap-1.5 text-xs text-slate-600 select-none cursor-pointer flex-shrink-0">
<input
type="checkbox"
checked={bodySent}
onChange={e => onToggleBody?.(e.target.checked)}
className="h-3.5 w-3.5 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500"
/>
Include body
</label>
<div className="flex items-center gap-2 flex-shrink-0">
{hasSelection && (
<button
type="button"
onClick={() => onToggleSelection?.(!usingSelection)}
className="text-xs text-indigo-600 hover:text-indigo-800 font-medium whitespace-nowrap"
>
{usingSelection
? 'Use full email instead'
: `Use selection (${selectedText.length})`}
</button>
)}
{!usingSelection && hasBody && (
<label className="flex items-center gap-1.5 text-xs text-slate-600 select-none cursor-pointer">
<input
type="checkbox"
checked={bodySent}
onChange={e => onToggleBody?.(e.target.checked)}
className="h-3.5 w-3.5 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500"
/>
Include body
</label>
)}
</div>
</div>
{usingSelection && (
<div className="mt-0.5 text-[11px] text-indigo-600">
Using selected text ({selectedText.length} chars)
</div>
)}
{bodyPreview && (
<div
className={`mt-0.5 text-xs ${
Expand All @@ -164,7 +189,7 @@ function OfficeMailContextBanner({
{bodyPreview}
</div>
)}
{!bodySent && (
{!bodySent && hasBody && (
<div className="mt-0.5 text-[11px] text-amber-600">Email body will not be sent.</div>
)}
</div>
Expand Down
1 change: 1 addition & 0 deletions client/src/features/office/hooks/useOfficeChatAdapter.js
View file Open in desktop
Original file line number Diff line number Diff line change
Expand Up @@ -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
});
Expand Down
36 changes: 32 additions & 4 deletions client/src/features/office/hooks/useOutlookMailContextSnapshot.js
View file Open in desktop
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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.
*/
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 : [];
Expand All @@ -145,6 +171,8 @@ export function useOutlookMailContextSnapshot() {
buildSnapshotOverride,
includeBody,
setIncludeBody,
useSelection,
setUseSelection,
generation
};
}
Expand Down
13 changes: 11 additions & 2 deletions client/src/features/office/utilities/buildChatApiMessages.js
View file Open in desktop
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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();
Expand All @@ -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}`);
}
Expand Down
Loading
Loading

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