From 1d777ca34260de0ff47a6e77a2507ac9411b0aa7 Mon Sep 17 00:00:00 2001 From: myk1yt Date: Sat, 31 Oct 2026 02:52:23 +0900 Subject: [PATCH 1/3] perf(webview): replace date-fns formatRelative in McpErrorRow with Intl What: McpErrorRow now formats its timestamp with Intl.DateTimeFormat instead of date-fns formatRelative, and webview-ui no longer depends on date-fns. Why: date-fns was a 20 MB installed dependency reached by exactly one import in the webview. The single call did not need the library. Impact: no user-visible change. formatRelative is a calendar formatter, not an elapsed-duration one, so Intl.RelativeTimeFormat was NOT a drop-in (it yields "5 minutes ago" where the UI showed "today at 9:00 PM"). The replacement reproduces date-fns' en-US calendar phrasing exactly, pinned by a 13-case characterization table that was run against date-fns first and against the new implementation second. 19 new tests. --- pnpm-lock.yaml | 8 -- webview-ui/package.json | 1 - webview-ui/src/components/mcp/McpErrorRow.tsx | 76 ++++++++++++++++++- .../mcp/__tests__/McpErrorRow.spec.tsx | 66 ++++++++++++++++ 4 files changed, 140 insertions(+), 11 deletions(-) create mode 100644 webview-ui/src/components/mcp/__tests__/McpErrorRow.spec.tsx diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4ca6b8edc6..ab71f1cdd2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -788,9 +788,6 @@ importers: cmdk: specifier: ^1.0.0 version: 1.1.1(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - date-fns: - specifier: ^4.1.0 - version: 4.4.0 debounce: specifier: ^2.1.1 version: 2.2.0 @@ -4680,9 +4677,6 @@ packages: resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} engines: {node: '>= 0.4'} - date-fns@4.4.0: - resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==} - dayjs@1.11.23: resolution: {integrity: sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==} @@ -13178,8 +13172,6 @@ snapshots: es-errors: 1.3.0 is-data-view: 1.0.2 - date-fns@4.4.0: {} - dayjs@1.11.23: {} debounce@2.2.0: {} diff --git a/webview-ui/package.json b/webview-ui/package.json index 2b455b95e4..bdf2ae2abb 100644 --- a/webview-ui/package.json +++ b/webview-ui/package.json @@ -42,7 +42,6 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.0.0", - "date-fns": "^4.1.0", "debounce": "^2.1.1", "diff": "^5.2.0", "fast-deep-equal": "^3.1.3", diff --git a/webview-ui/src/components/mcp/McpErrorRow.tsx b/webview-ui/src/components/mcp/McpErrorRow.tsx index 9de2ded184..b85c9da074 100644 --- a/webview-ui/src/components/mcp/McpErrorRow.tsx +++ b/webview-ui/src/components/mcp/McpErrorRow.tsx @@ -1,8 +1,80 @@ import { useMemo } from "react" -import { formatRelative } from "date-fns" import type { McpErrorEntry } from "@roo-code/types" +// `Intl.DateTimeFormat` instances are immutable and locale-fixed, so build them +// once per module instead of per render. +const TIME_FORMAT = new Intl.DateTimeFormat("en-US", { hour: "numeric", minute: "2-digit" }) +const WEEKDAY_FORMAT = new Intl.DateTimeFormat("en-US", { weekday: "long" }) +const SHORT_DATE_FORMAT = new Intl.DateTimeFormat("en-US", { + year: "numeric", + month: "2-digit", + day: "2-digit", +}) + +const MILLISECONDS_IN_DAY = 86_400_000 + +/** + * Whole calendar days from `baseDate` to `date`, in local time. + * + * Calendar days, not elapsed milliseconds: 23:59 and 00:01 on consecutive days + * are one day apart even though only two minutes elapsed. Local midnights are + * compared directly, so DST shifts (23h or 25h days) still round to one day. + */ +const differenceInCalendarDays = (date: Date, baseDate: Date): number => { + const dateMidnight = new Date(date.getFullYear(), date.getMonth(), date.getDate()) + const baseMidnight = new Date(baseDate.getFullYear(), baseDate.getMonth(), baseDate.getDate()) + + return Math.round((dateMidnight.getTime() - baseMidnight.getTime()) / MILLISECONDS_IN_DAY) +} + +/** + * Render a timestamp the way `date-fns`' `formatRelative` did for the `en-US` + * locale: a calendar phrase for the surrounding two weeks, a short date beyond. + * + * | Distance to `baseDate` | Result | + * | ---------------------- | ----------------------- | + * | Same day | `today at 9:00 AM` | + * | Previous day | `yesterday at 9:00 AM` | + * | Next day | `tomorrow at 9:00 AM` | + * | Previous 2-6 days | `last Tuesday at 9:00 AM` | + * | Next 2-6 days | `Saturday at 9:00 AM` | + * | Anything else | `10/22/2026` | + * + * The output is deliberately fixed to `en-US` because that is what the previous + * implementation produced: `formatRelative` was called without a `locale` + * option, so it used date-fns' `enUS` default regardless of the UI language. + * + * @throws {RangeError} When `date` is an invalid date. + */ +export const formatRelativeTimestamp = (date: Date, baseDate: Date): string => { + const dayDelta = differenceInCalendarDays(date, baseDate) + + if (Number.isNaN(dayDelta)) { + throw new RangeError("Invalid time value") + } + + const time = TIME_FORMAT.format(date) + + if (dayDelta < -6 || dayDelta >= 7) { + return SHORT_DATE_FORMAT.format(date) + } + if (dayDelta < -1) { + return `last ${WEEKDAY_FORMAT.format(date)} at ${time}` + } + if (dayDelta < 0) { + return `yesterday at ${time}` + } + if (dayDelta < 1) { + return `today at ${time}` + } + if (dayDelta < 2) { + return `tomorrow at ${time}` + } + + return `${WEEKDAY_FORMAT.format(date)} at ${time}` +} + type McpErrorRowProps = { error: McpErrorEntry } @@ -25,7 +97,7 @@ export const McpErrorRow = ({ error }: McpErrorRowProps) => { {error.message}
- {formatRelative(error.timestamp, new Date())} + {formatRelativeTimestamp(new Date(error.timestamp), new Date())}
) diff --git a/webview-ui/src/components/mcp/__tests__/McpErrorRow.spec.tsx b/webview-ui/src/components/mcp/__tests__/McpErrorRow.spec.tsx new file mode 100644 index 0000000000..27158031da --- /dev/null +++ b/webview-ui/src/components/mcp/__tests__/McpErrorRow.spec.tsx @@ -0,0 +1,66 @@ +import React from "react" +import { render, screen } from "@/utils/test-utils" + +import { McpErrorRow, formatRelativeTimestamp } from "../McpErrorRow" + +// Thursday 29 October 2026, 12:00 local. Fixed so every expectation below is +// independent of the machine clock and timezone. +const BASE = new Date(2026, 9, 29, 12, 0, 0) + +describe("formatRelativeTimestamp", () => { + // Expected strings captured from date-fns `formatRelative` (en-US) before the + // swap, so this table pins behaviour equivalence rather than new behaviour. + it.each([ + ["same day, earlier", new Date(2026, 9, 29, 9, 0), "today at 9:00 AM"], + ["same day, later", new Date(2026, 9, 29, 21, 5), "today at 9:05 PM"], + ["same day, midnight", new Date(2026, 9, 29, 0, 0), "today at 12:00 AM"], + ["previous day", new Date(2026, 9, 28, 23, 59), "yesterday at 11:59 PM"], + ["two days back", new Date(2026, 9, 27, 9, 0), "last Tuesday at 9:00 AM"], + ["six days back", new Date(2026, 9, 23, 9, 0), "last Friday at 9:00 AM"], + ["seven days back", new Date(2026, 9, 22, 9, 0), "10/22/2026"], + ["thirty days back", new Date(2026, 8, 29, 9, 0), "09/29/2026"], + ["four hundred days back", new Date(2025, 8, 24, 9, 0), "09/24/2025"], + ["next day", new Date(2026, 9, 30, 9, 0), "tomorrow at 9:00 AM"], + ["two days ahead", new Date(2026, 9, 31, 9, 0), "Saturday at 9:00 AM"], + ["six days ahead", new Date(2026, 10, 4, 9, 0), "Wednesday at 9:00 AM"], + ["seven days ahead", new Date(2026, 10, 5, 9, 0), "11/05/2026"], + ])("formats %s", (_label, date, expected) => { + expect(formatRelativeTimestamp(date, BASE)).toBe(expected) + }) + + it("counts calendar days, not elapsed time", () => { + // Two minutes apart, but on different calendar days. Expected strings + // verified against date-fns `formatRelative` for the same inputs. + expect(formatRelativeTimestamp(new Date(2026, 9, 29, 0, 1), new Date(2026, 9, 28, 23, 59))).toBe( + "tomorrow at 12:01 AM", + ) + expect(formatRelativeTimestamp(new Date(2026, 9, 28, 23, 59), new Date(2026, 9, 29, 0, 1))).toBe( + "yesterday at 11:59 PM", + ) + }) + + it("throws on an invalid date", () => { + expect(() => formatRelativeTimestamp(new Date(Number.NaN), BASE)).toThrow(RangeError) + }) +}) + +describe("McpErrorRow", () => { + it("renders the error message and a relative timestamp", () => { + const timestamp = Date.now() - 5 * 60 * 1000 + + render() + + expect(screen.getByText("Connection refused")).toBeInTheDocument() + expect(screen.getByText(/^today at \d{1,2}:\d{2} (AM|PM)$/)).toBeInTheDocument() + }) + + it.each([ + ["error", "var(--vscode-testing-iconFailed)"], + ["warn", "var(--vscode-charts-yellow)"], + ["info", "var(--vscode-testing-iconPassed)"], + ] as const)("colors the %s level", (level, expectedColor) => { + render() + + expect(screen.getByText("msg")).toHaveStyle({ color: expectedColor }) + }) +}) From 121494d64e34e9232df1d6f03726d8b51c1ca2ed Mon Sep 17 00:00:00 2001 From: myk1yt Date: Sat, 31 Oct 2026 02:56:22 +0900 Subject: [PATCH 2/3] perf(webview): inline the three brand icons instead of react-icons What: Announcement's X, Discord and Reddit marks now come from a local BrandIcons module. webview-ui no longer depends on react-icons. Why: react-icons installs 86 MB to serve three icons in one component, and those three were its only importers in the repo. Impact: no visual change. The path data is copied verbatim from react-icons 5.7.0 react-icons/si, and the spec asserts the serialized SVG is byte-identical to the library's output with the same props, so this is an equality check rather than a resemblance check. aria-hidden and the screen-reader-only labels are unchanged. @radix-ui/react-icons is a separate package with 5 remaining importers and is untouched. --- pnpm-lock.yaml | 12 ------ webview-ui/package.json | 1 - .../src/components/chat/Announcement.tsx | 8 ++-- .../src/components/common/BrandIcons.tsx | 40 ++++++++++++++++++ .../common/__tests__/BrandIcons.spec.tsx | 42 +++++++++++++++++++ 5 files changed, 86 insertions(+), 17 deletions(-) create mode 100644 webview-ui/src/components/common/BrandIcons.tsx create mode 100644 webview-ui/src/components/common/__tests__/BrandIcons.spec.tsx diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ab71f1cdd2..6029de6fd3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -836,9 +836,6 @@ importers: react-i18next: specifier: ^15.4.1 version: 15.7.4(i18next@25.10.10(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) - react-icons: - specifier: ^5.5.0 - version: 5.7.0(react@18.3.1) react-markdown: specifier: ^9.0.3 version: 9.1.0(@types/react@18.3.31)(react@18.3.1) @@ -7516,11 +7513,6 @@ packages: typescript: optional: true - react-icons@5.7.0: - resolution: {integrity: sha512-LBLy340Rzqy6+/yVhZKT3B/QpP1BZaesGqasf09HPOBzRarcDIFH0WwXlXQfE7q7ipxK4MSiC5DIBWURCny6fw==} - peerDependencies: - react: '*' - react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -16525,10 +16517,6 @@ snapshots: react-dom: 18.3.1(react@18.3.1) typescript: 5.9.3 - react-icons@5.7.0(react@18.3.1): - dependencies: - react: 18.3.1 - react-is@16.13.1: {} react-is@17.0.2: {} diff --git a/webview-ui/package.json b/webview-ui/package.json index bdf2ae2abb..3922825c50 100644 --- a/webview-ui/package.json +++ b/webview-ui/package.json @@ -58,7 +58,6 @@ "react-compiler-runtime": "^1.0.0", "react-dom": "^18.3.1", "react-i18next": "^15.4.1", - "react-icons": "^5.5.0", "react-markdown": "^9.0.3", "react-remark": "^2.1.0", "react-textarea-autosize": "^8.5.3", diff --git a/webview-ui/src/components/chat/Announcement.tsx b/webview-ui/src/components/chat/Announcement.tsx index a1231505a1..4bc78fae55 100644 --- a/webview-ui/src/components/chat/Announcement.tsx +++ b/webview-ui/src/components/chat/Announcement.tsx @@ -1,11 +1,11 @@ import { memo, type MouseEvent, type ReactNode, useState } from "react" import { Trans } from "react-i18next" -import { SiDiscord, SiReddit, SiX } from "react-icons/si" import { Package } from "@roo/package" import { useAppTranslation } from "@src/i18n/TranslationContext" import { vscode } from "@src/utils/vscode" import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@src/components/ui" +import { DiscordIcon, RedditIcon, XIcon } from "@src/components/common/BrandIcons" import { EXTERNAL_LINKS } from "@src/constants/externalLinks" interface AnnouncementProps { @@ -54,17 +54,17 @@ const Announcement = ({ hideAnnouncement }: AnnouncementProps) => {
} + icon={} label="X" href="https://x.com/ZooCodeDev" /> } + icon={} label="Discord" href="https://discord.gg/VxfP4Vx3gX" /> } + icon={} label="Reddit" href="https://www.reddit.com/r/ZooCode/" /> diff --git a/webview-ui/src/components/common/BrandIcons.tsx b/webview-ui/src/components/common/BrandIcons.tsx new file mode 100644 index 0000000000..055b418f97 --- /dev/null +++ b/webview-ui/src/components/common/BrandIcons.tsx @@ -0,0 +1,40 @@ +import type { SVGProps } from "react" + +type BrandIconProps = Omit, "viewBox" | "xmlns"> + +// Attribute order mirrors react-icons' `IconBase` so the serialized markup is +// byte-identical to what the library produced, which lets the spec assert exact +// equality instead of "looks the same". +const BrandIcon = ({ path, ...props }: BrandIconProps & { path: string }) => ( + + + +) + +// Path data copied verbatim from react-icons 5.7.0 `react-icons/si`, which +// sources it from Simple Icons. Kept byte-identical so the marks render +// unchanged; do not hand-edit these strings. + +const X_PATH = + "M14.234 10.162 22.977 0h-2.072l-7.591 8.824L7.251 0H.258l9.168 13.343L.258 24H2.33l8.016-9.318L16.749 24h6.993zm-2.837 3.299-.929-1.329L3.076 1.56h3.182l5.965 8.532.929 1.329 7.754 11.09h-3.182z" + +const DISCORD_PATH = + "M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z" + +const REDDIT_PATH = + "M12 0C5.373 0 0 5.373 0 12c0 3.314 1.343 6.314 3.515 8.485l-2.286 2.286C.775 23.225 1.097 24 1.738 24H12c6.627 0 12-5.373 12-12S18.627 0 12 0Zm4.388 3.199c1.104 0 1.999.895 1.999 1.999 0 1.105-.895 2-1.999 2-.946 0-1.739-.657-1.947-1.539v.002c-1.147.162-2.032 1.15-2.032 2.341v.007c1.776.067 3.4.567 4.686 1.363.473-.363 1.064-.58 1.707-.58 1.547 0 2.802 1.254 2.802 2.802 0 1.117-.655 2.081-1.601 2.531-.088 3.256-3.637 5.876-7.997 5.876-4.361 0-7.905-2.617-7.998-5.87-.954-.447-1.614-1.415-1.614-2.538 0-1.548 1.255-2.802 2.803-2.802.645 0 1.239.218 1.712.585 1.275-.79 2.881-1.291 4.64-1.365v-.01c0-1.663 1.263-3.034 2.88-3.207.188-.911.993-1.595 1.959-1.595Zm-8.085 8.376c-.784 0-1.459.78-1.506 1.797-.047 1.016.64 1.429 1.426 1.429.786 0 1.371-.369 1.418-1.385.047-1.017-.553-1.841-1.338-1.841Zm7.406 0c-.786 0-1.385.824-1.338 1.841.047 1.017.634 1.385 1.418 1.385.785 0 1.473-.413 1.426-1.429-.046-1.017-.721-1.797-1.506-1.797Zm-3.703 4.013c-.974 0-1.907.048-2.77.135-.147.015-.241.168-.183.305.483 1.154 1.622 1.964 2.953 1.964 1.33 0 2.47-.81 2.953-1.964.057-.137-.037-.29-.184-.305-.863-.087-1.795-.135-2.769-.135Z" + +export const XIcon = (props: BrandIconProps) => + +export const DiscordIcon = (props: BrandIconProps) => + +export const RedditIcon = (props: BrandIconProps) => diff --git a/webview-ui/src/components/common/__tests__/BrandIcons.spec.tsx b/webview-ui/src/components/common/__tests__/BrandIcons.spec.tsx new file mode 100644 index 0000000000..74c6c58f2e --- /dev/null +++ b/webview-ui/src/components/common/__tests__/BrandIcons.spec.tsx @@ -0,0 +1,42 @@ +import React from "react" +import { renderToStaticMarkup } from "react-dom/server" + +import { DiscordIcon, RedditIcon, XIcon } from "../BrandIcons" + +// Markup captured from react-icons 5.7.0 `react-icons/si` with the same props +// the Announcement passes, before the swap. Comparing the full serialized SVG +// proves the marks are byte-identical, not merely "looks similar". +const REACT_ICONS_MARKUP = { + X: '', + Discord: + '', + Reddit: '', +} as const + +describe("BrandIcons", () => { + it.each([ + ["X", XIcon], + ["Discord", DiscordIcon], + ["Reddit", RedditIcon], + ] as const)("renders %s byte-identically to react-icons", (name, Icon) => { + const markup = renderToStaticMarkup() + + expect(markup).toBe(REACT_ICONS_MARKUP[name]) + }) + + it("keeps the accessible name on the wrapping link, not the icon", () => { + // The icons are decorative: the visible label lives in a screen-reader-only + // span, so the SVG must stay out of the accessibility tree. + const markup = renderToStaticMarkup() + + expect(markup).toContain('aria-hidden="true"') + expect(markup).not.toContain(" { + const markup = renderToStaticMarkup() + + expect(markup).toContain('fill="currentColor"') + expect(markup).toContain('stroke="currentColor"') + }) +}) From 4f56db9b32b882f8882310e80573bb2964c3a123 Mon Sep 17 00:00:00 2001 From: myk1yt Date: Sat, 31 Oct 2026 04:05:51 +0900 Subject: [PATCH 3/3] fix(webview): keep two-digit years in McpErrorRow and freeze the spec clock What: differenceInCalendarDays now copies each input and zeroes it with setHours(0,0,0,0) instead of rebuilding it through new Date(year, month, day). The McpErrorRow spec freezes the system clock and gains two cases: the year-99/100 boundary and a just-after-midnight render. Why: two review findings, both real and both reproduced before fixing. 1. new Date(year, month, day) maps years 0-99 onto 1900-1999. A date in year 99 was read as 1999, so the year-99/100 boundary produced a delta of 693,959 days instead of 1 and the output degraded to a short date. Verified: the pre-fix helper returns "12/31/99" where "yesterday at 12:00 PM" is correct. 2. The component formats against the live new Date() while the fixture was built from the live Date.now(). In the first five minutes after local midnight the two land on different calendar days, so the /^today at/ assertion failed. Verified by freezing the clock at 00:02 local: the old assertion fails, and it now passes deterministically. Impact: no behaviour change outside those two cases. Re-ran the exhaustive equivalence sweep against date-fns formatRelative: 19,201 hourly samples across 5 timezones, mismatches=0, same as before the fix. Full webview suite 174 files / 2,249 tests passed. --- webview-ui/src/components/mcp/McpErrorRow.tsx | 12 +++++- .../mcp/__tests__/McpErrorRow.spec.tsx | 37 ++++++++++++++++++- 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/webview-ui/src/components/mcp/McpErrorRow.tsx b/webview-ui/src/components/mcp/McpErrorRow.tsx index b85c9da074..f1b2b4b1b6 100644 --- a/webview-ui/src/components/mcp/McpErrorRow.tsx +++ b/webview-ui/src/components/mcp/McpErrorRow.tsx @@ -20,10 +20,18 @@ const MILLISECONDS_IN_DAY = 86_400_000 * Calendar days, not elapsed milliseconds: 23:59 and 00:01 on consecutive days * are one day apart even though only two minutes elapsed. Local midnights are * compared directly, so DST shifts (23h or 25h days) still round to one day. + * + * The inputs are copied and zeroed with `setHours` rather than rebuilt through + * `new Date(year, month, day)`, because that constructor maps years 0-99 onto + * 1900-1999: a date in year 99 would be silently read as 1999 and produce a + * delta of centuries instead of a day. */ const differenceInCalendarDays = (date: Date, baseDate: Date): number => { - const dateMidnight = new Date(date.getFullYear(), date.getMonth(), date.getDate()) - const baseMidnight = new Date(baseDate.getFullYear(), baseDate.getMonth(), baseDate.getDate()) + const dateMidnight = new Date(date) + dateMidnight.setHours(0, 0, 0, 0) + + const baseMidnight = new Date(baseDate) + baseMidnight.setHours(0, 0, 0, 0) return Math.round((dateMidnight.getTime() - baseMidnight.getTime()) / MILLISECONDS_IN_DAY) } diff --git a/webview-ui/src/components/mcp/__tests__/McpErrorRow.spec.tsx b/webview-ui/src/components/mcp/__tests__/McpErrorRow.spec.tsx index 27158031da..b71cc9fd53 100644 --- a/webview-ui/src/components/mcp/__tests__/McpErrorRow.spec.tsx +++ b/webview-ui/src/components/mcp/__tests__/McpErrorRow.spec.tsx @@ -42,16 +42,51 @@ describe("formatRelativeTimestamp", () => { it("throws on an invalid date", () => { expect(() => formatRelativeTimestamp(new Date(Number.NaN), BASE)).toThrow(RangeError) }) + + it("keeps two-digit years instead of remapping them to 1900s", () => { + // `new Date(99, 11, 31)` would mean 1999. These are real year-99 dates, and + // the day delta across the year-99/100 boundary must stay one day. + const year99 = new Date("0099-12-31T12:00:00") + const year100 = new Date("0100-01-01T12:00:00") + + expect(year99.getFullYear()).toBe(99) + expect(formatRelativeTimestamp(year99, year100)).toBe("yesterday at 12:00 PM") + expect(formatRelativeTimestamp(year100, year99)).toBe("tomorrow at 12:00 PM") + expect(formatRelativeTimestamp(year100, year100)).toBe("today at 12:00 PM") + }) }) describe("McpErrorRow", () => { + // Freeze the clock at a stable midday instant. Without this the component + // formats against the live `new Date()` while the fixture is built from the + // live `Date.now()`; in the first five minutes after local midnight the two + // land on different calendar days and the "today at" assertion fails. + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date(2026, 9, 29, 12, 0, 0)) + }) + + afterEach(() => { + vi.useRealTimers() + }) + it("renders the error message and a relative timestamp", () => { const timestamp = Date.now() - 5 * 60 * 1000 render() expect(screen.getByText("Connection refused")).toBeInTheDocument() - expect(screen.getByText(/^today at \d{1,2}:\d{2} (AM|PM)$/)).toBeInTheDocument() + expect(screen.getByText("today at 11:55 AM")).toBeInTheDocument() + }) + + it("still renders a same-day timestamp just after local midnight", () => { + // The exact window the flake appeared in: 00:02 local, five minutes ago + // lands on the previous calendar day, so the phrasing must be "yesterday". + vi.setSystemTime(new Date(2026, 9, 29, 0, 2, 0)) + + render() + + expect(screen.getByText("yesterday at 11:57 PM")).toBeInTheDocument() }) it.each([