From 6126495fc73d5fa04ab160162df82ef5e1441fec Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 6 Jul 2026 17:58:31 +0000 Subject: [PATCH 1/6] feat(hooks): add useTransientLabel shared hook for ~2s label-swap feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extracts the recurring ad-hoc pattern (useState + setTimeout(2000)) into a single, reusable hook. The hook: - resets isActive to false after delay ms (default 2000) - clears the timer on unmount — no leaks - restarts the timer if trigger is called while already active - optionally provides an srAnnouncement string for a role=status live region so icon-only swaps are announced to screen readers (ERMAIN-435 A11y point) Co-authored-by: Daniel --- frontend/src/hooks/ui/index.ts | 1 + frontend/src/hooks/ui/useTransientLabel.ts | 85 ++++++++++++++++++++++ 2 files changed, 86 insertions(+) create mode 100644 frontend/src/hooks/ui/useTransientLabel.ts diff --git a/frontend/src/hooks/ui/index.ts b/frontend/src/hooks/ui/index.ts index 898f79b95..6b13cbd6a 100644 --- a/frontend/src/hooks/ui/index.ts +++ b/frontend/src/hooks/ui/index.ts @@ -12,3 +12,4 @@ export * from "./useScrollEvents"; export * from "./useFilePreviewModal"; export * from "./useThemedIcon"; export * from "./usePageAlignment"; +export * from "./useTransientLabel"; diff --git a/frontend/src/hooks/ui/useTransientLabel.ts b/frontend/src/hooks/ui/useTransientLabel.ts new file mode 100644 index 000000000..185586f0c --- /dev/null +++ b/frontend/src/hooks/ui/useTransientLabel.ts @@ -0,0 +1,85 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +interface UseTransientLabelOptions { + /** Duration the active state stays on before auto-resetting. Default: 2000 ms. */ + delay?: number; + /** + * Text to surface in the sr-only live region while active, for screen + * readers that would otherwise only observe a silent icon/label swap. + * When omitted no `srAnnouncement` text is produced. + */ + announcement?: string; +} + +interface UseTransientLabelResult { + /** True for `delay` ms after `trigger` is called, then false. */ + isActive: boolean; + /** + * Call this to start the transient active window. Safe to call while + * already active — restarts the timer from zero. + */ + trigger: () => void; + /** + * Non-empty while `isActive` and an `announcement` was supplied; empty + * string otherwise. Render inside a `role="status"` / `aria-live="polite"` + * sr-only element to announce success to screen readers that would not + * detect a silent icon swap. + * + * @example + *

{srAnnouncement}

+ */ + srAnnouncement: string; +} + +/** + * Drives the transient ~2 s label-swap success feedback pattern. + * + * `isActive` becomes `true` when `trigger` is called and automatically resets + * to `false` after `delay` ms (default 2000). The timer is cleared if the + * component unmounts before it fires — no timer leaks. Calling `trigger` + * while already active restarts the timer. + * + * @example + * const { isActive: isCopied, trigger: triggerCopied, srAnnouncement } = + * useTransientLabel({ announcement: t`Copied to clipboard` }); + * + * // in handler: + * await navigator.clipboard.writeText(text); + * triggerCopied(); + * + * // in render: + * {isCopied ? : } + *

{srAnnouncement}

+ */ +export function useTransientLabel({ + delay = 2000, + announcement, +}: UseTransientLabelOptions = {}): UseTransientLabelResult { + const [isActive, setIsActive] = useState(false); + const timerRef = useRef | null>(null); + + const trigger = useCallback(() => { + if (timerRef.current !== null) { + clearTimeout(timerRef.current); + } + setIsActive(true); + timerRef.current = setTimeout(() => { + setIsActive(false); + timerRef.current = null; + }, delay); + }, [delay]); + + useEffect(() => { + return () => { + if (timerRef.current !== null) { + clearTimeout(timerRef.current); + } + }; + }, []); + + return { + isActive, + trigger, + srAnnouncement: isActive && announcement ? announcement : "", + }; +} From 33be23eb29f06396ebe492041d1ca06ab87d5e33 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 6 Jul 2026 17:58:41 +0000 Subject: [PATCH 2/6] refactor(ActionConfirmationCard): remove resolved-row status/resolvedLabel machinery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The status / resolvedLabel / ActionConfirmationStatus type had zero consumers after ERMAIN-387 (add-in confirm cards now close on resolution instead of rendering a persistent resolved row). Removed: - ActionConfirmationStatus type and status/resolvedLabel props - isPending conditional rendering (card is always the pending form) - useLayoutEffect that moved focus on pending→resolved transition - resolvedRowLabel / resolvedTitle computed strings for resolved state - announcement live region update on resolution - conditional className / role / aria-label for resolved variant Kept: - mount-time focus grab (focusIfUnclaimed) - scrollIntoViewOnMount - polite sr-only announcement on mount for SR users who may not have focus on the card when it appears (simplified to fire once on mount) Co-authored-by: Daniel --- .../Message/ActionConfirmationCard.test.tsx | 99 +-------- .../ui/Message/ActionConfirmationCard.tsx | 210 +++++++----------- frontend/src/library/index.ts | 5 +- 3 files changed, 81 insertions(+), 233 deletions(-) diff --git a/frontend/src/components/ui/Message/ActionConfirmationCard.test.tsx b/frontend/src/components/ui/Message/ActionConfirmationCard.test.tsx index d46c08ef8..6581a6848 100644 --- a/frontend/src/components/ui/Message/ActionConfirmationCard.test.tsx +++ b/frontend/src/components/ui/Message/ActionConfirmationCard.test.tsx @@ -103,114 +103,21 @@ describe("ActionConfirmationCard", () => { expect(screen.getByText("Deny")).toBeDisabled(); }); - it("renders a compact resolved row instead of buttons once resolved", () => { + it("moves focus to the card on mount", () => { render( , ); - expect(screen.getByTestId("card")).toHaveTextContent("Reply opened"); - expect(screen.queryByText("Allow once")).not.toBeInTheDocument(); - }); - - it("moves focus to a pending card on mount", () => { - render( - , - ); - expect(screen.getByTestId("card")).toHaveFocus(); - }); - - it("does not grab focus when mounted already resolved", () => { - render( - , - ); - expect(screen.getByTestId("card")).not.toHaveFocus(); - }); - - it("keeps focus on the card when the pending buttons resolve away", () => { - const props = { - title: "t", - onAllowOnce: vi.fn(), - onDeny: vi.fn(), - "data-testid": "card", - }; - const { rerender } = render(); - screen.getByText("Deny").focus(); - rerender(); expect(screen.getByTestId("card")).toHaveFocus(); }); - it("populates the polite announcement region after mount and on resolution", () => { - const props = { onAllowOnce: vi.fn(), onDeny: vi.fn() }; - const { rerender } = render(); + it("populates the polite announcement region after mount", () => { + render(); expect(screen.getByRole("status")).toHaveTextContent("Allow this action?"); - rerender( - , - ); - expect(screen.getByRole("status")).toHaveTextContent("Reply opened"); - }); - - it("announces the pending→resolved transition exactly once", async () => { - const props = { onAllowOnce: vi.fn(), onDeny: vi.fn() }; - const { rerender } = render(); - const region = screen.getByRole("status"); - // Live regions announce on DOM mutation, so count mutations directly. - const mutations: string[] = []; - const observer = new MutationObserver((records) => { - mutations.push(...records.map(() => region.textContent ?? "")); - }); - observer.observe(region, { - childList: true, - characterData: true, - subtree: true, - }); - const resolvedProps = ( - - ); - rerender(resolvedProps); - // A second render of the already-resolved card must not re-announce. - rerender(resolvedProps); - await Promise.resolve(); // flush the MutationObserver microtask - observer.disconnect(); - expect(mutations).toEqual(["Reply opened"]); - }); - - it("does not announce a card that mounts already resolved", () => { - render( - , - ); - // Reloaded transcripts mount resolved cards in bulk; an announcement - // here would queue one polite readout per historical card. - expect(screen.getByRole("status")).toBeEmptyDOMElement(); }); it("scrolls into view on mount only when requested", () => { diff --git a/frontend/src/components/ui/Message/ActionConfirmationCard.tsx b/frontend/src/components/ui/Message/ActionConfirmationCard.tsx index 90b7380c8..0de6de36a 100644 --- a/frontend/src/components/ui/Message/ActionConfirmationCard.tsx +++ b/frontend/src/components/ui/Message/ActionConfirmationCard.tsx @@ -1,13 +1,11 @@ import { t } from "@lingui/core/macro"; -import { useEffect, useId, useLayoutEffect, useRef, useState } from "react"; +import { useEffect, useId, useRef, useState } from "react"; import { Button } from "../Controls/Button"; import type React from "react"; import type { ReactNode } from "react"; -export type ActionConfirmationStatus = "pending" | "confirmed" | "dismissed"; - interface ActionConfirmationCardProps { /** * Title of the permission step. Defaults to a generic "Allow this action?" @@ -39,14 +37,6 @@ interface ActionConfirmationCardProps { allowOnceLabel?: string; alwaysAllowLabel?: string; denyLabel?: string; - /** - * Lifecycle state. `pending` renders the decision buttons; - * `confirmed` / `dismissed` render a compact resolved row so the decision - * stays visible in the transcript. - */ - status?: ActionConfirmationStatus; - /** Text for the resolved row (e.g. "Reply opened"). */ - resolvedLabel?: string; /** Disables the buttons while the allowed action is executing. */ isBusy?: boolean; /** @@ -87,11 +77,14 @@ const focusIfUnclaimed = (card: HTMLElement | null) => { * case "always allow" renders greyed out with the reason. * * Unlike a modal, the card lives WITH the proposal in the conversation: it - * never steals focus, multiple proposals can be pending independently, and a - * resolved card stays visible as a record of the decision. The component is - * deliberately execution-agnostic — the callbacks may run a client-side - * action (e.g. the Outlook add-in opening a reply form) or call an approval - * endpoint for server-gated tools; the card only renders the decision step. + * never steals focus and multiple proposals can be pending independently. The + * component is deliberately execution-agnostic — the callbacks may run a + * client-side action or call an approval endpoint for server-gated tools; the + * card only renders the decision step. + * + * The resolved-row persistent record (status / resolvedLabel props) was + * removed in 2026-07: callers now close the card on resolution and show + * transient button feedback (e.g. "Opened!") using `useTransientLabel`. */ export const ActionConfirmationCard: React.FC = ({ title, @@ -103,8 +96,6 @@ export const ActionConfirmationCard: React.FC = ({ allowOnceLabel, alwaysAllowLabel, denyLabel, - status = "pending", - resolvedLabel, isBusy = false, scrollIntoViewOnMount = false, className = "", @@ -112,149 +103,102 @@ export const ActionConfirmationCard: React.FC = ({ }) => { const cardRef = useRef(null); const alwaysAllowReasonId = useId(); - const isPending = status === "pending"; useEffect(() => { if (scrollIntoViewOnMount) { cardRef.current?.scrollIntoView({ block: "nearest" }); } - if (isPending) { - // A card mounted already-resolved (e.g. a reloaded transcript) must - // not grab focus. - focusIfUnclaimed(cardRef.current); - } + focusIfUnclaimed(cardRef.current); // Mount-only by design: re-scrolling on later prop changes would yank // the viewport while the user reads or scrolls elsewhere. // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const previousStatusRef = useRef(status); - // Layout effect so focus moves before paint — with a passive effect, focus - // would sit on for a frame after the button row unmounts before - // focusIfUnclaimed claims it. - useLayoutEffect(() => { - const previousStatus = previousStatusRef.current; - previousStatusRef.current = status; - if (previousStatus === "pending" && status !== "pending") { - focusIfUnclaimed(cardRef.current); - } - }, [status]); - - const resolvedTitle = + const cardTitle = title ?? t({ id: "actionConfirmation.title", message: "Allow this action?", }); - const resolvedRowLabel = - resolvedLabel ?? - (status === "confirmed" - ? t({ - id: "actionConfirmation.confirmed", - message: "Confirmed", - }) - : t({ - id: "actionConfirmation.dismissed", - message: "Dismissed", - })); - // Live regions only announce mutations, so the region starts empty and is - // populated after paint — both the card's appearance and the - // pending→resolved transition then announce politely (non-modal by - // design: announce instead of trapping focus). A card mounted - // already-resolved (e.g. a reloaded transcript) stays silent, otherwise - // every historical card would queue an announcement. + // populated after paint — the card's appearance then announces politely + // (non-modal by design: announce instead of trapping focus). const [announcement, setAnnouncement] = useState(""); - const wasPendingAtMountRef = useRef(isPending); - const announcementText = isPending ? resolvedTitle : resolvedRowLabel; useEffect(() => { - if (wasPendingAtMountRef.current) { - setAnnouncement(announcementText); - } - }, [announcementText]); + setAnnouncement(cardTitle); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); return (
- // when the trigger disables on open or the button row unmounts. + // when the trigger disables on open. tabIndex={-1} - role={isPending ? "group" : undefined} - aria-label={isPending ? resolvedTitle : undefined} - className={ - isPending - ? `mt-2 space-y-2 rounded-md border border-theme-border bg-theme-bg-primary p-3 ${className}` - : `mt-2 rounded-md border border-theme-border bg-theme-bg-primary px-3 py-2 text-xs text-theme-fg-muted ${className}` - } + role="group" + aria-label={cardTitle} + className={`mt-2 space-y-2 rounded-md border border-theme-border bg-theme-bg-primary p-3 ${className}`} data-testid={dataTestId} > - {isPending ? ( - <> -

- {resolvedTitle} -

- {typeof description === "string" ? ( -

{description}

- ) : ( - description - )} -
- - {onAlwaysAllow && ( - - )} - -
- {alwaysAllowDisabledReason && ( -

- {alwaysAllowDisabledReason} -

- )} - +

{cardTitle}

+ {typeof description === "string" ? ( +

{description}

) : ( - resolvedRowLabel + description + )} +
+ + {onAlwaysAllow && ( + + )} + +
+ {alwaysAllowDisabledReason && ( +

+ {alwaysAllowDisabledReason} +

)}

{announcement} diff --git a/frontend/src/library/index.ts b/frontend/src/library/index.ts index d24a8a9c6..24720dcac 100644 --- a/frontend/src/library/index.ts +++ b/frontend/src/library/index.ts @@ -27,10 +27,7 @@ export { DefaultMessageControls } from "@/components/ui/Message/DefaultMessageCo export { DefaultEratoEmailCodeBlock } from "@/components/ui/Message/EratoEmailSuggestion"; export { FilePreviewModal } from "@/components/ui/Modal/FilePreviewModal"; export { ModalBase } from "@/components/ui/Modal/ModalBase"; -export { - ActionConfirmationCard, - type ActionConfirmationStatus, -} from "@/components/ui/Message/ActionConfirmationCard"; +export { ActionConfirmationCard } from "@/components/ui/Message/ActionConfirmationCard"; export { AppearanceTabContent } from "@/components/ui/Settings/AppearanceTabContent"; export { AudioInputTabContent } from "@/components/ui/Settings/AudioInputTabContent"; export { From 025c031c48ab1d404f104e419720c0b72bd62b59 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 6 Jul 2026 17:58:51 +0000 Subject: [PATCH 3/6] refactor: migrate all web label-swap call sites to useTransientLabel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces six independent ad-hoc implementations of the ~2s state+setTimeout copy-feedback pattern with the shared useTransientLabel hook. Sites migrated: - MessageContent.tsx (code-block copy button) — adds srAnnouncement live region - EratoEmailSuggestion.tsx (email copy button) - DefaultMessageControls.tsx (icon-only copy; removes useEffect+clearTimeout) - ChatShareDialog.tsx (share-URL copy; removes manual setIsCopied(false) on toggle-off) - ChatMessage.tsx (error-report copy button) - customer/examples/MessageControls.example.tsx (example override) No behaviour changes beyond eliminating timer leaks on unmount and standardising the 2000 ms reset across all sites. Co-authored-by: Daniel --- frontend/src/components/ui/Chat/ChatMessage.tsx | 9 +++++---- .../src/components/ui/Chat/ChatShareDialog.tsx | 11 ++++------- .../ui/Message/DefaultMessageControls.tsx | 16 ++++------------ .../ui/Message/EratoEmailSuggestion.tsx | 10 +++++----- .../components/ui/Message/MessageContent.tsx | 12 ++++++++---- .../examples/MessageControls.example.tsx | 17 +++++------------ 6 files changed, 31 insertions(+), 44 deletions(-) diff --git a/frontend/src/components/ui/Chat/ChatMessage.tsx b/frontend/src/components/ui/Chat/ChatMessage.tsx index 10ea148df..a1fd40792 100644 --- a/frontend/src/components/ui/Chat/ChatMessage.tsx +++ b/frontend/src/components/ui/Chat/ChatMessage.tsx @@ -6,6 +6,7 @@ import { memo, useCallback, useState } from "react"; import { InteractiveContainer } from "@/components/ui/Container/InteractiveContainer"; import { FilePreviewButton } from "@/components/ui/FileUpload/FilePreviewButton"; import { useImageLightbox } from "@/hooks/ui/useImageLightbox"; +import { useTransientLabel } from "@/hooks/ui/useTransientLabel"; import { useGetFile } from "@/lib/generated/v1betaApi/v1betaApiComponents"; import { useErrorReportFeature, @@ -119,7 +120,8 @@ export const ChatMessage = memo(function ChatMessage({ // Local state for raw markdown toggle const [showRawMarkdown, setShowRawMarkdown] = useState(false); - const [isErrorReportCopied, setIsErrorReportCopied] = useState(false); + const { isActive: isErrorReportCopied, trigger: triggerErrorReportCopied } = + useTransientLabel(); const handleToggleRawMarkdown = useCallback( () => setShowRawMarkdown((prev) => !prev), [], @@ -130,9 +132,8 @@ export const ChatMessage = memo(function ChatMessage({ } await navigator.clipboard.writeText(message.error_report); - setIsErrorReportCopied(true); - window.setTimeout(() => setIsErrorReportCopied(false), 2000); - }, [message.error_report]); + triggerErrorReportCopied(); + }, [message.error_report, triggerErrorReportCopied]); // Use custom hook for image lightbox state management const lightbox = useImageLightbox(); diff --git a/frontend/src/components/ui/Chat/ChatShareDialog.tsx b/frontend/src/components/ui/Chat/ChatShareDialog.tsx index 4267b96a5..fae791871 100644 --- a/frontend/src/components/ui/Chat/ChatShareDialog.tsx +++ b/frontend/src/components/ui/Chat/ChatShareDialog.tsx @@ -1,12 +1,13 @@ import { t } from "@lingui/core/macro"; import clsx from "clsx"; -import { useMemo, useState } from "react"; +import { useMemo } from "react"; import { InteractiveContainer } from "@/components/ui/Container/InteractiveContainer"; import { Button } from "@/components/ui/Controls/Button"; import { Alert } from "@/components/ui/Feedback/Alert"; import { Input } from "@/components/ui/Input/Input"; import { ModalBase } from "@/components/ui/Modal/ModalBase"; +import { useTransientLabel } from "@/hooks/ui/useTransientLabel"; import { useChatShareLink } from "@/hooks/useChatShareLink"; import { CheckIcon, CopyIcon } from "../icons"; @@ -28,7 +29,7 @@ export function ChatShareDialog({ const { shareLink, setEnabled, isUpdating } = useChatShareLink( isOpen ? chatId : null, ); - const [isCopied, setIsCopied] = useState(false); + const { isActive: isCopied, trigger: triggerCopied } = useTransientLabel(); const shareUrl = useMemo(() => { if (!shareLink?.enabled) { @@ -47,9 +48,6 @@ export function ChatShareDialog({ } await setEnabled(enabled); - if (!enabled) { - setIsCopied(false); - } }; const handleCopy = async () => { @@ -58,8 +56,7 @@ export function ChatShareDialog({ } await navigator.clipboard.writeText(shareUrl); - setIsCopied(true); - window.setTimeout(() => setIsCopied(false), 2000); + triggerCopied(); }; return ( diff --git a/frontend/src/components/ui/Message/DefaultMessageControls.tsx b/frontend/src/components/ui/Message/DefaultMessageControls.tsx index b244e306a..6917ac722 100644 --- a/frontend/src/components/ui/Message/DefaultMessageControls.tsx +++ b/frontend/src/components/ui/Message/DefaultMessageControls.tsx @@ -6,6 +6,7 @@ import { memo, useState, useEffect, useCallback } from "react"; import { Button } from "@/components/ui/Controls/Button"; import { MessageTimestamp } from "@/components/ui/Message/MessageTimestamp"; +import { useTransientLabel } from "@/hooks/ui/useTransientLabel"; import { createLogger } from "@/utils/debugLogger"; import { @@ -40,7 +41,7 @@ export const DefaultMessageControls = memo(function DefaultMessageControls({ hasToolCalls = false, onViewFeedback, }: MessageControlsProps) { - const [isCopied, setIsCopied] = useState(false); + const { isActive: isCopied, trigger: triggerCopied } = useTransientLabel(); const controlsRowStyle = { gap: "var(--theme-spacing-control-gap)", } as const; @@ -73,15 +74,6 @@ export const DefaultMessageControls = memo(function DefaultMessageControls({ } }, [initialFeedback]); - useEffect(() => { - if (isCopied) { - const timer = setTimeout(() => { - setIsCopied(false); - }, 2000); - return () => clearTimeout(timer); - } - }, [isCopied]); - const handleAction = useCallback( async (actionType: "copy" | "edit" | "regenerate" | "like" | "dislike") => { // Determine if user is clicking the same sentiment they already submitted @@ -122,7 +114,7 @@ export const DefaultMessageControls = memo(function DefaultMessageControls({ if (success) { if (actionType === "copy") { - setIsCopied(true); + triggerCopied(); } else if (actionType === "like") { setFeedbackState("liked"); } else if (actionType === "dislike") { @@ -133,7 +125,7 @@ export const DefaultMessageControls = memo(function DefaultMessageControls({ logger.log(`Action '${actionType}' failed for message ${messageId}`); } }, - [onAction, messageId, feedbackState, initialFeedback, onViewFeedback], + [onAction, messageId, feedbackState, initialFeedback, onViewFeedback, triggerCopied], ); // Ensure safeCreatedAt is always a Date object diff --git a/frontend/src/components/ui/Message/EratoEmailSuggestion.tsx b/frontend/src/components/ui/Message/EratoEmailSuggestion.tsx index fd0f24754..5f30ab136 100644 --- a/frontend/src/components/ui/Message/EratoEmailSuggestion.tsx +++ b/frontend/src/components/ui/Message/EratoEmailSuggestion.tsx @@ -1,7 +1,8 @@ import { t } from "@lingui/core/macro"; -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useMemo } from "react"; import { componentRegistry } from "@/config/componentRegistry"; +import { useTransientLabel } from "@/hooks/ui/useTransientLabel"; import { copyEmailToClipboard } from "@/utils/emailClipboard"; import { sanitizeHtmlPreview } from "@/utils/sanitizeHtmlPreview"; @@ -18,19 +19,18 @@ export function DefaultEratoEmailCodeBlock({ content, isHtml, }: EratoEmailCodeBlockProps) { - const [copied, setCopied] = useState(false); + const { isActive: copied, trigger: triggerCopied } = useTransientLabel(); const previewHtml = useMemo(() => sanitizeHtmlPreview(content), [content]); const handleCopy = useCallback(() => { void copyEmailToClipboard(content, isHtml ?? false) .then(() => { - setCopied(true); - setTimeout(() => setCopied(false), 2000); + triggerCopied(); }) .catch(() => { // Fallback: ignore clipboard errors }); - }, [content, isHtml]); + }, [content, isHtml, triggerCopied]); return (

diff --git a/frontend/src/components/ui/Message/MessageContent.tsx b/frontend/src/components/ui/Message/MessageContent.tsx index 68917173d..a81b7abf5 100644 --- a/frontend/src/components/ui/Message/MessageContent.tsx +++ b/frontend/src/components/ui/Message/MessageContent.tsx @@ -19,6 +19,7 @@ import { } from "@/config/codeHighlightThemes"; import { componentRegistry } from "@/config/componentRegistry"; import { useOptionalTranslation } from "@/hooks/i18n"; +import { useTransientLabel } from "@/hooks/ui/useTransientLabel"; import { useTraceFeature } from "@/providers/FeatureConfigProvider"; import { FileTypeUtil } from "@/utils/fileTypes"; @@ -197,7 +198,8 @@ function MarkdownPre({ ...props }: MarkdownPreProps) { const artifact = React.useContext(OutlookArtifactContext); - const [copied, setCopied] = React.useState(false); + const { isActive: copied, trigger: triggerCopied, srAnnouncement } = + useTransientLabel({ announcement: t`Copied to clipboard` }); // Extract the raw code text from the child element so the copy button // can access it without needing a separate context or ref strategy. @@ -215,11 +217,10 @@ function MarkdownPre({ void navigator.clipboard .writeText(codeContent) .then(() => { - setCopied(true); - setTimeout(() => setCopied(false), 2000); + triggerCopied(); }) .catch(() => {}); - }, [codeContent]); + }, [codeContent, triggerCopied]); // erato-email / erato-appointment blocks render a custom component, not a // code block — use a plain
to avoid inheriting
 monospace font
@@ -263,6 +264,9 @@ function MarkdownPre({
           
         )}
       
+      

+ {srAnnouncement} +

); } diff --git a/frontend/src/customer/examples/MessageControls.example.tsx b/frontend/src/customer/examples/MessageControls.example.tsx index 0dd48428c..ada8c8fd9 100644 --- a/frontend/src/customer/examples/MessageControls.example.tsx +++ b/frontend/src/customer/examples/MessageControls.example.tsx @@ -25,7 +25,7 @@ */ import clsx from "clsx"; -import { useState, useEffect, useCallback } from "react"; +import { useCallback, useState } from "react"; import { Button } from "@/components/ui/Controls/Button"; import { DropdownMenu } from "@/components/ui/Controls/DropdownMenu"; @@ -38,6 +38,7 @@ import { ShareIcon, MoreVertical, } from "@/components/ui/icons"; +import { useTransientLabel } from "@/hooks/ui/useTransientLabel"; import { createLogger } from "@/utils/debugLogger"; import type { DropdownMenuItem } from "@/components/ui/Controls/DropdownMenu"; @@ -108,7 +109,7 @@ export const MessageControls = ({ showRawMarkdown = false, onToggleRawMarkdown, }: MessageControlsProps) => { - const [isCopied, setIsCopied] = useState(false); + const { isActive: isCopied, trigger: triggerCopied } = useTransientLabel(); // Emoji reactions state (demo with mock data) const [reactions, setReactions] = useState>({ @@ -126,14 +127,6 @@ export const MessageControls = ({ // Chat-level edit permission const canEditChat = context.canEdit !== false; - // Reset copy state after 2 seconds - useEffect(() => { - if (isCopied) { - const timer = setTimeout(() => setIsCopied(false), 2000); - return () => clearTimeout(timer); - } - }, [isCopied]); - // Mock metadata (extend MessageControlsProps to pass real data) const metadata: MessageMetadata = { model: isUserMessage ? undefined : "GPT-4", @@ -146,10 +139,10 @@ export const MessageControls = ({ const handleCopy = useCallback(async () => { const success = await onAction({ type: "copy", messageId }); if (success) { - setIsCopied(true); + triggerCopied(); logger.log(`Copy succeeded for message ${messageId}`); } - }, [onAction, messageId]); + }, [onAction, messageId, triggerCopied]); const handleEdit = useCallback(async () => { const success = await onAction({ type: "edit", messageId }); From 767555cf11b717c4bf00007b3a505d3e8efceada Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 6 Jul 2026 17:58:59 +0000 Subject: [PATCH 4/6] docs(components): document success-feedback convention Establishes and documents the three-tier success/confirmation UX: - Transient ~2s label swap (useTransientLabel) for micro-actions - Toasts reserved for auth/permission and session/decision flows - Errors use sticky toast or inline Alert Resolves ERMAIN-435 item 4 (document the convention). Co-authored-by: Daniel --- frontend/src/components/README.md | 62 +++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 frontend/src/components/README.md diff --git a/frontend/src/components/README.md b/frontend/src/components/README.md new file mode 100644 index 000000000..782823b62 --- /dev/null +++ b/frontend/src/components/README.md @@ -0,0 +1,62 @@ +# UI Components — Conventions + +## Success / confirmation feedback + +### The canonical pattern: transient label swap + +All success feedback in the UI uses a **~2 s transient label/icon swap**. +Use the shared `useTransientLabel` hook from `@/hooks/ui/useTransientLabel`: + +```tsx +import { useTransientLabel } from "@/hooks/ui/useTransientLabel"; +import { t } from "@lingui/core/macro"; + +function CopyButton({ text }: { text: string }) { + const { isActive: isCopied, trigger: triggerCopied, srAnnouncement } = + useTransientLabel({ announcement: t`Copied to clipboard` }); + + return ( + <> + + {/* sr-only live region — include whenever the visible change is icon-only */} +

{srAnnouncement}

+ + ); +} +``` + +**Why not ad-hoc `useState` + `setTimeout`?** +The hook handles timer cleanup on unmount (no leak), restarts the timer on +rapid re-triggers, and optionally surfaces a `role="status"` announcement for +screen readers that would otherwise miss a silent icon swap. + +**Default delay: 2000 ms.** Pass `{ delay: N }` to override. + +### Accessibility + +Supply the `announcement` option whenever the only visible change is an icon +swap (no text label change). For buttons that already change their text label +(e.g. "Copy" → "Copied!"), the announcement is optional but harmless. + +Render `srAnnouncement` in a `role="status"` / `aria-live="polite"` sr-only +element. The live region is empty when inactive, so it only fires once on +trigger — not on every render. + +--- + +## What NOT to use for success feedback + +| Idiom | Use for | +|---|---| +| **Toasts** (`toast.success` / ``) | Auth/permission flows, session decisions ("You switched conversation", Graph sign-in prompts). **Not** for micro-actions like copy or form submission confirmations. | +| **Persistent resolved row** (removed 2026-07) | Was used by `ActionConfirmationCard`; removed because callers now close the card on resolution and show transient feedback on the trigger button instead. | + +Errors continue to use **sticky toast** (`toast.error`) or **inline ``** depending on scope (global vs. component-local). From ede7b475a647138a21386d0f2f007f5bbd8823bc Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 6 Jul 2026 18:04:16 +0000 Subject: [PATCH 5/6] chore(i18n): update locale catalogs for ERMAIN-435 changes - Add new 'Copied to clipboard' message (from MessageContent.tsx srAnnouncement) - Mark removed actionConfirmation.confirmed and actionConfirmation.dismissed messages as obsolete (ActionConfirmationCard resolved-row cleanup) Co-authored-by: Daniel --- frontend/src/locales/de/messages.po | 12 ++++++++---- frontend/src/locales/en/messages.po | 12 ++++++++---- frontend/src/locales/es/messages.po | 12 ++++++++---- frontend/src/locales/fr/messages.po | 12 ++++++++---- frontend/src/locales/pl/messages.po | 12 ++++++++---- 5 files changed, 40 insertions(+), 20 deletions(-) diff --git a/frontend/src/locales/de/messages.po b/frontend/src/locales/de/messages.po index a0449b0b0..5daafec61 100644 --- a/frontend/src/locales/de/messages.po +++ b/frontend/src/locales/de/messages.po @@ -33,8 +33,8 @@ msgstr "Immer erlauben" #. js-lingui-explicit-id #: src/components/ui/Message/ActionConfirmationCard.tsx -msgid "actionConfirmation.confirmed" -msgstr "Bestätigt" +#~ msgid "actionConfirmation.confirmed" +#~ msgstr "Bestätigt" #. js-lingui-explicit-id #: src/components/ui/Message/ActionConfirmationCard.tsx @@ -43,8 +43,8 @@ msgstr "Ablehnen" #. js-lingui-explicit-id #: src/components/ui/Message/ActionConfirmationCard.tsx -msgid "actionConfirmation.dismissed" -msgstr "Verworfen" +#~ msgid "actionConfirmation.dismissed" +#~ msgstr "Verworfen" #. js-lingui-explicit-id #: src/components/ui/Message/ActionConfirmationCard.tsx @@ -3053,6 +3053,10 @@ msgstr "" msgid "Copied" msgstr "" +#: src/components/ui/Message/MessageContent.tsx +msgid "Copied to clipboard" +msgstr "" + #: src/components/ui/Message/EratoEmailSuggestion.tsx msgid "Copied!" msgstr "" diff --git a/frontend/src/locales/en/messages.po b/frontend/src/locales/en/messages.po index 7538b1a4c..022922b73 100644 --- a/frontend/src/locales/en/messages.po +++ b/frontend/src/locales/en/messages.po @@ -33,8 +33,8 @@ msgstr "Always allow" #. js-lingui-explicit-id #: src/components/ui/Message/ActionConfirmationCard.tsx -msgid "actionConfirmation.confirmed" -msgstr "Confirmed" +#~ msgid "actionConfirmation.confirmed" +#~ msgstr "Confirmed" #. js-lingui-explicit-id #: src/components/ui/Message/ActionConfirmationCard.tsx @@ -43,8 +43,8 @@ msgstr "Deny" #. js-lingui-explicit-id #: src/components/ui/Message/ActionConfirmationCard.tsx -msgid "actionConfirmation.dismissed" -msgstr "Dismissed" +#~ msgid "actionConfirmation.dismissed" +#~ msgstr "Dismissed" #. js-lingui-explicit-id #: src/components/ui/Message/ActionConfirmationCard.tsx @@ -3053,6 +3053,10 @@ msgstr "Conversational" msgid "Copied" msgstr "Copied" +#: src/components/ui/Message/MessageContent.tsx +msgid "Copied to clipboard" +msgstr "Copied to clipboard" + #: src/components/ui/Message/EratoEmailSuggestion.tsx msgid "Copied!" msgstr "Copied!" diff --git a/frontend/src/locales/es/messages.po b/frontend/src/locales/es/messages.po index 69e629743..dcefac884 100644 --- a/frontend/src/locales/es/messages.po +++ b/frontend/src/locales/es/messages.po @@ -33,8 +33,8 @@ msgstr "Permitir siempre" #. js-lingui-explicit-id #: src/components/ui/Message/ActionConfirmationCard.tsx -msgid "actionConfirmation.confirmed" -msgstr "Confirmado" +#~ msgid "actionConfirmation.confirmed" +#~ msgstr "Confirmado" #. js-lingui-explicit-id #: src/components/ui/Message/ActionConfirmationCard.tsx @@ -43,8 +43,8 @@ msgstr "Denegar" #. js-lingui-explicit-id #: src/components/ui/Message/ActionConfirmationCard.tsx -msgid "actionConfirmation.dismissed" -msgstr "Descartado" +#~ msgid "actionConfirmation.dismissed" +#~ msgstr "Descartado" #. js-lingui-explicit-id #: src/components/ui/Message/ActionConfirmationCard.tsx @@ -3053,6 +3053,10 @@ msgstr "" msgid "Copied" msgstr "" +#: src/components/ui/Message/MessageContent.tsx +msgid "Copied to clipboard" +msgstr "" + #: src/components/ui/Message/EratoEmailSuggestion.tsx msgid "Copied!" msgstr "" diff --git a/frontend/src/locales/fr/messages.po b/frontend/src/locales/fr/messages.po index 8a2b7f51c..665cbf5a9 100644 --- a/frontend/src/locales/fr/messages.po +++ b/frontend/src/locales/fr/messages.po @@ -33,8 +33,8 @@ msgstr "Toujours autoriser" #. js-lingui-explicit-id #: src/components/ui/Message/ActionConfirmationCard.tsx -msgid "actionConfirmation.confirmed" -msgstr "Confirmé" +#~ msgid "actionConfirmation.confirmed" +#~ msgstr "Confirmé" #. js-lingui-explicit-id #: src/components/ui/Message/ActionConfirmationCard.tsx @@ -43,8 +43,8 @@ msgstr "Refuser" #. js-lingui-explicit-id #: src/components/ui/Message/ActionConfirmationCard.tsx -msgid "actionConfirmation.dismissed" -msgstr "Ignoré" +#~ msgid "actionConfirmation.dismissed" +#~ msgstr "Ignoré" #. js-lingui-explicit-id #: src/components/ui/Message/ActionConfirmationCard.tsx @@ -3053,6 +3053,10 @@ msgstr "" msgid "Copied" msgstr "" +#: src/components/ui/Message/MessageContent.tsx +msgid "Copied to clipboard" +msgstr "" + #: src/components/ui/Message/EratoEmailSuggestion.tsx msgid "Copied!" msgstr "" diff --git a/frontend/src/locales/pl/messages.po b/frontend/src/locales/pl/messages.po index 07656a576..2c4ffaa21 100644 --- a/frontend/src/locales/pl/messages.po +++ b/frontend/src/locales/pl/messages.po @@ -33,8 +33,8 @@ msgstr "Zawsze zezwalaj" #. js-lingui-explicit-id #: src/components/ui/Message/ActionConfirmationCard.tsx -msgid "actionConfirmation.confirmed" -msgstr "Potwierdzono" +#~ msgid "actionConfirmation.confirmed" +#~ msgstr "Potwierdzono" #. js-lingui-explicit-id #: src/components/ui/Message/ActionConfirmationCard.tsx @@ -43,8 +43,8 @@ msgstr "Odmów" #. js-lingui-explicit-id #: src/components/ui/Message/ActionConfirmationCard.tsx -msgid "actionConfirmation.dismissed" -msgstr "Odrzucono" +#~ msgid "actionConfirmation.dismissed" +#~ msgstr "Odrzucono" #. js-lingui-explicit-id #: src/components/ui/Message/ActionConfirmationCard.tsx @@ -3053,6 +3053,10 @@ msgstr "" msgid "Copied" msgstr "" +#: src/components/ui/Message/MessageContent.tsx +msgid "Copied to clipboard" +msgstr "" + #: src/components/ui/Message/EratoEmailSuggestion.tsx msgid "Copied!" msgstr "" From 44b88fddb448d26782cf2ec4c18e8b22c8fe2e92 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 6 Jul 2026 18:13:10 +0000 Subject: [PATCH 6/6] style: apply prettier formatting to ERMAIN-435 changes Co-authored-by: Daniel --- frontend/src/components/README.md | 19 ++++++++++++------- .../ui/Message/DefaultMessageControls.tsx | 9 ++++++++- .../components/ui/Message/MessageContent.tsx | 7 +++++-- 3 files changed, 25 insertions(+), 10 deletions(-) diff --git a/frontend/src/components/README.md b/frontend/src/components/README.md index 782823b62..bf986a28a 100644 --- a/frontend/src/components/README.md +++ b/frontend/src/components/README.md @@ -12,8 +12,11 @@ import { useTransientLabel } from "@/hooks/ui/useTransientLabel"; import { t } from "@lingui/core/macro"; function CopyButton({ text }: { text: string }) { - const { isActive: isCopied, trigger: triggerCopied, srAnnouncement } = - useTransientLabel({ announcement: t`Copied to clipboard` }); + const { + isActive: isCopied, + trigger: triggerCopied, + srAnnouncement, + } = useTransientLabel({ announcement: t`Copied to clipboard` }); return ( <> @@ -26,7 +29,9 @@ function CopyButton({ text }: { text: string }) { {isCopied ? "Copied!" : "Copy"} {/* sr-only live region — include whenever the visible change is icon-only */} -

{srAnnouncement}

+

+ {srAnnouncement} +

); } @@ -53,10 +58,10 @@ trigger — not on every render. ## What NOT to use for success feedback -| Idiom | Use for | -|---|---| -| **Toasts** (`toast.success` / ``) | Auth/permission flows, session decisions ("You switched conversation", Graph sign-in prompts). **Not** for micro-actions like copy or form submission confirmations. | -| **Persistent resolved row** (removed 2026-07) | Was used by `ActionConfirmationCard`; removed because callers now close the card on resolution and show transient feedback on the trigger button instead. | +| Idiom | Use for | +| --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Toasts** (`toast.success` / ``) | Auth/permission flows, session decisions ("You switched conversation", Graph sign-in prompts). **Not** for micro-actions like copy or form submission confirmations. | +| **Persistent resolved row** (removed 2026-07) | Was used by `ActionConfirmationCard`; removed because callers now close the card on resolution and show transient feedback on the trigger button instead. | Errors continue to use **sticky toast** (`toast.error`) or **inline ``** depending on scope (global vs. component-local). diff --git a/frontend/src/components/ui/Message/DefaultMessageControls.tsx b/frontend/src/components/ui/Message/DefaultMessageControls.tsx index 6917ac722..511603f3f 100644 --- a/frontend/src/components/ui/Message/DefaultMessageControls.tsx +++ b/frontend/src/components/ui/Message/DefaultMessageControls.tsx @@ -125,7 +125,14 @@ export const DefaultMessageControls = memo(function DefaultMessageControls({ logger.log(`Action '${actionType}' failed for message ${messageId}`); } }, - [onAction, messageId, feedbackState, initialFeedback, onViewFeedback, triggerCopied], + [ + onAction, + messageId, + feedbackState, + initialFeedback, + onViewFeedback, + triggerCopied, + ], ); // Ensure safeCreatedAt is always a Date object diff --git a/frontend/src/components/ui/Message/MessageContent.tsx b/frontend/src/components/ui/Message/MessageContent.tsx index a81b7abf5..1e26e33d8 100644 --- a/frontend/src/components/ui/Message/MessageContent.tsx +++ b/frontend/src/components/ui/Message/MessageContent.tsx @@ -198,8 +198,11 @@ function MarkdownPre({ ...props }: MarkdownPreProps) { const artifact = React.useContext(OutlookArtifactContext); - const { isActive: copied, trigger: triggerCopied, srAnnouncement } = - useTransientLabel({ announcement: t`Copied to clipboard` }); + const { + isActive: copied, + trigger: triggerCopied, + srAnnouncement, + } = useTransientLabel({ announcement: t`Copied to clipboard` }); // Extract the raw code text from the child element so the copy button // can access it without needing a separate context or ref strategy.