diff --git a/webui/src/components/MarkdownTextRenderer.tsx b/webui/src/components/MarkdownTextRenderer.tsx index d3a664b92..6009ce76b 100644 --- a/webui/src/components/MarkdownTextRenderer.tsx +++ b/webui/src/components/MarkdownTextRenderer.tsx @@ -272,7 +272,7 @@ function InlineLinkPreviewRow({ link }: { link: InlineLinkPreview }) { aria-label={`Open link: ${label}`} className={cn( "not-prose inline-flex max-w-full items-center gap-2 align-baseline", - "text-primary no-underline underline-offset-2 hover:underline", + "text-blue-600 no-underline underline-offset-2 hover:underline dark:text-blue-300", )} > {markdownChildren} @@ -508,7 +508,7 @@ export default function MarkdownTextRenderer({ "prose-ul:my-2 prose-ol:my-2 prose-li:my-0.5", "prose-blockquote:my-3 prose-blockquote:border-l-2 prose-blockquote:font-normal", "prose-blockquote:not-italic prose-blockquote:text-foreground/80", - "prose-a:text-primary prose-a:underline-offset-2 hover:prose-a:opacity-80", + "prose-a:text-blue-600 prose-a:underline-offset-2 hover:prose-a:text-blue-700 dark:prose-a:text-blue-300 dark:hover:prose-a:text-blue-200", "prose-hr:my-6", "prose-pre:my-0 prose-pre:bg-transparent prose-pre:p-0", "prose-code:before:content-none prose-code:after:content-none prose-code:font-normal", diff --git a/webui/src/components/MessageBubble.tsx b/webui/src/components/MessageBubble.tsx index d3d3b8ac5..523154590 100644 --- a/webui/src/components/MessageBubble.tsx +++ b/webui/src/components/MessageBubble.tsx @@ -573,7 +573,7 @@ export function ReasoningBubble({ "prose-headings:mt-2 prose-headings:mb-1 prose-headings:font-medium", "prose-headings:text-muted-foreground/92 prose-strong:text-muted-foreground", "prose-h1:text-[15px] prose-h2:text-[13.5px] prose-h3:text-[12.5px] prose-h4:text-[12px]", - "prose-a:text-muted-foreground/95 prose-a:underline hover:prose-a:opacity-90", + "prose-a:text-blue-600 prose-a:underline hover:prose-a:text-blue-700 dark:prose-a:text-blue-300 dark:hover:prose-a:text-blue-200", "prose-code:text-[0.92em]", )} > diff --git a/webui/src/components/thread/activity/ReasoningRow.tsx b/webui/src/components/thread/activity/ReasoningRow.tsx index 388375000..a707b1bdc 100644 --- a/webui/src/components/thread/activity/ReasoningRow.tsx +++ b/webui/src/components/thread/activity/ReasoningRow.tsx @@ -36,7 +36,7 @@ export function ReasoningRow({ "prose-headings:mt-2 prose-headings:mb-1 prose-headings:font-medium", "prose-headings:text-muted-foreground/88 prose-strong:text-muted-foreground", "prose-h1:text-[15px] prose-h2:text-[13.5px] prose-h3:text-[12.5px] prose-h4:text-[12px]", - "prose-a:text-muted-foreground/95 prose-a:underline hover:prose-a:opacity-90", + "prose-a:text-blue-600 prose-a:underline hover:prose-a:text-blue-700 dark:prose-a:text-blue-300 dark:hover:prose-a:text-blue-200", "prose-code:text-[0.92em]", )} > diff --git a/webui/src/hooks/useNanobotStream.ts b/webui/src/hooks/useNanobotStream.ts index 9bdc43b76..790e96bb4 100644 --- a/webui/src/hooks/useNanobotStream.ts +++ b/webui/src/hooks/useNanobotStream.ts @@ -145,7 +145,17 @@ function closeReasoningStream(prev: UIMessage[]): UIMessage[] { for (let i = prev.length - 1; i >= 0; i -= 1) { const candidate = prev[i]; if (!candidate.reasoningStreaming) continue; - const merged: UIMessage = { ...candidate, reasoningStreaming: false }; + const latencyMs = + candidate.latencyMs === undefined + && Number.isFinite(candidate.createdAt) + && candidate.createdAt > 1_000_000_000_000 + ? Math.max(0, Math.round(Date.now() - candidate.createdAt)) + : candidate.latencyMs; + const merged: UIMessage = { + ...candidate, + reasoningStreaming: false, + ...(latencyMs !== undefined ? { latencyMs } : {}), + }; return [...prev.slice(0, i), merged, ...prev.slice(i + 1)]; } return prev; diff --git a/webui/src/tests/markdown-text-renderer.test.tsx b/webui/src/tests/markdown-text-renderer.test.tsx index 4fc6ed1dc..5a27b8fed 100644 --- a/webui/src/tests/markdown-text-renderer.test.tsx +++ b/webui/src/tests/markdown-text-renderer.test.tsx @@ -4,6 +4,14 @@ import { describe, expect, it } from "vitest"; import MarkdownTextRenderer from "@/components/MarkdownTextRenderer"; describe("MarkdownTextRenderer", () => { + it("renders clickable markdown links in blue", () => { + render([local server](http://127.0.0.1:7891/)); + + const link = screen.getByRole("link", { name: "local server" }); + expect(link).toHaveAttribute("href", "http://127.0.0.1:7891/"); + expect(link).toHaveClass("text-blue-600", "dark:text-blue-300"); + }); + it("does not wrap complete fenced code blocks in an extra pre", () => { const { container } = render( diff --git a/webui/src/tests/useNanobotStream.test.tsx b/webui/src/tests/useNanobotStream.test.tsx index 08c32b17e..87c837ca5 100644 --- a/webui/src/tests/useNanobotStream.test.tsx +++ b/webui/src/tests/useNanobotStream.test.tsx @@ -1039,6 +1039,41 @@ describe("useNanobotStream", () => { expect(result.current.messages[1].reasoningStreaming).toBe(false); }); + it("stamps completed live Thought blocks with their own latency", async () => { + const dateNow = vi.spyOn(Date, "now"); + let now = Date.UTC(2026, 5, 1, 0, 0, 0); + dateNow.mockImplementation(() => now); + try { + const fake = fakeClient(); + const { result } = renderHook(() => useNanobotStream("chat-r5-lat", EMPTY_MESSAGES), { + wrapper: wrap(fake.client), + }); + await act(async () => {}); + + act(() => { + fake.emit("chat-r5-lat", { + event: "reasoning_delta", + chat_id: "chat-r5-lat", + text: "Thinking through the tests.", + }); + }); + await act(async () => { + await new Promise((resolve) => window.requestAnimationFrame(() => resolve())); + }); + + expect(result.current.messages[0].createdAt).toBe(now); + now += 2100; + act(() => { + fake.emit("chat-r5-lat", { event: "reasoning_end", chat_id: "chat-r5-lat" }); + }); + + expect(result.current.messages[0].reasoningStreaming).toBe(false); + expect(result.current.messages[0].latencyMs).toBe(2100); + } finally { + dateNow.mockRestore(); + } + }); + it("keeps alternating reasoning and answer deltas in separate ordered blocks", async () => { const fake = fakeClient(); const { result } = renderHook(() => useNanobotStream("chat-r5b", EMPTY_MESSAGES), {