Merge branch 'v1422/fixes' into release/v1.42.2

This commit is contained in:
Marc-André Bombeck committed 2026-10-11 13:28:29 +02:00
commit 3c4e053dcd
83 files changed
+1808 -258

No files matched your search

+12
View File
@@ -717,6 +717,18 @@ function DaySheet({
event.preventDefault();
returnFocus();
}}
onEscapeKeyDown={(event) => {
// A dialog opened from inside the day (the date's calendar) is
// portaled out of the panel. For a moment after it opens, until
// Radix has re-rendered both layers, the panel still holds the
// Escape listener: an Escape aimed at the calendar then closed the
// whole day. Escape from inside another dialog is never the day's.
const owner =
event.target instanceof Element
? event.target.closest('[role="dialog"]')
: null;
if (owner && owner !== panelRef.current) event.preventDefault();
}}
className={cn(
// Quicker than the primitive's 500 ms, which read as lag on a
// layer opened from a tap; no slide at all under reduced motion.
@@ -81,3 +81,41 @@ describe("the detail sheet's original-document transport", () => {
);
});
});
/**
* "Open in new tab" hands the browser the types it renders itself, so a long
* report gets the browser's zoom and search. It must open the object URL the
* sheet already holds (a bare navigation to the route carries no record-session
* assertion and is refused once the session has been inside a shared record),
* and it must never appear for a type outside the inline serving class.
*/
describe("the detail sheet's open-in-new-tab control", () => {
const control = SHEET.slice(
SHEET.indexOf("function DocumentOpenInTabButton("),
SHEET.indexOf("/** Inline preview for Class A documents"),
);
it("exists", () => {
expect(control.length).toBeGreaterThan(0);
expect(SHEET).toMatch(/<DocumentOpenInTabButton\s/);
});
it("renders only for the inline serving class", () => {
expect(SHEET).toMatch(
/doc\.servingClass === "inline" \? \(\s*\n\s*<DocumentOpenInTabButton\s+readyUrl=\{inlineSrc\}/,
);
expect([...SHEET.matchAll(/<DocumentOpenInTabButton\s/g)]).toHaveLength(1);
});
it("opens the fetched object URL, never the route, without an opener", () => {
expect(control).toContain('window.open(readyUrl, "_blank", "noopener")');
expect(control).not.toContain("/api/documents");
});
it("is disabled until the bytes are in hand, and when they failed", () => {
expect(control).toMatch(/disabled=\{readyUrl === null \|\| unavailable\}/);
expect(SHEET).toMatch(
/<DocumentOpenInTabButton\s+readyUrl=\{inlineSrc\}\s+unavailable=\{inlineFailed\}/,
);
});
});
@@ -23,6 +23,7 @@ import {
ArrowRight,
Check,
Download,
ExternalLink,
FlaskConical,
Pencil,
Plus,
@@ -199,6 +200,43 @@ function DocumentDownloadButton({
);
}
/**
* Open the stored original in a new browser tab, for the types the browser
* renders itself (the inline serving class: PDF, JPEG, PNG, WebP, GIF), so a
* long report gets the browser's own zoom and search.
*
* It opens the object URL the sheet already holds rather than the route: a
* plain navigation carries no record-session assertion, so the fence would
* refuse it once the session has been inside a shared record. The bytes are
* already in hand, so the tab opens inside the click and no popup blocker
* steps in. A type outside the inline class never gets the control.
*/
function DocumentOpenInTabButton({
readyUrl,
unavailable,
}: {
readyUrl: string | null;
unavailable: boolean;
}) {
const { t } = useTranslations();
return (
<Button
variant="outline"
data-slot="document-open-in-tab"
disabled={readyUrl === null || unavailable}
aria-label={t("documents.detail.openInNewTab")}
onClick={() => {
if (readyUrl) window.open(readyUrl, "_blank", "noopener");
}}
>
<ExternalLink className="size-4" aria-hidden />
<span className="hidden sm:inline">
{t("documents.detail.openInNewTab")}
</span>
</Button>
);
}
/** Inline preview for Class A documents; skeleton until the blob paints. */
function InlinePreview({
src,
@@ -759,8 +797,8 @@ export function DocumentDetailSheet({
doc ? (
// Delete sits bottom-LEFT, quieted to a text button: the
// bottom-right slot reads as the primary/confirm action, and a
// destructive control there is a footgun. Share + Download — the
// safe, expected actions — keep the trailing edge. On phones their
// destructive control there is a footgun. Share, open in a new tab and
// Download, the safe, expected actions, keep the trailing edge. On phones their
// labels collapse to icon-only (aria-labelled) so three controls
// never overflow the sheet at 360 px; the labels return at `sm+`.
<div className="flex w-full items-center justify-between gap-2">
@@ -824,6 +862,12 @@ export function DocumentDetailSheet({
</span>
</Button>
)}
{doc.servingClass === "inline" ? (
<DocumentOpenInTabButton
readyUrl={inlineSrc}
unavailable={inlineFailed}
/>
) : null}
<DocumentDownloadButton
documentId={doc.id}
filename={doc.filename}
@@ -699,7 +699,7 @@ describe("<MessageThread>", () => {
/>,
);
expect(html).toContain(
"Daily limit reached. The budget refreshes at midnight UTC.",
"Daily limit reached. The budget refreshes at midnight in your time zone.",
);
// v1.4.33 IW7 — fallback copy rewritten ("AI provider" -> "Insights
// provider"). Both old and new strings should be absent here when
@@ -560,7 +560,7 @@ function ChatBubbleImpl({
// v1.4.25 W5 — map server-emitted error codes to specific Coach
// i18n keys. Distinct user-quota and provider-rate-limit copy so the
// user understands daily-cap (resets at UTC midnight) vs. transient
// user understands daily-cap (resets at their local midnight) vs. transient
// provider load (retry in ~5 min). Codes that have no dedicated
// translation fall back to the generic provider-unavailable copy.
const errorKey = errorCode ? errorCodeToI18nKey(errorCode) : null;