diff --git a/frontend/components/pro/running-footer.tsx b/frontend/components/pro/running-footer.tsx index 58525503..69ad2816 100644 --- a/frontend/components/pro/running-footer.tsx +++ b/frontend/components/pro/running-footer.tsx @@ -11,7 +11,7 @@ import { RiArrowDownSLine, RiStopFill } from "@remixicon/react"; import { Dropdown, DropdownPopover, DropdownTrigger } from "@/components/base/dropdown/dropdown"; import { StatusRow } from "@/components/pro/composer-status-bar"; import type { RunningStatus } from "@/components/pro/running-phase"; -import { ElapsedTimer } from "@/components/session-ui/background-status-pill"; +import { ElapsedTimer } from "@/components/session-ui/elapsed-timer"; export function RunningFooter({ status, diff --git a/frontend/components/session-ui/background-status-pill.tsx b/frontend/components/session-ui/background-status-pill.tsx index e7a556e2..6380a10c 100644 --- a/frontend/components/session-ui/background-status-pill.tsx +++ b/frontend/components/session-ui/background-status-pill.tsx @@ -11,36 +11,12 @@ // this port is the standalone slim pill. The label is a plain prop (upstream // derives it from backgroundLiveness + fleet liveCount), the elapsed timer is // added from their MessagesTimeline WorkingTimer pattern (self-ticking text -// node, no React commits - that component is private to working-indicator.tsx), +// node, no React commits - that component now lives in elapsed-timer.tsx), // and Stop accepts the EXISTING cancel handler as a prop (session-view // handleStop posts the durable /cancel; no new API call here). Their // animate-status-pulse -> our ai-loading-pixel; tokens are our semantic tokens. -import { useEffect, useRef } from "react"; -import { formatWorkingTimerNow } from "./work-entry"; - -/** Self-ticking elapsed label; updates its own text node outside React commits. */ -export function ElapsedTimer({ startedAt }: { startedAt: string }) { - const textRef = useRef(null); - const initialText = formatWorkingTimerNow(startedAt); - - useEffect(() => { - const updateText = () => { - if (textRef.current) { - textRef.current.textContent = formatWorkingTimerNow(startedAt); - } - }; - updateText(); - const id = setInterval(updateText, 1000); - return () => clearInterval(id); - }, [startedAt]); - - return ( - - {initialText} - - ); -} +import { ElapsedTimer } from "./elapsed-timer"; /** * Slim persistent status pill for a thread with live work: pulsing dot + status diff --git a/frontend/components/session-ui/elapsed-timer.test.tsx b/frontend/components/session-ui/elapsed-timer.test.tsx new file mode 100644 index 00000000..45ca71e8 --- /dev/null +++ b/frontend/components/session-ui/elapsed-timer.test.tsx @@ -0,0 +1,9 @@ +import { expect, test } from "bun:test"; +import { renderToStaticMarkup } from "react-dom/server"; +import { ElapsedTimer } from "./elapsed-timer"; + +test("the timer renders the current elapsed text in tabular figures", () => { + const html = renderToStaticMarkup(); + expect(html).toContain("tabular-nums"); + expect(html).toContain("0s"); +}); diff --git a/frontend/components/session-ui/elapsed-timer.tsx b/frontend/components/session-ui/elapsed-timer.tsx new file mode 100644 index 00000000..d073e9a3 --- /dev/null +++ b/frontend/components/session-ui/elapsed-timer.tsx @@ -0,0 +1,32 @@ +"use client"; + +// The thread's one self-ticking elapsed label (shared by the working line and +// the background status pill): renders the current elapsed text, then updates +// its own text node every second outside React commits, from the +// MessagesTimeline WorkingTimer pattern vendored with working-indicator.tsx. + +import { useEffect, useRef } from "react"; +import { formatWorkingTimerNow } from "./work-entry"; + +/** Self-ticking elapsed label; updates its own text node outside React commits. */ +export function ElapsedTimer({ startedAt }: { startedAt: string }) { + const textRef = useRef(null); + const initialText = formatWorkingTimerNow(startedAt); + + useEffect(() => { + const updateText = () => { + if (textRef.current) { + textRef.current.textContent = formatWorkingTimerNow(startedAt); + } + }; + updateText(); + const id = setInterval(updateText, 1000); + return () => clearInterval(id); + }, [startedAt]); + + return ( + + {initialText} + + ); +} diff --git a/frontend/components/session-ui/working-indicator.tsx b/frontend/components/session-ui/working-indicator.tsx index 71d35e3b..ec95faec 100644 --- a/frontend/components/session-ui/working-indicator.tsx +++ b/frontend/components/session-ui/working-indicator.tsx @@ -6,36 +6,13 @@ // Source: apps/web/src/components/chat/MessagesTimeline.tsx (WorkingTimelineRow + // WorkingTimer): three staggered pulse dots, a SELF-TICKING "Working for Xs" label // (writes its own text node every second so elapsed time never causes a React -// commit while streaming), and an optional current-step suffix. +// commit while streaming), and an optional current-step suffix. The timer itself +// now lives in elapsed-timer.tsx, shared with the background status pill. // // Port notes: their `animate-status-pulse` utility -> our `ai-loading-pixel` // stagger (globals.css, reduced-motion aware); tokens are our semantic tokens. -import { useEffect, useRef } from "react"; -import { formatWorkingTimerNow } from "./work-entry"; - -/** Live "Working for Xs" label; updates its own text node outside React commits. */ -function WorkingTimer({ createdAt }: { createdAt: string }) { - const textRef = useRef(null); - const initialText = formatWorkingTimerNow(createdAt); - - useEffect(() => { - const updateText = () => { - if (textRef.current) { - textRef.current.textContent = formatWorkingTimerNow(createdAt); - } - }; - updateText(); - const id = setInterval(updateText, 1000); - return () => clearInterval(id); - }, [createdAt]); - - return ( - - {initialText} - - ); -} +import { ElapsedTimer } from "./elapsed-timer"; export function WorkingIndicator({ createdAt, @@ -59,7 +36,7 @@ export function WorkingIndicator({ {createdAt ? ( <> - Working for + Working for ) : ( "Working…"