diff --git a/packages/app/src/agent-stream/turn-footer.browser.test.tsx b/packages/app/src/agent-stream/turn-footer.browser.test.tsx
new file mode 100644
index 0000000000..dec9b7fe15
--- /dev/null
+++ b/packages/app/src/agent-stream/turn-footer.browser.test.tsx
@@ -0,0 +1,94 @@
+import React, { act } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, beforeEach, expect, it, vi } from "vitest";
+import { i18n } from "@/i18n/i18next";
+import type { TurnTiming } from "@/timeline/turn-time";
+import type { StreamItem } from "@/types/stream";
+import { createNativeStreamStrategy } from "./strategy-native";
+import { CompletedTurnFooterRow } from "./turn-footer";
+
+Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
+
+// A Sunday, so a turn that ended on Friday shows a weekday timestamp.
+const NOW = new Date(2026, 10, 8, 5, 0);
+const FRIDAY_NIGHT = new Date(2026, 10, 6, 23, 11);
+
+let root: Root;
+let host: HTMLDivElement;
+
+beforeEach(() => {
+ vi.stubGlobal("React", React);
+ vi.useFakeTimers({ toFake: ["Date"], now: NOW });
+ host = document.createElement("div");
+ host.style.width = "880px";
+ document.body.appendChild(host);
+ root = createRoot(host);
+});
+
+afterEach(async () => {
+ act(() => root.unmount());
+ host.remove();
+ vi.useRealTimers();
+ await i18n.changeLanguage("en");
+ vi.unstubAllGlobals();
+});
+
+const reply: StreamItem = {
+ kind: "assistant_message",
+ id: "reply",
+ text: "Done.",
+ timestamp: FRIDAY_NIGHT,
+};
+
+function renderCompletedFooter(timing: TurnTiming): void {
+ act(() =>
+ root.render(
+ ,
+ ),
+ );
+}
+
+/** How many painted lines each footer label takes. */
+function paintedLinesByLabel(element: HTMLElement): Record {
+ const lines: Record = {};
+ const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
+ while (walker.nextNode()) {
+ const text = walker.currentNode.textContent ?? "";
+ if (!text.trim()) continue;
+ const range = document.createRange();
+ range.selectNodeContents(walker.currentNode);
+ lines[text] = new Set(Array.from(range.getClientRects(), (rect) => Math.round(rect.top))).size;
+ }
+ return lines;
+}
+
+// The footer shows the duration and swaps to the end timestamp on hover or tap. Neither label
+// may be sized by the other: a timestamp with more characters can still paint narrower.
+it.each([
+ {
+ language: "en",
+ timing: { completedAt: FRIDAY_NIGHT, durationMs: 26_000 },
+ duration: "Worked for 26s",
+ },
+ // CJK glyphs make the shorter string paint wider in every font.
+ {
+ language: "ja",
+ timing: { completedAt: FRIDAY_NIGHT, durationMs: 59 * 60_000 + 58_000 },
+ duration: "作業時間 59m 58s",
+ },
+])("keeps both timing labels on one line when the chat has room ($language)", async (input) => {
+ await i18n.changeLanguage(input.language);
+
+ renderCompletedFooter(input.timing);
+
+ expect(paintedLinesByLabel(host)).toEqual({
+ [input.duration]: 1,
+ "Friday 11:11 PM": 1,
+ });
+});
diff --git a/packages/app/test-stubs/lucide-react-native.ts b/packages/app/test-stubs/lucide-react-native.ts
index e07f0b05e7..d213089efa 100644
--- a/packages/app/test-stubs/lucide-react-native.ts
+++ b/packages/app/test-stubs/lucide-react-native.ts
@@ -117,3 +117,11 @@ export const Maximize2 = StubIcon;
export const Workflow = StubIcon;
export const ZoomIn = StubIcon;
export const ZoomOut = StubIcon;
+export const CheckSquare = StubIcon;
+export const Circle = StubIcon;
+export const FileSymlink = StubIcon;
+export const MessageSquareCode = StubIcon;
+export const MicVocal = StubIcon;
+export const Scissors = StubIcon;
+export const Sparkles = StubIcon;
+export const TriangleAlertIcon = StubIcon;
diff --git a/packages/app/test-stubs/react-native.mjs b/packages/app/test-stubs/react-native.mjs
new file mode 100644
index 0000000000..9c72575d62
--- /dev/null
+++ b/packages/app/test-stubs/react-native.mjs
@@ -0,0 +1,14 @@
+// react-native-web has no ToastAndroid. The toast host imports it by name for its Android
+// branch; Metro and Node tolerate the missing binding, but browser tests load native ESM,
+// which rejects the import and fails every module that reaches the toast context.
+export * from "react-native-web/dist/index.js";
+
+export const ToastAndroid = {
+ SHORT: 0,
+ LONG: 1,
+ TOP: 0,
+ BOTTOM: 1,
+ CENTER: 2,
+ show() {},
+ showWithGravity() {},
+};
diff --git a/packages/app/vitest.config.ts b/packages/app/vitest.config.ts
index 69360fa7ee..386f63305c 100644
--- a/packages/app/vitest.config.ts
+++ b/packages/app/vitest.config.ts
@@ -253,12 +253,12 @@ export default defineConfig({
find: /^react-native\/Libraries\/Renderer\/shims\/ReactFabric$/,
replacement: path.resolve(__dirname, "test-stubs/react-native-fabric-shim.ts"),
},
- // Point to the ESM build so Vite can transform its imports and apply the
+ // Point to react-native-web's ESM build so Vite can transform its imports and apply the
// react alias below (the CJS build uses require('react') which bypasses
- // Vite alias resolution).
+ // Vite alias resolution). The stub re-exports it and adds what web lacks.
{
find: "react-native",
- replacement: path.resolve(rootNodeModules, "react-native-web/dist/index.js"),
+ replacement: path.resolve(__dirname, "test-stubs/react-native.mjs"),
},
{ find: "react", replacement: resolvePackageEntry("react") },
{