diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4ca6b8edc6..6029de6fd3 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 @@ -839,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) @@ -4680,9 +4674,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==} @@ -7522,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==} @@ -13178,8 +13164,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: {} @@ -16533,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 2b455b95e4..3922825c50 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", @@ -59,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"') + }) +}) diff --git a/webview-ui/src/components/mcp/McpErrorRow.tsx b/webview-ui/src/components/mcp/McpErrorRow.tsx index 9de2ded184..f1b2b4b1b6 100644 --- a/webview-ui/src/components/mcp/McpErrorRow.tsx +++ b/webview-ui/src/components/mcp/McpErrorRow.tsx @@ -1,8 +1,88 @@ 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. + * + * 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) + 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) +} + +/** + * 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 +105,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..b71cc9fd53 --- /dev/null +++ b/webview-ui/src/components/mcp/__tests__/McpErrorRow.spec.tsx @@ -0,0 +1,101 @@ +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) + }) + + 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 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([ + ["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 }) + }) +})