From e5be4dac7ab71ba5fb1498177617fe02671d51b3 Mon Sep 17 00:00:00 2001 From: Xubin Ren <52506698+Re-bin@users.noreply.github.com> Date: Sun, 17 May 2026 17:04:57 +0800 Subject: [PATCH 01/12] Optimize WebUI streaming and long history rendering Batch stream deltas, window long transcripts, lazy-load syntax highlighting, and refine activity/composer interactions. Add title refresh retries plus tests for streaming, windowing, code blocks, and live activity behavior. --- webui/src/App.tsx | 175 ++++++------ webui/src/components/CodeBlock.tsx | 90 ++++--- webui/src/components/MessageBubble.tsx | 46 ++-- .../thread/AgentActivityCluster.tsx | 90 ++++++- .../src/components/thread/ThreadMessages.tsx | 51 +++- webui/src/components/thread/ThreadShell.tsx | 1 + .../src/components/thread/ThreadViewport.tsx | 109 +++++++- webui/src/globals.css | 65 +++-- webui/src/hooks/useDeferredTitleRefresh.ts | 68 +++++ webui/src/hooks/useNanobotStream.ts | 253 +++++++++++++----- webui/src/hooks/useTheme.ts | 25 +- webui/src/i18n/locales/en/common.json | 3 +- webui/src/i18n/locales/es/common.json | 3 +- webui/src/i18n/locales/fr/common.json | 3 +- webui/src/i18n/locales/id/common.json | 3 +- webui/src/i18n/locales/ja/common.json | 3 +- webui/src/i18n/locales/ko/common.json | 3 +- webui/src/i18n/locales/vi/common.json | 3 +- webui/src/i18n/locales/zh-CN/common.json | 3 +- webui/src/i18n/locales/zh-TW/common.json | 3 +- .../src/tests/agent-activity-cluster.test.tsx | 204 ++++++++++++++ webui/src/tests/app-layout.test.tsx | 18 +- webui/src/tests/code-block.test.tsx | 92 +++++++ webui/src/tests/message-bubble.test.tsx | 4 +- webui/src/tests/thread-messages.test.tsx | 39 ++- webui/src/tests/thread-viewport.test.tsx | 193 ++++++++++++- .../tests/useDeferredTitleRefresh.test.tsx | 110 ++++++++ webui/src/tests/useNanobotStream.test.tsx | 121 ++++++++- .../react-syntax-highlighter-subpaths.d.ts | 22 ++ webui/vite.config.ts | 30 +++ 30 files changed, 1551 insertions(+), 282 deletions(-) create mode 100644 webui/src/hooks/useDeferredTitleRefresh.ts create mode 100644 webui/src/tests/agent-activity-cluster.test.tsx create mode 100644 webui/src/tests/code-block.test.tsx create mode 100644 webui/src/tests/useDeferredTitleRefresh.test.tsx create mode 100644 webui/src/types/react-syntax-highlighter-subpaths.d.ts diff --git a/webui/src/App.tsx b/webui/src/App.tsx index e8dc0722..fabcff18 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -7,7 +7,8 @@ import { ThreadShell } from "@/components/thread/ThreadShell"; import { Sheet, SheetContent } from "@/components/ui/sheet"; import { useSessions } from "@/hooks/useSessions"; -import { useTheme } from "@/hooks/useTheme"; +import { useDeferredTitleRefresh } from "@/hooks/useDeferredTitleRefresh"; +import { ThemeProvider, useTheme } from "@/hooks/useTheme"; import { cn } from "@/lib/utils"; import { clearSavedSecret, @@ -219,7 +220,13 @@ export default function App() { ); } -function Shell({ onModelNameChange, onLogout }: { onModelNameChange: (modelName: string | null) => void; onLogout: () => void }) { +function Shell({ + onModelNameChange, + onLogout, +}: { + onModelNameChange: (modelName: string | null) => void; + onLogout: () => void; +}) { const { t, i18n } = useTranslation(); const { client } = useClient(); const { theme, toggle } = useTheme(); @@ -362,9 +369,7 @@ function Shell({ onModelNameChange, onLogout }: { onModelNameChange: (modelName: }); }, [client, t]); - const onTurnEnd = useCallback(() => { - void refresh(); - }, [refresh]); + const onTurnEnd = useDeferredTitleRefresh(activeSession, refresh); const onConfirmDelete = useCallback(async () => { if (!pendingDelete) return; @@ -415,93 +420,95 @@ function Shell({ onModelNameChange, onLogout }: { onModelNameChange: (modelName: const showMainSidebar = view !== "settings"; return ( -
- {/* Desktop sidebar: in normal flow, so the thread area width stays honest. */} - {showMainSidebar ? ( - - ) : null} - - {showMainSidebar ? ( - setMobileSidebarOpen(open)} - > - - - - - ) : null} - -
-
- -
- {view === "settings" && ( -
-
- )} -
+ {view === "settings" && ( +
+ +
+ )} + - setPendingDelete(null)} - onConfirm={onConfirmDelete} - /> - {restartToast ? ( -
- {restartToast} -
- ) : null} -
+ setPendingDelete(null)} + onConfirm={onConfirmDelete} + /> + {restartToast ? ( +
+ {restartToast} +
+ ) : null} + + ); } diff --git a/webui/src/components/CodeBlock.tsx b/webui/src/components/CodeBlock.tsx index c19a7864..2ab6bd57 100644 --- a/webui/src/components/CodeBlock.tsx +++ b/webui/src/components/CodeBlock.tsx @@ -1,12 +1,8 @@ -import { useCallback, useEffect, useState } from "react"; +import { Suspense, lazy, useCallback, useState } from "react"; import { Check, Copy } from "lucide-react"; import { useTranslation } from "react-i18next"; -import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; -import { - oneDark, - oneLight, -} from "react-syntax-highlighter/dist/esm/styles/prism"; +import { useThemeValue } from "@/hooks/useTheme"; import { cn } from "@/lib/utils"; interface CodeBlockProps { @@ -15,30 +11,59 @@ interface CodeBlockProps { className?: string; } -/** Read dark mode straight from the DOM — stays in sync with Tailwind's `dark:`. */ -function useIsDark() { - const [isDark, setIsDark] = useState(() => - typeof document !== "undefined" - ? document.documentElement.classList.contains("dark") - : true, +interface HighlightedCodeProps { + language?: string; + code: string; + isDark: boolean; +} + +const LazyHighlightedCode = lazy(async () => { + const [ + { default: SyntaxHighlighter }, + { default: oneDark }, + { default: oneLight }, + ] = await Promise.all([ + import("react-syntax-highlighter/dist/esm/prism-async-light"), + import("react-syntax-highlighter/dist/esm/styles/prism/one-dark"), + import("react-syntax-highlighter/dist/esm/styles/prism/one-light"), + ]); + + return { + default({ language, code, isDark }: HighlightedCodeProps) { + return ( + + {code} + + ); + }, + }; +}); + +function PlainCodeFallback({ code }: { code: string }) { + return ( +
+      {code}
+    
); - - useEffect(() => { - const el = document.documentElement; - const observer = new MutationObserver(() => { - setIsDark(el.classList.contains("dark")); - }); - observer.observe(el, { attributeFilter: ["class"] }); - return () => observer.disconnect(); - }, []); - - return isDark; } export function CodeBlock({ language, code, className }: CodeBlockProps) { const { t } = useTranslation(); const [copied, setCopied] = useState(false); - const isDark = useIsDark(); + const isDark = useThemeValue() === "dark"; const onCopy = useCallback(() => { if (!navigator.clipboard) return; @@ -86,20 +111,9 @@ export function CodeBlock({ language, code, className }: CodeBlockProps) { {copied ? t("code.copied") : t("code.copy")} - - {code} - + }> + + ); } diff --git a/webui/src/components/MessageBubble.tsx b/webui/src/components/MessageBubble.tsx index ae15ced6..d5427ec4 100644 --- a/webui/src/components/MessageBubble.tsx +++ b/webui/src/components/MessageBubble.tsx @@ -167,10 +167,15 @@ function MessageMedia({ align: "left" | "right"; }) { if (media.length === 0) return null; - const images = media - .filter((item) => item.kind === "image") - .map(({ url, name }) => ({ url, name })); - const nonImages = media.filter((item) => item.kind !== "image"); + const images: UIImage[] = []; + const nonImages: UIMediaAttachment[] = []; + for (const item of media) { + if (item.kind === "image") { + images.push({ url: item.url, name: item.name }); + } else { + nonImages.push(item); + } + } return (
({ img, i })) - .filter(({ img }) => typeof img.url === "string" && img.url.length > 0); - const viewableImages = viewable.map(({ img }) => img); - const originalToViewable = new Map( - viewable.map(({ i }, v) => [i, v]), - ); + const viewableImages: UIImage[] = []; + const originalToViewable = new Map(); + for (let i = 0; i < images.length; i += 1) { + const img = images[i]; + if (typeof img.url !== "string" || img.url.length === 0) continue; + originalToViewable.set(i, viewableImages.length); + viewableImages.push(img); + } const [lightboxIndex, setLightboxIndex] = useState(null); @@ -416,7 +422,7 @@ function Dot({ delay }: { delay: string }) { ); } -/** L→R sheen overlay on label text; base copy stays solid ``text-muted-foreground``. */ +/** L→R sheen on the glyphs themselves; inactive labels stay solid muted text. */ export function StreamingLabelSheen({ children, active, @@ -426,21 +432,21 @@ export function StreamingLabelSheen({ active: boolean; className?: string; }) { + const sheenText = + typeof children === "string" || typeof children === "number" + ? String(children) + : undefined; return ( - + {children} - {active ? ( - - - - ) : null} ); } diff --git a/webui/src/components/thread/AgentActivityCluster.tsx b/webui/src/components/thread/AgentActivityCluster.tsx index 0bd05299..a29f590a 100644 --- a/webui/src/components/thread/AgentActivityCluster.tsx +++ b/webui/src/components/thread/AgentActivityCluster.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import { ChevronRight, Layers } from "lucide-react"; import { useTranslation } from "react-i18next"; @@ -8,6 +8,7 @@ import type { UIMessage } from "@/lib/types"; /** Scrollport height for the Cursor-style “live trace” strip (tailwind spacing). */ const CLUSTER_SCROLL_MAX_CLASS = "max-h-52"; +const ACTIVITY_SCROLL_NEAR_BOTTOM_PX = 24; export function isReasoningOnlyAssistant(m: UIMessage): boolean { if (m.role !== "assistant" || m.kind === "trace") return false; @@ -19,14 +20,20 @@ export function isAgentActivityMember(m: UIMessage): boolean { return isReasoningOnlyAssistant(m) || m.kind === "trace"; } -function countToolCalls(messages: UIMessage[]): number { - let n = 0; +function countActivity(messages: UIMessage[]): { reasoningSteps: number; toolCalls: number } { + let reasoningSteps = 0; + let toolCalls = 0; for (const m of messages) { - if (m.kind !== "trace") continue; - const lines = m.traces?.length ?? (m.content.trim() ? 1 : 0); - n += Math.max(lines, 1); + if (isReasoningOnlyAssistant(m)) { + reasoningSteps += 1; + continue; + } + if (m.kind === "trace") { + const lines = m.traces?.length ?? (m.content.trim() ? 1 : 0); + toolCalls += Math.max(lines, 1); + } } - return n; + return { reasoningSteps, toolCalls }; } interface AgentActivityClusterProps { @@ -46,11 +53,14 @@ export function AgentActivityCluster({ hasBodyBelow, }: AgentActivityClusterProps) { const { t } = useTranslation(); - const reasoningSteps = messages.filter(isReasoningOnlyAssistant).length; - const toolCalls = countToolCalls(messages); + const { reasoningSteps, toolCalls } = countActivity(messages); const [userToggledOuter, setUserToggledOuter] = useState(false); const [outerOpenLocal, setOuterOpenLocal] = useState(false); + const activityScrollRef = useRef(null); + const activityContentRef = useRef(null); + const autoFollowActivityRef = useRef(true); + const scrollFrameRef = useRef(null); /** Collapsed by default during “Working…” and after the turn; user expands to inspect traces. */ const outerExpanded = userToggledOuter ? outerOpenLocal : false; @@ -79,11 +89,66 @@ export function AgentActivityCluster({ defaultValue: "{{tools}} tool calls", }); + const cancelActivityScrollFrame = useCallback(() => { + if (scrollFrameRef.current !== null) { + window.cancelAnimationFrame(scrollFrameRef.current); + scrollFrameRef.current = null; + } + }, []); + + const scrollActivityToBottom = useCallback(() => { + const el = activityScrollRef.current; + if (!el) return; + el.scrollTop = Math.max(0, el.scrollHeight - el.clientHeight); + }, []); + + const scheduleActivityScrollToBottom = useCallback(() => { + cancelActivityScrollFrame(); + scrollFrameRef.current = window.requestAnimationFrame(() => { + scrollFrameRef.current = null; + scrollActivityToBottom(); + }); + }, [cancelActivityScrollFrame, scrollActivityToBottom]); + const toggleOuter = () => { + const nextOpen = userToggledOuter ? !outerOpenLocal : !outerExpanded; + if (nextOpen) { + autoFollowActivityRef.current = true; + } setUserToggledOuter(true); - setOuterOpenLocal((v) => (userToggledOuter ? !v : !outerExpanded)); + setOuterOpenLocal(nextOpen); }; + useLayoutEffect(() => { + if (!outerExpanded || !autoFollowActivityRef.current) return; + scheduleActivityScrollToBottom(); + }, [outerExpanded, messages, isTurnStreaming, scheduleActivityScrollToBottom]); + + useEffect(() => { + if (!outerExpanded) { + autoFollowActivityRef.current = true; + return; + } + const target = activityContentRef.current; + if (!target || typeof ResizeObserver === "undefined") return; + const observer = new ResizeObserver(() => { + if (autoFollowActivityRef.current) { + scheduleActivityScrollToBottom(); + } + }); + observer.observe(target); + return () => observer.disconnect(); + }, [outerExpanded, scheduleActivityScrollToBottom]); + + useEffect(() => cancelActivityScrollFrame, [cancelActivityScrollFrame]); + + const onActivityScroll = useCallback(() => { + const el = activityScrollRef.current; + if (!el) return; + const distance = el.scrollHeight - el.scrollTop - el.clientHeight; + autoFollowActivityRef.current = distance < ACTIVITY_SCROLL_NEAR_BOTTOM_PX; + }, []); + return (
+
+ ) : null} {units.map((unit, index) => { const prev = units[index - 1]; const marginTop = @@ -80,7 +123,7 @@ export function ThreadMessages({ messages, isStreaming = false }: ThreadMessages message={unit.message} showAssistantCopyAction={ unit.message.role === "assistant" - ? isFinalAssistantSliceBeforeNextUser(units, index) + ? copyFlags[index] : true } /> diff --git a/webui/src/components/thread/ThreadShell.tsx b/webui/src/components/thread/ThreadShell.tsx index 309f206c..a4844d30 100644 --- a/webui/src/components/thread/ThreadShell.tsx +++ b/webui/src/components/thread/ThreadShell.tsx @@ -389,6 +389,7 @@ export function ThreadShell({ composer={composer} scrollToBottomSignal={scrollToBottomSignal} conversationKey={historyKey} + showScrollToBottomButton={!!session} /> ); diff --git a/webui/src/components/thread/ThreadViewport.tsx b/webui/src/components/thread/ThreadViewport.tsx index 38b64340..3f84da68 100644 --- a/webui/src/components/thread/ThreadViewport.tsx +++ b/webui/src/components/thread/ThreadViewport.tsx @@ -1,8 +1,17 @@ -import { type ReactNode, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; +import { + type ReactNode, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; import { ArrowDown } from "lucide-react"; import { useTranslation } from "react-i18next"; import { ThreadMessages } from "@/components/thread/ThreadMessages"; +import { isAgentActivityMember } from "@/components/thread/AgentActivityCluster"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import type { UIMessage } from "@/lib/types"; @@ -14,9 +23,27 @@ interface ThreadViewportProps { emptyState?: ReactNode; scrollToBottomSignal?: number; conversationKey?: string | null; + showScrollToBottomButton?: boolean; } const NEAR_BOTTOM_PX = 48; +const DEFAULT_SCROLL_BUTTON_BOTTOM_PX = 192; +const SCROLL_BUTTON_COMPOSER_GAP_PX = 16; +export const INITIAL_HISTORY_WINDOW = 160; +export const HISTORY_WINDOW_INCREMENT = 120; + +export function windowMessages(messages: UIMessage[], visibleCount: number): UIMessage[] { + if (messages.length <= visibleCount) return messages; + let start = Math.max(0, messages.length - visibleCount); + while ( + start > 0 + && isAgentActivityMember(messages[start]) + && isAgentActivityMember(messages[start - 1]) + ) { + start -= 1; + } + return messages.slice(start); +} export function ThreadViewport({ messages, @@ -25,18 +52,33 @@ export function ThreadViewport({ emptyState, scrollToBottomSignal = 0, conversationKey = null, + showScrollToBottomButton = true, }: ThreadViewportProps) { const { t } = useTranslation(); const scrollRef = useRef(null); const contentRef = useRef(null); + const composerDockRef = useRef(null); const bottomRef = useRef(null); const lastConversationKeyRef = useRef(conversationKey); const pendingConversationScrollRef = useRef(true); const scrollFrameIdsRef = useRef([]); + const restoreScrollAfterPrependRef = + useRef<{ height: number; top: number } | null>(null); /** User scrolled away from the bottom; do not auto-yank until they return or we reset (new chat / send). */ const userReadingHistoryRef = useRef(false); const [atBottom, setAtBottom] = useState(true); + const [composerDockHeight, setComposerDockHeight] = useState(0); + const [visibleMessageCount, setVisibleMessageCount] = + useState(INITIAL_HISTORY_WINDOW); const hasMessages = messages.length > 0; + const visibleMessages = useMemo( + () => windowMessages(messages, visibleMessageCount), + [messages, visibleMessageCount], + ); + const hiddenMessageCount = messages.length - visibleMessages.length; + const scrollButtonBottom = composerDockHeight > 0 + ? composerDockHeight + SCROLL_BUTTON_COMPOSER_GAP_PX + : DEFAULT_SCROLL_BUTTON_BOTTOM_PX; const cancelScheduledBottomScroll = useCallback(() => { for (const id of scrollFrameIdsRef.current) { @@ -77,6 +119,30 @@ export function ThreadViewport({ [cancelScheduledBottomScroll, scrollToBottomNow], ); + const loadEarlierMessages = useCallback(() => { + const el = scrollRef.current; + if (el) { + restoreScrollAfterPrependRef.current = { + height: el.scrollHeight, + top: el.scrollTop, + }; + } + userReadingHistoryRef.current = true; + setAtBottom(false); + setVisibleMessageCount((count) => + Math.min(messages.length, count + HISTORY_WINDOW_INCREMENT), + ); + }, [messages.length]); + + const measureComposerDock = useCallback(() => { + const el = composerDockRef.current; + if (!el) return; + const height = el.getBoundingClientRect().height || el.offsetHeight; + setComposerDockHeight((current) => + Math.abs(current - height) < 1 ? current : height, + ); + }, []); + useEffect(() => { if (!atBottom) return; // Instant jump: CSS scroll-smooth + behavior "auto" still animates in some @@ -96,8 +162,19 @@ export function ThreadViewport({ pendingConversationScrollRef.current = true; userReadingHistoryRef.current = false; setAtBottom(true); + setVisibleMessageCount(INITIAL_HISTORY_WINDOW); }, [conversationKey]); + useLayoutEffect(() => { + const pending = restoreScrollAfterPrependRef.current; + if (!pending) return; + const el = scrollRef.current; + restoreScrollAfterPrependRef.current = null; + if (!el) return; + const delta = el.scrollHeight - pending.height; + el.scrollTop = pending.top + delta; + }, [visibleMessages.length]); + useLayoutEffect(() => { if (!pendingConversationScrollRef.current) return; if (!conversationKey) { @@ -110,6 +187,10 @@ export function ThreadViewport({ pendingConversationScrollRef.current = false; }, [conversationKey, hasMessages, messages, scrollToBottom]); + useLayoutEffect(() => { + measureComposerDock(); + }, [composer, hasMessages, measureComposerDock]); + useEffect(() => cancelScheduledBottomScroll, [cancelScheduledBottomScroll]); useEffect(() => { @@ -123,6 +204,14 @@ export function ThreadViewport({ return () => observer.disconnect(); }, [hasMessages, scrollToBottom]); + useEffect(() => { + const target = composerDockRef.current; + if (!target || typeof ResizeObserver === "undefined") return; + const observer = new ResizeObserver(() => measureComposerDock()); + observer.observe(target); + return () => observer.disconnect(); + }, [hasMessages, measureComposerDock]); + useEffect(() => { const el = scrollRef.current; if (!el) return; @@ -155,11 +244,20 @@ export function ThreadViewport({
- +
-
+
{composer}
@@ -183,17 +281,18 @@ export function ThreadViewport({ className="pointer-events-none absolute inset-x-0 top-0 h-6 bg-gradient-to-b from-background to-transparent" /> - {!atBottom && ( + {showScrollToBottomButton && !atBottom && (
} + />, + ); + const scroller = container.firstElementChild?.firstElementChild as HTMLElement; + Object.defineProperties(scroller, { + scrollHeight: { configurable: true, value: 2400 }, + clientHeight: { configurable: true, value: 600 }, + scrollTop: { configurable: true, value: 0 }, + }); + + act(() => { + scroller.dispatchEvent(new Event("scroll")); + }); + + const button = screen.getByRole("button", { name: "Scroll to bottom" }); + expect(button).toHaveStyle({ bottom: "192px" }); + + const composerDock = screen.getByTestId("thread-composer-dock"); + composerDock.getBoundingClientRect = () => + ({ + height: 240, + width: 800, + top: 0, + right: 800, + bottom: 240, + left: 0, + x: 0, + y: 0, + toJSON: () => ({}), + }) as DOMRect; + + const composerObserver = resizeObservers.find( + (observer) => observer.element === composerDock, + ); + expect(composerObserver).toBeDefined(); + + act(() => { + composerObserver!.callback([], composerObserver as unknown as ResizeObserver); + }); + + expect(button).toHaveStyle({ bottom: "256px" }); + } finally { + vi.stubGlobal("ResizeObserver", originalResizeObserver); + } + }); + + it("hides the scroll-to-bottom button when disabled for the welcome view", () => { + const { container } = render( + composer
} + emptyState={
welcome
} + showScrollToBottomButton={false} + />, + ); + const scroller = container.firstElementChild?.firstElementChild as HTMLElement; + Object.defineProperties(scroller, { + scrollHeight: { configurable: true, value: 2400 }, + clientHeight: { configurable: true, value: 600 }, + scrollTop: { configurable: true, value: 0 }, + }); + + act(() => { + scroller.dispatchEvent(new Event("scroll")); + }); + + expect(screen.queryByRole("button", { name: "Scroll to bottom" })).not.toBeInTheDocument(); + }); + + it("renders only the tail window for long history by default", () => { + const longMessages = makeLongMessages(300); + + render( + } + />, + ); + + expect(screen.queryByText("message 139")).not.toBeInTheDocument(); + expect(screen.getByText("message 140")).toBeInTheDocument(); + expect(screen.getByText("message 299")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Load earlier messages" })).toBeInTheDocument(); + }); + + it("loads earlier history in fixed increments without rendering the whole transcript", () => { + const longMessages = makeLongMessages(300); + + render( + } + />, + ); + + fireEvent.click(screen.getByRole("button", { name: "Load earlier messages" })); + + const firstVisible = + 300 - INITIAL_HISTORY_WINDOW - HISTORY_WINDOW_INCREMENT; + + expect( + screen.queryByText(`message ${firstVisible - 1}`), + ).not.toBeInTheDocument(); + expect(screen.getByText(`message ${firstVisible}`)).toBeInTheDocument(); + expect(screen.getByText("message 299")).toBeInTheDocument(); + }); + + it("expands the window start to avoid cutting an agent activity cluster", () => { + const clustered = makeLongMessages(200); + clustered.splice( + 38, + 3, + { + id: "r0", + role: "assistant", + content: "", + reasoning: "first reasoning", + createdAt: 38, + }, + { + id: "t0", + role: "tool", + kind: "trace", + content: "tool()", + traces: ["tool()"], + createdAt: 39, + }, + { + id: "r1", + role: "assistant", + content: "", + reasoning: "second reasoning", + createdAt: 40, + }, + ); + + const visible = windowMessages(clustered, INITIAL_HISTORY_WINDOW); + + expect(visible[0].id).toBe("r0"); + expect(visible).toHaveLength(INITIAL_HISTORY_WINDOW + 2); + }); + it("resets to the bottom when opening a different conversation", async () => { const scrollIntoView = vi.fn(); const originalScrollIntoView = HTMLElement.prototype.scrollIntoView; diff --git a/webui/src/tests/useDeferredTitleRefresh.test.tsx b/webui/src/tests/useDeferredTitleRefresh.test.tsx new file mode 100644 index 00000000..a823e504 --- /dev/null +++ b/webui/src/tests/useDeferredTitleRefresh.test.tsx @@ -0,0 +1,110 @@ +import { act, renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { useDeferredTitleRefresh } from "@/hooks/useDeferredTitleRefresh"; +import type { ChatSummary } from "@/lib/types"; + +function session(overrides: Partial = {}): ChatSummary { + return { + key: "websocket:chat-a", + channel: "websocket", + chatId: "chat-a", + createdAt: null, + updatedAt: null, + title: "", + preview: "First user message", + ...overrides, + }; +} + +describe("useDeferredTitleRefresh", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("retries refreshing untitled sessions after turn_end", () => { + const refresh = vi.fn().mockResolvedValue(undefined); + const { result } = renderHook(() => + useDeferredTitleRefresh(session(), refresh, [100, 300]), + ); + + act(() => { + result.current(); + }); + + expect(refresh).toHaveBeenCalledTimes(1); + + act(() => { + vi.advanceTimersByTime(100); + }); + expect(refresh).toHaveBeenCalledTimes(2); + + act(() => { + vi.advanceTimersByTime(200); + }); + expect(refresh).toHaveBeenCalledTimes(3); + }); + + it("stops pending retries once a generated title arrives", () => { + const refresh = vi.fn().mockResolvedValue(undefined); + const { result, rerender } = renderHook( + ({ activeSession }) => + useDeferredTitleRefresh(activeSession, refresh, [100, 300]), + { initialProps: { activeSession: session() } }, + ); + + act(() => { + result.current(); + }); + rerender({ activeSession: session({ title: "Generated title" }) }); + + act(() => { + vi.advanceTimersByTime(300); + }); + + expect(refresh).toHaveBeenCalledTimes(1); + }); + + it("does not retry when the active session already has a title", () => { + const refresh = vi.fn().mockResolvedValue(undefined); + const { result } = renderHook(() => + useDeferredTitleRefresh(session({ title: "Existing title" }), refresh, [100]), + ); + + act(() => { + result.current(); + vi.advanceTimersByTime(100); + }); + + expect(refresh).toHaveBeenCalledTimes(1); + }); + + it("clears pending retries when the active chat changes", () => { + const refresh = vi.fn().mockResolvedValue(undefined); + const { result, rerender } = renderHook( + ({ activeSession }) => + useDeferredTitleRefresh(activeSession, refresh, [100]), + { initialProps: { activeSession: session() } }, + ); + + act(() => { + result.current(); + }); + rerender({ + activeSession: session({ + key: "websocket:chat-b", + chatId: "chat-b", + }), + }); + + act(() => { + vi.advanceTimersByTime(100); + }); + + expect(refresh).toHaveBeenCalledTimes(1); + }); +}); diff --git a/webui/src/tests/useNanobotStream.test.tsx b/webui/src/tests/useNanobotStream.test.tsx index 57ecccd9..0f736a01 100644 --- a/webui/src/tests/useNanobotStream.test.tsx +++ b/webui/src/tests/useNanobotStream.test.tsx @@ -83,7 +83,112 @@ function wrap(client: ReturnType["client"]) { }; } +async function flushStreamFrame() { + await act(async () => { + await new Promise((resolve) => { + requestAnimationFrame(() => resolve()); + }); + }); +} + describe("useNanobotStream", () => { + it("batches answer deltas into one animation-frame update", async () => { + const fake = fakeClient(); + const requestFrame = vi.spyOn(window, "requestAnimationFrame"); + const { result } = renderHook(() => useNanobotStream("chat-batch", EMPTY_MESSAGES), { + wrapper: wrap(fake.client), + }); + + act(() => { + fake.emit("chat-batch", { + event: "delta", + chat_id: "chat-batch", + text: "Hello", + }); + fake.emit("chat-batch", { + event: "delta", + chat_id: "chat-batch", + text: " world", + }); + }); + + expect(requestFrame).toHaveBeenCalledTimes(1); + expect(result.current.messages).toHaveLength(0); + + await flushStreamFrame(); + + expect(result.current.messages).toHaveLength(1); + expect(result.current.messages[0]).toMatchObject({ + role: "assistant", + content: "Hello world", + isStreaming: true, + }); + requestFrame.mockRestore(); + }); + + it("flushes pending delta text before turn_end finalizes the turn", () => { + const fake = fakeClient(); + const { result } = renderHook(() => useNanobotStream("chat-flush", EMPTY_MESSAGES), { + wrapper: wrap(fake.client), + }); + + act(() => { + fake.emit("chat-flush", { + event: "delta", + chat_id: "chat-flush", + text: "final chunk", + }); + fake.emit("chat-flush", { + event: "turn_end", + chat_id: "chat-flush", + }); + }); + + expect(result.current.messages).toHaveLength(1); + expect(result.current.messages[0]).toMatchObject({ + role: "assistant", + content: "final chunk", + isStreaming: false, + }); + expect(result.current.isStreaming).toBe(false); + }); + + it("drops pending stream work when switching chats", async () => { + const fake = fakeClient(); + const { result, rerender } = renderHook( + ({ chatId }: { chatId: string }) => useNanobotStream(chatId, EMPTY_MESSAGES), + { + wrapper: wrap(fake.client), + initialProps: { chatId: "chat-old" }, + }, + ); + + act(() => { + fake.emit("chat-old", { + event: "delta", + chat_id: "chat-old", + text: "stale", + }); + }); + + rerender({ chatId: "chat-new" }); + + act(() => { + fake.emit("chat-new", { + event: "delta", + chat_id: "chat-new", + text: "fresh", + }); + }); + await flushStreamFrame(); + + expect(result.current.messages).toHaveLength(1); + expect(result.current.messages[0]).toMatchObject({ + role: "assistant", + content: "fresh", + }); + }); + it("starts in streaming mode when history shows pending tool calls", () => { const fake = fakeClient(); const initialMessages = [{ @@ -203,7 +308,7 @@ describe("useNanobotStream", () => { ); }); - it("accumulates reasoning_delta chunks on a placeholder until reasoning_end", () => { + it("accumulates reasoning_delta chunks on a placeholder until reasoning_end", async () => { const fake = fakeClient(); const { result } = renderHook(() => useNanobotStream("chat-r", EMPTY_MESSAGES), { wrapper: wrap(fake.client), @@ -222,6 +327,8 @@ describe("useNanobotStream", () => { }); }); + await flushStreamFrame(); + expect(result.current.messages).toHaveLength(1); expect(result.current.messages[0].role).toBe("assistant"); expect(result.current.messages[0].reasoning).toBe("Let me think step by step."); @@ -328,7 +435,7 @@ describe("useNanobotStream", () => { expect(result.current.messages[0].reasoningStreaming).toBe(false); }); - it("does not attach a new turn's reasoning across the latest user boundary", () => { + it("does not attach a new turn's reasoning across the latest user boundary", async () => { const fake = fakeClient(); const initialMessages = [ { @@ -358,6 +465,8 @@ describe("useNanobotStream", () => { }); }); + await flushStreamFrame(); + expect(result.current.messages).toHaveLength(3); expect(result.current.messages[0].reasoning).toBe("Previous thought."); expect(result.current.messages[2].role).toBe("assistant"); @@ -366,7 +475,7 @@ describe("useNanobotStream", () => { expect(result.current.messages[2].reasoningStreaming).toBe(true); }); - it("does not attach reasoning across a tool trace boundary", () => { + it("does not attach reasoning across a tool trace boundary", async () => { const fake = fakeClient(); const { result } = renderHook(() => useNanobotStream("chat-r7", EMPTY_MESSAGES), { wrapper: wrap(fake.client), @@ -392,6 +501,8 @@ describe("useNanobotStream", () => { }); }); + await flushStreamFrame(); + expect(result.current.messages).toHaveLength(3); expect(result.current.messages.map((m) => m.kind ?? "message")).toEqual([ "message", @@ -651,7 +762,7 @@ describe("useNanobotStream", () => { expect(result.current.messages[0].content).toBe("long task"); }); - it("keeps streaming alive across stream_end and completes on turn_end", () => { + it("keeps streaming alive across stream_end and completes on turn_end", async () => { const fake = fakeClient(); const onTurnEnd = vi.fn(); const { result } = renderHook(() => useNanobotStream("chat-s", EMPTY_MESSAGES, false, onTurnEnd), { @@ -666,6 +777,8 @@ describe("useNanobotStream", () => { }); }); + await flushStreamFrame(); + expect(result.current.isStreaming).toBe(true); expect(result.current.messages[0]).toMatchObject({ role: "assistant", diff --git a/webui/src/types/react-syntax-highlighter-subpaths.d.ts b/webui/src/types/react-syntax-highlighter-subpaths.d.ts new file mode 100644 index 00000000..57639f72 --- /dev/null +++ b/webui/src/types/react-syntax-highlighter-subpaths.d.ts @@ -0,0 +1,22 @@ +declare module "react-syntax-highlighter/dist/esm/prism-async-light" { + import * as React from "react"; + import type { SyntaxHighlighterProps } from "react-syntax-highlighter"; + + export default class SyntaxHighlighter extends React.Component { + static registerLanguage(name: string, func: unknown): void; + } +} + +declare module "react-syntax-highlighter/dist/esm/styles/prism/one-dark" { + import type * as React from "react"; + + const style: { [key: string]: React.CSSProperties }; + export default style; +} + +declare module "react-syntax-highlighter/dist/esm/styles/prism/one-light" { + import type * as React from "react"; + + const style: { [key: string]: React.CSSProperties }; + export default style; +} diff --git a/webui/vite.config.ts b/webui/vite.config.ts index 7a2c9edb..fb5dfe37 100644 --- a/webui/vite.config.ts +++ b/webui/vite.config.ts @@ -25,6 +25,36 @@ export default defineConfig(({ mode }) => { outDir: path.resolve(__dirname, "../nanobot/web/dist"), emptyOutDir: true, sourcemap: false, + rollupOptions: { + output: { + manualChunks(id) { + if (id.includes("node_modules/refractor/lang/")) { + return; + } + if ( + id.includes("node_modules/react-syntax-highlighter") + || id.includes("node_modules/refractor/core") + ) { + return "syntax-highlight"; + } + if ( + id.includes("node_modules/react-markdown") + || id.includes("node_modules/remark-") + || id.includes("node_modules/rehype-") + || id.includes("node_modules/unified") + || id.includes("node_modules/mdast-") + || id.includes("node_modules/hast-") + || id.includes("node_modules/micromark") + || id.includes("node_modules/unist-") + ) { + return "markdown-vendor"; + } + if (id.includes("node_modules/katex")) { + return "katex"; + } + }, + }, + }, }, server: { host: "127.0.0.1", From 9340567f2dcf85c30426614abeed1bb064c97b6d Mon Sep 17 00:00:00 2001 From: Xubin Ren <52506698+Re-bin@users.noreply.github.com> Date: Sun, 17 May 2026 17:11:38 +0800 Subject: [PATCH 02/12] Fix duplicate reasoning display --- .../src/components/thread/ThreadMessages.tsx | 35 ++++++++++++ webui/src/tests/thread-messages.test.tsx | 53 +++++++++++++++++++ 2 files changed, 88 insertions(+) diff --git a/webui/src/components/thread/ThreadMessages.tsx b/webui/src/components/thread/ThreadMessages.tsx index dfffae19..30817121 100644 --- a/webui/src/components/thread/ThreadMessages.tsx +++ b/webui/src/components/thread/ThreadMessages.tsx @@ -49,12 +49,47 @@ export function buildDisplayUnits(messages: UIMessage[]): DisplayUnit[] { out.push({ type: "cluster", messages: cluster }); continue; } + const previous = out[out.length - 1]; + if (previous?.type === "cluster" && assistantHasInlineReasoning(m)) { + previous.messages.push(reasoningOnlyMessageFromAnswer(m)); + out.push({ type: "single", message: stripInlineReasoning(m) }); + i += 1; + continue; + } out.push({ type: "single", message: m }); i += 1; } return out; } +function assistantHasInlineReasoning(message: UIMessage): boolean { + return ( + message.role === "assistant" + && message.kind !== "trace" + && message.content.trim().length > 0 + && (!!message.reasoning?.trim() || !!message.reasoningStreaming) + ); +} + +function reasoningOnlyMessageFromAnswer(message: UIMessage): UIMessage { + return { + id: `${message.id}-reasoning`, + role: "assistant", + content: "", + createdAt: message.createdAt, + reasoning: message.reasoning, + reasoningStreaming: message.reasoningStreaming, + isStreaming: message.reasoningStreaming, + }; +} + +function stripInlineReasoning(message: UIMessage): UIMessage { + const next = { ...message }; + delete next.reasoning; + delete next.reasoningStreaming; + return next; +} + export function assistantCopyFlags(units: DisplayUnit[]): boolean[] { const flags = new Array(units.length).fill(true); let hasLaterUnitBeforeUser = false; diff --git a/webui/src/tests/thread-messages.test.tsx b/webui/src/tests/thread-messages.test.tsx index f5ecba68..4e7711fa 100644 --- a/webui/src/tests/thread-messages.test.tsx +++ b/webui/src/tests/thread-messages.test.tsx @@ -55,6 +55,59 @@ describe("ThreadMessages", () => { expect(rows[1]).toHaveClass("mt-4"); }); + it("folds final answer reasoning into the preceding activity cluster", () => { + const messages: UIMessage[] = [ + { + id: "r1", + role: "assistant", + content: "", + reasoning: "search plan", + reasoningStreaming: false, + createdAt: 1, + }, + { + id: "t1", + role: "tool", + kind: "trace", + content: "web_search()", + traces: ["web_search()"], + createdAt: 2, + }, + { + id: "a1", + role: "assistant", + content: "final answer", + reasoning: "summarize results", + reasoningStreaming: false, + createdAt: 3, + }, + ]; + + const units = buildDisplayUnits(messages); + + expect(units).toHaveLength(2); + expect(units[0]).toMatchObject({ type: "cluster" }); + expect(units[0].type === "cluster" ? units[0].messages.map((m) => m.id) : []).toEqual([ + "r1", + "t1", + "a1-reasoning", + ]); + expect(units[1]).toMatchObject({ + type: "single", + message: { + id: "a1", + content: "final answer", + }, + }); + if (units[1].type === "single") { + expect(units[1].message).not.toHaveProperty("reasoning"); + } + + render(); + expect(screen.queryByRole("button", { name: /^thinking$/i })).not.toBeInTheDocument(); + expect(screen.getByText("final answer")).toBeInTheDocument(); + }); + it("shows copy only on the last assistant slice before the next user turn", () => { const messages: UIMessage[] = [ { From 4b5de66c5869028e1a5e366e1a577306d26e8550 Mon Sep 17 00:00:00 2001 From: Xubin Ren <52506698+Re-bin@users.noreply.github.com> Date: Sun, 17 May 2026 17:41:33 +0800 Subject: [PATCH 03/12] Polish WebUI streaming and provider settings --- nanobot/channels/websocket.py | 33 +++- nanobot/providers/registry.py | 2 +- tests/channels/test_websocket_channel.py | 28 ++- webui/src/components/CodeBlock.tsx | 18 +- webui/src/components/MarkdownText.tsx | 112 +++++++++++- webui/src/components/MarkdownTextRenderer.tsx | 162 ++++++++++-------- webui/src/components/MessageBubble.tsx | 8 +- .../src/components/settings/SettingsView.tsx | 48 +++++- webui/src/lib/types.ts | 1 + webui/src/tests/app-layout.test.tsx | 47 +++++ webui/src/tests/code-block.test.tsx | 12 ++ webui/src/tests/markdown-text.test.tsx | 82 +++++++++ 12 files changed, 456 insertions(+), 97 deletions(-) create mode 100644 webui/src/tests/markdown-text.test.tsx diff --git a/nanobot/channels/websocket.py b/nanobot/channels/websocket.py index 26e00ff6..86a33c8b 100644 --- a/nanobot/channels/websocket.py +++ b/nanobot/channels/websocket.py @@ -230,6 +230,25 @@ def _mask_secret_hint(secret: str | None) -> str | None: return f"{secret[:4]}••••{secret[-4:]}" +def _provider_requires_api_key(spec: Any) -> bool: + if spec.backend == "azure_openai": + return True + if spec.is_local or spec.is_direct: + return False + return True + + +def _provider_configured_for_settings(spec: Any, provider_config: Any) -> bool: + if _provider_requires_api_key(spec): + return bool(provider_config.api_key) + return bool( + provider_config.api_key + or provider_config.api_base + or getattr(provider_config, "region", None) + or getattr(provider_config, "profile", None) + ) + + _WEB_SEARCH_PROVIDER_OPTIONS: tuple[dict[str, str], ...] = ( {"name": "duckduckgo", "label": "DuckDuckGo", "credential": "none"}, {"name": "brave", "label": "Brave Search", "credential": "api_key"}, @@ -786,13 +805,14 @@ class WebSocketChannel(BaseChannel): providers = [] for spec in PROVIDERS: provider_config = getattr(config.providers, spec.name, None) - if provider_config is None or spec.is_oauth or spec.is_local: + if provider_config is None or spec.is_oauth: continue providers.append( { "name": spec.name, "label": spec.label, - "configured": bool(provider_config.api_key), + "configured": _provider_configured_for_settings(spec, provider_config), + "api_key_required": _provider_requires_api_key(spec), "api_key_hint": _mask_secret_hint(provider_config.api_key), "api_base": provider_config.api_base, "default_api_base": spec.default_api_base or None, @@ -862,7 +882,12 @@ class WebSocketChannel(BaseChannel): if find_by_name(provider) is None: return _http_error(400, "unknown provider") provider_config = getattr(config.providers, provider, None) - if provider_config is None or not provider_config.api_key: + spec = find_by_name(provider) + if ( + provider_config is None + or spec is None + or not _provider_configured_for_settings(spec, provider_config) + ): return _http_error(400, "provider is not configured") if defaults.provider != provider: defaults.provider = provider @@ -885,7 +910,7 @@ class WebSocketChannel(BaseChannel): if not provider_name: return _http_error(400, "provider is required") spec = find_by_name(provider_name) - if spec is None or spec.is_oauth or spec.is_local: + if spec is None or spec.is_oauth: return _http_error(400, "unknown provider") config = load_config() diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py index 4dba0c46..e6f02218 100644 --- a/nanobot/providers/registry.py +++ b/nanobot/providers/registry.py @@ -396,7 +396,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( name="vllm", keywords=("vllm",), env_key="HOSTED_VLLM_API_KEY", - display_name="vLLM/Local", + display_name="vLLM", backend="openai_compat", is_local=True, ), diff --git a/tests/channels/test_websocket_channel.py b/tests/channels/test_websocket_channel.py index 9b481e25..2fa7285f 100644 --- a/tests/channels/test_websocket_channel.py +++ b/tests/channels/test_websocket_channel.py @@ -946,7 +946,12 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist( providers = {provider["name"]: provider for provider in body["providers"]} assert providers["openai"]["configured"] is True assert providers["openai"]["api_key_hint"] == "secr••••-key" + assert providers["azure_openai"]["api_key_required"] is True assert providers["openrouter"]["configured"] is False + assert providers["openrouter"]["api_key_required"] is True + assert providers["atomic_chat"]["configured"] is False + assert providers["atomic_chat"]["api_key_required"] is False + assert providers["atomic_chat"]["default_api_base"] == "http://localhost:1337/v1" assert body["agent"]["has_api_key"] is True assert body["web_search"]["provider"] == "brave" assert body["web_search"]["api_key_hint"] == "brav••••cret" @@ -969,10 +974,24 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist( assert provider_rows["openrouter"]["configured"] is True assert "sk-or-test" not in provider_updated.text + local_provider_updated = await _http_get( + "http://127.0.0.1:" + f"{port}/api/settings/provider/update?provider=atomic_chat" + "&api_base=http%3A%2F%2Flocalhost%3A1337%2Fv1", + headers={"Authorization": "Bearer tok"}, + ) + assert local_provider_updated.status_code == 200 + local_provider_body = local_provider_updated.json() + local_provider_rows = { + provider["name"]: provider for provider in local_provider_body["providers"] + } + assert local_provider_rows["atomic_chat"]["configured"] is True + assert "localhost:1337" in local_provider_updated.text + updated = await _http_get( "http://127.0.0.1:" - f"{port}/api/settings/update?model=openrouter/test" - "&provider=openrouter", + f"{port}/api/settings/update?model=atomic_chat/test" + "&provider=atomic_chat", headers={"Authorization": "Bearer tok"}, ) assert updated.status_code == 200 @@ -992,10 +1011,11 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist( assert search_body["web_search"]["base_url"] == "https://search.example.com" saved = load_config(config_path) - assert saved.agents.defaults.model == "openrouter/test" - assert saved.agents.defaults.provider == "openrouter" + assert saved.agents.defaults.model == "atomic_chat/test" + assert saved.agents.defaults.provider == "atomic_chat" assert saved.providers.openrouter.api_key == "sk-or-test" assert saved.providers.openrouter.api_base == "https://openrouter.ai/api/v1" + assert saved.providers.atomic_chat.api_base == "http://localhost:1337/v1" assert saved.tools.web.search.provider == "searxng" assert saved.tools.web.search.api_key == "" assert saved.tools.web.search.base_url == "https://search.example.com" diff --git a/webui/src/components/CodeBlock.tsx b/webui/src/components/CodeBlock.tsx index 2ab6bd57..4e3b8b73 100644 --- a/webui/src/components/CodeBlock.tsx +++ b/webui/src/components/CodeBlock.tsx @@ -9,6 +9,7 @@ interface CodeBlockProps { language?: string; code: string; className?: string; + highlight?: boolean; } interface HighlightedCodeProps { @@ -60,7 +61,12 @@ function PlainCodeFallback({ code }: { code: string }) { ); } -export function CodeBlock({ language, code, className }: CodeBlockProps) { +export function CodeBlock({ + language, + code, + className, + highlight = true, +}: CodeBlockProps) { const { t } = useTranslation(); const [copied, setCopied] = useState(false); const isDark = useThemeValue() === "dark"; @@ -111,9 +117,13 @@ export function CodeBlock({ language, code, className }: CodeBlockProps) { {copied ? t("code.copied") : t("code.copy")}
- }> - - + {highlight ? ( + }> + + + ) : ( + + )}
); } diff --git a/webui/src/components/MarkdownText.tsx b/webui/src/components/MarkdownText.tsx index 11115896..076ad55d 100644 --- a/webui/src/components/MarkdownText.tsx +++ b/webui/src/components/MarkdownText.tsx @@ -1,15 +1,46 @@ -import { Suspense, lazy } from "react"; +import { + Suspense, + lazy, + memo, + startTransition, + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from "react"; import { cn } from "@/lib/utils"; interface MarkdownTextProps { children: string; className?: string; + streaming?: boolean; } const loadMarkdownRenderer = () => import("@/components/MarkdownTextRenderer"); const LazyMarkdownRenderer = lazy(loadMarkdownRenderer); +const MemoizedMarkdownRenderer = memo(function MemoizedMarkdownRenderer({ + source, + className, + highlightCode, +}: { + source: string; + className?: string; + highlightCode: boolean; +}) { + return ( + + {source} + + ); +}); + +const SHORT_STREAM_COMMIT_MS = 80; +const MEDIUM_STREAM_COMMIT_MS = 140; +const LONG_STREAM_COMMIT_MS = 220; + export function preloadMarkdownText(): void { void loadMarkdownRenderer(); } @@ -19,7 +50,18 @@ export function preloadMarkdownText(): void { * ``remark-math`` / ``rehype-katex``, and fenced code blocks delegated to * ``CodeBlock`` for copy-to-clipboard and syntax highlighting. */ -export function MarkdownText({ children, className }: MarkdownTextProps) { +export function MarkdownText({ + children, + className, + streaming = false, +}: MarkdownTextProps) { + const renderedSource = useStreamingMarkdownSource(children, streaming); + const highlightCode = !streaming && renderedSource === children; + + useEffect(() => { + if (streaming) preloadMarkdownText(); + }, [streaming]); + return ( - {children} + {renderedSource} } > - {children} + ); } + +function useStreamingMarkdownSource(source: string, streaming: boolean): string { + const [renderedSource, setRenderedSource] = useState(source); + const latestSourceRef = useRef(source); + const renderedSourceRef = useRef(source); + const timerRef = useRef(null); + + const clearPendingCommit = useCallback(() => { + if (timerRef.current !== null) { + window.clearTimeout(timerRef.current); + timerRef.current = null; + } + }, []); + + const commitSource = useCallback((next: string, urgent: boolean) => { + if (renderedSourceRef.current === next) return; + renderedSourceRef.current = next; + if (urgent) { + setRenderedSource(next); + return; + } + startTransition(() => setRenderedSource(next)); + }, []); + + const scheduleCommit = useCallback(() => { + if (timerRef.current !== null) return; + timerRef.current = window.setTimeout(() => { + timerRef.current = null; + commitSource(latestSourceRef.current, false); + }, streamingCommitDelay(latestSourceRef.current.length)); + }, [commitSource]); + + latestSourceRef.current = source; + + useLayoutEffect(() => { + latestSourceRef.current = source; + if (!streaming) { + clearPendingCommit(); + commitSource(source, true); + } + }, [clearPendingCommit, commitSource, source, streaming]); + + useEffect(() => { + latestSourceRef.current = source; + if (!streaming) return; + scheduleCommit(); + }, [scheduleCommit, source, streaming]); + + useEffect(() => clearPendingCommit, [clearPendingCommit]); + + return renderedSource; +} + +function streamingCommitDelay(length: number): number { + if (length > 24_000) return LONG_STREAM_COMMIT_MS; + if (length > 8_000) return MEDIUM_STREAM_COMMIT_MS; + return SHORT_STREAM_COMMIT_MS; +} diff --git a/webui/src/components/MarkdownTextRenderer.tsx b/webui/src/components/MarkdownTextRenderer.tsx index 17a7dc53..ff75004a 100644 --- a/webui/src/components/MarkdownTextRenderer.tsx +++ b/webui/src/components/MarkdownTextRenderer.tsx @@ -1,4 +1,5 @@ -import { Children, isValidElement } from "react"; +import { Children, isValidElement, useMemo } from "react"; +import type { Components } from "react-markdown"; import ReactMarkdown from "react-markdown"; import rehypeKatex from "rehype-katex"; import remarkGfm from "remark-gfm"; @@ -12,8 +13,12 @@ import "katex/dist/katex.min.css"; interface MarkdownTextRendererProps { children: string; className?: string; + highlightCode?: boolean; } +const remarkPlugins = [remarkGfm, remarkMath]; +const rehypePlugins = [rehypeKatex]; + /** * Heavy markdown stack (GFM, math, KaTeX, syntax highlighting) kept in a * separate chunk so the app shell can paint sooner on refresh. @@ -21,7 +26,88 @@ interface MarkdownTextRendererProps { export default function MarkdownTextRenderer({ children, className, + highlightCode = true, }: MarkdownTextRendererProps) { + const components = useMemo( + () => ({ + code({ className: cls, children: kids, ...props }) { + const match = /language-(\w+)/.exec(cls || ""); + if (match) { + const code = String(kids).replace(/\n$/, ""); + return ( + + ); + } + const raw = String(kids).replace(/\n$/, ""); + /** Plain fenced ``` blocks (no language) & wide one-liners: block monospace, not inline pill. */ + const widePlainBlock = raw.includes("\n") || raw.length > 120; + if (widePlainBlock) { + return ( + + {kids} + + ); + } + return ( + + {kids} + + ); + }, + pre({ children: markdownChildren }) { + const kids = Children.toArray(markdownChildren); + const lone = kids.length === 1 ? kids[0] : null; + /** Highlighted fences render ``CodeBlock`` (block shell); skip invalid ``
``. */ + if (lone != null && isValidElement(lone) && lone.type === CodeBlock) { + return <>{markdownChildren}; + } + return ( +
+            {markdownChildren}
+          
+ ); + }, + a({ href, children: markdownChildren, ...props }) { + return ( + + {markdownChildren} + + ); + }, + }), + [highlightCode], + ); + return (
; - } - const raw = String(kids).replace(/\n$/, ""); - /** Plain fenced ``` blocks (no language) & wide one-liners: block monospace, not inline pill. */ - const widePlainBlock = raw.includes("\n") || raw.length > 120; - if (widePlainBlock) { - return ( - - {kids} - - ); - } - return ( - - {kids} - - ); - }, - pre({ children: markdownChildren }) { - const kids = Children.toArray(markdownChildren); - const lone = kids.length === 1 ? kids[0] : null; - /** Highlighted fences render ``CodeBlock`` (block shell); skip invalid ``
``. */ - if (lone != null && isValidElement(lone) && lone.type === CodeBlock) { - return <>{markdownChildren}; - } - return ( -
-                {markdownChildren}
-              
- ); - }, - a({ href, children: markdownChildren, ...props }) { - return ( - - {markdownChildren} - - ); - }, - }} + remarkPlugins={remarkPlugins} + rehypePlugins={rehypePlugins} + components={components} > {children} diff --git a/webui/src/components/MessageBubble.tsx b/webui/src/components/MessageBubble.tsx index d5427ec4..98ab0c94 100644 --- a/webui/src/components/MessageBubble.tsx +++ b/webui/src/components/MessageBubble.tsx @@ -1,6 +1,5 @@ import { useCallback, - useDeferredValue, useEffect, useRef, useState, @@ -120,7 +119,7 @@ export function MessageBubble({ ) : empty && message.isStreaming ? null : ( <> - {message.content} + {message.content} {media.length > 0 ? : null} {showAssistantFooterRow ? (
@@ -480,8 +479,6 @@ export function ReasoningBubble({ embeddedInCluster = false, }: ReasoningBubbleProps) { const { t } = useTranslation(); - const deferredText = useDeferredValue(text); - const markdownSource = streaming ? deferredText : text; const [userToggled, setUserToggled] = useState(false); const [openLocal, setOpenLocal] = useState(true); const open = userToggled ? openLocal : streaming; @@ -537,6 +534,7 @@ export function ReasoningBubble({ )} > - {markdownSource} + {text}
)} diff --git a/webui/src/components/settings/SettingsView.tsx b/webui/src/components/settings/SettingsView.tsx index 96188e60..116b67d6 100644 --- a/webui/src/components/settings/SettingsView.tsx +++ b/webui/src/components/settings/SettingsView.tsx @@ -52,6 +52,13 @@ import type { SettingsPayload, WebSearchSettingsUpdate } from "@/lib/types"; type SettingsSectionKey = "general" | "byok"; type ByokPaneKey = "llm" | "web-search"; +const LOCAL_UNCONFIGURED_PROVIDER_ORDER = new Map( + ["vllm", "ollama", "lm_studio", "atomic_chat", "ovms"].map((name, index) => [ + name, + index, + ]), +); + interface SettingsViewProps { theme: "light" | "dark"; onToggleTheme: () => void; @@ -176,7 +183,8 @@ export function SettingsView({ if (!provider) return; const providerForm = providerForms[providerName] ?? { apiKey: "", apiBase: "" }; const apiKey = providerForm.apiKey.trim(); - if (!provider.configured && !apiKey) { + const apiKeyRequired = provider.api_key_required ?? true; + if (!provider.configured && apiKeyRequired && !apiKey) { setError(t("settings.byok.apiKeyRequired")); return; } @@ -917,7 +925,10 @@ function ByokSettings({ const [activePane, setActivePane] = useState("llm"); const [showAllUnconfigured, setShowAllUnconfigured] = useState(false); const configuredProviders = settings.providers.filter((provider) => provider.configured); - const unconfiguredProviders = settings.providers.filter((provider) => !provider.configured); + const unconfiguredProviders = useMemo( + () => orderUnconfiguredProviders(settings.providers.filter((provider) => !provider.configured)), + [settings.providers], + ); const initialUnconfiguredCount = 6; const visibleUnconfiguredProviders = showAllUnconfigured ? unconfiguredProviders @@ -935,6 +946,12 @@ function ByokSettings({ const saving = providerSaving === provider.name; const keyVisible = !!visibleProviderKeys[provider.name]; const editingKey = !provider.configured || !!editingProviderKeys[provider.name]; + const apiKeyRequired = provider.api_key_required ?? true; + const apiKey = form.apiKey.trim(); + const apiBase = form.apiBase.trim(); + const missingRequiredApiKey = apiKeyRequired && !provider.configured && !apiKey; + const missingOptionalCredential = + !apiKeyRequired && !provider.configured && !apiKey && !apiBase; return (
onSaveProvider(provider.name)} - disabled={saving || (!provider.configured && !form.apiKey.trim())} + disabled={saving || missingRequiredApiKey || missingOptionalCredential} className="rounded-full" > {saving ? t("settings.actions.saving") : t("settings.actions.save")} @@ -1188,6 +1205,25 @@ function ByokEmptyState({ children }: { children: ReactNode }) { ); } +function orderUnconfiguredProviders( + providers: SettingsPayload["providers"], +): SettingsPayload["providers"] { + return providers + .map((provider, index) => ({ provider, index })) + .sort((left, right) => { + const rank = providerVisibilityRank(left.provider) - providerVisibilityRank(right.provider); + return rank || left.index - right.index; + }) + .map(({ provider }) => provider); +} + +function providerVisibilityRank(provider: SettingsPayload["providers"][number]): number { + const localRank = LOCAL_UNCONFIGURED_PROVIDER_ORDER.get(provider.name); + if (localRank !== undefined) return localRank; + if ((provider.api_key_required ?? true) === false) return 100; + return 200; +} + const PROVIDER_ICONS: Record = { custom: Hexagon, openrouter: Sparkles, @@ -1212,6 +1248,12 @@ const PROVIDER_ICONS: Record = { qianfan: Database, azure_openai: Cloud, bedrock: Database, + vllm: Cpu, + ollama: Cpu, + lm_studio: Cpu, + atomic_chat: Cpu, + ovms: Cpu, + nvidia: Zap, }; function ProviderIcon({ provider }: { provider: string }) { diff --git a/webui/src/lib/types.ts b/webui/src/lib/types.ts index 0e54544b..59ad8566 100644 --- a/webui/src/lib/types.ts +++ b/webui/src/lib/types.ts @@ -110,6 +110,7 @@ export interface SettingsPayload { name: string; label: string; configured: boolean; + api_key_required?: boolean; api_key_hint?: string | null; api_base?: string | null; default_api_base?: string | null; diff --git a/webui/src/tests/app-layout.test.tsx b/webui/src/tests/app-layout.test.tsx index 7709c1c9..f6e3f8ae 100644 --- a/webui/src/tests/app-layout.test.tsx +++ b/webui/src/tests/app-layout.test.tsx @@ -198,8 +198,52 @@ describe("App layout", () => { name: "openrouter", label: "OpenRouter", configured: false, + api_key_required: true, default_api_base: "https://openrouter.ai/api/v1", }, + { + name: "azure_openai", + label: "Azure OpenAI", + configured: false, + api_key_required: true, + }, + { + name: "huggingface", + label: "Hugging Face", + configured: false, + api_key_required: true, + }, + { + name: "siliconflow", + label: "SiliconFlow", + configured: false, + api_key_required: true, + }, + { + name: "volcengine", + label: "VolcEngine", + configured: false, + api_key_required: true, + }, + { + name: "byteplus", + label: "BytePlus", + configured: false, + api_key_required: true, + }, + { + name: "qianfan", + label: "Qianfan", + configured: false, + api_key_required: true, + }, + { + name: "atomic_chat", + label: "Atomic Chat", + configured: false, + api_key_required: false, + default_api_base: "http://localhost:1337/v1", + }, ], web_search: { provider: "brave", @@ -254,6 +298,9 @@ describe("App layout", () => { fireEvent.click(screen.getByText("OpenAI")); expect(screen.getByText("open••••-key")).toBeInTheDocument(); expect(screen.queryByDisplayValue("unsaved-openai-key")).not.toBeInTheDocument(); + fireEvent.click(screen.getByText("Atomic Chat")); + expect(screen.getByDisplayValue("http://localhost:1337/v1")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Save" })).toBeEnabled(); fireEvent.click(screen.getByRole("tab", { name: "Web Search" })); expect(screen.getByText("Search provider")).toBeInTheDocument(); diff --git a/webui/src/tests/code-block.test.tsx b/webui/src/tests/code-block.test.tsx index 2a96bf64..b76aeb0d 100644 --- a/webui/src/tests/code-block.test.tsx +++ b/webui/src/tests/code-block.test.tsx @@ -35,6 +35,18 @@ vi.mock("react-syntax-highlighter/dist/esm/styles/prism/one-light", () => ({ })); describe("CodeBlock", () => { + it("renders plain code without mounting the highlighter when highlighting is disabled", () => { + render( + + + , + ); + + expect(screen.queryByTestId("highlighted-code")).not.toBeInTheDocument(); + expect(screen.getByText("const value = 1;")).toBeInTheDocument(); + expect(screen.getByText("ts")).toBeInTheDocument(); + }); + it("reads theme from context without creating per-block observers", async () => { const originalMutationObserver = globalThis.MutationObserver; const observer = vi.fn(); diff --git a/webui/src/tests/markdown-text.test.tsx b/webui/src/tests/markdown-text.test.tsx new file mode 100644 index 00000000..c818f2f5 --- /dev/null +++ b/webui/src/tests/markdown-text.test.tsx @@ -0,0 +1,82 @@ +import { act, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { MarkdownText } from "@/components/MarkdownText"; + +const rendererSpy = vi.hoisted(() => vi.fn()); + +vi.mock("@/components/MarkdownTextRenderer", () => ({ + default: ({ + children, + highlightCode, + }: { + children: string; + highlightCode?: boolean; + }) => { + rendererSpy({ children, highlightCode }); + return ( +
+ {children} +
+ ); + }, +})); + +describe("MarkdownText", () => { + it("throttles streaming markdown commits and flushes before final highlighting", async () => { + rendererSpy.mockClear(); + vi.useFakeTimers(); + try { + const { rerender } = render( + hello, + ); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(screen.getByTestId("markdown-renderer")).toHaveTextContent("hello"); + expect(screen.getByTestId("markdown-renderer")).toHaveAttribute( + "data-highlight-code", + "false", + ); + expect(rendererSpy).toHaveBeenCalledTimes(1); + + rerender(hello world); + expect(screen.getByTestId("markdown-renderer")).toHaveTextContent("hello"); + expect(rendererSpy).toHaveBeenCalledTimes(1); + + act(() => { + vi.advanceTimersByTime(79); + }); + expect(screen.getByTestId("markdown-renderer")).toHaveTextContent("hello"); + expect(rendererSpy).toHaveBeenCalledTimes(1); + + act(() => { + vi.advanceTimersByTime(1); + }); + await act(async () => { + await Promise.resolve(); + }); + + expect(screen.getByTestId("markdown-renderer")).toHaveTextContent("hello world"); + expect(rendererSpy).toHaveBeenCalledTimes(2); + + rerender(hello world!!!); + expect(screen.getByTestId("markdown-renderer")).toHaveTextContent("hello world"); + + rerender(hello world!!!); + expect(screen.getByTestId("markdown-renderer")).toHaveTextContent("hello world!!!"); + expect(screen.getByTestId("markdown-renderer")).toHaveAttribute( + "data-highlight-code", + "true", + ); + } finally { + vi.useRealTimers(); + } + }); +}); From c8bb04a8fec82c974bb92047e86b08f30012c1fa Mon Sep 17 00:00:00 2001 From: Xubin Ren <52506698+Re-bin@users.noreply.github.com> Date: Sun, 17 May 2026 23:51:52 +0800 Subject: [PATCH 04/12] feat(webui): persist agent activity events --- nanobot/agent/loop.py | 89 ++----- nanobot/agent/runner.py | 38 +++ nanobot/channels/websocket.py | 22 +- nanobot/utils/file_edit_events.py | 311 +++++++++++++++++++++++ nanobot/utils/progress_events.py | 19 +- nanobot/utils/webui_titles.py | 138 ---------- nanobot/utils/webui_transcript.py | 109 +++++++- nanobot/utils/webui_turn_helpers.py | 268 +++++++++++++++++++ tests/agent/test_loop_progress.py | 211 +++++++++++++++ tests/agent/test_loop_save_turn.py | 34 ++- tests/channels/test_websocket_channel.py | 68 +++++ tests/utils/test_file_edit_events.py | 83 ++++++ tests/utils/test_webui_transcript.py | 56 ++++ 13 files changed, 1238 insertions(+), 208 deletions(-) create mode 100644 nanobot/utils/file_edit_events.py delete mode 100644 nanobot/utils/webui_titles.py create mode 100644 tests/utils/test_file_edit_events.py diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index bc807092..81cc393b 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -33,7 +33,6 @@ from nanobot.config.schema import AgentDefaults, ModelPresetConfig from nanobot.providers.base import LLMProvider from nanobot.providers.factory import ProviderSnapshot from nanobot.session.goal_state import ( - goal_state_ws_blob, runner_wall_llm_timeout_s, ) from nanobot.session.manager import Session, SessionManager @@ -44,8 +43,11 @@ from nanobot.utils.helpers import truncate_text as truncate_text_fn from nanobot.utils.image_generation_intent import image_generation_prompt from nanobot.utils.runtime import EMPTY_FINAL_RESPONSE_MESSAGE from nanobot.utils.session_attachments import merge_turn_media_into_last_assistant -from nanobot.utils.webui_titles import mark_webui_session, maybe_generate_webui_title_after_turn -from nanobot.utils.webui_turn_helpers import publish_turn_run_status +from nanobot.utils.webui_turn_helpers import ( + WebuiTurnCoordinator, + build_bus_progress_callback, + mark_webui_session, +) if TYPE_CHECKING: from nanobot.config.schema import ( @@ -237,6 +239,11 @@ class AgentLoop: self.context = ContextBuilder(workspace, timezone=timezone, disabled_skills=disabled_skills) self.sessions = session_manager or SessionManager(workspace) + self._webui_turns = WebuiTurnCoordinator( + bus=self.bus, + sessions=self.sessions, + schedule_background=lambda coro: self._schedule_background(coro), + ) self.tools = ToolRegistry() # One file-read/write tracker per logical session. The tool registry is # shared by this loop, so tools resolve the active state via contextvars. @@ -524,34 +531,7 @@ class AgentLoop: self, msg: InboundMessage ) -> Callable[..., Awaitable[None]]: """Build a progress callback that publishes to the message bus.""" - - async def _bus_progress( - content: str, - *, - tool_hint: bool = False, - tool_events: list[dict[str, Any]] | None = None, - reasoning: bool = False, - reasoning_end: bool = False, - ) -> None: - meta = dict(msg.metadata or {}) - meta["_progress"] = True - meta["_tool_hint"] = tool_hint - if reasoning: - meta["_reasoning_delta"] = True - if reasoning_end: - meta["_reasoning_end"] = True - if tool_events: - meta["_tool_events"] = tool_events - await self.bus.publish_outbound( - OutboundMessage( - channel=msg.channel, - chat_id=msg.chat_id, - content=content, - metadata=meta, - ) - ) - - return _bus_progress + return build_bus_progress_callback(self.bus, msg) async def _build_retry_wait_callback( self, msg: InboundMessage @@ -938,38 +918,12 @@ class AgentLoop: content="", metadata=msg.metadata or {}, )) if msg.channel == "websocket": - # Signal that the turn is fully complete (all tools executed, - # final text streamed). This lets WS clients know when to - # definitively stop the loading indicator. turn_lat = self._pending_turn_latency_ms.pop(session_key, None) - turn_metadata: dict[str, Any] = {**msg.metadata, "_turn_end": True} - if turn_lat is not None: - turn_metadata["latency_ms"] = int(turn_lat) - sess_turn = self.sessions.get_or_create(session_key) - turn_metadata["goal_state"] = goal_state_ws_blob(sess_turn.metadata) - await self.bus.publish_outbound(OutboundMessage( - channel=msg.channel, chat_id=msg.chat_id, - content="", metadata=turn_metadata, - )) - if msg.metadata.get("webui") is True: - async def _generate_title_and_notify() -> None: - generated = await maybe_generate_webui_title_after_turn( - channel=msg.channel, - metadata=msg.metadata, - sessions=self.sessions, - session_key=session_key, - provider=self.provider, - model=self.model, - ) - if generated: - await self.bus.publish_outbound(OutboundMessage( - channel=msg.channel, - chat_id=msg.chat_id, - content="", - metadata={**msg.metadata, "_session_updated": True}, - )) - - self._schedule_background(_generate_title_and_notify()) + await self._webui_turns.handle_turn_end( + msg, + session_key=session_key, + latency_ms=turn_lat, + ) except asyncio.CancelledError: logger.info("Task cancelled for session {}", session_key) # Preserve partial context from the interrupted turn so @@ -1021,8 +975,9 @@ class AgentLoop: "Re-published {} leftover message(s) to bus for session {}", leftover, session_key, ) - await publish_turn_run_status(self.bus, msg, "idle") + await self._webui_turns.publish_run_status(msg, "idle") self._pending_turn_latency_ms.pop(session_key, None) + self._webui_turns.discard(session_key) async def close_mcp(self) -> None: """Drain pending background archives, then close MCP connections.""" @@ -1338,6 +1293,12 @@ class AgentLoop: "include_timestamps": True, } ctx.history = ctx.session.get_history(**_hist_kwargs) + self._webui_turns.capture_title_context( + ctx.session_key, + ctx.msg, + self.provider, + self.model, + ) ctx.initial_messages = self._build_initial_messages( ctx.msg, ctx.session, ctx.history, ctx.pending_summary @@ -1354,7 +1315,7 @@ class AgentLoop: return "ok" async def _state_run(self, ctx: TurnContext) -> str: - await publish_turn_run_status(self.bus, ctx.msg, "running") + await self._webui_turns.publish_run_status(ctx.msg, "running") result = await self._run_agent_loop( ctx.initial_messages, on_progress=ctx.on_progress, diff --git a/nanobot/agent/runner.py b/nanobot/agent/runner.py index 56482f75..64345822 100644 --- a/nanobot/agent/runner.py +++ b/nanobot/agent/runner.py @@ -15,6 +15,12 @@ from loguru import logger from nanobot.agent.hook import AgentHook, AgentHookContext from nanobot.agent.tools.registry import ToolRegistry from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest +from nanobot.utils.file_edit_events import ( + build_file_edit_end_event, + build_file_edit_error_event, + build_file_edit_start_event, + prepare_file_edit_tracker, +) from nanobot.utils.helpers import ( IncrementalThinkExtractor, build_assistant_message, @@ -26,6 +32,7 @@ from nanobot.utils.helpers import ( strip_think, truncate_text, ) +from nanobot.utils.progress_events import invoke_file_edit_progress from nanobot.utils.prompt_templates import render_template from nanobot.utils.runtime import ( EMPTY_FINAL_RESPONSE_MESSAGE, @@ -813,6 +820,21 @@ class AgentRunner: return prep_error + hint, event, ( RuntimeError(prep_error) if spec.fail_on_tool_error else None ) + file_edit_tracker = prepare_file_edit_tracker( + call_id=tool_call.id, + tool_name=tool_call.name, + tool=tool, + workspace=spec.workspace, + params=params if isinstance(params, dict) else None, + ) + if file_edit_tracker is not None and spec.progress_callback is not None: + await invoke_file_edit_progress( + spec.progress_callback, + [build_file_edit_start_event( + file_edit_tracker, + params if isinstance(params, dict) else None, + )], + ) try: if tool is not None: result = await tool.execute(**params) @@ -821,6 +843,11 @@ class AgentRunner: except asyncio.CancelledError: raise except BaseException as exc: + if file_edit_tracker is not None and spec.progress_callback is not None: + await invoke_file_edit_progress( + spec.progress_callback, + [build_file_edit_error_event(file_edit_tracker, str(exc))], + ) event = { "name": tool_call.name, "status": "error", @@ -842,6 +869,11 @@ class AgentRunner: return payload, event, None if isinstance(result, str) and result.startswith("Error"): + if file_edit_tracker is not None and spec.progress_callback is not None: + await invoke_file_edit_progress( + spec.progress_callback, + [build_file_edit_error_event(file_edit_tracker, result)], + ) event = { "name": tool_call.name, "status": "error", @@ -860,6 +892,12 @@ class AgentRunner: return result + hint, event, RuntimeError(result) return result + hint, event, None + if file_edit_tracker is not None and spec.progress_callback is not None: + await invoke_file_edit_progress( + spec.progress_callback, + [build_file_edit_end_event(file_edit_tracker)], + ) + detail = "" if result is None else str(result) detail = detail.replace("\n", " ").strip() if not detail: diff --git a/nanobot/channels/websocket.py b/nanobot/channels/websocket.py index 86a33c8b..0202bd33 100644 --- a/nanobot/channels/websocket.py +++ b/nanobot/channels/websocket.py @@ -1606,6 +1606,7 @@ class WebSocketChannel(BaseChannel): if not conns: if ( msg.metadata.get("_progress") + or msg.metadata.get("_file_edit_events") or msg.metadata.get("_turn_end") or msg.metadata.get("_session_updated") or msg.metadata.get("_goal_status") @@ -1638,7 +1639,22 @@ class WebSocketChannel(BaseChannel): await self.send_turn_end(msg.chat_id, latency_ms=lat_i, goal_state=gs_blob) return if msg.metadata.get("_session_updated"): - await self.send_session_updated(msg.chat_id) + scope = msg.metadata.get("_session_update_scope") + await self.send_session_updated( + msg.chat_id, + scope=scope if isinstance(scope, str) else None, + ) + return + if msg.metadata.get("_file_edit_events"): + payload: dict[str, Any] = { + "event": "file_edit", + "chat_id": msg.chat_id, + "edits": msg.metadata["_file_edit_events"], + } + self._try_append_webui_transcript(msg.chat_id, payload) + raw = json.dumps(payload, ensure_ascii=False) + for connection in conns: + await self._safe_send_to(connection, raw, label=" ") return text = msg.content payload: dict[str, Any] = { @@ -1805,12 +1821,14 @@ class WebSocketChannel(BaseChannel): for connection in conns: await self._safe_send_to(connection, raw, label=" goal_status ") - async def send_session_updated(self, chat_id: str) -> None: + async def send_session_updated(self, chat_id: str, *, scope: str | None = None) -> None: """Notify clients that session metadata changed outside the main turn.""" conns = list(self._subs.get(chat_id, ())) if not conns: return body: dict[str, Any] = {"event": "session_updated", "chat_id": chat_id} + if scope: + body["scope"] = scope raw = json.dumps(body, ensure_ascii=False) for connection in conns: await self._safe_send_to(connection, raw, label=" session_updated ") diff --git a/nanobot/utils/file_edit_events.py b/nanobot/utils/file_edit_events.py new file mode 100644 index 00000000..8164aa18 --- /dev/null +++ b/nanobot/utils/file_edit_events.py @@ -0,0 +1,311 @@ +"""File-edit activity helpers for WebUI progress events.""" + +from __future__ import annotations + +import difflib +import json +from dataclasses import dataclass +from pathlib import Path +from typing import Any + + +TRACKED_FILE_EDIT_TOOLS = frozenset({"write_file", "edit_file", "notebook_edit"}) +_MAX_SNAPSHOT_BYTES = 2 * 1024 * 1024 + + +@dataclass(slots=True) +class FileSnapshot: + path: Path + exists: bool + text: str | None + unreadable: bool = False + binary: bool = False + oversized: bool = False + + @property + def countable(self) -> bool: + return ( + self.text is not None + and not self.binary + and not self.oversized + and not self.unreadable + ) + + +@dataclass(slots=True) +class FileEditTracker: + call_id: str + tool: str + path: Path + display_path: str + before: FileSnapshot + + +def is_file_edit_tool(tool_name: str | None) -> bool: + return bool(tool_name) and tool_name in TRACKED_FILE_EDIT_TOOLS + + +def resolve_file_edit_path( + tool: Any, + workspace: Path | None, + params: dict[str, Any] | None, +) -> Path | None: + """Resolve the target file path after tool argument preparation.""" + if not isinstance(params, dict): + return None + raw_path = params.get("path") + if not isinstance(raw_path, str) or not raw_path.strip(): + return None + resolver = getattr(tool, "_resolve", None) + if callable(resolver): + try: + resolved = resolver(raw_path) + if isinstance(resolved, Path): + return resolved + if resolved: + return Path(resolved) + except Exception: + return None + if workspace is None: + return Path(raw_path).expanduser().resolve() + return (workspace / raw_path).expanduser().resolve() + + +def display_file_edit_path(path: Path, workspace: Path | None) -> str: + if workspace is not None: + try: + return path.resolve().relative_to(workspace.resolve()).as_posix() + except Exception: + pass + return path.as_posix() + + +def read_file_snapshot(path: Path, *, max_bytes: int = _MAX_SNAPSHOT_BYTES) -> FileSnapshot: + try: + if not path.exists() or not path.is_file(): + return FileSnapshot(path=path, exists=False, text="") + size = path.stat().st_size + if size > max_bytes: + return FileSnapshot(path=path, exists=True, text=None, oversized=True) + raw = path.read_bytes() + except OSError: + return FileSnapshot(path=path, exists=path.exists(), text=None, unreadable=True) + if b"\x00" in raw: + return FileSnapshot(path=path, exists=True, text=None, binary=True) + try: + text = raw.decode("utf-8") + except UnicodeDecodeError: + return FileSnapshot(path=path, exists=True, text=None, binary=True) + return FileSnapshot(path=path, exists=True, text=text.replace("\r\n", "\n")) + + +def line_diff_stats(before: str | None, after: str | None) -> tuple[int, int]: + """Return ``(added, deleted)`` for a UTF-8 text line-level diff.""" + if before is None or after is None: + return 0, 0 + before_lines = before.replace("\r\n", "\n").splitlines() + after_lines = after.replace("\r\n", "\n").splitlines() + added = 0 + deleted = 0 + matcher = difflib.SequenceMatcher(a=before_lines, b=after_lines, autojunk=False) + for tag, i1, i2, j1, j2 in matcher.get_opcodes(): + if tag == "equal": + continue + if tag in ("replace", "delete"): + deleted += i2 - i1 + if tag in ("replace", "insert"): + added += j2 - j1 + return added, deleted + + +def prepare_file_edit_tracker( + *, + call_id: str, + tool_name: str, + tool: Any, + workspace: Path | None, + params: dict[str, Any] | None, +) -> FileEditTracker | None: + if not is_file_edit_tool(tool_name): + return None + path = resolve_file_edit_path(tool, workspace, params) + if path is None: + return None + before = read_file_snapshot(path) + return FileEditTracker( + call_id=str(call_id or ""), + tool=tool_name, + path=path, + display_path=display_file_edit_path(path, workspace), + before=before, + ) + + +def build_file_edit_start_event( + tracker: FileEditTracker, + params: dict[str, Any] | None, +) -> dict[str, Any]: + predicted_after = _predict_after_text(tracker.tool, params or {}, tracker.before) + if tracker.before.countable and predicted_after is not None: + added, deleted = line_diff_stats(tracker.before.text, predicted_after) + else: + added, deleted = 0, 0 + return _event_payload( + tracker, + phase="start", + status="editing", + added=added, + deleted=deleted, + approximate=True, + ) + + +def build_file_edit_end_event(tracker: FileEditTracker) -> dict[str, Any]: + after = read_file_snapshot(tracker.path) + if tracker.before.countable and after.countable: + added, deleted = line_diff_stats(tracker.before.text, after.text) + else: + added, deleted = 0, 0 + return _event_payload( + tracker, + phase="end", + status="done", + added=added, + deleted=deleted, + approximate=False, + binary=after.binary or after.oversized or after.unreadable, + ) + + +def build_file_edit_error_event(tracker: FileEditTracker, error: str | None = None) -> dict[str, Any]: + payload = _event_payload( + tracker, + phase="error", + status="error", + added=0, + deleted=0, + approximate=False, + ) + if error: + payload["error"] = error.strip()[:240] + return payload + + +def _event_payload( + tracker: FileEditTracker, + *, + phase: str, + status: str, + added: int, + deleted: int, + approximate: bool, + binary: bool = False, +) -> dict[str, Any]: + payload: dict[str, Any] = { + "version": 1, + "call_id": tracker.call_id, + "tool": tracker.tool, + "path": tracker.display_path, + "phase": phase, + "added": max(0, int(added)), + "deleted": max(0, int(deleted)), + "approximate": bool(approximate), + "status": status, + } + if binary: + payload["binary"] = True + return payload + + +def _predict_after_text( + tool_name: str, + params: dict[str, Any], + before: FileSnapshot, +) -> str | None: + if not before.countable: + return None + before_text = before.text or "" + if tool_name == "write_file": + content = params.get("content") + return content if isinstance(content, str) else "" + if tool_name == "edit_file": + old_text = params.get("old_text") + new_text = params.get("new_text") + if not isinstance(old_text, str) or not isinstance(new_text, str): + return None + replace_all = bool(params.get("replace_all")) + if old_text == "": + return new_text if not before.exists else before_text + if old_text in before_text: + if replace_all: + return before_text.replace(old_text, new_text) + return before_text.replace(old_text, new_text, 1) + return None + if tool_name == "notebook_edit": + return _predict_notebook_after_text(params, before_text) + return None + + +def _predict_notebook_after_text(params: dict[str, Any], before_text: str) -> str | None: + try: + nb = json.loads(before_text) if before_text.strip() else _empty_notebook() + except Exception: + return None + cells = nb.get("cells") + if not isinstance(cells, list): + return None + try: + cell_index = int(params.get("cell_index", 0)) + except (TypeError, ValueError): + return None + new_source = params.get("new_source") + source = new_source if isinstance(new_source, str) else "" + cell_type = params.get("cell_type") if params.get("cell_type") in ("code", "markdown") else "code" + mode = params.get("edit_mode") if params.get("edit_mode") in ("replace", "insert", "delete") else "replace" + if mode == "delete": + if 0 <= cell_index < len(cells): + cells.pop(cell_index) + else: + return None + elif mode == "insert": + insert_at = min(max(cell_index + 1, 0), len(cells)) + cells.insert(insert_at, _new_notebook_cell(source, str(cell_type))) + else: + if not (0 <= cell_index < len(cells)): + return None + cell = cells[cell_index] + if not isinstance(cell, dict): + return None + cell["source"] = source + cell["cell_type"] = cell_type + if cell_type == "code": + cell.setdefault("outputs", []) + cell.setdefault("execution_count", None) + else: + cell.pop("outputs", None) + cell.pop("execution_count", None) + nb["cells"] = cells + try: + return json.dumps(nb, indent=1, ensure_ascii=False) + except Exception: + return None + + +def _empty_notebook() -> dict[str, Any]: + return { + "nbformat": 4, + "nbformat_minor": 5, + "metadata": { + "kernelspec": {"display_name": "Python 3", "language": "python", "name": "python3"}, + "language_info": {"name": "python"}, + }, + "cells": [], + } + + +def _new_notebook_cell(source: str, cell_type: str) -> dict[str, Any]: + cell: dict[str, Any] = {"cell_type": cell_type, "source": source, "metadata": {}} + if cell_type == "code": + cell["outputs"] = [] + cell["execution_count"] = None + return cell diff --git a/nanobot/utils/progress_events.py b/nanobot/utils/progress_events.py index 10a282b9..ccf125ec 100644 --- a/nanobot/utils/progress_events.py +++ b/nanobot/utils/progress_events.py @@ -10,13 +10,21 @@ from nanobot.agent.hook import AgentHookContext def on_progress_accepts_tool_events(cb: Callable[..., Any]) -> bool: + return _on_progress_accepts(cb, "tool_events") + + +def on_progress_accepts_file_edit_events(cb: Callable[..., Any]) -> bool: + return _on_progress_accepts(cb, "file_edit_events") + + +def _on_progress_accepts(cb: Callable[..., Any], name: str) -> bool: try: sig = inspect.signature(cb) except (TypeError, ValueError): return False if any(p.kind == inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values()): return True - return "tool_events" in sig.parameters + return name in sig.parameters async def invoke_on_progress( @@ -32,6 +40,15 @@ async def invoke_on_progress( await on_progress(content, tool_hint=tool_hint) +async def invoke_file_edit_progress( + on_progress: Callable[..., Awaitable[None]], + file_edit_events: list[dict[str, Any]], +) -> None: + if not file_edit_events or not on_progress_accepts_file_edit_events(on_progress): + return + await on_progress("", file_edit_events=file_edit_events) + + def build_tool_event_start_payload(tool_call: Any) -> dict[str, Any]: return { "version": 1, diff --git a/nanobot/utils/webui_titles.py b/nanobot/utils/webui_titles.py deleted file mode 100644 index 2d363f92..00000000 --- a/nanobot/utils/webui_titles.py +++ /dev/null @@ -1,138 +0,0 @@ -"""Helpers for WebUI chat title generation.""" - -from __future__ import annotations - -import re -from typing import Any - -from loguru import logger - -from nanobot.providers.base import LLMProvider -from nanobot.session.manager import Session, SessionManager -from nanobot.utils.helpers import truncate_text - -WEBUI_SESSION_METADATA_KEY = "webui" -WEBUI_TITLE_METADATA_KEY = "title" -WEBUI_TITLE_USER_EDITED_METADATA_KEY = "title_user_edited" -TITLE_MAX_CHARS = 60 - - -def mark_webui_session(session: Session, metadata: dict[str, Any]) -> bool: - """Persist a WebUI marker only when the inbound websocket frame opted in.""" - if metadata.get(WEBUI_SESSION_METADATA_KEY) is not True: - return False - session.metadata[WEBUI_SESSION_METADATA_KEY] = True - return True - - -def clean_generated_title(raw: str | None) -> str: - text = (raw or "").strip() - if not text: - return "" - text = re.sub(r"^\s*(title|标题)\s*[::]\s*", "", text, flags=re.IGNORECASE) - text = text.strip().strip("\"'`“”‘’") - text = re.sub(r"\s+", " ", text).strip() - text = text.rstrip("。.!!??,,;;:") - if len(text) > TITLE_MAX_CHARS: - text = text[: TITLE_MAX_CHARS - 1].rstrip() + "…" - return text - - -def _title_inputs(session: Session) -> tuple[str, str]: - user_text = "" - assistant_text = "" - for message in session.messages: - role = message.get("role") - content = message.get("content") - if not isinstance(content, str) or not content.strip(): - continue - if role == "user" and not user_text: - user_text = content.strip() - elif role == "assistant" and not assistant_text: - assistant_text = content.strip() - if user_text and assistant_text: - break - return user_text, assistant_text - - -async def maybe_generate_webui_title( - *, - sessions: SessionManager, - session_key: str, - provider: LLMProvider, - model: str, -) -> bool: - """Generate and persist a short title for WebUI-owned sessions only.""" - session = sessions.get_or_create(session_key) - if session.metadata.get(WEBUI_SESSION_METADATA_KEY) is not True: - return False - if session.metadata.get(WEBUI_TITLE_USER_EDITED_METADATA_KEY) is True: - return False - current_title = session.metadata.get(WEBUI_TITLE_METADATA_KEY) - if isinstance(current_title, str) and current_title.strip(): - return False - - user_text, assistant_text = _title_inputs(session) - if not user_text: - return False - - prompt = ( - "Generate a concise title for this chat.\n" - "Rules:\n" - "- Use the same language as the user when practical.\n" - "- 3 to 8 words.\n" - "- No quotes.\n" - "- No punctuation at the end.\n" - "- Return only the title.\n\n" - f"User: {truncate_text(user_text, 1_000)}" - ) - if assistant_text: - prompt += f"\nAssistant: {truncate_text(assistant_text, 1_000)}" - - try: - response = await provider.chat_with_retry( - [ - { - "role": "system", - "content": ( - "You write short, neutral chat titles. " - "Return only the title text." - ), - }, - {"role": "user", "content": prompt}, - ], - tools=None, - model=model, - max_tokens=32, - temperature=0.2, - retry_mode="standard", - ) - except Exception: - logger.debug("Failed to generate webui session title for {}", session_key, exc_info=True) - return False - - title = clean_generated_title(response.content) - if not title or title.lower().startswith("error"): - return False - session.metadata[WEBUI_TITLE_METADATA_KEY] = title - sessions.save(session) - return True - - -async def maybe_generate_webui_title_after_turn( - *, - channel: str, - metadata: dict[str, Any], - sessions: SessionManager, - session_key: str, - provider: LLMProvider, - model: str, -) -> bool: - if channel != "websocket" or metadata.get(WEBUI_SESSION_METADATA_KEY) is not True: - return False - return await maybe_generate_webui_title( - sessions=sessions, - session_key=session_key, - provider=provider, - model=model, - ) diff --git a/nanobot/utils/webui_transcript.py b/nanobot/utils/webui_transcript.py index dde0e916..bee71c54 100644 --- a/nanobot/utils/webui_transcript.py +++ b/nanobot/utils/webui_transcript.py @@ -125,11 +125,25 @@ def replay_transcript_to_ui_messages( buffer_message_id: str | None = None buffer_parts: list[str] = [] suppress_until_turn_end = False + active_activity_segment_id: str | None = None + active_file_edit_segment_id: str | None = None + activity_segment_counter = 0 _ts_base = int(time.time() * 1000) def _new_id(prefix: str, idx: int) -> str: return f"{prefix}-{idx}-{uuid.uuid4().hex[:8]}" + def _new_activity_segment(*, activate: bool = True) -> str: + nonlocal active_activity_segment_id, activity_segment_counter + activity_segment_counter += 1 + segment_id = f"activity-{activity_segment_counter}" + if activate: + active_activity_segment_id = segment_id + return segment_id + + def _ensure_activity_segment() -> str: + return active_activity_segment_id or _new_activity_segment() + def attach_reasoning_chunk(prev: list[dict[str, Any]], chunk: str, idx: int) -> None: for i in range(len(prev) - 1, -1, -1): candidate = prev[i] @@ -151,12 +165,19 @@ def replay_transcript_to_ui_messages( **candidate, "reasoning": (str(candidate.get("reasoning") or "")) + chunk, "reasoningStreaming": True, + "activitySegmentId": candidate.get("activitySegmentId") or _ensure_activity_segment(), } return if not has_answer and candidate.get("isStreaming"): - prev[i] = {**candidate, "reasoning": chunk, "reasoningStreaming": True} + prev[i] = { + **candidate, + "reasoning": chunk, + "reasoningStreaming": True, + "activitySegmentId": candidate.get("activitySegmentId") or _ensure_activity_segment(), + } return break + segment = _ensure_activity_segment() prev.append( { "id": _new_id("as", idx), @@ -165,6 +186,7 @@ def replay_transcript_to_ui_messages( "isStreaming": True, "reasoning": chunk, "reasoningStreaming": True, + "activitySegmentId": segment, "createdAt": _ts_base + idx, }, ) @@ -221,6 +243,7 @@ def replay_transcript_to_ui_messages( return def absorb_complete(extra: dict[str, Any], idx: int) -> None: + nonlocal active_activity_segment_id last = messages[-1] if messages else None if last and is_reasoning_only_placeholder(last): messages[-1] = { @@ -238,10 +261,76 @@ def replay_transcript_to_ui_messages( **extra, }, ) + active_activity_segment_id = None + + def _file_edit_key(edit: dict[str, Any]) -> str: + return "|".join( + str(edit.get(k) or "") + for k in ("call_id", "tool", "path") + ) + + def upsert_file_edits(edits: list[dict[str, Any]], idx: int) -> None: + nonlocal active_file_edit_segment_id + if not edits: + return + last = messages[-1] if messages else None + if ( + active_file_edit_segment_id + and last + and last.get("kind") == "trace" + and last.get("fileEdits") + ): + segment = active_file_edit_segment_id + else: + segment = _new_activity_segment(activate=False) + active_file_edit_segment_id = segment + if not ( + last + and last.get("kind") == "trace" + and not last.get("isStreaming") + and last.get("fileEdits") + and last.get("activitySegmentId") == segment + ): + messages.append( + { + "id": _new_id("tr", idx), + "role": "tool", + "kind": "trace", + "content": "", + "traces": [], + "fileEdits": [], + "activitySegmentId": segment, + "createdAt": _ts_base + idx, + }, + ) + last = messages[-1] + existing = list(last.get("fileEdits") or []) + index_by_key = { + _file_edit_key(edit): pos + for pos, edit in enumerate(existing) + if isinstance(edit, dict) + } + for edit in edits: + if not isinstance(edit, dict): + continue + key = _file_edit_key(edit) + if key in index_by_key: + pos = index_by_key[key] + existing[pos] = {**existing[pos], **edit} + else: + index_by_key[key] = len(existing) + existing.append(dict(edit)) + messages[-1] = { + **last, + "fileEdits": existing, + "activitySegmentId": last.get("activitySegmentId") or segment, + } for idx, rec in enumerate(lines): ev = rec.get("event") if ev == "user": + active_activity_segment_id = None + active_file_edit_segment_id = None text = rec.get("text") text_s = text if isinstance(text, str) else "" media_paths = rec.get("media_paths") @@ -264,6 +353,12 @@ def replay_transcript_to_ui_messages( messages.append(row) continue + if ev == "file_edit": + raw_edits = rec.get("edits") + if isinstance(raw_edits, list): + upsert_file_edits([e for e in raw_edits if isinstance(e, dict)], idx) + continue + if ev == "delta": if suppress_until_turn_end: continue @@ -338,14 +433,21 @@ def replay_transcript_to_ui_messages( trace_lines = structured if structured else ([text] if isinstance(text, str) and text else []) if not trace_lines: continue + segment = _ensure_activity_segment() last = messages[-1] if messages else None - if last and last.get("kind") == "trace" and not last.get("isStreaming"): + if ( + last + and last.get("kind") == "trace" + and not last.get("isStreaming") + and (last.get("activitySegmentId") in (None, segment)) + ): prev_traces = list(last.get("traces") or [last.get("content")]) merged_traces = prev_traces + trace_lines messages[-1] = { **last, "traces": merged_traces, "content": trace_lines[-1], + "activitySegmentId": last.get("activitySegmentId") or segment, } else: messages.append( @@ -355,6 +457,7 @@ def replay_transcript_to_ui_messages( "kind": "trace", "content": trace_lines[-1], "traces": trace_lines, + "activitySegmentId": segment, "createdAt": _ts_base + idx, }, ) @@ -389,6 +492,8 @@ def replay_transcript_to_ui_messages( if ev == "turn_end": suppress_until_turn_end = False + active_activity_segment_id = None + active_file_edit_segment_id = None for i, m in enumerate(messages): if m.get("isStreaming"): messages[i] = {**m, "isStreaming": False} diff --git a/nanobot/utils/webui_turn_helpers.py b/nanobot/utils/webui_turn_helpers.py index 3fbca372..10403852 100644 --- a/nanobot/utils/webui_turn_helpers.py +++ b/nanobot/utils/webui_turn_helpers.py @@ -6,15 +6,161 @@ AgentLoop uses these without importing a concrete channel plugin; only from __future__ import annotations +import re import time +from collections.abc import Awaitable, Callable +from dataclasses import dataclass, field from typing import Any +from loguru import logger + from nanobot.bus.events import InboundMessage, OutboundMessage from nanobot.bus.queue import MessageBus +from nanobot.providers.base import LLMProvider +from nanobot.session.goal_state import goal_state_ws_blob +from nanobot.session.manager import Session, SessionManager +from nanobot.utils.helpers import truncate_text + +WEBUI_SESSION_METADATA_KEY = "webui" +WEBUI_TITLE_METADATA_KEY = "title" +WEBUI_TITLE_USER_EDITED_METADATA_KEY = "title_user_edited" +TITLE_MAX_CHARS = 60 +TITLE_GENERATION_MAX_TOKENS = 96 +TITLE_GENERATION_REASONING_EFFORT = "none" # Wall-clock turn start per ``chat_id`` (websocket only). Survives browser refresh while the # gateway process stays up; cleared on idle/stop and implicitly dropped on restart. _WEBSOCKET_TURN_WALL_STARTED_AT: dict[str, float] = {} +TitleContext = tuple[LLMProvider, str] + + +def mark_webui_session(session: Session, metadata: dict[str, Any]) -> bool: + """Persist a WebUI marker only when the inbound websocket frame opted in.""" + if metadata.get(WEBUI_SESSION_METADATA_KEY) is not True: + return False + session.metadata[WEBUI_SESSION_METADATA_KEY] = True + return True + + +def clean_generated_title(raw: str | None) -> str: + text = (raw or "").strip() + if not text: + return "" + text = re.sub(r"^\s*(title|标题)\s*[::]\s*", "", text, flags=re.IGNORECASE) + text = text.strip().strip("\"'`“”‘’") + text = re.sub(r"\s+", " ", text).strip() + text = text.rstrip("。.!!??,,;;:") + if len(text) > TITLE_MAX_CHARS: + text = text[: TITLE_MAX_CHARS - 1].rstrip() + "…" + return text + + +def _title_inputs(session: Session) -> tuple[str, str]: + user_text = "" + assistant_text = "" + for message in session.messages: + if message.get("_command") is True: + continue + role = message.get("role") + content = message.get("content") + if not isinstance(content, str) or not content.strip(): + continue + if role == "user" and not user_text: + user_text = content.strip() + elif role == "assistant" and not assistant_text: + assistant_text = content.strip() + if user_text and assistant_text: + break + return user_text, assistant_text + + +async def maybe_generate_webui_title( + *, + sessions: SessionManager, + session_key: str, + provider: LLMProvider, + model: str, +) -> bool: + """Generate and persist a short title for WebUI-owned sessions only.""" + session = sessions.get_or_create(session_key) + if session.metadata.get(WEBUI_SESSION_METADATA_KEY) is not True: + return False + if session.metadata.get(WEBUI_TITLE_USER_EDITED_METADATA_KEY) is True: + return False + current_title = session.metadata.get(WEBUI_TITLE_METADATA_KEY) + if isinstance(current_title, str) and current_title.strip(): + return False + + user_text, assistant_text = _title_inputs(session) + if not user_text: + return False + + prompt = ( + "Generate a concise title for this chat.\n" + "Rules:\n" + "- Use the same language as the user when practical.\n" + "- 3 to 8 words.\n" + "- No quotes.\n" + "- No punctuation at the end.\n" + "- Return only the title.\n\n" + f"User: {truncate_text(user_text, 1_000)}" + ) + if assistant_text: + prompt += f"\nAssistant: {truncate_text(assistant_text, 1_000)}" + + try: + response = await provider.chat_with_retry( + [ + { + "role": "system", + "content": ( + "You write short, neutral chat titles. " + "Return only the title text." + ), + }, + {"role": "user", "content": prompt}, + ], + tools=None, + model=model, + max_tokens=TITLE_GENERATION_MAX_TOKENS, + temperature=0.2, + reasoning_effort=TITLE_GENERATION_REASONING_EFFORT, + retry_mode="standard", + ) + except Exception: + logger.debug("Failed to generate webui session title for {}", session_key, exc_info=True) + return False + + title = clean_generated_title(response.content) + if not title or title.lower().startswith("error"): + logger.debug( + "WebUI title generation returned no usable title for {} (finish_reason={})", + session_key, + response.finish_reason, + ) + return False + session.metadata[WEBUI_TITLE_METADATA_KEY] = title + sessions.save(session) + return True + + +async def maybe_generate_webui_title_after_turn( + *, + channel: str, + metadata: dict[str, Any], + sessions: SessionManager, + session_key: str, + provider: LLMProvider, + model: str, +) -> bool: + if channel != "websocket" or metadata.get(WEBUI_SESSION_METADATA_KEY) is not True: + return False + return await maybe_generate_webui_title( + sessions=sessions, + session_key=session_key, + provider=provider, + model=model, + ) def websocket_turn_wall_started_at(chat_id: str) -> float | None: @@ -46,3 +192,125 @@ async def publish_turn_run_status(bus: MessageBus, msg: InboundMessage, status: metadata=meta, ), ) + + +def build_bus_progress_callback( + bus: MessageBus, + msg: InboundMessage, +) -> Callable[..., Awaitable[None]]: + """Return the bus progress callback for agent runtime events.""" + + async def _bus_progress( + content: str, + *, + tool_hint: bool = False, + tool_events: list[dict[str, Any]] | None = None, + file_edit_events: list[dict[str, Any]] | None = None, + reasoning: bool = False, + reasoning_end: bool = False, + ) -> None: + if file_edit_events and msg.channel != "websocket": + return + meta = dict(msg.metadata or {}) + meta["_progress"] = True + meta["_tool_hint"] = tool_hint + if reasoning: + meta["_reasoning_delta"] = True + if reasoning_end: + meta["_reasoning_end"] = True + if tool_events: + meta["_tool_events"] = tool_events + if file_edit_events: + meta["_file_edit_events"] = file_edit_events + await bus.publish_outbound( + OutboundMessage( + channel=msg.channel, + chat_id=msg.chat_id, + content=content, + metadata=meta, + ) + ) + + return _bus_progress + + +@dataclass +class WebuiTurnCoordinator: + """Own the WebUI/WebSocket wire details that hang off AgentLoop turns.""" + + bus: MessageBus + sessions: SessionManager + schedule_background: Callable[[Awaitable[None]], None] + _title_contexts: dict[str, TitleContext] = field(default_factory=dict) + + def capture_title_context( + self, + session_key: str, + msg: InboundMessage, + provider: LLMProvider, + model: str, + ) -> None: + if msg.channel == "websocket" and msg.metadata.get("webui") is True: + self._title_contexts[session_key] = (provider, model) + + def discard(self, session_key: str) -> None: + self._title_contexts.pop(session_key, None) + + async def publish_run_status(self, msg: InboundMessage, status: str) -> None: + await publish_turn_run_status(self.bus, msg, status) + + async def handle_turn_end( + self, + msg: InboundMessage, + *, + session_key: str, + latency_ms: int | None, + ) -> None: + if msg.channel != "websocket": + return + + turn_metadata: dict[str, Any] = {**msg.metadata, "_turn_end": True} + if latency_ms is not None: + turn_metadata["latency_ms"] = int(latency_ms) + session = self.sessions.get_or_create(session_key) + turn_metadata["goal_state"] = goal_state_ws_blob(session.metadata) + await self.bus.publish_outbound(OutboundMessage( + channel=msg.channel, + chat_id=msg.chat_id, + content="", + metadata=turn_metadata, + )) + self._schedule_title_update(msg, session_key=session_key) + + def _schedule_title_update(self, msg: InboundMessage, *, session_key: str) -> None: + title_context = self._title_contexts.pop(session_key, None) + if msg.metadata.get("webui") is not True or title_context is None: + return + + title_provider, title_model = title_context + + async def _generate_title_and_notify( + provider: LLMProvider = title_provider, + model: str = title_model, + ) -> None: + generated = await maybe_generate_webui_title_after_turn( + channel=msg.channel, + metadata=msg.metadata, + sessions=self.sessions, + session_key=session_key, + provider=provider, + model=model, + ) + if generated: + await self.bus.publish_outbound(OutboundMessage( + channel=msg.channel, + chat_id=msg.chat_id, + content="", + metadata={ + **msg.metadata, + "_session_updated": True, + "_session_update_scope": "metadata", + }, + )) + + self.schedule_background(_generate_title_and_notify()) diff --git a/tests/agent/test_loop_progress.py b/tests/agent/test_loop_progress.py index fcf6198c..b1b33612 100644 --- a/tests/agent/test_loop_progress.py +++ b/tests/agent/test_loop_progress.py @@ -82,6 +82,96 @@ class TestToolEventProgress: ), ] + @pytest.mark.asyncio + async def test_write_file_emits_file_edit_progress(self, tmp_path: Path) -> None: + loop = _make_loop(tmp_path) + target = tmp_path / "foo.txt" + target.write_text("old\n", encoding="utf-8") + tool_call = ToolCallRequest( + id="call-write", + name="write_file", + arguments={"path": "foo.txt", "content": "new\nextra\n"}, + ) + calls = iter([ + LLMResponse(content="", tool_calls=[tool_call]), + LLMResponse(content="Done", tool_calls=[]), + ]) + loop.provider.chat_with_retry = AsyncMock(side_effect=lambda *a, **kw: next(calls)) + loop.tools.get_definitions = MagicMock(return_value=[]) + loop.tools.prepare_call = MagicMock( + return_value=(None, {"path": "foo.txt", "content": "new\nextra\n"}, None), + ) + + async def execute(name: str, params: dict) -> str: + target.write_text(params["content"], encoding="utf-8") + return "ok" + + loop.tools.execute = AsyncMock(side_effect=execute) + file_events: list[dict] = [] + + async def on_progress( + content: str, + *, + tool_hint: bool = False, + tool_events: list[dict] | None = None, + file_edit_events: list[dict] | None = None, + ) -> None: + if file_edit_events: + file_events.extend(file_edit_events) + + final_content, _, _, _, _ = await loop._run_agent_loop([], on_progress=on_progress) + + assert final_content == "Done" + assert [event["phase"] for event in file_events] == ["start", "end"] + assert file_events[0] == { + "version": 1, + "call_id": "call-write", + "tool": "write_file", + "path": "foo.txt", + "phase": "start", + "added": 2, + "deleted": 1, + "approximate": True, + "status": "editing", + } + assert file_events[1]["status"] == "done" + assert file_events[1]["approximate"] is False + assert (file_events[1]["added"], file_events[1]["deleted"]) == (2, 1) + + @pytest.mark.asyncio + async def test_exec_does_not_emit_file_edit_progress(self, tmp_path: Path) -> None: + loop = _make_loop(tmp_path) + tool_call = ToolCallRequest( + id="call-exec", + name="exec", + arguments={"command": "printf hi > foo.txt"}, + ) + calls = iter([ + LLMResponse(content="", tool_calls=[tool_call]), + LLMResponse(content="Done", tool_calls=[]), + ]) + loop.provider.chat_with_retry = AsyncMock(side_effect=lambda *a, **kw: next(calls)) + loop.tools.get_definitions = MagicMock(return_value=[]) + loop.tools.prepare_call = MagicMock( + return_value=(None, {"command": "printf hi > foo.txt"}, None), + ) + loop.tools.execute = AsyncMock(return_value="ok") + file_events: list[dict] = [] + + async def on_progress( + content: str, + *, + tool_hint: bool = False, + tool_events: list[dict] | None = None, + file_edit_events: list[dict] | None = None, + ) -> None: + if file_edit_events: + file_events.extend(file_edit_events) + + await loop._run_agent_loop([], on_progress=on_progress) + + assert file_events == [] + @pytest.mark.asyncio async def test_bus_progress_forwards_tool_events_to_outbound_metadata(self, tmp_path: Path) -> None: """When run() handles a bus message, _tool_events lands in OutboundMessage metadata.""" @@ -130,6 +220,42 @@ class TestToolEventProgress: assert finish["phase"] == "end" assert finish["result"] == "file.txt" + @pytest.mark.asyncio + async def test_bus_progress_forwards_file_edit_events_for_websocket_only(self, tmp_path: Path) -> None: + bus = MessageBus() + provider = MagicMock() + provider.get_default_model.return_value = "test-model" + loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model") + edit_events = [{ + "call_id": "call-write", + "tool": "write_file", + "path": "foo.txt", + "phase": "start", + "added": 1, + "deleted": 0, + "approximate": True, + "status": "editing", + }] + + websocket_progress = await loop._build_bus_progress_callback(InboundMessage( + channel="websocket", + sender_id="u1", + chat_id="chat1", + content="edit", + )) + await websocket_progress("", file_edit_events=edit_events) + outbound = await bus.consume_outbound() + assert outbound.metadata["_file_edit_events"] == edit_events + + telegram_progress = await loop._build_bus_progress_callback(InboundMessage( + channel="telegram", + sender_id="u1", + chat_id="chat2", + content="edit", + )) + await telegram_progress("", file_edit_events=edit_events) + assert bus.outbound_size == 0 + @pytest.mark.asyncio async def test_non_streaming_channel_does_not_publish_codex_progress_deltas( self, @@ -353,8 +479,93 @@ class TestToolEventProgress: assert session_updated is not None assert (session_updated.metadata or {}).get("_session_updated") is True + assert (session_updated.metadata or {}).get("_session_update_scope") == "metadata" assert provider.chat_with_retry.await_count == 2 + @pytest.mark.asyncio + async def test_webui_title_generation_uses_turn_model_snapshot( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + bus = MessageBus() + provider = MagicMock() + provider.get_default_model.return_value = "test-model" + provider.chat_with_retry = AsyncMock(return_value=LLMResponse(content="Done", tool_calls=[])) + loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model") + loop.tools.get_definitions = MagicMock(return_value=[]) + loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign] + + captured: dict[str, object] = {} + + async def fake_title_after_turn(**kwargs: object) -> bool: + captured.update(kwargs) + return False + + monkeypatch.setattr( + "nanobot.utils.webui_turn_helpers.maybe_generate_webui_title_after_turn", + fake_title_after_turn, + ) + scheduled_title: list[object] = [] + + def schedule_background(coro: object) -> None: + name = getattr(coro, "__qualname__", "") + if "_generate_title_and_notify" in name: + scheduled_title.append(coro) + elif hasattr(coro, "close"): + coro.close() + + loop._schedule_background = schedule_background # type: ignore[method-assign] + + await loop._dispatch(InboundMessage( + channel="websocket", + sender_id="u1", + chat_id="chat1", + content="say hello", + metadata={"webui": True}, + )) + + assert len(scheduled_title) == 1 + loop.provider = MagicMock() + loop.model = "switched-after-turn" + + await scheduled_title[0] # type: ignore[misc] + + assert captured["provider"] is provider + assert captured["model"] == "test-model" + + @pytest.mark.asyncio + async def test_webui_command_turn_does_not_schedule_title_generation( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + bus = MessageBus() + provider = MagicMock() + provider.get_default_model.return_value = "test-model" + provider.chat_with_retry = AsyncMock(return_value=LLMResponse(content="Done", tool_calls=[])) + loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model") + + async def fake_title_after_turn(**_kwargs: object) -> bool: + raise AssertionError("command-only turns should not generate titles") + + monkeypatch.setattr( + "nanobot.utils.webui_turn_helpers.maybe_generate_webui_title_after_turn", + fake_title_after_turn, + ) + scheduled: list[object] = [] + loop._schedule_background = scheduled.append # type: ignore[method-assign] + + await loop._dispatch(InboundMessage( + channel="websocket", + sender_id="u1", + chat_id="chat1", + content="/model", + metadata={"webui": True}, + )) + + assert scheduled == [] + @pytest.mark.asyncio async def test_non_websocket_dispatch_does_not_publish_turn_end_marker(self, tmp_path: Path) -> None: bus = MessageBus() diff --git a/tests/agent/test_loop_save_turn.py b/tests/agent/test_loop_save_turn.py index ed78e719..10529134 100644 --- a/tests/agent/test_loop_save_turn.py +++ b/tests/agent/test_loop_save_turn.py @@ -11,7 +11,9 @@ from nanobot.bus.queue import MessageBus from nanobot.providers.base import LLMResponse from nanobot.session.goal_state import GOAL_STATE_KEY from nanobot.session.manager import Session -from nanobot.utils.webui_titles import ( +from nanobot.utils.webui_turn_helpers import ( + TITLE_GENERATION_MAX_TOKENS, + TITLE_GENERATION_REASONING_EFFORT, WEBUI_SESSION_METADATA_KEY, WEBUI_TITLE_METADATA_KEY, maybe_generate_webui_title, @@ -55,6 +57,11 @@ async def test_generate_webui_title_only_for_marked_webui_sessions(tmp_path: Pat assert generated is True assert session.metadata[WEBUI_TITLE_METADATA_KEY] == "优化 WebUI 侧边栏" loop.provider.chat_with_retry.assert_awaited_once() + assert loop.provider.chat_with_retry.await_args.kwargs["max_tokens"] == TITLE_GENERATION_MAX_TOKENS + assert ( + loop.provider.chat_with_retry.await_args.kwargs["reasoning_effort"] + == TITLE_GENERATION_REASONING_EFFORT + ) @pytest.mark.asyncio @@ -79,6 +86,31 @@ async def test_generate_webui_title_skips_plain_websocket_sessions(tmp_path: Pat loop.provider.chat_with_retry.assert_not_awaited() +@pytest.mark.asyncio +async def test_generate_webui_title_ignores_command_only_sessions(tmp_path: Path) -> None: + loop = _make_full_loop(tmp_path) + session = loop.sessions.get_or_create("websocket:command-title") + session.metadata[WEBUI_SESSION_METADATA_KEY] = True + session.add_message("user", "/model deep", _command=True) + session.add_message( + "assistant", + "Switched model preset to `deep`.\n- Model: `deepseek-v4-pro`", + _command=True, + ) + loop.sessions.save(session) + + generated = await maybe_generate_webui_title( + sessions=loop.sessions, + session_key="websocket:command-title", + provider=loop.provider, + model=loop.model, + ) + + assert generated is False + assert WEBUI_TITLE_METADATA_KEY not in session.metadata + loop.provider.chat_with_retry.assert_not_awaited() + + def test_save_turn_skips_multimodal_user_when_only_runtime_context() -> None: loop = _mk_loop() session = Session(key="test:runtime-only") diff --git a/tests/channels/test_websocket_channel.py b/tests/channels/test_websocket_channel.py index 2fa7285f..c6f9d66a 100644 --- a/tests/channels/test_websocket_channel.py +++ b/tests/channels/test_websocket_channel.py @@ -370,6 +370,55 @@ async def test_send_progress_includes_structured_tool_events() -> None: ] +@pytest.mark.asyncio +async def test_send_file_edit_progress_uses_file_edit_event() -> None: + bus = MagicMock() + channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus) + mock_ws = AsyncMock() + channel._attach(mock_ws, "chat-1") + + await channel.send(OutboundMessage( + channel="websocket", + chat_id="chat-1", + content="", + metadata={ + "_progress": True, + "_file_edit_events": [ + { + "version": 1, + "phase": "start", + "call_id": "call-1", + "tool": "write_file", + "path": "src/app.py", + "added": 12, + "deleted": 2, + "approximate": True, + "status": "editing", + } + ], + }, + )) + + payload = json.loads(mock_ws.send.await_args.args[0]) + assert payload == { + "event": "file_edit", + "chat_id": "chat-1", + "edits": [ + { + "version": 1, + "phase": "start", + "call_id": "call-1", + "tool": "write_file", + "path": "src/app.py", + "added": 12, + "deleted": 2, + "approximate": True, + "status": "editing", + } + ], + } + + @pytest.mark.asyncio async def test_send_progress_includes_agent_ui_blob() -> None: bus = MagicMock() @@ -758,6 +807,25 @@ async def test_send_session_updated_emits_session_updated_event() -> None: assert body == {"event": "session_updated", "chat_id": "chat-1"} +@pytest.mark.asyncio +async def test_send_session_updated_includes_scope_when_present() -> None: + bus = MagicMock() + channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus) + mock_ws = AsyncMock() + channel._attach(mock_ws, "chat-1") + + await channel.send(OutboundMessage( + channel="websocket", + chat_id="chat-1", + content="", + metadata={"_session_updated": True, "_session_update_scope": "metadata"}, + )) + + mock_ws.send.assert_awaited_once() + body = json.loads(mock_ws.send.await_args.args[0]) + assert body == {"event": "session_updated", "chat_id": "chat-1", "scope": "metadata"} + + @pytest.mark.asyncio async def test_send_non_connection_closed_exception_is_raised() -> None: bus = MagicMock() diff --git a/tests/utils/test_file_edit_events.py b/tests/utils/test_file_edit_events.py new file mode 100644 index 00000000..6176a5e3 --- /dev/null +++ b/tests/utils/test_file_edit_events.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +from pathlib import Path + +from nanobot.utils.file_edit_events import ( + build_file_edit_end_event, + build_file_edit_start_event, + line_diff_stats, + prepare_file_edit_tracker, + read_file_snapshot, +) + + +def test_line_diff_stats_counts_replacements_insertions_and_deletions() -> None: + added, deleted = line_diff_stats("a\nb\nc\n", "a\nB\nc\nd\n") + assert (added, deleted) == (2, 1) + + +def test_line_diff_stats_normalizes_crlf() -> None: + assert line_diff_stats("a\r\nb\r\n", "a\nb\nc\n") == (1, 0) + + +def test_write_file_start_predicts_and_end_calibrates_exact_diff(tmp_path: Path) -> None: + target = tmp_path / "notes.txt" + target.write_text("old\nkeep\n", encoding="utf-8") + params = {"path": "notes.txt", "content": "new\nkeep\nextra\n"} + tracker = prepare_file_edit_tracker( + call_id="call-write", + tool_name="write_file", + tool=None, + workspace=tmp_path, + params=params, + ) + + assert tracker is not None + start = build_file_edit_start_event(tracker, params) + assert start == { + "version": 1, + "call_id": "call-write", + "tool": "write_file", + "path": "notes.txt", + "phase": "start", + "added": 2, + "deleted": 1, + "approximate": True, + "status": "editing", + } + + target.write_text("new\nkeep\nextra\n", encoding="utf-8") + end = build_file_edit_end_event(tracker) + assert end["phase"] == "end" + assert end["status"] == "done" + assert end["approximate"] is False + assert (end["added"], end["deleted"]) == (2, 1) + + +def test_binary_file_is_reported_but_not_counted(tmp_path: Path) -> None: + target = tmp_path / "data.bin" + target.write_bytes(b"\x00\x01before") + tracker = prepare_file_edit_tracker( + call_id="call-bin", + tool_name="edit_file", + tool=None, + workspace=tmp_path, + params={"path": "data.bin", "old_text": "before", "new_text": "after"}, + ) + + assert tracker is not None + assert not read_file_snapshot(target).countable + target.write_bytes(b"\x00\x01after") + event = build_file_edit_end_event(tracker) + assert event["binary"] is True + assert (event["added"], event["deleted"]) == (0, 0) + + +def test_untracked_tools_do_not_prepare_file_edit_tracker(tmp_path: Path) -> None: + assert prepare_file_edit_tracker( + call_id="call-exec", + tool_name="exec", + tool=None, + workspace=tmp_path, + params={"path": "created-by-shell.txt"}, + ) is None diff --git a/tests/utils/test_webui_transcript.py b/tests/utils/test_webui_transcript.py index 419abbfc..f13380f4 100644 --- a/tests/utils/test_webui_transcript.py +++ b/tests/utils/test_webui_transcript.py @@ -42,6 +42,62 @@ def test_replay_delta_and_turn_end(tmp_path, monkeypatch) -> None: assert msgs[1]["latencyMs"] == 42 +def test_replay_file_edit_event_creates_file_activity(tmp_path, monkeypatch) -> None: + monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path) + key = "websocket:t-file" + for ev in ( + {"event": "user", "chat_id": "t-file", "text": "edit"}, + { + "event": "message", + "chat_id": "t-file", + "text": 'write_file({"path":"foo.txt"})', + "kind": "tool_hint", + }, + { + "event": "file_edit", + "chat_id": "t-file", + "edits": [ + { + "version": 1, + "call_id": "call-write", + "tool": "write_file", + "path": "foo.txt", + "phase": "end", + "added": 2, + "deleted": 1, + "approximate": False, + "status": "done", + }, + ], + }, + ): + append_transcript_object(key, ev) + + msgs = replay_transcript_to_ui_messages(read_transcript_lines(key)) + + assert len(msgs) == 3 + assert msgs[1]["kind"] == "trace" + assert msgs[1]["traces"] == ['write_file({"path":"foo.txt"})'] + assert "fileEdits" not in msgs[1] + assert msgs[2]["kind"] == "trace" + assert msgs[2]["traces"] == [] + assert msgs[2]["fileEdits"] == [ + { + "version": 1, + "call_id": "call-write", + "tool": "write_file", + "path": "foo.txt", + "phase": "end", + "added": 2, + "deleted": 1, + "approximate": False, + "status": "done", + }, + ] + assert msgs[2]["activitySegmentId"] + assert msgs[2]["activitySegmentId"] != msgs[1]["activitySegmentId"] + + def test_build_response_schema(monkeypatch, tmp_path) -> None: from nanobot.utils.webui_transcript import build_webui_thread_response From 945f208d382c929f6988e358563771562061e294 Mon Sep 17 00:00:00 2001 From: Xubin Ren <52506698+Re-bin@users.noreply.github.com> Date: Sun, 17 May 2026 23:52:14 +0800 Subject: [PATCH 05/12] feat(webui): render file edit activity --- webui/src/components/FileReferenceChip.tsx | 220 +++++++++++ .../thread/AgentActivityCluster.tsx | 359 +++++++++++++++++- .../src/components/thread/ThreadMessages.tsx | 69 +++- webui/src/hooks/useNanobotStream.ts | 220 +++++++++-- webui/src/lib/types.ts | 25 +- .../src/tests/agent-activity-cluster.test.tsx | 134 ++++++- webui/src/tests/thread-messages.test.tsx | 147 +++++++ webui/src/tests/useNanobotStream.test.tsx | 167 ++++++++ 8 files changed, 1292 insertions(+), 49 deletions(-) create mode 100644 webui/src/components/FileReferenceChip.tsx diff --git a/webui/src/components/FileReferenceChip.tsx b/webui/src/components/FileReferenceChip.tsx new file mode 100644 index 00000000..18e63d1c --- /dev/null +++ b/webui/src/components/FileReferenceChip.tsx @@ -0,0 +1,220 @@ +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; + +type FileReferenceKind = + | "default" + | "css" + | "html" + | "json" + | "markdown" + | "notebook" + | "python" + | "react" + | "typescript"; + +interface FileReferenceChipProps { + path: string; + display?: "name" | "path"; + active?: boolean; + className?: string; + textClassName?: string; + testId?: string; +} + +export function FileReferenceChip({ + path, + display = "name", + active = false, + className, + textClassName, + testId = "inline-file-path", +}: FileReferenceChipProps) { + const { name } = splitFilePath(path); + const kind = fileKindForPath(path); + const displayText = display === "path" ? path.replace(/\\/g, "/") : name; + return ( + + + + + + + + {displayText} + + + + + + {path} + + + + ); +} + +export function isLikelyFilePath(value: string): boolean { + const raw = value.trim(); + if (!raw || raw.includes("\n")) return false; + if (/^[a-z][a-z0-9+.-]*:\/\//i.test(raw)) return false; + if (!/[\\/]/.test(raw) && !/^(dockerfile|makefile|readme|package-lock\.json)$/i.test(raw)) { + return false; + } + const normalized = raw.replace(/\\/g, "/"); + const name = normalized.split("/").filter(Boolean).pop() ?? normalized; + if (!name || name === "." || name === "..") return false; + if (/^(dockerfile|makefile|readme|package-lock\.json)$/i.test(name)) return true; + return /\.[a-z0-9][a-z0-9_-]{0,12}$/i.test(name); +} + +function splitFilePath(path: string): { directory: string; name: string } { + const normalized = path.replace(/\\/g, "/"); + const slash = normalized.lastIndexOf("/"); + if (slash < 0) return { directory: "", name: path }; + return { + directory: normalized.slice(0, slash + 1), + name: normalized.slice(slash + 1) || normalized, + }; +} + +function fileKindForPath(path: string): FileReferenceKind { + const normalized = path.toLowerCase(); + const name = normalized.split(/[\\/]/).pop() ?? normalized; + const ext = name.includes(".") ? name.split(".").pop() ?? "" : ""; + if (name === "dockerfile") { + return "default"; + } + switch (ext) { + case "py": + case "pyi": + return "python"; + case "jsx": + case "tsx": + return "react"; + case "ts": + return "typescript"; + case "html": + case "htm": + return "html"; + case "css": + case "scss": + case "sass": + return "css"; + case "json": + case "jsonl": + return "json"; + case "md": + case "mdx": + return "markdown"; + case "ipynb": + return "notebook"; + default: + return "default"; + } +} + +function FileReferenceIcon({ kind }: { kind: FileReferenceKind }) { + if (kind === "react") { + return ( + + + + + + + ); + } + if (kind === "default") { + return ( + + + + + ); + } + const label = fileKindLabel(kind); + return ( + + {label} + + ); +} + +function fileKindLabel(kind: FileReferenceKind): string { + switch (kind) { + case "css": + return "#"; + case "html": + return "H"; + case "json": + return "{}"; + case "markdown": + return "M"; + case "notebook": + return "N"; + case "python": + return "PY"; + case "typescript": + return "TS"; + default: + return ""; + } +} diff --git a/webui/src/components/thread/AgentActivityCluster.tsx b/webui/src/components/thread/AgentActivityCluster.tsx index a29f590a..792a4156 100644 --- a/webui/src/components/thread/AgentActivityCluster.tsx +++ b/webui/src/components/thread/AgentActivityCluster.tsx @@ -1,10 +1,11 @@ -import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; -import { ChevronRight, Layers } from "lucide-react"; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { AlertCircle, ChevronRight, Layers } from "lucide-react"; import { useTranslation } from "react-i18next"; +import { FileReferenceChip } from "@/components/FileReferenceChip"; import { ReasoningBubble, StreamingLabelSheen, TraceGroup } from "@/components/MessageBubble"; import { cn } from "@/lib/utils"; -import type { UIMessage } from "@/lib/types"; +import type { UIFileEdit, UIMessage } from "@/lib/types"; /** Scrollport height for the Cursor-style “live trace” strip (tailwind spacing). */ const CLUSTER_SCROLL_MAX_CLASS = "max-h-52"; @@ -20,7 +21,29 @@ export function isAgentActivityMember(m: UIMessage): boolean { return isReasoningOnlyAssistant(m) || m.kind === "trace"; } -function countActivity(messages: UIMessage[]): { reasoningSteps: number; toolCalls: number } { +interface ActivityCounts { + reasoningSteps: number; + toolCalls: number; + fileCount: number; + added: number; + deleted: number; + hasEditingFiles: boolean; + hasFailedFiles: boolean; + primaryFilePath?: string; +} + +interface FileEditSummary { + key: string; + path: string; + added: number; + deleted: number; + approximate: boolean; + binary: boolean; + status: UIFileEdit["status"]; + error?: string; +} + +function countActivity(messages: UIMessage[], fileEdits: FileEditSummary[]): ActivityCounts { let reasoningSteps = 0; let toolCalls = 0; for (const m of messages) { @@ -30,10 +53,38 @@ function countActivity(messages: UIMessage[]): { reasoningSteps: number; toolCal } if (m.kind === "trace") { const lines = m.traces?.length ?? (m.content.trim() ? 1 : 0); - toolCalls += Math.max(lines, 1); + toolCalls += lines; } } - return { reasoningSteps, toolCalls }; + let added = 0; + let deleted = 0; + let hasEditingFiles = false; + let failedFileCount = 0; + let primaryFilePath: string | undefined; + for (const edit of fileEdits) { + primaryFilePath = edit.path; + if (edit.status === "editing") { + hasEditingFiles = true; + } + if (edit.status === "error") { + failedFileCount += 1; + } + if (edit.status === "error" || edit.binary) { + continue; + } + added += edit.added; + deleted += edit.deleted; + } + return { + reasoningSteps, + toolCalls, + fileCount: fileEdits.length, + added, + deleted, + hasEditingFiles, + hasFailedFiles: fileEdits.length > 0 && failedFileCount === fileEdits.length, + primaryFilePath, + }; } interface AgentActivityClusterProps { @@ -53,7 +104,20 @@ export function AgentActivityCluster({ hasBodyBelow, }: AgentActivityClusterProps) { const { t } = useTranslation(); - const { reasoningSteps, toolCalls } = countActivity(messages); + const fileEdits = useMemo( + () => summarizeFileEdits(collectFileEdits(messages), isTurnStreaming), + [messages, isTurnStreaming], + ); + const { + reasoningSteps, + toolCalls, + fileCount, + added, + deleted, + hasEditingFiles, + hasFailedFiles, + primaryFilePath, + } = countActivity(messages, fileEdits); const [userToggledOuter, setUserToggledOuter] = useState(false); const [outerOpenLocal, setOuterOpenLocal] = useState(false); @@ -64,16 +128,32 @@ export function AgentActivityCluster({ /** Collapsed by default during “Working…” and after the turn; user expands to inspect traces. */ const outerExpanded = userToggledOuter ? outerOpenLocal : false; - const headerBusy = isTurnStreaming; + const hasLiveEditingFiles = isTurnStreaming && hasEditingFiles; + const headerBusy = fileCount > 0 ? hasEditingFiles : isTurnStreaming; - const summary = - isTurnStreaming + const fileActivitySummary = fileCount > 0 + ? fileCount === 1 && primaryFilePath + ? t(fileActivitySummaryKey(hasLiveEditingFiles, hasFailedFiles), { + file: shortFileName(primaryFilePath), + defaultValue: `${fileActivityVerb(hasLiveEditingFiles, hasFailedFiles)} {{file}}`, + }) + : t(fileActivityManySummaryKey(hasLiveEditingFiles, hasFailedFiles), { + count: fileCount, + defaultValue: `${fileActivityVerb(hasLiveEditingFiles, hasFailedFiles)} {{count}} files`, + }) + : ""; + + const summary = fileCount > 0 + ? fileActivitySummary + : isTurnStreaming ? reasoningSteps > 0 ? t("message.agentActivityLiveSummary", { reasoning: reasoningSteps, tools: toolCalls, defaultValue: "Working… · {{reasoning}} steps · {{tools}} tool calls", }) + : toolCalls === 0 && fileCount > 0 + ? t("message.agentActivityLiveFilesOnly", { defaultValue: "Working…" }) : t("message.agentActivityLiveToolsOnly", { tools: toolCalls, defaultValue: "Working… · {{tools}} tool calls", @@ -84,6 +164,8 @@ export function AgentActivityCluster({ tools: toolCalls, defaultValue: "{{reasoning}} steps · {{tools}} tool calls", }) + : toolCalls === 0 && fileCount > 0 + ? t("message.agentActivityFilesOnly", { defaultValue: "File changes" }) : t("message.agentActivityToolsOnly", { tools: toolCalls, defaultValue: "{{tools}} tool calls", @@ -161,12 +243,19 @@ export function AgentActivityCluster({ aria-expanded={outerExpanded} > - - {summary} - + + + {summary} + + {fileCount > 0 && ( + + + + )} + ); } if (m.kind === "trace") { - return ; + const hasTraceLines = (m.traces?.length ?? 0) > 0 || m.content.trim().length > 0; + return hasTraceLines ? ( +
+ +
+ ) : null; } return null; })} + {fileEdits.length ? : null}
@@ -216,3 +311,231 @@ export function AgentActivityCluster({
); } + +function shortFileName(path: string): string { + return path.split(/[\\/]/).pop() || path; +} + +function fileActivityVerb(editing: boolean, failed: boolean): string { + if (failed) return "Failed"; + return editing ? "Editing" : "Edited"; +} + +function fileActivitySummaryKey(editing: boolean, failed: boolean): string { + if (failed) return "message.fileActivityFailedOne"; + return editing ? "message.fileActivityEditingOne" : "message.fileActivityEditedOne"; +} + +function fileActivityManySummaryKey(editing: boolean, failed: boolean): string { + if (failed) return "message.fileActivityFailedMany"; + return editing ? "message.fileActivityEditingMany" : "message.fileActivityEditedMany"; +} + +function fileEditCallKey(edit: UIFileEdit): string { + return `${edit.call_id}|${edit.tool}|${edit.path}`; +} + +function collectFileEdits(messages: UIMessage[]): UIFileEdit[] { + const edits: UIFileEdit[] = []; + for (const message of messages) { + if (message.kind === "trace" && message.fileEdits?.length) { + edits.push(...message.fileEdits); + } + } + return edits; +} + +function latestFileEditEvents(edits: UIFileEdit[]): UIFileEdit[] { + const order: string[] = []; + const byKey = new Map(); + for (const edit of edits) { + const key = fileEditCallKey(edit); + if (!byKey.has(key)) order.push(key); + byKey.set(key, edit); + } + return order.map((key) => byKey.get(key)).filter(Boolean) as UIFileEdit[]; +} + +function summarizeFileEdits(edits: UIFileEdit[], active: boolean): FileEditSummary[] { + interface MutableSummary { + key: string; + path: string; + added: number; + deleted: number; + approximate: boolean; + binary: boolean; + hasSuccessfulChange: boolean; + hasActiveEditing: boolean; + hasFailed: boolean; + error?: string; + } + + const order: string[] = []; + const byPath = new Map(); + for (const edit of latestFileEditEvents(edits)) { + const key = edit.path; + let summary = byPath.get(key); + if (!summary) { + summary = { + key, + path: edit.path, + added: 0, + deleted: 0, + approximate: false, + binary: false, + hasSuccessfulChange: false, + hasActiveEditing: false, + hasFailed: false, + }; + byPath.set(key, summary); + order.push(key); + } + + if (active && edit.status === "editing") { + summary.hasActiveEditing = true; + summary.binary = summary.binary || !!edit.binary; + summary.approximate = summary.approximate || !!edit.approximate; + if (!edit.binary) { + summary.added += edit.added; + summary.deleted += edit.deleted; + } + continue; + } + + if (edit.status === "error") { + summary.hasFailed = true; + summary.error = edit.error ?? summary.error; + continue; + } + + summary.hasSuccessfulChange = true; + summary.binary = summary.binary || !!edit.binary; + summary.approximate = active && (summary.approximate || !!edit.approximate); + if (!edit.binary) { + summary.added += edit.added; + summary.deleted += edit.deleted; + } + } + + return order.map((key) => { + const summary = byPath.get(key)!; + const status: UIFileEdit["status"] = summary.hasActiveEditing + ? "editing" + : summary.hasSuccessfulChange + ? "done" + : summary.hasFailed + ? "error" + : "done"; + return { + key: summary.key, + path: summary.path, + added: summary.added, + deleted: summary.deleted, + approximate: summary.approximate, + binary: summary.binary, + status, + error: summary.error, + }; + }); +} + +function FileEditGroup({ edits }: { edits: FileEditSummary[] }) { + if (edits.length === 0) return null; + return ( +
    + {edits.map((edit) => ( + + ))} +
+ ); +} + +function FileEditRow({ edit }: { edit: FileEditSummary }) { + const { t } = useTranslation(); + const editing = edit.status === "editing"; + const failed = edit.status === "error"; + const hasCountedDiff = !failed && !edit.binary; + return ( +
  • +
    + + {failed ? ( + + + {t("message.fileEditFailed", { defaultValue: "Failed" })} + + ) : null} + {edit.approximate && !failed ? ( + + {t("message.fileEditApproximate", { defaultValue: "estimated" })} + + ) : null} +
    + {hasCountedDiff ? ( + + ) : null} +
  • + ); +} + +function DiffPair({ added, deleted }: { added: number; deleted: number }) { + return ( + + + + + + + - + + + ); +} + +function AnimatedNumber({ value }: { value: number }) { + const safeValue = Number.isFinite(value) ? Math.max(0, Math.round(value)) : 0; + const [display, setDisplay] = useState(0); + const displayRef = useRef(0); + + const setAnimatedDisplay = useCallback((next: number) => { + displayRef.current = next; + setDisplay(next); + }, []); + + useEffect(() => { + const reduceMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches; + if (reduceMotion) { + setAnimatedDisplay(safeValue); + return; + } + const start = displayRef.current; + const delta = safeValue - start; + if (delta === 0) { + setAnimatedDisplay(safeValue); + return; + } + const duration = 260; + const startedAt = performance.now(); + let frame = 0; + const tick = (now: number) => { + const progress = Math.min(1, (now - startedAt) / duration); + const eased = 1 - Math.pow(1 - progress, 3); + setAnimatedDisplay(Math.round(start + delta * eased)); + if (progress < 1) { + frame = window.requestAnimationFrame(tick); + return; + } + displayRef.current = safeValue; + }; + frame = window.requestAnimationFrame(tick); + return () => window.cancelAnimationFrame(frame); + }, [safeValue, setAnimatedDisplay]); + + return <>{display}; +} diff --git a/webui/src/components/thread/ThreadMessages.tsx b/webui/src/components/thread/ThreadMessages.tsx index 30817121..869d282f 100644 --- a/webui/src/components/thread/ThreadMessages.tsx +++ b/webui/src/components/thread/ThreadMessages.tsx @@ -42,26 +42,77 @@ export function buildDisplayUnits(messages: UIMessage[]): DisplayUnit[] { const m = messages[i]; if (isAgentActivityMember(m)) { const cluster: UIMessage[] = []; - while (i < messages.length && isAgentActivityMember(messages[i])) { - cluster.push(messages[i]); + let segmentId: string | undefined = m.activitySegmentId; + let clusterHasFileEdits = hasFileEdits(m); + while ( + i < messages.length + && isAgentActivityMember(messages[i]) + && canJoinActivityCluster(segmentId, clusterHasFileEdits, messages[i]) + ) { + const current = messages[i]; + if (!segmentId && current.activitySegmentId) { + segmentId = current.activitySegmentId; + } + clusterHasFileEdits = clusterHasFileEdits || hasFileEdits(current); + cluster.push(current); i += 1; } out.push({ type: "cluster", messages: cluster }); continue; } const previous = out[out.length - 1]; - if (previous?.type === "cluster" && assistantHasInlineReasoning(m)) { + if ( + previous?.type === "cluster" + && assistantHasInlineReasoning(m) + && canFoldInlineReasoning(previous.messages, m) + ) { previous.messages.push(reasoningOnlyMessageFromAnswer(m)); out.push({ type: "single", message: stripInlineReasoning(m) }); i += 1; continue; } + if (assistantHasInlineReasoning(m)) { + out.push({ type: "cluster", messages: [reasoningOnlyMessageFromAnswer(m)] }); + out.push({ type: "single", message: stripInlineReasoning(m) }); + i += 1; + continue; + } out.push({ type: "single", message: m }); i += 1; } return out; } +function clusterSegmentId(messages: UIMessage[]): string | undefined { + return messages.find((message) => message.activitySegmentId)?.activitySegmentId; +} + +function hasFileEdits(message: UIMessage): boolean { + return !!message.fileEdits?.length; +} + +function clusterHasFileEdits(messages: UIMessage[]): boolean { + return messages.some(hasFileEdits); +} + +function canJoinActivityCluster( + clusterSegmentId: string | undefined, + clusterIncludesFileEdits: boolean, + message: UIMessage, +): boolean { + const messageHasFileEdits = hasFileEdits(message); + if (!clusterIncludesFileEdits && !messageHasFileEdits) return true; + if (!clusterSegmentId || !message.activitySegmentId) return true; + return clusterSegmentId === message.activitySegmentId; +} + +function canFoldInlineReasoning(cluster: UIMessage[], message: UIMessage): boolean { + if (!clusterHasFileEdits(cluster) && !hasFileEdits(message)) return true; + const segmentId = clusterSegmentId(cluster); + if (!segmentId || !message.activitySegmentId) return true; + return segmentId === message.activitySegmentId; +} + function assistantHasInlineReasoning(message: UIMessage): boolean { return ( message.role === "assistant" @@ -80,6 +131,7 @@ function reasoningOnlyMessageFromAnswer(message: UIMessage): UIMessage { reasoning: message.reasoning, reasoningStreaming: message.reasoningStreaming, isStreaming: message.reasoningStreaming, + activitySegmentId: message.activitySegmentId, }; } @@ -116,6 +168,10 @@ export function ThreadMessages({ const { t } = useTranslation(); const units = useMemo(() => buildDisplayUnits(messages), [messages]); const copyFlags = useMemo(() => assistantCopyFlags(units), [units]); + const liveActivityClusterIndex = useMemo( + () => isStreaming ? currentActivityClusterIndex(units) : -1, + [isStreaming, units], + ); return (
    @@ -150,7 +206,7 @@ export function ThreadMessages({ {unit.type === "cluster" ? ( ) : ( @@ -170,6 +226,11 @@ export function ThreadMessages({ ); } +function currentActivityClusterIndex(units: DisplayUnit[]): number { + const last = units.length - 1; + return units[last]?.type === "cluster" ? last : -1; +} + function unitKey(unit: DisplayUnit, index: number): string { if (unit.type === "cluster") { const anchor = unit.messages[0]?.id; diff --git a/webui/src/hooks/useNanobotStream.ts b/webui/src/hooks/useNanobotStream.ts index 9ea03602..2ee11322 100644 --- a/webui/src/hooks/useNanobotStream.ts +++ b/webui/src/hooks/useNanobotStream.ts @@ -10,6 +10,7 @@ import type { OutboundMedia, GoalStateWsPayload, UIImage, + UIFileEdit, UIMessage, } from "@/lib/types"; @@ -27,12 +28,17 @@ type PendingStreamEvent = | { kind: "delta"; text: string } | { kind: "reasoning"; text: string }; -/** Scan upward from the bottom skipping trace rows so tool breadcrumbs don't steal the stream target. */ -function findStreamingAssistantIndex(prev: UIMessage[]): number | null { +/** Find a still-open streamed assistant turn. Closed stream segments stay visible + * as streaming until ``turn_end`` for visual continuity, but they must not + * receive later delta segments. */ +function findStreamingAssistantIndex( + prev: UIMessage[], + closedStreamIds: ReadonlySet, +): number | null { for (let i = prev.length - 1; i >= 0; i -= 1) { const m = prev[i]; if (m.kind === "trace") continue; - if (m.role === "assistant" && m.isStreaming) return i; + if (m.role === "assistant" && m.isStreaming && !closedStreamIds.has(m.id)) return i; if (m.role === "user") break; } return null; @@ -47,7 +53,13 @@ function findStreamingAssistantIndex(prev: UIMessage[]): number | null { * case the reasoning still belongs to the same assistant turn and must render * above the answer, not as a new row below it. */ -function attachReasoningChunk(prev: UIMessage[], chunk: string): UIMessage[] { +function attachReasoningChunk( + prev: UIMessage[], + chunk: string, + segments?: { + ensure: () => string; + }, +): UIMessage[] { for (let i = prev.length - 1; i >= 0; i -= 1) { const candidate = prev[i]; // A user turn is a hard boundary: reasoning after it belongs to the new @@ -58,6 +70,7 @@ function attachReasoningChunk(prev: UIMessage[], chunk: string): UIMessage[] { // that produced those tool calls. if (candidate.kind === "trace") break; if (candidate.role !== "assistant") continue; + const activitySegmentId = candidate.activitySegmentId ?? segments?.ensure(); const hasAnswer = candidate.content.length > 0; if ( candidate.reasoningStreaming @@ -69,6 +82,7 @@ function attachReasoningChunk(prev: UIMessage[], chunk: string): UIMessage[] { ...candidate, reasoning: (candidate.reasoning ?? "") + chunk, reasoningStreaming: true, + ...(activitySegmentId ? { activitySegmentId } : {}), }; return [...prev.slice(0, i), merged, ...prev.slice(i + 1)]; } @@ -77,11 +91,13 @@ function attachReasoningChunk(prev: UIMessage[], chunk: string): UIMessage[] { ...candidate, reasoning: chunk, reasoningStreaming: true, + ...(activitySegmentId ? { activitySegmentId } : {}), }; return [...prev.slice(0, i), merged, ...prev.slice(i + 1)]; } break; } + const activitySegmentId = segments?.ensure(); return [ ...prev, { @@ -91,6 +107,7 @@ function attachReasoningChunk(prev: UIMessage[], chunk: string): UIMessage[] { isStreaming: true, reasoning: chunk, reasoningStreaming: true, + ...(activitySegmentId ? { activitySegmentId } : {}), createdAt: Date.now(), }, ]; @@ -197,6 +214,47 @@ function absorbCompleteAssistantMessage( ]; } +function fileEditKey(edit: Pick): string { + return `${edit.call_id}|${edit.tool}|${edit.path}`; +} + +function normalizeFileEdit(edit: UIFileEdit): UIFileEdit | null { + if (!edit || !edit.path || !edit.tool) return null; + const inferredStatus = + edit.phase === "error" + ? "error" + : edit.phase === "end" + ? "done" + : "editing"; + return { + ...edit, + call_id: edit.call_id || `${edit.tool}:${edit.path}`, + added: Number.isFinite(edit.added) ? Math.max(0, Math.round(edit.added)) : 0, + deleted: Number.isFinite(edit.deleted) ? Math.max(0, Math.round(edit.deleted)) : 0, + status: edit.status === "error" || edit.status === "done" || edit.status === "editing" + ? edit.status + : inferredStatus, + }; +} + +function mergeFileEdits(existing: UIFileEdit[] | undefined, incoming: UIFileEdit[]): UIFileEdit[] { + const next = [...(existing ?? [])]; + const indexByKey = new Map(next.map((edit, index) => [fileEditKey(edit), index])); + for (const raw of incoming) { + const edit = normalizeFileEdit(raw); + if (!edit) continue; + const key = fileEditKey(edit); + const existingIndex = indexByKey.get(key); + if (existingIndex === undefined) { + indexByKey.set(key, next.length); + next.push(edit); + continue; + } + next[existingIndex] = { ...next[existingIndex], ...edit }; + } + return next; +} + /** * Subscribe to a chat by ID. Returns the in-memory message list for the chat, * a streaming flag, and a ``send`` function. Initial history must be seeded @@ -255,6 +313,10 @@ export function useNanobotStream( const [streamError, setStreamError] = useState(null); const buffer = useRef(null); const activeAssistantRef = useRef(null); + const closedAssistantStreamIdsRef = useRef>(new Set()); + const activitySegmentRef = useRef(null); + const fileEditSegmentRef = useRef(null); + const activitySegmentCounterRef = useRef(0); const pendingStreamEventsRef = useRef([]); const streamFrameRef = useRef(null); const suppressStreamUntilTurnEndRef = useRef(false); @@ -281,6 +343,40 @@ export function useNanobotStream( pendingStreamEventsRef.current = []; }, []); + const createActivitySegmentId = useCallback((activate = true) => { + activitySegmentCounterRef.current += 1; + const id = `activity-${activitySegmentCounterRef.current}`; + if (activate) activitySegmentRef.current = id; + return id; + }, []); + + const freshActivitySegmentId = useCallback( + () => createActivitySegmentId(true), + [createActivitySegmentId], + ); + + const detachedActivitySegmentId = useCallback( + () => createActivitySegmentId(false), + [createActivitySegmentId], + ); + + const ensureActivitySegmentId = useCallback(() => { + if (activitySegmentRef.current) return activitySegmentRef.current; + return freshActivitySegmentId(); + }, [freshActivitySegmentId]); + + const clearActivitySegment = useCallback(() => { + activitySegmentRef.current = null; + fileEditSegmentRef.current = null; + }, []); + + const closeActiveAssistantStream = useCallback(() => { + const closedStreamId = buffer.current?.messageId ?? activeAssistantRef.current?.id; + if (closedStreamId) closedAssistantStreamIdsRef.current.add(closedStreamId); + buffer.current = null; + activeAssistantRef.current = null; + }, []); + const resolveActiveAssistantIndex = useCallback((prev: UIMessage[]): number | null => { const cursor = activeAssistantRef.current; if (!cursor) return null; @@ -311,7 +407,7 @@ export function useNanobotStream( targetIndex = findActiveAssistantPlaceholderIndex(next); } if (targetIndex === null) { - targetIndex = findStreamingAssistantIndex(next); + targetIndex = findStreamingAssistantIndex(next, closedAssistantStreamIdsRef.current); } if (targetIndex === null) { const id = crypto.randomUUID(); @@ -334,6 +430,7 @@ export function useNanobotStream( content: target.content + chunk, isStreaming: true, }; + closedAssistantStreamIdsRef.current.delete(merged.id); activeAssistantRef.current = { id: merged.id, index: targetIndex }; buffer.current = { messageId: merged.id }; return replaceMessageAt(next, targetIndex, merged); @@ -353,23 +450,32 @@ export function useNanobotStream( } next = kind === "delta" ? appendAnswerChunk(next, text) - : attachReasoningChunk(next, text); + : attachReasoningChunk(next, text, { + ensure: ensureActivitySegmentId, + }); } return next; }, - [appendAnswerChunk], + [appendAnswerChunk, ensureActivitySegmentId], ); - const flushPendingStreamEvents = useCallback(() => { + const flushPendingStreamEvents = useCallback((options?: { closeAnswerSegment?: boolean }) => { if (streamFrameRef.current !== null) { window.cancelAnimationFrame(streamFrameRef.current); streamFrameRef.current = null; } const events = pendingStreamEventsRef.current; - if (events.length === 0) return; + if (events.length === 0) { + if (options?.closeAnswerSegment) closeActiveAssistantStream(); + return; + } pendingStreamEventsRef.current = []; - setMessages((prev) => applyPendingStreamEvents(prev, events)); - }, [applyPendingStreamEvents]); + setMessages((prev) => { + const next = applyPendingStreamEvents(prev, events); + if (options?.closeAnswerSegment) closeActiveAssistantStream(); + return next; + }); + }, [applyPendingStreamEvents, closeActiveAssistantStream]); const schedulePendingStreamFlush = useCallback(() => { if (streamFrameRef.current !== null) return; @@ -397,6 +503,8 @@ export function useNanobotStream( setGoalState(chatId ? client.getGoalState(chatId) : undefined); buffer.current = null; activeAssistantRef.current = null; + closedAssistantStreamIdsRef.current.clear(); + clearActivitySegment(); clearPendingStreamWork(); suppressStreamUntilTurnEndRef.current = false; if (streamEndTimerRef.current !== null) { @@ -404,7 +512,7 @@ export function useNanobotStream( streamEndTimerRef.current = null; } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [chatId, client, clearPendingStreamWork]); + }, [chatId, client, clearActivitySegment, clearPendingStreamWork]); useEffect(() => { if (hasPendingToolCalls) setIsStreaming(true); @@ -442,21 +550,17 @@ export function useNanobotStream( return; } - flushPendingStreamEvents(); - if (ev.event === "stream_end") { - if (suppressStreamUntilTurnEndRef.current) { - buffer.current = null; - return; - } + flushPendingStreamEvents({ closeAnswerSegment: true }); + if (suppressStreamUntilTurnEndRef.current) return; // stream_end only means the text segment finished — the model may // still be executing tools. Do NOT reset isStreaming here; the // definitive "turn is complete" signal is ``turn_end``. - if (!buffer.current) return; - buffer.current = null; return; } + flushPendingStreamEvents(); + if (ev.event === "reasoning_end") { if (suppressStreamUntilTurnEndRef.current) return; setMessages((prev) => closeReasoningStream(prev)); @@ -496,6 +600,8 @@ export function useNanobotStream( } buffer.current = null; activeAssistantRef.current = null; + clearActivitySegment(); + closedAssistantStreamIdsRef.current.clear(); return finalized; }); suppressStreamUntilTurnEndRef.current = false; @@ -516,7 +622,9 @@ export function useNanobotStream( if (ev.kind === "reasoning") { const line = ev.text; if (!line) return; - setMessages((prev) => closeReasoningStream(attachReasoningChunk(prev, line))); + setMessages((prev) => closeReasoningStream(attachReasoningChunk(prev, line, { + ensure: ensureActivitySegmentId, + }))); return; } // Intermediate agent breadcrumbs (tool-call hints, raw progress). @@ -531,12 +639,24 @@ export function useNanobotStream( : []; if (lines.length === 0) return; setMessages((prev) => { + const segmentId = ensureActivitySegmentId(); const last = prev[prev.length - 1]; - if (last && last.kind === "trace" && !last.isStreaming) { + if ( + last + && last.kind === "trace" + && !last.isStreaming + && (!last.activitySegmentId || last.activitySegmentId === segmentId) + ) { + const previousTraces = last.traces?.length + ? last.traces + : last.content + ? [last.content] + : []; const merged: UIMessage = { ...last, - traces: [...(last.traces ?? [last.content]), ...lines], + traces: [...previousTraces, ...lines], content: lines[lines.length - 1], + activitySegmentId: last.activitySegmentId ?? segmentId, }; return [...prev.slice(0, -1), merged]; } @@ -548,6 +668,7 @@ export function useNanobotStream( kind: "trace", content: lines[lines.length - 1], traces: lines, + activitySegmentId: segmentId, createdAt: Date.now(), }, ]; @@ -585,6 +706,46 @@ export function useNanobotStream( } return; } + if (ev.event === "file_edit") { + const edits = Array.isArray(ev.edits) ? ev.edits : []; + if (edits.length === 0) return; + setMessages((prev) => { + const last = prev[prev.length - 1]; + let segmentId = fileEditSegmentRef.current; + if (!segmentId || !(last?.kind === "trace" && last.fileEdits?.length)) { + segmentId = detachedActivitySegmentId(); + fileEditSegmentRef.current = segmentId; + } + if ( + last + && last.kind === "trace" + && !last.isStreaming + && !!last.fileEdits?.length + && last.activitySegmentId === segmentId + ) { + const merged: UIMessage = { + ...last, + fileEdits: mergeFileEdits(last.fileEdits, edits), + activitySegmentId: last.activitySegmentId ?? segmentId, + }; + return [...prev.slice(0, -1), merged]; + } + return [ + ...prev, + { + id: crypto.randomUUID(), + role: "tool", + kind: "trace", + content: "", + traces: [], + fileEdits: mergeFileEdits(undefined, edits), + activitySegmentId: segmentId, + createdAt: Date.now(), + }, + ]; + }); + return; + } // ``attached`` / ``error`` frames aren't actionable here; the client // shell handles them separately. }; @@ -594,6 +755,8 @@ export function useNanobotStream( unsub(); buffer.current = null; activeAssistantRef.current = null; + closedAssistantStreamIdsRef.current.clear(); + clearActivitySegment(); clearPendingStreamWork(); if (streamEndTimerRef.current !== null) { clearTimeout(streamEndTimerRef.current); @@ -603,7 +766,10 @@ export function useNanobotStream( }, [ chatId, client, + clearActivitySegment, clearPendingStreamWork, + detachedActivitySegmentId, + ensureActivitySegmentId, flushPendingStreamEvents, onTurnEnd, schedulePendingStreamFlush, @@ -622,6 +788,8 @@ export function useNanobotStream( setMessages((prev) => { buffer.current = null; activeAssistantRef.current = null; + closedAssistantStreamIdsRef.current.clear(); + clearActivitySegment(); return [ ...pruneReasoningOnlyPlaceholders(prev), { @@ -643,7 +811,7 @@ export function useNanobotStream( client.sendMessage(chatId, content, wireMedia); } }, - [chatId, client, flushPendingStreamEvents], + [chatId, clearActivitySegment, client, flushPendingStreamEvents], ); const stop = useCallback(() => { @@ -653,11 +821,13 @@ export function useNanobotStream( setMessages((prev) => { buffer.current = null; activeAssistantRef.current = null; + closedAssistantStreamIdsRef.current.clear(); + clearActivitySegment(); return prev.map((m) => (m.isStreaming ? { ...m, isStreaming: false } : m)); }); suppressStreamUntilTurnEndRef.current = false; client.sendMessage(chatId, "/stop"); - }, [chatId, client, flushPendingStreamEvents]); + }, [chatId, clearActivitySegment, client, flushPendingStreamEvents]); return { messages, diff --git a/webui/src/lib/types.ts b/webui/src/lib/types.ts index 59ad8566..8ffb4a70 100644 --- a/webui/src/lib/types.ts +++ b/webui/src/lib/types.ts @@ -40,6 +40,10 @@ export interface UIMessage { /** For trace rows: each individual hint line, so consecutive hints can * render as a single collapsible group. */ traces?: string[]; + /** Activity rows: explicit file edits emitted by edit tools. */ + fileEdits?: UIFileEdit[]; + /** Activity rows created during the same agent phase share one collapsible block. */ + activitySegmentId?: string; /** User turn: optimistic blob URLs for preview. Replay: placeholder chips. */ images?: UIImage[]; /** Signed or local UI-renderable media attachments. */ @@ -80,6 +84,20 @@ export interface ToolProgressEvent { embeds?: unknown[]; } +export interface UIFileEdit { + version?: number; + call_id: string; + tool: string; + path: string; + phase?: "start" | "end" | "error" | string; + added: number; + deleted: number; + approximate?: boolean; + status: "editing" | "done" | "error"; + binary?: boolean; + error?: string; +} + export interface ChatSummary { /** Server-side session key, e.g. ``websocket:abcd-...``. */ key: string; @@ -183,6 +201,11 @@ export type InboundEvent = /** Optional structured payload on progress frames (channel-specific). */ agent_ui?: AgentUIBlob; } + | { + event: "file_edit"; + chat_id: string; + edits: UIFileEdit[]; + } | { event: "delta"; chat_id: string; @@ -230,7 +253,7 @@ export type InboundEvent = chat_id: string; goal_state: GoalStateWsPayload; } - | { event: "session_updated"; chat_id: string } + | { event: "session_updated"; chat_id: string; scope?: "metadata" | "thread" | string } | { event: "error"; chat_id?: string; detail?: string }; /** Base64-encoded image attached to an outbound ``message`` envelope. diff --git a/webui/src/tests/agent-activity-cluster.test.tsx b/webui/src/tests/agent-activity-cluster.test.tsx index e6bffd38..12026850 100644 --- a/webui/src/tests/agent-activity-cluster.test.tsx +++ b/webui/src/tests/agent-activity-cluster.test.tsx @@ -1,4 +1,4 @@ -import { act, fireEvent, render, screen } from "@testing-library/react"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { describe, expect, it } from "vitest"; import { AgentActivityCluster } from "@/components/thread/AgentActivityCluster"; @@ -72,6 +72,25 @@ function setScrollGeometry( }); } +function installReducedMotion() { + const original = window.matchMedia; + Object.defineProperty(window, "matchMedia", { + configurable: true, + value: () => ({ + matches: true, + media: "(prefers-reduced-motion: reduce)", + addEventListener: () => {}, + removeEventListener: () => {}, + }), + }); + return () => { + Object.defineProperty(window, "matchMedia", { + configurable: true, + value: original, + }); + }; +} + describe("AgentActivityCluster", () => { it("jumps to the latest activity when opened", () => { const raf = installAnimationFrameQueue(); @@ -201,4 +220,117 @@ describe("AgentActivityCluster", () => { raf.restore(); } }); + + it("renders file edit totals and a compact expanded file list", async () => { + const restoreMotion = installReducedMotion(); + try { + render( + , + ); + + expect(screen.getByRole("button", { name: /edited app\.tsx/i })).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: /edited app\.tsx/i })); + + expect(screen.queryByText("Edited files")).not.toBeInTheDocument(); + expect(screen.queryByText("Edited")).not.toBeInTheDocument(); + const fileRef = screen.getByTestId("activity-file-reference"); + expect(fileRef).toHaveTextContent("src/app.tsx"); + expect(fileRef).toHaveAttribute("aria-label", "src/app.tsx"); + await waitFor(() => { + expect(screen.getAllByText("+12").length).toBeGreaterThan(0); + expect(screen.getAllByText("-3").length).toBeGreaterThan(0); + }); + } finally { + restoreMotion(); + } + }); + + it("merges repeated edits for the same path and lets successful edits win over failures", async () => { + const restoreMotion = installReducedMotion(); + try { + render( + , + ); + + expect(screen.getByRole("button", { name: /edited index\.html/i })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /failed index\.html/i })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: /edited index\.html/i })); + + const fileRefs = screen.getAllByTestId("activity-file-reference"); + expect(fileRefs).toHaveLength(1); + expect(fileRefs[0]).toHaveTextContent("minecraft-fps/index.html"); + expect(screen.queryByText("Failed")).not.toBeInTheDocument(); + await waitFor(() => { + expect(screen.getAllByText("+8").length).toBeGreaterThan(0); + expect(screen.getAllByText("-7").length).toBeGreaterThan(0); + }); + } finally { + restoreMotion(); + } + }); }); diff --git a/webui/src/tests/thread-messages.test.tsx b/webui/src/tests/thread-messages.test.tsx index 4e7711fa..7b3f2150 100644 --- a/webui/src/tests/thread-messages.test.tsx +++ b/webui/src/tests/thread-messages.test.tsx @@ -55,6 +55,153 @@ describe("ThreadMessages", () => { expect(rows[1]).toHaveClass("mt-4"); }); + it("starts a new activity cluster when the activity segment changes", () => { + const messages: UIMessage[] = [ + { + id: "r1", + role: "assistant", + content: "", + reasoning: "first pass", + activitySegmentId: "seg-1", + createdAt: 1, + }, + { + id: "t1", + role: "tool", + kind: "trace", + content: "edit_file()", + traces: ["edit_file()"], + fileEdits: [{ + call_id: "call-edit", + tool: "edit_file", + path: "foo.txt", + phase: "end", + added: 2, + deleted: 1, + status: "done", + }], + activitySegmentId: "seg-1", + createdAt: 2, + }, + { + id: "r2", + role: "assistant", + content: "", + reasoning: "second pass", + activitySegmentId: "seg-2", + createdAt: 3, + }, + ]; + + const units = buildDisplayUnits(messages); + + expect(units).toHaveLength(2); + expect(units[0].type === "cluster" ? units[0].messages.map((m) => m.id) : []).toEqual([ + "r1", + "t1", + ]); + expect(units[1].type === "cluster" ? units[1].messages.map((m) => m.id) : []).toEqual([ + "r2", + ]); + }); + + it("does not split ordinary tool activity just because segment ids changed", () => { + const messages: UIMessage[] = [ + { + id: "r1", + role: "assistant", + content: "", + reasoning: "first pass", + activitySegmentId: "seg-1", + createdAt: 1, + }, + { + id: "t1", + role: "tool", + kind: "trace", + content: "read_file()", + traces: ["read_file()"], + activitySegmentId: "seg-1", + createdAt: 2, + }, + { + id: "r2", + role: "assistant", + content: "", + reasoning: "second pass", + activitySegmentId: "seg-2", + createdAt: 3, + }, + { + id: "t2", + role: "tool", + kind: "trace", + content: "grep()", + traces: ["grep()"], + activitySegmentId: "seg-2", + createdAt: 4, + }, + ]; + + const units = buildDisplayUnits(messages); + + expect(units).toHaveLength(1); + expect(units[0].type === "cluster" ? units[0].messages.map((m) => m.id) : []).toEqual([ + "r1", + "t1", + "r2", + "t2", + ]); + }); + + it("only marks the current activity cluster as live while streaming", () => { + const messages: UIMessage[] = [ + { + id: "r1", + role: "assistant", + content: "", + reasoning: "first pass", + reasoningStreaming: true, + activitySegmentId: "seg-1", + createdAt: 1, + }, + { + id: "t1", + role: "tool", + kind: "trace", + content: "edit_file()", + traces: ["edit_file()"], + fileEdits: [{ + call_id: "call-edit", + tool: "edit_file", + path: "foo.txt", + phase: "start", + added: 4, + deleted: 1, + approximate: true, + status: "editing", + }], + activitySegmentId: "seg-1", + createdAt: 2, + }, + { + id: "r2", + role: "assistant", + content: "", + reasoning: "second pass", + reasoningStreaming: true, + activitySegmentId: "seg-2", + createdAt: 3, + }, + ]; + + render(); + + expect(screen.getByRole("button", { name: /edited foo\.txt/i })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /editing foo\.txt/i })).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: /working/i })).toBeInTheDocument(); + }); + it("folds final answer reasoning into the preceding activity cluster", () => { const messages: UIMessage[] = [ { diff --git a/webui/src/tests/useNanobotStream.test.tsx b/webui/src/tests/useNanobotStream.test.tsx index 0f736a01..925102da 100644 --- a/webui/src/tests/useNanobotStream.test.tsx +++ b/webui/src/tests/useNanobotStream.test.tsx @@ -308,6 +308,173 @@ describe("useNanobotStream", () => { ); }); + it("renders live file_edit events as their own activity trace", () => { + const fake = fakeClient(); + const { result } = renderHook(() => useNanobotStream("chat-file-edit", EMPTY_MESSAGES), { + wrapper: wrap(fake.client), + }); + + act(() => { + fake.emit("chat-file-edit", { + event: "message", + chat_id: "chat-file-edit", + text: 'write_file({"path":"foo.txt"})', + kind: "tool_hint", + }); + fake.emit("chat-file-edit", { + event: "file_edit", + chat_id: "chat-file-edit", + edits: [{ + call_id: "call-write", + tool: "write_file", + path: "foo.txt", + phase: "start", + added: 1, + deleted: 0, + approximate: true, + status: "editing", + }], + }); + fake.emit("chat-file-edit", { + event: "file_edit", + chat_id: "chat-file-edit", + edits: [{ + call_id: "call-write", + tool: "write_file", + path: "foo.txt", + phase: "end", + added: 3, + deleted: 1, + approximate: false, + status: "done", + }], + }); + }); + + expect(result.current.messages).toHaveLength(2); + expect(result.current.messages[0]).toMatchObject({ + role: "tool", + kind: "trace", + traces: ['write_file({"path":"foo.txt"})'], + }); + expect(result.current.messages[1]).toMatchObject({ + role: "tool", + kind: "trace", + fileEdits: [{ + call_id: "call-write", + status: "done", + added: 3, + deleted: 1, + approximate: false, + }], + }); + expect(result.current.messages[1].activitySegmentId).toBeTruthy(); + expect(result.current.messages[1].activitySegmentId).not.toBe( + result.current.messages[0].activitySegmentId, + ); + }); + + it("starts a new assistant bubble for deltas after stream_end and activity", async () => { + const fake = fakeClient(); + const { result } = renderHook(() => useNanobotStream("chat-stream-segments", EMPTY_MESSAGES), { + wrapper: wrap(fake.client), + }); + + act(() => { + fake.emit("chat-stream-segments", { + event: "delta", + chat_id: "chat-stream-segments", + text: "I created the files.", + }); + fake.emit("chat-stream-segments", { + event: "stream_end", + chat_id: "chat-stream-segments", + }); + fake.emit("chat-stream-segments", { + event: "message", + chat_id: "chat-stream-segments", + text: 'write_file({"path":"minecraft-fps/options.txt"})', + kind: "tool_hint", + }); + fake.emit("chat-stream-segments", { + event: "delta", + chat_id: "chat-stream-segments", + text: "Now I will summarize the edits.", + }); + }); + + await flushStreamFrame(); + + expect(result.current.messages).toHaveLength(3); + expect(result.current.messages[0]).toMatchObject({ + role: "assistant", + content: "I created the files.", + }); + expect(result.current.messages[1]).toMatchObject({ + role: "tool", + kind: "trace", + traces: ['write_file({"path":"minecraft-fps/options.txt"})'], + }); + expect(result.current.messages[2]).toMatchObject({ + role: "assistant", + content: "Now I will summarize the edits.", + }); + }); + + it("opens a new activity segment for reasoning after file edit activity", async () => { + const fake = fakeClient(); + const { result } = renderHook(() => useNanobotStream("chat-file-segments", EMPTY_MESSAGES), { + wrapper: wrap(fake.client), + }); + + act(() => { + fake.emit("chat-file-segments", { + event: "reasoning_delta", + chat_id: "chat-file-segments", + text: "Plan.", + }); + fake.emit("chat-file-segments", { + event: "reasoning_end", + chat_id: "chat-file-segments", + }); + fake.emit("chat-file-segments", { + event: "message", + chat_id: "chat-file-segments", + text: 'edit_file({"path":"foo.txt"})', + kind: "tool_hint", + }); + fake.emit("chat-file-segments", { + event: "file_edit", + chat_id: "chat-file-segments", + edits: [{ + call_id: "call-edit", + tool: "edit_file", + path: "foo.txt", + phase: "start", + added: 1, + deleted: 1, + approximate: true, + status: "editing", + }], + }); + fake.emit("chat-file-segments", { + event: "reasoning_delta", + chat_id: "chat-file-segments", + text: "Review result.", + }); + }); + + await flushStreamFrame(); + + expect(result.current.messages).toHaveLength(4); + const firstSegment = result.current.messages[0].activitySegmentId; + expect(firstSegment).toBeTruthy(); + expect(result.current.messages[1].activitySegmentId).toBe(firstSegment); + expect(result.current.messages[2].activitySegmentId).toBeTruthy(); + expect(result.current.messages[2].activitySegmentId).not.toBe(firstSegment); + expect(result.current.messages[3].activitySegmentId).toBe(firstSegment); + }); + it("accumulates reasoning_delta chunks on a placeholder until reasoning_end", async () => { const fake = fakeClient(); const { result } = renderHook(() => useNanobotStream("chat-r", EMPTY_MESSAGES), { From 361f31c0e4ecc16430a0e5513cfe90d4b3822a8d Mon Sep 17 00:00:00 2001 From: Xubin Ren <52506698+Re-bin@users.noreply.github.com> Date: Sun, 17 May 2026 23:52:29 +0800 Subject: [PATCH 06/12] fix(webui): use portal file reference tooltips --- webui/src/components/MarkdownTextRenderer.tsx | 4 ++ webui/src/components/ui/tooltip.tsx | 20 +++++---- webui/src/tests/message-bubble.test.tsx | 43 ++++++++++++++++++- 3 files changed, 57 insertions(+), 10 deletions(-) diff --git a/webui/src/components/MarkdownTextRenderer.tsx b/webui/src/components/MarkdownTextRenderer.tsx index ff75004a..aa757ff0 100644 --- a/webui/src/components/MarkdownTextRenderer.tsx +++ b/webui/src/components/MarkdownTextRenderer.tsx @@ -6,6 +6,7 @@ import remarkGfm from "remark-gfm"; import remarkMath from "remark-math"; import { CodeBlock } from "@/components/CodeBlock"; +import { FileReferenceChip, isLikelyFilePath } from "@/components/FileReferenceChip"; import { cn } from "@/lib/utils"; import "katex/dist/katex.min.css"; @@ -44,6 +45,9 @@ export default function MarkdownTextRenderer({ ); } const raw = String(kids).replace(/\n$/, ""); + if (isLikelyFilePath(raw)) { + return ; + } /** Plain fenced ``` blocks (no language) & wide one-liners: block monospace, not inline pill. */ const widePlainBlock = raw.includes("\n") || raw.length > 120; if (widePlainBlock) { diff --git a/webui/src/components/ui/tooltip.tsx b/webui/src/components/ui/tooltip.tsx index 95f7960c..d69f1227 100644 --- a/webui/src/components/ui/tooltip.tsx +++ b/webui/src/components/ui/tooltip.tsx @@ -11,15 +11,17 @@ const TooltipContent = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, sideOffset = 4, ...props }, ref) => ( - + + + )); TooltipContent.displayName = TooltipPrimitive.Content.displayName; diff --git a/webui/src/tests/message-bubble.test.tsx b/webui/src/tests/message-bubble.test.tsx index 410fbaba..baae344d 100644 --- a/webui/src/tests/message-bubble.test.tsx +++ b/webui/src/tests/message-bubble.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import { MessageBubble } from "@/components/MessageBubble"; @@ -179,6 +179,47 @@ describe("MessageBubble", () => { expect(screen.getByText("Body line.")).toBeInTheDocument(); }); + it("renders inline file paths as compact file references", async () => { + await import("@/components/MarkdownTextRenderer"); + const message: UIMessage = { + id: "a-file-path", + role: "assistant", + content: + "改动在 `webui/src/components/MarkdownTextRenderer.tsx` 和 `/Users/renxubin/.nanobot/workspace/minecraft-fps/index.html`。", + createdAt: Date.now(), + }; + + try { + render(); + + const references = await screen.findAllByTestId("inline-file-path"); + expect(references).toHaveLength(2); + expect(references[0].parentElement).not.toHaveClass("translate-y-[0.08em]"); + expect(references[0].parentElement).toHaveClass("align-[0.14em]"); + expect(references[0]).toHaveTextContent("MarkdownTextRenderer.tsx"); + expect(references[0]).not.toHaveTextContent("webui/src/components"); + expect(screen.getByText("index.html")).toBeInTheDocument(); + expect(references[1]).not.toHaveTextContent("/Users/renxubin"); + expect(references[1]).not.toHaveAttribute("title"); + expect(references[1]).toHaveAttribute( + "aria-label", + "/Users/renxubin/.nanobot/workspace/minecraft-fps/index.html", + ); + + vi.useFakeTimers(); + fireEvent.pointerMove(references[1].parentElement!); + await act(async () => { + vi.advanceTimersByTime(500); + }); + const tooltip = screen.getByRole("tooltip"); + expect(tooltip).toHaveTextContent( + "/Users/renxubin/.nanobot/workspace/minecraft-fps/index.html", + ); + } finally { + vi.useRealTimers(); + } + }); + it("renders assistant image media as a larger generated result", () => { const message: UIMessage = { id: "a-image", From 2f323e24c14ec4f35e135c28acc52ad6698f64a6 Mon Sep 17 00:00:00 2001 From: Xubin Ren <52506698+Re-bin@users.noreply.github.com> Date: Sun, 17 May 2026 23:52:50 +0800 Subject: [PATCH 07/12] fix(webui): polish session titles and status --- webui/src/App.tsx | 4 +- webui/src/components/ChatList.tsx | 10 ++-- webui/src/components/ConnectionBadge.tsx | 12 +++-- webui/src/components/Sidebar.tsx | 4 +- webui/src/components/thread/ThreadShell.tsx | 3 +- webui/src/lib/format.ts | 25 ++++++++++ webui/src/lib/nanobot-client.ts | 9 ++-- webui/src/tests/nanobot-client.test.ts | 8 ++- webui/src/tests/thread-shell.test.tsx | 54 +++++++++++++++++++-- webui/src/tests/useSessions.test.tsx | 32 ++++++++++-- 10 files changed, 134 insertions(+), 27 deletions(-) diff --git a/webui/src/App.tsx b/webui/src/App.tsx index fabcff18..591cf4a9 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -17,6 +17,7 @@ import { loadSavedSecret, saveSecret, } from "@/lib/bootstrap"; +import { deriveTitle } from "@/lib/format"; import { NanobotClient } from "@/lib/nanobot-client"; import { ClientProvider, useClient } from "@/providers/ClientProvider"; import type { ChatSummary } from "@/lib/types"; @@ -391,8 +392,7 @@ function Shell({ const headerTitle = activeSession ? activeSession.title || - activeSession.preview || - t("chat.fallbackTitle", { id: activeSession.chatId.slice(0, 6) }) + deriveTitle(activeSession.preview, t("chat.newChat")) : t("app.brand"); useEffect(() => { diff --git a/webui/src/components/ChatList.tsx b/webui/src/components/ChatList.tsx index fc667883..a5107651 100644 --- a/webui/src/components/ChatList.tsx +++ b/webui/src/components/ChatList.tsx @@ -7,6 +7,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { deriveTitle } from "@/lib/format"; import { cn } from "@/lib/utils"; import type { ChatSummary } from "@/lib/types"; @@ -64,8 +65,11 @@ export function ChatList({ const fallbackTitle = t("chat.fallbackTitle", { id: s.chatId.slice(0, 6), }); - const rawLabel = (s.title || s.preview)?.trim(); - const title = rawLabel || fallbackTitle; + const generatedTitle = s.title?.trim() || ""; + const title = + generatedTitle || deriveTitle(s.preview, t("chat.newChat")); + const tooltipTitle = + generatedTitle || deriveTitle(s.preview, fallbackTitle); return (
  • onSelect(s.key)} - title={rawLabel || fallbackTitle} + title={tooltipTitle} className="min-w-0 flex-1 overflow-hidden py-1.5 text-left" > {title} diff --git a/webui/src/components/ConnectionBadge.tsx b/webui/src/components/ConnectionBadge.tsx index 7616ddbe..a09aadd2 100644 --- a/webui/src/components/ConnectionBadge.tsx +++ b/webui/src/components/ConnectionBadge.tsx @@ -36,21 +36,25 @@ export function ConnectionBadge() { status === "connecting" || status === "reconnecting" || status === "error"; + const label = t(`connection.${status}`); return ( - + {pulsing && ( )} - + - {t(`connection.${status}`)} + {label} ); } diff --git a/webui/src/components/Sidebar.tsx b/webui/src/components/Sidebar.tsx index cf21c886..cd55475f 100644 --- a/webui/src/components/Sidebar.tsx +++ b/webui/src/components/Sidebar.tsx @@ -117,12 +117,12 @@ export function Sidebar(props: SidebarProps) { />
    -
    +