mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-08 05:18:49 +03:00
feat(webui): polish desktop chat experience
This commit is contained in:
+150
-39
@@ -1,5 +1,5 @@
|
||||
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 { DeleteConfirm } from "@/components/DeleteConfirm";
|
||||
import { RenameChatDialog } from "@/components/RenameChatDialog";
|
||||
@@ -264,11 +264,17 @@ function normalizeWorkspaceScope(scope: WorkspaceScopePayload): WorkspaceScopePa
|
||||
|
||||
function HostChrome({
|
||||
onToggleSidebar,
|
||||
onSidebarPreviewEnter,
|
||||
onSidebarPreviewLeave,
|
||||
sidebarOpen = true,
|
||||
theme,
|
||||
onToggleTheme,
|
||||
showThemeButton = true,
|
||||
}: {
|
||||
onToggleSidebar?: () => void;
|
||||
onSidebarPreviewEnter?: () => void;
|
||||
onSidebarPreviewLeave?: () => void;
|
||||
sidebarOpen?: boolean;
|
||||
theme: "light" | "dark";
|
||||
onToggleTheme: () => void;
|
||||
showThemeButton?: boolean;
|
||||
@@ -276,21 +282,24 @@ function HostChrome({
|
||||
const { t } = useTranslation();
|
||||
|
||||
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">
|
||||
<div className="flex min-w-[8rem] items-center">
|
||||
{onToggleSidebar ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t("thread.header.toggleSidebar")}
|
||||
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"
|
||||
>
|
||||
<Menu className="h-4 w-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<header className="host-drag-region pointer-events-none absolute inset-x-0 top-0 z-40 h-11 bg-transparent text-foreground/90">
|
||||
{onToggleSidebar ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t("thread.header.toggleSidebar")}
|
||||
data-testid="host-sidebar-toggle"
|
||||
onClick={onToggleSidebar}
|
||||
onFocus={!sidebarOpen ? onSidebarPreviewEnter : undefined}
|
||||
onBlur={!sidebarOpen ? onSidebarPreviewLeave : undefined}
|
||||
onMouseEnter={!sidebarOpen ? onSidebarPreviewEnter : undefined}
|
||||
onMouseLeave={!sidebarOpen ? onSidebarPreviewLeave : undefined}
|
||||
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"
|
||||
>
|
||||
<PanelLeft className="h-[15px] w-[15px]" strokeWidth={1.75} />
|
||||
</Button>
|
||||
) : null}
|
||||
{showThemeButton ? (
|
||||
<Button
|
||||
type="button"
|
||||
@@ -298,7 +307,7 @@ function HostChrome({
|
||||
size="icon"
|
||||
aria-label={t("thread.header.toggleTheme")}
|
||||
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" ? (
|
||||
<Sun className="h-4 w-4" />
|
||||
@@ -307,7 +316,7 @@ function HostChrome({
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<div aria-hidden className="host-no-drag pointer-events-none h-8 w-8" />
|
||||
null
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
@@ -532,6 +541,7 @@ function Shell({
|
||||
useState<SettingsSectionKey>(initialRouteRef.current.settingsSection);
|
||||
const [hostSidebarOpen, setHostSidebarOpen] =
|
||||
useState<boolean>(readSidebarOpen);
|
||||
const [hostSidebarPreviewOpen, setHostSidebarPreviewOpen] = useState(false);
|
||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
||||
const [sessionSearchOpen, setSessionSearchOpen] = useState(false);
|
||||
const [pendingDelete, setPendingDelete] = useState<{
|
||||
@@ -560,6 +570,11 @@ function Shell({
|
||||
useState<Record<string, WorkspaceScopePayload>>({});
|
||||
const runningChatIdsRef = useRef<Set<string>>(new Set());
|
||||
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(
|
||||
(route: ShellRoute, options?: { replace?: boolean }) => {
|
||||
@@ -745,13 +760,74 @@ function Shell({
|
||||
});
|
||||
}, [client, loading, sessions]);
|
||||
|
||||
const closeHostSidebar = useCallback(() => {
|
||||
setHostSidebarOpen(false);
|
||||
const clearHostSidebarPreviewCloseTimer = useCallback(() => {
|
||||
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(() => {
|
||||
closeHostSidebarPreview();
|
||||
setHostSidebarOpen(true);
|
||||
}, []);
|
||||
}, [closeHostSidebarPreview]);
|
||||
|
||||
const toggleHostSidebar = useCallback(() => {
|
||||
closeHostSidebarPreview();
|
||||
setHostSidebarOpen((v) => !v);
|
||||
}, [closeHostSidebarPreview]);
|
||||
|
||||
const closeMobileSidebar = useCallback(() => {
|
||||
setMobileSidebarOpen(false);
|
||||
@@ -762,11 +838,12 @@ function Shell({
|
||||
typeof window !== "undefined" &&
|
||||
window.matchMedia("(min-width: 1024px)").matches;
|
||||
if (isNativeHost) {
|
||||
closeHostSidebarPreview();
|
||||
setHostSidebarOpen((v) => !v);
|
||||
} else {
|
||||
setMobileSidebarOpen((v) => !v);
|
||||
}
|
||||
}, []);
|
||||
}, [closeHostSidebarPreview]);
|
||||
|
||||
const applyWorkspaceScope = useCallback(
|
||||
(scope: WorkspaceScopePayload) => {
|
||||
@@ -1041,6 +1118,10 @@ function Shell({
|
||||
setMobileSidebarOpen(false);
|
||||
}, [activeKey, navigate]);
|
||||
|
||||
const onOpenModelSettings = useCallback(() => {
|
||||
onOpenSettings("models");
|
||||
}, [onOpenSettings]);
|
||||
|
||||
const onOpenApps = useCallback(() => {
|
||||
setSessionSearchOpen(false);
|
||||
navigate({ view: "apps", activeKey, settingsSection: "apps" });
|
||||
@@ -1238,11 +1319,13 @@ function Shell({
|
||||
archivedCount: sidebarState.archived_keys.length,
|
||||
defaultWorkspacePath: workspaces?.default_scope.project_path ?? null,
|
||||
};
|
||||
const effectiveRuntimeSurface =
|
||||
settingsSnapshot?.surface ?? settingsSnapshot?.runtime_surface ?? runtimeSurface;
|
||||
const isNativeHostSetupSurface = effectiveRuntimeSurface === "native";
|
||||
const showHostChrome = isNativeHostSetupSurface;
|
||||
const showMainSidebar = view !== "settings";
|
||||
const hostSidebarCollapsed = showHostChrome && !hostSidebarOpen;
|
||||
const showHostSidebarPreview =
|
||||
showMainSidebar && hostSidebarCollapsed && hostSidebarPreviewOpen;
|
||||
const hostSidebarFlowWidth = showHostChrome
|
||||
? (hostSidebarOpen ? SIDEBAR_WIDTH : 0)
|
||||
: (hostSidebarOpen ? SIDEBAR_WIDTH : SIDEBAR_RAIL_WIDTH);
|
||||
const renderHostSidebarFlowContent = !showHostChrome || hostSidebarOpen;
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.toggle("native-host", showHostChrome);
|
||||
@@ -1261,7 +1344,10 @@ function Shell({
|
||||
>
|
||||
{showHostChrome ? (
|
||||
<HostChrome
|
||||
onToggleSidebar={showMainSidebar ? toggleSidebar : undefined}
|
||||
onToggleSidebar={showMainSidebar ? toggleHostSidebar : undefined}
|
||||
onSidebarPreviewEnter={openHostSidebarPreview}
|
||||
onSidebarPreviewLeave={scheduleHostSidebarPreviewClose}
|
||||
sidebarOpen={hostSidebarOpen}
|
||||
theme={theme}
|
||||
onToggleTheme={toggle}
|
||||
/>
|
||||
@@ -1274,25 +1360,47 @@ function Shell({
|
||||
{/* Host sidebar: in normal flow, so the thread area width stays honest. */}
|
||||
{showMainSidebar ? (
|
||||
<aside
|
||||
data-testid="host-sidebar-flow"
|
||||
className={cn(
|
||||
"relative z-20 hidden shrink-0 overflow-hidden lg:block",
|
||||
"transition-[width] duration-300 ease-out",
|
||||
)}
|
||||
style={{
|
||||
width: hostSidebarOpen ? SIDEBAR_WIDTH : SIDEBAR_RAIL_WIDTH,
|
||||
width: hostSidebarFlowWidth,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-y-0 left-0 h-full w-full overflow-hidden",
|
||||
showHostChrome
|
||||
? "host-sidebar-glass"
|
||||
: "bg-sidebar shadow-inner-right",
|
||||
)}
|
||||
>
|
||||
{renderHostSidebarFlowContent ? (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-y-0 left-0 h-full w-full overflow-hidden",
|
||||
showHostChrome
|
||||
? "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
|
||||
{...sidebarProps}
|
||||
collapsed={!hostSidebarOpen}
|
||||
hostChromeInset={showHostChrome}
|
||||
onCollapse={closeHostSidebar}
|
||||
onExpand={openHostSidebar}
|
||||
@@ -1335,7 +1443,7 @@ function Shell({
|
||||
<main
|
||||
className={cn(
|
||||
"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
|
||||
@@ -1354,6 +1462,7 @@ function Shell({
|
||||
theme={theme}
|
||||
onToggleTheme={toggle}
|
||||
hideSidebarToggleForHostChrome
|
||||
hostChromeTitleInset={hostSidebarCollapsed}
|
||||
hideThemeButton={showHostChrome}
|
||||
hideHeader={false}
|
||||
workspaceScope={activeWorkspaceScope}
|
||||
@@ -1363,6 +1472,7 @@ function Shell({
|
||||
workspaceError={workspaceError}
|
||||
onWorkspaceScopeChange={applyWorkspaceScope}
|
||||
settingsSnapshot={settingsSnapshot}
|
||||
onOpenModelSettings={onOpenModelSettings}
|
||||
/>
|
||||
</div>
|
||||
{view !== "chat" && (
|
||||
@@ -1370,6 +1480,7 @@ function Shell({
|
||||
<SettingsView
|
||||
theme={theme}
|
||||
initialSection={settingsInitialSection}
|
||||
initialSettings={settingsSnapshot}
|
||||
showSidebar={view === "settings"}
|
||||
onToggleTheme={toggle}
|
||||
onBackToChat={onBackToChat}
|
||||
|
||||
@@ -9,15 +9,33 @@ interface CodeBlockProps {
|
||||
language?: string;
|
||||
code: string;
|
||||
className?: string;
|
||||
chrome?: "default" | "none";
|
||||
highlight?: boolean;
|
||||
showLineNumbers?: boolean;
|
||||
wrapLongLines?: boolean;
|
||||
}
|
||||
|
||||
interface HighlightedCodeProps {
|
||||
language?: string;
|
||||
code: string;
|
||||
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 [
|
||||
{ default: SyntaxHighlighter },
|
||||
@@ -30,19 +48,56 @@ const LazyHighlightedCode = lazy(async () => {
|
||||
]);
|
||||
|
||||
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 (
|
||||
<SyntaxHighlighter
|
||||
language={language || "text"}
|
||||
style={isDark ? oneDark : oneLight}
|
||||
style={transparentTheme}
|
||||
customStyle={{
|
||||
background: chrome === "none" ? "transparent" : undefined,
|
||||
margin: 0,
|
||||
padding: "1rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: 1.6,
|
||||
padding: chrome === "none" ? "0.75rem 1rem" : "1rem",
|
||||
fontFamily: CODE_FONT_STACK,
|
||||
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"
|
||||
wrapLongLines
|
||||
showLineNumbers={showLineNumbers}
|
||||
wrapLongLines={wrapLongLines}
|
||||
>
|
||||
{code}
|
||||
</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 (
|
||||
<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"
|
||||
>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -66,11 +147,15 @@ export function CodeBlock({
|
||||
language,
|
||||
code,
|
||||
className,
|
||||
chrome = "default",
|
||||
highlight = true,
|
||||
showLineNumbers = false,
|
||||
wrapLongLines = true,
|
||||
}: CodeBlockProps) {
|
||||
const { t } = useTranslation();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const isDark = useThemeValue() === "dark";
|
||||
const hasChrome = chrome === "default";
|
||||
|
||||
const onCopy = useCallback(() => {
|
||||
if (!navigator.clipboard) return;
|
||||
@@ -83,47 +168,69 @@ export function CodeBlock({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden rounded-lg border",
|
||||
isDark ? "border-white/10" : "border-black/10",
|
||||
"overflow-hidden",
|
||||
hasChrome && "rounded-lg border",
|
||||
hasChrome && (isDark ? "border-white/10" : "border-black/10"),
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"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}
|
||||
{hasChrome ? (
|
||||
<div
|
||||
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
|
||||
? "text-zinc-400 hover:bg-zinc-700 hover:text-zinc-200"
|
||||
: "text-zinc-500 hover:bg-zinc-200 hover:text-zinc-700",
|
||||
? "bg-zinc-800 text-zinc-300"
|
||||
: "bg-zinc-100 text-zinc-600",
|
||||
)}
|
||||
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>
|
||||
<span className="lowercase font-mono">
|
||||
{language || t("code.fallbackLanguage")}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCopy}
|
||||
className={cn(
|
||||
"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 ? (
|
||||
<Suspense fallback={<PlainCodeFallback code={code} />}>
|
||||
<LazyHighlightedCode language={language} code={code} isDark={isDark} />
|
||||
<Suspense
|
||||
fallback={
|
||||
<PlainCodeFallback
|
||||
code={code}
|
||||
chrome={chrome}
|
||||
showLineNumbers={showLineNumbers}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<LazyHighlightedCode
|
||||
language={language}
|
||||
code={code}
|
||||
isDark={isDark}
|
||||
chrome={chrome}
|
||||
showLineNumbers={showLineNumbers}
|
||||
wrapLongLines={wrapLongLines}
|
||||
/>
|
||||
</Suspense>
|
||||
) : (
|
||||
<PlainCodeFallback code={code} />
|
||||
<PlainCodeFallback
|
||||
code={code}
|
||||
chrome={chrome}
|
||||
showLineNumbers={showLineNumbers}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { KeyboardEvent, MouseEvent } from "react";
|
||||
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -6,10 +8,11 @@ import {
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type FileReferenceKind =
|
||||
export type FileReferenceKind =
|
||||
| "default"
|
||||
| "css"
|
||||
| "html"
|
||||
| "javascript"
|
||||
| "json"
|
||||
| "markdown"
|
||||
| "notebook"
|
||||
@@ -24,6 +27,8 @@ interface FileReferenceChipProps {
|
||||
active?: boolean;
|
||||
className?: string;
|
||||
textClassName?: string;
|
||||
previewPath?: string;
|
||||
onOpen?: (path: string) => void;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
@@ -34,12 +39,26 @@ export function FileReferenceChip({
|
||||
active = false,
|
||||
className,
|
||||
textClassName,
|
||||
previewPath,
|
||||
onOpen,
|
||||
testId = "inline-file-path",
|
||||
}: FileReferenceChipProps) {
|
||||
const { directory, name } = splitFilePath(path);
|
||||
const kind = fileKindForPath(path);
|
||||
const displayText = display === "path" ? path.replace(/\\/g, "/") : name;
|
||||
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 (
|
||||
<TooltipProvider delayDuration={500} skipDelayDuration={100}>
|
||||
<Tooltip>
|
||||
@@ -50,10 +69,18 @@ export function FileReferenceChip({
|
||||
<span
|
||||
data-testid={testId}
|
||||
aria-label={fullPath}
|
||||
role={interactive ? "button" : undefined}
|
||||
tabIndex={interactive ? 0 : undefined}
|
||||
onClick={interactive ? openPreview : undefined}
|
||||
onKeyDown={interactive ? onKeyDown : undefined}
|
||||
className={cn(
|
||||
"inline-flex max-w-full items-baseline gap-[0.28em] font-medium leading-[inherit]",
|
||||
"text-sky-600 transition-colors hover:text-sky-700",
|
||||
"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} />
|
||||
@@ -110,7 +137,7 @@ export function isLikelyFilePath(value: string): boolean {
|
||||
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 slash = normalized.lastIndexOf("/");
|
||||
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 name = normalized.split(/[\\/]/).pop() ?? normalized;
|
||||
const ext = name.includes(".") ? name.split(".").pop() ?? "" : "";
|
||||
@@ -134,7 +161,13 @@ function fileKindForPath(path: string): FileReferenceKind {
|
||||
case "jsx":
|
||||
case "tsx":
|
||||
return "react";
|
||||
case "js":
|
||||
case "mjs":
|
||||
case "cjs":
|
||||
return "javascript";
|
||||
case "ts":
|
||||
case "mts":
|
||||
case "cts":
|
||||
return "typescript";
|
||||
case "html":
|
||||
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") {
|
||||
return (
|
||||
<svg
|
||||
@@ -234,6 +287,8 @@ function fileKindLabel(kind: FileReferenceKind): string {
|
||||
return "#";
|
||||
case "html":
|
||||
return "H";
|
||||
case "javascript":
|
||||
return "JS";
|
||||
case "json":
|
||||
return "{}";
|
||||
case "markdown":
|
||||
|
||||
@@ -16,6 +16,7 @@ interface MarkdownTextProps {
|
||||
children: string;
|
||||
className?: string;
|
||||
streaming?: boolean;
|
||||
onOpenFilePreview?: (path: string) => void;
|
||||
}
|
||||
|
||||
const loadMarkdownRenderer = () => import("@/components/MarkdownTextRenderer");
|
||||
@@ -25,13 +26,19 @@ const MemoizedMarkdownRenderer = memo(function MemoizedMarkdownRenderer({
|
||||
source,
|
||||
className,
|
||||
highlightCode,
|
||||
onOpenFilePreview,
|
||||
}: {
|
||||
source: string;
|
||||
className?: string;
|
||||
highlightCode: boolean;
|
||||
onOpenFilePreview?: (path: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<LazyMarkdownRenderer className={className} highlightCode={highlightCode}>
|
||||
<LazyMarkdownRenderer
|
||||
className={className}
|
||||
highlightCode={highlightCode}
|
||||
onOpenFilePreview={onOpenFilePreview}
|
||||
>
|
||||
{source}
|
||||
</LazyMarkdownRenderer>
|
||||
);
|
||||
@@ -55,6 +62,7 @@ export function MarkdownText({
|
||||
children,
|
||||
className,
|
||||
streaming = false,
|
||||
onOpenFilePreview,
|
||||
}: MarkdownTextProps) {
|
||||
const renderedSource = useStreamingMarkdownSource(children, streaming);
|
||||
const highlightCode = streaming
|
||||
@@ -82,6 +90,7 @@ export function MarkdownText({
|
||||
source={renderedSource}
|
||||
className={className}
|
||||
highlightCode={highlightCode}
|
||||
onOpenFilePreview={onOpenFilePreview}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
@@ -19,6 +19,7 @@ interface MarkdownTextRendererProps {
|
||||
children: string;
|
||||
className?: string;
|
||||
highlightCode?: boolean;
|
||||
onOpenFilePreview?: (path: string) => void;
|
||||
}
|
||||
|
||||
type MarkdownAstNode = {
|
||||
@@ -187,6 +188,38 @@ function nodeText(value: ReactNode): string {
|
||||
.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 } {
|
||||
let text = "";
|
||||
let href: string | undefined;
|
||||
@@ -326,6 +359,7 @@ export default function MarkdownTextRenderer({
|
||||
children,
|
||||
className,
|
||||
highlightCode = true,
|
||||
onOpenFilePreview,
|
||||
}: MarkdownTextRendererProps) {
|
||||
const components = useMemo<Components>(
|
||||
() => ({
|
||||
@@ -344,7 +378,7 @@ export default function MarkdownTextRenderer({
|
||||
}
|
||||
const raw = String(kids).replace(/\n$/, "");
|
||||
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. */
|
||||
const widePlainBlock = raw.includes("\n") || raw.length > 120;
|
||||
@@ -405,6 +439,18 @@ export default function MarkdownTextRenderer({
|
||||
);
|
||||
},
|
||||
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 (
|
||||
<a
|
||||
href={href}
|
||||
@@ -495,7 +541,7 @@ export default function MarkdownTextRenderer({
|
||||
);
|
||||
},
|
||||
}),
|
||||
[highlightCode],
|
||||
[highlightCode, onOpenFilePreview],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -33,6 +33,7 @@ interface MessageBubbleProps {
|
||||
showAssistantCopyAction?: boolean;
|
||||
cliApps?: CliAppInfo[];
|
||||
mcpPresets?: McpPresetInfo[];
|
||||
onOpenFilePreview?: (path: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -49,6 +50,7 @@ export function MessageBubble({
|
||||
showAssistantCopyAction = true,
|
||||
cliApps = [],
|
||||
mcpPresets = [],
|
||||
onOpenFilePreview,
|
||||
}: MessageBubbleProps) {
|
||||
const { t } = useTranslation();
|
||||
const [copied, setCopied] = useState(false);
|
||||
@@ -142,13 +144,23 @@ export function MessageBubble({
|
||||
return (
|
||||
<div className={cn("w-full text-[15px]", baseAnim)} style={{ lineHeight: "var(--cjk-line-height)" }}>
|
||||
{hasReasoning ? (
|
||||
<ReasoningBubble text={reasoning} streaming={reasoningStreaming} hasBodyBelow={!empty} />
|
||||
<ReasoningBubble
|
||||
text={reasoning}
|
||||
streaming={reasoningStreaming}
|
||||
hasBodyBelow={!empty}
|
||||
onOpenFilePreview={onOpenFilePreview}
|
||||
/>
|
||||
) : null}
|
||||
{empty && message.isStreaming && !hasReasoning ? (
|
||||
<TypingDots />
|
||||
) : 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}
|
||||
{showAssistantFooterRow ? (
|
||||
<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;
|
||||
/** When true, skip the slide-in wrapper (used inside ``AgentActivityCluster``). */
|
||||
embeddedInCluster?: boolean;
|
||||
onOpenFilePreview?: (path: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -509,6 +522,7 @@ export function ReasoningBubble({
|
||||
streaming,
|
||||
hasBodyBelow,
|
||||
embeddedInCluster = false,
|
||||
onOpenFilePreview,
|
||||
}: ReasoningBubbleProps) {
|
||||
const { t } = useTranslation();
|
||||
const [userToggled, setUserToggled] = useState(false);
|
||||
@@ -567,6 +581,7 @@ export function ReasoningBubble({
|
||||
>
|
||||
<MarkdownText
|
||||
streaming={streaming}
|
||||
onOpenFilePreview={onOpenFilePreview}
|
||||
className={cn(
|
||||
"text-[12.5px] italic text-muted-foreground/88",
|
||||
"prose-p:my-1.5 prose-li:my-0.5",
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
Bot,
|
||||
Brain,
|
||||
Check,
|
||||
CircleAlert,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
@@ -70,9 +71,16 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import {
|
||||
createModelConfiguration,
|
||||
fetchSettings,
|
||||
fetchSettingsUsage,
|
||||
fetchCliApps,
|
||||
fetchMcpPresets,
|
||||
fetchProviderModels,
|
||||
@@ -99,6 +107,7 @@ import {
|
||||
providerDisplayLabel,
|
||||
} from "@/lib/provider-brand";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { shortWorkspacePath } from "@/lib/workspace";
|
||||
import { useClient } from "@/providers/ClientProvider";
|
||||
import type {
|
||||
CliAppInfo,
|
||||
@@ -167,7 +176,6 @@ type ProviderApiType = "auto" | "chat_completions" | "responses";
|
||||
type ProviderForm = { apiKey: string; apiBase: string; apiType: ProviderApiType };
|
||||
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 DEFERRED_MODEL_LIST_PROVIDERS = new Set([
|
||||
"aihubmix",
|
||||
@@ -265,6 +273,7 @@ const DEFAULT_CUSTOM_MCP_FORM: CustomMcpForm = {
|
||||
interface SettingsViewProps {
|
||||
theme: "light" | "dark";
|
||||
initialSection?: SettingsSectionKey;
|
||||
initialSettings?: SettingsPayload | null;
|
||||
showSidebar?: boolean;
|
||||
onToggleTheme: () => void;
|
||||
onBackToChat: () => void;
|
||||
@@ -311,9 +320,130 @@ function editableDefaultProvider(payload: SettingsPayload): string {
|
||||
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({
|
||||
theme,
|
||||
initialSection = "overview",
|
||||
initialSettings = null,
|
||||
showSidebar = true,
|
||||
onToggleTheme,
|
||||
onBackToChat,
|
||||
@@ -328,10 +458,10 @@ export function SettingsView({
|
||||
}: SettingsViewProps) {
|
||||
const { t } = useTranslation();
|
||||
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 [mcpPresets, setMcpPresets] = useState<McpPresetsPayload | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loading, setLoading] = useState(() => initialSettings === null);
|
||||
const [cliAppsLoading, setCliAppsLoading] = useState(true);
|
||||
const [mcpPresetsLoading, setMcpPresetsLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -370,26 +500,18 @@ export function SettingsView({
|
||||
EMPTY_PENDING_RESTART_SECTIONS,
|
||||
);
|
||||
const [localPrefs, setLocalPrefs] = useState<LocalPreferences>(() => readLocalPreferences());
|
||||
const [webSearchForm, setWebSearchForm] = useState<WebSearchSettingsUpdate>({
|
||||
provider: "duckduckgo",
|
||||
apiKey: "",
|
||||
baseUrl: "",
|
||||
maxResults: 5,
|
||||
timeout: 30,
|
||||
useJinaReader: true,
|
||||
});
|
||||
const [imageGenerationForm, setImageGenerationForm] = useState<ImageGenerationSettingsUpdate>({
|
||||
enabled: false,
|
||||
provider: "openrouter",
|
||||
model: "openai/gpt-5.4-image-2",
|
||||
defaultAspectRatio: "1:1",
|
||||
defaultImageSize: "1K",
|
||||
maxImagesPerTurn: 4,
|
||||
});
|
||||
const [networkSafetyForm, setNetworkSafetyForm] = useState<NetworkSafetySettingsUpdate>({
|
||||
webuiAllowLocalServiceAccess: true,
|
||||
webuiDefaultAccessMode: "default",
|
||||
});
|
||||
const [webSearchForm, setWebSearchForm] = useState<WebSearchSettingsUpdate>(() =>
|
||||
initialSettings ? webSearchFormFromPayload(initialSettings) : DEFAULT_WEB_SEARCH_FORM,
|
||||
);
|
||||
const [imageGenerationForm, setImageGenerationForm] = useState<ImageGenerationSettingsUpdate>(
|
||||
() =>
|
||||
initialSettings
|
||||
? imageGenerationFormFromPayload(initialSettings)
|
||||
: DEFAULT_IMAGE_GENERATION_FORM,
|
||||
);
|
||||
const [networkSafetyForm, setNetworkSafetyForm] = useState<NetworkSafetySettingsUpdate>(() =>
|
||||
initialSettings ? networkSafetyFormFromPayload(initialSettings) : DEFAULT_NETWORK_SAFETY_FORM,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveSection(initialSection);
|
||||
@@ -404,17 +526,9 @@ export function SettingsView({
|
||||
);
|
||||
const [webSearchKeyVisible, setWebSearchKeyVisible] = useState(false);
|
||||
const [webSearchKeyEditing, setWebSearchKeyEditing] = useState(false);
|
||||
const [form, setForm] = useState<AgentSettingsDraft>({
|
||||
model: "",
|
||||
provider: "",
|
||||
modelPreset: "default",
|
||||
presetLabel: "Default",
|
||||
contextWindowTokens: 65_536,
|
||||
timezone: "UTC",
|
||||
botName: "nanobot",
|
||||
botIcon: "",
|
||||
toolHintMaxLength: 40,
|
||||
});
|
||||
const [form, setForm] = useState<AgentSettingsDraft>(() =>
|
||||
initialSettings ? agentDraftFromPayload(initialSettings) : DEFAULT_AGENT_SETTINGS_DRAFT,
|
||||
);
|
||||
|
||||
const text = useCallback(
|
||||
(key: string, fallback: string, options?: Record<string, unknown>) =>
|
||||
@@ -423,59 +537,27 @@ export function SettingsView({
|
||||
);
|
||||
|
||||
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);
|
||||
setForm({
|
||||
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,
|
||||
});
|
||||
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),
|
||||
});
|
||||
setForm(agentDraftFromPayload(payload));
|
||||
setWebSearchForm((prev) => webSearchFormFromPayload(payload, prev));
|
||||
setImageGenerationForm(imageGenerationFormFromPayload(payload));
|
||||
setNetworkSafetyForm(networkSafetyFormFromPayload(payload));
|
||||
if (payload.restart_required_sections) {
|
||||
setPendingRestartSections({
|
||||
runtime: payload.restart_required_sections.includes("runtime"),
|
||||
browser: payload.restart_required_sections.includes("browser"),
|
||||
image: payload.restart_required_sections.includes("image"),
|
||||
});
|
||||
setPendingRestartSections(pendingRestartSectionsFromPayload(payload));
|
||||
}
|
||||
onSettingsChange?.(payload);
|
||||
}, [onSettingsChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialSettings || settings !== null) return;
|
||||
applyPayload(initialSettings);
|
||||
setLoading(false);
|
||||
}, [applyPayload, initialSettings, settings]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
const showLoading = settings === null;
|
||||
if (showLoading) setLoading(true);
|
||||
fetchSettings(token)
|
||||
.then((payload) => {
|
||||
if (!cancelled) {
|
||||
@@ -484,7 +566,7 @@ export function SettingsView({
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setError((err as Error).message);
|
||||
if (!cancelled && showLoading) setError((err as Error).message);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
@@ -494,6 +576,34 @@ export function SettingsView({
|
||||
};
|
||||
}, [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(() => {
|
||||
if (activeSection !== "apps") return;
|
||||
let cancelled = false;
|
||||
@@ -1135,8 +1245,6 @@ export function SettingsView({
|
||||
<OverviewSettings
|
||||
settings={settings}
|
||||
requiresRestart={hasPendingRestart}
|
||||
onRestart={restartViaSettingsSurface}
|
||||
isRestarting={isRestarting || hostEngineApplying}
|
||||
showBrandLogos={localPrefs.brandLogos}
|
||||
onSelectSection={selectSection}
|
||||
/>
|
||||
@@ -1354,10 +1462,10 @@ export function SettingsView({
|
||||
)}
|
||||
>
|
||||
<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")}
|
||||
</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))}
|
||||
</h1>
|
||||
</div>
|
||||
@@ -1437,7 +1545,7 @@ function SettingsSidebar({
|
||||
{t("settings.backToChat")}
|
||||
</button>
|
||||
<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")}
|
||||
</h2>
|
||||
</div>
|
||||
@@ -1488,15 +1596,11 @@ function SettingsSidebar({
|
||||
function OverviewSettings({
|
||||
settings,
|
||||
requiresRestart,
|
||||
onRestart,
|
||||
isRestarting,
|
||||
onSelectSection,
|
||||
showBrandLogos,
|
||||
}: {
|
||||
settings: SettingsPayload;
|
||||
requiresRestart: boolean;
|
||||
onRestart?: () => void;
|
||||
isRestarting?: boolean;
|
||||
onSelectSection: (section: SettingsSectionKey) => void;
|
||||
showBrandLogos: boolean;
|
||||
}) {
|
||||
@@ -1504,6 +1608,16 @@ function OverviewSettings({
|
||||
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||
const activePreset = settings.agent.model_preset || "default";
|
||||
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
|
||||
? tx("settings.values.enabled", "Enabled")
|
||||
: tx("settings.values.disabled", "Disabled");
|
||||
@@ -1515,48 +1629,23 @@ function OverviewSettings({
|
||||
? tx("settings.values.configured", "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 (
|
||||
<div className="space-y-7">
|
||||
<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)]">
|
||||
<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>
|
||||
<TokenUsageHeatmap usage={settings.usage} />
|
||||
</section>
|
||||
|
||||
<section>
|
||||
@@ -1566,8 +1655,8 @@ function OverviewSettings({
|
||||
icon={Bot}
|
||||
valueLogoProvider={activeProvider}
|
||||
title={tx("settings.overview.model", "Current model")}
|
||||
value={settings.agent.model}
|
||||
caption={`${activeProvider} · ${activePreset}`}
|
||||
value={activeModelValue}
|
||||
caption={activeModelCaption}
|
||||
showBrandLogos={showBrandLogos}
|
||||
onClick={() => onSelectSection("models")}
|
||||
/>
|
||||
@@ -1603,20 +1692,16 @@ function OverviewSettings({
|
||||
<SettingsGroup>
|
||||
<OverviewListRow
|
||||
icon={Server}
|
||||
title={tx("settings.rows.gateway", "Gateway")}
|
||||
value={`${settings.runtime.gateway_host}:${settings.runtime.gateway_port}`}
|
||||
caption={
|
||||
requiresRestart
|
||||
? tx("settings.values.restartPending", "Restart pending")
|
||||
: tx("settings.values.ready", "Ready")
|
||||
}
|
||||
title={runtimeTitle}
|
||||
value={runtimeValue}
|
||||
caption={runtimeCaption}
|
||||
onClick={() => onSelectSection("runtime")}
|
||||
/>
|
||||
<OverviewListRow
|
||||
icon={HardDrive}
|
||||
title={tx("settings.overview.workspace", "Workspace")}
|
||||
value={settings.runtime.workspace_path}
|
||||
caption={settings.runtime.config_path}
|
||||
value={tx("settings.values.defaultWorkspace", "Default workspace")}
|
||||
caption={workspaceCaption}
|
||||
onClick={() => onSelectSection("runtime")}
|
||||
/>
|
||||
</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({
|
||||
theme,
|
||||
onToggleTheme,
|
||||
@@ -1885,9 +2183,8 @@ function ModelsSettings({
|
||||
const { t } = useTranslation();
|
||||
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||
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 selectableProviders = uniqueProviders([...configuredProviders, ...oauthProviders]);
|
||||
const selectableProviders = uniqueProviders(configuredProviders);
|
||||
const providerOptions = showAutoProvider
|
||||
? [{ name: "auto", label: tx("settings.values.auto", "Auto") }, ...selectableProviders]
|
||||
: selectableProviders;
|
||||
@@ -1900,6 +2197,7 @@ function ModelsSettings({
|
||||
const selectedProviderNeedsSignIn =
|
||||
selectedProvider?.auth_type === "oauth" && !selectedProvider.configured;
|
||||
const selectedProviderSigningIn = providerSaving === selectedProvider?.name;
|
||||
const selectedProviderConfigured = settingsProviderConfigured(settings, form.provider);
|
||||
const modelFieldsMissing =
|
||||
!form.model.trim() ||
|
||||
!form.provider.trim() ||
|
||||
@@ -1918,6 +2216,7 @@ function ModelsSettings({
|
||||
settings={settings}
|
||||
draftModel={form.model}
|
||||
draftProvider={form.provider}
|
||||
providerConfigured={selectedProviderConfigured}
|
||||
showProviderLogos={showBrandLogos}
|
||||
onChange={(modelPreset) => {
|
||||
const nextPreset = settings.model_presets.find((preset) => preset.name === modelPreset);
|
||||
@@ -4060,10 +4359,12 @@ function RuntimeSettings({
|
||||
<section>
|
||||
<SettingsSectionTitle>{t("settings.sections.system")}</SettingsSectionTitle>
|
||||
<SettingsGroup>
|
||||
<ReadOnlyRow
|
||||
title={tx("settings.rows.gateway", "Gateway")}
|
||||
value={`${settings.runtime.gateway_host}:${settings.runtime.gateway_port}`}
|
||||
/>
|
||||
{!isNativeHost ? (
|
||||
<ReadOnlyRow
|
||||
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={tx("settings.rows.workspacePath", "Default workspace")} value={settings.runtime.workspace_path} />
|
||||
{onRestart && !requiresRestartPending ? (
|
||||
@@ -4369,7 +4670,14 @@ function ModelIdPicker({
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const effectiveProvider =
|
||||
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 providerModels = payload?.models ?? [];
|
||||
const visibleModels = providerModels
|
||||
@@ -4390,13 +4698,15 @@ function ModelIdPicker({
|
||||
const hasModelList = payload?.status === "available";
|
||||
const showModels = Boolean(hasModelList && payload && (!isCatalog || normalizedQuery));
|
||||
const customCandidate = query.trim();
|
||||
const allowCustomModel = !providerRequiresConfiguration;
|
||||
const exactQueryMatch = providerModels.some((model) => model.id === customCandidate);
|
||||
const providerModelCount = payload?.model_count ?? providerModels.length;
|
||||
const modelUnconfigured = !value.trim() || !providerConfigured;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setQuery("");
|
||||
}, [open, effectiveProvider]);
|
||||
setQuery(providerUsesManualModelIds || !hasConcreteProvider ? value : "");
|
||||
}, [open, effectiveProvider, hasConcreteProvider, providerUsesManualModelIds, value]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !shouldFetchModels) {
|
||||
@@ -4443,7 +4753,11 @@ function ModelIdPicker({
|
||||
)}
|
||||
>
|
||||
<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">
|
||||
{model.label ?? model.id}
|
||||
</span>
|
||||
@@ -4467,7 +4781,11 @@ function ModelIdPicker({
|
||||
)}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<ProviderPickerIcon provider={effectiveProvider} showBrandLogos={showProviderLogos} />
|
||||
<ProviderPickerIcon
|
||||
provider={effectiveProvider}
|
||||
showBrandLogos={showProviderLogos}
|
||||
unconfigured={modelUnconfigured}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-0 truncate font-medium",
|
||||
@@ -4500,7 +4818,15 @@ function ModelIdPicker({
|
||||
</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">
|
||||
{tx("settings.models.autoProviderCustomOnly", "Auto provider mode uses custom model IDs.")}
|
||||
</div>
|
||||
@@ -4544,7 +4870,7 @@ function ModelIdPicker({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{customCandidate && !exactQueryMatch && customCandidate !== value ? (
|
||||
{allowCustomModel && customCandidate && !exactQueryMatch && customCandidate !== value ? (
|
||||
<>
|
||||
{showModels ? <DropdownMenuSeparator /> : null}
|
||||
<DropdownMenuItem
|
||||
@@ -4581,17 +4907,31 @@ function formatContextWindow(tokens: number): string {
|
||||
function ProviderPickerIcon({
|
||||
provider,
|
||||
showBrandLogos,
|
||||
unconfigured = false,
|
||||
}: {
|
||||
provider: string;
|
||||
showBrandLogos: boolean;
|
||||
unconfigured?: boolean;
|
||||
}) {
|
||||
const [logoIndex, setLogoIndex] = useState(0);
|
||||
const brand = providerBrand(provider);
|
||||
const Icon = PROVIDER_ICONS[provider] ?? Sparkles;
|
||||
const Icon = PROVIDER_ICONS[provider] ?? Hexagon;
|
||||
const logoUrl = brand?.logoUrls[logoIndex];
|
||||
|
||||
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) {
|
||||
return (
|
||||
<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({
|
||||
icon: Icon,
|
||||
}: {
|
||||
@@ -5090,6 +5404,7 @@ function ModelPresetPicker({
|
||||
settings,
|
||||
draftModel,
|
||||
draftProvider,
|
||||
providerConfigured,
|
||||
showProviderLogos,
|
||||
onChange,
|
||||
onCreateConfiguration,
|
||||
@@ -5099,6 +5414,7 @@ function ModelPresetPicker({
|
||||
settings: SettingsPayload;
|
||||
draftModel: string;
|
||||
draftProvider: string;
|
||||
providerConfigured: boolean;
|
||||
showProviderLogos: boolean;
|
||||
onChange: (preset: string) => void;
|
||||
onCreateConfiguration: () => void;
|
||||
@@ -5126,6 +5442,7 @@ function ModelPresetPicker({
|
||||
settings={settings}
|
||||
draftModel={draftModel}
|
||||
draftProvider={draftProvider}
|
||||
forceUnconfigured={selectedPreset?.is_default ? !providerConfigured : undefined}
|
||||
showProviderLogos={showProviderLogos}
|
||||
compact
|
||||
/>
|
||||
@@ -5190,6 +5507,7 @@ function ModelPresetOptionContent({
|
||||
settings,
|
||||
draftModel,
|
||||
draftProvider,
|
||||
forceUnconfigured,
|
||||
showProviderLogos,
|
||||
compact = false,
|
||||
}: {
|
||||
@@ -5197,27 +5515,50 @@ function ModelPresetOptionContent({
|
||||
settings: SettingsPayload;
|
||||
draftModel: string;
|
||||
draftProvider: string;
|
||||
forceUnconfigured?: boolean;
|
||||
showProviderLogos: boolean;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||
const provider = modelPresetProviderKey(preset, settings, {
|
||||
draftProvider: preset.is_default ? draftProvider : undefined,
|
||||
});
|
||||
const model = preset.is_default ? draftModel : preset.model;
|
||||
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 (
|
||||
<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="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
|
||||
className={cn(
|
||||
"mt-0.5 block truncate text-muted-foreground",
|
||||
compact ? "text-[11.5px]" : "text-[12px]",
|
||||
)}
|
||||
>
|
||||
{providerName}
|
||||
{preset.label ? ` · ${preset.label}` : ""}
|
||||
{caption}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
@@ -173,6 +173,7 @@ interface AgentActivityClusterProps {
|
||||
turnLatencyMs?: number;
|
||||
cliApps?: CliAppInfo[];
|
||||
mcpPresets?: McpPresetInfo[];
|
||||
onOpenFilePreview?: (path: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -186,6 +187,7 @@ export function AgentActivityCluster({
|
||||
turnLatencyMs,
|
||||
cliApps = [],
|
||||
mcpPresets = [],
|
||||
onOpenFilePreview,
|
||||
}: AgentActivityClusterProps) {
|
||||
const { t } = useTranslation();
|
||||
const fileEdits = useMemo(
|
||||
@@ -423,6 +425,7 @@ export function AgentActivityCluster({
|
||||
added={added}
|
||||
deleted={deleted}
|
||||
hasDiffStats={hasDiffStats}
|
||||
onOpenFilePreview={onOpenFilePreview}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -449,6 +452,8 @@ export function AgentActivityCluster({
|
||||
<FileReferenceChip
|
||||
path={singleFilePath}
|
||||
tooltipPath={singleFileTooltipPath}
|
||||
previewPath={singleFileTooltipPath || singleFilePath}
|
||||
onOpen={onOpenFilePreview}
|
||||
active={hasLiveEditingFiles}
|
||||
className="-my-0.5 min-w-0"
|
||||
textClassName="text-xs"
|
||||
@@ -494,6 +499,7 @@ export function AgentActivityCluster({
|
||||
key={m.id}
|
||||
text={m.reasoning ?? ""}
|
||||
streaming={isTurnStreaming && !!m.reasoningStreaming}
|
||||
onOpenFilePreview={onOpenFilePreview}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -510,7 +516,12 @@ export function AgentActivityCluster({
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
{fileEdits.length ? <FileEditGroup edits={fileEdits} /> : null}
|
||||
{fileEdits.length ? (
|
||||
<FileEditGroup
|
||||
edits={fileEdits}
|
||||
onOpenFilePreview={onOpenFilePreview}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -537,6 +548,7 @@ function FileEditFlatActivity({
|
||||
added,
|
||||
deleted,
|
||||
hasDiffStats,
|
||||
onOpenFilePreview,
|
||||
}: {
|
||||
edits: FileEditSummary[];
|
||||
active: boolean;
|
||||
@@ -550,6 +562,7 @@ function FileEditFlatActivity({
|
||||
added: number;
|
||||
deleted: number;
|
||||
hasDiffStats: boolean;
|
||||
onOpenFilePreview?: (path: string) => void;
|
||||
}) {
|
||||
const showRows = edits.length > 1 || edits.some((edit) => edit.status === "error" || edit.pending);
|
||||
return (
|
||||
@@ -569,6 +582,8 @@ function FileEditFlatActivity({
|
||||
<FileReferenceChip
|
||||
path={singleFilePath}
|
||||
tooltipPath={singleFileTooltipPath}
|
||||
previewPath={singleFileTooltipPath || singleFilePath}
|
||||
onOpen={onOpenFilePreview}
|
||||
active={hasLiveEditingFiles}
|
||||
className="-my-0.5 min-w-0"
|
||||
textClassName="text-xs"
|
||||
@@ -583,7 +598,7 @@ function FileEditFlatActivity({
|
||||
</div>
|
||||
{showRows ? (
|
||||
<div className="mt-0.5 pl-4">
|
||||
<FileEditGroup edits={edits} />
|
||||
<FileEditGroup edits={edits} onOpenFilePreview={onOpenFilePreview} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -34,14 +34,16 @@ interface PromptMarker {
|
||||
}
|
||||
|
||||
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_BUCKET_HEIGHT_PX = 12;
|
||||
const DENSE_BUCKET_FALLBACK_COUNT = 32;
|
||||
const DENSE_BUCKET_MAX_COUNT = 42;
|
||||
const MARKER_MIN_GAP_PX = 9;
|
||||
const MARKER_BASE_WIDTH_PX = 26;
|
||||
const MARKER_MAX_WIDTH_PX = 42;
|
||||
const MARKER_BASE_WIDTH_PX = 16;
|
||||
const MARKER_MAX_WIDTH_PX = 28;
|
||||
const MEASURE_RETRY_FRAMES = 4;
|
||||
const RAIL_REVEAL_MS = 1400;
|
||||
|
||||
export function PromptRail({
|
||||
bottomOffset,
|
||||
@@ -52,6 +54,19 @@ export function PromptRail({
|
||||
const promptAnchors = useMemo(() => userPromptAnchors(messages), [messages]);
|
||||
const [markers, setMarkers] = useState<PromptMarker[]>([]);
|
||||
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 scrollEl = scrollRef.current;
|
||||
@@ -74,8 +89,18 @@ export function PromptRail({
|
||||
}, [promptAnchors, scrollRef]);
|
||||
|
||||
useEffect(() => {
|
||||
updateMarkers();
|
||||
}, [updateMarkers]);
|
||||
let frame = 0;
|
||||
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(() => {
|
||||
const scrollEl = scrollRef.current;
|
||||
@@ -84,6 +109,7 @@ export function PromptRail({
|
||||
let frame = 0;
|
||||
const schedule = () => {
|
||||
window.cancelAnimationFrame(frame);
|
||||
revealTemporarily();
|
||||
frame = window.requestAnimationFrame(updateMarkers);
|
||||
};
|
||||
|
||||
@@ -94,7 +120,7 @@ export function PromptRail({
|
||||
scrollEl.removeEventListener("scroll", schedule);
|
||||
window.removeEventListener("resize", schedule);
|
||||
};
|
||||
}, [scrollRef, updateMarkers]);
|
||||
}, [revealTemporarily, scrollRef, updateMarkers]);
|
||||
|
||||
useEffect(() => {
|
||||
const scrollEl = scrollRef.current;
|
||||
@@ -105,22 +131,36 @@ export function PromptRail({
|
||||
return () => observer.disconnect();
|
||||
}, [scrollRef, updateMarkers]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (revealTimeoutRef.current !== null) {
|
||||
window.clearTimeout(revealTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (markers.length === 0) return null;
|
||||
|
||||
const maxMarkerCount = Math.max(...markers.map((marker) => marker.count));
|
||||
const activeMarkerIndex = markers.findIndex((marker) =>
|
||||
marker.ids.includes(activePromptId ?? ""),
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={railRef}
|
||||
aria-label="User prompt navigation"
|
||||
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",
|
||||
)}
|
||||
style={{ bottom: Math.max(80, bottomOffset) }}
|
||||
>
|
||||
{markers.map((marker) => {
|
||||
const index = markers.indexOf(marker);
|
||||
const active = marker.ids.includes(activePromptId ?? "");
|
||||
const nearActive = activeMarkerIndex < 0 || Math.abs(index - activeMarkerIndex) <= 1;
|
||||
return (
|
||||
<button
|
||||
key={marker.ids.join("|")}
|
||||
@@ -129,12 +169,16 @@ export function PromptRail({
|
||||
aria-label={`Jump to prompt: ${marker.label}`}
|
||||
onClick={() => jumpToPrompt(scrollRef.current, marker.ids[marker.ids.length - 1])}
|
||||
className={cn(
|
||||
"pointer-events-auto absolute right-0 h-1.5 -translate-y-1/2 rounded-full",
|
||||
"bg-muted-foreground/30 transition-all duration-150",
|
||||
"hover:bg-blue-500/80 focus-visible:bg-blue-500",
|
||||
"absolute right-0 h-[3px] -translate-y-1/2 rounded-full",
|
||||
"bg-foreground/20 transition-[background-color,opacity,transform,width] duration-200",
|
||||
"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",
|
||||
marker.count > 1 && "bg-muted-foreground/45",
|
||||
active && "bg-foreground shadow-sm",
|
||||
marker.count > 1 && "bg-foreground/30",
|
||||
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={{
|
||||
top: `${marker.topPercent}%`,
|
||||
|
||||
@@ -94,6 +94,8 @@ interface ThreadComposerProps {
|
||||
modelLabel?: string | null;
|
||||
modelProvider?: string | null;
|
||||
modelProviderLabel?: string | null;
|
||||
modelNeedsSetup?: boolean;
|
||||
onModelBadgeClick?: () => void;
|
||||
variant?: "thread" | "hero";
|
||||
slashCommands?: SlashCommand[];
|
||||
cliApps?: CliAppInfo[];
|
||||
@@ -647,6 +649,8 @@ export function ThreadComposer({
|
||||
modelLabel = null,
|
||||
modelProvider = null,
|
||||
modelProviderLabel = null,
|
||||
modelNeedsSetup = false,
|
||||
onModelBadgeClick,
|
||||
variant = "thread",
|
||||
slashCommands = [],
|
||||
cliApps = [],
|
||||
@@ -759,17 +763,21 @@ export function ThreadComposer({
|
||||
);
|
||||
const hasErrors = images.some((img) => img.status === "error");
|
||||
|
||||
const hasComposerContent = value.trim().length > 0 || readyImages.length > 0;
|
||||
const canSend =
|
||||
!disabled
|
||||
&& !modelNeedsSetup
|
||||
&& !encoding
|
||||
&& !hasErrors
|
||||
&& (value.trim().length > 0 || readyImages.length > 0);
|
||||
&& hasComposerContent;
|
||||
const canOpenModelSettings = Boolean(modelNeedsSetup && onModelBadgeClick && !disabled);
|
||||
const canQueueGuidance =
|
||||
isStreaming
|
||||
&& !disabled
|
||||
&& !modelNeedsSetup
|
||||
&& !encoding
|
||||
&& !hasErrors
|
||||
&& (value.trim().length > 0 || readyImages.length > 0)
|
||||
&& hasComposerContent
|
||||
&& !value.trimStart().startsWith("/");
|
||||
|
||||
const slashQuery = useMemo(() => {
|
||||
@@ -1181,6 +1189,10 @@ export function ThreadComposer({
|
||||
}, [onStop, queuedPrompts.length]);
|
||||
|
||||
const submit = useCallback(() => {
|
||||
if (modelNeedsSetup) {
|
||||
onModelBadgeClick?.();
|
||||
return;
|
||||
}
|
||||
if (!canSend) return;
|
||||
const trimmed = value.trim();
|
||||
const content = trimmed;
|
||||
@@ -1219,6 +1231,8 @@ export function ThreadComposer({
|
||||
canSend,
|
||||
clear,
|
||||
clearComposerText,
|
||||
modelNeedsSetup,
|
||||
onModelBadgeClick,
|
||||
onSend,
|
||||
readyImages,
|
||||
value,
|
||||
@@ -1533,24 +1547,32 @@ export function ThreadComposer({
|
||||
label={modelLabel}
|
||||
provider={modelProvider}
|
||||
providerLabel={modelProviderLabel}
|
||||
needsSetup={modelNeedsSetup}
|
||||
isHero={isHero}
|
||||
onClick={modelNeedsSetup ? onModelBadgeClick : undefined}
|
||||
/>
|
||||
) : null}
|
||||
<Button
|
||||
type={showStopButton ? "button" : "submit"}
|
||||
type={showStopButton || modelNeedsSetup ? "button" : "submit"}
|
||||
size="icon"
|
||||
disabled={showStopButton ? disabled : !canSend}
|
||||
aria-label={showStopButton ? t("thread.composer.stop") : t("thread.composer.send")}
|
||||
onClick={showStopButton ? handleStop : undefined}
|
||||
disabled={showStopButton ? disabled : !canSend && !canOpenModelSettings}
|
||||
aria-label={
|
||||
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(
|
||||
"rounded-full transition-transform",
|
||||
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"
|
||||
: 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_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_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 disabled:bg-foreground disabled:text-background",
|
||||
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 ? (
|
||||
@@ -1766,44 +1788,59 @@ function ComposerModelBadge({
|
||||
label,
|
||||
provider,
|
||||
providerLabel,
|
||||
needsSetup,
|
||||
isHero,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
provider?: string | null;
|
||||
providerLabel?: string | null;
|
||||
needsSetup?: boolean;
|
||||
isHero: boolean;
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
const inferredProvider = provider || inferProviderFromModelName(label);
|
||||
const inferredProvider = needsSetup ? null : provider || inferProviderFromModelName(label);
|
||||
const brand = providerBrand(inferredProvider);
|
||||
const [logoIndex, setLogoIndex] = useState(0);
|
||||
const logoUrl = brand?.logoUrls[logoIndex];
|
||||
const showLogo = !!logoUrl;
|
||||
const title = providerLabel ? `${label} · ${providerLabel}` : label;
|
||||
const interactive = Boolean(onClick);
|
||||
const Container = interactive ? "button" : "span";
|
||||
|
||||
useEffect(() => setLogoIndex(0), [inferredProvider]);
|
||||
|
||||
return (
|
||||
<span
|
||||
<Container
|
||||
title={title}
|
||||
type={interactive ? "button" : undefined}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"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)]",
|
||||
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]",
|
||||
)}
|
||||
>
|
||||
<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(
|
||||
"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",
|
||||
)}
|
||||
style={{
|
||||
borderColor: brand ? `${brand.color}28` : undefined,
|
||||
boxShadow: brand ? `inset 0 0 0 1px ${brand.color}18` : undefined,
|
||||
borderColor: !needsSetup && brand ? `${brand.color}28` : undefined,
|
||||
boxShadow: !needsSetup && brand ? `inset 0 0 0 1px ${brand.color}18` : undefined,
|
||||
}}
|
||||
aria-hidden
|
||||
>
|
||||
{showLogo ? (
|
||||
{needsSetup ? (
|
||||
<CircleHelp className={cn(isHero ? "h-3 w-3" : "h-3.5 w-3.5")} strokeWidth={1.8} />
|
||||
) : showLogo ? (
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt=""
|
||||
@@ -1825,7 +1862,7 @@ function ComposerModelBadge({
|
||||
)}
|
||||
</span>
|
||||
<span className="truncate">{label}</span>
|
||||
</span>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ interface ThreadHeaderProps {
|
||||
theme: "light" | "dark";
|
||||
onToggleTheme: () => void;
|
||||
hideSidebarToggleForHostChrome?: boolean;
|
||||
hostChromeTitleInset?: boolean;
|
||||
hideThemeButton?: boolean;
|
||||
minimal?: boolean;
|
||||
}
|
||||
@@ -20,6 +21,7 @@ export function ThreadHeader({
|
||||
theme,
|
||||
onToggleTheme,
|
||||
hideSidebarToggleForHostChrome = false,
|
||||
hostChromeTitleInset = false,
|
||||
hideThemeButton = false,
|
||||
minimal = false,
|
||||
}: ThreadHeaderProps) {
|
||||
@@ -52,7 +54,12 @@ export function ThreadHeader({
|
||||
}
|
||||
|
||||
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">
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -14,6 +14,7 @@ interface ThreadMessagesProps {
|
||||
onLoadEarlier?: () => void;
|
||||
cliApps?: CliAppInfo[];
|
||||
mcpPresets?: McpPresetInfo[];
|
||||
onOpenFilePreview?: (path: string) => void;
|
||||
}
|
||||
|
||||
export type DisplayUnit = TurnUnit;
|
||||
@@ -33,8 +34,13 @@ export function isFinalAssistantSliceBeforeNextUser(
|
||||
return true;
|
||||
}
|
||||
|
||||
export function buildDisplayUnits(messages: UIMessage[]): DisplayUnit[] {
|
||||
return normalizeActivityTimeline(messages);
|
||||
export function buildDisplayUnits(
|
||||
messages: UIMessage[],
|
||||
isStreaming = false,
|
||||
): DisplayUnit[] {
|
||||
return normalizeActivityTimeline(messages, {
|
||||
preserveTrailingActivity: isStreaming,
|
||||
});
|
||||
}
|
||||
|
||||
export function assistantCopyFlags(units: DisplayUnit[]): boolean[] {
|
||||
@@ -61,9 +67,10 @@ export function ThreadMessages({
|
||||
onLoadEarlier,
|
||||
cliApps = [],
|
||||
mcpPresets = [],
|
||||
onOpenFilePreview,
|
||||
}: ThreadMessagesProps) {
|
||||
const { t } = useTranslation();
|
||||
const units = useMemo(() => buildDisplayUnits(messages), [messages]);
|
||||
const units = useMemo(() => buildDisplayUnits(messages, isStreaming), [isStreaming, messages]);
|
||||
const copyFlags = useMemo(() => assistantCopyFlags(units), [units]);
|
||||
const liveActivityClusterIndices = useMemo(
|
||||
() => isStreaming ? currentActivityClusterIndices(units) : new Set<number>(),
|
||||
@@ -117,6 +124,7 @@ export function ThreadMessages({
|
||||
turnLatencyMs={unit.turnLatencyMs}
|
||||
cliApps={cliApps}
|
||||
mcpPresets={mcpPresets}
|
||||
onOpenFilePreview={onOpenFilePreview}
|
||||
/>
|
||||
) : (
|
||||
<MessageBubble
|
||||
@@ -128,6 +136,7 @@ export function ThreadMessages({
|
||||
}
|
||||
cliApps={cliApps}
|
||||
mcpPresets={mcpPresets}
|
||||
onOpenFilePreview={onOpenFilePreview}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import type { PointerEvent as ReactPointerEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { FilePreviewPanel } from "@/components/FilePreviewPanel";
|
||||
import { ThreadComposer } from "@/components/thread/ThreadComposer";
|
||||
import { ThreadHeader } from "@/components/thread/ThreadHeader";
|
||||
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));
|
||||
}
|
||||
|
||||
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 {
|
||||
session: ChatSummary | null;
|
||||
title: string;
|
||||
@@ -62,6 +81,7 @@ interface ThreadShellProps {
|
||||
theme?: "light" | "dark";
|
||||
onToggleTheme?: () => void;
|
||||
hideSidebarToggleForHostChrome?: boolean;
|
||||
hostChromeTitleInset?: boolean;
|
||||
hideThemeButton?: boolean;
|
||||
hideHeader?: boolean;
|
||||
workspaceScope?: WorkspaceScopePayload | null;
|
||||
@@ -71,6 +91,7 @@ interface ThreadShellProps {
|
||||
workspaceError?: string | null;
|
||||
onWorkspaceScopeChange?: (scope: WorkspaceScopePayload) => void;
|
||||
settingsSnapshot?: SettingsPayload | null;
|
||||
onOpenModelSettings?: () => void;
|
||||
}
|
||||
|
||||
function toModelBadgeLabel(modelName: string | null): string | null {
|
||||
@@ -85,6 +106,7 @@ interface ModelBadgeInfo {
|
||||
label: string | null;
|
||||
provider: string | null;
|
||||
providerLabel: string | null;
|
||||
needsSetup: boolean;
|
||||
}
|
||||
|
||||
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 {
|
||||
const label = toModelBadgeLabel(modelName || settings?.agent.model || null);
|
||||
const provider = resolvedModelProvider(settings, modelName || settings?.agent.model || null);
|
||||
const model = 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 {
|
||||
label,
|
||||
provider,
|
||||
providerLabel: provider ? providerDisplayLabel(settings?.providers ?? [], provider) : null,
|
||||
needsSetup,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -143,6 +173,7 @@ export function ThreadShell({
|
||||
theme = "light",
|
||||
onToggleTheme = () => {},
|
||||
hideSidebarToggleForHostChrome = false,
|
||||
hostChromeTitleInset = false,
|
||||
hideThemeButton = false,
|
||||
hideHeader = false,
|
||||
workspaceScope = null,
|
||||
@@ -152,6 +183,7 @@ export function ThreadShell({
|
||||
workspaceError = null,
|
||||
onWorkspaceScopeChange,
|
||||
settingsSnapshot = null,
|
||||
onOpenModelSettings,
|
||||
}: ThreadShellProps) {
|
||||
const { t } = useTranslation();
|
||||
const chatId = session?.chatId ?? null;
|
||||
@@ -171,6 +203,12 @@ export function ThreadShell({
|
||||
const [settings, setSettings] = useState<SettingsPayload | null>(settingsSnapshot);
|
||||
const [heroGreetingKey, setHeroGreetingKey] = useState(randomHeroGreetingKey);
|
||||
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 messageCacheRef = useRef<Map<string, UIMessage[]>>(new Map());
|
||||
/** 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);
|
||||
}, [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 showHeroComposer = messages.length === 0 && !loading;
|
||||
@@ -212,6 +271,9 @@ export function ThreadShell({
|
||||
() => toModelBadgeInfo(modelName, settings),
|
||||
[modelName, settings],
|
||||
);
|
||||
const modelBadgeLabel = modelBadge.needsSetup
|
||||
? t("thread.composer.modelNotConfigured", { defaultValue: "Model not configured" })
|
||||
: modelBadge.label;
|
||||
useEffect(() => {
|
||||
if (showHeroComposer && !wasShowingHeroComposerRef.current) {
|
||||
setHeroGreetingKey(randomHeroGreetingKey());
|
||||
@@ -482,6 +544,94 @@ export function ThreadShell({
|
||||
[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 = (
|
||||
<>
|
||||
{streamError ? (
|
||||
@@ -500,9 +650,11 @@ export function ThreadShell({
|
||||
? t("thread.composer.placeholderHero")
|
||||
: t("thread.composer.placeholderThread")
|
||||
}
|
||||
modelLabel={modelBadge.label}
|
||||
modelLabel={modelBadgeLabel}
|
||||
modelProvider={modelBadge.provider}
|
||||
modelProviderLabel={modelBadge.providerLabel}
|
||||
modelNeedsSetup={modelBadge.needsSetup}
|
||||
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
|
||||
variant={showHeroComposer ? "hero" : "thread"}
|
||||
slashCommands={slashCommands}
|
||||
cliApps={cliApps}
|
||||
@@ -528,9 +680,11 @@ export function ThreadShell({
|
||||
? t("thread.composer.placeholderOpening")
|
||||
: t("thread.composer.placeholderHero")
|
||||
}
|
||||
modelLabel={modelBadge.label}
|
||||
modelLabel={modelBadgeLabel}
|
||||
modelProvider={modelBadge.provider}
|
||||
modelProviderLabel={modelBadge.providerLabel}
|
||||
modelNeedsSetup={modelBadge.needsSetup}
|
||||
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
|
||||
variant="hero"
|
||||
slashCommands={slashCommands}
|
||||
cliApps={cliApps}
|
||||
@@ -561,29 +715,44 @@ export function ThreadShell({
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
{!hideHeader ? (
|
||||
<ThreadHeader
|
||||
title={title}
|
||||
onToggleSidebar={onToggleSidebar}
|
||||
theme={theme}
|
||||
onToggleTheme={onToggleTheme}
|
||||
hideSidebarToggleForHostChrome={hideSidebarToggleForHostChrome}
|
||||
hideThemeButton={hideThemeButton}
|
||||
minimal={!session && !loading}
|
||||
<section ref={shellRef} className="relative flex min-h-0 flex-1 overflow-hidden">
|
||||
<div className="relative flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||
{!hideHeader ? (
|
||||
<ThreadHeader
|
||||
title={title}
|
||||
onToggleSidebar={onToggleSidebar}
|
||||
theme={theme}
|
||||
onToggleTheme={onToggleTheme}
|
||||
hideSidebarToggleForHostChrome={hideSidebarToggleForHostChrome}
|
||||
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}
|
||||
<ThreadViewport
|
||||
messages={displayMessages}
|
||||
isStreaming={isStreaming}
|
||||
emptyState={emptyState}
|
||||
composer={composer}
|
||||
scrollToBottomSignal={scrollToBottomSignal}
|
||||
conversationKey={historyKey}
|
||||
showScrollToBottomButton={!!session}
|
||||
cliApps={cliApps}
|
||||
mcpPresets={mcpPresets}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ interface ThreadViewportProps {
|
||||
showScrollToBottomButton?: boolean;
|
||||
cliApps?: CliAppInfo[];
|
||||
mcpPresets?: McpPresetInfo[];
|
||||
onOpenFilePreview?: (path: string) => void;
|
||||
}
|
||||
|
||||
const NEAR_BOTTOM_PX = 48;
|
||||
@@ -58,6 +59,7 @@ export function ThreadViewport({
|
||||
showScrollToBottomButton = true,
|
||||
cliApps = [],
|
||||
mcpPresets = [],
|
||||
onOpenFilePreview,
|
||||
}: ThreadViewportProps) {
|
||||
const { t } = useTranslation();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
@@ -256,6 +258,7 @@ export function ThreadViewport({
|
||||
onLoadEarlier={loadEarlierMessages}
|
||||
cliApps={cliApps}
|
||||
mcpPresets={mcpPresets}
|
||||
onOpenFilePreview={onOpenFilePreview}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -106,7 +106,7 @@ export function WorkspaceProjectPicker({
|
||||
|
||||
if (nativeProjectPicker) {
|
||||
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
|
||||
type="button"
|
||||
disabled={disabled || pickingFolder}
|
||||
@@ -133,7 +133,7 @@ export function WorkspaceProjectPicker({
|
||||
}
|
||||
|
||||
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}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
|
||||
@@ -22,18 +22,34 @@ export interface FileEditSummary {
|
||||
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;
|
||||
return (
|
||||
<ul className="space-y-1">
|
||||
{edits.map((edit) => (
|
||||
<FileEditRow key={edit.key} edit={edit} />
|
||||
<FileEditRow
|
||||
key={edit.key}
|
||||
edit={edit}
|
||||
onOpenFilePreview={onOpenFilePreview}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function FileEditRow({ edit }: { edit: FileEditSummary }) {
|
||||
function FileEditRow({
|
||||
edit,
|
||||
onOpenFilePreview,
|
||||
}: {
|
||||
edit: FileEditSummary;
|
||||
onOpenFilePreview?: (path: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const editing = edit.status === "editing";
|
||||
const failed = edit.status === "error";
|
||||
@@ -76,6 +92,8 @@ function FileEditRow({ edit }: { edit: FileEditSummary }) {
|
||||
<FileReferenceChip
|
||||
path={edit.path}
|
||||
tooltipPath={edit.absolute_path}
|
||||
previewPath={edit.absolute_path || edit.path}
|
||||
onOpen={onOpenFilePreview}
|
||||
display="path"
|
||||
active={editing}
|
||||
className="min-w-0"
|
||||
|
||||
@@ -10,9 +10,11 @@ import { ActivityStep } from "./ActivityStep";
|
||||
export function ReasoningRow({
|
||||
text,
|
||||
streaming,
|
||||
onOpenFilePreview,
|
||||
}: {
|
||||
text: string;
|
||||
streaming: boolean;
|
||||
onOpenFilePreview?: (path: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
useEffect(() => {
|
||||
@@ -30,6 +32,7 @@ export function ReasoningRow({
|
||||
{text.trim() ? (
|
||||
<MarkdownText
|
||||
streaming={streaming}
|
||||
onOpenFilePreview={onOpenFilePreview}
|
||||
className={cn(
|
||||
"min-w-0 text-[12.5px] italic text-muted-foreground/78",
|
||||
"prose-p:my-1 prose-li:my-0.5",
|
||||
|
||||
@@ -121,10 +121,13 @@
|
||||
}
|
||||
|
||||
.host-sidebar-glass {
|
||||
background: hsl(var(--sidebar) / 0.94);
|
||||
-webkit-backdrop-filter: saturate(145%) blur(18px);
|
||||
backdrop-filter: saturate(145%) blur(18px);
|
||||
box-shadow:
|
||||
inset -1px 0 0 hsl(var(--border) / 0.36),
|
||||
inset 1px 0 0 hsl(var(--background) / 0.34),
|
||||
18px 0 44px -42px rgb(0 0 0 / 0.42);
|
||||
inset -1px 0 0 hsl(var(--border) / 0.32),
|
||||
inset 1px 0 0 hsl(var(--background) / 0.52),
|
||||
14px 0 32px -30px rgb(0 0 0 / 0.22);
|
||||
}
|
||||
|
||||
.dark .host-window-shell,
|
||||
@@ -135,10 +138,11 @@
|
||||
}
|
||||
|
||||
.dark .host-sidebar-glass {
|
||||
background: hsl(var(--sidebar) / 0.96);
|
||||
box-shadow:
|
||||
inset -1px 0 0 hsl(var(--border) / 0.42),
|
||||
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))) {
|
||||
|
||||
@@ -20,6 +20,7 @@ import type {
|
||||
UIImage,
|
||||
UIFileEdit,
|
||||
UIMessage,
|
||||
UITurnPhase,
|
||||
WorkspaceScopePayload,
|
||||
} from "@/lib/types";
|
||||
|
||||
@@ -34,22 +35,50 @@ interface ActiveAssistantCursor {
|
||||
}
|
||||
|
||||
type PendingStreamEvent =
|
||||
| { kind: "delta"; text: string }
|
||||
| { kind: "reasoning"; text: string };
|
||||
| { kind: "delta"; text: string; turn: UIMessageTurnFields }
|
||||
| { 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"]);
|
||||
|
||||
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
|
||||
* as streaming until ``turn_end`` for visual continuity, but they must not
|
||||
* receive later delta segments. */
|
||||
function findStreamingAssistantIndex(
|
||||
prev: UIMessage[],
|
||||
closedStreamIds: ReadonlySet<string>,
|
||||
turn: UIMessageTurnFields = {},
|
||||
): number | null {
|
||||
for (let i = prev.length - 1; i >= 0; i -= 1) {
|
||||
const m = prev[i];
|
||||
if (m.kind === "trace") continue;
|
||||
if (m.role === "assistant" && m.isStreaming && !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;
|
||||
}
|
||||
return null;
|
||||
@@ -69,6 +98,7 @@ function attachReasoningChunk(
|
||||
segments?: {
|
||||
ensure: () => string;
|
||||
},
|
||||
turn: UIMessageTurnFields = {},
|
||||
): UIMessage[] {
|
||||
for (let i = prev.length - 1; i >= 0; i -= 1) {
|
||||
const candidate = prev[i];
|
||||
@@ -80,6 +110,7 @@ function attachReasoningChunk(
|
||||
// that produced those tool calls.
|
||||
if (candidate.kind === "trace") break;
|
||||
if (candidate.role !== "assistant") continue;
|
||||
if (!matchesTurn(candidate, turn)) break;
|
||||
const activitySegmentId = candidate.activitySegmentId ?? segments?.ensure();
|
||||
const hasAnswer = candidate.content.length > 0;
|
||||
if (hasAnswer) break;
|
||||
@@ -93,6 +124,7 @@ function attachReasoningChunk(
|
||||
reasoning: (candidate.reasoning ?? "") + chunk,
|
||||
reasoningStreaming: true,
|
||||
...(activitySegmentId ? { activitySegmentId } : {}),
|
||||
...turn,
|
||||
};
|
||||
return [...prev.slice(0, i), merged, ...prev.slice(i + 1)];
|
||||
}
|
||||
@@ -109,6 +141,7 @@ function attachReasoningChunk(
|
||||
reasoning: chunk,
|
||||
reasoningStreaming: true,
|
||||
...(activitySegmentId ? { activitySegmentId } : {}),
|
||||
...turn,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
];
|
||||
@@ -122,12 +155,16 @@ function attachReasoningChunk(
|
||||
* the model already produced an answer in a previous turn, so the new
|
||||
* 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];
|
||||
if (!last) return null;
|
||||
if (last.role !== "assistant" || last.kind === "trace") return null;
|
||||
if (last.content.length > 0) return null;
|
||||
if (!last.isStreaming) return null;
|
||||
if (!matchesTurn(last, turn)) return null;
|
||||
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) {
|
||||
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 };
|
||||
return [...prev.slice(0, i), merged, ...prev.slice(i + 1)];
|
||||
}
|
||||
@@ -203,7 +248,7 @@ function absorbCompleteAssistantMessage(
|
||||
message: Omit<UIMessage, "id" | "role" | "createdAt">,
|
||||
): UIMessage[] {
|
||||
const last = prev[prev.length - 1];
|
||||
if (!last || !isReasoningOnlyPlaceholder(last)) {
|
||||
if (!last || !isReasoningOnlyPlaceholder(last) || !matchesTurn(last, message)) {
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
@@ -482,7 +527,10 @@ export function useNanobotStream(
|
||||
return !!closedStreamId;
|
||||
}, []);
|
||||
|
||||
const resolveActiveAssistantIndex = useCallback((prev: UIMessage[]): number | null => {
|
||||
const resolveActiveAssistantIndex = useCallback((
|
||||
prev: UIMessage[],
|
||||
turn: UIMessageTurnFields = {},
|
||||
): number | null => {
|
||||
const cursor = activeAssistantRef.current;
|
||||
if (!cursor) return null;
|
||||
const indexed = prev[cursor.index];
|
||||
@@ -491,6 +539,7 @@ export function useNanobotStream(
|
||||
&& indexed.role === "assistant"
|
||||
&& indexed.kind !== "trace"
|
||||
&& indexed.isStreaming
|
||||
&& matchesTurn(indexed, turn)
|
||||
) {
|
||||
return cursor.index;
|
||||
}
|
||||
@@ -500,7 +549,12 @@ export function useNanobotStream(
|
||||
return null;
|
||||
}
|
||||
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;
|
||||
return null;
|
||||
}
|
||||
@@ -509,15 +563,15 @@ export function useNanobotStream(
|
||||
}, []);
|
||||
|
||||
const appendAnswerChunk = useCallback(
|
||||
(prev: UIMessage[], chunk: string): UIMessage[] => {
|
||||
(prev: UIMessage[], chunk: string, turn: UIMessageTurnFields = {}): UIMessage[] => {
|
||||
let next = prev;
|
||||
let targetIndex = resolveActiveAssistantIndex(next);
|
||||
let targetIndex = resolveActiveAssistantIndex(next, turn);
|
||||
|
||||
if (targetIndex === null) {
|
||||
targetIndex = findActiveAssistantPlaceholderIndex(next);
|
||||
targetIndex = findActiveAssistantPlaceholderIndex(next, turn);
|
||||
}
|
||||
if (targetIndex === null) {
|
||||
targetIndex = findStreamingAssistantIndex(next, closedAssistantStreamIdsRef.current);
|
||||
targetIndex = findStreamingAssistantIndex(next, closedAssistantStreamIdsRef.current, turn);
|
||||
}
|
||||
if (targetIndex === null) {
|
||||
const id = crypto.randomUUID();
|
||||
@@ -539,6 +593,7 @@ export function useNanobotStream(
|
||||
...target,
|
||||
content: target.content + chunk,
|
||||
isStreaming: true,
|
||||
...turn,
|
||||
};
|
||||
closedAssistantStreamIdsRef.current.delete(merged.id);
|
||||
activeAssistantRef.current = { id: merged.id, index: targetIndex };
|
||||
@@ -551,20 +606,17 @@ export function useNanobotStream(
|
||||
const applyPendingStreamEvents = useCallback(
|
||||
(prev: UIMessage[], events: PendingStreamEvent[]): UIMessage[] => {
|
||||
let next = prev;
|
||||
for (let i = 0; i < events.length;) {
|
||||
const kind = events[i].kind;
|
||||
let text = "";
|
||||
while (i < events.length && events[i].kind === kind) {
|
||||
text += events[i].text;
|
||||
i += 1;
|
||||
}
|
||||
if (kind === "delta") {
|
||||
next = appendAnswerChunk(next, text);
|
||||
for (const event of events) {
|
||||
if (event.kind === "delta") {
|
||||
next = appendAnswerChunk(next, event.text, event.turn);
|
||||
} else {
|
||||
if (closeActiveAssistantStream()) clearActivitySegment();
|
||||
next = attachReasoningChunk(next, text, {
|
||||
ensure: ensureActivitySegmentId,
|
||||
});
|
||||
next = attachReasoningChunk(
|
||||
next,
|
||||
event.text,
|
||||
{ ensure: ensureActivitySegmentId },
|
||||
event.turn,
|
||||
);
|
||||
}
|
||||
}
|
||||
return next;
|
||||
@@ -575,6 +627,7 @@ export function useNanobotStream(
|
||||
const flushPendingStreamEvents = useCallback((options?: {
|
||||
closeAnswerSegment?: boolean;
|
||||
finalAnswerText?: string;
|
||||
turn?: UIMessageTurnFields;
|
||||
}) => {
|
||||
if (streamFrameRef.current !== null) {
|
||||
window.cancelAnimationFrame(streamFrameRef.current);
|
||||
@@ -582,6 +635,7 @@ export function useNanobotStream(
|
||||
}
|
||||
const events = pendingStreamEventsRef.current;
|
||||
const finalAnswerText = options?.finalAnswerText;
|
||||
const turn = options?.turn ?? {};
|
||||
if (events.length === 0 && finalAnswerText === undefined) {
|
||||
if (options?.closeAnswerSegment) closeActiveAssistantStream();
|
||||
return;
|
||||
@@ -591,14 +645,15 @@ export function useNanobotStream(
|
||||
let next = events.length > 0 ? applyPendingStreamEvents(prev, events) : prev;
|
||||
if (finalAnswerText !== undefined) {
|
||||
const targetIndex =
|
||||
resolveActiveAssistantIndex(next)
|
||||
?? findStreamingAssistantIndex(next, closedAssistantStreamIdsRef.current);
|
||||
resolveActiveAssistantIndex(next, turn)
|
||||
?? findStreamingAssistantIndex(next, closedAssistantStreamIdsRef.current, turn);
|
||||
if (targetIndex !== null) {
|
||||
const target = next[targetIndex];
|
||||
next = replaceMessageAt(next, targetIndex, {
|
||||
...target,
|
||||
content: finalAnswerText,
|
||||
isStreaming: true,
|
||||
...turn,
|
||||
});
|
||||
} else {
|
||||
const id = crypto.randomUUID();
|
||||
@@ -610,6 +665,7 @@ export function useNanobotStream(
|
||||
role: "assistant",
|
||||
content: finalAnswerText,
|
||||
isStreaming: true,
|
||||
...turn,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
];
|
||||
@@ -679,7 +735,11 @@ export function useNanobotStream(
|
||||
if (!chunk) return;
|
||||
clearActivitySegment();
|
||||
setIsStreaming(true);
|
||||
pendingStreamEventsRef.current.push({ kind: "delta", text: chunk });
|
||||
pendingStreamEventsRef.current.push({
|
||||
kind: "delta",
|
||||
text: chunk,
|
||||
turn: turnFieldsFromEvent(ev, "answer"),
|
||||
});
|
||||
schedulePendingStreamFlush();
|
||||
return;
|
||||
}
|
||||
@@ -690,7 +750,11 @@ export function useNanobotStream(
|
||||
if (!chunk) return;
|
||||
if (fileEditSegmentRef.current) clearActivitySegment();
|
||||
setIsStreaming(true);
|
||||
pendingStreamEventsRef.current.push({ kind: "reasoning", text: chunk });
|
||||
pendingStreamEventsRef.current.push({
|
||||
kind: "reasoning",
|
||||
text: chunk,
|
||||
turn: turnFieldsFromEvent(ev, "reasoning"),
|
||||
});
|
||||
schedulePendingStreamFlush();
|
||||
return;
|
||||
}
|
||||
@@ -699,6 +763,7 @@ export function useNanobotStream(
|
||||
flushPendingStreamEvents({
|
||||
closeAnswerSegment: true,
|
||||
...(typeof ev.text === "string" ? { finalAnswerText: ev.text } : {}),
|
||||
turn: turnFieldsFromEvent(ev, "answer"),
|
||||
});
|
||||
if (suppressStreamUntilTurnEndRef.current) return;
|
||||
// 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));
|
||||
finalized = pruneReasoningOnlyPlaceholders(finalized);
|
||||
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;
|
||||
activeAssistantRef.current = null;
|
||||
@@ -778,9 +847,12 @@ export function useNanobotStream(
|
||||
const line = ev.text;
|
||||
if (!line) return;
|
||||
if (fileEditSegmentRef.current) clearActivitySegment();
|
||||
setMessages((prev) => closeReasoningStream(attachReasoningChunk(prev, line, {
|
||||
ensure: ensureActivitySegmentId,
|
||||
})));
|
||||
setMessages((prev) => closeReasoningStream(attachReasoningChunk(
|
||||
prev,
|
||||
line,
|
||||
{ ensure: ensureActivitySegmentId },
|
||||
turnFieldsFromEvent(ev, "reasoning"),
|
||||
)));
|
||||
return;
|
||||
}
|
||||
// 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.
|
||||
if (ev.kind === "tool_hint" || ev.kind === "progress") {
|
||||
const structuredEvents = normalizeToolProgressEvents(ev.tool_events);
|
||||
const turn = turnFieldsFromEvent(ev, "activity");
|
||||
setMessages((prev) => {
|
||||
const segmentId = ensureActivitySegmentId();
|
||||
const base = prev;
|
||||
@@ -826,6 +899,7 @@ export function useNanobotStream(
|
||||
? mergeToolProgressEvents(last.toolEvents, visibleStructuredEvents)
|
||||
: last.toolEvents,
|
||||
activitySegmentId: last.activitySegmentId ?? segmentId,
|
||||
...turn,
|
||||
};
|
||||
return [...base.slice(0, -1), merged];
|
||||
}
|
||||
@@ -839,6 +913,7 @@ export function useNanobotStream(
|
||||
traces: lines,
|
||||
...(visibleStructuredEvents.length ? { toolEvents: visibleStructuredEvents } : {}),
|
||||
activitySegmentId: segmentId,
|
||||
...turn,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
];
|
||||
@@ -870,6 +945,7 @@ export function useNanobotStream(
|
||||
content,
|
||||
...(hasMedia ? { media } : {}),
|
||||
...(lat !== undefined ? { latencyMs: lat } : {}),
|
||||
...turnFieldsFromEvent(ev, "answer"),
|
||||
});
|
||||
});
|
||||
if (hasMedia) {
|
||||
@@ -882,6 +958,7 @@ export function useNanobotStream(
|
||||
if (edits.length === 0) return;
|
||||
const normalized = mergeFileEdits(undefined, edits);
|
||||
if (normalized.length === 0) return;
|
||||
const turn = turnFieldsFromEvent(ev, "activity");
|
||||
const opensFileEditPhase = normalized.some(
|
||||
(edit) => edit.status === "editing" || edit.phase === "start",
|
||||
);
|
||||
@@ -903,6 +980,7 @@ export function useNanobotStream(
|
||||
...cleanedTarget,
|
||||
fileEdits: mergeFileEdits(cleanedTarget.fileEdits, normalized),
|
||||
activitySegmentId: segmentId,
|
||||
...turn,
|
||||
};
|
||||
return replaceMessageAt(base, targetIndex, merged);
|
||||
}
|
||||
@@ -918,6 +996,7 @@ export function useNanobotStream(
|
||||
traces: [],
|
||||
fileEdits: normalized,
|
||||
activitySegmentId: segmentId,
|
||||
...turn,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
];
|
||||
@@ -962,6 +1041,7 @@ export function useNanobotStream(
|
||||
if (!hasImages && !content.trim()) return;
|
||||
|
||||
flushPendingStreamEvents();
|
||||
const turnId = crypto.randomUUID();
|
||||
const previews = hasImages ? images!.map((i) => i.preview) : undefined;
|
||||
setMessages((prev) => {
|
||||
buffer.current = null;
|
||||
@@ -974,6 +1054,9 @@ export function useNanobotStream(
|
||||
id: crypto.randomUUID(),
|
||||
role: "user",
|
||||
content,
|
||||
turnId,
|
||||
turnPhase: "user",
|
||||
turnSeq: 0,
|
||||
createdAt: Date.now(),
|
||||
...(previews ? { images: previews } : {}),
|
||||
...(options?.cliApps?.length ? { cliApps: options.cliApps } : {}),
|
||||
@@ -985,11 +1068,7 @@ export function useNanobotStream(
|
||||
// right away, before the first delta arrives from the server.
|
||||
setIsStreaming(true);
|
||||
const wireMedia = hasImages ? images!.map((i) => i.media) : undefined;
|
||||
if (options) {
|
||||
client.sendMessage(chatId, content, wireMedia, options);
|
||||
} else {
|
||||
client.sendMessage(chatId, content, wireMedia);
|
||||
}
|
||||
client.sendMessage(chatId, content, wireMedia, { ...options, turnId });
|
||||
},
|
||||
[chatId, clearActivitySegment, client, flushPendingStreamEvents],
|
||||
);
|
||||
|
||||
@@ -295,6 +295,9 @@
|
||||
"disabled": "Disabled",
|
||||
"restartPending": "Restart pending",
|
||||
"ready": "Ready",
|
||||
"privateEngine": "Private engine",
|
||||
"unixSocket": "Unix socket",
|
||||
"defaultWorkspace": "Default workspace",
|
||||
"comfortable": "Comfortable",
|
||||
"compact": "Compact",
|
||||
"auto": "Auto",
|
||||
@@ -386,6 +389,31 @@
|
||||
"imageGeneration": "Image generation",
|
||||
"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": {
|
||||
"searchPlaceholder": "Search providers",
|
||||
"noMatches": "No providers match this search.",
|
||||
@@ -565,6 +593,8 @@
|
||||
"goalStateCloseAria": "Close goal",
|
||||
"send": "Send message",
|
||||
"stop": "Stop response",
|
||||
"modelNotConfigured": "Model not configured",
|
||||
"configureModel": "Configure model",
|
||||
"queued": {
|
||||
"label": "Queued guidance",
|
||||
"guide": "Guide",
|
||||
@@ -733,6 +763,15 @@
|
||||
"next": "Next image",
|
||||
"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": {
|
||||
"fallbackLanguage": "code",
|
||||
"copyAria": "Copy code",
|
||||
|
||||
@@ -187,6 +187,9 @@
|
||||
"disabled": "Desactivado",
|
||||
"restartPending": "Reinicio pendiente",
|
||||
"ready": "Listo",
|
||||
"privateEngine": "Motor privado",
|
||||
"unixSocket": "Socket Unix",
|
||||
"defaultWorkspace": "Espacio predeterminado",
|
||||
"comfortable": "Cómodo",
|
||||
"compact": "Compacto",
|
||||
"auto": "Automático",
|
||||
@@ -278,6 +281,31 @@
|
||||
"imageGeneration": "Generación de imágenes",
|
||||
"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": {
|
||||
"searchPlaceholder": "Buscar proveedores",
|
||||
"noMatches": "Ningún proveedor coincide con esta búsqueda.",
|
||||
@@ -565,6 +593,8 @@
|
||||
"goalStateCloseAria": "Cerrar objetivo",
|
||||
"send": "Enviar mensaje",
|
||||
"stop": "Detener respuesta",
|
||||
"modelNotConfigured": "Modelo no configurado",
|
||||
"configureModel": "Configurar modelo",
|
||||
"queued": {
|
||||
"label": "Guía en cola",
|
||||
"guide": "Guiar",
|
||||
@@ -733,6 +763,15 @@
|
||||
"next": "Imagen siguiente",
|
||||
"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": {
|
||||
"fallbackLanguage": "código",
|
||||
"copyAria": "Copiar código",
|
||||
|
||||
@@ -187,6 +187,9 @@
|
||||
"disabled": "Désactivé",
|
||||
"restartPending": "Redémarrage en attente",
|
||||
"ready": "Prêt",
|
||||
"privateEngine": "Moteur privé",
|
||||
"unixSocket": "Socket Unix",
|
||||
"defaultWorkspace": "Espace par défaut",
|
||||
"comfortable": "Confortable",
|
||||
"compact": "Compacte",
|
||||
"auto": "Automatique",
|
||||
@@ -278,6 +281,31 @@
|
||||
"imageGeneration": "Génération d’images",
|
||||
"workspace": "Espace de travail"
|
||||
},
|
||||
"usage": {
|
||||
"title": "Activité des tokens",
|
||||
"shortTitle": "Token Usage",
|
||||
"subtitle": "Usage signalé par le fournisseur sur les 12 derniers mois.",
|
||||
"empty": "L’activité 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": {
|
||||
"searchPlaceholder": "Rechercher des fournisseurs",
|
||||
"noMatches": "Aucun fournisseur ne correspond.",
|
||||
@@ -565,6 +593,8 @@
|
||||
"goalStateCloseAria": "Fermer l’objectif",
|
||||
"send": "Envoyer le message",
|
||||
"stop": "Arrêter la réponse",
|
||||
"modelNotConfigured": "Modèle non configuré",
|
||||
"configureModel": "Configurer le modèle",
|
||||
"queued": {
|
||||
"label": "Guidage en attente",
|
||||
"guide": "Guider",
|
||||
@@ -733,6 +763,15 @@
|
||||
"next": "Image suivante",
|
||||
"close": "Fermer l’aperçu"
|
||||
},
|
||||
"filePreview": {
|
||||
"aria": "Aperçu du fichier",
|
||||
"close": "Fermer l’aperçu du fichier",
|
||||
"loading": "Chargement de l’aperçu...",
|
||||
"failed": "Impossible de prévisualiser ce fichier.",
|
||||
"routeMissing": "L’aperçu du fichier nécessite le dernier gateway. Redémarrez nanobot gateway puis réessayez.",
|
||||
"resize": "Redimensionner l’aperçu du fichier",
|
||||
"truncated": "L’aperçu est tronqué car le fichier est volumineux."
|
||||
},
|
||||
"code": {
|
||||
"fallbackLanguage": "code",
|
||||
"copyAria": "Copier le code",
|
||||
|
||||
@@ -187,6 +187,9 @@
|
||||
"disabled": "Nonaktif",
|
||||
"restartPending": "Menunggu mulai ulang",
|
||||
"ready": "Siap",
|
||||
"privateEngine": "Mesin privat",
|
||||
"unixSocket": "Soket Unix",
|
||||
"defaultWorkspace": "Workspace default",
|
||||
"comfortable": "Nyaman",
|
||||
"compact": "Ringkas",
|
||||
"auto": "Otomatis",
|
||||
@@ -278,6 +281,31 @@
|
||||
"imageGeneration": "Pembuatan gambar",
|
||||
"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": {
|
||||
"searchPlaceholder": "Cari penyedia",
|
||||
"noMatches": "Tidak ada penyedia yang cocok.",
|
||||
@@ -565,6 +593,8 @@
|
||||
"goalStateCloseAria": "Tutup tujuan",
|
||||
"send": "Kirim pesan",
|
||||
"stop": "Hentikan respons",
|
||||
"modelNotConfigured": "Model belum dikonfigurasi",
|
||||
"configureModel": "Konfigurasi model",
|
||||
"queued": {
|
||||
"label": "Panduan antrean",
|
||||
"guide": "Pandu",
|
||||
@@ -733,6 +763,15 @@
|
||||
"next": "Gambar berikutnya",
|
||||
"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": {
|
||||
"fallbackLanguage": "kode",
|
||||
"copyAria": "Salin kode",
|
||||
|
||||
@@ -187,6 +187,9 @@
|
||||
"disabled": "無効",
|
||||
"restartPending": "再起動待ち",
|
||||
"ready": "準備完了",
|
||||
"privateEngine": "プライベートエンジン",
|
||||
"unixSocket": "Unix ソケット",
|
||||
"defaultWorkspace": "デフォルトワークスペース",
|
||||
"comfortable": "標準",
|
||||
"compact": "コンパクト",
|
||||
"auto": "自動",
|
||||
@@ -278,6 +281,31 @@
|
||||
"imageGeneration": "画像生成",
|
||||
"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": {
|
||||
"searchPlaceholder": "プロバイダーを検索",
|
||||
"noMatches": "一致するプロバイダーはありません。",
|
||||
@@ -565,6 +593,8 @@
|
||||
"goalStateCloseAria": "目標を閉じる",
|
||||
"send": "メッセージを送信",
|
||||
"stop": "応答を停止",
|
||||
"modelNotConfigured": "モデルが未設定です",
|
||||
"configureModel": "モデルを設定",
|
||||
"queued": {
|
||||
"label": "保留中のガイド",
|
||||
"guide": "ガイド",
|
||||
@@ -733,6 +763,15 @@
|
||||
"next": "次の画像",
|
||||
"close": "プレビューを閉じる"
|
||||
},
|
||||
"filePreview": {
|
||||
"aria": "ファイルプレビュー",
|
||||
"close": "ファイルプレビューを閉じる",
|
||||
"loading": "プレビューを読み込み中...",
|
||||
"failed": "このファイルをプレビューできませんでした。",
|
||||
"routeMissing": "ファイルプレビューには最新の gateway が必要です。nanobot gateway を再起動してから再試行してください。",
|
||||
"resize": "ファイルプレビューの幅を変更",
|
||||
"truncated": "ファイルが大きいため、プレビューは途中まで表示されています。"
|
||||
},
|
||||
"code": {
|
||||
"fallbackLanguage": "コード",
|
||||
"copyAria": "コードをコピー",
|
||||
|
||||
@@ -187,6 +187,9 @@
|
||||
"disabled": "비활성화됨",
|
||||
"restartPending": "재시작 대기",
|
||||
"ready": "준비됨",
|
||||
"privateEngine": "비공개 엔진",
|
||||
"unixSocket": "Unix 소켓",
|
||||
"defaultWorkspace": "기본 작업 공간",
|
||||
"comfortable": "편안함",
|
||||
"compact": "컴팩트",
|
||||
"auto": "자동",
|
||||
@@ -278,6 +281,31 @@
|
||||
"imageGeneration": "이미지 생성",
|
||||
"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": {
|
||||
"searchPlaceholder": "제공자 검색",
|
||||
"noMatches": "일치하는 제공자가 없습니다.",
|
||||
@@ -565,6 +593,8 @@
|
||||
"goalStateCloseAria": "목표 닫기",
|
||||
"send": "메시지 보내기",
|
||||
"stop": "응답 중지",
|
||||
"modelNotConfigured": "모델이 설정되지 않음",
|
||||
"configureModel": "모델 설정",
|
||||
"queued": {
|
||||
"label": "대기 중인 안내",
|
||||
"guide": "안내",
|
||||
@@ -733,6 +763,15 @@
|
||||
"next": "다음 이미지",
|
||||
"close": "미리보기 닫기"
|
||||
},
|
||||
"filePreview": {
|
||||
"aria": "파일 미리보기",
|
||||
"close": "파일 미리보기 닫기",
|
||||
"loading": "미리보기 로딩 중...",
|
||||
"failed": "이 파일을 미리 볼 수 없습니다.",
|
||||
"routeMissing": "파일 미리보기에는 최신 gateway가 필요합니다. nanobot gateway를 다시 시작한 뒤 다시 시도하세요.",
|
||||
"resize": "파일 미리보기 크기 조절",
|
||||
"truncated": "파일이 커서 미리보기가 잘렸습니다."
|
||||
},
|
||||
"code": {
|
||||
"fallbackLanguage": "코드",
|
||||
"copyAria": "코드 복사",
|
||||
|
||||
@@ -187,6 +187,9 @@
|
||||
"disabled": "Đã tắt",
|
||||
"restartPending": "Chờ khởi động lại",
|
||||
"ready": "Sẵn sàng",
|
||||
"privateEngine": "Bộ máy riêng",
|
||||
"unixSocket": "Socket Unix",
|
||||
"defaultWorkspace": "Workspace mặc định",
|
||||
"comfortable": "Thoải mái",
|
||||
"compact": "Gọn",
|
||||
"auto": "Tự động",
|
||||
@@ -278,6 +281,31 @@
|
||||
"imageGeneration": "Tạo hình ảnh",
|
||||
"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": {
|
||||
"searchPlaceholder": "Tìm nhà cung cấp",
|
||||
"noMatches": "Không có nhà cung cấp phù hợp.",
|
||||
@@ -565,6 +593,8 @@
|
||||
"goalStateCloseAria": "Đóng mục tiêu",
|
||||
"send": "Gửi tin nhắn",
|
||||
"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": {
|
||||
"label": "Hướng dẫn đang chờ",
|
||||
"guide": "Hướng dẫn",
|
||||
@@ -733,6 +763,15 @@
|
||||
"next": "Ảnh tiếp theo",
|
||||
"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": {
|
||||
"fallbackLanguage": "mã",
|
||||
"copyAria": "Sao chép mã",
|
||||
|
||||
@@ -295,6 +295,9 @@
|
||||
"disabled": "已禁用",
|
||||
"restartPending": "等待重启",
|
||||
"ready": "就绪",
|
||||
"privateEngine": "私有引擎",
|
||||
"unixSocket": "Unix socket",
|
||||
"defaultWorkspace": "默认工作区",
|
||||
"comfortable": "舒适",
|
||||
"compact": "紧凑",
|
||||
"auto": "自动",
|
||||
@@ -386,6 +389,31 @@
|
||||
"imageGeneration": "图片生成",
|
||||
"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": {
|
||||
"searchPlaceholder": "搜索提供商",
|
||||
"noMatches": "没有匹配的提供商。",
|
||||
@@ -564,6 +592,8 @@
|
||||
"goalStateSheetTitle": "目标",
|
||||
"send": "发送消息",
|
||||
"stop": "停止响应",
|
||||
"modelNotConfigured": "模型未配置",
|
||||
"configureModel": "配置模型",
|
||||
"queued": {
|
||||
"label": "待引导提示",
|
||||
"guide": "引导",
|
||||
@@ -733,6 +763,15 @@
|
||||
"next": "下一张",
|
||||
"close": "关闭预览"
|
||||
},
|
||||
"filePreview": {
|
||||
"aria": "文件预览",
|
||||
"close": "关闭文件预览",
|
||||
"loading": "正在加载预览...",
|
||||
"failed": "无法预览这个文件。",
|
||||
"routeMissing": "文件预览需要最新的 gateway。请重启 nanobot gateway 后再试。",
|
||||
"resize": "调整文件预览宽度",
|
||||
"truncated": "文件较大,当前只显示前半部分预览。"
|
||||
},
|
||||
"code": {
|
||||
"fallbackLanguage": "代码",
|
||||
"copyAria": "复制代码",
|
||||
|
||||
@@ -187,6 +187,9 @@
|
||||
"disabled": "已停用",
|
||||
"restartPending": "等待重啟",
|
||||
"ready": "就緒",
|
||||
"privateEngine": "私有引擎",
|
||||
"unixSocket": "Unix socket",
|
||||
"defaultWorkspace": "預設工作區",
|
||||
"comfortable": "舒適",
|
||||
"compact": "緊湊",
|
||||
"auto": "自動",
|
||||
@@ -278,6 +281,31 @@
|
||||
"imageGeneration": "圖片生成",
|
||||
"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": {
|
||||
"searchPlaceholder": "搜尋供應商",
|
||||
"noMatches": "沒有符合的供應商。",
|
||||
@@ -565,6 +593,8 @@
|
||||
"goalStateCloseAria": "關閉目標",
|
||||
"send": "送出訊息",
|
||||
"stop": "停止回覆",
|
||||
"modelNotConfigured": "模型未配置",
|
||||
"configureModel": "配置模型",
|
||||
"queued": {
|
||||
"label": "待引導提示",
|
||||
"guide": "引導",
|
||||
@@ -733,6 +763,15 @@
|
||||
"next": "下一張",
|
||||
"close": "關閉預覽"
|
||||
},
|
||||
"filePreview": {
|
||||
"aria": "檔案預覽",
|
||||
"close": "關閉檔案預覽",
|
||||
"loading": "正在載入預覽...",
|
||||
"failed": "無法預覽這個檔案。",
|
||||
"routeMissing": "檔案預覽需要最新的 gateway。請重啟 nanobot gateway 後再試。",
|
||||
"resize": "調整檔案預覽寬度",
|
||||
"truncated": "檔案較大,目前只顯示前半部分預覽。"
|
||||
},
|
||||
"code": {
|
||||
"fallbackLanguage": "程式碼",
|
||||
"copyAria": "複製程式碼",
|
||||
|
||||
@@ -38,6 +38,10 @@ export type TurnUnit =
|
||||
| { type: "activity"; messages: UIMessage[]; items: ActivityItem[]; turnLatencyMs?: number }
|
||||
| { type: "message"; message: UIMessage };
|
||||
|
||||
interface NormalizeActivityTimelineOptions {
|
||||
preserveTrailingActivity?: boolean;
|
||||
}
|
||||
|
||||
export function isReasoningOnlyAssistant(message: UIMessage): boolean {
|
||||
if (message.role !== "assistant" || message.kind === "trace") 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";
|
||||
}
|
||||
|
||||
export function normalizeActivityTimeline(messages: UIMessage[]): TurnUnit[] {
|
||||
export function normalizeActivityTimeline(
|
||||
messages: UIMessage[],
|
||||
options: NormalizeActivityTimelineOptions = {},
|
||||
): TurnUnit[] {
|
||||
const units: TurnUnit[] = [];
|
||||
let turnMessages: UIMessage[] = [];
|
||||
let activeTurnId: string | undefined;
|
||||
|
||||
const flushTurn = () => {
|
||||
const flushTurn = (flushOptions: NormalizeActivityTimelineOptions = {}) => {
|
||||
if (turnMessages.length === 0) return;
|
||||
|
||||
const visibleMessages = visibleMessagesForTurn(turnMessages);
|
||||
const turnUnits: TurnUnit[] = [];
|
||||
const orderedTurnMessages = orderMessagesByTurnSeq(turnMessages);
|
||||
const visibleMessages = visibleMessagesForTurn(orderedTurnMessages);
|
||||
let visibleIndex = 0;
|
||||
let activityMessages: UIMessage[] = [];
|
||||
|
||||
const flushActivityMessages = () => {
|
||||
if (!activityMessages.length) return;
|
||||
pushActivityUnits(units, activityMessages, visibleMessages.slice(visibleIndex));
|
||||
pushActivityUnits(turnUnits, activityMessages, visibleMessages.slice(visibleIndex));
|
||||
activityMessages = [];
|
||||
};
|
||||
|
||||
for (const message of turnMessages) {
|
||||
for (const message of orderedTurnMessages) {
|
||||
if (isAgentActivityMember(message)) {
|
||||
activityMessages.push(message);
|
||||
continue;
|
||||
@@ -74,34 +84,87 @@ export function normalizeActivityTimeline(messages: UIMessage[]): TurnUnit[] {
|
||||
if (assistantHasInlineReasoning(message)) {
|
||||
activityMessages.push(reasoningOnlyMessageFromAnswer(message));
|
||||
flushActivityMessages();
|
||||
units.push({ type: "message", message: stripInlineReasoning(message) });
|
||||
turnUnits.push({ type: "message", message: stripInlineReasoning(message) });
|
||||
visibleIndex += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
flushActivityMessages();
|
||||
units.push({ type: "message", message });
|
||||
turnUnits.push({ type: "message", message });
|
||||
visibleIndex += 1;
|
||||
}
|
||||
|
||||
flushActivityMessages();
|
||||
units.push(...normalizeCompletedTurnUnits(turnUnits, flushOptions));
|
||||
turnMessages = [];
|
||||
activeTurnId = undefined;
|
||||
};
|
||||
|
||||
for (const message of messages) {
|
||||
if (message.role === "user") {
|
||||
flushTurn();
|
||||
units.push({ type: "message", message });
|
||||
activeTurnId = message.turnId;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (message.turnId && activeTurnId && message.turnId !== activeTurnId) {
|
||||
flushTurn();
|
||||
}
|
||||
if (message.turnId) {
|
||||
activeTurnId = message.turnId;
|
||||
}
|
||||
turnMessages.push(message);
|
||||
}
|
||||
|
||||
flushTurn();
|
||||
flushTurn(options);
|
||||
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[] {
|
||||
const visibleMessages: UIMessage[] = [];
|
||||
for (const message of messages) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type {
|
||||
ChatSummary,
|
||||
CliAppsPayload,
|
||||
FilePreviewPayload,
|
||||
ImageGenerationSettingsUpdate,
|
||||
McpPresetsPayload,
|
||||
ModelConfigurationCreate,
|
||||
@@ -134,6 +135,22 @@ export async function fetchWebuiThread(
|
||||
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(
|
||||
token: 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(
|
||||
token: string,
|
||||
base: string = "",
|
||||
|
||||
@@ -336,6 +336,7 @@ export class NanobotClient {
|
||||
cliApps?: OutboundCliAppMention[];
|
||||
mcpPresets?: OutboundMcpPresetMention[];
|
||||
workspaceScope?: WorkspaceScopePayload | null;
|
||||
turnId?: string;
|
||||
},
|
||||
): void {
|
||||
this.knownChats.add(chatId);
|
||||
@@ -348,6 +349,7 @@ export class NanobotClient {
|
||||
...(options?.cliApps?.length ? { cli_apps: options.cliApps } : {}),
|
||||
...(options?.mcpPresets?.length ? { mcp_presets: options.mcpPresets } : {}),
|
||||
...(options?.workspaceScope ? { workspace_scope: options.workspaceScope } : {}),
|
||||
...(options?.turnId ? { turn_id: options.turnId } : {}),
|
||||
webui: true,
|
||||
};
|
||||
this.queueSend(frame);
|
||||
|
||||
+74
-14
@@ -4,6 +4,8 @@ export type Role = "user" | "assistant" | "tool" | "system";
|
||||
* progress pings) that should not be rendered as conversational replies. */
|
||||
export type MessageKind = "message" | "trace";
|
||||
|
||||
export type UITurnPhase = "user" | "reasoning" | "activity" | "answer" | "complete";
|
||||
|
||||
/** One image attached to a UIMessage.
|
||||
*
|
||||
* ``url`` can arrive in three different shapes, which the bubble renders
|
||||
@@ -64,6 +66,10 @@ export interface UIMessage {
|
||||
reasoningStreaming?: boolean;
|
||||
/** End-to-end wall time for this assistant turn (persisted ``latency_ms`` / ``turn_end``). */
|
||||
latencyMs?: number;
|
||||
/** Stable protocol metadata for grouping all activity emitted by one user turn. */
|
||||
turnId?: string;
|
||||
turnPhase?: UITurnPhase;
|
||||
turnSeq?: number;
|
||||
}
|
||||
|
||||
export interface UICliAppAttachment {
|
||||
@@ -352,6 +358,43 @@ export interface SettingsPayload {
|
||||
};
|
||||
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: {
|
||||
restrict_to_workspace: boolean;
|
||||
workspace_sandbox?: {
|
||||
@@ -605,10 +648,16 @@ export type ConnectionStatus =
|
||||
| "closed"
|
||||
| "error";
|
||||
|
||||
export interface InboundTurnMetadata {
|
||||
turn_id?: string;
|
||||
turn_phase?: UITurnPhase;
|
||||
turn_seq?: number;
|
||||
}
|
||||
|
||||
export type InboundEvent =
|
||||
| { event: "ready"; chat_id: string; client_id: string }
|
||||
| { event: "attached"; chat_id: string }
|
||||
| {
|
||||
| ({
|
||||
event: "message";
|
||||
chat_id: string;
|
||||
text: string;
|
||||
@@ -623,47 +672,47 @@ export type InboundEvent =
|
||||
latency_ms?: number;
|
||||
/** Optional structured payload on progress frames (channel-specific). */
|
||||
agent_ui?: AgentUIBlob;
|
||||
}
|
||||
| {
|
||||
} & InboundTurnMetadata)
|
||||
| ({
|
||||
event: "file_edit";
|
||||
chat_id: string;
|
||||
edits: UIFileEdit[];
|
||||
}
|
||||
| {
|
||||
} & InboundTurnMetadata)
|
||||
| ({
|
||||
event: "delta";
|
||||
chat_id: string;
|
||||
text: string;
|
||||
stream_id?: string;
|
||||
}
|
||||
| {
|
||||
} & InboundTurnMetadata)
|
||||
| ({
|
||||
event: "stream_end";
|
||||
chat_id: string;
|
||||
stream_id?: string;
|
||||
text?: string;
|
||||
}
|
||||
| {
|
||||
} & InboundTurnMetadata)
|
||||
| ({
|
||||
event: "reasoning_delta";
|
||||
chat_id: string;
|
||||
text: string;
|
||||
stream_id?: string;
|
||||
}
|
||||
| {
|
||||
} & InboundTurnMetadata)
|
||||
| ({
|
||||
event: "reasoning_end";
|
||||
chat_id: string;
|
||||
stream_id?: string;
|
||||
}
|
||||
} & InboundTurnMetadata)
|
||||
| {
|
||||
event: "runtime_model_updated";
|
||||
model_name: string;
|
||||
model_preset?: string | null;
|
||||
}
|
||||
| {
|
||||
| ({
|
||||
event: "turn_end";
|
||||
chat_id: string;
|
||||
latency_ms?: number;
|
||||
/** Authoritative sustained-goal snapshot for this chat (same shape as ``goal_state`` events). */
|
||||
goal_state?: GoalStateWsPayload;
|
||||
}
|
||||
} & InboundTurnMetadata)
|
||||
| {
|
||||
event: "goal_status";
|
||||
chat_id: string;
|
||||
@@ -732,6 +781,16 @@ export interface WebuiThreadPersistedPayload {
|
||||
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 =
|
||||
| { type: "new_chat"; workspace_scope?: WorkspaceScopePayload }
|
||||
| { type: "attach"; chat_id: string }
|
||||
@@ -745,6 +804,7 @@ export type Outbound =
|
||||
cli_apps?: OutboundCliAppMention[];
|
||||
mcp_presets?: OutboundMcpPresetMention[];
|
||||
workspace_scope?: WorkspaceScopePayload;
|
||||
turn_id?: string;
|
||||
/** Marks messages sent by the embedded WebUI, without changing the
|
||||
* generic websocket protocol for other clients. */
|
||||
webui?: true;
|
||||
|
||||
@@ -3,9 +3,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createModelConfiguration,
|
||||
deleteSession,
|
||||
fetchFilePreview,
|
||||
fetchCliApps,
|
||||
fetchMcpPresets,
|
||||
fetchProviderModels,
|
||||
fetchSettingsUsage,
|
||||
fetchSidebarState,
|
||||
fetchWebuiThread,
|
||||
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 () => {
|
||||
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 () => {
|
||||
await createModelConfiguration("tok", {
|
||||
label: "Fast writing",
|
||||
|
||||
@@ -208,6 +208,7 @@ describe("App layout", () => {
|
||||
runStatusHandlers.clear();
|
||||
window.history.replaceState(null, "", "/");
|
||||
setNavigatorPlatform("Linux x86_64");
|
||||
localStorage.removeItem("nanobot-webui.sidebar");
|
||||
localStorage.removeItem("nanobot-webui.sidebar.completed-runs.v1");
|
||||
vi.mocked(fetchBootstrap).mockReset().mockResolvedValue({
|
||||
token: "tok",
|
||||
@@ -243,6 +244,60 @@ describe("App layout", () => {
|
||||
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 () => {
|
||||
mockSessions = [
|
||||
{
|
||||
@@ -907,7 +962,6 @@ describe("App layout", () => {
|
||||
|
||||
expect(await screen.findByRole("heading", { name: "Overview" })).toBeInTheDocument();
|
||||
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-brave")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("overview-logo-openrouter")).toBeInTheDocument();
|
||||
|
||||
@@ -51,6 +51,25 @@ describe("CodeBlock", () => {
|
||||
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 () => {
|
||||
render(
|
||||
<ThemeProvider theme="dark">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import MarkdownTextRenderer from "@/components/MarkdownTextRenderer";
|
||||
|
||||
@@ -12,6 +12,43 @@ describe("MarkdownTextRenderer", () => {
|
||||
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", () => {
|
||||
const { container } = render(
|
||||
<MarkdownTextRenderer highlightCode={false}>
|
||||
|
||||
@@ -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", () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
|
||||
@@ -118,7 +118,7 @@ const installedAnyGen = {
|
||||
|
||||
function renderSettingsView(
|
||||
options: {
|
||||
initialSection?: "apps" | "advanced" | "models";
|
||||
initialSection?: "overview" | "apps" | "advanced" | "models";
|
||||
onSettingsChange?: (payload: SettingsPayload) => void;
|
||||
} = {},
|
||||
) {
|
||||
@@ -219,6 +219,55 @@ describe("SettingsView Apps catalog", () => {
|
||||
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 () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
@@ -242,6 +291,280 @@ describe("SettingsView Apps catalog", () => {
|
||||
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 () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
|
||||
@@ -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[] = [
|
||||
{
|
||||
id: "r1",
|
||||
@@ -182,14 +182,14 @@ describe("ThreadMessages", () => {
|
||||
|
||||
expect(units).toHaveLength(3);
|
||||
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",
|
||||
message: {
|
||||
id: "a1",
|
||||
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", () => {
|
||||
@@ -324,7 +324,7 @@ describe("ThreadMessages", () => {
|
||||
},
|
||||
];
|
||||
|
||||
const units = buildDisplayUnits(messages);
|
||||
const units = buildDisplayUnits(messages, true);
|
||||
|
||||
expect(units).toHaveLength(3);
|
||||
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();
|
||||
});
|
||||
|
||||
it("keeps late activity after a completed assistant answer", () => {
|
||||
it("moves late activity before a completed assistant answer", () => {
|
||||
const messages: UIMessage[] = [
|
||||
{
|
||||
id: "r1",
|
||||
@@ -376,21 +376,164 @@ describe("ThreadMessages", () => {
|
||||
|
||||
expect(units).toHaveLength(3);
|
||||
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",
|
||||
message: {
|
||||
id: "a1",
|
||||
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} />);
|
||||
|
||||
const answer = screen.getByText("Hong Kong is hot today.");
|
||||
const laterActivity = screen.getAllByText(/thought/i).at(-1);
|
||||
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", () => {
|
||||
@@ -509,6 +652,30 @@ describe("ThreadMessages", () => {
|
||||
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", () => {
|
||||
const units = buildDisplayUnits([
|
||||
{ id: "u1", role: "user", content: "one", createdAt: 1 },
|
||||
|
||||
@@ -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) {
|
||||
return {
|
||||
key: `websocket:${chatId}`,
|
||||
@@ -270,6 +284,45 @@ describe("ThreadShell", () => {
|
||||
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 () => {
|
||||
const client = makeClient();
|
||||
const disabledSettings = modelSettings("deepseek-v4-pro", "deepseek");
|
||||
@@ -339,11 +392,7 @@ describe("ThreadShell", () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(client.sendMessage).toHaveBeenCalledWith(
|
||||
"chat-a",
|
||||
"persist me across tabs",
|
||||
undefined,
|
||||
),
|
||||
expectSendMessageWithTurn(client, "chat-a", "persist me across tabs"),
|
||||
);
|
||||
expect(screen.getByText("persist me across tabs")).toBeInTheDocument();
|
||||
|
||||
@@ -403,11 +452,7 @@ describe("ThreadShell", () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(client.sendMessage).toHaveBeenCalledWith(
|
||||
"chat-a",
|
||||
"delete me cleanly",
|
||||
undefined,
|
||||
),
|
||||
expectSendMessageWithTurn(client, "chat-a", "delete me cleanly"),
|
||||
);
|
||||
expect(screen.getByText("delete me cleanly")).toBeInTheDocument();
|
||||
|
||||
@@ -506,11 +551,7 @@ describe("ThreadShell", () => {
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(client.sendMessage).toHaveBeenCalledWith(
|
||||
"chat-new",
|
||||
"first message should stay",
|
||||
undefined,
|
||||
),
|
||||
expectSendMessageWithTurn(client, "chat-new", "first message should stay"),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("first message should stay")).toBeInTheDocument(),
|
||||
@@ -575,7 +616,7 @@ describe("ThreadShell", () => {
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(client.sendMessage).toHaveBeenCalledWith("chat-new", "/model", undefined),
|
||||
expectSendMessageWithTurn(client, "chat-new", "/model"),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
@@ -703,11 +744,7 @@ describe("ThreadShell", () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(client.sendMessage).toHaveBeenCalledWith(
|
||||
"chat-a",
|
||||
"only in chat a",
|
||||
undefined,
|
||||
),
|
||||
expectSendMessageWithTurn(client, "chat-a", "only in chat a"),
|
||||
);
|
||||
expect(screen.getByText("only in chat a")).toBeInTheDocument();
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
const promptMessages = makeLongMessages(100);
|
||||
const { container } = render(
|
||||
|
||||
@@ -1342,6 +1342,8 @@ describe("useNanobotStream", () => {
|
||||
expect(result.current.messages).toHaveLength(1);
|
||||
expect(result.current.messages[0].role).toBe("user");
|
||||
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", () => {
|
||||
@@ -1482,7 +1484,10 @@ describe("useNanobotStream", () => {
|
||||
"chat-img",
|
||||
"draw a square icon",
|
||||
undefined,
|
||||
{ imageGeneration: { enabled: true, aspect_ratio: "1:1" } },
|
||||
expect.objectContaining({
|
||||
imageGeneration: { enabled: true, aspect_ratio: "1:1" },
|
||||
turnId: expect.any(String),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user