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