}
+ 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 })
+ })
+})