feat(webui): polish desktop chat experience

This commit is contained in:
Xubin Ren
2026-06-06 00:19:31 +08:00
parent 8e7af82338
commit a71222607e
43 changed files with 3021 additions and 434 deletions
+150 -39
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Menu, Moon, Sun } from "lucide-react"; import { Moon, PanelLeft, Sun } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { DeleteConfirm } from "@/components/DeleteConfirm"; import { DeleteConfirm } from "@/components/DeleteConfirm";
import { RenameChatDialog } from "@/components/RenameChatDialog"; import { RenameChatDialog } from "@/components/RenameChatDialog";
@@ -264,11 +264,17 @@ function normalizeWorkspaceScope(scope: WorkspaceScopePayload): WorkspaceScopePa
function HostChrome({ function HostChrome({
onToggleSidebar, onToggleSidebar,
onSidebarPreviewEnter,
onSidebarPreviewLeave,
sidebarOpen = true,
theme, theme,
onToggleTheme, onToggleTheme,
showThemeButton = true, showThemeButton = true,
}: { }: {
onToggleSidebar?: () => void; onToggleSidebar?: () => void;
onSidebarPreviewEnter?: () => void;
onSidebarPreviewLeave?: () => void;
sidebarOpen?: boolean;
theme: "light" | "dark"; theme: "light" | "dark";
onToggleTheme: () => void; onToggleTheme: () => void;
showThemeButton?: boolean; showThemeButton?: boolean;
@@ -276,21 +282,24 @@ function HostChrome({
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<header className="host-drag-region pointer-events-none absolute inset-x-0 top-0 z-40 flex h-11 items-start justify-between bg-transparent px-3 pt-2 text-foreground/90"> <header className="host-drag-region pointer-events-none absolute inset-x-0 top-0 z-40 h-11 bg-transparent text-foreground/90">
<div className="flex min-w-[8rem] items-center"> {onToggleSidebar ? (
{onToggleSidebar ? ( <Button
<Button type="button"
type="button" variant="ghost"
variant="ghost" size="icon"
size="icon" aria-label={t("thread.header.toggleSidebar")}
aria-label={t("thread.header.toggleSidebar")} data-testid="host-sidebar-toggle"
onClick={onToggleSidebar} onClick={onToggleSidebar}
className="host-no-drag pointer-events-auto ml-[88px] h-8 w-8 rounded-xl text-muted-foreground/85 hover:bg-accent/40 hover:text-foreground" onFocus={!sidebarOpen ? onSidebarPreviewEnter : undefined}
> onBlur={!sidebarOpen ? onSidebarPreviewLeave : undefined}
<Menu className="h-4 w-4" /> onMouseEnter={!sidebarOpen ? onSidebarPreviewEnter : undefined}
</Button> onMouseLeave={!sidebarOpen ? onSidebarPreviewLeave : undefined}
) : null} className="host-no-drag pointer-events-auto absolute left-[88px] top-[8px] h-7 w-7 rounded-lg bg-transparent text-muted-foreground/85 shadow-none hover:bg-transparent hover:text-foreground"
</div> >
<PanelLeft className="h-[15px] w-[15px]" strokeWidth={1.75} />
</Button>
) : null}
{showThemeButton ? ( {showThemeButton ? (
<Button <Button
type="button" type="button"
@@ -298,7 +307,7 @@ function HostChrome({
size="icon" size="icon"
aria-label={t("thread.header.toggleTheme")} aria-label={t("thread.header.toggleTheme")}
onClick={onToggleTheme} onClick={onToggleTheme}
className="host-no-drag pointer-events-auto h-8 w-8 rounded-full text-muted-foreground/85 hover:bg-accent/40 hover:text-foreground" className="host-no-drag pointer-events-auto absolute right-3 top-2 h-8 w-8 rounded-full text-muted-foreground/85 hover:bg-accent/40 hover:text-foreground"
> >
{theme === "dark" ? ( {theme === "dark" ? (
<Sun className="h-4 w-4" /> <Sun className="h-4 w-4" />
@@ -307,7 +316,7 @@ function HostChrome({
)} )}
</Button> </Button>
) : ( ) : (
<div aria-hidden className="host-no-drag pointer-events-none h-8 w-8" /> null
)} )}
</header> </header>
); );
@@ -532,6 +541,7 @@ function Shell({
useState<SettingsSectionKey>(initialRouteRef.current.settingsSection); useState<SettingsSectionKey>(initialRouteRef.current.settingsSection);
const [hostSidebarOpen, setHostSidebarOpen] = const [hostSidebarOpen, setHostSidebarOpen] =
useState<boolean>(readSidebarOpen); useState<boolean>(readSidebarOpen);
const [hostSidebarPreviewOpen, setHostSidebarPreviewOpen] = useState(false);
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
const [sessionSearchOpen, setSessionSearchOpen] = useState(false); const [sessionSearchOpen, setSessionSearchOpen] = useState(false);
const [pendingDelete, setPendingDelete] = useState<{ const [pendingDelete, setPendingDelete] = useState<{
@@ -560,6 +570,11 @@ function Shell({
useState<Record<string, WorkspaceScopePayload>>({}); useState<Record<string, WorkspaceScopePayload>>({});
const runningChatIdsRef = useRef<Set<string>>(new Set()); const runningChatIdsRef = useRef<Set<string>>(new Set());
const activeChatIdRef = useRef<string | null>(null); const activeChatIdRef = useRef<string | null>(null);
const hostSidebarPreviewCloseTimerRef = useRef<number | null>(null);
const effectiveRuntimeSurface =
settingsSnapshot?.surface ?? settingsSnapshot?.runtime_surface ?? runtimeSurface;
const showHostChrome = effectiveRuntimeSurface === "native";
const showMainSidebar = view !== "settings";
const navigate = useCallback( const navigate = useCallback(
(route: ShellRoute, options?: { replace?: boolean }) => { (route: ShellRoute, options?: { replace?: boolean }) => {
@@ -745,13 +760,74 @@ function Shell({
}); });
}, [client, loading, sessions]); }, [client, loading, sessions]);
const closeHostSidebar = useCallback(() => { const clearHostSidebarPreviewCloseTimer = useCallback(() => {
setHostSidebarOpen(false); if (hostSidebarPreviewCloseTimerRef.current === null) return;
window.clearTimeout(hostSidebarPreviewCloseTimerRef.current);
hostSidebarPreviewCloseTimerRef.current = null;
}, []); }, []);
const closeHostSidebarPreview = useCallback(() => {
clearHostSidebarPreviewCloseTimer();
setHostSidebarPreviewOpen(false);
}, [clearHostSidebarPreviewCloseTimer]);
const openHostSidebarPreview = useCallback(() => {
if (!showHostChrome || !showMainSidebar || hostSidebarOpen) return;
clearHostSidebarPreviewCloseTimer();
setHostSidebarPreviewOpen(true);
}, [
clearHostSidebarPreviewCloseTimer,
hostSidebarOpen,
showHostChrome,
showMainSidebar,
]);
const scheduleHostSidebarPreviewClose = useCallback(() => {
clearHostSidebarPreviewCloseTimer();
if (!showHostChrome || !showMainSidebar || hostSidebarOpen) {
setHostSidebarPreviewOpen(false);
return;
}
hostSidebarPreviewCloseTimerRef.current = window.setTimeout(() => {
setHostSidebarPreviewOpen(false);
hostSidebarPreviewCloseTimerRef.current = null;
}, 160);
}, [
clearHostSidebarPreviewCloseTimer,
hostSidebarOpen,
showHostChrome,
showMainSidebar,
]);
useEffect(() => {
return () => clearHostSidebarPreviewCloseTimer();
}, [clearHostSidebarPreviewCloseTimer]);
useEffect(() => {
if (!showHostChrome || !showMainSidebar || hostSidebarOpen) {
closeHostSidebarPreview();
}
}, [
closeHostSidebarPreview,
hostSidebarOpen,
showHostChrome,
showMainSidebar,
]);
const closeHostSidebar = useCallback(() => {
closeHostSidebarPreview();
setHostSidebarOpen(false);
}, [closeHostSidebarPreview]);
const openHostSidebar = useCallback(() => { const openHostSidebar = useCallback(() => {
closeHostSidebarPreview();
setHostSidebarOpen(true); setHostSidebarOpen(true);
}, []); }, [closeHostSidebarPreview]);
const toggleHostSidebar = useCallback(() => {
closeHostSidebarPreview();
setHostSidebarOpen((v) => !v);
}, [closeHostSidebarPreview]);
const closeMobileSidebar = useCallback(() => { const closeMobileSidebar = useCallback(() => {
setMobileSidebarOpen(false); setMobileSidebarOpen(false);
@@ -762,11 +838,12 @@ function Shell({
typeof window !== "undefined" && typeof window !== "undefined" &&
window.matchMedia("(min-width: 1024px)").matches; window.matchMedia("(min-width: 1024px)").matches;
if (isNativeHost) { if (isNativeHost) {
closeHostSidebarPreview();
setHostSidebarOpen((v) => !v); setHostSidebarOpen((v) => !v);
} else { } else {
setMobileSidebarOpen((v) => !v); setMobileSidebarOpen((v) => !v);
} }
}, []); }, [closeHostSidebarPreview]);
const applyWorkspaceScope = useCallback( const applyWorkspaceScope = useCallback(
(scope: WorkspaceScopePayload) => { (scope: WorkspaceScopePayload) => {
@@ -1041,6 +1118,10 @@ function Shell({
setMobileSidebarOpen(false); setMobileSidebarOpen(false);
}, [activeKey, navigate]); }, [activeKey, navigate]);
const onOpenModelSettings = useCallback(() => {
onOpenSettings("models");
}, [onOpenSettings]);
const onOpenApps = useCallback(() => { const onOpenApps = useCallback(() => {
setSessionSearchOpen(false); setSessionSearchOpen(false);
navigate({ view: "apps", activeKey, settingsSection: "apps" }); navigate({ view: "apps", activeKey, settingsSection: "apps" });
@@ -1238,11 +1319,13 @@ function Shell({
archivedCount: sidebarState.archived_keys.length, archivedCount: sidebarState.archived_keys.length,
defaultWorkspacePath: workspaces?.default_scope.project_path ?? null, defaultWorkspacePath: workspaces?.default_scope.project_path ?? null,
}; };
const effectiveRuntimeSurface = const hostSidebarCollapsed = showHostChrome && !hostSidebarOpen;
settingsSnapshot?.surface ?? settingsSnapshot?.runtime_surface ?? runtimeSurface; const showHostSidebarPreview =
const isNativeHostSetupSurface = effectiveRuntimeSurface === "native"; showMainSidebar && hostSidebarCollapsed && hostSidebarPreviewOpen;
const showHostChrome = isNativeHostSetupSurface; const hostSidebarFlowWidth = showHostChrome
const showMainSidebar = view !== "settings"; ? (hostSidebarOpen ? SIDEBAR_WIDTH : 0)
: (hostSidebarOpen ? SIDEBAR_WIDTH : SIDEBAR_RAIL_WIDTH);
const renderHostSidebarFlowContent = !showHostChrome || hostSidebarOpen;
useEffect(() => { useEffect(() => {
document.documentElement.classList.toggle("native-host", showHostChrome); document.documentElement.classList.toggle("native-host", showHostChrome);
@@ -1261,7 +1344,10 @@ function Shell({
> >
{showHostChrome ? ( {showHostChrome ? (
<HostChrome <HostChrome
onToggleSidebar={showMainSidebar ? toggleSidebar : undefined} onToggleSidebar={showMainSidebar ? toggleHostSidebar : undefined}
onSidebarPreviewEnter={openHostSidebarPreview}
onSidebarPreviewLeave={scheduleHostSidebarPreviewClose}
sidebarOpen={hostSidebarOpen}
theme={theme} theme={theme}
onToggleTheme={toggle} onToggleTheme={toggle}
/> />
@@ -1274,25 +1360,47 @@ function Shell({
{/* Host sidebar: in normal flow, so the thread area width stays honest. */} {/* Host sidebar: in normal flow, so the thread area width stays honest. */}
{showMainSidebar ? ( {showMainSidebar ? (
<aside <aside
data-testid="host-sidebar-flow"
className={cn( className={cn(
"relative z-20 hidden shrink-0 overflow-hidden lg:block", "relative z-20 hidden shrink-0 overflow-hidden lg:block",
"transition-[width] duration-300 ease-out", "transition-[width] duration-300 ease-out",
)} )}
style={{ style={{
width: hostSidebarOpen ? SIDEBAR_WIDTH : SIDEBAR_RAIL_WIDTH, width: hostSidebarFlowWidth,
}} }}
> >
<div {renderHostSidebarFlowContent ? (
className={cn( <div
"absolute inset-y-0 left-0 h-full w-full overflow-hidden", className={cn(
showHostChrome "absolute inset-y-0 left-0 h-full w-full overflow-hidden",
? "host-sidebar-glass" showHostChrome
: "bg-sidebar shadow-inner-right", ? "host-sidebar-glass"
)} : "bg-sidebar shadow-inner-right",
> )}
>
<Sidebar
{...sidebarProps}
collapsed={!showHostChrome && !hostSidebarOpen}
hostChromeInset={showHostChrome}
onCollapse={closeHostSidebar}
onExpand={openHostSidebar}
/>
</div>
) : null}
</aside>
) : null}
{showHostSidebarPreview ? (
<aside
data-testid="host-sidebar-preview"
className="absolute inset-y-0 left-0 z-30 hidden overflow-hidden lg:block animate-in fade-in-0 slide-in-from-left-2 duration-150"
style={{ width: SIDEBAR_WIDTH }}
onMouseEnter={openHostSidebarPreview}
onMouseLeave={scheduleHostSidebarPreviewClose}
>
<div className="h-full w-full overflow-hidden host-sidebar-glass shadow-2xl">
<Sidebar <Sidebar
{...sidebarProps} {...sidebarProps}
collapsed={!hostSidebarOpen}
hostChromeInset={showHostChrome} hostChromeInset={showHostChrome}
onCollapse={closeHostSidebar} onCollapse={closeHostSidebar}
onExpand={openHostSidebar} onExpand={openHostSidebar}
@@ -1335,7 +1443,7 @@ function Shell({
<main <main
className={cn( className={cn(
"relative flex h-full min-w-0 flex-1 flex-col overflow-hidden bg-background", "relative flex h-full min-w-0 flex-1 flex-col overflow-hidden bg-background",
showHostChrome && "border-l border-border/55", showHostChrome && hostSidebarOpen && "border-l border-border/55",
)} )}
> >
<div <div
@@ -1354,6 +1462,7 @@ function Shell({
theme={theme} theme={theme}
onToggleTheme={toggle} onToggleTheme={toggle}
hideSidebarToggleForHostChrome hideSidebarToggleForHostChrome
hostChromeTitleInset={hostSidebarCollapsed}
hideThemeButton={showHostChrome} hideThemeButton={showHostChrome}
hideHeader={false} hideHeader={false}
workspaceScope={activeWorkspaceScope} workspaceScope={activeWorkspaceScope}
@@ -1363,6 +1472,7 @@ function Shell({
workspaceError={workspaceError} workspaceError={workspaceError}
onWorkspaceScopeChange={applyWorkspaceScope} onWorkspaceScopeChange={applyWorkspaceScope}
settingsSnapshot={settingsSnapshot} settingsSnapshot={settingsSnapshot}
onOpenModelSettings={onOpenModelSettings}
/> />
</div> </div>
{view !== "chat" && ( {view !== "chat" && (
@@ -1370,6 +1480,7 @@ function Shell({
<SettingsView <SettingsView
theme={theme} theme={theme}
initialSection={settingsInitialSection} initialSection={settingsInitialSection}
initialSettings={settingsSnapshot}
showSidebar={view === "settings"} showSidebar={view === "settings"}
onToggleTheme={toggle} onToggleTheme={toggle}
onBackToChat={onBackToChat} onBackToChat={onBackToChat}
+147 -40
View File
@@ -9,15 +9,33 @@ interface CodeBlockProps {
language?: string; language?: string;
code: string; code: string;
className?: string; className?: string;
chrome?: "default" | "none";
highlight?: boolean; highlight?: boolean;
showLineNumbers?: boolean;
wrapLongLines?: boolean;
} }
interface HighlightedCodeProps { interface HighlightedCodeProps {
language?: string; language?: string;
code: string; code: string;
isDark: boolean; isDark: boolean;
chrome: "default" | "none";
showLineNumbers: boolean;
wrapLongLines: boolean;
} }
const CODE_FONT_STACK = [
'"JetBrains Mono"',
'"SFMono-Regular"',
'"SF Mono"',
'"Fira Code"',
'"Cascadia Code"',
'"Source Code Pro"',
"Menlo",
"Consolas",
"monospace",
].join(", ");
const LazyHighlightedCode = lazy(async () => { const LazyHighlightedCode = lazy(async () => {
const [ const [
{ default: SyntaxHighlighter }, { default: SyntaxHighlighter },
@@ -30,19 +48,56 @@ const LazyHighlightedCode = lazy(async () => {
]); ]);
return { return {
default({ language, code, isDark }: HighlightedCodeProps) { default({
language,
code,
isDark,
chrome,
showLineNumbers,
wrapLongLines,
}: HighlightedCodeProps) {
const theme = isDark ? oneDark : oneLight;
const transparentTheme = chrome === "none" ? {
...theme,
'pre[class*="language-"]': {
...theme['pre[class*="language-"]'],
background: "transparent",
},
'code[class*="language-"]': {
...theme['code[class*="language-"]'],
background: "transparent",
},
} : theme;
return ( return (
<SyntaxHighlighter <SyntaxHighlighter
language={language || "text"} language={language || "text"}
style={isDark ? oneDark : oneLight} style={transparentTheme}
customStyle={{ customStyle={{
background: chrome === "none" ? "transparent" : undefined,
margin: 0, margin: 0,
padding: "1rem", padding: chrome === "none" ? "0.75rem 1rem" : "1rem",
fontSize: "0.875rem", fontFamily: CODE_FONT_STACK,
lineHeight: 1.6, fontSize: chrome === "none" ? "13px" : "0.875rem",
lineHeight: chrome === "none" ? 1.55 : 1.6,
tabSize: 2,
}}
codeTagProps={{
style: chrome === "none" ? {
background: "transparent",
fontFamily: CODE_FONT_STACK,
} : undefined,
}}
lineNumberStyle={{
minWidth: "2.6em",
paddingRight: "1.15rem",
color: isDark ? "rgba(212, 212, 216, 0.45)" : "rgba(63, 63, 70, 0.68)",
fontFamily: CODE_FONT_STACK,
userSelect: "none",
}} }}
PreTag="pre" PreTag="pre"
wrapLongLines showLineNumbers={showLineNumbers}
wrapLongLines={wrapLongLines}
> >
{code} {code}
</SyntaxHighlighter> </SyntaxHighlighter>
@@ -51,13 +106,39 @@ const LazyHighlightedCode = lazy(async () => {
}; };
}); });
function PlainCodeFallback({ code }: { code: string }) { function PlainCodeFallback({
code,
chrome,
showLineNumbers,
}: {
code: string;
chrome: "default" | "none";
showLineNumbers: boolean;
}) {
const lines = code.split("\n");
return ( return (
<pre <pre
className="m-0 overflow-x-auto whitespace-pre-wrap bg-background p-4 font-mono text-sm leading-[1.6] text-foreground/90" className={cn(
"m-0 overflow-x-auto p-4 font-mono text-sm leading-[1.6] text-foreground/90",
showLineNumbers ? "whitespace-pre" : "whitespace-pre-wrap",
chrome === "default" ? "bg-background" : "bg-transparent",
chrome === "none" && "p-3 text-[13px] leading-[1.55]",
)}
data-testid="plain-code-fallback" data-testid="plain-code-fallback"
> >
<code className="text-inherit">{code}</code> <code className="text-inherit">
{showLineNumbers ? (
lines.map((line, index) => (
<span key={index} className="flex min-w-max">
<span className="w-10 shrink-0 select-none pr-4 text-right text-muted-foreground/60">
{index + 1}
</span>
<span className="whitespace-pre">{line || " "}</span>
{index < lines.length - 1 ? "\n" : null}
</span>
))
) : code}
</code>
</pre> </pre>
); );
} }
@@ -66,11 +147,15 @@ export function CodeBlock({
language, language,
code, code,
className, className,
chrome = "default",
highlight = true, highlight = true,
showLineNumbers = false,
wrapLongLines = true,
}: CodeBlockProps) { }: CodeBlockProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const isDark = useThemeValue() === "dark"; const isDark = useThemeValue() === "dark";
const hasChrome = chrome === "default";
const onCopy = useCallback(() => { const onCopy = useCallback(() => {
if (!navigator.clipboard) return; if (!navigator.clipboard) return;
@@ -83,47 +168,69 @@ export function CodeBlock({
return ( return (
<div <div
className={cn( className={cn(
"overflow-hidden rounded-lg border", "overflow-hidden",
isDark ? "border-white/10" : "border-black/10", hasChrome && "rounded-lg border",
hasChrome && (isDark ? "border-white/10" : "border-black/10"),
className, className,
)} )}
> >
<div {hasChrome ? (
className={cn( <div
"flex items-center justify-between px-4 py-1.5 text-xs font-medium",
isDark
? "bg-zinc-800 text-zinc-300"
: "bg-zinc-100 text-zinc-600",
)}
>
<span className="lowercase font-mono">
{language || t("code.fallbackLanguage")}
</span>
<button
type="button"
onClick={onCopy}
className={cn( className={cn(
"inline-flex items-center gap-1 rounded px-1.5 py-0.5 font-mono transition-colors", "flex items-center justify-between px-4 py-1.5 text-xs font-medium",
isDark isDark
? "text-zinc-400 hover:bg-zinc-700 hover:text-zinc-200" ? "bg-zinc-800 text-zinc-300"
: "text-zinc-500 hover:bg-zinc-200 hover:text-zinc-700", : "bg-zinc-100 text-zinc-600",
)} )}
aria-label={t("code.copyAria")}
> >
{copied ? ( <span className="lowercase font-mono">
<Check className="h-3.5 w-3.5" /> {language || t("code.fallbackLanguage")}
) : ( </span>
<Copy className="h-3.5 w-3.5" /> <button
)} type="button"
<span>{copied ? t("code.copied") : t("code.copy")}</span> onClick={onCopy}
</button> className={cn(
</div> "inline-flex items-center gap-1 rounded px-1.5 py-0.5 font-mono transition-colors",
isDark
? "text-zinc-400 hover:bg-zinc-700 hover:text-zinc-200"
: "text-zinc-500 hover:bg-zinc-200 hover:text-zinc-700",
)}
aria-label={t("code.copyAria")}
>
{copied ? (
<Check className="h-3.5 w-3.5" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
<span>{copied ? t("code.copied") : t("code.copy")}</span>
</button>
</div>
) : null}
{highlight ? ( {highlight ? (
<Suspense fallback={<PlainCodeFallback code={code} />}> <Suspense
<LazyHighlightedCode language={language} code={code} isDark={isDark} /> fallback={
<PlainCodeFallback
code={code}
chrome={chrome}
showLineNumbers={showLineNumbers}
/>
}
>
<LazyHighlightedCode
language={language}
code={code}
isDark={isDark}
chrome={chrome}
showLineNumbers={showLineNumbers}
wrapLongLines={wrapLongLines}
/>
</Suspense> </Suspense>
) : ( ) : (
<PlainCodeFallback code={code} /> <PlainCodeFallback
code={code}
chrome={chrome}
showLineNumbers={showLineNumbers}
/>
)} )}
</div> </div>
); );
+289
View File
@@ -0,0 +1,289 @@
import { useEffect, useMemo, useState } from "react";
import type { CSSProperties, PointerEvent as ReactPointerEvent } from "react";
import { AlertCircle, ChevronRight, FileText, Loader2, X } from "lucide-react";
import { useTranslation } from "react-i18next";
import { CodeBlock } from "@/components/CodeBlock";
import {
splitFilePath,
} from "@/components/FileReferenceChip";
import { ApiError, fetchFilePreview } from "@/lib/api";
import type { FilePreviewPayload } from "@/lib/types";
import { cn } from "@/lib/utils";
interface FilePreviewPanelProps {
sessionKey: string;
path: string;
token: string;
desktopWidth?: number;
isClosing?: boolean;
onResizeStart?: (event: ReactPointerEvent<HTMLButtonElement>) => void;
onClose: () => void;
}
type PreviewState =
| { status: "loading" }
| { status: "error"; message: string }
| { status: "ready"; payload: FilePreviewPayload };
function supportsHoverCloseControl(): boolean {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false;
return window.matchMedia("(hover: hover) and (pointer: fine)").matches;
}
export function FilePreviewPanel({
sessionKey,
path,
token,
desktopWidth = 544,
isClosing = false,
onResizeStart,
onClose,
}: FilePreviewPanelProps) {
const { t } = useTranslation();
const [state, setState] = useState<PreviewState>({ status: "loading" });
const [entered, setEntered] = useState(false);
const [supportsHoverClose, setSupportsHoverClose] = useState(supportsHoverCloseControl);
useEffect(() => {
const frame = window.requestAnimationFrame(() => setEntered(true));
return () => window.cancelAnimationFrame(frame);
}, []);
useEffect(() => {
if (typeof window.matchMedia !== "function") return undefined;
const query = window.matchMedia("(hover: hover) and (pointer: fine)");
const update = () => setSupportsHoverClose(query.matches);
update();
if (typeof query.addEventListener === "function") {
query.addEventListener("change", update);
return () => query.removeEventListener("change", update);
}
query.addListener(update);
return () => query.removeListener(update);
}, []);
useEffect(() => {
let cancelled = false;
setState({ status: "loading" });
fetchFilePreview(token, sessionKey, path)
.then((payload) => {
if (!cancelled) setState({ status: "ready", payload });
})
.catch((error: unknown) => {
if (cancelled) return;
const message = error instanceof ApiError
? (error.status === 404 && /API route not found/i.test(error.message)
? t("filePreview.routeMissing", {
defaultValue: "File preview needs the latest gateway. Restart nanobot gateway and try again.",
})
: error.message)
: t("filePreview.failed", { defaultValue: "Could not preview this file." });
setState({ status: "error", message });
});
return () => {
cancelled = true;
};
}, [path, sessionKey, t, token]);
const displayPath = state.status === "ready" ? state.payload.display_path : path;
const previewPath = state.status === "ready" ? state.payload.path : displayPath;
const normalizedPreviewPath = previewPath.replace(/\\/g, "/");
const hasRootPrefix = normalizedPreviewPath.startsWith("/");
const { name } = splitFilePath(displayPath);
const breadcrumbs = useMemo(
() => normalizedPreviewPath.split("/").filter(Boolean),
[normalizedPreviewPath],
);
const compactBreadcrumbs = useMemo(
() => (breadcrumbs.length > 2 ? breadcrumbs.slice(-2) : breadcrumbs),
[breadcrumbs],
);
const hasCompactPrefix = breadcrumbs.length > compactBreadcrumbs.length;
return (
<aside
aria-label={t("filePreview.aria", { defaultValue: "File preview" })}
style={{
"--file-preview-width": `${desktopWidth}px`,
"--file-preview-slot-width": !entered || isClosing ? "0px" : `${desktopWidth}px`,
} as CSSProperties}
className={cn(
"absolute inset-y-0 right-0 z-30 w-[min(92vw,var(--file-preview-slot-width))] overflow-hidden",
"transition-[width] duration-300 ease-out will-change-[width]",
"md:relative md:z-auto md:w-[var(--file-preview-slot-width)] md:min-w-0 md:shrink-0",
isClosing && "pointer-events-none",
)}
data-testid="file-preview-panel"
data-file-preview-panel
>
<div
className={cn(
"absolute inset-y-0 right-0 flex w-[min(92vw,var(--file-preview-width))] flex-col overflow-hidden md:w-[var(--file-preview-width)]",
"border-l border-border/70 bg-background shadow-2xl md:shadow-none",
"transition-[opacity,transform] duration-300 ease-out will-change-transform",
!entered || isClosing ? "translate-x-full opacity-0" : "translate-x-0 opacity-100",
"motion-reduce:translate-x-0",
)}
>
{onResizeStart ? (
<button
type="button"
aria-label={t("filePreview.resize", { defaultValue: "Resize file preview" })}
className={cn(
"group absolute inset-y-0 left-0 z-20 hidden w-3 -translate-x-1/2 cursor-col-resize touch-none md:flex",
"items-stretch justify-center focus-visible:outline-none",
)}
onPointerDown={onResizeStart}
>
<span
aria-hidden
className={cn(
"h-full w-px bg-foreground/25 opacity-0 transition-opacity",
"group-hover:opacity-100 group-focus-visible:bg-ring group-focus-visible:opacity-100",
)}
/>
</button>
) : null}
<div className="flex min-h-0 flex-1 flex-col">
<div className="flex h-12 shrink-0 items-center gap-2 border-b border-border/60 px-3">
{supportsHoverClose ? (
<div
className={cn(
"group inline-flex max-w-full min-w-0 items-center gap-2 rounded-[12px]",
"bg-muted/70 px-2.5 py-1.5 text-sm font-medium",
)}
title={name || displayPath}
>
<button
type="button"
onClick={onClose}
className={cn(
"relative inline-flex h-5 w-5 shrink-0 items-center justify-center overflow-hidden rounded-full",
"text-muted-foreground/75 transition-[background-color,color,opacity] duration-150 ease-out",
"group-hover:bg-foreground group-hover:text-background group-hover:opacity-100",
"group-focus-within:bg-foreground group-focus-within:text-background",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
)}
aria-label={t("filePreview.close", { defaultValue: "Close file preview" })}
>
<FileText
className={cn(
"absolute h-4 w-4 transition-all duration-150 ease-out",
"opacity-100 group-hover:scale-75 group-hover:opacity-0",
"group-focus-within:scale-75 group-focus-within:opacity-0",
)}
aria-hidden
/>
<X
className={cn(
"absolute h-3.5 w-3.5 scale-75 opacity-0 transition-all duration-150 ease-out",
"group-hover:scale-100 group-hover:opacity-100",
"group-focus-within:scale-100 group-focus-within:opacity-100",
)}
aria-hidden
/>
</button>
<span className="min-w-0 truncate">{name || displayPath}</span>
</div>
) : (
<>
<button
type="button"
onClick={onClose}
className={cn(
"inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-full",
"text-muted-foreground transition-colors hover:bg-muted hover:text-foreground",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
)}
aria-label={t("filePreview.close", { defaultValue: "Close file preview" })}
>
<X className="h-5 w-5" aria-hidden />
</button>
<span className="min-w-0 truncate text-sm font-medium">
{name || displayPath}
</span>
</>
)}
</div>
<div className="flex min-h-0 flex-1 flex-col">
<div
className={cn(
"flex min-h-10 shrink-0 items-center gap-1.5 overflow-hidden",
"border-b border-border/45 px-4 text-[13px] text-muted-foreground",
)}
title={previewPath}
>
<div className="flex min-w-0 items-center gap-1.5">
{hasCompactPrefix ? (
<span className="shrink-0 text-muted-foreground/55">...</span>
) : hasRootPrefix ? (
<span className="shrink-0 text-muted-foreground/55">/</span>
) : null}
{compactBreadcrumbs.length > 0 ? (
compactBreadcrumbs.map((part, index) => (
<span key={`${part}-${index}`} className="flex min-w-0 items-center gap-1.5">
{index > 0 || hasCompactPrefix || hasRootPrefix ? (
<ChevronRight
className="h-3 w-3 shrink-0 text-muted-foreground/40"
aria-hidden
/>
) : null}
<span
className={cn(
"min-w-0 truncate",
index === compactBreadcrumbs.length - 1
? "font-medium text-foreground"
: "max-w-[42vw] shrink text-muted-foreground/76",
)}
>
{part}
</span>
</span>
))
) : (
<span className="truncate">{previewPath}</span>
)}
</div>
</div>
<div className="min-h-0 flex-1 overflow-auto">
{state.status === "loading" ? (
<div className="flex h-full items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
{t("filePreview.loading", { defaultValue: "Loading preview..." })}
</div>
) : state.status === "error" ? (
<div className="flex h-full items-center justify-center px-8 text-center text-sm text-muted-foreground">
<div className="max-w-sm">
<AlertCircle className="mx-auto mb-3 h-5 w-5 text-muted-foreground/70" aria-hidden />
<p>{state.message}</p>
</div>
</div>
) : (
<div className="min-h-full">
{state.payload.truncated ? (
<div className="mx-4 mt-3 rounded-md border border-amber-500/25 bg-amber-500/10 px-3 py-2 text-xs text-amber-700 dark:text-amber-200">
{t("filePreview.truncated", {
defaultValue: "Preview is truncated because this file is large.",
})}
</div>
) : null}
<CodeBlock
language={state.payload.language}
code={state.payload.content}
chrome="none"
showLineNumbers
wrapLongLines={false}
className="min-h-full"
/>
</div>
)}
</div>
</div>
</div>
</div>
</aside>
);
}
+59 -4
View File
@@ -1,3 +1,5 @@
import type { KeyboardEvent, MouseEvent } from "react";
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
@@ -6,10 +8,11 @@ import {
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
type FileReferenceKind = export type FileReferenceKind =
| "default" | "default"
| "css" | "css"
| "html" | "html"
| "javascript"
| "json" | "json"
| "markdown" | "markdown"
| "notebook" | "notebook"
@@ -24,6 +27,8 @@ interface FileReferenceChipProps {
active?: boolean; active?: boolean;
className?: string; className?: string;
textClassName?: string; textClassName?: string;
previewPath?: string;
onOpen?: (path: string) => void;
testId?: string; testId?: string;
} }
@@ -34,12 +39,26 @@ export function FileReferenceChip({
active = false, active = false,
className, className,
textClassName, textClassName,
previewPath,
onOpen,
testId = "inline-file-path", testId = "inline-file-path",
}: FileReferenceChipProps) { }: FileReferenceChipProps) {
const { directory, name } = splitFilePath(path); const { directory, name } = splitFilePath(path);
const kind = fileKindForPath(path); const kind = fileKindForPath(path);
const displayText = display === "path" ? path.replace(/\\/g, "/") : name; const displayText = display === "path" ? path.replace(/\\/g, "/") : name;
const fullPath = tooltipPath || path; const fullPath = tooltipPath || path;
const targetPath = previewPath || tooltipPath || path;
const interactive = Boolean(onOpen);
const openPreview = (event: MouseEvent | KeyboardEvent) => {
if (!onOpen) return;
event.preventDefault();
event.stopPropagation();
onOpen(targetPath);
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Enter" && event.key !== " ") return;
openPreview(event);
};
return ( return (
<TooltipProvider delayDuration={500} skipDelayDuration={100}> <TooltipProvider delayDuration={500} skipDelayDuration={100}>
<Tooltip> <Tooltip>
@@ -50,10 +69,18 @@ export function FileReferenceChip({
<span <span
data-testid={testId} data-testid={testId}
aria-label={fullPath} aria-label={fullPath}
role={interactive ? "button" : undefined}
tabIndex={interactive ? 0 : undefined}
onClick={interactive ? openPreview : undefined}
onKeyDown={interactive ? onKeyDown : undefined}
className={cn( className={cn(
"inline-flex max-w-full items-baseline gap-[0.28em] font-medium leading-[inherit]", "inline-flex max-w-full items-baseline gap-[0.28em] font-medium leading-[inherit]",
"text-sky-600 transition-colors hover:text-sky-700", "text-sky-600 transition-colors hover:text-sky-700",
"dark:text-sky-300 dark:hover:text-sky-200", "dark:text-sky-300 dark:hover:text-sky-200",
interactive && [
"cursor-pointer rounded-[5px]",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-400/45",
],
)} )}
> >
<FileReferenceIcon kind={kind} /> <FileReferenceIcon kind={kind} />
@@ -110,7 +137,7 @@ export function isLikelyFilePath(value: string): boolean {
return /\.[a-z0-9][a-z0-9_-]{0,12}$/i.test(name); return /\.[a-z0-9][a-z0-9_-]{0,12}$/i.test(name);
} }
function splitFilePath(path: string): { directory: string; name: string } { export function splitFilePath(path: string): { directory: string; name: string } {
const normalized = path.replace(/\\/g, "/"); const normalized = path.replace(/\\/g, "/");
const slash = normalized.lastIndexOf("/"); const slash = normalized.lastIndexOf("/");
if (slash < 0) return { directory: "", name: path }; if (slash < 0) return { directory: "", name: path };
@@ -120,7 +147,7 @@ function splitFilePath(path: string): { directory: string; name: string } {
}; };
} }
function fileKindForPath(path: string): FileReferenceKind { export function fileKindForPath(path: string): FileReferenceKind {
const normalized = path.toLowerCase(); const normalized = path.toLowerCase();
const name = normalized.split(/[\\/]/).pop() ?? normalized; const name = normalized.split(/[\\/]/).pop() ?? normalized;
const ext = name.includes(".") ? name.split(".").pop() ?? "" : ""; const ext = name.includes(".") ? name.split(".").pop() ?? "" : "";
@@ -134,7 +161,13 @@ function fileKindForPath(path: string): FileReferenceKind {
case "jsx": case "jsx":
case "tsx": case "tsx":
return "react"; return "react";
case "js":
case "mjs":
case "cjs":
return "javascript";
case "ts": case "ts":
case "mts":
case "cts":
return "typescript"; return "typescript";
case "html": case "html":
case "htm": case "htm":
@@ -156,7 +189,27 @@ function fileKindForPath(path: string): FileReferenceKind {
} }
} }
function FileReferenceIcon({ kind }: { kind: FileReferenceKind }) { export function FileReferenceIcon({ kind }: { kind: FileReferenceKind }) {
if (kind === "python") {
return (
<svg
aria-hidden
className="h-[1em] w-[1em] shrink-0 translate-y-[0.12em]"
viewBox="0 0 24 24"
>
<path
d="M11.9 2.3c-3 0-4.5.8-4.5 2.3v2.1h4.8v.8H5.5C4 7.5 3 8.8 3 10.8v2.1c0 1.8 1.1 3 2.7 3h1.6v-2.3c0-1.7 1.4-3.1 3.1-3.1h4.2c1.3 0 2.3-1 2.3-2.3V4.6c0-1.4-1.5-2.3-4.6-2.3h-.4Z"
fill="#3776AB"
/>
<path
d="M12.1 21.7c3 0 4.5-.8 4.5-2.3v-2.1h-4.8v-.8h6.7c1.5 0 2.5-1.3 2.5-3.3v-2.1c0-1.8-1.1-3-2.7-3h-1.6v2.3c0 1.7-1.4 3.1-3.1 3.1H9.4c-1.3 0-2.3 1-2.3 2.3v3.6c0 1.4 1.5 2.3 4.6 2.3h.4Z"
fill="#FFD43B"
/>
<circle cx="9" cy="5.1" r="0.8" fill="#fff" />
<circle cx="15" cy="18.9" r="0.8" fill="#5C3B00" opacity="0.85" />
</svg>
);
}
if (kind === "react") { if (kind === "react") {
return ( return (
<svg <svg
@@ -234,6 +287,8 @@ function fileKindLabel(kind: FileReferenceKind): string {
return "#"; return "#";
case "html": case "html":
return "H"; return "H";
case "javascript":
return "JS";
case "json": case "json":
return "{}"; return "{}";
case "markdown": case "markdown":
+10 -1
View File
@@ -16,6 +16,7 @@ interface MarkdownTextProps {
children: string; children: string;
className?: string; className?: string;
streaming?: boolean; streaming?: boolean;
onOpenFilePreview?: (path: string) => void;
} }
const loadMarkdownRenderer = () => import("@/components/MarkdownTextRenderer"); const loadMarkdownRenderer = () => import("@/components/MarkdownTextRenderer");
@@ -25,13 +26,19 @@ const MemoizedMarkdownRenderer = memo(function MemoizedMarkdownRenderer({
source, source,
className, className,
highlightCode, highlightCode,
onOpenFilePreview,
}: { }: {
source: string; source: string;
className?: string; className?: string;
highlightCode: boolean; highlightCode: boolean;
onOpenFilePreview?: (path: string) => void;
}) { }) {
return ( return (
<LazyMarkdownRenderer className={className} highlightCode={highlightCode}> <LazyMarkdownRenderer
className={className}
highlightCode={highlightCode}
onOpenFilePreview={onOpenFilePreview}
>
{source} {source}
</LazyMarkdownRenderer> </LazyMarkdownRenderer>
); );
@@ -55,6 +62,7 @@ export function MarkdownText({
children, children,
className, className,
streaming = false, streaming = false,
onOpenFilePreview,
}: MarkdownTextProps) { }: MarkdownTextProps) {
const renderedSource = useStreamingMarkdownSource(children, streaming); const renderedSource = useStreamingMarkdownSource(children, streaming);
const highlightCode = streaming const highlightCode = streaming
@@ -82,6 +90,7 @@ export function MarkdownText({
source={renderedSource} source={renderedSource}
className={className} className={className}
highlightCode={highlightCode} highlightCode={highlightCode}
onOpenFilePreview={onOpenFilePreview}
/> />
</Suspense> </Suspense>
); );
+48 -2
View File
@@ -19,6 +19,7 @@ interface MarkdownTextRendererProps {
children: string; children: string;
className?: string; className?: string;
highlightCode?: boolean; highlightCode?: boolean;
onOpenFilePreview?: (path: string) => void;
} }
type MarkdownAstNode = { type MarkdownAstNode = {
@@ -187,6 +188,38 @@ function nodeText(value: ReactNode): string {
.join(""); .join("");
} }
function cleanFileReferenceTarget(value: string): string {
let target = value.trim();
if (!target) return "";
try {
if (/^file:\/\//i.test(target)) {
target = decodeURIComponent(new URL(target).pathname);
} else {
target = decodeURIComponent(target);
}
} catch {
// Keep the raw value when URL/path decoding is not possible.
}
target = target.split("?", 1)[0]?.split("#", 1)[0]?.trim() ?? "";
if (!/^[A-Za-z]:[\\/]/.test(target)) {
target = target.replace(/:\d+(?::\d+)?$/, "");
}
return target;
}
function isPreviewableFileTarget(value: string): boolean {
if (isLikelyFilePath(value)) return true;
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(value)) return false;
if (/[\\/]/.test(value)) return false;
return /^[^?#]+\.[a-z0-9][a-z0-9_-]{0,12}$/i.test(value);
}
function fileReferenceFromLink(href: string | undefined): string | null {
if (!href || /^https?:\/\//i.test(href) || href.startsWith("#")) return null;
const target = cleanFileReferenceTarget(href);
return isPreviewableFileTarget(target) ? target : null;
}
function linkPreviewParts(value: ReactNode): { text: string; href?: string } { function linkPreviewParts(value: ReactNode): { text: string; href?: string } {
let text = ""; let text = "";
let href: string | undefined; let href: string | undefined;
@@ -326,6 +359,7 @@ export default function MarkdownTextRenderer({
children, children,
className, className,
highlightCode = true, highlightCode = true,
onOpenFilePreview,
}: MarkdownTextRendererProps) { }: MarkdownTextRendererProps) {
const components = useMemo<Components>( const components = useMemo<Components>(
() => ({ () => ({
@@ -344,7 +378,7 @@ export default function MarkdownTextRenderer({
} }
const raw = String(kids).replace(/\n$/, ""); const raw = String(kids).replace(/\n$/, "");
if (isLikelyFilePath(raw)) { if (isLikelyFilePath(raw)) {
return <FileReferenceChip path={raw} />; return <FileReferenceChip path={raw} onOpen={onOpenFilePreview} />;
} }
/** Plain fenced ``` blocks (no language) & wide one-liners: block monospace, not inline pill. */ /** Plain fenced ``` blocks (no language) & wide one-liners: block monospace, not inline pill. */
const widePlainBlock = raw.includes("\n") || raw.length > 120; const widePlainBlock = raw.includes("\n") || raw.length > 120;
@@ -405,6 +439,18 @@ export default function MarkdownTextRenderer({
); );
}, },
a({ href, children: markdownChildren, ...props }) { a({ href, children: markdownChildren, ...props }) {
const filePath = fileReferenceFromLink(href);
if (filePath) {
const label = nodeText(markdownChildren).trim();
return (
<FileReferenceChip
path={label || filePath}
tooltipPath={filePath}
previewPath={filePath}
onOpen={onOpenFilePreview}
/>
);
}
return ( return (
<a <a
href={href} href={href}
@@ -495,7 +541,7 @@ export default function MarkdownTextRenderer({
); );
}, },
}), }),
[highlightCode], [highlightCode, onOpenFilePreview],
); );
return ( return (
+17 -2
View File
@@ -33,6 +33,7 @@ interface MessageBubbleProps {
showAssistantCopyAction?: boolean; showAssistantCopyAction?: boolean;
cliApps?: CliAppInfo[]; cliApps?: CliAppInfo[];
mcpPresets?: McpPresetInfo[]; mcpPresets?: McpPresetInfo[];
onOpenFilePreview?: (path: string) => void;
} }
/** /**
@@ -49,6 +50,7 @@ export function MessageBubble({
showAssistantCopyAction = true, showAssistantCopyAction = true,
cliApps = [], cliApps = [],
mcpPresets = [], mcpPresets = [],
onOpenFilePreview,
}: MessageBubbleProps) { }: MessageBubbleProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
@@ -142,13 +144,23 @@ export function MessageBubble({
return ( return (
<div className={cn("w-full text-[15px]", baseAnim)} style={{ lineHeight: "var(--cjk-line-height)" }}> <div className={cn("w-full text-[15px]", baseAnim)} style={{ lineHeight: "var(--cjk-line-height)" }}>
{hasReasoning ? ( {hasReasoning ? (
<ReasoningBubble text={reasoning} streaming={reasoningStreaming} hasBodyBelow={!empty} /> <ReasoningBubble
text={reasoning}
streaming={reasoningStreaming}
hasBodyBelow={!empty}
onOpenFilePreview={onOpenFilePreview}
/>
) : null} ) : null}
{empty && message.isStreaming && !hasReasoning ? ( {empty && message.isStreaming && !hasReasoning ? (
<TypingDots /> <TypingDots />
) : empty && message.isStreaming ? null : ( ) : empty && message.isStreaming ? null : (
<> <>
<MarkdownText streaming={!!message.isStreaming}>{message.content}</MarkdownText> <MarkdownText
streaming={!!message.isStreaming}
onOpenFilePreview={onOpenFilePreview}
>
{message.content}
</MarkdownText>
{media.length > 0 ? <MessageMedia media={media} align="left" /> : null} {media.length > 0 ? <MessageMedia media={media} align="left" /> : null}
{showAssistantFooterRow ? ( {showAssistantFooterRow ? (
<div className="mt-2 flex min-h-8 flex-wrap items-center gap-x-2 gap-y-1 text-muted-foreground"> <div className="mt-2 flex min-h-8 flex-wrap items-center gap-x-2 gap-y-1 text-muted-foreground">
@@ -488,6 +500,7 @@ interface ReasoningBubbleProps {
hasBodyBelow: boolean; hasBodyBelow: boolean;
/** When true, skip the slide-in wrapper (used inside ``AgentActivityCluster``). */ /** When true, skip the slide-in wrapper (used inside ``AgentActivityCluster``). */
embeddedInCluster?: boolean; embeddedInCluster?: boolean;
onOpenFilePreview?: (path: string) => void;
} }
/** /**
@@ -509,6 +522,7 @@ export function ReasoningBubble({
streaming, streaming,
hasBodyBelow, hasBodyBelow,
embeddedInCluster = false, embeddedInCluster = false,
onOpenFilePreview,
}: ReasoningBubbleProps) { }: ReasoningBubbleProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [userToggled, setUserToggled] = useState(false); const [userToggled, setUserToggled] = useState(false);
@@ -567,6 +581,7 @@ export function ReasoningBubble({
> >
<MarkdownText <MarkdownText
streaming={streaming} streaming={streaming}
onOpenFilePreview={onOpenFilePreview}
className={cn( className={cn(
"text-[12.5px] italic text-muted-foreground/88", "text-[12.5px] italic text-muted-foreground/88",
"prose-p:my-1.5 prose-li:my-0.5", "prose-p:my-1.5 prose-li:my-0.5",
+524 -183
View File
@@ -13,6 +13,7 @@ import {
Bot, Bot,
Brain, Brain,
Check, Check,
CircleAlert,
ChevronDown, ChevronDown,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
@@ -70,9 +71,16 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { import {
createModelConfiguration, createModelConfiguration,
fetchSettings, fetchSettings,
fetchSettingsUsage,
fetchCliApps, fetchCliApps,
fetchMcpPresets, fetchMcpPresets,
fetchProviderModels, fetchProviderModels,
@@ -99,6 +107,7 @@ import {
providerDisplayLabel, providerDisplayLabel,
} from "@/lib/provider-brand"; } from "@/lib/provider-brand";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { shortWorkspacePath } from "@/lib/workspace";
import { useClient } from "@/providers/ClientProvider"; import { useClient } from "@/providers/ClientProvider";
import type { import type {
CliAppInfo, CliAppInfo,
@@ -167,7 +176,6 @@ type ProviderApiType = "auto" | "chat_completions" | "responses";
type ProviderForm = { apiKey: string; apiBase: string; apiType: ProviderApiType }; type ProviderForm = { apiKey: string; apiBase: string; apiType: ProviderApiType };
type CustomMcpTransport = "stdio" | "streamableHttp" | "sse"; type CustomMcpTransport = "stdio" | "streamableHttp" | "sse";
const NANOBOT_ICON_SRC = "/brand/nanobot_icon.png";
const CONTEXT_WINDOW_TOKEN_OPTIONS = [65_536, 262_144] as const; const CONTEXT_WINDOW_TOKEN_OPTIONS = [65_536, 262_144] as const;
const DEFERRED_MODEL_LIST_PROVIDERS = new Set([ const DEFERRED_MODEL_LIST_PROVIDERS = new Set([
"aihubmix", "aihubmix",
@@ -265,6 +273,7 @@ const DEFAULT_CUSTOM_MCP_FORM: CustomMcpForm = {
interface SettingsViewProps { interface SettingsViewProps {
theme: "light" | "dark"; theme: "light" | "dark";
initialSection?: SettingsSectionKey; initialSection?: SettingsSectionKey;
initialSettings?: SettingsPayload | null;
showSidebar?: boolean; showSidebar?: boolean;
onToggleTheme: () => void; onToggleTheme: () => void;
onBackToChat: () => void; onBackToChat: () => void;
@@ -311,9 +320,130 @@ function editableDefaultProvider(payload: SettingsPayload): string {
return base?.provider ?? payload.agent.provider ?? payload.agent.resolved_provider ?? ""; return base?.provider ?? payload.agent.provider ?? payload.agent.resolved_provider ?? "";
} }
function settingsProviderRow(
payload: SettingsPayload,
provider: string | null | undefined,
): SettingsPayload["providers"][number] | null {
if (!provider) return null;
return payload.providers.find((row) => row.name === provider) ?? null;
}
function settingsProviderConfigured(
payload: SettingsPayload,
provider: string | null | undefined,
): boolean {
const row = settingsProviderRow(payload, provider);
if (row) return row.configured;
return payload.agent.has_api_key;
}
const DEFAULT_AGENT_SETTINGS_DRAFT: AgentSettingsDraft = {
model: "",
provider: "",
modelPreset: "default",
presetLabel: "Default",
contextWindowTokens: 65_536,
timezone: "UTC",
botName: "nanobot",
botIcon: "",
toolHintMaxLength: 40,
};
const DEFAULT_WEB_SEARCH_FORM: WebSearchSettingsUpdate = {
provider: "duckduckgo",
apiKey: "",
baseUrl: "",
maxResults: 5,
timeout: 30,
useJinaReader: true,
};
const DEFAULT_IMAGE_GENERATION_FORM: ImageGenerationSettingsUpdate = {
enabled: false,
provider: "openrouter",
model: "openai/gpt-5.4-image-2",
defaultAspectRatio: "1:1",
defaultImageSize: "1K",
maxImagesPerTurn: 4,
};
const DEFAULT_NETWORK_SAFETY_FORM: NetworkSafetySettingsUpdate = {
webuiAllowLocalServiceAccess: true,
webuiDefaultAccessMode: "default",
};
function agentDraftFromPayload(payload: SettingsPayload): AgentSettingsDraft {
const fallbackDefault = defaultPreset(payload);
const activePresetName = modelPresetValue(payload);
const activePreset =
payload.model_presets.find((preset) => preset.name === activePresetName) ?? fallbackDefault;
return {
model: activePreset?.model ?? payload.agent.model,
provider: activePreset?.is_default
? editableDefaultProvider(payload)
: activePreset?.provider ?? editableDefaultProvider(payload),
modelPreset: activePresetName,
presetLabel: activePreset?.label ?? activePresetName,
contextWindowTokens: normalizeContextWindowTokens(
activePreset?.context_window_tokens ?? payload.agent.context_window_tokens,
),
timezone: payload.agent.timezone,
botName: payload.agent.bot_name,
botIcon: payload.agent.bot_icon,
toolHintMaxLength: payload.agent.tool_hint_max_length,
};
}
function webSearchFormFromPayload(
payload: SettingsPayload,
previous?: WebSearchSettingsUpdate,
): WebSearchSettingsUpdate {
return {
provider: payload.web_search.provider,
apiKey: previous?.provider === payload.web_search.provider ? previous.apiKey ?? "" : "",
baseUrl: payload.web_search.base_url ?? "",
maxResults: payload.web_search.max_results,
timeout: payload.web_search.timeout,
useJinaReader: payload.web.fetch.use_jina_reader,
};
}
function imageGenerationFormFromPayload(payload: SettingsPayload): ImageGenerationSettingsUpdate {
return {
enabled: payload.image_generation.enabled,
provider: payload.image_generation.provider,
model: payload.image_generation.model,
defaultAspectRatio: payload.image_generation.default_aspect_ratio,
defaultImageSize: payload.image_generation.default_image_size,
maxImagesPerTurn: payload.image_generation.max_images_per_turn,
};
}
function networkSafetyFormFromPayload(payload: SettingsPayload): NetworkSafetySettingsUpdate {
return {
webuiAllowLocalServiceAccess:
payload.advanced.webui_allow_local_service_access ??
payload.advanced.allow_local_preview_access ??
true,
webuiDefaultAccessMode: visibleWebuiDefaultAccessMode(
payload.advanced.webui_default_access_mode,
),
};
}
function pendingRestartSectionsFromPayload(payload: SettingsPayload): PendingRestartSections {
const sections = payload.restart_required_sections ?? [];
return {
runtime: sections.includes("runtime"),
browser: sections.includes("browser"),
image: sections.includes("image"),
};
}
export function SettingsView({ export function SettingsView({
theme, theme,
initialSection = "overview", initialSection = "overview",
initialSettings = null,
showSidebar = true, showSidebar = true,
onToggleTheme, onToggleTheme,
onBackToChat, onBackToChat,
@@ -328,10 +458,10 @@ export function SettingsView({
}: SettingsViewProps) { }: SettingsViewProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { token } = useClient(); const { token } = useClient();
const [settings, setSettings] = useState<SettingsPayload | null>(null); const [settings, setSettings] = useState<SettingsPayload | null>(() => initialSettings);
const [cliApps, setCliApps] = useState<CliAppsPayload | null>(null); const [cliApps, setCliApps] = useState<CliAppsPayload | null>(null);
const [mcpPresets, setMcpPresets] = useState<McpPresetsPayload | null>(null); const [mcpPresets, setMcpPresets] = useState<McpPresetsPayload | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(() => initialSettings === null);
const [cliAppsLoading, setCliAppsLoading] = useState(true); const [cliAppsLoading, setCliAppsLoading] = useState(true);
const [mcpPresetsLoading, setMcpPresetsLoading] = useState(true); const [mcpPresetsLoading, setMcpPresetsLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -370,26 +500,18 @@ export function SettingsView({
EMPTY_PENDING_RESTART_SECTIONS, EMPTY_PENDING_RESTART_SECTIONS,
); );
const [localPrefs, setLocalPrefs] = useState<LocalPreferences>(() => readLocalPreferences()); const [localPrefs, setLocalPrefs] = useState<LocalPreferences>(() => readLocalPreferences());
const [webSearchForm, setWebSearchForm] = useState<WebSearchSettingsUpdate>({ const [webSearchForm, setWebSearchForm] = useState<WebSearchSettingsUpdate>(() =>
provider: "duckduckgo", initialSettings ? webSearchFormFromPayload(initialSettings) : DEFAULT_WEB_SEARCH_FORM,
apiKey: "", );
baseUrl: "", const [imageGenerationForm, setImageGenerationForm] = useState<ImageGenerationSettingsUpdate>(
maxResults: 5, () =>
timeout: 30, initialSettings
useJinaReader: true, ? imageGenerationFormFromPayload(initialSettings)
}); : DEFAULT_IMAGE_GENERATION_FORM,
const [imageGenerationForm, setImageGenerationForm] = useState<ImageGenerationSettingsUpdate>({ );
enabled: false, const [networkSafetyForm, setNetworkSafetyForm] = useState<NetworkSafetySettingsUpdate>(() =>
provider: "openrouter", initialSettings ? networkSafetyFormFromPayload(initialSettings) : DEFAULT_NETWORK_SAFETY_FORM,
model: "openai/gpt-5.4-image-2", );
defaultAspectRatio: "1:1",
defaultImageSize: "1K",
maxImagesPerTurn: 4,
});
const [networkSafetyForm, setNetworkSafetyForm] = useState<NetworkSafetySettingsUpdate>({
webuiAllowLocalServiceAccess: true,
webuiDefaultAccessMode: "default",
});
useEffect(() => { useEffect(() => {
setActiveSection(initialSection); setActiveSection(initialSection);
@@ -404,17 +526,9 @@ export function SettingsView({
); );
const [webSearchKeyVisible, setWebSearchKeyVisible] = useState(false); const [webSearchKeyVisible, setWebSearchKeyVisible] = useState(false);
const [webSearchKeyEditing, setWebSearchKeyEditing] = useState(false); const [webSearchKeyEditing, setWebSearchKeyEditing] = useState(false);
const [form, setForm] = useState<AgentSettingsDraft>({ const [form, setForm] = useState<AgentSettingsDraft>(() =>
model: "", initialSettings ? agentDraftFromPayload(initialSettings) : DEFAULT_AGENT_SETTINGS_DRAFT,
provider: "", );
modelPreset: "default",
presetLabel: "Default",
contextWindowTokens: 65_536,
timezone: "UTC",
botName: "nanobot",
botIcon: "",
toolHintMaxLength: 40,
});
const text = useCallback( const text = useCallback(
(key: string, fallback: string, options?: Record<string, unknown>) => (key: string, fallback: string, options?: Record<string, unknown>) =>
@@ -423,59 +537,27 @@ export function SettingsView({
); );
const applyPayload = useCallback((payload: SettingsPayload) => { const applyPayload = useCallback((payload: SettingsPayload) => {
const fallbackDefault = defaultPreset(payload);
const activePresetName = modelPresetValue(payload);
const activePreset =
payload.model_presets.find((preset) => preset.name === activePresetName) ?? fallbackDefault;
setSettings(payload); setSettings(payload);
setForm({ setForm(agentDraftFromPayload(payload));
model: activePreset?.model ?? payload.agent.model, setWebSearchForm((prev) => webSearchFormFromPayload(payload, prev));
provider: activePreset?.is_default setImageGenerationForm(imageGenerationFormFromPayload(payload));
? editableDefaultProvider(payload) setNetworkSafetyForm(networkSafetyFormFromPayload(payload));
: activePreset?.provider ?? editableDefaultProvider(payload),
modelPreset: activePresetName,
presetLabel: activePreset?.label ?? activePresetName,
contextWindowTokens: normalizeContextWindowTokens(
activePreset?.context_window_tokens ?? payload.agent.context_window_tokens,
),
timezone: payload.agent.timezone,
botName: payload.agent.bot_name,
botIcon: payload.agent.bot_icon,
toolHintMaxLength: payload.agent.tool_hint_max_length,
});
setWebSearchForm((prev) => ({
provider: payload.web_search.provider,
apiKey: prev.provider === payload.web_search.provider ? prev.apiKey ?? "" : "",
baseUrl: payload.web_search.base_url ?? "",
maxResults: payload.web_search.max_results,
timeout: payload.web_search.timeout,
useJinaReader: payload.web.fetch.use_jina_reader,
}));
setImageGenerationForm({
enabled: payload.image_generation.enabled,
provider: payload.image_generation.provider,
model: payload.image_generation.model,
defaultAspectRatio: payload.image_generation.default_aspect_ratio,
defaultImageSize: payload.image_generation.default_image_size,
maxImagesPerTurn: payload.image_generation.max_images_per_turn,
});
setNetworkSafetyForm({
webuiAllowLocalServiceAccess: payload.advanced.webui_allow_local_service_access ?? payload.advanced.allow_local_preview_access ?? true,
webuiDefaultAccessMode: visibleWebuiDefaultAccessMode(payload.advanced.webui_default_access_mode),
});
if (payload.restart_required_sections) { if (payload.restart_required_sections) {
setPendingRestartSections({ setPendingRestartSections(pendingRestartSectionsFromPayload(payload));
runtime: payload.restart_required_sections.includes("runtime"),
browser: payload.restart_required_sections.includes("browser"),
image: payload.restart_required_sections.includes("image"),
});
} }
onSettingsChange?.(payload); onSettingsChange?.(payload);
}, [onSettingsChange]); }, [onSettingsChange]);
useEffect(() => {
if (!initialSettings || settings !== null) return;
applyPayload(initialSettings);
setLoading(false);
}, [applyPayload, initialSettings, settings]);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
setLoading(true); const showLoading = settings === null;
if (showLoading) setLoading(true);
fetchSettings(token) fetchSettings(token)
.then((payload) => { .then((payload) => {
if (!cancelled) { if (!cancelled) {
@@ -484,7 +566,7 @@ export function SettingsView({
} }
}) })
.catch((err) => { .catch((err) => {
if (!cancelled) setError((err as Error).message); if (!cancelled && showLoading) setError((err as Error).message);
}) })
.finally(() => { .finally(() => {
if (!cancelled) setLoading(false); if (!cancelled) setLoading(false);
@@ -494,6 +576,34 @@ export function SettingsView({
}; };
}, [applyPayload, token]); }, [applyPayload, token]);
const hasSettings = settings !== null;
useEffect(() => {
if (activeSection !== "overview" || !hasSettings) return;
let cancelled = false;
const refresh = () => {
fetchSettingsUsage(token)
.then((usage) => {
if (cancelled) return;
setSettings((current) => (current ? { ...current, usage } : current));
})
.catch(() => {});
};
void refresh();
const interval = window.setInterval(refresh, 5000);
const onFocus = () => refresh();
const onVisibilityChange = () => {
if (document.visibilityState === "visible") refresh();
};
window.addEventListener("focus", onFocus);
document.addEventListener("visibilitychange", onVisibilityChange);
return () => {
cancelled = true;
window.clearInterval(interval);
window.removeEventListener("focus", onFocus);
document.removeEventListener("visibilitychange", onVisibilityChange);
};
}, [activeSection, hasSettings, token]);
useEffect(() => { useEffect(() => {
if (activeSection !== "apps") return; if (activeSection !== "apps") return;
let cancelled = false; let cancelled = false;
@@ -1135,8 +1245,6 @@ export function SettingsView({
<OverviewSettings <OverviewSettings
settings={settings} settings={settings}
requiresRestart={hasPendingRestart} requiresRestart={hasPendingRestart}
onRestart={restartViaSettingsSurface}
isRestarting={isRestarting || hostEngineApplying}
showBrandLogos={localPrefs.brandLogos} showBrandLogos={localPrefs.brandLogos}
onSelectSection={selectSection} onSelectSection={selectSection}
/> />
@@ -1354,10 +1462,10 @@ export function SettingsView({
)} )}
> >
<div className="mb-7"> <div className="mb-7">
<p className="mb-2 text-[13px] font-medium text-muted-foreground"> <p className="mb-2 text-[12px] font-normal text-muted-foreground">
{t("settings.sidebar.title")} {t("settings.sidebar.title")}
</p> </p>
<h1 className="text-[28px] font-semibold leading-tight tracking-[-0.02em] text-foreground sm:text-[34px]"> <h1 className="text-[24px] font-normal leading-tight tracking-normal text-foreground sm:text-[28px]">
{text(`settings.nav.${activeSection}`, titleForSection(activeSection))} {text(`settings.nav.${activeSection}`, titleForSection(activeSection))}
</h1> </h1>
</div> </div>
@@ -1437,7 +1545,7 @@ function SettingsSidebar({
{t("settings.backToChat")} {t("settings.backToChat")}
</button> </button>
<div className="mb-3 px-1 md:mb-4 md:px-2"> <div className="mb-3 px-1 md:mb-4 md:px-2">
<h2 className="text-[21px] font-semibold tracking-[-0.02em] text-foreground"> <h2 className="text-[18px] font-normal tracking-normal text-foreground">
{t("settings.sidebar.title")} {t("settings.sidebar.title")}
</h2> </h2>
</div> </div>
@@ -1488,15 +1596,11 @@ function SettingsSidebar({
function OverviewSettings({ function OverviewSettings({
settings, settings,
requiresRestart, requiresRestart,
onRestart,
isRestarting,
onSelectSection, onSelectSection,
showBrandLogos, showBrandLogos,
}: { }: {
settings: SettingsPayload; settings: SettingsPayload;
requiresRestart: boolean; requiresRestart: boolean;
onRestart?: () => void;
isRestarting?: boolean;
onSelectSection: (section: SettingsSectionKey) => void; onSelectSection: (section: SettingsSectionKey) => void;
showBrandLogos: boolean; showBrandLogos: boolean;
}) { }) {
@@ -1504,6 +1608,16 @@ function OverviewSettings({
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback }); const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const activePreset = settings.agent.model_preset || "default"; const activePreset = settings.agent.model_preset || "default";
const activeProvider = settings.agent.resolved_provider ?? settings.agent.provider; const activeProvider = settings.agent.resolved_provider ?? settings.agent.provider;
const activeProviderConfigured = settingsProviderConfigured(settings, activeProvider);
const activeProviderLabel = providerDisplayLabel(settings.providers, activeProvider);
const activeModelValue = activeProviderConfigured
? settings.agent.model
: tx("settings.values.notConfigured", "Not configured");
const activeModelCaption = activeProviderConfigured
? `${activeProvider} · ${activePreset}`
: activeProviderLabel || settings.agent.model
? [activeProviderLabel, settings.agent.model].filter(Boolean).join(" · ")
: tx("settings.byok.noConfiguredProviders", "No configured providers");
const webStatus = settings.web.enable const webStatus = settings.web.enable
? tx("settings.values.enabled", "Enabled") ? tx("settings.values.enabled", "Enabled")
: tx("settings.values.disabled", "Disabled"); : tx("settings.values.disabled", "Disabled");
@@ -1515,48 +1629,23 @@ function OverviewSettings({
? tx("settings.values.configured", "Configured") ? tx("settings.values.configured", "Configured")
: tx("settings.values.notConfigured", "Not configured") : tx("settings.values.notConfigured", "Not configured")
}`; }`;
const isNativeHost = (settings.surface ?? settings.runtime_surface) === "native";
const workspaceCaption = shortWorkspacePath(settings.runtime.workspace_path);
const runtimeTitle = isNativeHost
? tx("settings.rows.engine", "Engine")
: tx("settings.rows.gateway", "Gateway");
const runtimeValue = isNativeHost
? tx("settings.values.privateEngine", "Private engine")
: `${settings.runtime.gateway_host}:${settings.runtime.gateway_port}`;
const runtimeCaption = isNativeHost
? tx("settings.values.unixSocket", "Unix socket")
: requiresRestart
? tx("settings.values.restartPending", "Restart pending")
: tx("settings.values.ready", "Ready");
return ( return (
<div className="space-y-7"> <div className="space-y-7">
<section> <section>
<div className="overflow-hidden rounded-[22px] border border-border/45 bg-card/86 shadow-[0_18px_65px_rgba(15,23,42,0.075)] backdrop-blur-xl dark:border-white/10 dark:shadow-[0_18px_65px_rgba(0,0,0,0.24)]"> <TokenUsageHeatmap usage={settings.usage} />
<div className="flex flex-col gap-4 px-5 py-5 sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-w-0 items-center gap-3">
<NanobotBrandLogo size="lg" testId="overview-nanobot-logo" />
<div className="min-w-0">
<div className="text-[12px] font-medium text-muted-foreground">nanobot</div>
<div className="mt-0.5 truncate text-[18px] font-semibold leading-6 text-foreground">
{settings.agent.model}
</div>
<div className="mt-0.5 truncate text-[13px] leading-5 text-muted-foreground">
{activeProvider} · {activePreset}
</div>
</div>
</div>
<div className="flex flex-wrap items-center gap-2 sm:justify-end">
<StatusPill tone={requiresRestart ? "neutral" : "success"}>
{requiresRestart
? tx("settings.values.restartPending", "Restart pending")
: tx("settings.values.ready", "Ready")}
</StatusPill>
{requiresRestart && onRestart ? (
<Button
size="sm"
variant="ghost"
onClick={onRestart}
disabled={isRestarting}
className="rounded-full"
>
{isRestarting ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
) : (
<RotateCcw className="mr-1.5 h-3.5 w-3.5" aria-hidden />
)}
{isRestarting ? t("app.system.restarting") : t("app.system.restart")}
</Button>
) : null}
</div>
</div>
</div>
</section> </section>
<section> <section>
@@ -1566,8 +1655,8 @@ function OverviewSettings({
icon={Bot} icon={Bot}
valueLogoProvider={activeProvider} valueLogoProvider={activeProvider}
title={tx("settings.overview.model", "Current model")} title={tx("settings.overview.model", "Current model")}
value={settings.agent.model} value={activeModelValue}
caption={`${activeProvider} · ${activePreset}`} caption={activeModelCaption}
showBrandLogos={showBrandLogos} showBrandLogos={showBrandLogos}
onClick={() => onSelectSection("models")} onClick={() => onSelectSection("models")}
/> />
@@ -1603,20 +1692,16 @@ function OverviewSettings({
<SettingsGroup> <SettingsGroup>
<OverviewListRow <OverviewListRow
icon={Server} icon={Server}
title={tx("settings.rows.gateway", "Gateway")} title={runtimeTitle}
value={`${settings.runtime.gateway_host}:${settings.runtime.gateway_port}`} value={runtimeValue}
caption={ caption={runtimeCaption}
requiresRestart
? tx("settings.values.restartPending", "Restart pending")
: tx("settings.values.ready", "Ready")
}
onClick={() => onSelectSection("runtime")} onClick={() => onSelectSection("runtime")}
/> />
<OverviewListRow <OverviewListRow
icon={HardDrive} icon={HardDrive}
title={tx("settings.overview.workspace", "Workspace")} title={tx("settings.overview.workspace", "Workspace")}
value={settings.runtime.workspace_path} value={tx("settings.values.defaultWorkspace", "Default workspace")}
caption={settings.runtime.config_path} caption={workspaceCaption}
onClick={() => onSelectSection("runtime")} onClick={() => onSelectSection("runtime")}
/> />
</SettingsGroup> </SettingsGroup>
@@ -1625,6 +1710,219 @@ function OverviewSettings({
); );
} }
type TokenUsagePayload = NonNullable<SettingsPayload["usage"]>;
type TokenUsageDay = TokenUsagePayload["days"][number];
type TokenUsageCell = {
date: string;
total: number;
estimated: number;
requests: number;
sources: NonNullable<TokenUsageDay["sources"]>;
future: boolean;
};
type TokenUsageMonthLabel = {
label: string;
column: number;
};
const TOKEN_HEATMAP_CELLS = 371;
const TOKEN_HEATMAP_COLUMNS = Math.ceil(TOKEN_HEATMAP_CELLS / 7);
const TOKEN_USAGE_SOURCE_ORDER = ["user", "api", "cron", "dream", "system"] as const;
function startOfUtcDay(date: Date): Date {
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
}
function addUtcDays(date: Date, days: number): Date {
const next = new Date(date);
next.setUTCDate(next.getUTCDate() + days);
return next;
}
function isoDay(date: Date): string {
return date.toISOString().slice(0, 10);
}
function buildTokenUsageCalendar(
days: TokenUsageDay[] | undefined,
monthFormatter: Intl.DateTimeFormat,
): { cells: TokenUsageCell[]; monthLabels: TokenUsageMonthLabel[] } {
const byDate = new Map((days ?? []).map((day) => [day.date, day]));
const today = startOfUtcDay(new Date());
const end = addUtcDays(today, 6 - today.getUTCDay());
const start = addUtcDays(end, -(TOKEN_HEATMAP_CELLS - 1));
const seenMonths = new Set<string>();
const monthLabels: TokenUsageMonthLabel[] = [];
const cells = Array.from({ length: TOKEN_HEATMAP_CELLS }, (_, index) => {
const date = addUtcDays(start, index);
const key = isoDay(date);
const row = byDate.get(key);
const monthKey = key.slice(0, 7);
if (!seenMonths.has(monthKey)) {
seenMonths.add(monthKey);
monthLabels.push({
label: monthFormatter.format(date),
column: Math.floor(index / 7) + 1,
});
}
return {
date: key,
total: row?.total_tokens ?? 0,
estimated: row?.estimated_tokens ?? 0,
requests: row?.requests ?? 0,
sources: row?.sources ?? {},
future: date > today,
};
});
return { cells, monthLabels };
}
function tokenUsageSourceLabel(
source: string,
tx: (key: string, fallback: string, values?: Record<string, unknown>) => string,
): string {
if (source === "user") return tx("settings.usage.sources.user", "Chat");
if (source === "api") return tx("settings.usage.sources.api", "API");
if (source === "cron") return tx("settings.usage.sources.cron", "Automations");
if (source === "dream") return tx("settings.usage.sources.dream", "Memory");
return tx("settings.usage.sources.system", "System");
}
function tokenUsageSourceBreakdown(
cell: TokenUsageCell,
tx: (key: string, fallback: string, values?: Record<string, unknown>) => string,
): string {
const known = TOKEN_USAGE_SOURCE_ORDER.filter((source) => cell.sources[source]?.total_tokens > 0);
const extra = Object.keys(cell.sources)
.filter((source) => !TOKEN_USAGE_SOURCE_ORDER.includes(source as typeof TOKEN_USAGE_SOURCE_ORDER[number]))
.filter((source) => cell.sources[source]?.total_tokens > 0)
.sort();
return [...known, ...extra]
.map((source) => {
const label = tokenUsageSourceLabel(source, tx);
const tokens = formatCompactTokens(cell.sources[source]?.total_tokens ?? 0);
return `${label} ${tokens}`;
})
.join(" · ");
}
function formatCompactTokens(tokens: number): string {
if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(tokens >= 10_000_000 ? 0 : 1)}M`;
if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(tokens >= 10_000 ? 0 : 1)}K`;
return String(tokens);
}
function tokenUsageLevel(tokens: number, max: number): number {
if (tokens <= 0 || max <= 0) return 0;
const ratio = tokens / max;
if (ratio >= 0.75) return 4;
if (ratio >= 0.45) return 3;
if (ratio >= 0.2) return 2;
return 1;
}
function tokenUsageCellClass(level: number, future: boolean): string {
if (future) return "bg-transparent ring-1 ring-neutral-200/70 dark:ring-white/[0.045]";
if (level === 4) return "bg-sky-300 dark:bg-sky-300";
if (level === 3) return "bg-sky-400/85 dark:bg-sky-500/80";
if (level === 2) return "bg-sky-500/60 dark:bg-sky-700/85";
if (level === 1) return "bg-sky-500/30 dark:bg-sky-900/80";
return "bg-neutral-200/70 ring-1 ring-black/[0.025] dark:bg-white/[0.08] dark:ring-white/[0.035]";
}
function TokenUsageHeatmap({ usage }: { usage?: TokenUsagePayload }) {
const { t, i18n } = useTranslation();
const tx = (key: string, fallback: string, values?: Record<string, unknown>) =>
t(key, { defaultValue: fallback, ...(values ?? {}) });
const monthFormatter = useMemo(
() => new Intl.DateTimeFormat(i18n.language, { month: "short", timeZone: "UTC" }),
[i18n.language],
);
const { cells, monthLabels } = useMemo(
() => buildTokenUsageCalendar(usage?.days, monthFormatter),
[monthFormatter, usage?.days],
);
const maxTokens = Math.max(0, ...cells.map((cell) => cell.total));
return (
<div className="overflow-x-auto pb-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<div className="mx-auto w-full min-w-[760px] max-w-[1054px] px-0.5">
<div className="mb-2 flex justify-end">
<span className="text-[11px] font-normal leading-none text-muted-foreground/64">
{tx("settings.usage.shortTitle", "Token Usage")}
</span>
</div>
<div
className="mb-2 grid h-4 gap-1.5 text-[10px] font-normal leading-4 text-muted-foreground/62"
style={{ gridTemplateColumns: `repeat(${TOKEN_HEATMAP_COLUMNS}, minmax(0, 1fr))` }}
aria-hidden
>
{monthLabels.map((month) => (
<span
key={`${month.label}-${month.column}`}
className="truncate"
style={{ gridColumnStart: month.column, gridColumnEnd: "span 4" }}
>
{month.label}
</span>
))}
</div>
<div
className="grid grid-flow-col grid-rows-7 gap-1.5"
style={{ gridTemplateColumns: `repeat(${TOKEN_HEATMAP_COLUMNS}, minmax(0, 1fr))` }}
aria-label={tx("settings.usage.title", "Token activity")}
>
<TooltipProvider delayDuration={120} skipDelayDuration={80}>
{cells.map((cell) => {
const level = tokenUsageLevel(cell.total, maxTokens);
const baseLabel = cell.future
? cell.date
: tx("settings.usage.cellTitle", "{{date}}: {{tokens}} tokens, {{requests}} requests", {
date: cell.date,
tokens: formatCompactTokens(cell.total),
requests: cell.requests,
});
const label = cell.future || cell.estimated <= 0
? baseLabel
: `${baseLabel} · ${
cell.estimated >= cell.total
? tx("settings.usage.estimated", "estimated")
: tx("settings.usage.includesEstimates", "includes estimates")
}`;
const breakdown = cell.future ? "" : tokenUsageSourceBreakdown(cell, tx);
const ariaLabel = breakdown ? `${label} · ${breakdown}` : label;
return (
<Tooltip key={cell.date}>
<TooltipTrigger asChild>
<span
aria-label={ariaLabel}
className={cn(
"aspect-square w-full rounded-[4px] transition-transform hover:scale-110",
tokenUsageCellClass(level, cell.future),
)}
/>
</TooltipTrigger>
<TooltipContent
side="top"
align="center"
className="rounded-[10px] border-border/45 bg-popover px-2.5 py-1.5 text-[11px] font-normal text-popover-foreground shadow-lg"
>
<span className="block">{label}</span>
{breakdown ? (
<span className="mt-1 block text-muted-foreground">{breakdown}</span>
) : null}
</TooltipContent>
</Tooltip>
);
})}
</TooltipProvider>
</div>
</div>
</div>
);
}
function AppearanceSettings({ function AppearanceSettings({
theme, theme,
onToggleTheme, onToggleTheme,
@@ -1885,9 +2183,8 @@ function ModelsSettings({
const { t } = useTranslation(); const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback }); const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const configuredProviders = settings.providers.filter((provider) => provider.configured); const configuredProviders = settings.providers.filter((provider) => provider.configured);
const oauthProviders = settings.providers.filter((provider) => provider.auth_type === "oauth");
const showAutoProvider = defaultPreset(settings)?.provider === "auto" || form.provider === "auto"; const showAutoProvider = defaultPreset(settings)?.provider === "auto" || form.provider === "auto";
const selectableProviders = uniqueProviders([...configuredProviders, ...oauthProviders]); const selectableProviders = uniqueProviders(configuredProviders);
const providerOptions = showAutoProvider const providerOptions = showAutoProvider
? [{ name: "auto", label: tx("settings.values.auto", "Auto") }, ...selectableProviders] ? [{ name: "auto", label: tx("settings.values.auto", "Auto") }, ...selectableProviders]
: selectableProviders; : selectableProviders;
@@ -1900,6 +2197,7 @@ function ModelsSettings({
const selectedProviderNeedsSignIn = const selectedProviderNeedsSignIn =
selectedProvider?.auth_type === "oauth" && !selectedProvider.configured; selectedProvider?.auth_type === "oauth" && !selectedProvider.configured;
const selectedProviderSigningIn = providerSaving === selectedProvider?.name; const selectedProviderSigningIn = providerSaving === selectedProvider?.name;
const selectedProviderConfigured = settingsProviderConfigured(settings, form.provider);
const modelFieldsMissing = const modelFieldsMissing =
!form.model.trim() || !form.model.trim() ||
!form.provider.trim() || !form.provider.trim() ||
@@ -1918,6 +2216,7 @@ function ModelsSettings({
settings={settings} settings={settings}
draftModel={form.model} draftModel={form.model}
draftProvider={form.provider} draftProvider={form.provider}
providerConfigured={selectedProviderConfigured}
showProviderLogos={showBrandLogos} showProviderLogos={showBrandLogos}
onChange={(modelPreset) => { onChange={(modelPreset) => {
const nextPreset = settings.model_presets.find((preset) => preset.name === modelPreset); const nextPreset = settings.model_presets.find((preset) => preset.name === modelPreset);
@@ -4060,10 +4359,12 @@ function RuntimeSettings({
<section> <section>
<SettingsSectionTitle>{t("settings.sections.system")}</SettingsSectionTitle> <SettingsSectionTitle>{t("settings.sections.system")}</SettingsSectionTitle>
<SettingsGroup> <SettingsGroup>
<ReadOnlyRow {!isNativeHost ? (
title={tx("settings.rows.gateway", "Gateway")} <ReadOnlyRow
value={`${settings.runtime.gateway_host}:${settings.runtime.gateway_port}`} title={tx("settings.rows.gateway", "Gateway")}
/> value={`${settings.runtime.gateway_host}:${settings.runtime.gateway_port}`}
/>
) : null}
<ReadOnlyRow title={t("settings.rows.configPath")} value={settings.runtime.config_path} /> <ReadOnlyRow title={t("settings.rows.configPath")} value={settings.runtime.config_path} />
<ReadOnlyRow title={tx("settings.rows.workspacePath", "Default workspace")} value={settings.runtime.workspace_path} /> <ReadOnlyRow title={tx("settings.rows.workspacePath", "Default workspace")} value={settings.runtime.workspace_path} />
{onRestart && !requiresRestartPending ? ( {onRestart && !requiresRestartPending ? (
@@ -4369,7 +4670,14 @@ function ModelIdPicker({
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const effectiveProvider = const effectiveProvider =
provider === "auto" ? settings.agent.resolved_provider ?? provider : provider; provider === "auto" ? settings.agent.resolved_provider ?? provider : provider;
const canFetchModels = Boolean(effectiveProvider && effectiveProvider !== "auto"); const hasConcreteProvider = Boolean(effectiveProvider && effectiveProvider !== "auto");
const providerRow = settingsProviderRow(settings, effectiveProvider);
const providerConfigured = settingsProviderConfigured(settings, effectiveProvider);
const providerRequiresConfiguration = hasConcreteProvider && !providerConfigured;
const providerUsesManualModelIds =
hasConcreteProvider && providerConfigured && providerRow?.auth_type === "oauth";
const canFetchModels =
hasConcreteProvider && providerConfigured && !providerUsesManualModelIds;
const normalizedQuery = query.trim().toLowerCase(); const normalizedQuery = query.trim().toLowerCase();
const providerModels = payload?.models ?? []; const providerModels = payload?.models ?? [];
const visibleModels = providerModels const visibleModels = providerModels
@@ -4390,13 +4698,15 @@ function ModelIdPicker({
const hasModelList = payload?.status === "available"; const hasModelList = payload?.status === "available";
const showModels = Boolean(hasModelList && payload && (!isCatalog || normalizedQuery)); const showModels = Boolean(hasModelList && payload && (!isCatalog || normalizedQuery));
const customCandidate = query.trim(); const customCandidate = query.trim();
const allowCustomModel = !providerRequiresConfiguration;
const exactQueryMatch = providerModels.some((model) => model.id === customCandidate); const exactQueryMatch = providerModels.some((model) => model.id === customCandidate);
const providerModelCount = payload?.model_count ?? providerModels.length; const providerModelCount = payload?.model_count ?? providerModels.length;
const modelUnconfigured = !value.trim() || !providerConfigured;
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
setQuery(""); setQuery(providerUsesManualModelIds || !hasConcreteProvider ? value : "");
}, [open, effectiveProvider]); }, [open, effectiveProvider, hasConcreteProvider, providerUsesManualModelIds, value]);
useEffect(() => { useEffect(() => {
if (!open || !shouldFetchModels) { if (!open || !shouldFetchModels) {
@@ -4443,7 +4753,11 @@ function ModelIdPicker({
)} )}
> >
<span className="flex min-w-0 items-center gap-2"> <span className="flex min-w-0 items-center gap-2">
<ProviderPickerIcon provider={effectiveProvider} showBrandLogos={showProviderLogos} /> <ProviderPickerIcon
provider={effectiveProvider}
showBrandLogos={showProviderLogos}
unconfigured={!providerConfigured}
/>
<span className="min-w-0 truncate font-medium text-foreground"> <span className="min-w-0 truncate font-medium text-foreground">
{model.label ?? model.id} {model.label ?? model.id}
</span> </span>
@@ -4467,7 +4781,11 @@ function ModelIdPicker({
)} )}
> >
<span className="flex min-w-0 items-center gap-2"> <span className="flex min-w-0 items-center gap-2">
<ProviderPickerIcon provider={effectiveProvider} showBrandLogos={showProviderLogos} /> <ProviderPickerIcon
provider={effectiveProvider}
showBrandLogos={showProviderLogos}
unconfigured={modelUnconfigured}
/>
<span <span
className={cn( className={cn(
"min-w-0 truncate font-medium", "min-w-0 truncate font-medium",
@@ -4500,7 +4818,15 @@ function ModelIdPicker({
</div> </div>
</div> </div>
{!canFetchModels ? ( {providerRequiresConfiguration ? (
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
{tx("settings.models.providerNotConfigured", "Configure this provider before loading models.")}
</div>
) : providerUsesManualModelIds ? (
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
{tx("settings.models.unsupportedModelList", "Type a model ID manually.")}
</div>
) : !canFetchModels ? (
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground"> <div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
{tx("settings.models.autoProviderCustomOnly", "Auto provider mode uses custom model IDs.")} {tx("settings.models.autoProviderCustomOnly", "Auto provider mode uses custom model IDs.")}
</div> </div>
@@ -4544,7 +4870,7 @@ function ModelIdPicker({
</div> </div>
) : null} ) : null}
{customCandidate && !exactQueryMatch && customCandidate !== value ? ( {allowCustomModel && customCandidate && !exactQueryMatch && customCandidate !== value ? (
<> <>
{showModels ? <DropdownMenuSeparator /> : null} {showModels ? <DropdownMenuSeparator /> : null}
<DropdownMenuItem <DropdownMenuItem
@@ -4581,17 +4907,31 @@ function formatContextWindow(tokens: number): string {
function ProviderPickerIcon({ function ProviderPickerIcon({
provider, provider,
showBrandLogos, showBrandLogos,
unconfigured = false,
}: { }: {
provider: string; provider: string;
showBrandLogos: boolean; showBrandLogos: boolean;
unconfigured?: boolean;
}) { }) {
const [logoIndex, setLogoIndex] = useState(0); const [logoIndex, setLogoIndex] = useState(0);
const brand = providerBrand(provider); const brand = providerBrand(provider);
const Icon = PROVIDER_ICONS[provider] ?? Sparkles; const Icon = PROVIDER_ICONS[provider] ?? Hexagon;
const logoUrl = brand?.logoUrls[logoIndex]; const logoUrl = brand?.logoUrls[logoIndex];
useEffect(() => setLogoIndex(0), [provider]); useEffect(() => setLogoIndex(0), [provider]);
if (unconfigured) {
return (
<span
data-testid="provider-picker-unconfigured-icon"
className="grid h-5 w-5 shrink-0 place-items-center text-amber-700 dark:text-amber-200"
aria-hidden
>
<CircleAlert className="h-4 w-4" strokeWidth={1.8} />
</span>
);
}
if (showBrandLogos && logoUrl) { if (showBrandLogos && logoUrl) {
return ( return (
<span <span
@@ -4901,32 +5241,6 @@ function ProviderIcon({
); );
} }
function NanobotBrandLogo({
size = "sm",
testId,
}: {
size?: "sm" | "lg";
testId?: string;
}) {
return (
<span
data-testid={testId}
className={cn(
"grid shrink-0 place-items-center overflow-hidden border border-border/45 bg-background shadow-[inset_0_0_0_1px_rgba(0,0,0,0.025)]",
size === "lg" ? "h-12 w-12 rounded-[16px]" : "h-9 w-9 rounded-[12px]",
)}
aria-hidden
>
<img
src={NANOBOT_ICON_SRC}
alt=""
className={cn("select-none object-contain", size === "lg" ? "h-10 w-10" : "h-7 w-7")}
draggable={false}
/>
</span>
);
}
function OverviewRowIcon({ function OverviewRowIcon({
icon: Icon, icon: Icon,
}: { }: {
@@ -5090,6 +5404,7 @@ function ModelPresetPicker({
settings, settings,
draftModel, draftModel,
draftProvider, draftProvider,
providerConfigured,
showProviderLogos, showProviderLogos,
onChange, onChange,
onCreateConfiguration, onCreateConfiguration,
@@ -5099,6 +5414,7 @@ function ModelPresetPicker({
settings: SettingsPayload; settings: SettingsPayload;
draftModel: string; draftModel: string;
draftProvider: string; draftProvider: string;
providerConfigured: boolean;
showProviderLogos: boolean; showProviderLogos: boolean;
onChange: (preset: string) => void; onChange: (preset: string) => void;
onCreateConfiguration: () => void; onCreateConfiguration: () => void;
@@ -5126,6 +5442,7 @@ function ModelPresetPicker({
settings={settings} settings={settings}
draftModel={draftModel} draftModel={draftModel}
draftProvider={draftProvider} draftProvider={draftProvider}
forceUnconfigured={selectedPreset?.is_default ? !providerConfigured : undefined}
showProviderLogos={showProviderLogos} showProviderLogos={showProviderLogos}
compact compact
/> />
@@ -5190,6 +5507,7 @@ function ModelPresetOptionContent({
settings, settings,
draftModel, draftModel,
draftProvider, draftProvider,
forceUnconfigured,
showProviderLogos, showProviderLogos,
compact = false, compact = false,
}: { }: {
@@ -5197,27 +5515,50 @@ function ModelPresetOptionContent({
settings: SettingsPayload; settings: SettingsPayload;
draftModel: string; draftModel: string;
draftProvider: string; draftProvider: string;
forceUnconfigured?: boolean;
showProviderLogos: boolean; showProviderLogos: boolean;
compact?: boolean; compact?: boolean;
}) { }) {
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const provider = modelPresetProviderKey(preset, settings, { const provider = modelPresetProviderKey(preset, settings, {
draftProvider: preset.is_default ? draftProvider : undefined, draftProvider: preset.is_default ? draftProvider : undefined,
}); });
const model = preset.is_default ? draftModel : preset.model; const model = preset.is_default ? draftModel : preset.model;
const providerName = providerDisplayLabel(settings.providers, provider); const providerName = providerDisplayLabel(settings.providers, provider);
const providerConfigured =
forceUnconfigured === undefined
? settingsProviderConfigured(settings, provider)
: !forceUnconfigured;
const title = providerConfigured ? model || preset.label : tx("settings.values.notConfigured", "Not configured");
const caption = providerConfigured
? `${providerName}${preset.label ? ` · ${preset.label}` : ""}`
: providerName || model || preset.label
? [providerName, model || preset.label].filter(Boolean).join(" · ")
: tx("settings.byok.noConfiguredProviders", "No configured providers");
return ( return (
<span className="flex min-w-0 items-center gap-2.5"> <span className="flex min-w-0 items-center gap-2.5">
<ProviderPickerIcon provider={provider} showBrandLogos={showProviderLogos} /> <ProviderPickerIcon
provider={provider}
showBrandLogos={showProviderLogos}
unconfigured={!providerConfigured}
/>
<span className="min-w-0 text-left leading-tight"> <span className="min-w-0 text-left leading-tight">
<span className="block truncate font-medium text-foreground">{model || preset.label}</span> <span
className={cn(
"block truncate font-medium",
providerConfigured ? "text-foreground" : "text-amber-800 dark:text-amber-200",
)}
>
{title}
</span>
<span <span
className={cn( className={cn(
"mt-0.5 block truncate text-muted-foreground", "mt-0.5 block truncate text-muted-foreground",
compact ? "text-[11.5px]" : "text-[12px]", compact ? "text-[11.5px]" : "text-[12px]",
)} )}
> >
{providerName} {caption}
{preset.label ? ` · ${preset.label}` : ""}
</span> </span>
</span> </span>
</span> </span>
@@ -173,6 +173,7 @@ interface AgentActivityClusterProps {
turnLatencyMs?: number; turnLatencyMs?: number;
cliApps?: CliAppInfo[]; cliApps?: CliAppInfo[];
mcpPresets?: McpPresetInfo[]; mcpPresets?: McpPresetInfo[];
onOpenFilePreview?: (path: string) => void;
} }
/** /**
@@ -186,6 +187,7 @@ export function AgentActivityCluster({
turnLatencyMs, turnLatencyMs,
cliApps = [], cliApps = [],
mcpPresets = [], mcpPresets = [],
onOpenFilePreview,
}: AgentActivityClusterProps) { }: AgentActivityClusterProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const fileEdits = useMemo( const fileEdits = useMemo(
@@ -423,6 +425,7 @@ export function AgentActivityCluster({
added={added} added={added}
deleted={deleted} deleted={deleted}
hasDiffStats={hasDiffStats} hasDiffStats={hasDiffStats}
onOpenFilePreview={onOpenFilePreview}
/> />
); );
} }
@@ -449,6 +452,8 @@ export function AgentActivityCluster({
<FileReferenceChip <FileReferenceChip
path={singleFilePath} path={singleFilePath}
tooltipPath={singleFileTooltipPath} tooltipPath={singleFileTooltipPath}
previewPath={singleFileTooltipPath || singleFilePath}
onOpen={onOpenFilePreview}
active={hasLiveEditingFiles} active={hasLiveEditingFiles}
className="-my-0.5 min-w-0" className="-my-0.5 min-w-0"
textClassName="text-xs" textClassName="text-xs"
@@ -494,6 +499,7 @@ export function AgentActivityCluster({
key={m.id} key={m.id}
text={m.reasoning ?? ""} text={m.reasoning ?? ""}
streaming={isTurnStreaming && !!m.reasoningStreaming} streaming={isTurnStreaming && !!m.reasoningStreaming}
onOpenFilePreview={onOpenFilePreview}
/> />
); );
} }
@@ -510,7 +516,12 @@ export function AgentActivityCluster({
} }
return null; return null;
})} })}
{fileEdits.length ? <FileEditGroup edits={fileEdits} /> : null} {fileEdits.length ? (
<FileEditGroup
edits={fileEdits}
onOpenFilePreview={onOpenFilePreview}
/>
) : null}
</div> </div>
</div> </div>
</div> </div>
@@ -537,6 +548,7 @@ function FileEditFlatActivity({
added, added,
deleted, deleted,
hasDiffStats, hasDiffStats,
onOpenFilePreview,
}: { }: {
edits: FileEditSummary[]; edits: FileEditSummary[];
active: boolean; active: boolean;
@@ -550,6 +562,7 @@ function FileEditFlatActivity({
added: number; added: number;
deleted: number; deleted: number;
hasDiffStats: boolean; hasDiffStats: boolean;
onOpenFilePreview?: (path: string) => void;
}) { }) {
const showRows = edits.length > 1 || edits.some((edit) => edit.status === "error" || edit.pending); const showRows = edits.length > 1 || edits.some((edit) => edit.status === "error" || edit.pending);
return ( return (
@@ -569,6 +582,8 @@ function FileEditFlatActivity({
<FileReferenceChip <FileReferenceChip
path={singleFilePath} path={singleFilePath}
tooltipPath={singleFileTooltipPath} tooltipPath={singleFileTooltipPath}
previewPath={singleFileTooltipPath || singleFilePath}
onOpen={onOpenFilePreview}
active={hasLiveEditingFiles} active={hasLiveEditingFiles}
className="-my-0.5 min-w-0" className="-my-0.5 min-w-0"
textClassName="text-xs" textClassName="text-xs"
@@ -583,7 +598,7 @@ function FileEditFlatActivity({
</div> </div>
{showRows ? ( {showRows ? (
<div className="mt-0.5 pl-4"> <div className="mt-0.5 pl-4">
<FileEditGroup edits={edits} /> <FileEditGroup edits={edits} onOpenFilePreview={onOpenFilePreview} />
</div> </div>
) : null} ) : null}
</div> </div>
+56 -12
View File
@@ -34,14 +34,16 @@ interface PromptMarker {
} }
const MIN_PROMPTS_FOR_RAIL = 3; const MIN_PROMPTS_FOR_RAIL = 3;
const RAIL_MIN_SCROLL_RANGE_PX = 240; const RAIL_MIN_SCROLL_RANGE_PX = 80;
const DENSE_PROMPT_THRESHOLD = 30; const DENSE_PROMPT_THRESHOLD = 30;
const DENSE_BUCKET_HEIGHT_PX = 12; const DENSE_BUCKET_HEIGHT_PX = 12;
const DENSE_BUCKET_FALLBACK_COUNT = 32; const DENSE_BUCKET_FALLBACK_COUNT = 32;
const DENSE_BUCKET_MAX_COUNT = 42; const DENSE_BUCKET_MAX_COUNT = 42;
const MARKER_MIN_GAP_PX = 9; const MARKER_MIN_GAP_PX = 9;
const MARKER_BASE_WIDTH_PX = 26; const MARKER_BASE_WIDTH_PX = 16;
const MARKER_MAX_WIDTH_PX = 42; const MARKER_MAX_WIDTH_PX = 28;
const MEASURE_RETRY_FRAMES = 4;
const RAIL_REVEAL_MS = 1400;
export function PromptRail({ export function PromptRail({
bottomOffset, bottomOffset,
@@ -52,6 +54,19 @@ export function PromptRail({
const promptAnchors = useMemo(() => userPromptAnchors(messages), [messages]); const promptAnchors = useMemo(() => userPromptAnchors(messages), [messages]);
const [markers, setMarkers] = useState<PromptMarker[]>([]); const [markers, setMarkers] = useState<PromptMarker[]>([]);
const [activePromptId, setActivePromptId] = useState<string | null>(null); const [activePromptId, setActivePromptId] = useState<string | null>(null);
const [revealed, setRevealed] = useState(false);
const revealTimeoutRef = useRef<number | null>(null);
const revealTemporarily = useCallback(() => {
setRevealed(true);
if (revealTimeoutRef.current !== null) {
window.clearTimeout(revealTimeoutRef.current);
}
revealTimeoutRef.current = window.setTimeout(() => {
setRevealed(false);
revealTimeoutRef.current = null;
}, RAIL_REVEAL_MS);
}, []);
const updateMarkers = useCallback(() => { const updateMarkers = useCallback(() => {
const scrollEl = scrollRef.current; const scrollEl = scrollRef.current;
@@ -74,8 +89,18 @@ export function PromptRail({
}, [promptAnchors, scrollRef]); }, [promptAnchors, scrollRef]);
useEffect(() => { useEffect(() => {
updateMarkers(); let frame = 0;
}, [updateMarkers]); let remainingFrames = MEASURE_RETRY_FRAMES;
const measure = () => {
updateMarkers();
remainingFrames -= 1;
if (remainingFrames > 0) {
frame = window.requestAnimationFrame(measure);
}
};
measure();
return () => window.cancelAnimationFrame(frame);
}, [bottomOffset, updateMarkers]);
useEffect(() => { useEffect(() => {
const scrollEl = scrollRef.current; const scrollEl = scrollRef.current;
@@ -84,6 +109,7 @@ export function PromptRail({
let frame = 0; let frame = 0;
const schedule = () => { const schedule = () => {
window.cancelAnimationFrame(frame); window.cancelAnimationFrame(frame);
revealTemporarily();
frame = window.requestAnimationFrame(updateMarkers); frame = window.requestAnimationFrame(updateMarkers);
}; };
@@ -94,7 +120,7 @@ export function PromptRail({
scrollEl.removeEventListener("scroll", schedule); scrollEl.removeEventListener("scroll", schedule);
window.removeEventListener("resize", schedule); window.removeEventListener("resize", schedule);
}; };
}, [scrollRef, updateMarkers]); }, [revealTemporarily, scrollRef, updateMarkers]);
useEffect(() => { useEffect(() => {
const scrollEl = scrollRef.current; const scrollEl = scrollRef.current;
@@ -105,22 +131,36 @@ export function PromptRail({
return () => observer.disconnect(); return () => observer.disconnect();
}, [scrollRef, updateMarkers]); }, [scrollRef, updateMarkers]);
useEffect(() => {
return () => {
if (revealTimeoutRef.current !== null) {
window.clearTimeout(revealTimeoutRef.current);
}
};
}, []);
if (markers.length === 0) return null; if (markers.length === 0) return null;
const maxMarkerCount = Math.max(...markers.map((marker) => marker.count)); const maxMarkerCount = Math.max(...markers.map((marker) => marker.count));
const activeMarkerIndex = markers.findIndex((marker) =>
marker.ids.includes(activePromptId ?? ""),
);
return ( return (
<div <div
ref={railRef} ref={railRef}
aria-label="User prompt navigation" aria-label="User prompt navigation"
className={cn( className={cn(
"pointer-events-none absolute right-6 top-12 z-20 hidden w-12 md:block", "group pointer-events-auto absolute right-4 top-14 z-20 hidden w-8 opacity-70 md:block",
"transition-opacity duration-200 hover:opacity-100",
"motion-safe:animate-in motion-safe:fade-in-0 motion-safe:duration-200", "motion-safe:animate-in motion-safe:fade-in-0 motion-safe:duration-200",
)} )}
style={{ bottom: Math.max(80, bottomOffset) }} style={{ bottom: Math.max(80, bottomOffset) }}
> >
{markers.map((marker) => { {markers.map((marker) => {
const index = markers.indexOf(marker);
const active = marker.ids.includes(activePromptId ?? ""); const active = marker.ids.includes(activePromptId ?? "");
const nearActive = activeMarkerIndex < 0 || Math.abs(index - activeMarkerIndex) <= 1;
return ( return (
<button <button
key={marker.ids.join("|")} key={marker.ids.join("|")}
@@ -129,12 +169,16 @@ export function PromptRail({
aria-label={`Jump to prompt: ${marker.label}`} aria-label={`Jump to prompt: ${marker.label}`}
onClick={() => jumpToPrompt(scrollRef.current, marker.ids[marker.ids.length - 1])} onClick={() => jumpToPrompt(scrollRef.current, marker.ids[marker.ids.length - 1])}
className={cn( className={cn(
"pointer-events-auto absolute right-0 h-1.5 -translate-y-1/2 rounded-full", "absolute right-0 h-[3px] -translate-y-1/2 rounded-full",
"bg-muted-foreground/30 transition-all duration-150", "bg-foreground/20 transition-[background-color,opacity,transform,width] duration-200",
"hover:bg-blue-500/80 focus-visible:bg-blue-500", "hover:bg-blue-500/70 hover:opacity-100 hover:scale-x-110",
"focus-visible:bg-blue-500 focus-visible:opacity-100 focus-visible:scale-x-110",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-400/60", "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-400/60",
marker.count > 1 && "bg-muted-foreground/45", marker.count > 1 && "bg-foreground/30",
active && "bg-foreground shadow-sm", active && "h-1 bg-foreground/65 opacity-80 shadow-sm",
!active && nearActive && "opacity-25 group-hover:opacity-55",
!active && !nearActive && !revealed && "opacity-0 group-hover:opacity-40",
!active && !nearActive && revealed && "opacity-35",
)} )}
style={{ style={{
top: `${marker.topPercent}%`, top: `${marker.topPercent}%`,
+54 -17
View File
@@ -94,6 +94,8 @@ interface ThreadComposerProps {
modelLabel?: string | null; modelLabel?: string | null;
modelProvider?: string | null; modelProvider?: string | null;
modelProviderLabel?: string | null; modelProviderLabel?: string | null;
modelNeedsSetup?: boolean;
onModelBadgeClick?: () => void;
variant?: "thread" | "hero"; variant?: "thread" | "hero";
slashCommands?: SlashCommand[]; slashCommands?: SlashCommand[];
cliApps?: CliAppInfo[]; cliApps?: CliAppInfo[];
@@ -647,6 +649,8 @@ export function ThreadComposer({
modelLabel = null, modelLabel = null,
modelProvider = null, modelProvider = null,
modelProviderLabel = null, modelProviderLabel = null,
modelNeedsSetup = false,
onModelBadgeClick,
variant = "thread", variant = "thread",
slashCommands = [], slashCommands = [],
cliApps = [], cliApps = [],
@@ -759,17 +763,21 @@ export function ThreadComposer({
); );
const hasErrors = images.some((img) => img.status === "error"); const hasErrors = images.some((img) => img.status === "error");
const hasComposerContent = value.trim().length > 0 || readyImages.length > 0;
const canSend = const canSend =
!disabled !disabled
&& !modelNeedsSetup
&& !encoding && !encoding
&& !hasErrors && !hasErrors
&& (value.trim().length > 0 || readyImages.length > 0); && hasComposerContent;
const canOpenModelSettings = Boolean(modelNeedsSetup && onModelBadgeClick && !disabled);
const canQueueGuidance = const canQueueGuidance =
isStreaming isStreaming
&& !disabled && !disabled
&& !modelNeedsSetup
&& !encoding && !encoding
&& !hasErrors && !hasErrors
&& (value.trim().length > 0 || readyImages.length > 0) && hasComposerContent
&& !value.trimStart().startsWith("/"); && !value.trimStart().startsWith("/");
const slashQuery = useMemo(() => { const slashQuery = useMemo(() => {
@@ -1181,6 +1189,10 @@ export function ThreadComposer({
}, [onStop, queuedPrompts.length]); }, [onStop, queuedPrompts.length]);
const submit = useCallback(() => { const submit = useCallback(() => {
if (modelNeedsSetup) {
onModelBadgeClick?.();
return;
}
if (!canSend) return; if (!canSend) return;
const trimmed = value.trim(); const trimmed = value.trim();
const content = trimmed; const content = trimmed;
@@ -1219,6 +1231,8 @@ export function ThreadComposer({
canSend, canSend,
clear, clear,
clearComposerText, clearComposerText,
modelNeedsSetup,
onModelBadgeClick,
onSend, onSend,
readyImages, readyImages,
value, value,
@@ -1533,24 +1547,32 @@ export function ThreadComposer({
label={modelLabel} label={modelLabel}
provider={modelProvider} provider={modelProvider}
providerLabel={modelProviderLabel} providerLabel={modelProviderLabel}
needsSetup={modelNeedsSetup}
isHero={isHero} isHero={isHero}
onClick={modelNeedsSetup ? onModelBadgeClick : undefined}
/> />
) : null} ) : null}
<Button <Button
type={showStopButton ? "button" : "submit"} type={showStopButton || modelNeedsSetup ? "button" : "submit"}
size="icon" size="icon"
disabled={showStopButton ? disabled : !canSend} disabled={showStopButton ? disabled : !canSend && !canOpenModelSettings}
aria-label={showStopButton ? t("thread.composer.stop") : t("thread.composer.send")} aria-label={
onClick={showStopButton ? handleStop : undefined} showStopButton
? t("thread.composer.stop")
: modelNeedsSetup
? t("thread.composer.configureModel", { defaultValue: "Configure model" })
: t("thread.composer.send")
}
onClick={showStopButton ? handleStop : modelNeedsSetup ? onModelBadgeClick : undefined}
className={cn( className={cn(
"rounded-full transition-transform", "rounded-full transition-transform",
showStopButton showStopButton
? "border border-border/70 bg-card text-foreground/85 shadow-[0_3px_10px_rgba(15,23,42,0.08)] hover:bg-muted/65 hover:text-foreground disabled:text-muted-foreground/50" ? "border border-border/70 bg-card text-foreground/85 shadow-[0_3px_10px_rgba(15,23,42,0.08)] hover:bg-muted/65 hover:text-foreground disabled:text-muted-foreground/50"
: isHero : isHero
? "border border-foreground bg-foreground text-background shadow-[0_4px_12px_rgba(15,23,42,0.20)] hover:bg-foreground/90 disabled:border-foreground/35 disabled:bg-foreground/35 disabled:text-background/80" ? "border border-foreground bg-foreground text-background shadow-[0_4px_12px_rgba(15,23,42,0.20)] hover:bg-foreground/90 disabled:border-foreground disabled:bg-foreground disabled:text-background"
: "border border-foreground bg-foreground text-background shadow-[0_3px_10px_rgba(15,23,42,0.18)] hover:bg-foreground/90 disabled:border-foreground/35 disabled:bg-foreground/35 disabled:text-background/80", : "border border-foreground bg-foreground text-background shadow-[0_3px_10px_rgba(15,23,42,0.18)] hover:bg-foreground/90 disabled:border-foreground disabled:bg-foreground disabled:text-background",
isHero ? "h-8 w-8" : "h-9 w-9", isHero ? "h-8 w-8" : "h-9 w-9",
(canSend || showStopButton) && "hover:scale-[1.03] active:scale-95", (canSend || canOpenModelSettings || showStopButton) && "hover:scale-[1.03] active:scale-95",
)} )}
> >
{showStopButton ? ( {showStopButton ? (
@@ -1766,44 +1788,59 @@ function ComposerModelBadge({
label, label,
provider, provider,
providerLabel, providerLabel,
needsSetup,
isHero, isHero,
onClick,
}: { }: {
label: string; label: string;
provider?: string | null; provider?: string | null;
providerLabel?: string | null; providerLabel?: string | null;
needsSetup?: boolean;
isHero: boolean; isHero: boolean;
onClick?: () => void;
}) { }) {
const inferredProvider = provider || inferProviderFromModelName(label); const inferredProvider = needsSetup ? null : provider || inferProviderFromModelName(label);
const brand = providerBrand(inferredProvider); const brand = providerBrand(inferredProvider);
const [logoIndex, setLogoIndex] = useState(0); const [logoIndex, setLogoIndex] = useState(0);
const logoUrl = brand?.logoUrls[logoIndex]; const logoUrl = brand?.logoUrls[logoIndex];
const showLogo = !!logoUrl; const showLogo = !!logoUrl;
const title = providerLabel ? `${label} · ${providerLabel}` : label; const title = providerLabel ? `${label} · ${providerLabel}` : label;
const interactive = Boolean(onClick);
const Container = interactive ? "button" : "span";
useEffect(() => setLogoIndex(0), [inferredProvider]); useEffect(() => setLogoIndex(0), [inferredProvider]);
return ( return (
<span <Container
title={title} title={title}
type={interactive ? "button" : undefined}
onClick={onClick}
className={cn( className={cn(
"inline-flex min-w-0 items-center rounded-full border border-border/55 bg-card font-medium text-foreground/82", "inline-flex min-w-0 items-center rounded-full border border-border/55 bg-card font-medium text-foreground/82",
"shadow-[0_2px_8px_rgba(15,23,42,0.045)]", "shadow-[0_2px_8px_rgba(15,23,42,0.045)]",
interactive && "cursor-pointer hover:bg-accent/55 hover:text-foreground",
needsSetup && "border-amber-500/35 bg-amber-50/70 text-amber-900 dark:bg-amber-500/10 dark:text-amber-200",
isHero ? "h-8 max-w-[12.5rem] gap-1.5 px-2 text-[11.5px]" : "h-9 max-w-[12rem] gap-2 px-2.5 text-[12px]", isHero ? "h-8 max-w-[12.5rem] gap-1.5 px-2 text-[11.5px]" : "h-9 max-w-[12rem] gap-2 px-2.5 text-[12px]",
)} )}
> >
<span <span
data-testid={inferredProvider ? `composer-model-logo-${inferredProvider}` : "composer-model-logo"} data-testid={needsSetup ? "composer-model-setup-icon" : inferredProvider ? `composer-model-logo-${inferredProvider}` : "composer-model-logo"}
className={cn( className={cn(
"grid shrink-0 place-items-center overflow-hidden rounded-full border bg-background", "grid shrink-0 place-items-center overflow-hidden",
needsSetup
? "text-amber-800 dark:text-amber-200"
: "rounded-full border bg-background",
isHero ? "h-[18px] w-[18px]" : "h-5 w-5", isHero ? "h-[18px] w-[18px]" : "h-5 w-5",
)} )}
style={{ style={{
borderColor: brand ? `${brand.color}28` : undefined, borderColor: !needsSetup && brand ? `${brand.color}28` : undefined,
boxShadow: brand ? `inset 0 0 0 1px ${brand.color}18` : undefined, boxShadow: !needsSetup && brand ? `inset 0 0 0 1px ${brand.color}18` : undefined,
}} }}
aria-hidden aria-hidden
> >
{showLogo ? ( {needsSetup ? (
<CircleHelp className={cn(isHero ? "h-3 w-3" : "h-3.5 w-3.5")} strokeWidth={1.8} />
) : showLogo ? (
<img <img
src={logoUrl} src={logoUrl}
alt="" alt=""
@@ -1825,7 +1862,7 @@ function ComposerModelBadge({
)} )}
</span> </span>
<span className="truncate">{label}</span> <span className="truncate">{label}</span>
</span> </Container>
); );
} }
+8 -1
View File
@@ -10,6 +10,7 @@ interface ThreadHeaderProps {
theme: "light" | "dark"; theme: "light" | "dark";
onToggleTheme: () => void; onToggleTheme: () => void;
hideSidebarToggleForHostChrome?: boolean; hideSidebarToggleForHostChrome?: boolean;
hostChromeTitleInset?: boolean;
hideThemeButton?: boolean; hideThemeButton?: boolean;
minimal?: boolean; minimal?: boolean;
} }
@@ -20,6 +21,7 @@ export function ThreadHeader({
theme, theme,
onToggleTheme, onToggleTheme,
hideSidebarToggleForHostChrome = false, hideSidebarToggleForHostChrome = false,
hostChromeTitleInset = false,
hideThemeButton = false, hideThemeButton = false,
minimal = false, minimal = false,
}: ThreadHeaderProps) { }: ThreadHeaderProps) {
@@ -52,7 +54,12 @@ export function ThreadHeader({
} }
return ( return (
<div className="relative z-10 flex items-center justify-between gap-3 px-3 py-2"> <div
className={cn(
"relative z-10 flex items-center justify-between gap-3 px-3 py-2",
hostChromeTitleInset && "lg:pl-[128px]",
)}
>
<div className="relative flex min-w-0 items-center gap-2"> <div className="relative flex min-w-0 items-center gap-2">
<Button <Button
variant="ghost" variant="ghost"
+12 -3
View File
@@ -14,6 +14,7 @@ interface ThreadMessagesProps {
onLoadEarlier?: () => void; onLoadEarlier?: () => void;
cliApps?: CliAppInfo[]; cliApps?: CliAppInfo[];
mcpPresets?: McpPresetInfo[]; mcpPresets?: McpPresetInfo[];
onOpenFilePreview?: (path: string) => void;
} }
export type DisplayUnit = TurnUnit; export type DisplayUnit = TurnUnit;
@@ -33,8 +34,13 @@ export function isFinalAssistantSliceBeforeNextUser(
return true; return true;
} }
export function buildDisplayUnits(messages: UIMessage[]): DisplayUnit[] { export function buildDisplayUnits(
return normalizeActivityTimeline(messages); messages: UIMessage[],
isStreaming = false,
): DisplayUnit[] {
return normalizeActivityTimeline(messages, {
preserveTrailingActivity: isStreaming,
});
} }
export function assistantCopyFlags(units: DisplayUnit[]): boolean[] { export function assistantCopyFlags(units: DisplayUnit[]): boolean[] {
@@ -61,9 +67,10 @@ export function ThreadMessages({
onLoadEarlier, onLoadEarlier,
cliApps = [], cliApps = [],
mcpPresets = [], mcpPresets = [],
onOpenFilePreview,
}: ThreadMessagesProps) { }: ThreadMessagesProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const units = useMemo(() => buildDisplayUnits(messages), [messages]); const units = useMemo(() => buildDisplayUnits(messages, isStreaming), [isStreaming, messages]);
const copyFlags = useMemo(() => assistantCopyFlags(units), [units]); const copyFlags = useMemo(() => assistantCopyFlags(units), [units]);
const liveActivityClusterIndices = useMemo( const liveActivityClusterIndices = useMemo(
() => isStreaming ? currentActivityClusterIndices(units) : new Set<number>(), () => isStreaming ? currentActivityClusterIndices(units) : new Set<number>(),
@@ -117,6 +124,7 @@ export function ThreadMessages({
turnLatencyMs={unit.turnLatencyMs} turnLatencyMs={unit.turnLatencyMs}
cliApps={cliApps} cliApps={cliApps}
mcpPresets={mcpPresets} mcpPresets={mcpPresets}
onOpenFilePreview={onOpenFilePreview}
/> />
) : ( ) : (
<MessageBubble <MessageBubble
@@ -128,6 +136,7 @@ export function ThreadMessages({
} }
cliApps={cliApps} cliApps={cliApps}
mcpPresets={mcpPresets} mcpPresets={mcpPresets}
onOpenFilePreview={onOpenFilePreview}
/> />
)} )}
</div> </div>
+194 -25
View File
@@ -1,6 +1,8 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import type { PointerEvent as ReactPointerEvent } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { FilePreviewPanel } from "@/components/FilePreviewPanel";
import { ThreadComposer } from "@/components/thread/ThreadComposer"; import { ThreadComposer } from "@/components/thread/ThreadComposer";
import { ThreadHeader } from "@/components/thread/ThreadHeader"; import { ThreadHeader } from "@/components/thread/ThreadHeader";
import { StreamErrorNotice } from "@/components/thread/StreamErrorNotice"; import { StreamErrorNotice } from "@/components/thread/StreamErrorNotice";
@@ -51,6 +53,23 @@ function isStaleThreadSnapshot(current: UIMessage[], snapshot: UIMessage[]): boo
return snapshot.every((message, index) => sameMessageShape(current[index], message)); return snapshot.every((message, index) => sameMessageShape(current[index], message));
} }
const FILE_PREVIEW_DEFAULT_WIDTH = 544;
const FILE_PREVIEW_MIN_WIDTH = 360;
const FILE_PREVIEW_MAX_WIDTH = 860;
const FILE_PREVIEW_MIN_MAIN_WIDTH = 420;
const FILE_PREVIEW_CLOSE_ANIMATION_MS = 320;
function clampFilePreviewWidth(width: number, maxWidth: number): number {
return Math.min(Math.max(width, FILE_PREVIEW_MIN_WIDTH), maxWidth);
}
function maxFilePreviewWidth(containerWidth: number): number {
return Math.max(
FILE_PREVIEW_MIN_WIDTH,
Math.min(FILE_PREVIEW_MAX_WIDTH, containerWidth - FILE_PREVIEW_MIN_MAIN_WIDTH),
);
}
interface ThreadShellProps { interface ThreadShellProps {
session: ChatSummary | null; session: ChatSummary | null;
title: string; title: string;
@@ -62,6 +81,7 @@ interface ThreadShellProps {
theme?: "light" | "dark"; theme?: "light" | "dark";
onToggleTheme?: () => void; onToggleTheme?: () => void;
hideSidebarToggleForHostChrome?: boolean; hideSidebarToggleForHostChrome?: boolean;
hostChromeTitleInset?: boolean;
hideThemeButton?: boolean; hideThemeButton?: boolean;
hideHeader?: boolean; hideHeader?: boolean;
workspaceScope?: WorkspaceScopePayload | null; workspaceScope?: WorkspaceScopePayload | null;
@@ -71,6 +91,7 @@ interface ThreadShellProps {
workspaceError?: string | null; workspaceError?: string | null;
onWorkspaceScopeChange?: (scope: WorkspaceScopePayload) => void; onWorkspaceScopeChange?: (scope: WorkspaceScopePayload) => void;
settingsSnapshot?: SettingsPayload | null; settingsSnapshot?: SettingsPayload | null;
onOpenModelSettings?: () => void;
} }
function toModelBadgeLabel(modelName: string | null): string | null { function toModelBadgeLabel(modelName: string | null): string | null {
@@ -85,6 +106,7 @@ interface ModelBadgeInfo {
label: string | null; label: string | null;
provider: string | null; provider: string | null;
providerLabel: string | null; providerLabel: string | null;
needsSetup: boolean;
} }
function activeModelPreset(settings: SettingsPayload | null): SettingsPayload["model_presets"][number] | null { function activeModelPreset(settings: SettingsPayload | null): SettingsPayload["model_presets"][number] | null {
@@ -107,12 +129,20 @@ function resolvedModelProvider(settings: SettingsPayload | null, modelName: stri
} }
function toModelBadgeInfo(modelName: string | null, settings: SettingsPayload | null): ModelBadgeInfo { function toModelBadgeInfo(modelName: string | null, settings: SettingsPayload | null): ModelBadgeInfo {
const label = toModelBadgeLabel(modelName || settings?.agent.model || null); const model = modelName || settings?.agent.model || null;
const provider = resolvedModelProvider(settings, modelName || settings?.agent.model || null); const label = toModelBadgeLabel(model);
const provider = resolvedModelProvider(settings, model);
const providerRow = provider
? settings?.providers.find((item) => item.name === provider)
: null;
const needsSetup = Boolean(
settings && (!model || !provider || !providerRow || !providerRow.configured),
);
return { return {
label, label,
provider, provider,
providerLabel: provider ? providerDisplayLabel(settings?.providers ?? [], provider) : null, providerLabel: provider ? providerDisplayLabel(settings?.providers ?? [], provider) : null,
needsSetup,
}; };
} }
@@ -143,6 +173,7 @@ export function ThreadShell({
theme = "light", theme = "light",
onToggleTheme = () => {}, onToggleTheme = () => {},
hideSidebarToggleForHostChrome = false, hideSidebarToggleForHostChrome = false,
hostChromeTitleInset = false,
hideThemeButton = false, hideThemeButton = false,
hideHeader = false, hideHeader = false,
workspaceScope = null, workspaceScope = null,
@@ -152,6 +183,7 @@ export function ThreadShell({
workspaceError = null, workspaceError = null,
onWorkspaceScopeChange, onWorkspaceScopeChange,
settingsSnapshot = null, settingsSnapshot = null,
onOpenModelSettings,
}: ThreadShellProps) { }: ThreadShellProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const chatId = session?.chatId ?? null; const chatId = session?.chatId ?? null;
@@ -171,6 +203,12 @@ export function ThreadShell({
const [settings, setSettings] = useState<SettingsPayload | null>(settingsSnapshot); const [settings, setSettings] = useState<SettingsPayload | null>(settingsSnapshot);
const [heroGreetingKey, setHeroGreetingKey] = useState(randomHeroGreetingKey); const [heroGreetingKey, setHeroGreetingKey] = useState(randomHeroGreetingKey);
const [scrollToBottomSignal, setScrollToBottomSignal] = useState(0); const [scrollToBottomSignal, setScrollToBottomSignal] = useState(0);
const [filePreviewPath, setFilePreviewPath] = useState<string | null>(null);
const [filePreviewClosing, setFilePreviewClosing] = useState(false);
const [filePreviewWidth, setFilePreviewWidth] = useState(FILE_PREVIEW_DEFAULT_WIDTH);
const shellRef = useRef<HTMLElement | null>(null);
const filePreviewWidthRef = useRef(FILE_PREVIEW_DEFAULT_WIDTH);
const filePreviewCloseTimerRef = useRef<number | null>(null);
const pendingFirstRef = useRef<PendingFirstMessage | null>(null); const pendingFirstRef = useRef<PendingFirstMessage | null>(null);
const messageCacheRef = useRef<Map<string, UIMessage[]>>(new Map()); const messageCacheRef = useRef<Map<string, UIMessage[]>>(new Map());
/** Last chatId we associated with the in-memory thread (for cache-on-switch). */ /** Last chatId we associated with the in-memory thread (for cache-on-switch). */
@@ -204,6 +242,27 @@ export function ThreadShell({
if (chatId && historyKey) sessionKeyByChatIdRef.current.set(chatId, historyKey); if (chatId && historyKey) sessionKeyByChatIdRef.current.set(chatId, historyKey);
}, [chatId, historyKey]); }, [chatId, historyKey]);
useEffect(() => {
filePreviewWidthRef.current = filePreviewWidth;
}, [filePreviewWidth]);
useEffect(() => {
if (filePreviewCloseTimerRef.current !== null) {
window.clearTimeout(filePreviewCloseTimerRef.current);
filePreviewCloseTimerRef.current = null;
}
setFilePreviewClosing(false);
setFilePreviewPath(null);
}, [historyKey]);
useEffect(() => {
return () => {
if (filePreviewCloseTimerRef.current !== null) {
window.clearTimeout(filePreviewCloseTimerRef.current);
}
};
}, []);
const displayMessages = useMemo(() => projectWebuiThreadMessages(messages), [messages]); const displayMessages = useMemo(() => projectWebuiThreadMessages(messages), [messages]);
const showHeroComposer = messages.length === 0 && !loading; const showHeroComposer = messages.length === 0 && !loading;
@@ -212,6 +271,9 @@ export function ThreadShell({
() => toModelBadgeInfo(modelName, settings), () => toModelBadgeInfo(modelName, settings),
[modelName, settings], [modelName, settings],
); );
const modelBadgeLabel = modelBadge.needsSetup
? t("thread.composer.modelNotConfigured", { defaultValue: "Model not configured" })
: modelBadge.label;
useEffect(() => { useEffect(() => {
if (showHeroComposer && !wasShowingHeroComposerRef.current) { if (showHeroComposer && !wasShowingHeroComposerRef.current) {
setHeroGreetingKey(randomHeroGreetingKey()); setHeroGreetingKey(randomHeroGreetingKey());
@@ -482,6 +544,94 @@ export function ThreadShell({
[send, withWorkspaceScope], [send, withWorkspaceScope],
); );
const handleOpenFilePreview = useCallback((path: string) => {
if (filePreviewCloseTimerRef.current !== null) {
window.clearTimeout(filePreviewCloseTimerRef.current);
filePreviewCloseTimerRef.current = null;
}
setFilePreviewClosing(false);
setFilePreviewPath(path);
}, []);
const handleCloseFilePreview = useCallback(() => {
if (!filePreviewPath || filePreviewClosing) return;
setFilePreviewClosing(true);
filePreviewCloseTimerRef.current = window.setTimeout(() => {
filePreviewCloseTimerRef.current = null;
setFilePreviewPath(null);
setFilePreviewClosing(false);
}, FILE_PREVIEW_CLOSE_ANIMATION_MS);
}, [filePreviewClosing, filePreviewPath]);
const handleFilePreviewResizeStart = useCallback((event: ReactPointerEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
const panel = event.currentTarget.closest<HTMLElement>("[data-file-preview-panel]");
const shellRect = shellRef.current?.getBoundingClientRect();
const rightEdge = shellRect?.right ?? window.innerWidth;
const maxWidth = maxFilePreviewWidth(shellRect?.width ?? window.innerWidth);
const originalBodyCursor = document.body.style.cursor;
const originalBodyUserSelect = document.body.style.userSelect;
const originalPanelTransition = panel?.style.transition ?? "";
let nextWidth = filePreviewWidthRef.current;
let frame: number | null = null;
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
if (panel) panel.style.transition = "none";
const applyWidth = (clientX: number) => {
nextWidth = clampFilePreviewWidth(rightEdge - clientX, maxWidth);
filePreviewWidthRef.current = nextWidth;
if (frame !== null) return;
frame = window.requestAnimationFrame(() => {
frame = null;
panel?.style.setProperty("--file-preview-width", `${nextWidth}px`);
panel?.style.setProperty("--file-preview-slot-width", `${nextWidth}px`);
});
};
const handlePointerMove = (moveEvent: PointerEvent) => {
moveEvent.preventDefault();
applyWidth(moveEvent.clientX);
};
const handlePointerUp = () => {
if (frame !== null) {
window.cancelAnimationFrame(frame);
frame = null;
}
panel?.style.setProperty("--file-preview-width", `${nextWidth}px`);
panel?.style.setProperty("--file-preview-slot-width", `${nextWidth}px`);
if (panel) panel.style.transition = originalPanelTransition;
setFilePreviewWidth(nextWidth);
document.body.style.cursor = originalBodyCursor;
document.body.style.userSelect = originalBodyUserSelect;
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", handlePointerUp);
window.removeEventListener("pointercancel", handlePointerUp);
};
applyWidth(event.clientX);
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", handlePointerUp);
window.addEventListener("pointercancel", handlePointerUp);
}, []);
useEffect(() => {
if (!filePreviewPath) return;
const clampToShell = () => {
const shellWidth = shellRef.current?.getBoundingClientRect().width ?? window.innerWidth;
const maxWidth = maxFilePreviewWidth(shellWidth);
const nextWidth = clampFilePreviewWidth(filePreviewWidthRef.current, maxWidth);
filePreviewWidthRef.current = nextWidth;
setFilePreviewWidth(nextWidth);
};
clampToShell();
window.addEventListener("resize", clampToShell);
return () => {
window.removeEventListener("resize", clampToShell);
};
}, [filePreviewPath]);
const composer = ( const composer = (
<> <>
{streamError ? ( {streamError ? (
@@ -500,9 +650,11 @@ export function ThreadShell({
? t("thread.composer.placeholderHero") ? t("thread.composer.placeholderHero")
: t("thread.composer.placeholderThread") : t("thread.composer.placeholderThread")
} }
modelLabel={modelBadge.label} modelLabel={modelBadgeLabel}
modelProvider={modelBadge.provider} modelProvider={modelBadge.provider}
modelProviderLabel={modelBadge.providerLabel} modelProviderLabel={modelBadge.providerLabel}
modelNeedsSetup={modelBadge.needsSetup}
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
variant={showHeroComposer ? "hero" : "thread"} variant={showHeroComposer ? "hero" : "thread"}
slashCommands={slashCommands} slashCommands={slashCommands}
cliApps={cliApps} cliApps={cliApps}
@@ -528,9 +680,11 @@ export function ThreadShell({
? t("thread.composer.placeholderOpening") ? t("thread.composer.placeholderOpening")
: t("thread.composer.placeholderHero") : t("thread.composer.placeholderHero")
} }
modelLabel={modelBadge.label} modelLabel={modelBadgeLabel}
modelProvider={modelBadge.provider} modelProvider={modelBadge.provider}
modelProviderLabel={modelBadge.providerLabel} modelProviderLabel={modelBadge.providerLabel}
modelNeedsSetup={modelBadge.needsSetup}
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
variant="hero" variant="hero"
slashCommands={slashCommands} slashCommands={slashCommands}
cliApps={cliApps} cliApps={cliApps}
@@ -561,29 +715,44 @@ export function ThreadShell({
); );
return ( return (
<section className="relative flex min-h-0 flex-1 flex-col overflow-hidden"> <section ref={shellRef} className="relative flex min-h-0 flex-1 overflow-hidden">
{!hideHeader ? ( <div className="relative flex min-w-0 flex-1 flex-col overflow-hidden">
<ThreadHeader {!hideHeader ? (
title={title} <ThreadHeader
onToggleSidebar={onToggleSidebar} title={title}
theme={theme} onToggleSidebar={onToggleSidebar}
onToggleTheme={onToggleTheme} theme={theme}
hideSidebarToggleForHostChrome={hideSidebarToggleForHostChrome} onToggleTheme={onToggleTheme}
hideThemeButton={hideThemeButton} hideSidebarToggleForHostChrome={hideSidebarToggleForHostChrome}
minimal={!session && !loading} hostChromeTitleInset={hostChromeTitleInset}
hideThemeButton={hideThemeButton}
minimal={!session && !loading}
/>
) : null}
<ThreadViewport
messages={displayMessages}
isStreaming={isStreaming}
emptyState={emptyState}
composer={composer}
scrollToBottomSignal={scrollToBottomSignal}
conversationKey={historyKey}
showScrollToBottomButton={!!session}
cliApps={cliApps}
mcpPresets={mcpPresets}
onOpenFilePreview={historyKey ? handleOpenFilePreview : undefined}
/>
</div>
{filePreviewPath && historyKey ? (
<FilePreviewPanel
sessionKey={historyKey}
path={filePreviewPath}
token={token}
desktopWidth={filePreviewWidth}
isClosing={filePreviewClosing}
onResizeStart={handleFilePreviewResizeStart}
onClose={handleCloseFilePreview}
/> />
) : null} ) : null}
<ThreadViewport
messages={displayMessages}
isStreaming={isStreaming}
emptyState={emptyState}
composer={composer}
scrollToBottomSignal={scrollToBottomSignal}
conversationKey={historyKey}
showScrollToBottomButton={!!session}
cliApps={cliApps}
mcpPresets={mcpPresets}
/>
</section> </section>
); );
} }
@@ -27,6 +27,7 @@ interface ThreadViewportProps {
showScrollToBottomButton?: boolean; showScrollToBottomButton?: boolean;
cliApps?: CliAppInfo[]; cliApps?: CliAppInfo[];
mcpPresets?: McpPresetInfo[]; mcpPresets?: McpPresetInfo[];
onOpenFilePreview?: (path: string) => void;
} }
const NEAR_BOTTOM_PX = 48; const NEAR_BOTTOM_PX = 48;
@@ -58,6 +59,7 @@ export function ThreadViewport({
showScrollToBottomButton = true, showScrollToBottomButton = true,
cliApps = [], cliApps = [],
mcpPresets = [], mcpPresets = [],
onOpenFilePreview,
}: ThreadViewportProps) { }: ThreadViewportProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
@@ -256,6 +258,7 @@ export function ThreadViewport({
onLoadEarlier={loadEarlierMessages} onLoadEarlier={loadEarlierMessages}
cliApps={cliApps} cliApps={cliApps}
mcpPresets={mcpPresets} mcpPresets={mcpPresets}
onOpenFilePreview={onOpenFilePreview}
/> />
</div> </div>
</div> </div>
@@ -106,7 +106,7 @@ export function WorkspaceProjectPicker({
if (nativeProjectPicker) { if (nativeProjectPicker) {
return ( return (
<div className="flex items-center border-t border-border/25 bg-muted/60 px-4 py-1.5 dark:bg-white/[0.055]"> <div className="flex items-center rounded-b-[28px] border-t border-border/25 bg-muted/60 px-4 py-1.5 dark:bg-white/[0.055]">
<button <button
type="button" type="button"
disabled={disabled || pickingFolder} disabled={disabled || pickingFolder}
@@ -133,7 +133,7 @@ export function WorkspaceProjectPicker({
} }
return ( return (
<div className="flex items-center border-t border-border/25 bg-muted/60 px-4 py-1.5 dark:bg-white/[0.055]"> <div className="flex items-center rounded-b-[28px] border-t border-border/25 bg-muted/60 px-4 py-1.5 dark:bg-white/[0.055]">
<DropdownMenu open={open} onOpenChange={setOpen}> <DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<button <button
@@ -22,18 +22,34 @@ export interface FileEditSummary {
error?: string; error?: string;
} }
export function FileEditGroup({ edits }: { edits: FileEditSummary[] }) { export function FileEditGroup({
edits,
onOpenFilePreview,
}: {
edits: FileEditSummary[];
onOpenFilePreview?: (path: string) => void;
}) {
if (edits.length === 0) return null; if (edits.length === 0) return null;
return ( return (
<ul className="space-y-1"> <ul className="space-y-1">
{edits.map((edit) => ( {edits.map((edit) => (
<FileEditRow key={edit.key} edit={edit} /> <FileEditRow
key={edit.key}
edit={edit}
onOpenFilePreview={onOpenFilePreview}
/>
))} ))}
</ul> </ul>
); );
} }
function FileEditRow({ edit }: { edit: FileEditSummary }) { function FileEditRow({
edit,
onOpenFilePreview,
}: {
edit: FileEditSummary;
onOpenFilePreview?: (path: string) => void;
}) {
const { t } = useTranslation(); const { t } = useTranslation();
const editing = edit.status === "editing"; const editing = edit.status === "editing";
const failed = edit.status === "error"; const failed = edit.status === "error";
@@ -76,6 +92,8 @@ function FileEditRow({ edit }: { edit: FileEditSummary }) {
<FileReferenceChip <FileReferenceChip
path={edit.path} path={edit.path}
tooltipPath={edit.absolute_path} tooltipPath={edit.absolute_path}
previewPath={edit.absolute_path || edit.path}
onOpen={onOpenFilePreview}
display="path" display="path"
active={editing} active={editing}
className="min-w-0" className="min-w-0"
@@ -10,9 +10,11 @@ import { ActivityStep } from "./ActivityStep";
export function ReasoningRow({ export function ReasoningRow({
text, text,
streaming, streaming,
onOpenFilePreview,
}: { }: {
text: string; text: string;
streaming: boolean; streaming: boolean;
onOpenFilePreview?: (path: string) => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
useEffect(() => { useEffect(() => {
@@ -30,6 +32,7 @@ export function ReasoningRow({
{text.trim() ? ( {text.trim() ? (
<MarkdownText <MarkdownText
streaming={streaming} streaming={streaming}
onOpenFilePreview={onOpenFilePreview}
className={cn( className={cn(
"min-w-0 text-[12.5px] italic text-muted-foreground/78", "min-w-0 text-[12.5px] italic text-muted-foreground/78",
"prose-p:my-1 prose-li:my-0.5", "prose-p:my-1 prose-li:my-0.5",
+8 -4
View File
@@ -121,10 +121,13 @@
} }
.host-sidebar-glass { .host-sidebar-glass {
background: hsl(var(--sidebar) / 0.94);
-webkit-backdrop-filter: saturate(145%) blur(18px);
backdrop-filter: saturate(145%) blur(18px);
box-shadow: box-shadow:
inset -1px 0 0 hsl(var(--border) / 0.36), inset -1px 0 0 hsl(var(--border) / 0.32),
inset 1px 0 0 hsl(var(--background) / 0.34), inset 1px 0 0 hsl(var(--background) / 0.52),
18px 0 44px -42px rgb(0 0 0 / 0.42); 14px 0 32px -30px rgb(0 0 0 / 0.22);
} }
.dark .host-window-shell, .dark .host-window-shell,
@@ -135,10 +138,11 @@
} }
.dark .host-sidebar-glass { .dark .host-sidebar-glass {
background: hsl(var(--sidebar) / 0.96);
box-shadow: box-shadow:
inset -1px 0 0 hsl(var(--border) / 0.42), inset -1px 0 0 hsl(var(--border) / 0.42),
inset 1px 0 0 hsl(var(--foreground) / 0.05), inset 1px 0 0 hsl(var(--foreground) / 0.05),
18px 0 46px -42px rgb(0 0 0 / 0.72); 14px 0 34px -30px rgb(0 0 0 / 0.62);
} }
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) { @supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
+117 -38
View File
@@ -20,6 +20,7 @@ import type {
UIImage, UIImage,
UIFileEdit, UIFileEdit,
UIMessage, UIMessage,
UITurnPhase,
WorkspaceScopePayload, WorkspaceScopePayload,
} from "@/lib/types"; } from "@/lib/types";
@@ -34,22 +35,50 @@ interface ActiveAssistantCursor {
} }
type PendingStreamEvent = type PendingStreamEvent =
| { kind: "delta"; text: string } | { kind: "delta"; text: string; turn: UIMessageTurnFields }
| { kind: "reasoning"; text: string }; | { kind: "reasoning"; text: string; turn: UIMessageTurnFields };
type UIMessageTurnFields = Pick<UIMessage, "turnId" | "turnPhase" | "turnSeq">;
const FILE_EDIT_TOOL_NAMES = new Set(["write_file", "edit_file", "apply_patch"]); const FILE_EDIT_TOOL_NAMES = new Set(["write_file", "edit_file", "apply_patch"]);
function turnFieldsFromEvent(
ev: { turn_id?: string; turn_phase?: UITurnPhase; turn_seq?: number },
fallbackPhase?: UITurnPhase,
): UIMessageTurnFields {
const fields: UIMessageTurnFields = {};
if (typeof ev.turn_id === "string" && ev.turn_id.length > 0) {
fields.turnId = ev.turn_id;
}
const phase = ev.turn_phase ?? fallbackPhase;
if (phase) fields.turnPhase = phase;
if (typeof ev.turn_seq === "number" && Number.isFinite(ev.turn_seq)) {
fields.turnSeq = ev.turn_seq;
}
return fields;
}
function matchesTurn(message: UIMessage, turn: UIMessageTurnFields): boolean {
return !turn.turnId || !message.turnId || message.turnId === turn.turnId;
}
/** Find a still-open streamed assistant turn. Closed stream segments stay visible /** Find a still-open streamed assistant turn. Closed stream segments stay visible
* as streaming until ``turn_end`` for visual continuity, but they must not * as streaming until ``turn_end`` for visual continuity, but they must not
* receive later delta segments. */ * receive later delta segments. */
function findStreamingAssistantIndex( function findStreamingAssistantIndex(
prev: UIMessage[], prev: UIMessage[],
closedStreamIds: ReadonlySet<string>, closedStreamIds: ReadonlySet<string>,
turn: UIMessageTurnFields = {},
): number | null { ): number | null {
for (let i = prev.length - 1; i >= 0; i -= 1) { for (let i = prev.length - 1; i >= 0; i -= 1) {
const m = prev[i]; const m = prev[i];
if (m.kind === "trace") continue; if (m.kind === "trace") continue;
if (m.role === "assistant" && m.isStreaming && !closedStreamIds.has(m.id)) return i; if (
m.role === "assistant"
&& m.isStreaming
&& !closedStreamIds.has(m.id)
&& matchesTurn(m, turn)
) return i;
if (m.role === "user") break; if (m.role === "user") break;
} }
return null; return null;
@@ -69,6 +98,7 @@ function attachReasoningChunk(
segments?: { segments?: {
ensure: () => string; ensure: () => string;
}, },
turn: UIMessageTurnFields = {},
): UIMessage[] { ): UIMessage[] {
for (let i = prev.length - 1; i >= 0; i -= 1) { for (let i = prev.length - 1; i >= 0; i -= 1) {
const candidate = prev[i]; const candidate = prev[i];
@@ -80,6 +110,7 @@ function attachReasoningChunk(
// that produced those tool calls. // that produced those tool calls.
if (candidate.kind === "trace") break; if (candidate.kind === "trace") break;
if (candidate.role !== "assistant") continue; if (candidate.role !== "assistant") continue;
if (!matchesTurn(candidate, turn)) break;
const activitySegmentId = candidate.activitySegmentId ?? segments?.ensure(); const activitySegmentId = candidate.activitySegmentId ?? segments?.ensure();
const hasAnswer = candidate.content.length > 0; const hasAnswer = candidate.content.length > 0;
if (hasAnswer) break; if (hasAnswer) break;
@@ -93,6 +124,7 @@ function attachReasoningChunk(
reasoning: (candidate.reasoning ?? "") + chunk, reasoning: (candidate.reasoning ?? "") + chunk,
reasoningStreaming: true, reasoningStreaming: true,
...(activitySegmentId ? { activitySegmentId } : {}), ...(activitySegmentId ? { activitySegmentId } : {}),
...turn,
}; };
return [...prev.slice(0, i), merged, ...prev.slice(i + 1)]; return [...prev.slice(0, i), merged, ...prev.slice(i + 1)];
} }
@@ -109,6 +141,7 @@ function attachReasoningChunk(
reasoning: chunk, reasoning: chunk,
reasoningStreaming: true, reasoningStreaming: true,
...(activitySegmentId ? { activitySegmentId } : {}), ...(activitySegmentId ? { activitySegmentId } : {}),
...turn,
createdAt: Date.now(), createdAt: Date.now(),
}, },
]; ];
@@ -122,12 +155,16 @@ function attachReasoningChunk(
* the model already produced an answer in a previous turn, so the new * the model already produced an answer in a previous turn, so the new
* delta belongs in a fresh row. * delta belongs in a fresh row.
*/ */
function findActiveAssistantPlaceholderIndex(prev: UIMessage[]): number | null { function findActiveAssistantPlaceholderIndex(
prev: UIMessage[],
turn: UIMessageTurnFields = {},
): number | null {
const last = prev[prev.length - 1]; const last = prev[prev.length - 1];
if (!last) return null; if (!last) return null;
if (last.role !== "assistant" || last.kind === "trace") return null; if (last.role !== "assistant" || last.kind === "trace") return null;
if (last.content.length > 0) return null; if (last.content.length > 0) return null;
if (!last.isStreaming) return null; if (!last.isStreaming) return null;
if (!matchesTurn(last, turn)) return null;
return prev.length - 1; return prev.length - 1;
} }
@@ -187,10 +224,18 @@ function pruneReasoningOnlyPlaceholders(prev: UIMessage[]): UIMessage[] {
}); });
} }
function stampLastAssistantLatency(prev: UIMessage[], latencyMs: number): UIMessage[] { function stampLastAssistantLatency(
prev: UIMessage[],
latencyMs: number,
turnId?: string,
): UIMessage[] {
for (let i = prev.length - 1; i >= 0; i -= 1) { for (let i = prev.length - 1; i >= 0; i -= 1) {
const m = prev[i]; const m = prev[i];
if (m.role === "assistant" && m.kind !== "trace") { if (
m.role === "assistant"
&& m.kind !== "trace"
&& (!turnId || !m.turnId || m.turnId === turnId)
) {
const merged: UIMessage = { ...m, latencyMs, isStreaming: false }; const merged: UIMessage = { ...m, latencyMs, isStreaming: false };
return [...prev.slice(0, i), merged, ...prev.slice(i + 1)]; return [...prev.slice(0, i), merged, ...prev.slice(i + 1)];
} }
@@ -203,7 +248,7 @@ function absorbCompleteAssistantMessage(
message: Omit<UIMessage, "id" | "role" | "createdAt">, message: Omit<UIMessage, "id" | "role" | "createdAt">,
): UIMessage[] { ): UIMessage[] {
const last = prev[prev.length - 1]; const last = prev[prev.length - 1];
if (!last || !isReasoningOnlyPlaceholder(last)) { if (!last || !isReasoningOnlyPlaceholder(last) || !matchesTurn(last, message)) {
return [ return [
...prev, ...prev,
{ {
@@ -482,7 +527,10 @@ export function useNanobotStream(
return !!closedStreamId; return !!closedStreamId;
}, []); }, []);
const resolveActiveAssistantIndex = useCallback((prev: UIMessage[]): number | null => { const resolveActiveAssistantIndex = useCallback((
prev: UIMessage[],
turn: UIMessageTurnFields = {},
): number | null => {
const cursor = activeAssistantRef.current; const cursor = activeAssistantRef.current;
if (!cursor) return null; if (!cursor) return null;
const indexed = prev[cursor.index]; const indexed = prev[cursor.index];
@@ -491,6 +539,7 @@ export function useNanobotStream(
&& indexed.role === "assistant" && indexed.role === "assistant"
&& indexed.kind !== "trace" && indexed.kind !== "trace"
&& indexed.isStreaming && indexed.isStreaming
&& matchesTurn(indexed, turn)
) { ) {
return cursor.index; return cursor.index;
} }
@@ -500,7 +549,12 @@ export function useNanobotStream(
return null; return null;
} }
const found = prev[idx]; const found = prev[idx];
if (found.role !== "assistant" || found.kind === "trace" || !found.isStreaming) { if (
found.role !== "assistant"
|| found.kind === "trace"
|| !found.isStreaming
|| !matchesTurn(found, turn)
) {
activeAssistantRef.current = null; activeAssistantRef.current = null;
return null; return null;
} }
@@ -509,15 +563,15 @@ export function useNanobotStream(
}, []); }, []);
const appendAnswerChunk = useCallback( const appendAnswerChunk = useCallback(
(prev: UIMessage[], chunk: string): UIMessage[] => { (prev: UIMessage[], chunk: string, turn: UIMessageTurnFields = {}): UIMessage[] => {
let next = prev; let next = prev;
let targetIndex = resolveActiveAssistantIndex(next); let targetIndex = resolveActiveAssistantIndex(next, turn);
if (targetIndex === null) { if (targetIndex === null) {
targetIndex = findActiveAssistantPlaceholderIndex(next); targetIndex = findActiveAssistantPlaceholderIndex(next, turn);
} }
if (targetIndex === null) { if (targetIndex === null) {
targetIndex = findStreamingAssistantIndex(next, closedAssistantStreamIdsRef.current); targetIndex = findStreamingAssistantIndex(next, closedAssistantStreamIdsRef.current, turn);
} }
if (targetIndex === null) { if (targetIndex === null) {
const id = crypto.randomUUID(); const id = crypto.randomUUID();
@@ -539,6 +593,7 @@ export function useNanobotStream(
...target, ...target,
content: target.content + chunk, content: target.content + chunk,
isStreaming: true, isStreaming: true,
...turn,
}; };
closedAssistantStreamIdsRef.current.delete(merged.id); closedAssistantStreamIdsRef.current.delete(merged.id);
activeAssistantRef.current = { id: merged.id, index: targetIndex }; activeAssistantRef.current = { id: merged.id, index: targetIndex };
@@ -551,20 +606,17 @@ export function useNanobotStream(
const applyPendingStreamEvents = useCallback( const applyPendingStreamEvents = useCallback(
(prev: UIMessage[], events: PendingStreamEvent[]): UIMessage[] => { (prev: UIMessage[], events: PendingStreamEvent[]): UIMessage[] => {
let next = prev; let next = prev;
for (let i = 0; i < events.length;) { for (const event of events) {
const kind = events[i].kind; if (event.kind === "delta") {
let text = ""; next = appendAnswerChunk(next, event.text, event.turn);
while (i < events.length && events[i].kind === kind) {
text += events[i].text;
i += 1;
}
if (kind === "delta") {
next = appendAnswerChunk(next, text);
} else { } else {
if (closeActiveAssistantStream()) clearActivitySegment(); if (closeActiveAssistantStream()) clearActivitySegment();
next = attachReasoningChunk(next, text, { next = attachReasoningChunk(
ensure: ensureActivitySegmentId, next,
}); event.text,
{ ensure: ensureActivitySegmentId },
event.turn,
);
} }
} }
return next; return next;
@@ -575,6 +627,7 @@ export function useNanobotStream(
const flushPendingStreamEvents = useCallback((options?: { const flushPendingStreamEvents = useCallback((options?: {
closeAnswerSegment?: boolean; closeAnswerSegment?: boolean;
finalAnswerText?: string; finalAnswerText?: string;
turn?: UIMessageTurnFields;
}) => { }) => {
if (streamFrameRef.current !== null) { if (streamFrameRef.current !== null) {
window.cancelAnimationFrame(streamFrameRef.current); window.cancelAnimationFrame(streamFrameRef.current);
@@ -582,6 +635,7 @@ export function useNanobotStream(
} }
const events = pendingStreamEventsRef.current; const events = pendingStreamEventsRef.current;
const finalAnswerText = options?.finalAnswerText; const finalAnswerText = options?.finalAnswerText;
const turn = options?.turn ?? {};
if (events.length === 0 && finalAnswerText === undefined) { if (events.length === 0 && finalAnswerText === undefined) {
if (options?.closeAnswerSegment) closeActiveAssistantStream(); if (options?.closeAnswerSegment) closeActiveAssistantStream();
return; return;
@@ -591,14 +645,15 @@ export function useNanobotStream(
let next = events.length > 0 ? applyPendingStreamEvents(prev, events) : prev; let next = events.length > 0 ? applyPendingStreamEvents(prev, events) : prev;
if (finalAnswerText !== undefined) { if (finalAnswerText !== undefined) {
const targetIndex = const targetIndex =
resolveActiveAssistantIndex(next) resolveActiveAssistantIndex(next, turn)
?? findStreamingAssistantIndex(next, closedAssistantStreamIdsRef.current); ?? findStreamingAssistantIndex(next, closedAssistantStreamIdsRef.current, turn);
if (targetIndex !== null) { if (targetIndex !== null) {
const target = next[targetIndex]; const target = next[targetIndex];
next = replaceMessageAt(next, targetIndex, { next = replaceMessageAt(next, targetIndex, {
...target, ...target,
content: finalAnswerText, content: finalAnswerText,
isStreaming: true, isStreaming: true,
...turn,
}); });
} else { } else {
const id = crypto.randomUUID(); const id = crypto.randomUUID();
@@ -610,6 +665,7 @@ export function useNanobotStream(
role: "assistant", role: "assistant",
content: finalAnswerText, content: finalAnswerText,
isStreaming: true, isStreaming: true,
...turn,
createdAt: Date.now(), createdAt: Date.now(),
}, },
]; ];
@@ -679,7 +735,11 @@ export function useNanobotStream(
if (!chunk) return; if (!chunk) return;
clearActivitySegment(); clearActivitySegment();
setIsStreaming(true); setIsStreaming(true);
pendingStreamEventsRef.current.push({ kind: "delta", text: chunk }); pendingStreamEventsRef.current.push({
kind: "delta",
text: chunk,
turn: turnFieldsFromEvent(ev, "answer"),
});
schedulePendingStreamFlush(); schedulePendingStreamFlush();
return; return;
} }
@@ -690,7 +750,11 @@ export function useNanobotStream(
if (!chunk) return; if (!chunk) return;
if (fileEditSegmentRef.current) clearActivitySegment(); if (fileEditSegmentRef.current) clearActivitySegment();
setIsStreaming(true); setIsStreaming(true);
pendingStreamEventsRef.current.push({ kind: "reasoning", text: chunk }); pendingStreamEventsRef.current.push({
kind: "reasoning",
text: chunk,
turn: turnFieldsFromEvent(ev, "reasoning"),
});
schedulePendingStreamFlush(); schedulePendingStreamFlush();
return; return;
} }
@@ -699,6 +763,7 @@ export function useNanobotStream(
flushPendingStreamEvents({ flushPendingStreamEvents({
closeAnswerSegment: true, closeAnswerSegment: true,
...(typeof ev.text === "string" ? { finalAnswerText: ev.text } : {}), ...(typeof ev.text === "string" ? { finalAnswerText: ev.text } : {}),
turn: turnFieldsFromEvent(ev, "answer"),
}); });
if (suppressStreamUntilTurnEndRef.current) return; if (suppressStreamUntilTurnEndRef.current) return;
// stream_end only means the text segment finished — the model may // stream_end only means the text segment finished — the model may
@@ -751,7 +816,11 @@ export function useNanobotStream(
let finalized = prev.map((m) => (m.isStreaming ? { ...m, isStreaming: false } : m)); let finalized = prev.map((m) => (m.isStreaming ? { ...m, isStreaming: false } : m));
finalized = pruneReasoningOnlyPlaceholders(finalized); finalized = pruneReasoningOnlyPlaceholders(finalized);
if (typeof ev.latency_ms === "number" && ev.latency_ms >= 0) { if (typeof ev.latency_ms === "number" && ev.latency_ms >= 0) {
finalized = stampLastAssistantLatency(finalized, Math.round(ev.latency_ms)); finalized = stampLastAssistantLatency(
finalized,
Math.round(ev.latency_ms),
ev.turn_id,
);
} }
buffer.current = null; buffer.current = null;
activeAssistantRef.current = null; activeAssistantRef.current = null;
@@ -778,9 +847,12 @@ export function useNanobotStream(
const line = ev.text; const line = ev.text;
if (!line) return; if (!line) return;
if (fileEditSegmentRef.current) clearActivitySegment(); if (fileEditSegmentRef.current) clearActivitySegment();
setMessages((prev) => closeReasoningStream(attachReasoningChunk(prev, line, { setMessages((prev) => closeReasoningStream(attachReasoningChunk(
ensure: ensureActivitySegmentId, prev,
}))); line,
{ ensure: ensureActivitySegmentId },
turnFieldsFromEvent(ev, "reasoning"),
)));
return; return;
} }
// Intermediate agent breadcrumbs (tool-call hints, raw progress). // Intermediate agent breadcrumbs (tool-call hints, raw progress).
@@ -788,6 +860,7 @@ export function useNanobotStream(
// so a sequence of calls collapses into one compact trace group. // so a sequence of calls collapses into one compact trace group.
if (ev.kind === "tool_hint" || ev.kind === "progress") { if (ev.kind === "tool_hint" || ev.kind === "progress") {
const structuredEvents = normalizeToolProgressEvents(ev.tool_events); const structuredEvents = normalizeToolProgressEvents(ev.tool_events);
const turn = turnFieldsFromEvent(ev, "activity");
setMessages((prev) => { setMessages((prev) => {
const segmentId = ensureActivitySegmentId(); const segmentId = ensureActivitySegmentId();
const base = prev; const base = prev;
@@ -826,6 +899,7 @@ export function useNanobotStream(
? mergeToolProgressEvents(last.toolEvents, visibleStructuredEvents) ? mergeToolProgressEvents(last.toolEvents, visibleStructuredEvents)
: last.toolEvents, : last.toolEvents,
activitySegmentId: last.activitySegmentId ?? segmentId, activitySegmentId: last.activitySegmentId ?? segmentId,
...turn,
}; };
return [...base.slice(0, -1), merged]; return [...base.slice(0, -1), merged];
} }
@@ -839,6 +913,7 @@ export function useNanobotStream(
traces: lines, traces: lines,
...(visibleStructuredEvents.length ? { toolEvents: visibleStructuredEvents } : {}), ...(visibleStructuredEvents.length ? { toolEvents: visibleStructuredEvents } : {}),
activitySegmentId: segmentId, activitySegmentId: segmentId,
...turn,
createdAt: Date.now(), createdAt: Date.now(),
}, },
]; ];
@@ -870,6 +945,7 @@ export function useNanobotStream(
content, content,
...(hasMedia ? { media } : {}), ...(hasMedia ? { media } : {}),
...(lat !== undefined ? { latencyMs: lat } : {}), ...(lat !== undefined ? { latencyMs: lat } : {}),
...turnFieldsFromEvent(ev, "answer"),
}); });
}); });
if (hasMedia) { if (hasMedia) {
@@ -882,6 +958,7 @@ export function useNanobotStream(
if (edits.length === 0) return; if (edits.length === 0) return;
const normalized = mergeFileEdits(undefined, edits); const normalized = mergeFileEdits(undefined, edits);
if (normalized.length === 0) return; if (normalized.length === 0) return;
const turn = turnFieldsFromEvent(ev, "activity");
const opensFileEditPhase = normalized.some( const opensFileEditPhase = normalized.some(
(edit) => edit.status === "editing" || edit.phase === "start", (edit) => edit.status === "editing" || edit.phase === "start",
); );
@@ -903,6 +980,7 @@ export function useNanobotStream(
...cleanedTarget, ...cleanedTarget,
fileEdits: mergeFileEdits(cleanedTarget.fileEdits, normalized), fileEdits: mergeFileEdits(cleanedTarget.fileEdits, normalized),
activitySegmentId: segmentId, activitySegmentId: segmentId,
...turn,
}; };
return replaceMessageAt(base, targetIndex, merged); return replaceMessageAt(base, targetIndex, merged);
} }
@@ -918,6 +996,7 @@ export function useNanobotStream(
traces: [], traces: [],
fileEdits: normalized, fileEdits: normalized,
activitySegmentId: segmentId, activitySegmentId: segmentId,
...turn,
createdAt: Date.now(), createdAt: Date.now(),
}, },
]; ];
@@ -962,6 +1041,7 @@ export function useNanobotStream(
if (!hasImages && !content.trim()) return; if (!hasImages && !content.trim()) return;
flushPendingStreamEvents(); flushPendingStreamEvents();
const turnId = crypto.randomUUID();
const previews = hasImages ? images!.map((i) => i.preview) : undefined; const previews = hasImages ? images!.map((i) => i.preview) : undefined;
setMessages((prev) => { setMessages((prev) => {
buffer.current = null; buffer.current = null;
@@ -974,6 +1054,9 @@ export function useNanobotStream(
id: crypto.randomUUID(), id: crypto.randomUUID(),
role: "user", role: "user",
content, content,
turnId,
turnPhase: "user",
turnSeq: 0,
createdAt: Date.now(), createdAt: Date.now(),
...(previews ? { images: previews } : {}), ...(previews ? { images: previews } : {}),
...(options?.cliApps?.length ? { cliApps: options.cliApps } : {}), ...(options?.cliApps?.length ? { cliApps: options.cliApps } : {}),
@@ -985,11 +1068,7 @@ export function useNanobotStream(
// right away, before the first delta arrives from the server. // right away, before the first delta arrives from the server.
setIsStreaming(true); setIsStreaming(true);
const wireMedia = hasImages ? images!.map((i) => i.media) : undefined; const wireMedia = hasImages ? images!.map((i) => i.media) : undefined;
if (options) { client.sendMessage(chatId, content, wireMedia, { ...options, turnId });
client.sendMessage(chatId, content, wireMedia, options);
} else {
client.sendMessage(chatId, content, wireMedia);
}
}, },
[chatId, clearActivitySegment, client, flushPendingStreamEvents], [chatId, clearActivitySegment, client, flushPendingStreamEvents],
); );
+39
View File
@@ -295,6 +295,9 @@
"disabled": "Disabled", "disabled": "Disabled",
"restartPending": "Restart pending", "restartPending": "Restart pending",
"ready": "Ready", "ready": "Ready",
"privateEngine": "Private engine",
"unixSocket": "Unix socket",
"defaultWorkspace": "Default workspace",
"comfortable": "Comfortable", "comfortable": "Comfortable",
"compact": "Compact", "compact": "Compact",
"auto": "Auto", "auto": "Auto",
@@ -386,6 +389,31 @@
"imageGeneration": "Image generation", "imageGeneration": "Image generation",
"workspace": "Workspace" "workspace": "Workspace"
}, },
"usage": {
"title": "Token activity",
"shortTitle": "Token Usage",
"subtitle": "Provider-reported usage over the last 12 months.",
"empty": "Token activity will appear after new model replies.",
"totalTokens": "Total tokens",
"peakTokens": "Peak tokens",
"thirtyDayTokens": "30-day tokens",
"currentStreak": "Current streak",
"longestStreak": "Longest streak",
"daysValue": "{{count}}d",
"last30": "30 days",
"activeDays": "Active days",
"requests": "Requests",
"estimated": "estimated",
"includesEstimates": "includes estimates",
"cellTitle": "{{date}}: {{tokens}} tokens, {{requests}} requests",
"sources": {
"user": "Chat",
"api": "API",
"cron": "Automations",
"dream": "Memory",
"system": "System"
}
},
"providers": { "providers": {
"searchPlaceholder": "Search providers", "searchPlaceholder": "Search providers",
"noMatches": "No providers match this search.", "noMatches": "No providers match this search.",
@@ -565,6 +593,8 @@
"goalStateCloseAria": "Close goal", "goalStateCloseAria": "Close goal",
"send": "Send message", "send": "Send message",
"stop": "Stop response", "stop": "Stop response",
"modelNotConfigured": "Model not configured",
"configureModel": "Configure model",
"queued": { "queued": {
"label": "Queued guidance", "label": "Queued guidance",
"guide": "Guide", "guide": "Guide",
@@ -733,6 +763,15 @@
"next": "Next image", "next": "Next image",
"close": "Close image preview" "close": "Close image preview"
}, },
"filePreview": {
"aria": "File preview",
"close": "Close file preview",
"loading": "Loading preview...",
"failed": "Could not preview this file.",
"routeMissing": "File preview needs the latest gateway. Restart nanobot gateway and try again.",
"resize": "Resize file preview",
"truncated": "Preview is truncated because this file is large."
},
"code": { "code": {
"fallbackLanguage": "code", "fallbackLanguage": "code",
"copyAria": "Copy code", "copyAria": "Copy code",
+39
View File
@@ -187,6 +187,9 @@
"disabled": "Desactivado", "disabled": "Desactivado",
"restartPending": "Reinicio pendiente", "restartPending": "Reinicio pendiente",
"ready": "Listo", "ready": "Listo",
"privateEngine": "Motor privado",
"unixSocket": "Socket Unix",
"defaultWorkspace": "Espacio predeterminado",
"comfortable": "Cómodo", "comfortable": "Cómodo",
"compact": "Compacto", "compact": "Compacto",
"auto": "Automático", "auto": "Automático",
@@ -278,6 +281,31 @@
"imageGeneration": "Generación de imágenes", "imageGeneration": "Generación de imágenes",
"workspace": "Espacio de trabajo" "workspace": "Espacio de trabajo"
}, },
"usage": {
"title": "Actividad de tokens",
"shortTitle": "Token Usage",
"subtitle": "Uso reportado por el proveedor durante los últimos 12 meses.",
"empty": "La actividad de tokens aparecerá después de nuevas respuestas del modelo.",
"totalTokens": "Tokens totales",
"peakTokens": "Pico de tokens",
"thirtyDayTokens": "Tokens en 30 días",
"currentStreak": "Racha actual",
"longestStreak": "Racha más larga",
"daysValue": "{{count}} d",
"last30": "30 días",
"activeDays": "Días activos",
"requests": "Solicitudes",
"estimated": "estimado",
"includesEstimates": "incluye estimaciones",
"cellTitle": "{{date}}: {{tokens}} tokens, {{requests}} solicitudes",
"sources": {
"user": "Chat",
"api": "API",
"cron": "Automatizaciones",
"dream": "Memoria",
"system": "Sistema"
}
},
"providers": { "providers": {
"searchPlaceholder": "Buscar proveedores", "searchPlaceholder": "Buscar proveedores",
"noMatches": "Ningún proveedor coincide con esta búsqueda.", "noMatches": "Ningún proveedor coincide con esta búsqueda.",
@@ -565,6 +593,8 @@
"goalStateCloseAria": "Cerrar objetivo", "goalStateCloseAria": "Cerrar objetivo",
"send": "Enviar mensaje", "send": "Enviar mensaje",
"stop": "Detener respuesta", "stop": "Detener respuesta",
"modelNotConfigured": "Modelo no configurado",
"configureModel": "Configurar modelo",
"queued": { "queued": {
"label": "Guía en cola", "label": "Guía en cola",
"guide": "Guiar", "guide": "Guiar",
@@ -733,6 +763,15 @@
"next": "Imagen siguiente", "next": "Imagen siguiente",
"close": "Cerrar vista previa" "close": "Cerrar vista previa"
}, },
"filePreview": {
"aria": "Vista previa de archivo",
"close": "Cerrar vista previa de archivo",
"loading": "Cargando vista previa...",
"failed": "No se pudo previsualizar este archivo.",
"routeMissing": "La vista previa necesita el gateway más reciente. Reinicia nanobot gateway e inténtalo de nuevo.",
"resize": "Cambiar el tamaño de la vista previa",
"truncated": "La vista previa está truncada porque el archivo es grande."
},
"code": { "code": {
"fallbackLanguage": "código", "fallbackLanguage": "código",
"copyAria": "Copiar código", "copyAria": "Copiar código",
+39
View File
@@ -187,6 +187,9 @@
"disabled": "Désactivé", "disabled": "Désactivé",
"restartPending": "Redémarrage en attente", "restartPending": "Redémarrage en attente",
"ready": "Prêt", "ready": "Prêt",
"privateEngine": "Moteur privé",
"unixSocket": "Socket Unix",
"defaultWorkspace": "Espace par défaut",
"comfortable": "Confortable", "comfortable": "Confortable",
"compact": "Compacte", "compact": "Compacte",
"auto": "Automatique", "auto": "Automatique",
@@ -278,6 +281,31 @@
"imageGeneration": "Génération dimages", "imageGeneration": "Génération dimages",
"workspace": "Espace de travail" "workspace": "Espace de travail"
}, },
"usage": {
"title": "Activité des tokens",
"shortTitle": "Token Usage",
"subtitle": "Usage signalé par le fournisseur sur les 12 derniers mois.",
"empty": "Lactivité des tokens apparaîtra après les nouvelles réponses du modèle.",
"totalTokens": "Tokens cumulés",
"peakTokens": "Pic de tokens",
"thirtyDayTokens": "Tokens sur 30 jours",
"currentStreak": "Série actuelle",
"longestStreak": "Plus longue série",
"daysValue": "{{count}} j",
"last30": "30 jours",
"activeDays": "Jours actifs",
"requests": "Requêtes",
"estimated": "estimé",
"includesEstimates": "inclut des estimations",
"cellTitle": "{{date}} : {{tokens}} tokens, {{requests}} requêtes",
"sources": {
"user": "Chat",
"api": "API",
"cron": "Automatisations",
"dream": "Mémoire",
"system": "Système"
}
},
"providers": { "providers": {
"searchPlaceholder": "Rechercher des fournisseurs", "searchPlaceholder": "Rechercher des fournisseurs",
"noMatches": "Aucun fournisseur ne correspond.", "noMatches": "Aucun fournisseur ne correspond.",
@@ -565,6 +593,8 @@
"goalStateCloseAria": "Fermer lobjectif", "goalStateCloseAria": "Fermer lobjectif",
"send": "Envoyer le message", "send": "Envoyer le message",
"stop": "Arrêter la réponse", "stop": "Arrêter la réponse",
"modelNotConfigured": "Modèle non configuré",
"configureModel": "Configurer le modèle",
"queued": { "queued": {
"label": "Guidage en attente", "label": "Guidage en attente",
"guide": "Guider", "guide": "Guider",
@@ -733,6 +763,15 @@
"next": "Image suivante", "next": "Image suivante",
"close": "Fermer laperçu" "close": "Fermer laperçu"
}, },
"filePreview": {
"aria": "Aperçu du fichier",
"close": "Fermer laperçu du fichier",
"loading": "Chargement de laperçu...",
"failed": "Impossible de prévisualiser ce fichier.",
"routeMissing": "Laperçu du fichier nécessite le dernier gateway. Redémarrez nanobot gateway puis réessayez.",
"resize": "Redimensionner laperçu du fichier",
"truncated": "Laperçu est tronqué car le fichier est volumineux."
},
"code": { "code": {
"fallbackLanguage": "code", "fallbackLanguage": "code",
"copyAria": "Copier le code", "copyAria": "Copier le code",
+39
View File
@@ -187,6 +187,9 @@
"disabled": "Nonaktif", "disabled": "Nonaktif",
"restartPending": "Menunggu mulai ulang", "restartPending": "Menunggu mulai ulang",
"ready": "Siap", "ready": "Siap",
"privateEngine": "Mesin privat",
"unixSocket": "Soket Unix",
"defaultWorkspace": "Workspace default",
"comfortable": "Nyaman", "comfortable": "Nyaman",
"compact": "Ringkas", "compact": "Ringkas",
"auto": "Otomatis", "auto": "Otomatis",
@@ -278,6 +281,31 @@
"imageGeneration": "Pembuatan gambar", "imageGeneration": "Pembuatan gambar",
"workspace": "Ruang kerja" "workspace": "Ruang kerja"
}, },
"usage": {
"title": "Aktivitas token",
"shortTitle": "Token Usage",
"subtitle": "Penggunaan yang dilaporkan penyedia selama 12 bulan terakhir.",
"empty": "Aktivitas token akan muncul setelah balasan model baru.",
"totalTokens": "Total token",
"peakTokens": "Puncak token",
"thirtyDayTokens": "Token 30 hari",
"currentStreak": "Rentetan saat ini",
"longestStreak": "Rentetan terpanjang",
"daysValue": "{{count}} h",
"last30": "30 hari",
"activeDays": "Hari aktif",
"requests": "Permintaan",
"estimated": "perkiraan",
"includesEstimates": "termasuk perkiraan",
"cellTitle": "{{date}}: {{tokens}} token, {{requests}} permintaan",
"sources": {
"user": "Chat",
"api": "API",
"cron": "Otomasi",
"dream": "Memori",
"system": "Sistem"
}
},
"providers": { "providers": {
"searchPlaceholder": "Cari penyedia", "searchPlaceholder": "Cari penyedia",
"noMatches": "Tidak ada penyedia yang cocok.", "noMatches": "Tidak ada penyedia yang cocok.",
@@ -565,6 +593,8 @@
"goalStateCloseAria": "Tutup tujuan", "goalStateCloseAria": "Tutup tujuan",
"send": "Kirim pesan", "send": "Kirim pesan",
"stop": "Hentikan respons", "stop": "Hentikan respons",
"modelNotConfigured": "Model belum dikonfigurasi",
"configureModel": "Konfigurasi model",
"queued": { "queued": {
"label": "Panduan antrean", "label": "Panduan antrean",
"guide": "Pandu", "guide": "Pandu",
@@ -733,6 +763,15 @@
"next": "Gambar berikutnya", "next": "Gambar berikutnya",
"close": "Tutup pratinjau" "close": "Tutup pratinjau"
}, },
"filePreview": {
"aria": "Pratinjau file",
"close": "Tutup pratinjau file",
"loading": "Memuat pratinjau...",
"failed": "Tidak dapat mempratinjau file ini.",
"routeMissing": "Pratinjau file memerlukan gateway terbaru. Mulai ulang nanobot gateway lalu coba lagi.",
"resize": "Ubah ukuran pratinjau file",
"truncated": "Pratinjau dipotong karena file ini besar."
},
"code": { "code": {
"fallbackLanguage": "kode", "fallbackLanguage": "kode",
"copyAria": "Salin kode", "copyAria": "Salin kode",
+39
View File
@@ -187,6 +187,9 @@
"disabled": "無効", "disabled": "無効",
"restartPending": "再起動待ち", "restartPending": "再起動待ち",
"ready": "準備完了", "ready": "準備完了",
"privateEngine": "プライベートエンジン",
"unixSocket": "Unix ソケット",
"defaultWorkspace": "デフォルトワークスペース",
"comfortable": "標準", "comfortable": "標準",
"compact": "コンパクト", "compact": "コンパクト",
"auto": "自動", "auto": "自動",
@@ -278,6 +281,31 @@
"imageGeneration": "画像生成", "imageGeneration": "画像生成",
"workspace": "ワークスペース" "workspace": "ワークスペース"
}, },
"usage": {
"title": "Token アクティビティ",
"shortTitle": "Token Usage",
"subtitle": "直近 12 か月にプロバイダーが報告した使用量。",
"empty": "新しいモデル返信の後に token アクティビティが表示されます。",
"totalTokens": "累計 Token 数",
"peakTokens": "ピーク Token 数",
"thirtyDayTokens": "30 日 Token 数",
"currentStreak": "現在の連続日数",
"longestStreak": "最長連続日数",
"daysValue": "{{count}} 日",
"last30": "30 日",
"activeDays": "アクティブ日数",
"requests": "リクエスト",
"estimated": "推定",
"includesEstimates": "推定を含む",
"cellTitle": "{{date}}: {{tokens}} tokens, {{requests}} 件のリクエスト",
"sources": {
"user": "チャット",
"api": "API",
"cron": "自動タスク",
"dream": "メモリ整理",
"system": "システム"
}
},
"providers": { "providers": {
"searchPlaceholder": "プロバイダーを検索", "searchPlaceholder": "プロバイダーを検索",
"noMatches": "一致するプロバイダーはありません。", "noMatches": "一致するプロバイダーはありません。",
@@ -565,6 +593,8 @@
"goalStateCloseAria": "目標を閉じる", "goalStateCloseAria": "目標を閉じる",
"send": "メッセージを送信", "send": "メッセージを送信",
"stop": "応答を停止", "stop": "応答を停止",
"modelNotConfigured": "モデルが未設定です",
"configureModel": "モデルを設定",
"queued": { "queued": {
"label": "保留中のガイド", "label": "保留中のガイド",
"guide": "ガイド", "guide": "ガイド",
@@ -733,6 +763,15 @@
"next": "次の画像", "next": "次の画像",
"close": "プレビューを閉じる" "close": "プレビューを閉じる"
}, },
"filePreview": {
"aria": "ファイルプレビュー",
"close": "ファイルプレビューを閉じる",
"loading": "プレビューを読み込み中...",
"failed": "このファイルをプレビューできませんでした。",
"routeMissing": "ファイルプレビューには最新の gateway が必要です。nanobot gateway を再起動してから再試行してください。",
"resize": "ファイルプレビューの幅を変更",
"truncated": "ファイルが大きいため、プレビューは途中まで表示されています。"
},
"code": { "code": {
"fallbackLanguage": "コード", "fallbackLanguage": "コード",
"copyAria": "コードをコピー", "copyAria": "コードをコピー",
+39
View File
@@ -187,6 +187,9 @@
"disabled": "비활성화됨", "disabled": "비활성화됨",
"restartPending": "재시작 대기", "restartPending": "재시작 대기",
"ready": "준비됨", "ready": "준비됨",
"privateEngine": "비공개 엔진",
"unixSocket": "Unix 소켓",
"defaultWorkspace": "기본 작업 공간",
"comfortable": "편안함", "comfortable": "편안함",
"compact": "컴팩트", "compact": "컴팩트",
"auto": "자동", "auto": "자동",
@@ -278,6 +281,31 @@
"imageGeneration": "이미지 생성", "imageGeneration": "이미지 생성",
"workspace": "작업공간" "workspace": "작업공간"
}, },
"usage": {
"title": "Token 활동",
"shortTitle": "Token Usage",
"subtitle": "최근 12개월 동안 제공자가 보고한 사용량입니다.",
"empty": "새 모델 응답 이후 token 활동이 표시됩니다.",
"totalTokens": "누적 Token 수",
"peakTokens": "최고 Token 수",
"thirtyDayTokens": "30일 Token 수",
"currentStreak": "현재 연속 일수",
"longestStreak": "최장 연속 일수",
"daysValue": "{{count}}일",
"last30": "30일",
"activeDays": "활성 일수",
"requests": "요청",
"estimated": "추정",
"includesEstimates": "추정 포함",
"cellTitle": "{{date}}: {{tokens}} tokens, 요청 {{requests}}회",
"sources": {
"user": "채팅",
"api": "API",
"cron": "자동화",
"dream": "메모리 정리",
"system": "시스템"
}
},
"providers": { "providers": {
"searchPlaceholder": "제공자 검색", "searchPlaceholder": "제공자 검색",
"noMatches": "일치하는 제공자가 없습니다.", "noMatches": "일치하는 제공자가 없습니다.",
@@ -565,6 +593,8 @@
"goalStateCloseAria": "목표 닫기", "goalStateCloseAria": "목표 닫기",
"send": "메시지 보내기", "send": "메시지 보내기",
"stop": "응답 중지", "stop": "응답 중지",
"modelNotConfigured": "모델이 설정되지 않음",
"configureModel": "모델 설정",
"queued": { "queued": {
"label": "대기 중인 안내", "label": "대기 중인 안내",
"guide": "안내", "guide": "안내",
@@ -733,6 +763,15 @@
"next": "다음 이미지", "next": "다음 이미지",
"close": "미리보기 닫기" "close": "미리보기 닫기"
}, },
"filePreview": {
"aria": "파일 미리보기",
"close": "파일 미리보기 닫기",
"loading": "미리보기 로딩 중...",
"failed": "이 파일을 미리 볼 수 없습니다.",
"routeMissing": "파일 미리보기에는 최신 gateway가 필요합니다. nanobot gateway를 다시 시작한 뒤 다시 시도하세요.",
"resize": "파일 미리보기 크기 조절",
"truncated": "파일이 커서 미리보기가 잘렸습니다."
},
"code": { "code": {
"fallbackLanguage": "코드", "fallbackLanguage": "코드",
"copyAria": "코드 복사", "copyAria": "코드 복사",
+39
View File
@@ -187,6 +187,9 @@
"disabled": "Đã tắt", "disabled": "Đã tắt",
"restartPending": "Chờ khởi động lại", "restartPending": "Chờ khởi động lại",
"ready": "Sẵn sàng", "ready": "Sẵn sàng",
"privateEngine": "Bộ máy riêng",
"unixSocket": "Socket Unix",
"defaultWorkspace": "Workspace mặc định",
"comfortable": "Thoải mái", "comfortable": "Thoải mái",
"compact": "Gọn", "compact": "Gọn",
"auto": "Tự động", "auto": "Tự động",
@@ -278,6 +281,31 @@
"imageGeneration": "Tạo hình ảnh", "imageGeneration": "Tạo hình ảnh",
"workspace": "Không gian làm việc" "workspace": "Không gian làm việc"
}, },
"usage": {
"title": "Hoạt động token",
"shortTitle": "Token Usage",
"subtitle": "Mức dùng do nhà cung cấp báo cáo trong 12 tháng gần nhất.",
"empty": "Hoạt động token sẽ xuất hiện sau các phản hồi mô hình mới.",
"totalTokens": "Tổng token",
"peakTokens": "Đỉnh token",
"thirtyDayTokens": "Token 30 ngày",
"currentStreak": "Chuỗi hiện tại",
"longestStreak": "Chuỗi dài nhất",
"daysValue": "{{count}} ngày",
"last30": "30 ngày",
"activeDays": "Ngày hoạt động",
"requests": "Yêu cầu",
"estimated": "ước tính",
"includesEstimates": "bao gồm ước tính",
"cellTitle": "{{date}}: {{tokens}} tokens, {{requests}} yêu cầu",
"sources": {
"user": "Trò chuyện",
"api": "API",
"cron": "Tự động hóa",
"dream": "Bộ nhớ",
"system": "Hệ thống"
}
},
"providers": { "providers": {
"searchPlaceholder": "Tìm nhà cung cấp", "searchPlaceholder": "Tìm nhà cung cấp",
"noMatches": "Không có nhà cung cấp phù hợp.", "noMatches": "Không có nhà cung cấp phù hợp.",
@@ -565,6 +593,8 @@
"goalStateCloseAria": "Đóng mục tiêu", "goalStateCloseAria": "Đóng mục tiêu",
"send": "Gửi tin nhắn", "send": "Gửi tin nhắn",
"stop": "Dừng phản hồi", "stop": "Dừng phản hồi",
"modelNotConfigured": "Chưa cấu hình mô hình",
"configureModel": "Cấu hình mô hình",
"queued": { "queued": {
"label": "Hướng dẫn đang chờ", "label": "Hướng dẫn đang chờ",
"guide": "Hướng dẫn", "guide": "Hướng dẫn",
@@ -733,6 +763,15 @@
"next": "Ảnh tiếp theo", "next": "Ảnh tiếp theo",
"close": "Đóng xem trước" "close": "Đóng xem trước"
}, },
"filePreview": {
"aria": "Xem trước tệp",
"close": "Đóng xem trước tệp",
"loading": "Đang tải bản xem trước...",
"failed": "Không thể xem trước tệp này.",
"routeMissing": "Xem trước tệp cần gateway mới nhất. Hãy khởi động lại nanobot gateway rồi thử lại.",
"resize": "Đổi kích thước bản xem trước tệp",
"truncated": "Bản xem trước bị cắt vì tệp này lớn."
},
"code": { "code": {
"fallbackLanguage": "mã", "fallbackLanguage": "mã",
"copyAria": "Sao chép mã", "copyAria": "Sao chép mã",
+39
View File
@@ -295,6 +295,9 @@
"disabled": "已禁用", "disabled": "已禁用",
"restartPending": "等待重启", "restartPending": "等待重启",
"ready": "就绪", "ready": "就绪",
"privateEngine": "私有引擎",
"unixSocket": "Unix socket",
"defaultWorkspace": "默认工作区",
"comfortable": "舒适", "comfortable": "舒适",
"compact": "紧凑", "compact": "紧凑",
"auto": "自动", "auto": "自动",
@@ -386,6 +389,31 @@
"imageGeneration": "图片生成", "imageGeneration": "图片生成",
"workspace": "工作区" "workspace": "工作区"
}, },
"usage": {
"title": "Token 活动",
"shortTitle": "Token Usage",
"subtitle": "最近 12 个月由提供商上报的 token 用量。",
"empty": "新的模型回复产生后,这里会显示 token 活动。",
"totalTokens": "累计 Token 数",
"peakTokens": "峰值 Token 数",
"thirtyDayTokens": "30 天 Token 数",
"currentStreak": "当前连续天数",
"longestStreak": "最长连续天数",
"daysValue": "{{count}} 天",
"last30": "30 天",
"activeDays": "活跃天数",
"requests": "请求数",
"estimated": "估算",
"includesEstimates": "包含估算",
"cellTitle": "{{date}}{{tokens}} tokens{{requests}} 次请求",
"sources": {
"user": "对话",
"api": "API",
"cron": "自动任务",
"dream": "记忆整理",
"system": "系统"
}
},
"providers": { "providers": {
"searchPlaceholder": "搜索提供商", "searchPlaceholder": "搜索提供商",
"noMatches": "没有匹配的提供商。", "noMatches": "没有匹配的提供商。",
@@ -564,6 +592,8 @@
"goalStateSheetTitle": "目标", "goalStateSheetTitle": "目标",
"send": "发送消息", "send": "发送消息",
"stop": "停止响应", "stop": "停止响应",
"modelNotConfigured": "模型未配置",
"configureModel": "配置模型",
"queued": { "queued": {
"label": "待引导提示", "label": "待引导提示",
"guide": "引导", "guide": "引导",
@@ -733,6 +763,15 @@
"next": "下一张", "next": "下一张",
"close": "关闭预览" "close": "关闭预览"
}, },
"filePreview": {
"aria": "文件预览",
"close": "关闭文件预览",
"loading": "正在加载预览...",
"failed": "无法预览这个文件。",
"routeMissing": "文件预览需要最新的 gateway。请重启 nanobot gateway 后再试。",
"resize": "调整文件预览宽度",
"truncated": "文件较大,当前只显示前半部分预览。"
},
"code": { "code": {
"fallbackLanguage": "代码", "fallbackLanguage": "代码",
"copyAria": "复制代码", "copyAria": "复制代码",
+39
View File
@@ -187,6 +187,9 @@
"disabled": "已停用", "disabled": "已停用",
"restartPending": "等待重啟", "restartPending": "等待重啟",
"ready": "就緒", "ready": "就緒",
"privateEngine": "私有引擎",
"unixSocket": "Unix socket",
"defaultWorkspace": "預設工作區",
"comfortable": "舒適", "comfortable": "舒適",
"compact": "緊湊", "compact": "緊湊",
"auto": "自動", "auto": "自動",
@@ -278,6 +281,31 @@
"imageGeneration": "圖片生成", "imageGeneration": "圖片生成",
"workspace": "工作區" "workspace": "工作區"
}, },
"usage": {
"title": "Token 活動",
"shortTitle": "Token Usage",
"subtitle": "最近 12 個月由供應商回報的 token 用量。",
"empty": "新的模型回覆產生後,這裡會顯示 token 活動。",
"totalTokens": "累計 Token 數",
"peakTokens": "峰值 Token 數",
"thirtyDayTokens": "30 天 Token 數",
"currentStreak": "目前連續天數",
"longestStreak": "最長連續天數",
"daysValue": "{{count}} 天",
"last30": "30 天",
"activeDays": "活躍天數",
"requests": "請求數",
"estimated": "估算",
"includesEstimates": "包含估算",
"cellTitle": "{{date}}{{tokens}} tokens{{requests}} 次請求",
"sources": {
"user": "對話",
"api": "API",
"cron": "自動任務",
"dream": "記憶整理",
"system": "系統"
}
},
"providers": { "providers": {
"searchPlaceholder": "搜尋供應商", "searchPlaceholder": "搜尋供應商",
"noMatches": "沒有符合的供應商。", "noMatches": "沒有符合的供應商。",
@@ -565,6 +593,8 @@
"goalStateCloseAria": "關閉目標", "goalStateCloseAria": "關閉目標",
"send": "送出訊息", "send": "送出訊息",
"stop": "停止回覆", "stop": "停止回覆",
"modelNotConfigured": "模型未配置",
"configureModel": "配置模型",
"queued": { "queued": {
"label": "待引導提示", "label": "待引導提示",
"guide": "引導", "guide": "引導",
@@ -733,6 +763,15 @@
"next": "下一張", "next": "下一張",
"close": "關閉預覽" "close": "關閉預覽"
}, },
"filePreview": {
"aria": "檔案預覽",
"close": "關閉檔案預覽",
"loading": "正在載入預覽...",
"failed": "無法預覽這個檔案。",
"routeMissing": "檔案預覽需要最新的 gateway。請重啟 nanobot gateway 後再試。",
"resize": "調整檔案預覽寬度",
"truncated": "檔案較大,目前只顯示前半部分預覽。"
},
"code": { "code": {
"fallbackLanguage": "程式碼", "fallbackLanguage": "程式碼",
"copyAria": "複製程式碼", "copyAria": "複製程式碼",
+71 -8
View File
@@ -38,6 +38,10 @@ export type TurnUnit =
| { type: "activity"; messages: UIMessage[]; items: ActivityItem[]; turnLatencyMs?: number } | { type: "activity"; messages: UIMessage[]; items: ActivityItem[]; turnLatencyMs?: number }
| { type: "message"; message: UIMessage }; | { type: "message"; message: UIMessage };
interface NormalizeActivityTimelineOptions {
preserveTrailingActivity?: boolean;
}
export function isReasoningOnlyAssistant(message: UIMessage): boolean { export function isReasoningOnlyAssistant(message: UIMessage): boolean {
if (message.role !== "assistant" || message.kind === "trace") return false; if (message.role !== "assistant" || message.kind === "trace") return false;
if (message.content.trim().length > 0) return false; if (message.content.trim().length > 0) return false;
@@ -48,24 +52,30 @@ export function isAgentActivityMember(message: UIMessage): boolean {
return isReasoningOnlyAssistant(message) || message.kind === "trace"; return isReasoningOnlyAssistant(message) || message.kind === "trace";
} }
export function normalizeActivityTimeline(messages: UIMessage[]): TurnUnit[] { export function normalizeActivityTimeline(
messages: UIMessage[],
options: NormalizeActivityTimelineOptions = {},
): TurnUnit[] {
const units: TurnUnit[] = []; const units: TurnUnit[] = [];
let turnMessages: UIMessage[] = []; let turnMessages: UIMessage[] = [];
let activeTurnId: string | undefined;
const flushTurn = () => { const flushTurn = (flushOptions: NormalizeActivityTimelineOptions = {}) => {
if (turnMessages.length === 0) return; if (turnMessages.length === 0) return;
const visibleMessages = visibleMessagesForTurn(turnMessages); const turnUnits: TurnUnit[] = [];
const orderedTurnMessages = orderMessagesByTurnSeq(turnMessages);
const visibleMessages = visibleMessagesForTurn(orderedTurnMessages);
let visibleIndex = 0; let visibleIndex = 0;
let activityMessages: UIMessage[] = []; let activityMessages: UIMessage[] = [];
const flushActivityMessages = () => { const flushActivityMessages = () => {
if (!activityMessages.length) return; if (!activityMessages.length) return;
pushActivityUnits(units, activityMessages, visibleMessages.slice(visibleIndex)); pushActivityUnits(turnUnits, activityMessages, visibleMessages.slice(visibleIndex));
activityMessages = []; activityMessages = [];
}; };
for (const message of turnMessages) { for (const message of orderedTurnMessages) {
if (isAgentActivityMember(message)) { if (isAgentActivityMember(message)) {
activityMessages.push(message); activityMessages.push(message);
continue; continue;
@@ -74,34 +84,87 @@ export function normalizeActivityTimeline(messages: UIMessage[]): TurnUnit[] {
if (assistantHasInlineReasoning(message)) { if (assistantHasInlineReasoning(message)) {
activityMessages.push(reasoningOnlyMessageFromAnswer(message)); activityMessages.push(reasoningOnlyMessageFromAnswer(message));
flushActivityMessages(); flushActivityMessages();
units.push({ type: "message", message: stripInlineReasoning(message) }); turnUnits.push({ type: "message", message: stripInlineReasoning(message) });
visibleIndex += 1; visibleIndex += 1;
continue; continue;
} }
flushActivityMessages(); flushActivityMessages();
units.push({ type: "message", message }); turnUnits.push({ type: "message", message });
visibleIndex += 1; visibleIndex += 1;
} }
flushActivityMessages(); flushActivityMessages();
units.push(...normalizeCompletedTurnUnits(turnUnits, flushOptions));
turnMessages = []; turnMessages = [];
activeTurnId = undefined;
}; };
for (const message of messages) { for (const message of messages) {
if (message.role === "user") { if (message.role === "user") {
flushTurn(); flushTurn();
units.push({ type: "message", message }); units.push({ type: "message", message });
activeTurnId = message.turnId;
continue; continue;
} }
if (message.turnId && activeTurnId && message.turnId !== activeTurnId) {
flushTurn();
}
if (message.turnId) {
activeTurnId = message.turnId;
}
turnMessages.push(message); turnMessages.push(message);
} }
flushTurn(); flushTurn(options);
return units; return units;
} }
function orderMessagesByTurnSeq(messages: UIMessage[]): UIMessage[] {
if (
messages.length < 2
|| !messages.every((message) => Number.isFinite(message.turnSeq))
) {
return messages;
}
return messages
.map((message, index) => ({ message, index }))
.sort((left, right) => {
const bySeq = (left.message.turnSeq ?? 0) - (right.message.turnSeq ?? 0);
return bySeq || left.index - right.index;
})
.map(({ message }) => message);
}
function normalizeCompletedTurnUnits(
turnUnits: TurnUnit[],
options: NormalizeActivityTimelineOptions,
): TurnUnit[] {
if (options.preserveTrailingActivity || turnUnits.length < 2) return turnUnits;
if (turnUnits[turnUnits.length - 1]?.type !== "activity") return turnUnits;
let trailingStart = turnUnits.length - 1;
while (trailingStart > 0 && turnUnits[trailingStart - 1]?.type === "activity") {
trailingStart -= 1;
}
const previous = turnUnits[trailingStart - 1];
if (
!previous
|| previous.type !== "message"
|| previous.message.role !== "assistant"
) {
return turnUnits;
}
return [
...turnUnits.slice(0, trailingStart - 1),
...turnUnits.slice(trailingStart),
previous,
];
}
function visibleMessagesForTurn(messages: UIMessage[]): UIMessage[] { function visibleMessagesForTurn(messages: UIMessage[]): UIMessage[] {
const visibleMessages: UIMessage[] = []; const visibleMessages: UIMessage[] = [];
for (const message of messages) { for (const message of messages) {
+29
View File
@@ -1,6 +1,7 @@
import type { import type {
ChatSummary, ChatSummary,
CliAppsPayload, CliAppsPayload,
FilePreviewPayload,
ImageGenerationSettingsUpdate, ImageGenerationSettingsUpdate,
McpPresetsPayload, McpPresetsPayload,
ModelConfigurationCreate, ModelConfigurationCreate,
@@ -134,6 +135,22 @@ export async function fetchWebuiThread(
return (await res.json()) as WebuiThreadPersistedPayload; return (await res.json()) as WebuiThreadPersistedPayload;
} }
export async function fetchFilePreview(
token: string,
key: string,
path: string,
base: string = "",
): Promise<FilePreviewPayload> {
const query = new URLSearchParams();
query.set("path", path);
return request<FilePreviewPayload>(
`${base}/api/sessions/${encodeURIComponent(key)}/file-preview?${query}`,
token,
undefined,
API_READ_TIMEOUT_MS,
);
}
export async function deleteSession( export async function deleteSession(
token: string, token: string,
key: string, key: string,
@@ -158,6 +175,18 @@ export async function fetchSettings(
); );
} }
export async function fetchSettingsUsage(
token: string,
base: string = "",
): Promise<NonNullable<SettingsPayload["usage"]>> {
return request<NonNullable<SettingsPayload["usage"]>>(
`${base}/api/settings/usage`,
token,
undefined,
API_READ_TIMEOUT_MS,
);
}
export async function fetchWorkspaces( export async function fetchWorkspaces(
token: string, token: string,
base: string = "", base: string = "",
+2
View File
@@ -336,6 +336,7 @@ export class NanobotClient {
cliApps?: OutboundCliAppMention[]; cliApps?: OutboundCliAppMention[];
mcpPresets?: OutboundMcpPresetMention[]; mcpPresets?: OutboundMcpPresetMention[];
workspaceScope?: WorkspaceScopePayload | null; workspaceScope?: WorkspaceScopePayload | null;
turnId?: string;
}, },
): void { ): void {
this.knownChats.add(chatId); this.knownChats.add(chatId);
@@ -348,6 +349,7 @@ export class NanobotClient {
...(options?.cliApps?.length ? { cli_apps: options.cliApps } : {}), ...(options?.cliApps?.length ? { cli_apps: options.cliApps } : {}),
...(options?.mcpPresets?.length ? { mcp_presets: options.mcpPresets } : {}), ...(options?.mcpPresets?.length ? { mcp_presets: options.mcpPresets } : {}),
...(options?.workspaceScope ? { workspace_scope: options.workspaceScope } : {}), ...(options?.workspaceScope ? { workspace_scope: options.workspaceScope } : {}),
...(options?.turnId ? { turn_id: options.turnId } : {}),
webui: true, webui: true,
}; };
this.queueSend(frame); this.queueSend(frame);
+74 -14
View File
@@ -4,6 +4,8 @@ export type Role = "user" | "assistant" | "tool" | "system";
* progress pings) that should not be rendered as conversational replies. */ * progress pings) that should not be rendered as conversational replies. */
export type MessageKind = "message" | "trace"; export type MessageKind = "message" | "trace";
export type UITurnPhase = "user" | "reasoning" | "activity" | "answer" | "complete";
/** One image attached to a UIMessage. /** One image attached to a UIMessage.
* *
* ``url`` can arrive in three different shapes, which the bubble renders * ``url`` can arrive in three different shapes, which the bubble renders
@@ -64,6 +66,10 @@ export interface UIMessage {
reasoningStreaming?: boolean; reasoningStreaming?: boolean;
/** End-to-end wall time for this assistant turn (persisted ``latency_ms`` / ``turn_end``). */ /** End-to-end wall time for this assistant turn (persisted ``latency_ms`` / ``turn_end``). */
latencyMs?: number; latencyMs?: number;
/** Stable protocol metadata for grouping all activity emitted by one user turn. */
turnId?: string;
turnPhase?: UITurnPhase;
turnSeq?: number;
} }
export interface UICliAppAttachment { export interface UICliAppAttachment {
@@ -352,6 +358,43 @@ export interface SettingsPayload {
}; };
unified_session: boolean; unified_session: boolean;
}; };
usage?: {
days: Array<{
date: string;
prompt_tokens: number;
completion_tokens: number;
cached_tokens: number;
total_tokens: number;
provider_tokens?: number;
estimated_tokens?: number;
requests: number;
provider_requests?: number;
estimated_requests?: number;
sources?: Record<
"user" | "api" | "cron" | "dream" | "system" | string,
{
prompt_tokens: number;
completion_tokens: number;
cached_tokens: number;
total_tokens: number;
provider_tokens?: number;
estimated_tokens?: number;
requests: number;
provider_requests?: number;
estimated_requests?: number;
}
>;
}>;
total_tokens: number;
total_tokens_30d: number;
total_tokens_365d: number;
peak_day_tokens: number;
current_streak_days: number;
longest_streak_days: number;
active_days_30d: number;
requests_30d: number;
updated_at?: string | null;
};
advanced: { advanced: {
restrict_to_workspace: boolean; restrict_to_workspace: boolean;
workspace_sandbox?: { workspace_sandbox?: {
@@ -605,10 +648,16 @@ export type ConnectionStatus =
| "closed" | "closed"
| "error"; | "error";
export interface InboundTurnMetadata {
turn_id?: string;
turn_phase?: UITurnPhase;
turn_seq?: number;
}
export type InboundEvent = export type InboundEvent =
| { event: "ready"; chat_id: string; client_id: string } | { event: "ready"; chat_id: string; client_id: string }
| { event: "attached"; chat_id: string } | { event: "attached"; chat_id: string }
| { | ({
event: "message"; event: "message";
chat_id: string; chat_id: string;
text: string; text: string;
@@ -623,47 +672,47 @@ export type InboundEvent =
latency_ms?: number; latency_ms?: number;
/** Optional structured payload on progress frames (channel-specific). */ /** Optional structured payload on progress frames (channel-specific). */
agent_ui?: AgentUIBlob; agent_ui?: AgentUIBlob;
} } & InboundTurnMetadata)
| { | ({
event: "file_edit"; event: "file_edit";
chat_id: string; chat_id: string;
edits: UIFileEdit[]; edits: UIFileEdit[];
} } & InboundTurnMetadata)
| { | ({
event: "delta"; event: "delta";
chat_id: string; chat_id: string;
text: string; text: string;
stream_id?: string; stream_id?: string;
} } & InboundTurnMetadata)
| { | ({
event: "stream_end"; event: "stream_end";
chat_id: string; chat_id: string;
stream_id?: string; stream_id?: string;
text?: string; text?: string;
} } & InboundTurnMetadata)
| { | ({
event: "reasoning_delta"; event: "reasoning_delta";
chat_id: string; chat_id: string;
text: string; text: string;
stream_id?: string; stream_id?: string;
} } & InboundTurnMetadata)
| { | ({
event: "reasoning_end"; event: "reasoning_end";
chat_id: string; chat_id: string;
stream_id?: string; stream_id?: string;
} } & InboundTurnMetadata)
| { | {
event: "runtime_model_updated"; event: "runtime_model_updated";
model_name: string; model_name: string;
model_preset?: string | null; model_preset?: string | null;
} }
| { | ({
event: "turn_end"; event: "turn_end";
chat_id: string; chat_id: string;
latency_ms?: number; latency_ms?: number;
/** Authoritative sustained-goal snapshot for this chat (same shape as ``goal_state`` events). */ /** Authoritative sustained-goal snapshot for this chat (same shape as ``goal_state`` events). */
goal_state?: GoalStateWsPayload; goal_state?: GoalStateWsPayload;
} } & InboundTurnMetadata)
| { | {
event: "goal_status"; event: "goal_status";
chat_id: string; chat_id: string;
@@ -732,6 +781,16 @@ export interface WebuiThreadPersistedPayload {
workspace_scope?: WorkspaceScopePayload; workspace_scope?: WorkspaceScopePayload;
} }
export interface FilePreviewPayload {
path: string;
display_path: string;
project_path: string;
language: string;
content: string;
size: number;
truncated: boolean;
}
export type Outbound = export type Outbound =
| { type: "new_chat"; workspace_scope?: WorkspaceScopePayload } | { type: "new_chat"; workspace_scope?: WorkspaceScopePayload }
| { type: "attach"; chat_id: string } | { type: "attach"; chat_id: string }
@@ -745,6 +804,7 @@ export type Outbound =
cli_apps?: OutboundCliAppMention[]; cli_apps?: OutboundCliAppMention[];
mcp_presets?: OutboundMcpPresetMention[]; mcp_presets?: OutboundMcpPresetMention[];
workspace_scope?: WorkspaceScopePayload; workspace_scope?: WorkspaceScopePayload;
turn_id?: string;
/** Marks messages sent by the embedded WebUI, without changing the /** Marks messages sent by the embedded WebUI, without changing the
* generic websocket protocol for other clients. */ * generic websocket protocol for other clients. */
webui?: true; webui?: true;
+25
View File
@@ -3,9 +3,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { import {
createModelConfiguration, createModelConfiguration,
deleteSession, deleteSession,
fetchFilePreview,
fetchCliApps, fetchCliApps,
fetchMcpPresets, fetchMcpPresets,
fetchProviderModels, fetchProviderModels,
fetchSettingsUsage,
fetchSidebarState, fetchSidebarState,
fetchWebuiThread, fetchWebuiThread,
fetchWorkspaces, fetchWorkspaces,
@@ -55,6 +57,18 @@ describe("webui API helpers", () => {
); );
}); });
it("percent-encodes websocket keys and paths when fetching file previews", async () => {
await fetchFilePreview("tok", "websocket:chat-1", "/tmp/project/hook.py:12");
expect(fetch).toHaveBeenCalledWith(
"/api/sessions/websocket%3Achat-1/file-preview?path=%2Ftmp%2Fproject%2Fhook.py%3A12",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
credentials: "same-origin",
}),
);
});
it("percent-encodes websocket keys when deleting a session", async () => { it("percent-encodes websocket keys when deleting a session", async () => {
await deleteSession("tok", "websocket:chat-1"); await deleteSession("tok", "websocket:chat-1");
@@ -86,6 +100,17 @@ describe("webui API helpers", () => {
); );
}); });
it("fetches token usage through the lightweight settings endpoint", async () => {
await fetchSettingsUsage("tok");
expect(fetch).toHaveBeenCalledWith(
"/api/settings/usage",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
);
});
it("serializes model configuration creation", async () => { it("serializes model configuration creation", async () => {
await createModelConfiguration("tok", { await createModelConfiguration("tok", {
label: "Fast writing", label: "Fast writing",
+55 -1
View File
@@ -208,6 +208,7 @@ describe("App layout", () => {
runStatusHandlers.clear(); runStatusHandlers.clear();
window.history.replaceState(null, "", "/"); window.history.replaceState(null, "", "/");
setNavigatorPlatform("Linux x86_64"); setNavigatorPlatform("Linux x86_64");
localStorage.removeItem("nanobot-webui.sidebar");
localStorage.removeItem("nanobot-webui.sidebar.completed-runs.v1"); localStorage.removeItem("nanobot-webui.sidebar.completed-runs.v1");
vi.mocked(fetchBootstrap).mockReset().mockResolvedValue({ vi.mocked(fetchBootstrap).mockReset().mockResolvedValue({
token: "tok", token: "tok",
@@ -243,6 +244,60 @@ describe("App layout", () => {
expect(asideClassNames.some((cls) => cls.includes("lg:block"))).toBe(true); expect(asideClassNames.some((cls) => cls.includes("lg:block"))).toBe(true);
}); });
it("fully collapses the native host sidebar and previews it on hover", async () => {
mockSessions = [
{
key: "websocket:chat-a",
channel: "websocket",
chatId: "chat-a",
createdAt: "2026-04-16T10:00:00Z",
updatedAt: "2026-04-16T10:00:00Z",
preview: "Desktop chat",
},
];
vi.mocked(fetchBootstrap).mockResolvedValue({
token: "tok",
ws_path: "/",
expires_in: 300,
runtime_surface: "native",
});
render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
const flowSidebar = screen.getByTestId("host-sidebar-flow");
const toggle = screen.getByTestId("host-sidebar-toggle");
expect(flowSidebar).toHaveStyle({ width: "272px" });
expect(
screen.getByRole("navigation", { name: "Sidebar navigation" }),
).toBeInTheDocument();
fireEvent.click(toggle);
await waitFor(() => expect(flowSidebar).toHaveStyle({ width: "0px" }));
expect(
screen.queryByRole("navigation", { name: "Sidebar navigation" }),
).not.toBeInTheDocument();
fireEvent.mouseEnter(toggle);
const previewSidebar = await screen.findByTestId("host-sidebar-preview");
expect(flowSidebar).toHaveStyle({ width: "0px" });
expect(previewSidebar).toHaveStyle({ width: "272px" });
expect(
within(previewSidebar).getByRole("navigation", {
name: "Sidebar navigation",
}),
).toBeInTheDocument();
fireEvent.click(toggle);
await waitFor(() =>
expect(screen.queryByTestId("host-sidebar-preview")).not.toBeInTheDocument(),
);
expect(flowSidebar).toHaveStyle({ width: "272px" });
expect(
screen.getByRole("navigation", { name: "Sidebar navigation" }),
).toBeInTheDocument();
});
it("switches to the next session when deleting the active chat", async () => { it("switches to the next session when deleting the active chat", async () => {
mockSessions = [ mockSessions = [
{ {
@@ -907,7 +962,6 @@ describe("App layout", () => {
expect(await screen.findByRole("heading", { name: "Overview" })).toBeInTheDocument(); expect(await screen.findByRole("heading", { name: "Overview" })).toBeInTheDocument();
expect(document.title).toBe("Settings · nanobot"); expect(document.title).toBe("Settings · nanobot");
expect(screen.getByTestId("overview-nanobot-logo")).toBeInTheDocument();
expect(screen.getByTestId("overview-logo-openai")).toBeInTheDocument(); expect(screen.getByTestId("overview-logo-openai")).toBeInTheDocument();
expect(screen.getByTestId("overview-logo-brave")).toBeInTheDocument(); expect(screen.getByTestId("overview-logo-brave")).toBeInTheDocument();
expect(screen.getByTestId("overview-logo-openrouter")).toBeInTheDocument(); expect(screen.getByTestId("overview-logo-openrouter")).toBeInTheDocument();
+19
View File
@@ -51,6 +51,25 @@ describe("CodeBlock", () => {
expect(screen.getByTestId("plain-code-fallback")).toHaveClass("text-foreground/90"); expect(screen.getByTestId("plain-code-fallback")).toHaveClass("text-foreground/90");
}); });
it("can render without chat-style chrome for file previews", () => {
render(
<ThemeProvider theme="light">
<CodeBlock
language="html"
code="<main />"
chrome="none"
highlight={false}
showLineNumbers
/>
</ThemeProvider>,
);
expect(screen.queryByText("html")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /copy/i })).not.toBeInTheDocument();
expect(screen.getByText("1")).toBeInTheDocument();
expect(screen.getByTestId("plain-code-fallback")).toHaveClass("bg-transparent");
});
it("falls back to 'text' language when language is undefined", async () => { it("falls back to 'text' language when language is undefined", async () => {
render( render(
<ThemeProvider theme="dark"> <ThemeProvider theme="dark">
@@ -1,5 +1,5 @@
import { render, screen } from "@testing-library/react"; import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest"; import { describe, expect, it, vi } from "vitest";
import MarkdownTextRenderer from "@/components/MarkdownTextRenderer"; import MarkdownTextRenderer from "@/components/MarkdownTextRenderer";
@@ -12,6 +12,43 @@ describe("MarkdownTextRenderer", () => {
expect(link).toHaveClass("text-blue-500", "dark:text-blue-300"); expect(link).toHaveClass("text-blue-500", "dark:text-blue-300");
}); });
it("renders local file links as previewable file references", () => {
const onOpenFilePreview = vi.fn();
render(
<MarkdownTextRenderer onOpenFilePreview={onOpenFilePreview}>
{"Edited [hook.py](/Users/test/project/nanobot/agent/hook.py:12)"}
</MarkdownTextRenderer>,
);
const reference = screen.getByTestId("inline-file-path");
expect(reference).toHaveTextContent("hook.py");
expect(reference).toHaveAttribute(
"aria-label",
"/Users/test/project/nanobot/agent/hook.py",
);
fireEvent.click(reference);
expect(onOpenFilePreview).toHaveBeenCalledWith(
"/Users/test/project/nanobot/agent/hook.py",
);
});
it("does not treat non-file hrefs as previews just because the label looks like a file", () => {
const onOpenFilePreview = vi.fn();
render(
<MarkdownTextRenderer onOpenFilePreview={onOpenFilePreview}>
{"Download [index.html](/api/media/sig/html)"}
</MarkdownTextRenderer>,
);
expect(screen.queryByTestId("inline-file-path")).not.toBeInTheDocument();
expect(screen.getByRole("link", { name: "index.html" })).toHaveAttribute(
"href",
"/api/media/sig/html",
);
});
it("does not wrap complete fenced code blocks in an extra pre", () => { it("does not wrap complete fenced code blocks in an extra pre", () => {
const { container } = render( const { container } = render(
<MarkdownTextRenderer highlightCode={false}> <MarkdownTextRenderer highlightCode={false}>
+18
View File
@@ -429,6 +429,24 @@ describe("NanobotClient", () => {
); );
}); });
it("includes an explicit turn id on outbound WebUI messages", () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
client.connect();
lastSocket().fakeOpen();
client.sendMessage("chat-x", "hello", undefined, { turnId: "turn-1" });
expect(JSON.parse(lastSocket().sent.at(-1) as string)).toEqual({
type: "message",
chat_id: "chat-x",
content: "hello",
turn_id: "turn-1",
webui: true,
});
});
it("includes image generation options in outbound messages", () => { it("includes image generation options in outbound messages", () => {
const client = new NanobotClient({ const client = new NanobotClient({
url: "ws://test", url: "ws://test",
+324 -1
View File
@@ -118,7 +118,7 @@ const installedAnyGen = {
function renderSettingsView( function renderSettingsView(
options: { options: {
initialSection?: "apps" | "advanced" | "models"; initialSection?: "overview" | "apps" | "advanced" | "models";
onSettingsChange?: (payload: SettingsPayload) => void; onSettingsChange?: (payload: SettingsPayload) => void;
} = {}, } = {},
) { ) {
@@ -219,6 +219,55 @@ describe("SettingsView Apps catalog", () => {
await waitFor(() => expect(onSettingsChange).toHaveBeenCalledWith(payload)); await waitFor(() => expect(onSettingsChange).toHaveBeenCalledWith(payload));
}); });
it("shows token activity on the overview", async () => {
const payload: SettingsPayload = {
...settingsPayload(),
usage: {
days: [
{
date: "2026-06-03",
prompt_tokens: 1200,
completion_tokens: 300,
cached_tokens: 500,
total_tokens: 1500,
requests: 2,
},
],
total_tokens: 1500,
total_tokens_30d: 1500,
total_tokens_365d: 1500,
peak_day_tokens: 1500,
current_streak_days: 1,
longest_streak_days: 1,
active_days_30d: 1,
requests_30d: 2,
updated_at: "2026-06-03T00:00:00Z",
},
};
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(payload);
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
return { ok: false, status: 404, json: async () => ({}) } as Response;
}),
);
renderSettingsView({ initialSection: "overview" });
expect(await screen.findByLabelText("Token activity")).toBeInTheDocument();
expect(screen.getByText("Token Usage")).toBeInTheDocument();
expect(screen.queryByText("Token activity")).not.toBeInTheDocument();
expect(screen.queryByText("Total tokens")).not.toBeInTheDocument();
expect(screen.queryByText("Peak tokens")).not.toBeInTheDocument();
});
it("shows context window options in model settings", async () => { it("shows context window options in model settings", async () => {
vi.stubGlobal( vi.stubGlobal(
"fetch", "fetch",
@@ -242,6 +291,280 @@ describe("SettingsView Apps catalog", () => {
expect(screen.getByRole("button", { name: "256K" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "256K" })).toBeInTheDocument();
}); });
it("marks the current model as unconfigured when its provider needs setup", async () => {
const payload: SettingsPayload = {
...settingsPayload(),
agent: {
...settingsPayload().agent,
model: "openai-codex/gpt-5.1-codex",
provider: "openai_codex",
resolved_provider: "openai_codex",
has_api_key: false,
},
model_presets: [
{
...settingsPayload().model_presets[0],
model: "openai-codex/gpt-5.1-codex",
provider: "openai_codex",
},
],
providers: [
{
name: "openai_codex",
label: "OpenAI Codex",
configured: false,
auth_type: "oauth",
api_key_required: false,
api_key_hint: null,
api_base: null,
default_api_base: null,
oauth_account: null,
oauth_expires_at: null,
oauth_login_supported: true,
},
],
};
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(payload);
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
return { ok: false, status: 404, json: async () => ({}) } as Response;
}),
);
renderSettingsView({ initialSection: "models" });
const configurationButton = await screen.findByRole("button", {
name: "Current configuration",
});
expect(configurationButton).toHaveTextContent("Not configured");
expect(configurationButton).toHaveTextContent("OpenAI Codex · openai-codex/gpt-5.1-codex");
expect(await screen.findByRole("button", { name: "Sign in" })).toBeInTheDocument();
});
it("keeps unsigned OAuth providers out of the active provider picker", async () => {
const payload: SettingsPayload = {
...settingsPayload(),
agent: {
...settingsPayload().agent,
model: "deepseek-chat",
provider: "deepseek",
resolved_provider: "deepseek",
},
model_presets: [
{
...settingsPayload().model_presets[0],
model: "deepseek-chat",
provider: "deepseek",
},
],
providers: [
{
name: "deepseek",
label: "DeepSeek",
configured: true,
auth_type: "api_key",
api_key_required: true,
api_key_hint: "sk-...",
api_base: "https://api.deepseek.com",
default_api_base: "https://api.deepseek.com",
},
{
name: "openai_codex",
label: "OpenAI Codex",
configured: false,
auth_type: "oauth",
api_key_required: false,
api_key_hint: null,
api_base: null,
default_api_base: null,
oauth_account: null,
oauth_expires_at: null,
oauth_login_supported: true,
},
{
name: "github_copilot",
label: "GitHub Copilot",
configured: false,
auth_type: "oauth",
api_key_required: false,
api_key_hint: null,
api_base: null,
default_api_base: "https://api.githubcopilot.com",
oauth_account: null,
oauth_expires_at: null,
oauth_login_supported: true,
},
],
};
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(payload);
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
return { ok: false, status: 404, json: async () => ({}) } as Response;
}),
);
renderSettingsView({ initialSection: "models" });
const deepseekButtons = await screen.findAllByRole("button", { name: /DeepSeek/ });
const providerPicker = deepseekButtons.find(
(button) => button.getAttribute("aria-haspopup") === "menu",
);
if (!providerPicker) throw new Error("provider picker was not found");
fireEvent.pointerDown(providerPicker);
expect(await screen.findByRole("menuitem", { name: /DeepSeek/ })).toBeInTheDocument();
expect(screen.queryByRole("menuitem", { name: /OpenAI Codex/ })).not.toBeInTheDocument();
expect(screen.queryByRole("menuitem", { name: /GitHub Copilot/ })).not.toBeInTheDocument();
});
it("does not fetch model lists for unsigned OAuth providers", async () => {
const payload: SettingsPayload = {
...settingsPayload(),
agent: {
...settingsPayload().agent,
model: "",
provider: "openai_codex",
resolved_provider: "openai_codex",
},
model_presets: [
{
...settingsPayload().model_presets[0],
model: "",
provider: "openai_codex",
},
],
providers: [
{
name: "openai_codex",
label: "OpenAI Codex",
configured: false,
auth_type: "oauth",
api_key_required: false,
api_key_hint: null,
api_base: null,
default_api_base: null,
oauth_account: null,
oauth_expires_at: null,
oauth_login_supported: true,
},
{
name: "github_copilot",
label: "GitHub Copilot",
configured: false,
auth_type: "oauth",
api_key_required: false,
api_key_hint: null,
api_base: null,
default_api_base: "https://api.githubcopilot.com",
oauth_account: null,
oauth_expires_at: null,
oauth_login_supported: true,
},
],
};
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(payload);
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
return { ok: false, status: 404, json: async () => ({}) } as Response;
});
vi.stubGlobal("fetch", fetchMock);
renderSettingsView({ initialSection: "models" });
fireEvent.pointerDown(await screen.findByRole("button", { name: /Select model/i }));
expect(
await screen.findByText("Configure this provider before loading models."),
).toBeInTheDocument();
expect(
fetchMock.mock.calls.some(([input]) =>
String(input).startsWith("/api/settings/provider-models"),
),
).toBe(false);
});
it("prefills manual model ids for configured OAuth providers", async () => {
const payload: SettingsPayload = {
...settingsPayload(),
agent: {
...settingsPayload().agent,
model: "open-codex/gpt-5.5",
provider: "openai_codex",
resolved_provider: "openai_codex",
},
model_presets: [
{
...settingsPayload().model_presets[0],
model: "open-codex/gpt-5.5",
provider: "openai_codex",
},
],
providers: [
{
name: "openai_codex",
label: "OpenAI Codex",
configured: true,
auth_type: "oauth",
api_key_required: false,
api_key_hint: null,
api_base: null,
default_api_base: null,
oauth_account: "acct-test",
oauth_expires_at: null,
oauth_login_supported: true,
},
],
};
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(payload);
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
return { ok: false, status: 404, json: async () => ({}) } as Response;
});
vi.stubGlobal("fetch", fetchMock);
renderSettingsView({ initialSection: "models" });
const modelButtons = await screen.findAllByRole("button", { name: /open-codex\/gpt-5\.5/i });
fireEvent.pointerDown(modelButtons[modelButtons.length - 1]);
const input = (await screen.findByPlaceholderText("Search or type model ID")) as HTMLInputElement;
expect(input.value).toBe("open-codex/gpt-5.5");
fireEvent.change(input, { target: { value: "openai-codex/gpt-5.5" } });
expect(await screen.findByText("“openai-codex/gpt-5.5”")).toBeInTheDocument();
expect(
fetchMock.mock.calls.some(([input]) =>
String(input).startsWith("/api/settings/provider-models"),
),
).toBe(false);
});
it("can close the new configuration dialog without trapping the settings page", async () => { it("can close the new configuration dialog without trapping the settings page", async () => {
vi.stubGlobal( vi.stubGlobal(
"fetch", "fetch",
+175 -8
View File
@@ -151,7 +151,7 @@ describe("ThreadMessages", () => {
]); ]);
}); });
it("renders a later tool segment after the visible answer that preceded it", () => { it("moves orphan trailing activity before the completed assistant answer", () => {
const messages: UIMessage[] = [ const messages: UIMessage[] = [
{ {
id: "r1", id: "r1",
@@ -182,14 +182,14 @@ describe("ThreadMessages", () => {
expect(units).toHaveLength(3); expect(units).toHaveLength(3);
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual(["r1"]); expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual(["r1"]);
expect(units[1]).toMatchObject({ expect(units[1].type === "activity" ? units[1].messages.map((m) => m.id) : []).toEqual(["t1"]);
expect(units[2]).toMatchObject({
type: "message", type: "message",
message: { message: {
id: "a1", id: "a1",
content: "Let me search the latest data.", content: "Let me search the latest data.",
}, },
}); });
expect(units[2].type === "activity" ? units[2].messages.map((m) => m.id) : []).toEqual(["t1"]);
}); });
it("only marks the current activity timeline as live while streaming", () => { it("only marks the current activity timeline as live while streaming", () => {
@@ -324,7 +324,7 @@ describe("ThreadMessages", () => {
}, },
]; ];
const units = buildDisplayUnits(messages); const units = buildDisplayUnits(messages, true);
expect(units).toHaveLength(3); expect(units).toHaveLength(3);
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual(["t0"]); expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual(["t0"]);
@@ -344,7 +344,7 @@ describe("ThreadMessages", () => {
expect(answer.compareDocumentPosition(liveActivity) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); expect(answer.compareDocumentPosition(liveActivity) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
}); });
it("keeps late activity after a completed assistant answer", () => { it("moves late activity before a completed assistant answer", () => {
const messages: UIMessage[] = [ const messages: UIMessage[] = [
{ {
id: "r1", id: "r1",
@@ -376,21 +376,164 @@ describe("ThreadMessages", () => {
expect(units).toHaveLength(3); expect(units).toHaveLength(3);
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual(["r1"]); expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual(["r1"]);
expect(units[1]).toMatchObject({ expect(units[1].type === "activity" ? units[1].messages.map((m) => m.id) : []).toEqual(["t1"]);
expect(units[2]).toMatchObject({
type: "message", type: "message",
message: { message: {
id: "a1", id: "a1",
content: "Hong Kong is hot today.", content: "Hong Kong is hot today.",
}, },
}); });
expect(units[2].type === "activity" ? units[2].messages.map((m) => m.id) : []).toEqual(["t1"]);
render(<ThreadMessages messages={messages} isStreaming={false} />); render(<ThreadMessages messages={messages} isStreaming={false} />);
const answer = screen.getByText("Hong Kong is hot today."); const answer = screen.getByText("Hong Kong is hot today.");
const laterActivity = screen.getAllByText(/thought/i).at(-1); const laterActivity = screen.getAllByText(/thought/i).at(-1);
expect(laterActivity).toBeTruthy(); expect(laterActivity).toBeTruthy();
expect(answer.compareDocumentPosition(laterActivity!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); expect(laterActivity!.compareDocumentPosition(answer) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
it("does not leave a completed web-search thought below the final answer", () => {
const messages: UIMessage[] = [
{
id: "user",
role: "user",
content: "最近科隆major开打了,你知道不?",
createdAt: 1,
},
{
id: "thought",
role: "assistant",
content: "",
reasoning: "I should verify the current event details.",
activitySegmentId: "seg-major",
createdAt: 2,
},
{
id: "answer",
role: "assistant",
content: "知道,IEM Cologne Major 2026 今天开打了。",
latencyMs: 18_000,
createdAt: 3,
},
{
id: "web",
role: "tool",
kind: "trace",
content: "Searching query: 2026 Cologne Major esports started 科隆 Major 开打了 2026",
traces: ["Searching query: 2026 Cologne Major esports started 科隆 Major 开打了 2026"],
activitySegmentId: "seg-major",
createdAt: 4,
},
];
render(<ThreadMessages messages={messages} isStreaming={false} />);
const thought = screen.getAllByText(/thought/i).at(-1);
const answer = screen.getByText("知道,IEM Cologne Major 2026 今天开打了。");
expect(thought).toBeTruthy();
expect(thought!.compareDocumentPosition(answer) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
it("normalizes completed prior turns while the next user turn is streaming", () => {
const messages: UIMessage[] = [
{
id: "thought",
role: "assistant",
content: "",
reasoning: "I should verify the current event details.",
activitySegmentId: "seg-major",
createdAt: 1,
},
{
id: "answer",
role: "assistant",
content: "Yep — IEM Cologne Major 2026 is in Cologne.",
latencyMs: 20_000,
createdAt: 2,
},
{
id: "web",
role: "tool",
kind: "trace",
content: "Searching query: site:counter-strike.net majors 2026",
traces: ["Searching query: site:counter-strike.net majors 2026"],
activitySegmentId: "seg-major",
createdAt: 3,
},
{
id: "next-user",
role: "user",
content: "看一下目前的赛果,整个表哥",
createdAt: 4,
},
];
const units = buildDisplayUnits(messages, true);
expect(units).toHaveLength(4);
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual([
"thought",
]);
expect(units[1].type === "activity" ? units[1].messages.map((m) => m.id) : []).toEqual([
"web",
]);
expect(units[2]).toMatchObject({
type: "message",
message: { id: "answer" },
});
expect(units[3]).toMatchObject({
type: "message",
message: { id: "next-user" },
});
});
it("orders live turn activity by causal turn sequence before the final answer", () => {
const messages: UIMessage[] = [
{
id: "web-1",
role: "tool",
kind: "trace",
content: "Searching query: 2026 Counter-Strike 2 Major location",
traces: ["Searching query: 2026 Counter-Strike 2 Major location"],
turnId: "turn-major",
turnSeq: 3,
activitySegmentId: "seg-1",
createdAt: 1,
},
{
id: "answer",
role: "assistant",
content: "Yep — IEM Cologne Major 2026 is in Cologne.",
isStreaming: true,
turnId: "turn-major",
turnSeq: 84,
createdAt: 3,
},
{
id: "web-2",
role: "tool",
kind: "trace",
content: "Searching query: site:counter-strike.net majors 2026",
traces: ["Searching query: site:counter-strike.net majors 2026"],
turnId: "turn-major",
turnSeq: 83,
activitySegmentId: "seg-2",
createdAt: 2,
},
];
const units = buildDisplayUnits(messages, true);
expect(units).toHaveLength(2);
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual([
"web-1",
"web-2",
]);
expect(units[1]).toMatchObject({
type: "message",
message: { id: "answer" },
});
}); });
it("renders interrupted pre-tool text as activity before the final answer", () => { it("renders interrupted pre-tool text as activity before the final answer", () => {
@@ -509,6 +652,30 @@ describe("ThreadMessages", () => {
expect(screen.getAllByRole("button", { name: "Copy reply" })).toHaveLength(1); expect(screen.getAllByRole("button", { name: "Copy reply" })).toHaveLength(1);
}); });
it("uses turn ids as activity grouping boundaries when available", () => {
const units = buildDisplayUnits([
{ id: "u1", role: "user", content: "one", turnId: "turn-1", createdAt: 1 },
{ id: "a1", role: "assistant", content: "answer one", turnId: "turn-1", createdAt: 2 },
{
id: "t2",
role: "tool",
kind: "trace",
content: "search()",
traces: ["search()"],
turnId: "turn-2",
createdAt: 3,
},
{ id: "a2", role: "assistant", content: "answer two", turnId: "turn-2", createdAt: 4 },
]);
expect(units.map((unit) => unit.type === "message" ? unit.message.id : "activity")).toEqual([
"u1",
"a1",
"activity",
"a2",
]);
});
it("computes final assistant copy flags with user-boundary semantics", () => { it("computes final assistant copy flags with user-boundary semantics", () => {
const units = buildDisplayUnits([ const units = buildDisplayUnits([
{ id: "u1", role: "user", content: "one", createdAt: 1 }, { id: "u1", role: "user", content: "one", createdAt: 1 },
+58 -21
View File
@@ -78,6 +78,20 @@ function wrap(client: ReturnType<typeof makeClient>, children: ReactNode, modelN
); );
} }
function expectSendMessageWithTurn(
client: ReturnType<typeof makeClient>,
chatId: string,
content: string,
options: unknown = undefined,
) {
expect(client.sendMessage).toHaveBeenCalledWith(
chatId,
content,
options,
expect.objectContaining({ turnId: expect.any(String) }),
);
}
function session(chatId: string) { function session(chatId: string) {
return { return {
key: `websocket:${chatId}`, key: `websocket:${chatId}`,
@@ -270,6 +284,45 @@ describe("ThreadShell", () => {
expect(await screen.findByTestId("composer-model-logo-openai_codex")).toBeInTheDocument(); expect(await screen.findByTestId("composer-model-logo-openai_codex")).toBeInTheDocument();
}); });
it("opens model settings from the unconfigured model badge", async () => {
const client = makeClient();
const settings = modelSettings("openai-codex/gpt-5.1-codex", "openai_codex");
settings.agent.has_api_key = false;
settings.providers = settings.providers.map((provider) =>
provider.name === "openai_codex"
? { ...provider, auth_type: "oauth", configured: false }
: provider,
);
const onOpenModelSettings = vi.fn();
render(
wrap(
client,
<ThreadShell
session={session("unconfigured-model")}
title="Unconfigured model"
onToggleSidebar={() => {}}
settingsSnapshot={settings}
onOpenModelSettings={onOpenModelSettings}
/>,
"openai-codex/gpt-5.1-codex",
),
);
const badge = await screen.findByRole("button", { name: "Model not configured" });
expect(screen.getByTestId("composer-model-setup-icon")).toBeInTheDocument();
expect(screen.queryByTestId("composer-model-logo-openai_codex")).not.toBeInTheDocument();
fireEvent.click(badge);
expect(onOpenModelSettings).toHaveBeenCalledTimes(1);
fireEvent.change(screen.getByRole("textbox", { name: "Message input" }), {
target: { value: "hello" },
});
fireEvent.click(screen.getByRole("button", { name: "Configure model" }));
expect(onOpenModelSettings).toHaveBeenCalledTimes(2);
expect(client.sendMessage).not.toHaveBeenCalled();
});
it("keeps image generation controls out of the composer", async () => { it("keeps image generation controls out of the composer", async () => {
const client = makeClient(); const client = makeClient();
const disabledSettings = modelSettings("deepseek-v4-pro", "deepseek"); const disabledSettings = modelSettings("deepseek-v4-pro", "deepseek");
@@ -339,11 +392,7 @@ describe("ThreadShell", () => {
fireEvent.click(screen.getByRole("button", { name: "Send message" })); fireEvent.click(screen.getByRole("button", { name: "Send message" }));
await waitFor(() => await waitFor(() =>
expect(client.sendMessage).toHaveBeenCalledWith( expectSendMessageWithTurn(client, "chat-a", "persist me across tabs"),
"chat-a",
"persist me across tabs",
undefined,
),
); );
expect(screen.getByText("persist me across tabs")).toBeInTheDocument(); expect(screen.getByText("persist me across tabs")).toBeInTheDocument();
@@ -403,11 +452,7 @@ describe("ThreadShell", () => {
fireEvent.click(screen.getByRole("button", { name: "Send message" })); fireEvent.click(screen.getByRole("button", { name: "Send message" }));
await waitFor(() => await waitFor(() =>
expect(client.sendMessage).toHaveBeenCalledWith( expectSendMessageWithTurn(client, "chat-a", "delete me cleanly"),
"chat-a",
"delete me cleanly",
undefined,
),
); );
expect(screen.getByText("delete me cleanly")).toBeInTheDocument(); expect(screen.getByText("delete me cleanly")).toBeInTheDocument();
@@ -506,11 +551,7 @@ describe("ThreadShell", () => {
}); });
await waitFor(() => await waitFor(() =>
expect(client.sendMessage).toHaveBeenCalledWith( expectSendMessageWithTurn(client, "chat-new", "first message should stay"),
"chat-new",
"first message should stay",
undefined,
),
); );
await waitFor(() => await waitFor(() =>
expect(screen.getByText("first message should stay")).toBeInTheDocument(), expect(screen.getByText("first message should stay")).toBeInTheDocument(),
@@ -575,7 +616,7 @@ describe("ThreadShell", () => {
}); });
await waitFor(() => await waitFor(() =>
expect(client.sendMessage).toHaveBeenCalledWith("chat-new", "/model", undefined), expectSendMessageWithTurn(client, "chat-new", "/model"),
); );
await act(async () => { await act(async () => {
@@ -703,11 +744,7 @@ describe("ThreadShell", () => {
fireEvent.click(screen.getByRole("button", { name: "Send message" })); fireEvent.click(screen.getByRole("button", { name: "Send message" }));
await waitFor(() => await waitFor(() =>
expect(client.sendMessage).toHaveBeenCalledWith( expectSendMessageWithTurn(client, "chat-a", "only in chat a"),
"chat-a",
"only in chat a",
undefined,
),
); );
expect(screen.getByText("only in chat a")).toBeInTheDocument(); expect(screen.getByText("only in chat a")).toBeInTheDocument();
+36
View File
@@ -215,6 +215,42 @@ describe("ThreadViewport", () => {
}); });
}); });
it("renders the prompt rail for compact scroll ranges", async () => {
const promptMessages = makeLongMessages(3);
const { container } = render(
<ThreadViewport
messages={promptMessages}
isStreaming={false}
composer={<div />}
/>,
);
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
Object.defineProperties(scroller, {
scrollHeight: { configurable: true, value: 700 },
clientHeight: { configurable: true, value: 600 },
scrollTop: { configurable: true, value: 0 },
});
const promptEls = Array.from(
container.querySelectorAll<HTMLElement>("[data-user-prompt-id]"),
);
expect(promptEls).toHaveLength(3);
promptEls.forEach((el, index) => {
Object.defineProperty(el, "offsetTop", {
configurable: true,
value: index * 50,
});
});
await act(async () => {
window.dispatchEvent(new Event("resize"));
await new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve()));
});
expect(screen.getByLabelText("User prompt navigation")).toBeInTheDocument();
});
it("buckets dense prompt rails without rendering every prompt as a marker", async () => { it("buckets dense prompt rails without rendering every prompt as a marker", async () => {
const promptMessages = makeLongMessages(100); const promptMessages = makeLongMessages(100);
const { container } = render( const { container } = render(
+6 -1
View File
@@ -1342,6 +1342,8 @@ describe("useNanobotStream", () => {
expect(result.current.messages).toHaveLength(1); expect(result.current.messages).toHaveLength(1);
expect(result.current.messages[0].role).toBe("user"); expect(result.current.messages[0].role).toBe("user");
expect(result.current.messages[0].content).toBe("fine"); expect(result.current.messages[0].content).toBe("fine");
expect(result.current.messages[0].turnId).toEqual(expect.any(String));
expect(result.current.messages[0].turnPhase).toBe("user");
}); });
it("attaches assistant media_urls to complete messages", () => { it("attaches assistant media_urls to complete messages", () => {
@@ -1482,7 +1484,10 @@ describe("useNanobotStream", () => {
"chat-img", "chat-img",
"draw a square icon", "draw a square icon",
undefined, undefined,
{ imageGeneration: { enabled: true, aspect_ratio: "1:1" } }, expect.objectContaining({
imageGeneration: { enabled: true, aspect_ratio: "1:1" },
turnId: expect.any(String),
}),
); );
}); });