diff --git a/backend/erato.template.toml b/backend/erato.template.toml index ee555772..bc9f101a 100644 --- a/backend/erato.template.toml +++ b/backend/erato.template.toml @@ -456,13 +456,19 @@ chat_provider_ids = ["test-token-limit"] # display_name = "Outlook Appointment Scheduling" # platform = "outlook" # tool_call_allowlist = ["outlook/*"] -# # Do not declare client_actions here until the add-in implements the action -# # (ERMAIN-387): the model would announce a confirmation the UI never shows. +# # Confirm path: once the user picks a slot, the model emits the appointment +# # as an erato-appointment fenced JSON block and proposes this action; the +# # add-in confirms and opens a prefilled appointment form (nothing is created +# # server-side). The three keys travel together (startup validation). +# client_actions = ["outlook.create_appointment"] +# presentation = "auto_prompt" +# client_actions_always_ask = ["outlook.create_appointment"] # allowed_args = ["now_iso", "timezone"] # template = """ # FOR THIS MESSAGE ONLY: +# returned data in the user's local time zone, present valid free slots, and on +# a pick emit one erato-appointment JSON block + one propose_client_action call> # """ # Experimental facets feature configuration diff --git a/frontend/src/components/ui/Message/MessageContent.test.tsx b/frontend/src/components/ui/Message/MessageContent.test.tsx index faa36b55..65a47a47 100644 --- a/frontend/src/components/ui/Message/MessageContent.test.tsx +++ b/frontend/src/components/ui/Message/MessageContent.test.tsx @@ -7,6 +7,7 @@ import { THEME_MODE_LOCAL_STORAGE_KEY, ThemeProvider, } from "@/components/providers/ThemeProvider"; +import { componentRegistry } from "@/config/componentRegistry"; import { messages as enMessages } from "@/locales/en/messages.json"; import { StaticFeatureConfigProvider } from "@/providers/FeatureConfigProvider"; import { FileTypeUtil } from "@/utils/fileTypes"; @@ -941,6 +942,90 @@ describe("MessageContent", () => { expect(screen.getByRole("button", { name: /Copy/ })).toBeInTheDocument(); }); + it("does NOT whole-body-fallback for an action facet without a bodyFormat", () => { + renderWithTheme( + , + ); + + // Scheduling prose is prose — no bodyFormat means no insertable email, + // even though the facet proposed a (non-email) client action. + expect(screen.queryByRole("button", { name: /Copy/ })).toBeNull(); + expect(screen.getByText(/Tuesday 10:00 works well/).tagName).toBe("P"); + }); + + it("does NOT rescue drifted email tags for an action facet without a bodyFormat", () => { + const { container } = renderWithTheme( + , + ); + + // A generic fence in scheduling prose stays an ordinary code block. + expect( + container.querySelector("pre.message-content-code-block code"), + ).toHaveTextContent("some quoted schedule data"); + }); + + it("dispatches erato-appointment fences to a registered renderer", () => { + const original = componentRegistry.EratoAppointmentCodeBlock; + function AppointmentBlockStub({ content }: { content: string }) { + return
{content}
; + } + componentRegistry.EratoAppointmentCodeBlock = AppointmentBlockStub; + try { + const { container } = renderWithTheme( + , + ); + + expect(screen.getByTestId("appointment-block")).toHaveTextContent( + '"start":"2026-07-09T10:00:00+02:00"', + ); + expect( + container.querySelector("pre.message-content-code-block"), + ).toBeNull(); + } finally { + componentRegistry.EratoAppointmentCodeBlock = original; + } + }); + + it("keeps erato-appointment fences as plain code blocks without a registered renderer", () => { + const { container } = renderWithTheme( + , + ); + + expect( + container.querySelector("pre.message-content-code-block code"), + ).toHaveTextContent('"start"'); + }); + it("does NOT whole-body-fallback for review_draft (feedback stays markdown)", () => { renderWithTheme( & { node?: unknown; }; -function isEratoEmailCodeChild( +/** + * The `erato-appointment` fence renders as a card only when a host registered + * a renderer for it (the Outlook add-in); otherwise it stays a plain code + * block, exactly like before the registry key existed. + */ +function isEratoAppointmentLanguage(language: string): boolean { + return ( + language === "erato-appointment" && + componentRegistry.EratoAppointmentCodeBlock !== null + ); +} + +function isCardCodeChild( children: React.ReactNode, artifact: OutlookArtifact | null, ): boolean { @@ -167,7 +184,10 @@ function isEratoEmailCodeChild( const match = /language-([\w-]+)/.exec(cls); const language = match ? match[1] : ""; // Only isEmail is consumed here; it doesn't depend on content. - return classifyEratoEmailBlock(language, artifact, "").isEmail; + return ( + classifyEratoEmailBlock(language, artifact, "").isEmail || + isEratoAppointmentLanguage(language) + ); } function MarkdownPre({ @@ -201,11 +221,11 @@ function MarkdownPre({ .catch(() => {}); }, [codeContent]); - // erato-email blocks render a custom component, not a code block — - // use a plain
to avoid inheriting
 monospace font and
-  // horizontal scroll from message-content-code-block styling.
+  // erato-email / erato-appointment blocks render a custom component, not a
+  // code block — use a plain 
to avoid inheriting
 monospace font
+  // and horizontal scroll from message-content-code-block styling.
   try {
-    if (isEratoEmailCodeChild(children, artifact)) {
+    if (isCardCodeChild(children, artifact)) {
       return (
         
@@ -279,6 +299,11 @@ function MarkdownCode({ ); } + const AppointmentBlock = componentRegistry.EratoAppointmentCodeBlock; + if (isBlockCode && AppointmentBlock && isEratoAppointmentLanguage(language)) { + return ; + } + if (isBlockCode) { return ( 0 && diff --git a/frontend/src/config/componentRegistry.ts b/frontend/src/config/componentRegistry.ts index 1971d371..29a9b2ff 100644 --- a/frontend/src/config/componentRegistry.ts +++ b/frontend/src/config/componentRegistry.ts @@ -42,6 +42,14 @@ export interface EratoEmailCodeBlockProps { isHtml?: boolean; } +export interface EratoAppointmentCodeBlockProps { + /** + * The raw text inside the erato-appointment code block: the JSON appointment + * payload the model emitted (may be incomplete while streaming). + */ + content: string; +} + /** * Props for a host-contributed section rendered inside the unified chat "+" * add menu (e.g. the Outlook add-in's email-content sources). The shared menu @@ -173,6 +181,17 @@ export interface ComponentRegistry { * Copy button. */ EratoEmailCodeBlock: ComponentType | null; + + /** + * Override for rendering `erato-appointment` fenced code blocks (the JSON + * appointment payload a scheduling facet's confirm step emits). + * + * Used by the Office addin to render the appointment summary + confirm + * card that opens a prefilled Outlook appointment form. + * + * When null, `erato-appointment` blocks render as a plain code block. + */ + EratoAppointmentCodeBlock: ComponentType | null; } type ComponentRegistryComponent = Exclude< @@ -218,6 +237,7 @@ const emptyComponentRegistry = (): ComponentRegistry => ({ ChatMessageRenderer: null, ChatTopLeftAccessory: null, EratoEmailCodeBlock: null, + EratoAppointmentCodeBlock: null, }); const buildComponentRegistry = ( diff --git a/frontend/src/library/index.ts b/frontend/src/library/index.ts index 35c66868..d24a8a9c 100644 --- a/frontend/src/library/index.ts +++ b/frontend/src/library/index.ts @@ -210,6 +210,7 @@ export { type ComponentKitComponentRegistration, type ComponentKitRegistration, type EratoEmailCodeBlockProps, + type EratoAppointmentCodeBlockProps, type ChatAddMenuExtraContentProps, } from "@/config/componentRegistry"; export { diff --git a/frontend/src/types/chat.ts b/frontend/src/types/chat.ts index 6895858e..0ea52edd 100644 --- a/frontend/src/types/chat.ts +++ b/frontend/src/types/chat.ts @@ -43,8 +43,16 @@ export interface MessageError { export interface OutlookArtifact { /** The action facet id that produced this assistant message (free-form). */ facetId: string; - /** Output format the facet requested; drives HTML-vs-text rendering. */ - bodyFormat: "text" | "html"; + /** + * Output format the facet requested; drives HTML-vs-text rendering. Present + * exactly when the facet's output IS an email body (it carried a + * `body_format` arg) — everything email-shaped keys off it: the drifted + * fence-tag rescue and the unfenced whole-body email card. Absent for + * facets whose output is prose that may carry action fences (e.g. a + * scheduling facet's `erato-appointment` block), which must never be + * treated as an insertable email. + */ + bodyFormat?: "text" | "html"; /** * How to treat the assistant's output: * - `"body"`: the whole response is a single insertable email body, so an diff --git a/office-addin/pnpm-lock.yaml b/office-addin/pnpm-lock.yaml index 422e3d88..28db4e82 100644 --- a/office-addin/pnpm-lock.yaml +++ b/office-addin/pnpm-lock.yaml @@ -322,7 +322,7 @@ packages: hasBin: true '@erato/frontend@file:../frontend/dist-package/erato-frontend.tgz': - resolution: {integrity: sha512-9i6lm82nP9sReWVjkKNpv9NFMWFwjybslyoL38nnxy6syd+E7KcPtOAvO+qz0nuEDFXdkiPtE0Iz7cWHdzlmiw==, tarball: file:../frontend/dist-package/erato-frontend.tgz} + resolution: {integrity: sha512-1ePXB/iUDyNR9oMMCnCz3NG9EufI+26hL9u8Lw1RBBDBfmi4lWAi+YQQbULVhSLvUnQWSdqRanIpjgUR0DG6TA==, tarball: file:../frontend/dist-package/erato-frontend.tgz} version: 0.1.0 peerDependencies: '@lingui/core': ^5.9.4 diff --git a/office-addin/src/components/AddinChat.tsx b/office-addin/src/components/AddinChat.tsx index fa3d257f..2c06b03c 100644 --- a/office-addin/src/components/AddinChat.tsx +++ b/office-addin/src/components/AddinChat.tsx @@ -56,10 +56,7 @@ import { OUTLOOK_GRAPH_MESSAGE_TIMEOUT_MS, runWithGraphTimeout, } from "../utils/graphRequestTimeout"; -import { - computeShouldRenderEmailCard, - extractProposedClientAction, -} from "../utils/outlookClientActions"; +import { buildOutlookArtifact } from "../utils/outlookClientActions"; import { containsFetchAvailabilityToolUse } from "../utils/outlookScheduleTool"; import { parseDroppedFiles } from "../utils/parseDroppedFiles"; import { parseEmlBytes } from "../utils/parsedEmail"; @@ -775,13 +772,18 @@ export function AddinChat({ assistantId }: AddinChatProps = {}) { // user message carried an Outlook action facet (resolved via // `previous_message_id`). The shared renderer uses it to show the // insert/replace email UI even when the model drifted or omitted the - // `erato-email` fence tag. + // `erato-email` fence tag, and the fence renderers use it for client-action + // proposals. // - // Convention-driven, NOT an id allowlist: any Outlook action facet carries a - // `body_format` arg, so a facet added only in erato.toml (e.g. `compose_email`) - // renders here with no add-in change. `renderMode` defaults to "body" (whole - // response is an insertable email); only review/critique facets opt into - // "suggestions" (fenced blocks render, unfenced prose stays markdown). + // Convention-driven, NOT an id allowlist: an email-bodied facet qualifies + // by its `body_format` arg, an action facet by its backend-advertised + // `client_actions` — so a facet added only in erato.toml (e.g. + // `compose_email`, `outlook_schedule`) renders here with no add-in change. + // The full decision lives in `buildOutlookArtifact`; `itemIdentity` is the + // SEND-time identity, and fresh implies it is known: completions whose + // identity is unknown (capture failed, edit/regenerate of an exchange this + // session no longer knows, ambiguous multi-id completion) were never added + // to `freshMessageIds`, so they render as history-like drafts. const clientActionsByFacetId = useActionFacetClientActions(); const messagesWithArtifact = useMemo(() => { @@ -797,66 +799,25 @@ export function AddinChat({ assistantId }: AddinChatProps = {}) { } const previous = messages[message.previous_message_id]; const facetId = previous?.action_facet_id; - const facetBodyFormat = previous?.action_facet_args?.body_format; - if ( - !facetId || - (facetBodyFormat !== "text" && facetBodyFormat !== "html") - ) { + const outlookArtifact = buildOutlookArtifact({ + facetId, + facetArgs: previous?.action_facet_args, + clientActionInfo: facetId + ? clientActionsByFacetId.get(facetId) + : undefined, + content: message.content, + messageId: id, + freshItemIdentity: freshMessageIds.has(id) + ? freshItemIdentityRef.current.get(id)! + : undefined, + }); + if (!outlookArtifact) { continue; } - const renderMode = - facetId === "outlook_review_draft" ? "suggestions" : "body"; - // Client actions (e.g. reply / reply-all from read mode): the offerable - // set comes from the backend facet config, and the model's proposal is - // only stamped after revalidation against that set + tool-call status — - // never parsed out of message text. - const clientActionInfo = clientActionsByFacetId.get(facetId); - const allowedClientActions = clientActionInfo?.clientActions; - const proposedClientAction = allowedClientActions - ? extractProposedClientAction(message.content, allowedClientActions) - : undefined; - // Single source of truth for carding (see OutlookArtifact.shouldRenderEmailCard). - // Stamped below only when it SUPPRESSES (false); absent ⇒ cards. - const shouldRenderEmailCard = computeShouldRenderEmailCard({ - allowedClientActions, - proposedClientAction, - }); if (next === messages) { next = { ...messages }; } - next[id] = { - ...message, - outlookArtifact: { - facetId, - bodyFormat: facetBodyFormat, - renderMode, - messageId: id, - ...(allowedClientActions ? { allowedClientActions } : {}), - ...(renderMode === "body" && !shouldRenderEmailCard - ? { shouldRenderEmailCard: false } - : {}), - ...(clientActionInfo && clientActionInfo.alwaysAskActions.length > 0 - ? { alwaysAskClientActions: clientActionInfo.alwaysAskActions } - : {}), - ...(proposedClientAction ? { proposedClientAction } : {}), - ...(clientActionInfo?.presentation - ? { clientActionPresentation: clientActionInfo.presentation } - : {}), - // `itemIdentity` is the SEND-time identity, and fresh implies it - // is known: completions whose identity is unknown (capture - // failed, edit/regenerate of an exchange this session no longer - // knows, ambiguous multi-id completion) were never added to - // `freshMessageIds`, so they render as history-like drafts. The - // renderer still fails closed on a fresh-but-identity-less - // artifact, but this component no longer produces that shape. - ...(freshMessageIds.has(id) - ? { - isFreshCompletion: true, - itemIdentity: freshItemIdentityRef.current.get(id)!, - } - : {}), - }, - }; + next[id] = { ...message, outlookArtifact }; } return next; }, [messages, messageOrder, clientActionsByFacetId, freshMessageIds]); diff --git a/office-addin/src/components/AddinChatInput.tsx b/office-addin/src/components/AddinChatInput.tsx index a38cdbcc..6a5bb827 100644 --- a/office-addin/src/components/AddinChatInput.tsx +++ b/office-addin/src/components/AddinChatInput.tsx @@ -22,7 +22,10 @@ import { useOutlookCalendarFetcher } from "../hooks/useOutlookCalendarFetcher"; import { useOutlookComposeSelection } from "../hooks/useOutlookComposeSelection"; import { useOffice } from "../providers/OfficeProvider"; import { useOutlookEmailSource } from "../providers/OutlookEmailSourceProvider"; -import { useOutlookMailItem } from "../providers/OutlookMailItemProvider"; +import { + NO_ITEM_SEND_IDENTITY, + useOutlookMailItem, +} from "../providers/OutlookMailItemProvider"; import { resolveOutlookActionFacet } from "../utils/outlookActionFacet"; import { OUTLOOK_REPLY_FROM_READ_FACET_ID } from "../utils/outlookClientActions"; import { getComposeBodyType } from "../utils/outlookComposeWrite"; @@ -646,8 +649,12 @@ export const AddinChatInput = forwardRef< ) => { // Capture the item identity BEFORE any await: the uploads below can // take long enough for the user to switch emails, and the wrong-item - // guard must be anchored to the item the user actually sent from. - const sendItemIdentity = itemIdentity; + // guard must be anchored to the item the user actually sent from. A + // send with NO item open records the no-item sentinel — a real value, + // so neutral-context completions still count as fresh (item-independent + // actions like create-appointment can auto-prompt) while item-bound + // executors treat it as a mismatch and fail closed. + const sendItemIdentity = itemIdentity ?? NO_ITEM_SEND_IDENTITY; // Build the action facet (selection rewrite vs. draft review) via a pure // resolver so the selection-priority and draft de-dup rules stay testable. diff --git a/office-addin/src/components/OutlookEratoAppointmentRenderer.tsx b/office-addin/src/components/OutlookEratoAppointmentRenderer.tsx new file mode 100644 index 00000000..176694ba --- /dev/null +++ b/office-addin/src/components/OutlookEratoAppointmentRenderer.tsx @@ -0,0 +1,406 @@ +import { + ActionConfirmationCard, + useOutlookArtifact, + usePersistedState, +} from "@erato/frontend/library"; +import { t } from "@lingui/core/macro"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { + ACTION_BUTTON_CLASS, + PRIMARY_ACTION_BUTTON_CLASS, +} from "./clientActionButtonStyles"; +import { useClientActionConfirmFlow } from "../hooks/useClientActionConfirmFlow"; +import { useOutlookMailItem } from "../providers/OutlookMailItemProvider"; +import { + CLIENT_ACTION_DECISIONS_KEY, + DEFAULT_CLIENT_ACTION_DECISIONS, + clientActionDecisionsPersistedOptions, + decisionKey, + isActionDenied, + resolveClickBehavior, +} from "../utils/clientActionPolicy"; +import { + clientActionDisplayLabel, + offerableAppointmentClientActions, + type OutlookAppointmentClientAction, +} from "../utils/outlookClientActions"; +import { + isCreateAppointmentSupported, + openNewAppointmentForm, + parseAppointmentDetails, + type AppointmentDetails, +} from "../utils/outlookCreateAppointment"; + +import type { EratoAppointmentCodeBlockProps } from "@erato/frontend/library"; + +const DATE_TIME_FORMAT: Intl.DateTimeFormatOptions = { + weekday: "short", + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", +}; + +/** + * The appointment's time range for the summary and the confirmation card, + * rendered in the runtime's local zone — which is the zone the facet template + * instructed the model to emit (`{{timezone}}` is captured from the same + * runtime at send time). + */ +function formatAppointmentWhen(details: AppointmentDetails): string { + const start = new Date(details.start); + const end = new Date(details.end); + const startText = start.toLocaleString(undefined, DATE_TIME_FORMAT); + const sameDay = start.toDateString() === end.toDateString(); + const endText = sameDay + ? end.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }) + : end.toLocaleString(undefined, DATE_TIME_FORMAT); + return `${startText} – ${endText}`; +} + +/** Combined required + optional attendees as a comma-separated string. */ +function formatAttendees(details: AppointmentDetails): string { + return [...details.attendees, ...(details.optionalAttendees ?? [])].join( + ", ", + ); +} + +/** + * Office-aware renderer for erato-appointment code blocks (the JSON payload + * the scheduling facet's confirm step emits). Registered via componentRegistry + * in main.tsx. + * + * Renders the parsed appointment as a summary card. When the producing facet + * allows `outlook.create_appointment` (from `GET /me/facets`, intersected with + * the add-in's fixed registry) and the host supports it (Mailbox 1.1, not + * Outlook iOS/Android), an "Open appointment" button opens Outlook's native + * new-appointment form prefilled from the parsed payload — the user reviews + * and Saves/Sends there; nothing is ever created by the add-in itself. + * + * Unlike the reply flow, execution is deliberately NOT bound to the open + * Outlook item: the whole payload lives in this fence, and + * `displayNewAppointmentForm` sits on the mailbox (not the item), so it works + * identically in read, compose, and no-item contexts and cannot act on "the + * wrong email". The auto-prompt freshness/identity guards still apply — they + * gate SURFACING, not payload correctness. + * + * While the fence is still streaming (or the model emitted malformed JSON) + * the payload doesn't parse; the raw content renders as a muted block with no + * actions, and the completed message re-renders into the card. + */ +export function OutlookEratoAppointmentRenderer({ + content, +}: EratoAppointmentCodeBlockProps) { + const { itemIdentity } = useOutlookMailItem(); + const artifact = useOutlookArtifact(); + const [decisions, setDecisions] = usePersistedState( + CLIENT_ACTION_DECISIONS_KEY, + DEFAULT_CLIENT_ACTION_DECISIONS, + clientActionDecisionsPersistedOptions, + ); + const facetId = artifact?.facetId ?? ""; + const enforcedAskActions = useMemo( + () => artifact?.alwaysAskClientActions ?? [], + [artifact], + ); + const details = useMemo(() => parseAppointmentDetails(content), [content]); + + const offeredActions = useMemo( + () => + // The capability check is host-static for the session (see + // isCreateAppointmentSupported), so reading it inside this memo cannot + // go stale the way a live-item check would. + details && isCreateAppointmentSupported() + ? offerableAppointmentClientActions( + artifact?.allowedClientActions, + ).filter( + (action) => + !isActionDenied({ + facetId, + action, + decisions, + enforcedAskActions, + }), + ) + : [], + [details, artifact, facetId, decisions, enforcedAskActions], + ); + const proposedAction = + artifact?.proposedClientAction && + (offeredActions as string[]).includes(artifact.proposedClientAction) + ? (artifact.proposedClientAction as OutlookAppointmentClientAction) + : undefined; + + const [status, setStatus] = useState<"idle" | "opening" | "done" | "error">( + "idle", + ); + const isBusy = status === "opening"; + + // Single pending status-reset timer: a new schedule cancels the previous + // one, and unmount cancels outright. + const statusResetRef = useRef | null>(null); + const scheduleStatusReset = useCallback((delayMs: number) => { + if (statusResetRef.current) { + clearTimeout(statusResetRef.current); + } + statusResetRef.current = setTimeout(() => setStatus("idle"), delayMs); + }, []); + useEffect( + () => () => { + if (statusResetRef.current) { + clearTimeout(statusResetRef.current); + } + }, + [], + ); + + /** Resolves `true` only when the appointment form actually opened. */ + const executeAppointment = useCallback(async (): Promise => { + if (!details || !isCreateAppointmentSupported()) { + setStatus("error"); + scheduleStatusReset(4000); + return false; + } + setStatus("opening"); + try { + await openNewAppointmentForm(details); + setStatus("done"); + scheduleStatusReset(2000); + return true; + } catch (err) { + console.warn("Failed to open the new appointment form:", err); + setStatus("error"); + scheduleStatusReset(4000); + return false; + } + }, [details, scheduleStatusReset]); + + const buildSummary = useCallback(() => details, [details]); + + // Shared confirm-card state machine + auto-prompt one-shot. The summary is + // the parsed fence payload — exactly what the form will be prefilled with. + // create_appointment is item-independent (payload lives in the fence, form + // sits on the mailbox), so passing the send-time identity as the current one + // makes the identity gate a no-op; freshness/latest/once-per-message still + // bound WHEN it auto-prompts. Also covers unsaved-compose, whose minted + // identity re-mints on every re-resolve and could never match itself. + const { + confirmCard, + isConfirmPending, + requestConfirmation, + allowCard, + denyCard, + } = useClientActionConfirmFlow< + AppointmentDetails, + OutlookAppointmentClientAction + >({ + promptScope: "appointment", + facetId: artifact?.facetId, + decisions, + enforcedAskActions, + buildSummary, + execute: executeAppointment, + itemIdentity, + presentation: artifact?.clientActionPresentation, + messageId: artifact?.messageId, + isFreshCompletion: !!artifact?.isFreshCompletion, + proposedAction, + expectedItemIdentity: artifact?.itemIdentity, + currentItemIdentity: artifact?.itemIdentity, + }); + + const handleActionClick = useCallback( + (action: OutlookAppointmentClientAction) => { + if ( + resolveClickBehavior({ + facetId, + action, + decisions, + enforcedAskActions, + }) === "execute" + ) { + void executeAppointment(); + return; + } + if (!requestConfirmation(action)) { + setStatus("error"); + scheduleStatusReset(4000); + } + }, + [ + decisions, + enforcedAskActions, + executeAppointment, + facetId, + requestConfirmation, + scheduleStatusReset, + ], + ); + + // Streaming / malformed payload: no actionable card, keep the raw text + // visible but unstyled as an artifact. + if (!details) { + return ( +
+
+          {content}
+        
+
+ ); + } + + const when = formatAppointmentWhen(details); + const attendeesText = formatAttendees(details); + + return ( +
+
+
+
+ {t({ + id: "officeAddin.appointmentRenderer.subject", + message: "Subject:", + })} +
+
+ {details.subject || + t({ + id: "officeAddin.appointmentRenderer.noSubject", + message: "(none)", + })} +
+
+
+
+ {t({ + id: "officeAddin.appointmentRenderer.when", + message: "When:", + })} +
+
+ {when} +
+
+ {attendeesText && ( +
+
+ {t({ + id: "officeAddin.appointmentRenderer.attendees", + message: "Attendees:", + })} +
+
+ {attendeesText} +
+
+ )} + {details.location && ( +
+
+ {t({ + id: "officeAddin.appointmentRenderer.location", + message: "Location:", + })} +
+
+ {details.location} +
+
+ )} +
+ {offeredActions.length > 0 && ( +
+ {offeredActions.map((action) => ( + + ))} +
+ )} + {status === "error" && ( +

+ {t({ + id: "officeAddin.appointmentRenderer.openFailed", + message: + "Failed to open the appointment form. You can create the appointment manually in Outlook.", + })} +

+ )} + {confirmCard && ( + +

+ {clientActionDisplayLabel(confirmCard.action)} +

+

+ {t({ + id: "officeAddin.appointmentRenderer.confirmMessage", + message: + "This opens a prefilled appointment form in Outlook. Nothing is saved or sent until you do it there.", + })} +

+

+ {formatAppointmentWhen(confirmCard.summary)} + {confirmCard.summary.attendees.length > 0 || + (confirmCard.summary.optionalAttendees?.length ?? 0) > 0 + ? ` · ${formatAttendees(confirmCard.summary)}` + : ""} +

+
+ } + onAllowOnce={() => allowCard(confirmCard)} + onAlwaysAllow={() => { + // Persist the grant for THIS facet + action, then execute. The + // store is the source the settings page mirrors and revises. + setDecisions({ + ...decisions, + [decisionKey(facetId, confirmCard.action)]: "always", + }); + allowCard(confirmCard); + }} + alwaysAllowDisabledReason={ + enforcedAskActions.includes(confirmCard.action) + ? t({ + id: "officeAddin.appointmentRenderer.alwaysAllowLocked", + message: + "Your organization requires confirmation for this action every time.", + }) + : undefined + } + onDeny={() => denyCard(confirmCard)} + isBusy={isBusy} + scrollIntoViewOnMount={confirmCard.autoTriggered} + /> + )} +
+ ); +} diff --git a/office-addin/src/components/OutlookEratoEmailRenderer.tsx b/office-addin/src/components/OutlookEratoEmailRenderer.tsx index 1f9ac757..ea9c6b55 100644 --- a/office-addin/src/components/OutlookEratoEmailRenderer.tsx +++ b/office-addin/src/components/OutlookEratoEmailRenderer.tsx @@ -2,14 +2,18 @@ import { ActionConfirmationCard, copyEmailToClipboard, sanitizeHtmlPreview, - useChatContext, useOutlookArtifact, usePersistedState, } from "@erato/frontend/library"; import { plural, t } from "@lingui/core/macro"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + ACTION_BUTTON_CLASS, + PRIMARY_ACTION_BUTTON_CLASS, +} from "./clientActionButtonStyles"; import { useComposeSelectionSnapshot } from "../hooks/composeSelectionStore"; +import { useClientActionConfirmFlow } from "../hooks/useClientActionConfirmFlow"; import { useOutlookMailItem } from "../providers/OutlookMailItemProvider"; import { CLIENT_ACTION_DECISIONS_KEY, @@ -17,13 +21,12 @@ import { clientActionDecisionsPersistedOptions, decisionKey, isActionDenied, - resolveAutoPromptBehavior, resolveClickBehavior, } from "../utils/clientActionPolicy"; import { clientActionDisplayLabel, - offerableClientActions, - type OutlookClientAction, + offerableEmailClientActions, + type OutlookEmailClientAction, } from "../utils/outlookClientActions"; import { replaceComposeSelection } from "../utils/outlookComposeWrite"; import { @@ -36,46 +39,6 @@ import { import type { EratoEmailCodeBlockProps } from "@erato/frontend/library"; -const ACTION_BUTTON_CLASS = - "rounded-md border border-theme-border bg-theme-bg-primary px-3 py-1 text-xs hover:bg-theme-bg-tertiary disabled:opacity-50"; -// Mirrors the library Button "primary" variant tokens (Button.tsx) at the -// compact geometry of the sibling action buttons. -const PRIMARY_ACTION_BUTTON_CLASS = - "rounded-md px-3 py-1 text-xs font-medium bg-theme-action-primary-bg text-theme-action-primary-fg hover:bg-theme-action-primary-hover theme-transition disabled:opacity-50"; - -/** - * Auto-prompt fires at most once per assistant message, across remounts - * (virtualized lists unmount/remount renderers) and across multiple fences - * within one message. Module-level by design. - */ -const firedAutoPrompts = new Set(); - -interface ConfirmCardState { - /** - * Monotonic per-request id: a new confirmation replaces a resolved card - * and remounts it (fresh scroll/focus), and an async resolution only - * lands on the card it was started from. - */ - requestId: number; - action: OutlookClientAction; - recipients: ReadModeRecipientSummary; - /** Surfaced by auto-prompt (scrolls into view) rather than a click. */ - autoTriggered: boolean; - /** - * Item identity when the card opened — the item the shown recipients - * were read from. Execution re-checks it so confirming a card opened on - * email A can never open a reply on email B, independent of whether the - * artifact carries an identity (history drafts don't). - */ - itemIdentityAtOpen: string | null; - /** - * `pending` renders the decision buttons; afterwards the card stays - * mounted as a visible record of the outcome (allow → `opened` or - * `failed`, deny → `denied`). - */ - resolution: "pending" | "opened" | "denied" | "failed"; -} - /** * Office-aware renderer for erato-email code blocks. * Registered via componentRegistry in main.tsx so the shared MessageContent @@ -132,7 +95,7 @@ export function OutlookEratoEmailRenderer({ [artifact], ); - const readActions = useMemo( + const readActions = useMemo( () => // Gate on the REACTIVE read-item signal (`isReadMode`, synced to the live // item by OutlookMailItemProvider) plus the host-static capability check @@ -141,8 +104,9 @@ export function OutlookEratoEmailRenderer({ // was momentarily unavailable (e.g. a pinned-pane reload), permanently // hiding the reply buttons. Execution re-checks the live item in // openReplyForm, so offering optimistically here fails closed on click. + // EMAIL kind only: an appointment action is never a reply button. isReadMode && isReplyFormHostSupported() - ? offerableClientActions(artifact?.allowedClientActions).filter( + ? offerableEmailClientActions(artifact?.allowedClientActions).filter( (action) => !isActionDenied({ facetId, @@ -157,7 +121,7 @@ export function OutlookEratoEmailRenderer({ const proposedAction = artifact?.proposedClientAction && (readActions as string[]).includes(artifact.proposedClientAction) - ? (artifact.proposedClientAction as OutlookClientAction) + ? (artifact.proposedClientAction as OutlookEmailClientAction) : undefined; const [status, setStatus] = useState< @@ -166,13 +130,15 @@ export function OutlookEratoEmailRenderer({ const [errorKind, setErrorKind] = useState< "insert" | "reply" | "tooLarge" | "staleItem" >("insert"); - const [busyAction, setBusyAction] = useState( + const [busyAction, setBusyAction] = useState( null, ); - const [confirmCard, setConfirmCard] = useState(null); - const confirmRequestIdRef = useRef(0); + // Which reply button flips to the ~2s "Opened!" swap — the add-in's + // standard transient success feedback (like Copy's "Copied!"). Only + // meaningful while `status` is "done"; overwritten by the next open. + const [openedAction, setOpenedAction] = + useState(null); const isBusy = status === "inserting"; - const isConfirmPending = confirmCard?.resolution === "pending"; const previewHtml = useMemo( () => (isHtml ? sanitizeHtmlPreview(content) : null), [content, isHtml], @@ -221,7 +187,7 @@ export function OutlookEratoEmailRenderer({ /** Resolves `true` only when the reply form actually opened. */ const executeReply = useCallback( async ( - action: OutlookClientAction, + action: OutlookEmailClientAction, /** * Identity snapshotted when the confirmation card opened. `undefined` * for direct (card-less) executions; when provided it must still match @@ -245,6 +211,7 @@ export function OutlookEratoEmailRenderer({ setStatus("inserting"); try { await openReplyForm(action, content, !!isHtml); + setOpenedAction(action); setStatus("done"); scheduleStatusReset(2000); return true; @@ -269,46 +236,41 @@ export function OutlookEratoEmailRenderer({ ], ); - // Open the inline confirmation card with recipients RE-READ from the - // current item — the user confirms against fresh data, not against - // whatever the chat message was generated from. Replaces a resolved card. - const requestConfirmation = useCallback( - (action: OutlookClientAction, autoTriggered = false): boolean => { - const recipients = getReadModeRecipientSummary(); - if (!recipients) { - return false; - } - confirmRequestIdRef.current += 1; - setConfirmCard({ - requestId: confirmRequestIdRef.current, - action, - recipients, - autoTriggered, - itemIdentityAtOpen: itemIdentity, - resolution: "pending", - }); - return true; - }, - [itemIdentity], - ); - - // Allow paths: execute, then resolve THIS card into its record state — a - // newer confirmation may have replaced it while the form was opening. - const handleCardAllow = useCallback( - (card: ConfirmCardState) => { - void executeReply(card.action, card.itemIdentityAtOpen).then((opened) => { - setConfirmCard((current) => - current?.requestId === card.requestId - ? { ...current, resolution: opened ? "opened" : "failed" } - : current, - ); - }); - }, - [executeReply], - ); + // Shared confirm-card state machine + auto-prompt one-shot. The summary is + // the recipients RE-READ from the current item when the card opens; the + // auto-prompt only fires under the facet's `auto_prompt` presentation, for + // a validated proposal on a FRESH completion that is still the latest + // assistant message and whose send-time item is still the open one, at most + // once per message, and still through the user's approval preference. If + // the user meanwhile left read mode, the summary snapshot fails and nothing + // happens — the buttons remain as fallback. + const { + confirmCard, + isConfirmPending, + requestConfirmation, + allowCard, + denyCard, + } = useClientActionConfirmFlow< + ReadModeRecipientSummary, + OutlookEmailClientAction + >({ + promptScope: "email", + facetId: artifact?.facetId, + decisions, + enforcedAskActions, + buildSummary: getReadModeRecipientSummary, + execute: executeReply, + itemIdentity, + presentation: artifact?.clientActionPresentation, + messageId: artifact?.messageId, + isFreshCompletion: !!artifact?.isFreshCompletion, + proposedAction, + expectedItemIdentity, + currentItemIdentity: itemIdentity, + }); const handleReplyAction = useCallback( - (action: OutlookClientAction) => { + (action: OutlookEmailClientAction) => { if (isStaleForCurrentItem) { // Don't open a confirmation that would show the NEW email's // recipients for a draft written against the old one. @@ -341,70 +303,6 @@ export function OutlookEratoEmailRenderer({ ], ); - // Auto-prompt: only under the facet's `auto_prompt` presentation, only for - // a validated proposal on a FRESH completion (stamped by AddinChat — never - // history reloads) that is still the latest assistant message and whose - // send-time item is still the open one, at most once per message, and - // still through the user's approval preference ("don't ask" opens the - // form, "ask" surfaces the inline confirmation card). If the user - // meanwhile left read mode, requestConfirmation returns false and nothing - // happens — the buttons remain as fallback. - const { messages, messageOrder } = useChatContext(); - const autoPromptMessageId = artifact?.messageId; - const isFreshCompletion = !!artifact?.isFreshCompletion; - const isLatestAssistantMessage = useMemo(() => { - if (!autoPromptMessageId) { - return false; - } - for (let index = messageOrder.length - 1; index >= 0; index -= 1) { - const id = messageOrder[index]; - if (messages[id]?.role === "assistant") { - return id === autoPromptMessageId; - } - } - return false; - }, [autoPromptMessageId, messages, messageOrder]); - const autoPromptBehavior = resolveAutoPromptBehavior({ - presentation: artifact?.clientActionPresentation, - facetId: artifact?.facetId, - proposedAction, - isFreshCompletion, - isLatestAssistantMessage, - expectedItemIdentity, - currentItemIdentity: itemIdentity, - decisions, - enforcedAskActions, - }); - useEffect(() => { - if (!autoPromptMessageId || !isFreshCompletion) { - return; - } - if (firedAutoPrompts.has(autoPromptMessageId)) { - return; - } - // Consume the once-per-message slot on the FIRST evaluation for a fresh - // completion, regardless of the resolved behavior: a later settings flip - // (never → always allow) or a much later scroll-back mount must not - // resurrect the prompt. The failure direction is always a missed - // auto-open, never a late one — the buttons remain as fallback. - firedAutoPrompts.add(autoPromptMessageId); - if (autoPromptBehavior === "none" || !proposedAction) { - return; - } - if (autoPromptBehavior === "execute") { - void executeReply(proposedAction); - } else { - requestConfirmation(proposedAction, true); - } - }, [ - autoPromptBehavior, - autoPromptMessageId, - isFreshCompletion, - proposedAction, - executeReply, - requestConfirmation, - ]); - const handleCopy = useCallback(() => { void copyEmailToClipboard(content, isHtml ?? false) .then(() => { @@ -438,7 +336,7 @@ export function OutlookEratoEmailRenderer({ }); })(); - const replyActionLabel = (action: OutlookClientAction) => + const replyActionLabel = (action: OutlookEmailClientAction) => action === "outlook.reply_all" ? t({ id: "officeAddin.emailRenderer.replyAll", @@ -463,10 +361,8 @@ export function OutlookEratoEmailRenderer({ // the card lists — never a separately computed number that could drift. const confirmListedEntries = confirmCard ? [ - ...(confirmCard.recipients.sender - ? [confirmCard.recipients.sender] - : []), - ...(isConfirmingReplyAll ? confirmCard.recipients.recipients : []), + ...(confirmCard.summary.sender ? [confirmCard.summary.sender] : []), + ...(isConfirmingReplyAll ? confirmCard.summary.recipients : []), ] : []; const confirmRecipientCount = confirmListedEntries.length; @@ -502,7 +398,12 @@ export function OutlookEratoEmailRenderer({ id: "officeAddin.emailRenderer.opening", message: "Opening...", }) - : replyActionLabel(action)} + : status === "done" && openedAction === action + ? t({ + id: "officeAddin.emailRenderer.opened", + message: "Opened!", + }) + : replyActionLabel(action)} )) : !isReadMode && ( @@ -589,7 +490,7 @@ export function OutlookEratoEmailRenderer({

} - onAllowOnce={() => handleCardAllow(confirmCard)} + onAllowOnce={() => allowCard(confirmCard)} onAlwaysAllow={() => { // Persist the grant for THIS facet + action, then execute. The // store is the source the settings page mirrors and revises. @@ -597,7 +498,7 @@ export function OutlookEratoEmailRenderer({ ...decisions, [decisionKey(facetId, confirmCard.action)]: "always", }); - handleCardAllow(confirmCard); + allowCard(confirmCard); }} alwaysAllowDisabledReason={ enforcedAskActions.includes(confirmCard.action) @@ -608,38 +509,7 @@ export function OutlookEratoEmailRenderer({ }) : undefined } - onDeny={() => - setConfirmCard((current) => - current?.requestId === confirmCard.requestId - ? { ...current, resolution: "denied" } - : current, - ) - } - status={ - confirmCard.resolution === "pending" - ? "pending" - : confirmCard.resolution === "opened" - ? "confirmed" - : "dismissed" - } - resolvedLabel={ - confirmCard.resolution === "opened" - ? confirmCard.action === "outlook.reply_all" - ? t({ - id: "officeAddin.emailRenderer.replyAllFormOpened", - message: "Reply All form opened", - }) - : t({ - id: "officeAddin.emailRenderer.replyFormOpened", - message: "Reply form opened", - }) - : confirmCard.resolution === "failed" - ? t({ - id: "officeAddin.emailRenderer.replyFormNotOpened", - message: "Reply form could not be opened", - }) - : undefined - } + onDeny={() => denyCard(confirmCard)} isBusy={isBusy} scrollIntoViewOnMount={confirmCard.autoTriggered} /> diff --git a/office-addin/src/components/__tests__/OutlookEratoAppointmentRenderer.test.tsx b/office-addin/src/components/__tests__/OutlookEratoAppointmentRenderer.test.tsx new file mode 100644 index 00000000..cefe551b --- /dev/null +++ b/office-addin/src/components/__tests__/OutlookEratoAppointmentRenderer.test.tsx @@ -0,0 +1,340 @@ +import { i18n } from "@lingui/core"; +import { + act, + cleanup, + fireEvent, + render, + screen, +} from "@testing-library/react"; +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; + +import { OutlookEratoAppointmentRenderer } from "../OutlookEratoAppointmentRenderer"; + +// Mocked at the same seams as the email renderer's suite: the artifact + chat +// snapshot (what AddinChat stamps), the current Outlook item identity, the +// persisted decisions, and the Office.js form module — the tests only assert +// WHETHER the appointment form is reached, never how. The fence PARSER runs +// for real: the payload contract is part of what's under test. +const mockUseOutlookMailItem = vi.fn(); +const mockUseOutlookArtifact = vi.fn(); +const mockUseChatContext = vi.fn(); +const mockUsePersistedState = vi.fn(); +const mockOpenNewAppointmentForm = vi.fn(); +const mockIsCreateAppointmentSupported = vi.fn(); + +vi.mock("@erato/frontend/library", () => ({ + ActionConfirmationCard: (props: { + description?: unknown; + onAllowOnce: () => void; + onAlwaysAllow: () => void; + onDeny: () => void; + status?: string; + resolvedLabel?: string; + isBusy?: boolean; + }) => ( +
+
+ {props.description as never} +
+ {(props.status ?? "pending") === "pending" ? ( + <> + + + + + ) : ( + {props.resolvedLabel} + )} +
+ ), + useChatContext: () => mockUseChatContext(), + useOutlookArtifact: () => mockUseOutlookArtifact(), + usePersistedState: () => mockUsePersistedState(), +})); + +vi.mock("../../providers/OutlookMailItemProvider", () => ({ + NO_ITEM_SEND_IDENTITY: "no-item", + useOutlookMailItem: () => mockUseOutlookMailItem(), +})); + +vi.mock("../../utils/outlookCreateAppointment", async (importOriginal) => ({ + ...(await importOriginal()), + isCreateAppointmentSupported: () => mockIsCreateAppointmentSupported(), + openNewAppointmentForm: (...args: unknown[]) => + Promise.resolve(mockOpenNewAppointmentForm(...args)), +})); + +const FACET = "outlook_schedule"; +const CREATE = "outlook.create_appointment"; + +const DETAILS = { + start: "2026-07-09T10:00:00+02:00", + end: "2026-07-09T10:30:00+02:00", + subject: "Projekt-Sync", + attendees: ["alice@example.com"], +}; +const FENCE = JSON.stringify(DETAILS); + +interface TestArtifact { + facetId: string; + renderMode: "body" | "suggestions"; + messageId: string; + allowedClientActions: string[]; + alwaysAskClientActions?: string[]; + clientActionPresentation: string; + isFreshCompletion?: boolean; + itemIdentity?: string; + proposedClientAction?: string; +} + +// Unique per test: the once-per-message auto-prompt slot is module-level +// state that intentionally survives remounts (and thus tests). +let nextMessageId = 0; + +function makeArtifact(overrides: Partial = {}): TestArtifact { + nextMessageId += 1; + return { + facetId: FACET, + renderMode: "body", + messageId: `appt-msg-${nextMessageId}`, + allowedClientActions: [CREATE], + alwaysAskClientActions: [CREATE], + clientActionPresentation: "render_buttons", + ...overrides, + }; +} + +function prime(options: { + artifact: TestArtifact | null; + currentItemIdentity: string | null; + decisions?: Record; + supported?: boolean; +}) { + mockUseOutlookArtifact.mockReturnValue(options.artifact); + mockUseOutlookMailItem.mockReturnValue({ + mailItem: null, + itemIdentity: options.currentItemIdentity, + }); + const messageId = options.artifact?.messageId ?? "unrelated"; + mockUseChatContext.mockReturnValue({ + messages: { [messageId]: { id: messageId, role: "assistant" } }, + messageOrder: [messageId], + }); + mockUsePersistedState.mockReturnValue([options.decisions ?? {}, vi.fn()]); + mockIsCreateAppointmentSupported.mockReturnValue(options.supported ?? true); + mockOpenNewAppointmentForm.mockResolvedValue(undefined); +} + +beforeAll(() => { + i18n.load("en", {}); + i18n.activate("en"); +}); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe("OutlookEratoAppointmentRenderer — summary card", () => { + it("renders the parsed appointment (subject, time, attendees) with the action button", () => { + prime({ artifact: makeArtifact(), currentItemIdentity: null }); + render(); + + expect(screen.getByText("Projekt-Sync")).toBeInTheDocument(); + expect(screen.getByText("alice@example.com")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Open appointment" }), + ).toBeInTheDocument(); + }); + + it("renders the raw payload without actions while it doesn't parse (streaming)", () => { + prime({ artifact: makeArtifact(), currentItemIdentity: null }); + render(); + + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + expect(screen.getByText(/"start": "2026-07-/)).toBeInTheDocument(); + }); + + it("offers no action when the facet does not advertise it (read-only backend)", () => { + prime({ + artifact: makeArtifact({ allowedClientActions: [] }), + currentItemIdentity: null, + }); + render(); + + expect(screen.getByText("Projekt-Sync")).toBeInTheDocument(); + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + }); + + it("offers no action without an artifact at all (foreign fence)", () => { + prime({ artifact: null, currentItemIdentity: null }); + render(); + + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + }); + + it("offers no action on hosts without form support (Outlook mobile)", () => { + prime({ + artifact: makeArtifact(), + currentItemIdentity: null, + supported: false, + }); + render(); + + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + }); +}); + +describe("OutlookEratoAppointmentRenderer — confirm flow", () => { + it("confirms via the card (time + attendees summary) and opens the prefilled form", async () => { + prime({ artifact: makeArtifact(), currentItemIdentity: null }); + render(); + + fireEvent.click(screen.getByRole("button", { name: "Open appointment" })); + const description = screen.getByTestId("confirmation-description"); + expect(description).toHaveTextContent("alice@example.com"); + expect(mockOpenNewAppointmentForm).not.toHaveBeenCalled(); + + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "allow-once" })); + }); + + expect(mockOpenNewAppointmentForm).toHaveBeenCalledWith(DETAILS); + // No persistent record: the card closes and the button shows the + // transient "Opened!" swap (the add-in's standard success idiom). + expect(screen.queryByTestId("confirmation-card")).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Opened!" })).toBeInTheDocument(); + }); + + it("closes the card on deny without opening anything", () => { + prime({ artifact: makeArtifact(), currentItemIdentity: null }); + render(); + + fireEvent.click(screen.getByRole("button", { name: "Open appointment" })); + fireEvent.click(screen.getByRole("button", { name: "deny" })); + + expect(mockOpenNewAppointmentForm).not.toHaveBeenCalled(); + expect(screen.queryByTestId("confirmation-card")).not.toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Open appointment" }), + ).toBeEnabled(); + }); + + it("still confirms under a stored grant when the deployment enforces always-ask", () => { + prime({ + artifact: makeArtifact(), + currentItemIdentity: null, + decisions: { [`${FACET}/${CREATE}`]: "always" }, + }); + render(); + + fireEvent.click(screen.getByRole("button", { name: "Open appointment" })); + + expect(mockOpenNewAppointmentForm).not.toHaveBeenCalled(); + expect(screen.getByTestId("confirmation-card")).toBeInTheDocument(); + }); + + it("closes the card when the form fails; the inline alert is the feedback", async () => { + prime({ artifact: makeArtifact(), currentItemIdentity: null }); + mockOpenNewAppointmentForm.mockRejectedValue(new Error("host says no")); + render(); + + fireEvent.click(screen.getByRole("button", { name: "Open appointment" })); + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "allow-once" })); + }); + + expect(screen.queryByTestId("confirmation-card")).not.toBeInTheDocument(); + expect(screen.getByRole("alert")).toHaveTextContent( + "Failed to open the appointment form", + ); + // No success swap on a failed open. + expect(screen.queryByRole("button", { name: "Opened!" })).toBeNull(); + }); +}); + +describe("OutlookEratoAppointmentRenderer — auto-prompt", () => { + function makeAutoPromptArtifact( + overrides: Partial = {}, + ): TestArtifact { + return makeArtifact({ + isFreshCompletion: true, + itemIdentity: "item-a", + clientActionPresentation: "auto_prompt", + proposedClientAction: CREATE, + ...overrides, + }); + } + + it("auto-surfaces the confirmation card for a fresh matching proposal", () => { + prime({ + artifact: makeAutoPromptArtifact(), + currentItemIdentity: "item-a", + }); + render(); + + expect(screen.getByTestId("confirmation-card")).toBeInTheDocument(); + expect(mockOpenNewAppointmentForm).not.toHaveBeenCalled(); + }); + + it("auto-surfaces for a NEUTRAL-context send (no item open, sentinel identity)", () => { + // The scheduling facet's flagship context: pinned pane, nothing selected. + // The send recorded the no-item sentinel; at prompt time there is still + // no item — that must count as a match, not as a failed capture. + prime({ + artifact: makeAutoPromptArtifact({ itemIdentity: "no-item" }), + currentItemIdentity: null, + }); + render(); + + expect(screen.getByTestId("confirmation-card")).toBeInTheDocument(); + }); + + it("auto-surfaces when the live item drifted from the send-time identity", () => { + // create_appointment is item-independent: a mid-stream re-resolve that + // re-mints an unsaved-compose identity (or a navigation to another item) + // must not suppress the auto-prompt for a payload that lives in the fence. + prime({ + artifact: makeAutoPromptArtifact({ itemIdentity: "compose:abc" }), + currentItemIdentity: "compose:xyz", + }); + render(); + + expect(screen.getByTestId("confirmation-card")).toBeInTheDocument(); + }); + + it("never auto-surfaces for history messages", () => { + prime({ + artifact: makeAutoPromptArtifact({ + isFreshCompletion: undefined, + itemIdentity: undefined, + }), + currentItemIdentity: null, + }); + render(); + + expect(screen.queryByTestId("confirmation-card")).not.toBeInTheDocument(); + // The button remains as the manual path. + expect( + screen.getByRole("button", { name: "Open appointment" }), + ).toBeInTheDocument(); + }); +}); diff --git a/office-addin/src/components/__tests__/OutlookEratoEmailRenderer.test.tsx b/office-addin/src/components/__tests__/OutlookEratoEmailRenderer.test.tsx index 916dd69a..ac882a79 100644 --- a/office-addin/src/components/__tests__/OutlookEratoEmailRenderer.test.tsx +++ b/office-addin/src/components/__tests__/OutlookEratoEmailRenderer.test.tsx @@ -315,11 +315,8 @@ describe("OutlookEratoEmailRenderer — confirmation-card item snapshot", () => expect(screen.getByRole("alert")).toHaveTextContent( "written for a different email", ); - // The card records the failed outcome instead of vanishing. - expect(screen.getByTestId("confirmation-card")).toHaveAttribute( - "data-status", - "dismissed", - ); + // The card closes; the inline alert is the failure feedback. + expect(screen.queryByTestId("confirmation-card")).not.toBeInTheDocument(); }); it("aborts always-allow the same way (the persisted grant must not bypass the snapshot)", async () => { @@ -438,8 +435,11 @@ describe("OutlookEratoEmailRenderer — auto-prompt one-shot", () => { }); }); -describe("OutlookEratoEmailRenderer — resolved confirmation card", () => { - it("keeps the card as a confirmed record after allow-once and re-enables the proposal buttons", async () => { +describe("OutlookEratoEmailRenderer — confirmation card resolution", () => { + // No persistent resolved record: the card closes on every outcome, and + // success feedback is the ~2s "Opened!" swap on the action button (the + // add-in's standard transient idiom, like Copy's "Copied!"). + it("closes the card after allow-once and shows the transient Opened! swap", async () => { prime({ artifact: makeArtifact(), currentItemIdentity: "item-a" }); render(); @@ -451,17 +451,15 @@ describe("OutlookEratoEmailRenderer — resolved confirmation card", () => { }); expect(mockOpenReplyForm).toHaveBeenCalledWith(REPLY, "Draft body", false); - expect(screen.getByTestId("confirmation-card")).toHaveAttribute( - "data-status", - "confirmed", - ); - expect(screen.getByTestId("resolved-label")).toHaveTextContent( - "Reply form opened", - ); - expect(screen.getByRole("button", { name: "Reply" })).toBeEnabled(); + expect(screen.queryByTestId("confirmation-card")).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Opened!" })).toBeInTheDocument(); + // Only the executed action swaps; the sibling keeps its label. + expect( + screen.getByRole("button", { name: "Reply All" }), + ).toBeInTheDocument(); }); - it("labels a confirmed reply-all with its own outcome", async () => { + it("swaps the reply-all button when that action was the one executed", async () => { prime({ artifact: makeArtifact(), currentItemIdentity: "item-a" }); render(); @@ -470,12 +468,12 @@ describe("OutlookEratoEmailRenderer — resolved confirmation card", () => { fireEvent.click(screen.getByRole("button", { name: "allow-once" })); }); - expect(screen.getByTestId("resolved-label")).toHaveTextContent( - "Reply All form opened", - ); + expect(screen.getByRole("button", { name: "Opened!" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Reply" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Reply All" })).toBeNull(); }); - it("records a deny as dismissed instead of unmounting the card", () => { + it("closes the card on deny and re-enables the buttons", () => { prime({ artifact: makeArtifact(), currentItemIdentity: "item-a" }); render(); @@ -483,14 +481,11 @@ describe("OutlookEratoEmailRenderer — resolved confirmation card", () => { fireEvent.click(screen.getByRole("button", { name: "deny" })); expect(mockOpenReplyForm).not.toHaveBeenCalled(); - expect(screen.getByTestId("confirmation-card")).toHaveAttribute( - "data-status", - "dismissed", - ); + expect(screen.queryByTestId("confirmation-card")).not.toBeInTheDocument(); expect(screen.getByRole("button", { name: "Reply" })).toBeEnabled(); }); - it("resolves the card as not opened when the reply form fails", async () => { + it("closes the card when the reply form fails; the inline alert is the feedback", async () => { mockOpenReplyForm.mockRejectedValueOnce(new Error("boom")); const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); prime({ artifact: makeArtifact(), currentItemIdentity: "item-a" }); @@ -501,20 +496,16 @@ describe("OutlookEratoEmailRenderer — resolved confirmation card", () => { fireEvent.click(screen.getByRole("button", { name: "allow-once" })); }); - expect(screen.getByTestId("confirmation-card")).toHaveAttribute( - "data-status", - "dismissed", - ); - expect(screen.getByTestId("resolved-label")).toHaveTextContent( - "could not be opened", - ); + expect(screen.queryByTestId("confirmation-card")).not.toBeInTheDocument(); expect(screen.getByRole("alert")).toHaveTextContent( "Failed to open the reply form", ); + // No success swap on a failed open. + expect(screen.queryByRole("button", { name: "Opened!" })).toBeNull(); warn.mockRestore(); }); - it("disables the card buttons while the reply form is opening", async () => { + it("keeps the card mounted and busy while the reply form is opening", async () => { let resolveOpen!: () => void; mockOpenReplyForm.mockImplementationOnce( () => @@ -537,22 +528,16 @@ describe("OutlookEratoEmailRenderer — resolved confirmation card", () => { resolveOpen(); }); - expect(screen.getByTestId("confirmation-card")).toHaveAttribute( - "data-status", - "confirmed", - ); + expect(screen.queryByTestId("confirmation-card")).not.toBeInTheDocument(); }); - it("replaces a resolved card with a fresh pending one on the next request", () => { + it("opens a fresh card on the next request after a deny", () => { prime({ artifact: makeArtifact(), currentItemIdentity: "item-a" }); render(); fireEvent.click(screen.getByRole("button", { name: "Reply" })); fireEvent.click(screen.getByRole("button", { name: "deny" })); - expect(screen.getByTestId("confirmation-card")).toHaveAttribute( - "data-status", - "dismissed", - ); + expect(screen.queryByTestId("confirmation-card")).not.toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Reply" })); expect(screen.getByTestId("confirmation-card")).toHaveAttribute( @@ -612,6 +597,25 @@ describe("OutlookEratoEmailRenderer — read-reply gate tracks the reactive item expect(screen.queryByRole("button", { name: "Reply" })).toBeNull(); }); + + it("never offers an appointment action as a reply button (kind filter)", () => { + // An appointment-only facet (outlook_schedule) stamps allowedClientActions + // = ["outlook.create_appointment"]; the email renderer must not turn that + // into a button whose click would open a REPLY form with the prose body. + prime({ + artifact: makeArtifact({ + allowedClientActions: ["outlook.create_appointment"], + proposedClientAction: "outlook.create_appointment", + }), + currentItemIdentity: "item-a", + }); + render(); + + expect(screen.queryByRole("button", { name: "Reply" })).toBeNull(); + // Only the Copy fallback remains. + expect(screen.getAllByRole("button")).toHaveLength(1); + expect(screen.getByRole("button", { name: "Copy" })).toBeInTheDocument(); + }); }); describe("OutlookEratoEmailRenderer — copy", () => { diff --git a/office-addin/src/components/clientActionButtonStyles.ts b/office-addin/src/components/clientActionButtonStyles.ts new file mode 100644 index 00000000..a2b504d7 --- /dev/null +++ b/office-addin/src/components/clientActionButtonStyles.ts @@ -0,0 +1,7 @@ +// Button classes shared by the Outlook client-action renderers (email + +// appointment): compact geometry matching the sibling action buttons. +export const ACTION_BUTTON_CLASS = + "rounded-md border border-theme-border bg-theme-bg-primary px-3 py-1 text-xs hover:bg-theme-bg-tertiary disabled:opacity-50"; +// Mirrors the library Button "primary" variant tokens (Button.tsx). +export const PRIMARY_ACTION_BUTTON_CLASS = + "rounded-md px-3 py-1 text-xs font-medium bg-theme-action-primary-bg text-theme-action-primary-fg hover:bg-theme-action-primary-hover theme-transition disabled:opacity-50"; diff --git a/office-addin/src/hooks/useClientActionConfirmFlow.ts b/office-addin/src/hooks/useClientActionConfirmFlow.ts new file mode 100644 index 00000000..3d945b5a --- /dev/null +++ b/office-addin/src/hooks/useClientActionConfirmFlow.ts @@ -0,0 +1,221 @@ +import { useChatContext } from "@erato/frontend/library"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { resolveAutoPromptBehavior } from "../utils/clientActionPolicy"; + +import type { ClientActionDecisionMap } from "../utils/clientActionPolicy"; +import type { OutlookClientAction } from "../utils/outlookClientActions"; + +/** + * Auto-prompt fires at most once per assistant message PER SCOPE, across + * remounts (virtualized lists unmount/remount renderers) and across multiple + * fences within one message. Module-level by design. Scoped so the email and + * appointment renderers own independent slots: one renderer consuming its + * slot (even resolving to "none") must not suppress the other's prompt for + * the same message. + */ +const firedAutoPrompts = new Set(); + +export interface ConfirmCardState< + TSummary, + TAction extends OutlookClientAction = OutlookClientAction, +> { + /** + * Monotonic per-request id: a new confirmation replaces an earlier card + * and remounts it (fresh scroll/focus), and an async resolution only + * closes the card it was started from. + */ + requestId: number; + action: TAction; + /** + * The renderer-specific payload the user confirms against, snapshotted + * when the card opened (reply: freshly re-read recipients; appointment: + * the parsed fence details). + */ + summary: TSummary; + /** Surfaced by auto-prompt (scrolls into view) rather than a click. */ + autoTriggered: boolean; + /** + * Item identity when the card opened. Executors whose action is bound to + * the open item re-check it so confirming a card opened on email A can + * never act on email B; item-independent executors ignore it. + */ + itemIdentityAtOpen: string | null; +} + +/** + * The confirm-card state machine + auto-prompt one-shot shared by the client + * action renderers. The renderer supplies what differs: how to snapshot the + * confirmation summary, how to execute the action, and the card's copy — + * everything about WHEN a card may open, replace, close, or auto-surface is + * identical by construction. + * + * A card only exists while the decision is pending: allow closes it once + * execution completes (the opened window / the renderer's transient button + * swap is the success feedback, the renderer's inline alert the failure + * feedback), deny closes it immediately. No persistent resolved record — the + * add-in's success-feedback idiom is the ~2s label swap. + */ +export function useClientActionConfirmFlow< + TSummary, + TAction extends OutlookClientAction = OutlookClientAction, +>(args: { + /** Namespaces the once-per-message auto-prompt slot (e.g. `"email"`). */ + promptScope: string; + facetId: string | undefined; + decisions: ClientActionDecisionMap; + enforcedAskActions: readonly string[]; + /** + * Snapshot the confirmation payload for the card. Returning `null` aborts + * opening the card (e.g. the read-mode item disappeared). + */ + buildSummary: (action: TAction) => TSummary | null; + /** + * Execute the action; resolves `true` only when the form actually opened. + * `itemIdentityAtOpen` is the card's snapshot for card-driven executions + * and `undefined` for direct (card-less) ones. + */ + execute: ( + action: TAction, + itemIdentityAtOpen?: string | null, + ) => Promise; + /** Live item identity, snapshotted onto the card when it opens. */ + itemIdentity: string | null; + /** The producing facet's presentation (`auto_prompt` enables the one-shot). */ + presentation: string | undefined; + messageId: string | undefined; + isFreshCompletion: boolean; + proposedAction: TAction | undefined; + /** Send-time item identity stamped on the artifact, if capture succeeded. */ + expectedItemIdentity: string | null | undefined; + /** + * Identity of the currently open item AS THE EXECUTOR BINDS IT — the email + * renderer passes the raw live identity (a reply is item-bound), the + * appointment renderer normalizes null to its no-item sentinel (creating + * an appointment is item-independent). + */ + currentItemIdentity: string | null | undefined; +}) { + const { buildSummary, execute, itemIdentity } = args; + const [confirmCard, setConfirmCard] = useState | null>(null); + const confirmRequestIdRef = useRef(0); + + // Open the inline confirmation card with a summary snapshotted NOW — the + // user confirms against fresh data, not against whatever the chat message + // was generated from. Replaces a resolved card. + const requestConfirmation = useCallback( + (action: TAction, autoTriggered = false): boolean => { + const summary = buildSummary(action); + if (summary === null) { + return false; + } + confirmRequestIdRef.current += 1; + setConfirmCard({ + requestId: confirmRequestIdRef.current, + action, + summary, + autoTriggered, + itemIdentityAtOpen: itemIdentity, + }); + return true; + }, + [buildSummary, itemIdentity], + ); + + // Allow path: execute, then close THIS card — the card stays mounted (busy) + // while the form is opening, and a newer confirmation may have replaced it + // meanwhile. Success/failure feedback is the renderer's (button swap / + // inline alert), not the card's. + const allowCard = useCallback( + (card: ConfirmCardState) => { + void execute(card.action, card.itemIdentityAtOpen).then(() => { + setConfirmCard((current) => + current?.requestId === card.requestId ? null : current, + ); + }); + }, + [execute], + ); + + const denyCard = useCallback((card: ConfirmCardState) => { + setConfirmCard((current) => + current?.requestId === card.requestId ? null : current, + ); + }, []); + + // Auto-prompt: only under the facet's `auto_prompt` presentation, only for + // a validated proposal on a FRESH completion (stamped by AddinChat — never + // history reloads) that is still the latest assistant message and whose + // send-time context still matches, at most once per message and scope, and + // still through the user's approval preference ("don't ask" opens the + // form, "ask" surfaces the inline confirmation card). If the summary can't + // be snapshotted, requestConfirmation returns false and nothing happens — + // the buttons remain as fallback. + const { messages, messageOrder } = useChatContext(); + const isLatestAssistantMessage = useMemo(() => { + if (!args.messageId) { + return false; + } + for (let index = messageOrder.length - 1; index >= 0; index -= 1) { + const id = messageOrder[index]; + if (messages[id]?.role === "assistant") { + return id === args.messageId; + } + } + return false; + }, [args.messageId, messages, messageOrder]); + const autoPromptBehavior = resolveAutoPromptBehavior({ + presentation: args.presentation, + facetId: args.facetId, + proposedAction: args.proposedAction, + isFreshCompletion: args.isFreshCompletion, + isLatestAssistantMessage, + expectedItemIdentity: args.expectedItemIdentity, + currentItemIdentity: args.currentItemIdentity, + decisions: args.decisions, + enforcedAskActions: args.enforcedAskActions, + }); + const { promptScope, messageId, isFreshCompletion, proposedAction } = args; + useEffect(() => { + if (!messageId || !isFreshCompletion) { + return; + } + const promptKey = `${promptScope}:${messageId}`; + if (firedAutoPrompts.has(promptKey)) { + return; + } + // Consume the once-per-message slot on the FIRST evaluation for a fresh + // completion, regardless of the resolved behavior: a later settings flip + // (never → always allow) or a much later scroll-back mount must not + // resurrect the prompt. The failure direction is always a missed + // auto-open, never a late one — the buttons remain as fallback. + firedAutoPrompts.add(promptKey); + if (autoPromptBehavior === "none" || !proposedAction) { + return; + } + if (autoPromptBehavior === "execute") { + void execute(proposedAction); + } else { + requestConfirmation(proposedAction, true); + } + }, [ + autoPromptBehavior, + promptScope, + messageId, + isFreshCompletion, + proposedAction, + execute, + requestConfirmation, + ]); + + return { + confirmCard, + isConfirmPending: confirmCard !== null, + requestConfirmation, + allowCard, + denyCard, + }; +} diff --git a/office-addin/src/locales/de/messages.po b/office-addin/src/locales/de/messages.po index 4c611ece..4f6c14e5 100644 --- a/office-addin/src/locales/de/messages.po +++ b/office-addin/src/locales/de/messages.po @@ -13,6 +13,74 @@ msgstr "" "Language-Team: \n" "Plural-Forms: \n" +# ============================== +# Explicit IDs +# ============================== +#. js-lingui-explicit-id +#: src/components/OutlookEratoAppointmentRenderer.tsx +msgid "officeAddin.appointmentRenderer.alwaysAllowLocked" +msgstr "Ihre Organisation verlangt für diese Aktion jedes Mal eine Bestätigung." + +#. js-lingui-explicit-id +#: src/components/OutlookEratoAppointmentRenderer.tsx +msgid "officeAddin.appointmentRenderer.attendees" +msgstr "Teilnehmer:" + +#. js-lingui-explicit-id +#: src/components/OutlookEratoAppointmentRenderer.tsx +msgid "officeAddin.appointmentRenderer.confirmMessage" +msgstr "Dies öffnet ein vorausgefülltes Terminformular in Outlook. Nichts wird gespeichert oder gesendet, bis Sie es dort tun." + +#. js-lingui-explicit-id +#: src/components/OutlookEratoAppointmentRenderer.tsx +#~ msgid "officeAddin.appointmentRenderer.formNotOpened" +#~ msgstr "Terminformular konnte nicht geöffnet werden" + +#. js-lingui-explicit-id +#: src/components/OutlookEratoAppointmentRenderer.tsx +#~ msgid "officeAddin.appointmentRenderer.formOpened" +#~ msgstr "Terminformular geöffnet" + +#. js-lingui-explicit-id +#: src/components/OutlookEratoAppointmentRenderer.tsx +msgid "officeAddin.appointmentRenderer.location" +msgstr "Ort:" + +#. js-lingui-explicit-id +#: src/components/OutlookEratoAppointmentRenderer.tsx +msgid "officeAddin.appointmentRenderer.noSubject" +msgstr "(kein)" + +#. js-lingui-explicit-id +#: src/components/OutlookEratoAppointmentRenderer.tsx +msgid "officeAddin.appointmentRenderer.openAppointment" +msgstr "Termin öffnen" + +#. js-lingui-explicit-id +#: src/components/OutlookEratoAppointmentRenderer.tsx +msgid "officeAddin.appointmentRenderer.opened" +msgstr "Geöffnet!" + +#. js-lingui-explicit-id +#: src/components/OutlookEratoAppointmentRenderer.tsx +msgid "officeAddin.appointmentRenderer.openFailed" +msgstr "Terminformular konnte nicht geöffnet werden. Sie können den Termin manuell in Outlook anlegen." + +#. js-lingui-explicit-id +#: src/components/OutlookEratoAppointmentRenderer.tsx +msgid "officeAddin.appointmentRenderer.opening" +msgstr "Wird geöffnet..." + +#. js-lingui-explicit-id +#: src/components/OutlookEratoAppointmentRenderer.tsx +msgid "officeAddin.appointmentRenderer.subject" +msgstr "Betreff:" + +#. js-lingui-explicit-id +#: src/components/OutlookEratoAppointmentRenderer.tsx +msgid "officeAddin.appointmentRenderer.when" +msgstr "Wann:" + # ============================== # Explicit IDs # ============================== @@ -223,6 +291,11 @@ msgstr "Unbekannter Absender" msgid "officeAddin.chatInput.validation.tooLarge" msgstr "Datei überschreitet das Serverlimit von {globalMaxFormatted}" +#. js-lingui-explicit-id +#: src/utils/outlookClientActions.ts +msgid "officeAddin.clientActions.createAppointment" +msgstr "Einen vorausgefüllten Termin öffnen" + #. js-lingui-explicit-id #: src/utils/outlookClientActions.ts msgid "officeAddin.clientActions.reply" @@ -288,6 +361,11 @@ msgstr "Einfügen in den Textkörper konnte nicht durchgeführt werden." msgid "officeAddin.emailRenderer.inserting" msgstr "Wird eingefügt..." +#. js-lingui-explicit-id +#: src/components/OutlookEratoEmailRenderer.tsx +msgid "officeAddin.emailRenderer.opened" +msgstr "Geöffnet!" + #. js-lingui-explicit-id #: src/components/OutlookEratoEmailRenderer.tsx msgid "officeAddin.emailRenderer.opening" @@ -325,8 +403,8 @@ msgstr "{confirmRecipientCount, plural, one {Dies öffnet eine Antwort an # Pers #. js-lingui-explicit-id #: src/components/OutlookEratoEmailRenderer.tsx -msgid "officeAddin.emailRenderer.replyAllFormOpened" -msgstr "Antwortfenster für alle Empfänger geöffnet" +#~ msgid "officeAddin.emailRenderer.replyAllFormOpened" +#~ msgstr "Antwortfenster für alle Empfänger geöffnet" #. js-lingui-explicit-id #: src/components/OutlookEratoEmailRenderer.tsx @@ -350,13 +428,13 @@ msgstr "Das Antwortfenster konnte nicht geöffnet werden. Stellen Sie sicher, da #. js-lingui-explicit-id #: src/components/OutlookEratoEmailRenderer.tsx -msgid "officeAddin.emailRenderer.replyFormNotOpened" -msgstr "Antwortfenster konnte nicht geöffnet werden" +#~ msgid "officeAddin.emailRenderer.replyFormNotOpened" +#~ msgstr "Antwortfenster konnte nicht geöffnet werden" #. js-lingui-explicit-id #: src/components/OutlookEratoEmailRenderer.tsx -msgid "officeAddin.emailRenderer.replyFormOpened" -msgstr "Antwortfenster geöffnet" +#~ msgid "officeAddin.emailRenderer.replyFormOpened" +#~ msgstr "Antwortfenster geöffnet" #. js-lingui-explicit-id #: src/components/OutlookEratoEmailRenderer.tsx diff --git a/office-addin/src/locales/en/messages.po b/office-addin/src/locales/en/messages.po index 597a017a..b3689e31 100644 --- a/office-addin/src/locales/en/messages.po +++ b/office-addin/src/locales/en/messages.po @@ -13,6 +13,74 @@ msgstr "" "Language-Team: \n" "Plural-Forms: \n" +# ============================== +# Explicit IDs +# ============================== +#. js-lingui-explicit-id +#: src/components/OutlookEratoAppointmentRenderer.tsx +msgid "officeAddin.appointmentRenderer.alwaysAllowLocked" +msgstr "Your organization requires confirmation for this action every time." + +#. js-lingui-explicit-id +#: src/components/OutlookEratoAppointmentRenderer.tsx +msgid "officeAddin.appointmentRenderer.attendees" +msgstr "Attendees:" + +#. js-lingui-explicit-id +#: src/components/OutlookEratoAppointmentRenderer.tsx +msgid "officeAddin.appointmentRenderer.confirmMessage" +msgstr "This opens a prefilled appointment form in Outlook. Nothing is saved or sent until you do it there." + +#. js-lingui-explicit-id +#: src/components/OutlookEratoAppointmentRenderer.tsx +#~ msgid "officeAddin.appointmentRenderer.formNotOpened" +#~ msgstr "Appointment form could not be opened" + +#. js-lingui-explicit-id +#: src/components/OutlookEratoAppointmentRenderer.tsx +#~ msgid "officeAddin.appointmentRenderer.formOpened" +#~ msgstr "Appointment form opened" + +#. js-lingui-explicit-id +#: src/components/OutlookEratoAppointmentRenderer.tsx +msgid "officeAddin.appointmentRenderer.location" +msgstr "Location:" + +#. js-lingui-explicit-id +#: src/components/OutlookEratoAppointmentRenderer.tsx +msgid "officeAddin.appointmentRenderer.noSubject" +msgstr "(none)" + +#. js-lingui-explicit-id +#: src/components/OutlookEratoAppointmentRenderer.tsx +msgid "officeAddin.appointmentRenderer.openAppointment" +msgstr "Open appointment" + +#. js-lingui-explicit-id +#: src/components/OutlookEratoAppointmentRenderer.tsx +msgid "officeAddin.appointmentRenderer.opened" +msgstr "Opened!" + +#. js-lingui-explicit-id +#: src/components/OutlookEratoAppointmentRenderer.tsx +msgid "officeAddin.appointmentRenderer.openFailed" +msgstr "Failed to open the appointment form. You can create the appointment manually in Outlook." + +#. js-lingui-explicit-id +#: src/components/OutlookEratoAppointmentRenderer.tsx +msgid "officeAddin.appointmentRenderer.opening" +msgstr "Opening..." + +#. js-lingui-explicit-id +#: src/components/OutlookEratoAppointmentRenderer.tsx +msgid "officeAddin.appointmentRenderer.subject" +msgstr "Subject:" + +#. js-lingui-explicit-id +#: src/components/OutlookEratoAppointmentRenderer.tsx +msgid "officeAddin.appointmentRenderer.when" +msgstr "When:" + # ============================== # Explicit IDs # ============================== @@ -223,6 +291,11 @@ msgstr "Unknown sender" msgid "officeAddin.chatInput.validation.tooLarge" msgstr "File exceeds the server limit of {globalMaxFormatted}" +#. js-lingui-explicit-id +#: src/utils/outlookClientActions.ts +msgid "officeAddin.clientActions.createAppointment" +msgstr "Open a prefilled appointment" + #. js-lingui-explicit-id #: src/utils/outlookClientActions.ts msgid "officeAddin.clientActions.reply" @@ -288,6 +361,11 @@ msgstr "Failed to insert into compose body." msgid "officeAddin.emailRenderer.inserting" msgstr "Inserting..." +#. js-lingui-explicit-id +#: src/components/OutlookEratoEmailRenderer.tsx +msgid "officeAddin.emailRenderer.opened" +msgstr "Opened!" + #. js-lingui-explicit-id #: src/components/OutlookEratoEmailRenderer.tsx msgid "officeAddin.emailRenderer.opening" @@ -325,8 +403,8 @@ msgstr "{confirmRecipientCount, plural, one {This opens a reply addressed to # p #. js-lingui-explicit-id #: src/components/OutlookEratoEmailRenderer.tsx -msgid "officeAddin.emailRenderer.replyAllFormOpened" -msgstr "Reply All form opened" +#~ msgid "officeAddin.emailRenderer.replyAllFormOpened" +#~ msgstr "Reply All form opened" #. js-lingui-explicit-id #: src/components/OutlookEratoEmailRenderer.tsx @@ -350,13 +428,13 @@ msgstr "Failed to open the reply form. Make sure the received email is still ope #. js-lingui-explicit-id #: src/components/OutlookEratoEmailRenderer.tsx -msgid "officeAddin.emailRenderer.replyFormNotOpened" -msgstr "Reply form could not be opened" +#~ msgid "officeAddin.emailRenderer.replyFormNotOpened" +#~ msgstr "Reply form could not be opened" #. js-lingui-explicit-id #: src/components/OutlookEratoEmailRenderer.tsx -msgid "officeAddin.emailRenderer.replyFormOpened" -msgstr "Reply form opened" +#~ msgid "officeAddin.emailRenderer.replyFormOpened" +#~ msgstr "Reply form opened" #. js-lingui-explicit-id #: src/components/OutlookEratoEmailRenderer.tsx diff --git a/office-addin/src/locales/es/messages.po b/office-addin/src/locales/es/messages.po index eba69cb7..0cb65f5a 100644 --- a/office-addin/src/locales/es/messages.po +++ b/office-addin/src/locales/es/messages.po @@ -13,6 +13,74 @@ msgstr "" "Language-Team: \n" "Plural-Forms: \n" +# ============================== +# Explicit IDs +# ============================== +#. js-lingui-explicit-id +#: src/components/OutlookEratoAppointmentRenderer.tsx +msgid "officeAddin.appointmentRenderer.alwaysAllowLocked" +msgstr "Tu organización requiere confirmación para esta acción cada vez." + +#. js-lingui-explicit-id +#: src/components/OutlookEratoAppointmentRenderer.tsx +msgid "officeAddin.appointmentRenderer.attendees" +msgstr "Participantes:" + +#. js-lingui-explicit-id +#: src/components/OutlookEratoAppointmentRenderer.tsx +msgid "officeAddin.appointmentRenderer.confirmMessage" +msgstr "Esto abre un formulario de cita prellenado en Outlook. No se guarda ni se envía nada hasta que lo hagas allí." + +#. js-lingui-explicit-id +#: src/components/OutlookEratoAppointmentRenderer.tsx +#~ msgid "officeAddin.appointmentRenderer.formNotOpened" +#~ msgstr "No se pudo abrir el formulario de cita" + +#. js-lingui-explicit-id +#: src/components/OutlookEratoAppointmentRenderer.tsx +#~ msgid "officeAddin.appointmentRenderer.formOpened" +#~ msgstr "Se abrió el formulario de cita" + +#. js-lingui-explicit-id +#: src/components/OutlookEratoAppointmentRenderer.tsx +msgid "officeAddin.appointmentRenderer.location" +msgstr "Lugar:" + +#. js-lingui-explicit-id +#: src/components/OutlookEratoAppointmentRenderer.tsx +msgid "officeAddin.appointmentRenderer.noSubject" +msgstr "(ninguno)" + +#. js-lingui-explicit-id +#: src/components/OutlookEratoAppointmentRenderer.tsx +msgid "officeAddin.appointmentRenderer.openAppointment" +msgstr "Abrir cita" + +#. js-lingui-explicit-id +#: src/components/OutlookEratoAppointmentRenderer.tsx +msgid "officeAddin.appointmentRenderer.opened" +msgstr "Abierto" + +#. js-lingui-explicit-id +#: src/components/OutlookEratoAppointmentRenderer.tsx +msgid "officeAddin.appointmentRenderer.openFailed" +msgstr "No se pudo abrir el formulario de cita. Puedes crear la cita manualmente en Outlook." + +#. js-lingui-explicit-id +#: src/components/OutlookEratoAppointmentRenderer.tsx +msgid "officeAddin.appointmentRenderer.opening" +msgstr "Abriendo..." + +#. js-lingui-explicit-id +#: src/components/OutlookEratoAppointmentRenderer.tsx +msgid "officeAddin.appointmentRenderer.subject" +msgstr "Asunto:" + +#. js-lingui-explicit-id +#: src/components/OutlookEratoAppointmentRenderer.tsx +msgid "officeAddin.appointmentRenderer.when" +msgstr "Cuándo:" + # ============================== # Explicit IDs # ============================== @@ -223,6 +291,11 @@ msgstr "Remitente desconocido" msgid "officeAddin.chatInput.validation.tooLarge" msgstr "El archivo supera el límite del servidor de {globalMaxFormatted}" +#. js-lingui-explicit-id +#: src/utils/outlookClientActions.ts +msgid "officeAddin.clientActions.createAppointment" +msgstr "Abrir una cita prellenada" + #. js-lingui-explicit-id #: src/utils/outlookClientActions.ts msgid "officeAddin.clientActions.reply" @@ -288,6 +361,11 @@ msgstr "No se pudo insertar en el cuerpo del mensaje." msgid "officeAddin.emailRenderer.inserting" msgstr "Insertando..." +#. js-lingui-explicit-id +#: src/components/OutlookEratoEmailRenderer.tsx +msgid "officeAddin.emailRenderer.opened" +msgstr "Abierto" + #. js-lingui-explicit-id #: src/components/OutlookEratoEmailRenderer.tsx msgid "officeAddin.emailRenderer.opening" @@ -325,8 +403,8 @@ msgstr "{confirmRecipientCount, plural, one {Esto abre una respuesta dirigida a #. js-lingui-explicit-id #: src/components/OutlookEratoEmailRenderer.tsx -msgid "officeAddin.emailRenderer.replyAllFormOpened" -msgstr "Se abrió el formulario de respuesta a todos" +#~ msgid "officeAddin.emailRenderer.replyAllFormOpened" +#~ msgstr "Se abrió el formulario de respuesta a todos" #. js-lingui-explicit-id #: src/components/OutlookEratoEmailRenderer.tsx @@ -350,13 +428,13 @@ msgstr "No se pudo abrir el formulario de respuesta. Asegúrate de que el correo #. js-lingui-explicit-id #: src/components/OutlookEratoEmailRenderer.tsx -msgid "officeAddin.emailRenderer.replyFormNotOpened" -msgstr "No se pudo abrir el formulario de respuesta" +#~ msgid "officeAddin.emailRenderer.replyFormNotOpened" +#~ msgstr "No se pudo abrir el formulario de respuesta" #. js-lingui-explicit-id #: src/components/OutlookEratoEmailRenderer.tsx -msgid "officeAddin.emailRenderer.replyFormOpened" -msgstr "Se abrió el formulario de respuesta" +#~ msgid "officeAddin.emailRenderer.replyFormOpened" +#~ msgstr "Se abrió el formulario de respuesta" #. js-lingui-explicit-id #: src/components/OutlookEratoEmailRenderer.tsx diff --git a/office-addin/src/locales/fr/messages.po b/office-addin/src/locales/fr/messages.po index 52776ed2..ba7ecddf 100644 --- a/office-addin/src/locales/fr/messages.po +++ b/office-addin/src/locales/fr/messages.po @@ -13,6 +13,74 @@ msgstr "" "Language-Team: \n" "Plural-Forms: \n" +# ============================== +# Explicit IDs +# ============================== +#. js-lingui-explicit-id +#: src/components/OutlookEratoAppointmentRenderer.tsx +msgid "officeAddin.appointmentRenderer.alwaysAllowLocked" +msgstr "Votre organisation exige une confirmation pour cette action à chaque fois." + +#. js-lingui-explicit-id +#: src/components/OutlookEratoAppointmentRenderer.tsx +msgid "officeAddin.appointmentRenderer.attendees" +msgstr "Participants :" + +#. js-lingui-explicit-id +#: src/components/OutlookEratoAppointmentRenderer.tsx +msgid "officeAddin.appointmentRenderer.confirmMessage" +msgstr "Ceci ouvre un formulaire de rendez-vous prérempli dans Outlook. Rien n'est enregistré ni envoyé tant que vous ne le faites pas là-bas." + +#. js-lingui-explicit-id +#: src/components/OutlookEratoAppointmentRenderer.tsx +#~ msgid "officeAddin.appointmentRenderer.formNotOpened" +#~ msgstr "Le formulaire de rendez-vous n'a pas pu être ouvert" + +#. js-lingui-explicit-id +#: src/components/OutlookEratoAppointmentRenderer.tsx +#~ msgid "officeAddin.appointmentRenderer.formOpened" +#~ msgstr "Formulaire de rendez-vous ouvert" + +#. js-lingui-explicit-id +#: src/components/OutlookEratoAppointmentRenderer.tsx +msgid "officeAddin.appointmentRenderer.location" +msgstr "Lieu :" + +#. js-lingui-explicit-id +#: src/components/OutlookEratoAppointmentRenderer.tsx +msgid "officeAddin.appointmentRenderer.noSubject" +msgstr "(aucun)" + +#. js-lingui-explicit-id +#: src/components/OutlookEratoAppointmentRenderer.tsx +msgid "officeAddin.appointmentRenderer.openAppointment" +msgstr "Ouvrir le rendez-vous" + +#. js-lingui-explicit-id +#: src/components/OutlookEratoAppointmentRenderer.tsx +msgid "officeAddin.appointmentRenderer.opened" +msgstr "Ouvert" + +#. js-lingui-explicit-id +#: src/components/OutlookEratoAppointmentRenderer.tsx +msgid "officeAddin.appointmentRenderer.openFailed" +msgstr "Le formulaire de rendez-vous n'a pas pu être ouvert. Vous pouvez créer le rendez-vous manuellement dans Outlook." + +#. js-lingui-explicit-id +#: src/components/OutlookEratoAppointmentRenderer.tsx +msgid "officeAddin.appointmentRenderer.opening" +msgstr "Ouverture..." + +#. js-lingui-explicit-id +#: src/components/OutlookEratoAppointmentRenderer.tsx +msgid "officeAddin.appointmentRenderer.subject" +msgstr "Objet :" + +#. js-lingui-explicit-id +#: src/components/OutlookEratoAppointmentRenderer.tsx +msgid "officeAddin.appointmentRenderer.when" +msgstr "Quand :" + # ============================== # Explicit IDs # ============================== @@ -223,6 +291,11 @@ msgstr "Expéditeur inconnu" msgid "officeAddin.chatInput.validation.tooLarge" msgstr "Le fichier dépasse la limite du serveur de {globalMaxFormatted}" +#. js-lingui-explicit-id +#: src/utils/outlookClientActions.ts +msgid "officeAddin.clientActions.createAppointment" +msgstr "Ouvrir un rendez-vous prérempli" + #. js-lingui-explicit-id #: src/utils/outlookClientActions.ts msgid "officeAddin.clientActions.reply" @@ -288,6 +361,11 @@ msgstr "Échec de l’insertion dans le corps du message." msgid "officeAddin.emailRenderer.inserting" msgstr "Insertion..." +#. js-lingui-explicit-id +#: src/components/OutlookEratoEmailRenderer.tsx +msgid "officeAddin.emailRenderer.opened" +msgstr "Ouvert" + #. js-lingui-explicit-id #: src/components/OutlookEratoEmailRenderer.tsx msgid "officeAddin.emailRenderer.opening" @@ -325,8 +403,8 @@ msgstr "{confirmRecipientCount, plural, one {Ceci ouvre une réponse adressée #. js-lingui-explicit-id #: src/components/OutlookEratoEmailRenderer.tsx -msgid "officeAddin.emailRenderer.replyAllFormOpened" -msgstr "Formulaire de réponse à tous ouvert" +#~ msgid "officeAddin.emailRenderer.replyAllFormOpened" +#~ msgstr "Formulaire de réponse à tous ouvert" #. js-lingui-explicit-id #: src/components/OutlookEratoEmailRenderer.tsx @@ -350,13 +428,13 @@ msgstr "Impossible d'ouvrir le formulaire de réponse. Vérifiez que l'e-mail re #. js-lingui-explicit-id #: src/components/OutlookEratoEmailRenderer.tsx -msgid "officeAddin.emailRenderer.replyFormNotOpened" -msgstr "Le formulaire de réponse n'a pas pu être ouvert" +#~ msgid "officeAddin.emailRenderer.replyFormNotOpened" +#~ msgstr "Le formulaire de réponse n'a pas pu être ouvert" #. js-lingui-explicit-id #: src/components/OutlookEratoEmailRenderer.tsx -msgid "officeAddin.emailRenderer.replyFormOpened" -msgstr "Formulaire de réponse ouvert" +#~ msgid "officeAddin.emailRenderer.replyFormOpened" +#~ msgstr "Formulaire de réponse ouvert" #. js-lingui-explicit-id #: src/components/OutlookEratoEmailRenderer.tsx diff --git a/office-addin/src/locales/pl/messages.po b/office-addin/src/locales/pl/messages.po index 6fb01dac..be0ab5f2 100644 --- a/office-addin/src/locales/pl/messages.po +++ b/office-addin/src/locales/pl/messages.po @@ -13,6 +13,74 @@ msgstr "" "Language-Team: \n" "Plural-Forms: \n" +# ============================== +# Explicit IDs +# ============================== +#. js-lingui-explicit-id +#: src/components/OutlookEratoAppointmentRenderer.tsx +msgid "officeAddin.appointmentRenderer.alwaysAllowLocked" +msgstr "Twoja organizacja wymaga potwierdzenia tej akcji za każdym razem." + +#. js-lingui-explicit-id +#: src/components/OutlookEratoAppointmentRenderer.tsx +msgid "officeAddin.appointmentRenderer.attendees" +msgstr "Uczestnicy:" + +#. js-lingui-explicit-id +#: src/components/OutlookEratoAppointmentRenderer.tsx +msgid "officeAddin.appointmentRenderer.confirmMessage" +msgstr "Spowoduje to otwarcie wstępnie wypełnionego formularza terminu w Outlooku. Nic nie zostanie zapisane ani wysłane, dopóki nie zrobisz tego tam." + +#. js-lingui-explicit-id +#: src/components/OutlookEratoAppointmentRenderer.tsx +#~ msgid "officeAddin.appointmentRenderer.formNotOpened" +#~ msgstr "Nie udało się otworzyć formularza terminu" + +#. js-lingui-explicit-id +#: src/components/OutlookEratoAppointmentRenderer.tsx +#~ msgid "officeAddin.appointmentRenderer.formOpened" +#~ msgstr "Otwarto formularz terminu" + +#. js-lingui-explicit-id +#: src/components/OutlookEratoAppointmentRenderer.tsx +msgid "officeAddin.appointmentRenderer.location" +msgstr "Miejsce:" + +#. js-lingui-explicit-id +#: src/components/OutlookEratoAppointmentRenderer.tsx +msgid "officeAddin.appointmentRenderer.noSubject" +msgstr "(brak)" + +#. js-lingui-explicit-id +#: src/components/OutlookEratoAppointmentRenderer.tsx +msgid "officeAddin.appointmentRenderer.openAppointment" +msgstr "Otwórz termin" + +#. js-lingui-explicit-id +#: src/components/OutlookEratoAppointmentRenderer.tsx +msgid "officeAddin.appointmentRenderer.opened" +msgstr "Otwarto" + +#. js-lingui-explicit-id +#: src/components/OutlookEratoAppointmentRenderer.tsx +msgid "officeAddin.appointmentRenderer.openFailed" +msgstr "Nie udało się otworzyć formularza terminu. Możesz utworzyć termin ręcznie w Outlooku." + +#. js-lingui-explicit-id +#: src/components/OutlookEratoAppointmentRenderer.tsx +msgid "officeAddin.appointmentRenderer.opening" +msgstr "Otwieranie..." + +#. js-lingui-explicit-id +#: src/components/OutlookEratoAppointmentRenderer.tsx +msgid "officeAddin.appointmentRenderer.subject" +msgstr "Temat:" + +#. js-lingui-explicit-id +#: src/components/OutlookEratoAppointmentRenderer.tsx +msgid "officeAddin.appointmentRenderer.when" +msgstr "Kiedy:" + # ============================== # Explicit IDs # ============================== @@ -223,6 +291,11 @@ msgstr "Nieznany nadawca" msgid "officeAddin.chatInput.validation.tooLarge" msgstr "Plik przekracza limit serwera wynoszący {globalMaxFormatted}" +#. js-lingui-explicit-id +#: src/utils/outlookClientActions.ts +msgid "officeAddin.clientActions.createAppointment" +msgstr "Otwórz wstępnie wypełniony termin" + #. js-lingui-explicit-id #: src/utils/outlookClientActions.ts msgid "officeAddin.clientActions.reply" @@ -288,6 +361,11 @@ msgstr "Nie udało się wstawić do treści wiadomości." msgid "officeAddin.emailRenderer.inserting" msgstr "Wstawianie..." +#. js-lingui-explicit-id +#: src/components/OutlookEratoEmailRenderer.tsx +msgid "officeAddin.emailRenderer.opened" +msgstr "Otwarto" + #. js-lingui-explicit-id #: src/components/OutlookEratoEmailRenderer.tsx msgid "officeAddin.emailRenderer.opening" @@ -325,8 +403,8 @@ msgstr "{confirmRecipientCount, plural, one {Spowoduje to otwarcie odpowiedzi za #. js-lingui-explicit-id #: src/components/OutlookEratoEmailRenderer.tsx -msgid "officeAddin.emailRenderer.replyAllFormOpened" -msgstr "Otwarto formularz odpowiedzi do wszystkich" +#~ msgid "officeAddin.emailRenderer.replyAllFormOpened" +#~ msgstr "Otwarto formularz odpowiedzi do wszystkich" #. js-lingui-explicit-id #: src/components/OutlookEratoEmailRenderer.tsx @@ -350,13 +428,13 @@ msgstr "Nie udało się otworzyć formularza odpowiedzi. Upewnij się, że otrzy #. js-lingui-explicit-id #: src/components/OutlookEratoEmailRenderer.tsx -msgid "officeAddin.emailRenderer.replyFormNotOpened" -msgstr "Nie udało się otworzyć formularza odpowiedzi" +#~ msgid "officeAddin.emailRenderer.replyFormNotOpened" +#~ msgstr "Nie udało się otworzyć formularza odpowiedzi" #. js-lingui-explicit-id #: src/components/OutlookEratoEmailRenderer.tsx -msgid "officeAddin.emailRenderer.replyFormOpened" -msgstr "Otwarto formularz odpowiedzi" +#~ msgid "officeAddin.emailRenderer.replyFormOpened" +#~ msgstr "Otwarto formularz odpowiedzi" #. js-lingui-explicit-id #: src/components/OutlookEratoEmailRenderer.tsx diff --git a/office-addin/src/main.tsx b/office-addin/src/main.tsx index 17cc2fd2..821fec90 100644 --- a/office-addin/src/main.tsx +++ b/office-addin/src/main.tsx @@ -7,6 +7,7 @@ import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom"; import App from "./App"; import { injectFrontendEnv } from "./app/env"; import { AddinChatAddMenuExtraContent } from "./components/AddinChatAddMenuExtraContent"; +import { OutlookEratoAppointmentRenderer } from "./components/OutlookEratoAppointmentRenderer"; import { OutlookEratoEmailRenderer } from "./components/OutlookEratoEmailRenderer"; import { AddinSetupRoute } from "./pages/AddinSetupPage"; @@ -16,6 +17,7 @@ injectFrontendEnv(); // ChatAddMenuExtraContent slot; file sources and tools come from the core menu. componentRegistry.ChatAddMenuExtraContent = AddinChatAddMenuExtraContent; componentRegistry.EratoEmailCodeBlock = OutlookEratoEmailRenderer; +componentRegistry.EratoAppointmentCodeBlock = OutlookEratoAppointmentRenderer; const rootElement = document.getElementById("root"); diff --git a/office-addin/src/providers/OutlookMailItemProvider.tsx b/office-addin/src/providers/OutlookMailItemProvider.tsx index 832906cf..a2955216 100644 --- a/office-addin/src/providers/OutlookMailItemProvider.tsx +++ b/office-addin/src/providers/OutlookMailItemProvider.tsx @@ -89,6 +89,17 @@ function parseRecipients( })); } +/** + * Sentinel send-time identity for a send with NO Outlook item open (neutral + * context: pinned pane with nothing selected). A REAL captured value — not a + * capture failure — so completions from neutral sends still count as fresh + * and item-INDEPENDENT actions (create appointment) can auto-prompt there. + * Item-BOUND executors (reply) treat it like any mismatching identity and + * fail closed. Distinct by construction from every built identity (message + * ids, `subject:date`, `compose:` mints). + */ +export const NO_ITEM_SEND_IDENTITY = "no-item"; + function buildMailItemIdentity( item: Office.MessageRead | Office.MessageCompose | null, ): string | null { diff --git a/office-addin/src/utils/__tests__/outlookClientActions.test.ts b/office-addin/src/utils/__tests__/outlookClientActions.test.ts index 90ed638a..a590476f 100644 --- a/office-addin/src/utils/__tests__/outlookClientActions.test.ts +++ b/office-addin/src/utils/__tests__/outlookClientActions.test.ts @@ -2,10 +2,13 @@ import { describe, it, expect } from "vitest"; import { CLIENT_ACTION_TOOL_NAME, + buildOutlookArtifact, computeShouldRenderEmailCard, extractProposedClientAction, isImplementedClientAction, + offerableAppointmentClientActions, offerableClientActions, + offerableEmailClientActions, } from "../outlookClientActions"; import type { ContentPart } from "@erato/frontend/library"; @@ -40,12 +43,33 @@ describe("offerableClientActions", () => { expect(offerableClientActions([])).toEqual([]); expect(offerableClientActions(["not.implemented"])).toEqual([]); }); + + it("partitions by kind: each renderer only offers its own actions", () => { + const allowed = [ + "outlook.create_appointment", + "outlook.reply", + "outlook.reply_all", + ]; + expect(offerableEmailClientActions(allowed)).toEqual([ + "outlook.reply", + "outlook.reply_all", + ]); + expect(offerableAppointmentClientActions(allowed)).toEqual([ + "outlook.create_appointment", + ]); + // An appointment-only facet (outlook_schedule) yields NO reply buttons. + expect(offerableEmailClientActions(["outlook.create_appointment"])).toEqual( + [], + ); + expect(offerableAppointmentClientActions(["outlook.reply"])).toEqual([]); + }); }); describe("isImplementedClientAction", () => { it("accepts only registry actions", () => { expect(isImplementedClientAction("outlook.reply")).toBe(true); expect(isImplementedClientAction("outlook.reply_all")).toBe(true); + expect(isImplementedClientAction("outlook.create_appointment")).toBe(true); expect(isImplementedClientAction("outlook.send")).toBe(false); expect(isImplementedClientAction("")).toBe(false); }); @@ -118,6 +142,27 @@ describe("extractProposedClientAction", () => { expect(extractProposedClientAction(undefined, ALLOWED)).toBeUndefined(); expect(extractProposedClientAction([], ALLOWED)).toBeUndefined(); }); + + it("accepts outlook.create_appointment only through BOTH gates", () => { + const proposal = [ + toolUsePart({ input: { action: "outlook.create_appointment" } }), + ]; + // In the registry AND in the facet's client_actions → accepted. + expect( + extractProposedClientAction(proposal, ["outlook.create_appointment"]), + ).toBe("outlook.create_appointment"); + // Removed from the facet's client_actions → ignored despite the registry. + expect(extractProposedClientAction(proposal, [])).toBeUndefined(); + expect(extractProposedClientAction(proposal, ALLOWED)).toBeUndefined(); + // Allowed by the facet but outside the registry → ignored (the second + // gate; asserted with a look-alike id the add-in does not implement). + expect( + extractProposedClientAction( + [toolUsePart({ input: { action: "outlook.create_meeting" } })], + ["outlook.create_meeting"], + ), + ).toBeUndefined(); + }); }); describe("computeShouldRenderEmailCard", () => { @@ -181,4 +226,150 @@ describe("computeShouldRenderEmailCard", () => { }), ).toBe(true); }); + + it("never lets an appointment proposal promote prose to an email card", () => { + // A facet offering both kinds: the create-appointment proposal is not an + // email draft, so the ambient-suppression verdict must hold. + expect( + computeShouldRenderEmailCard({ + allowedClientActions: [...ALLOWED, "outlook.create_appointment"], + proposedClientAction: "outlook.create_appointment", + }), + ).toBe(false); + }); + + it("treats an appointment-only facet as not email-carding-relevant", () => { + // outlook_schedule: the verdict is true ("always cards") but inert — the + // whole-body email card additionally requires a bodyFormat, which + // appointment facets never stamp (see buildOutlookArtifact below). + expect( + computeShouldRenderEmailCard({ + allowedClientActions: ["outlook.create_appointment"], + proposedClientAction: undefined, + }), + ).toBe(true); + }); +}); + +describe("buildOutlookArtifact", () => { + const scheduleInfo = { + clientActions: ["outlook.create_appointment"], + alwaysAskActions: ["outlook.create_appointment"], + presentation: "auto_prompt", + }; + + it("stamps an artifact for outlook_schedule despite it carrying no body_format", () => { + // The decoupled gate (ERMAIN-387): the confirm card can only render when + // the artifact (facet metadata + proposal) reaches the fence renderer. + const artifact = buildOutlookArtifact({ + facetId: "outlook_schedule", + facetArgs: { + now_iso: "2026-07-06T10:00:00+02:00", + timezone: "Europe/Berlin", + }, + clientActionInfo: scheduleInfo, + content: [ + toolUsePart({ input: { action: "outlook.create_appointment" } }), + ], + messageId: "msg-1", + freshItemIdentity: undefined, + }); + expect(artifact).toMatchObject({ + facetId: "outlook_schedule", + renderMode: "body", + messageId: "msg-1", + allowedClientActions: ["outlook.create_appointment"], + alwaysAskClientActions: ["outlook.create_appointment"], + proposedClientAction: "outlook.create_appointment", + clientActionPresentation: "auto_prompt", + }); + // No bodyFormat ⇒ the email-shaped paths (drift rescue, whole-body card) + // stay off for scheduling prose. + expect(artifact?.bodyFormat).toBeUndefined(); + expect(artifact?.isFreshCompletion).toBeUndefined(); + }); + + it("returns undefined for a facet with neither body_format nor offerable actions", () => { + expect( + buildOutlookArtifact({ + facetId: "some_facet", + facetArgs: { anything: "else" }, + clientActionInfo: undefined, + content: [], + messageId: "msg-1", + freshItemIdentity: undefined, + }), + ).toBeUndefined(); + expect( + buildOutlookArtifact({ + facetId: undefined, + facetArgs: { body_format: "text" }, + clientActionInfo: undefined, + content: [], + messageId: "msg-1", + freshItemIdentity: undefined, + }), + ).toBeUndefined(); + // Advertising only unimplemented actions does not open the second door. + expect( + buildOutlookArtifact({ + facetId: "some_facet", + facetArgs: {}, + clientActionInfo: { + clientActions: ["teams.post"], + alwaysAskActions: [], + }, + content: [], + messageId: "msg-1", + freshItemIdentity: undefined, + }), + ).toBeUndefined(); + }); + + it("keeps the email-facet stamp unchanged (bodyFormat, suppression, freshness)", () => { + const artifact = buildOutlookArtifact({ + facetId: "outlook_reply_from_read", + facetArgs: { body_format: "html" }, + clientActionInfo: { + clientActions: ALLOWED, + alwaysAskActions: ["outlook.reply_all"], + presentation: "auto_prompt", + }, + content: [], + messageId: "msg-2", + freshItemIdentity: "item-abc", + }); + expect(artifact).toMatchObject({ + facetId: "outlook_reply_from_read", + bodyFormat: "html", + renderMode: "body", + // No proposal on an ambient client-action facet ⇒ suppressed. + shouldRenderEmailCard: false, + isFreshCompletion: true, + itemIdentity: "item-abc", + }); + }); + + it("marks review facets as suggestions and plain body facets as body", () => { + expect( + buildOutlookArtifact({ + facetId: "outlook_review_draft", + facetArgs: { body_format: "text" }, + clientActionInfo: undefined, + content: [], + messageId: "m", + freshItemIdentity: undefined, + })?.renderMode, + ).toBe("suggestions"); + expect( + buildOutlookArtifact({ + facetId: "compose_email", + facetArgs: { body_format: "text" }, + clientActionInfo: undefined, + content: [], + messageId: "m", + freshItemIdentity: undefined, + }), + ).toMatchObject({ renderMode: "body", bodyFormat: "text" }); + }); }); diff --git a/office-addin/src/utils/__tests__/outlookCreateAppointment.test.ts b/office-addin/src/utils/__tests__/outlookCreateAppointment.test.ts new file mode 100644 index 00000000..9b52b5d2 --- /dev/null +++ b/office-addin/src/utils/__tests__/outlookCreateAppointment.test.ts @@ -0,0 +1,222 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + AppointmentFormError, + isCreateAppointmentSupported, + openNewAppointmentForm, + parseAppointmentDetails, +} from "../outlookCreateAppointment"; + +type OfficeGlobal = { Office?: unknown }; + +function installOffice({ + supportedSets = ["Mailbox 1.1", "Mailbox 1.9"], + hostName = "Outlook", + displayNewAppointmentFormAsync = vi.fn( + (_form: unknown, cb: (r: { status: string; value: undefined }) => void) => + cb({ status: "succeeded", value: undefined }), + ), + displayNewAppointmentForm = vi.fn(), +}: { + supportedSets?: string[]; + hostName?: string; + displayNewAppointmentFormAsync?: ReturnType; + displayNewAppointmentForm?: ReturnType; +} = {}) { + (globalThis as OfficeGlobal).Office = { + AsyncResultStatus: { Succeeded: "succeeded", Failed: "failed" }, + context: { + mailbox: { + diagnostics: { hostName }, + displayNewAppointmentFormAsync, + displayNewAppointmentForm, + }, + requirements: { + isSetSupported: (name: string, version: string) => + supportedSets.includes(`${name} ${version}`), + }, + }, + }; + return { displayNewAppointmentFormAsync, displayNewAppointmentForm }; +} + +afterEach(() => { + delete (globalThis as OfficeGlobal).Office; + vi.restoreAllMocks(); +}); + +const VALID = { + start: "2026-07-09T10:00:00+02:00", + end: "2026-07-09T10:30:00+02:00", + subject: "Projekt-Sync", + attendees: ["alice@example.com"], +}; + +describe("parseAppointmentDetails", () => { + it("parses a valid payload with optional fields", () => { + expect( + parseAppointmentDetails( + JSON.stringify({ + ...VALID, + optionalAttendees: ["bob@example.com"], + location: "Raum 3", + body: "Agenda folgt.", + }), + ), + ).toEqual({ + ...VALID, + optionalAttendees: ["bob@example.com"], + location: "Raum 3", + body: "Agenda folgt.", + }); + }); + + it("tolerates missing subject/attendees and non-string entries", () => { + expect( + parseAppointmentDetails( + JSON.stringify({ + start: VALID.start, + end: VALID.end, + attendees: ["a@example.com", 42, null], + }), + ), + ).toEqual({ + start: VALID.start, + end: VALID.end, + subject: "", + attendees: ["a@example.com"], + }); + }); + + it("returns null for malformed or incomplete payloads", () => { + // Mid-stream truncation is the common case: never throw, never card. + expect(parseAppointmentDetails('{ "start": "2026-07-09T10')).toBeNull(); + expect(parseAppointmentDetails("")).toBeNull(); + expect(parseAppointmentDetails("[]")).toBeNull(); + expect(parseAppointmentDetails('"just a string"')).toBeNull(); + expect( + parseAppointmentDetails(JSON.stringify({ start: VALID.start })), + ).toBeNull(); + expect( + parseAppointmentDetails(JSON.stringify({ ...VALID, end: 1234 })), + ).toBeNull(); + }); + + it("returns null when start/end don't parse as dates", () => { + expect( + parseAppointmentDetails( + JSON.stringify({ ...VALID, start: "next Tuesday" }), + ), + ).toBeNull(); + }); + + it("returns null for an inverted or zero-length range", () => { + // Swapped start/end (or a timezone slip) — never open a negative-duration + // form; end === start is equally non-actionable. + expect( + parseAppointmentDetails( + JSON.stringify({ ...VALID, start: VALID.end, end: VALID.start }), + ), + ).toBeNull(); + expect( + parseAppointmentDetails(JSON.stringify({ ...VALID, end: VALID.start })), + ).toBeNull(); + }); +}); + +describe("isCreateAppointmentSupported", () => { + it("requires Mailbox 1.1", () => { + installOffice(); + expect(isCreateAppointmentSupported()).toBe(true); + installOffice({ supportedSets: [] }); + expect(isCreateAppointmentSupported()).toBe(false); + }); + + it("is false on Outlook mobile, where the form API is unsupported", () => { + installOffice({ hostName: "OutlookIOS" }); + expect(isCreateAppointmentSupported()).toBe(false); + installOffice({ hostName: "OutlookAndroid" }); + expect(isCreateAppointmentSupported()).toBe(false); + installOffice({ hostName: "OutlookWebApp" }); + expect(isCreateAppointmentSupported()).toBe(true); + }); +}); + +describe("openNewAppointmentForm", () => { + it("prefills the async form on Mailbox 1.9 hosts (attendees, times, no recurrence)", async () => { + const { displayNewAppointmentFormAsync } = installOffice(); + await openNewAppointmentForm({ + ...VALID, + optionalAttendees: ["bob@example.com"], + location: "Raum 3", + body: "Agenda folgt.", + }); + expect(displayNewAppointmentFormAsync).toHaveBeenCalledTimes(1); + const form = displayNewAppointmentFormAsync.mock.calls[0][0] as Record< + string, + unknown + >; + expect(form).toEqual({ + requiredAttendees: ["alice@example.com"], + optionalAttendees: ["bob@example.com"], + start: new Date(VALID.start), + end: new Date(VALID.end), + subject: "Projekt-Sync", + location: "Raum 3", + body: "Agenda folgt.", + }); + expect(form).not.toHaveProperty("recurrence"); + }); + + it("falls back to the sync form without Mailbox 1.9", async () => { + const { displayNewAppointmentForm, displayNewAppointmentFormAsync } = + installOffice({ supportedSets: ["Mailbox 1.1"] }); + await openNewAppointmentForm(VALID); + expect(displayNewAppointmentForm).toHaveBeenCalledTimes(1); + expect(displayNewAppointmentFormAsync).not.toHaveBeenCalled(); + }); + + it("wraps host failures in AppointmentFormError", async () => { + installOffice({ + displayNewAppointmentFormAsync: vi.fn(() => { + throw new Error("host says no"); + }), + }); + await expect(openNewAppointmentForm(VALID)).rejects.toBeInstanceOf( + AppointmentFormError, + ); + }); + + it("times out when a wedged host drops the async callback", async () => { + vi.useFakeTimers(); + // vi.fn() never invokes the callback — a dropped Office callback. + installOffice({ displayNewAppointmentFormAsync: vi.fn() }); + const assertion = expect( + openNewAppointmentForm(VALID), + ).rejects.toBeInstanceOf(AppointmentFormError); + await vi.advanceTimersByTimeAsync(15_000); + await assertion; + vi.useRealTimers(); + }); + + it("rejects when the async callback reports failure", async () => { + installOffice({ + displayNewAppointmentFormAsync: vi.fn( + ( + _form: unknown, + cb: (r: { + status: string; + value: undefined; + error: { message: string }; + }) => void, + ) => + cb({ + status: "failed", + value: undefined, + error: { message: "denied" }, + }), + ), + }); + await expect(openNewAppointmentForm(VALID)).rejects.toThrow(); + }); +}); diff --git a/office-addin/src/utils/outlookClientActions.ts b/office-addin/src/utils/outlookClientActions.ts index 8afb4e50..abebab61 100644 --- a/office-addin/src/utils/outlookClientActions.ts +++ b/office-addin/src/utils/outlookClientActions.ts @@ -1,6 +1,6 @@ import { t } from "@lingui/core/macro"; -import type { ContentPart } from "@erato/frontend/library"; +import type { ContentPart, OutlookArtifact } from "@erato/frontend/library"; /** * Id of the config-defined action facet (erato.toml only) that lets the model @@ -15,14 +15,39 @@ export const OUTLOOK_REPLY_FROM_READ_FACET_ID = "outlook_reply_from_read"; */ export const CLIENT_ACTION_TOOL_NAME = "propose_client_action"; +/** + * Actions whose payload is the message's email draft — executed by the + * erato-email renderer via Outlook's reply forms. + */ +export const EMAIL_CLIENT_ACTIONS = [ + "outlook.reply", + "outlook.reply_all", +] as const; + +export type OutlookEmailClientAction = (typeof EMAIL_CLIENT_ACTIONS)[number]; + +/** + * Actions whose payload is the message's `erato-appointment` fence — executed + * by the appointment renderer via Outlook's new-appointment form. + */ +export const APPOINTMENT_CLIENT_ACTIONS = [ + "outlook.create_appointment", +] as const; + +export type OutlookAppointmentClientAction = + (typeof APPOINTMENT_CLIENT_ACTIONS)[number]; + /** * Client actions this add-in implements. A fixed, code-defined allowlist — * never extended at runtime. Anything the model (or backend config) proposes - * outside this list is ignored. + * outside this list is ignored. Partitioned by KIND (email vs appointment): + * each renderer only offers and executes its own kind, so a facet advertising + * `outlook.create_appointment` can never surface a reply button (or vice + * versa). */ export const IMPLEMENTED_CLIENT_ACTIONS = [ - "outlook.reply", - "outlook.reply_all", + ...EMAIL_CLIENT_ACTIONS, + ...APPOINTMENT_CLIENT_ACTIONS, ] as const; export type OutlookClientAction = (typeof IMPLEMENTED_CLIENT_ACTIONS)[number]; @@ -38,15 +63,23 @@ export function isImplementedClientAction( * payload and the settings decision toggles. */ export function clientActionDisplayLabel(action: OutlookClientAction): string { - return action === "outlook.reply_all" - ? t({ + switch (action) { + case "outlook.reply_all": + return t({ id: "officeAddin.clientActions.replyAll", message: "Reply to all recipients", - }) - : t({ + }); + case "outlook.create_appointment": + return t({ + id: "officeAddin.clientActions.createAppointment", + message: "Open a prefilled appointment", + }); + case "outlook.reply": + return t({ id: "officeAddin.clientActions.reply", message: "Reply to sender", }); + } } /** @@ -65,6 +98,24 @@ export function offerableClientActions( ); } +/** {@link offerableClientActions} restricted to the email kind. */ +export function offerableEmailClientActions( + allowedActions: readonly string[] | undefined, +): OutlookEmailClientAction[] { + return EMAIL_CLIENT_ACTIONS.filter((action) => + (allowedActions ?? []).includes(action), + ); +} + +/** {@link offerableClientActions} restricted to the appointment kind. */ +export function offerableAppointmentClientActions( + allowedActions: readonly string[] | undefined, +): OutlookAppointmentClientAction[] { + return APPOINTMENT_CLIENT_ACTIONS.filter((action) => + (allowedActions ?? []).includes(action), + ); +} + /** * Extract the model's validated client-action proposal from an assistant * message's content parts. @@ -113,20 +164,110 @@ export function extractProposedClientAction( /** * Producer-side verdict for {@link OutlookArtifact.shouldRenderEmailCard}: * whether an UNFENCED, `"body"`-mode response should render as the insertable - * email card. A facet that OFFERS client actions (reply / reply-all) is + * email card. A facet that OFFERS email client actions (reply / reply-all) is * attached ambiently to every read-mode message, so a plain answer with no - * proposal must NOT card; a facet with no offerable actions — compose / + * proposal must NOT card; a facet with no offerable email actions — compose / * rewrite-selection, or a facet advertising ONLY actions this add-in does not * implement — always cards. Keyed off the OFFERABLE set (`allowedClientActions` * intersected with the implemented registry) so the carding verdict matches - * what the renderer can actually act on. A fenced draft never reaches this - * verdict; the renderer's fence path handles it. + * what the renderer can actually act on, and restricted to the EMAIL kind: an + * appointment proposal is not an email draft and must never promote prose to + * an email card. A fenced draft never reaches this verdict; the renderer's + * fence path handles it. (The verdict is only consumed for email-bodied + * facets at all — the whole-body fallback additionally requires a + * `bodyFormat`.) */ export function computeShouldRenderEmailCard(args: { allowedClientActions: readonly string[] | undefined; proposedClientAction: string | undefined; }): boolean { - const isClientActionFacet = - offerableClientActions(args.allowedClientActions).length > 0; - return !isClientActionFacet || args.proposedClientAction != null; + const emailActions = offerableEmailClientActions(args.allowedClientActions); + const proposedEmailAction = + args.proposedClientAction != null && + (emailActions as readonly string[]).includes(args.proposedClientAction); + return emailActions.length === 0 || proposedEmailAction; +} + +/** + * Build the `outlookArtifact` stamp for one assistant message, or `undefined` + * when the producing facet doesn't render through the artifact machinery. + * + * A message qualifies through EITHER door: + * - the facet carried a `body_format` arg (`"text"`/`"html"`) — its output is + * an insertable email body (compose / rewrite / reply / review facets), or + * - the facet advertises client actions this add-in implements — its output + * carries an actionable proposal even without an email body (e.g. the + * scheduling facet's `erato-appointment` fence). + * + * `bodyFormat` is stamped only from the first door: its presence is what + * unlocks the email-shaped rendering paths (drifted-tag rescue, whole-body + * card), which must stay off for action-fence facets. + */ +export function buildOutlookArtifact(args: { + facetId: string | undefined; + facetArgs: Record | undefined; + /** The facet's entry from `GET /me/facets`, if it declares client actions. */ + clientActionInfo: + | { + clientActions: string[]; + alwaysAskActions: string[]; + presentation?: string; + } + | undefined; + /** The assistant message's content parts (proposal extraction). */ + content: ContentPart[] | undefined; + messageId: string; + /** + * Send-time item identity when this message is a FRESH completion whose + * identity is known; `undefined` otherwise (history and identity-unknown + * completions render as history-like drafts). + */ + freshItemIdentity: string | undefined; +}): OutlookArtifact | undefined { + const bodyFormatArg = args.facetArgs?.body_format; + const bodyFormat = + bodyFormatArg === "text" || bodyFormatArg === "html" + ? bodyFormatArg + : undefined; + const allowedClientActions = args.clientActionInfo?.clientActions; + const hasOfferableActions = + offerableClientActions(allowedClientActions).length > 0; + if (!args.facetId || (bodyFormat === undefined && !hasOfferableActions)) { + return undefined; + } + const renderMode = + args.facetId === "outlook_review_draft" ? "suggestions" : "body"; + // The model's proposal is only stamped after revalidation against the + // backend-advertised set + tool-call status — never parsed out of message + // text. + const proposedClientAction = allowedClientActions + ? extractProposedClientAction(args.content, allowedClientActions) + : undefined; + // Single source of truth for carding (see OutlookArtifact.shouldRenderEmailCard). + // Stamped below only when it SUPPRESSES (false); absent ⇒ cards. + const shouldRenderEmailCard = computeShouldRenderEmailCard({ + allowedClientActions, + proposedClientAction, + }); + return { + facetId: args.facetId, + ...(bodyFormat ? { bodyFormat } : {}), + renderMode, + messageId: args.messageId, + ...(allowedClientActions ? { allowedClientActions } : {}), + ...(renderMode === "body" && !shouldRenderEmailCard + ? { shouldRenderEmailCard: false } + : {}), + ...(args.clientActionInfo && + args.clientActionInfo.alwaysAskActions.length > 0 + ? { alwaysAskClientActions: args.clientActionInfo.alwaysAskActions } + : {}), + ...(proposedClientAction ? { proposedClientAction } : {}), + ...(args.clientActionInfo?.presentation + ? { clientActionPresentation: args.clientActionInfo.presentation } + : {}), + ...(args.freshItemIdentity !== undefined + ? { isFreshCompletion: true, itemIdentity: args.freshItemIdentity } + : {}), + }; } diff --git a/office-addin/src/utils/outlookCreateAppointment.ts b/office-addin/src/utils/outlookCreateAppointment.ts new file mode 100644 index 00000000..7dc174c4 --- /dev/null +++ b/office-addin/src/utils/outlookCreateAppointment.ts @@ -0,0 +1,148 @@ +import { callOfficeAsync } from "./officeAsync"; + +// A wedged host could drop the callback and hang the open forever — bound it. +const APPOINTMENT_FORM_TIMEOUT_MS = 15_000; + +/** + * The appointment the model emitted inside its fenced `erato-appointment` + * block, validated into a shape the Office.js new-appointment form accepts. + * `start`/`end` are the ISO-8601 strings the model produced (local, + * offset-bearing per the facet template); they are turned into `Date`s only at + * the point the form opens. Recurrence is deliberately absent: the form API + * has no recurrence property and none is ever prefilled. + */ +export interface AppointmentDetails { + start: string; + end: string; + subject: string; + attendees: string[]; + optionalAttendees?: string[]; + location?: string; + body?: string; +} + +/** Thrown when the new-appointment form could not be opened. */ +export class AppointmentFormError extends Error { + constructor(cause?: unknown) { + super("Failed to open the new appointment form"); + this.name = "AppointmentFormError"; + this.cause = cause; + } +} + +function toStringArray(value: unknown): string[] { + return Array.isArray(value) + ? value.filter((entry): entry is string => typeof entry === "string") + : []; +} + +function isParseableDate(value: string): boolean { + return !Number.isNaN(new Date(value).getTime()); +} + +/** + * Parse the JSON payload of an `erato-appointment` fence (the renderer + * receives the fence body directly). Returns `null` when the JSON is + * unparseable (including mid-stream truncation), the required `start`/`end` + * are missing, they don't parse as dates, or `end` is not after `start` — the + * caller then simply shows no actionable card. Never throws. + */ +export function parseAppointmentDetails( + fenceBody: string, +): AppointmentDetails | null { + let parsed: unknown; + try { + parsed = JSON.parse(fenceBody.trim()); + } catch { + return null; + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + return null; + } + + const obj = parsed as Record; + if (typeof obj.start !== "string" || typeof obj.end !== "string") { + return null; + } + if (!isParseableDate(obj.start) || !isParseableDate(obj.end)) { + return null; + } + // Reject inverted/zero-length ranges — never open a negative-duration form. + if (new Date(obj.end).getTime() <= new Date(obj.start).getTime()) { + return null; + } + + const details: AppointmentDetails = { + start: obj.start, + end: obj.end, + subject: typeof obj.subject === "string" ? obj.subject : "", + attendees: toStringArray(obj.attendees), + }; + const optionalAttendees = toStringArray(obj.optionalAttendees); + if (optionalAttendees.length > 0) { + details.optionalAttendees = optionalAttendees; + } + if (typeof obj.location === "string") { + details.location = obj.location; + } + if (typeof obj.body === "string") { + details.body = obj.body; + } + return details; +} + +/** + * Whether this host can open a new-appointment form: Mailbox 1.1 and not + * Outlook on iOS/Android, where `displayNewAppointmentForm` is unsupported. + * Host-static for the session — `displayNewAppointmentForm` lives on + * `Office.context.mailbox` (NOT the item), so unlike the reply forms it needs + * no live-item guard and works in read, compose, and no-item contexts alike. + */ +export function isCreateAppointmentSupported(): boolean { + const hostName = Office.context?.mailbox?.diagnostics?.hostName; + if (hostName === "OutlookIOS" || hostName === "OutlookAndroid") { + return false; + } + return ( + Office.context.requirements?.isSetSupported?.("Mailbox", "1.1") ?? false + ); +} + +/** + * Open Outlook's native new-appointment form prefilled with the parsed + * details. This never sends or saves anything — the user reviews and Saves or + * Sends in Outlook's own appointment window. + * + * Throws {@link AppointmentFormError} when the host refuses to open the form + * so the confirm card can resolve into its failure record. + */ +export async function openNewAppointmentForm( + details: AppointmentDetails, +): Promise { + const form: Office.AppointmentForm = { + requiredAttendees: details.attendees, + ...(details.optionalAttendees + ? { optionalAttendees: details.optionalAttendees } + : {}), + start: new Date(details.start), + end: new Date(details.end), + subject: details.subject, + ...(details.location !== undefined ? { location: details.location } : {}), + ...(details.body !== undefined ? { body: details.body } : {}), + }; + const supportsAsync = + Office.context.requirements?.isSetSupported?.("Mailbox", "1.9") ?? false; + try { + if (supportsAsync) { + await callOfficeAsync( + (callback) => + Office.context.mailbox.displayNewAppointmentFormAsync(form, callback), + { timeoutMs: APPOINTMENT_FORM_TIMEOUT_MS }, + ); + } else { + Office.context.mailbox.displayNewAppointmentForm(form); + } + } catch (error) { + throw new AppointmentFormError(error); + } +} diff --git a/office-addin/src/utils/outlookReadReply.ts b/office-addin/src/utils/outlookReadReply.ts index 774880d6..0a94c825 100644 --- a/office-addin/src/utils/outlookReadReply.ts +++ b/office-addin/src/utils/outlookReadReply.ts @@ -2,7 +2,7 @@ import { callOfficeAsync } from "./officeAsync"; import { sanitizeReplyFormHtml } from "./sanitizeReplyFormHtml"; import { isMessageRead } from "../sessionPolicy/outlookAnchor"; -import type { OutlookClientAction } from "./outlookClientActions"; +import type { OutlookEmailClientAction } from "./outlookClientActions"; /** * Office.js rejects reply form bodies above 32 KB (displayReplyForm / @@ -156,7 +156,7 @@ export function getReadModeRecipientSummary(): ReadModeRecipientSummary | null { * body exceeds the Office.js limit. */ export async function openReplyForm( - action: OutlookClientAction, + action: OutlookEmailClientAction, content: string, isHtml: boolean, ): Promise {