Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions scripts/e2e/run-playwright-smoke.js
Original file line number Diff line number Diff line change
Expand Up @@ -766,8 +766,6 @@ async function assertInputProcessCopyJourney(context, baseUrl, locale) {

const copyButton = page.getByRole("button", { name: /^Copy$/ }).first();
await copyButton.waitFor({ state: "visible", timeout: 15_000 });
// Wait for DeferredToaster to mount (which has a 2000ms delay)
await page.waitForTimeout(2500);
await copyButton.click();

const copiedToast = page.getByText(/copied/i).first();
Expand Down
4 changes: 2 additions & 2 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { Metadata } from "next";
import "@/app/globals.css";
import { DeferredToaster } from "@/components/ui/deferred-toaster";
import { AppToaster } from "@/components/ui/app-toaster";
import { PWA_THEME_COLOR } from "@/core/pwa/constants";
import { buildDefaultOgImageUrl, buildSiteKeywords } from "@/core/seo/seo";

Expand Down Expand Up @@ -47,7 +47,7 @@ export default function RootLayout({
</head>
<body className="antialiased" suppressHydrationWarning>
{children}
<DeferredToaster />
<AppToaster />
</body>
</html>
);
Expand Down
12 changes: 12 additions & 0 deletions src/components/ui/app-toaster.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
"use client"

import dynamic from "next/dynamic"

const Toaster = dynamic(
() => import("./sonner").then((mod) => mod.Toaster),
{ ssr: false },
)

export function AppToaster() {
return <Toaster />
}
15 changes: 0 additions & 15 deletions src/components/ui/deferred-toaster.tsx

This file was deleted.

98 changes: 50 additions & 48 deletions src/components/ui/sonner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,61 +8,63 @@ import {
TriangleAlertIcon,
} from "lucide-react"
import * as React from "react"
import { Toaster as Sonner, useSonner, type ToasterProps } from "sonner"
import { Toaster as Sonner, toast, type ToasterProps } from "sonner"
import { drainQueuedToastFeedback, setToastLiveRegionReady } from "@/core/feedback/toast-live-region-state"
import { useThemePreference } from "@/hooks/use-theme-preference"

function toastNodeToText(value: React.ReactNode | (() => React.ReactNode)): string {
if (value === null || value === undefined || typeof value === "boolean") return ""
if (typeof value === "function") return toastNodeToText(value())
if (typeof value === "string" || typeof value === "number" || typeof value === "bigint") return String(value)
if (Array.isArray(value)) return value.map(toastNodeToText).filter(Boolean).join(" ")
if (React.isValidElement(value)) {
return toastNodeToText((value.props as { children?: React.ReactNode }).children)
}
return ""
}
const Toaster = ({ ...props }: ToasterProps) => {
const { resolvedTheme } = useThemePreference()
const sonnerRef = React.useRef<HTMLElement | null>(null)

function ToastLiveRegion() {
const { toasts } = useSonner()
const latestToast = toasts.find((toast) => !toast.delete)
const message = latestToast
? [toastNodeToText(latestToast.title), toastNodeToText(latestToast.description)].filter(Boolean).join(". ")
: ""
React.useEffect(() => {
const liveRegion = sonnerRef.current
const queuedFeedback = drainQueuedToastFeedback()
const previousLiveMode = liveRegion?.getAttribute("aria-live") || "polite"

return (
<div className="sr-only" role="status" aria-live="polite" aria-atomic="true" data-toast-live-region>
{message}
</div>
)
}
if (queuedFeedback.length > 0) {
liveRegion?.setAttribute("aria-live", "off")
for (const feedback of queuedFeedback) {
const options = { id: feedback.id, description: feedback.description }
if (feedback.type === "error") {
toast.error(feedback.message, options)
} else {
toast.success(feedback.message, options)
}
}
}

const Toaster = ({ ...props }: ToasterProps) => {
const { resolvedTheme } = useThemePreference()
setToastLiveRegionReady(true)
if (queuedFeedback.length > 0) {
globalThis.setTimeout(() => liveRegion?.setAttribute("aria-live", previousLiveMode), 0)
}

return () => {
setToastLiveRegionReady(false)
}
}, [])

return (
<>
<ToastLiveRegion />
<Sonner
theme={resolvedTheme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: <CircleCheckIcon className="size-4" />,
info: <InfoIcon className="size-4" />,
warning: <TriangleAlertIcon className="size-4" />,
error: <OctagonXIcon className="size-4" />,
loading: <Loader2Icon className="size-4 animate-spin" />,
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
{...props}
/>
</>
<Sonner
ref={sonnerRef}
theme={resolvedTheme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: <CircleCheckIcon className="size-4" />,
info: <InfoIcon className="size-4" />,
warning: <TriangleAlertIcon className="size-4" />,
error: <OctagonXIcon className="size-4" />,
loading: <Loader2Icon className="size-4 animate-spin" />,
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
{...props}
/>
)
}

Expand Down
27 changes: 27 additions & 0 deletions src/core/feedback/toast-live-region-state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
export type QueuedToastFeedback = {
id: string | number
type: "success" | "error"
message: string
description?: string
}

let liveRegionReady = false
const queuedFeedback = new Map<string | number, QueuedToastFeedback>()

export function isToastLiveRegionReady() {
return liveRegionReady
}

export function setToastLiveRegionReady(ready: boolean) {
liveRegionReady = ready
}

export function queueToastFeedback(feedback: QueuedToastFeedback) {
queuedFeedback.set(feedback.id, feedback)
}

export function drainQueuedToastFeedback() {
const feedback = [...queuedFeedback.values()]
queuedFeedback.clear()
return feedback
}
14 changes: 10 additions & 4 deletions src/features/tool-shell/external-request-status.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,17 +63,16 @@ export function ExternalRequestStatus({
const Icon = STATUS_ICON[status]
const labels = t.common.external_request_status
const role = ASSERTIVE_STATUSES.has(status) ? "alert" : "status"
const boundary = `${labels.boundary_label}: ${t.common.external_network_notice.consent_required_message} ${hosts.join(", ")}`
const announcement = `${labels[status]}. ${message} ${labels.next_step_label}: ${nextStep} ${boundary}`

return (
<section
id={id}
role={role}
aria-live={role === "alert" ? "assertive" : "polite"}
aria-atomic="true"
data-external-request-status={status}
className={cn("rounded-lg border p-3 text-sm", STATUS_STYLE[status], className)}
>
<div className="flex items-start gap-2">
<div className="flex items-start gap-2" aria-hidden="true">
<Icon className={cn("mt-0.5 h-4 w-4 shrink-0", status === "requesting" && "animate-pulse")} aria-hidden />
<div className="min-w-0 flex-1 space-y-1.5">
<div className="font-semibold text-foreground">
Expand All @@ -91,6 +90,13 @@ export function ExternalRequestStatus({
</p>
</div>
</div>
<span
className="sr-only"
role={role}
aria-live={role === "alert" ? "assertive" : "polite"}
aria-atomic="true"
data-external-request-announcement
>{announcement}</span>
</section>
)
}
54 changes: 41 additions & 13 deletions src/features/tool-shell/tool-action-bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export type ToolActionResult = {
status: ToolActionResultStatus
message?: string
description?: string
announce?: boolean
}

export type ToolAction = {
Expand All @@ -48,6 +49,10 @@ export type ToolAction = {
}

type AnalyticsAction = "tool_run" | "copy_output" | "download_output" | null
type ActionAnnouncement = {
actionId: string
message: string
}

const ACTION_BASE_CLASS =
"inline-flex min-h-11 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 lg:min-h-9"
Expand Down Expand Up @@ -161,8 +166,9 @@ export function ToolActionBar({
}) {
const pathname = usePathname()
const { t, lang } = useLang()
const [pendingActionId, setPendingActionId] = React.useState<string | null>(null)
const [actionAnnouncement, setActionAnnouncement] = React.useState("")
const pendingActionIdsRef = React.useRef<Set<string>>(new Set())
const [pendingActionIds, setPendingActionIds] = React.useState<ReadonlySet<string>>(() => new Set())
const [actionAnnouncement, setActionAnnouncement] = React.useState<ActionAnnouncement | null>(null)
const [lastActionStatus, setLastActionStatus] = React.useState<Record<string, ToolActionRuntimeStatus>>({})
const toolKey = React.useMemo(() => {
const context = getRouteContext(pathname)
Expand Down Expand Up @@ -191,7 +197,7 @@ export function ToolActionBar({

const triggerAction = React.useCallback(
async (action: ToolAction) => {
if (action.disabled || pendingActionId === action.id) return
if (action.disabled || pendingActionIdsRef.current.has(action.id)) return
const analyticsAction = classifyAnalyticsAction(action.id)
if (toolKey && analyticsAction) {
if (analyticsAction === "tool_run") {
Expand All @@ -211,32 +217,54 @@ export function ToolActionBar({
...current,
[action.id]: isActionResult(maybeResult) ? maybeResult.status : "success",
}))
setActionAnnouncement(getResultAnnouncement(action, maybeResult, t))
if (isActionResult(maybeResult) && maybeResult.announce === false) {
setActionAnnouncement((current) => current?.actionId === action.id ? null : current)
} else {
setActionAnnouncement({
actionId: action.id,
message: getResultAnnouncement(action, maybeResult, t),
})
}
return
}

setPendingActionId(action.id)
pendingActionIdsRef.current.add(action.id)
setPendingActionIds(new Set(pendingActionIdsRef.current))
setLastActionStatus((current) => ({ ...current, [action.id]: "pending" }))
setActionAnnouncement(formatActionStatus(t.common.action_status_pending, action.label, "in progress"))
setActionAnnouncement({
actionId: action.id,
message: formatActionStatus(t.common.action_status_pending, action.label, "in progress"),
})
const awaitedResult = await maybeResult
if (isActionResult(awaitedResult)) {
setLastActionStatus((current) => ({
...current,
[action.id]: awaitedResult.status,
}))
setActionAnnouncement(getResultAnnouncement(action, awaitedResult, t))
if (awaitedResult.announce === false) {
setActionAnnouncement((current) => current?.actionId === action.id ? null : current)
} else {
setActionAnnouncement({
actionId: action.id,
message: getResultAnnouncement(action, awaitedResult, t),
})
}
} else {
setLastActionStatus((current) => ({ ...current, [action.id]: "idle" }))
setActionAnnouncement("")
setActionAnnouncement((current) => current?.actionId === action.id ? null : current)
}
} catch {
setLastActionStatus((current) => ({ ...current, [action.id]: "failed" }))
setActionAnnouncement(formatActionStatus(t.common.action_status_failed, action.label, "failed"))
setActionAnnouncement({
actionId: action.id,
message: formatActionStatus(t.common.action_status_failed, action.label, "failed"),
})
} finally {
setPendingActionId((current) => (current === action.id ? null : current))
pendingActionIdsRef.current.delete(action.id)
setPendingActionIds(new Set(pendingActionIdsRef.current))
}
},
[lang, pendingActionId, t, toolKey],
[lang, t, toolKey],
)

const handoffDisabledReason = !handoffPayload?.trim() ? t.common.action_disabled_no_output : undefined
Expand All @@ -256,7 +284,7 @@ export function ToolActionBar({
const title = disabledReason ? accessibleLabel : action.title || action.label
const disabledDescriptionId = disabledReason ? getActionDescriptionId(action.id) : undefined
const isDestructive = isDestructiveAction(action)
const isPending = pendingActionId === action.id
const isPending = pendingActionIds.has(action.id)
const runtimeStatus: ToolActionRuntimeStatus = action.disabled
? "disabled"
: isPending
Expand Down Expand Up @@ -393,7 +421,7 @@ export function ToolActionBar({
)}
{actionAnnouncement ? (
<span className="sr-only" role="status" aria-live="polite" aria-atomic="true" data-tool-action-status>
{actionAnnouncement}
{actionAnnouncement.message}
</span>
) : null}
</div>
Expand Down
21 changes: 15 additions & 6 deletions src/features/tool-shell/tool-action-feedback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { toast } from "sonner"
import type { TranslationType } from "@/core/i18n/lang-provider"
import { safeClipboardWrite } from "@/core/clipboard/clipboard"
import { isToastLiveRegionReady, queueToastFeedback } from "@/core/feedback/toast-live-region-state"
import type { ToolActionResult } from "./tool-action-bar"

type ActionFeedbackKind = "copy" | "download" | "export" | "share"
Expand All @@ -14,8 +15,8 @@ type ActionFeedbackOptions = {
description?: string
}

function result(status: "success" | "failed", message: string, description?: string): ToolActionResult {
return { status, message, description }
function result(status: "success" | "failed", message: string, description: string | undefined, announce: boolean): ToolActionResult {
return { status, message, description, announce }
}

export function notifyToolActionSuccess(
Expand All @@ -28,8 +29,12 @@ export function notifyToolActionSuccess(
const message = title || fallbackTitle
const detail = description || (kind === "copy" ? `${label}: ${t.common.copied_desc}` : undefined)

toast.success(message, detail ? { description: detail } : undefined)
return result("success", message, detail)
const announceInToolbar = !isToastLiveRegionReady()
const toastId = toast.success(message, detail ? { description: detail } : undefined)
if (announceInToolbar) {
queueToastFeedback({ id: toastId, type: "success", message, description: detail })
}
return result("success", message, detail, announceInToolbar)
}

export function notifyToolActionFailure(
Expand All @@ -39,8 +44,12 @@ export function notifyToolActionFailure(
const message = title || t.common.copy_failed
const detail = description || label

toast.error(message, detail ? { description: detail } : undefined)
return result("failed", message, detail)
const announceInToolbar = !isToastLiveRegionReady()
const toastId = toast.error(message, detail ? { description: detail } : undefined)
if (announceInToolbar) {
queueToastFeedback({ id: toastId, type: "error", message, description: detail })
}
return result("failed", message, detail, announceInToolbar)
}

export async function copyTextWithToolFeedback(
Expand Down
Loading