feat(desktop): polish desktop shell and shared WebUI surfaces (#4195)

* feat(desktop): add native host scaffold

* feat(webui): track turns and usage in gateway

* feat(webui): polish desktop chat experience

* feat(apps): add ArcGIS and Joplin logos

* feat(desktop): polish shell and shared surfaces

* fix(webui): avoid preview chips for glob references

* test: align CI expectations for token fallback

* feat(webui): preview prompt rail entries

* feat(webui): add prompt navigator drawer

* style(webui): refine prompt navigator placement

* style(webui): align prompt navigator with header actions

* style(webui): simplify prompt navigator header

* refactor(webui): clean thread resource refresh

* feat(desktop): add native reply notifications

* fix(webui): preserve desktop restart and replay state

* fix(desktop): harden gateway proxy startup

* fix(web): fall back when readability is unavailable

* fix(desktop): hide window instead of closing on macos

* fix(webui): unify desktop header actions

* fix(webui): simplify prompt history rows

* fix(desktop): log notification delivery failures

* chore(desktop): clean source package artifacts

* fix(cron): support one-time relative reminders

* fix(webui): reveal scroll button in place

* Revert "fix(cron): support one-time relative reminders"

This reverts commit 4c4661da120a3c7283e0768412bae48604e7390b.

* refactor(webui): extract token usage heatmap

* docs(desktop): clarify contributor guides

---------

Co-authored-by: chengyongru <2755839590@qq.com>
This commit is contained in:
Xubin Ren
2026-06-06 19:49:33 +08:00
committed by GitHub
co-authored by chengyongru
parent a1b9577224
commit ab9f49970d
103 changed files with 10483 additions and 1003 deletions
+258 -119
View File
@@ -1,5 +1,12 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Menu, Moon, Sun } from "lucide-react";
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { Moon, PanelLeft, Sun } from "lucide-react";
import { useTranslation } from "react-i18next";
import { DeleteConfirm } from "@/components/DeleteConfirm";
import { RenameChatDialog } from "@/components/RenameChatDialog";
@@ -12,6 +19,7 @@ import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
import { useSessions } from "@/hooks/useSessions";
import { useDeferredTitleRefresh } from "@/hooks/useDeferredTitleRefresh";
import { useSidebarState } from "@/hooks/useSidebarState";
import { useSkills } from "@/hooks/useSkills";
import { ThemeProvider, useTheme } from "@/hooks/useTheme";
import { cn } from "@/lib/utils";
import {
@@ -36,6 +44,7 @@ import { Input } from "@/components/ui/input";
import { fetchSettings, fetchWorkspaces } from "@/lib/api";
import {
createRuntimeHost,
getHostApi,
toRuntimeSurface,
} from "@/lib/runtime";
import { projectNameFromPath } from "@/lib/workspace";
@@ -60,7 +69,7 @@ const SIDEBAR_WIDTH = 272;
const SIDEBAR_RAIL_WIDTH = 56;
const TOKEN_REFRESH_MARGIN_MS = 30_000;
const TOKEN_REFRESH_MIN_DELAY_MS = 5_000;
type ShellView = "chat" | "settings" | "apps";
type ShellView = "chat" | "settings" | "apps" | "skills";
type ShellRoute = {
view: ShellView;
activeKey: string | null;
@@ -74,6 +83,7 @@ const SETTINGS_SECTION_KEYS: SettingsSectionKey[] = [
"image",
"browser",
"apps",
"skills",
"runtime",
"advanced",
];
@@ -86,6 +96,11 @@ function defaultShellRoute(): ShellRoute {
return { view: "chat", activeKey: null, settingsSection: "overview" };
}
function shellViewForSettingsSection(section: SettingsSectionKey): ShellView {
if (section === "apps" || section === "skills") return section;
return "settings";
}
function readShellRoute(): ShellRoute {
if (typeof window === "undefined") return defaultShellRoute();
const hash = window.location.hash.startsWith("#")
@@ -102,11 +117,18 @@ function readShellRoute(): ShellRoute {
const activeKey = params.get("chat")?.trim() || null;
if (path === "/settings") {
return { view: "settings", activeKey, settingsSection };
return {
view: shellViewForSettingsSection(settingsSection),
activeKey,
settingsSection,
};
}
if (path === "/apps") {
return { view: "apps", activeKey, settingsSection: "apps" };
}
if (path === "/skills") {
return { view: "skills", activeKey, settingsSection: "skills" };
}
if (path.startsWith("/chat/")) {
const encoded = path.slice("/chat/".length);
try {
@@ -264,51 +286,43 @@ function normalizeWorkspaceScope(scope: WorkspaceScopePayload): WorkspaceScopePa
function HostChrome({
onToggleSidebar,
theme,
onToggleTheme,
showThemeButton = true,
onSidebarPreviewEnter,
onSidebarPreviewLeave,
sidebarOpen = true,
rightAction,
}: {
onToggleSidebar?: () => void;
theme: "light" | "dark";
onToggleTheme: () => void;
showThemeButton?: boolean;
onSidebarPreviewEnter?: () => void;
onSidebarPreviewLeave?: () => void;
sidebarOpen?: boolean;
rightAction?: ReactNode;
}) {
const { t } = useTranslation();
return (
<header className="host-drag-region pointer-events-none absolute inset-x-0 top-0 z-40 flex h-11 items-start justify-between bg-transparent px-3 pt-2 text-foreground/90">
<div className="flex min-w-[8rem] items-center">
{onToggleSidebar ? (
<Button
type="button"
variant="ghost"
size="icon"
aria-label={t("thread.header.toggleSidebar")}
onClick={onToggleSidebar}
className="host-no-drag pointer-events-auto ml-[88px] h-8 w-8 rounded-xl text-muted-foreground/85 hover:bg-accent/40 hover:text-foreground"
>
<Menu className="h-4 w-4" />
</Button>
) : null}
</div>
{showThemeButton ? (
<header className="host-drag-region pointer-events-none absolute inset-x-0 top-0 z-40 h-11 bg-transparent text-foreground/90">
{onToggleSidebar ? (
<Button
type="button"
variant="ghost"
size="icon"
aria-label={t("thread.header.toggleTheme")}
onClick={onToggleTheme}
className="host-no-drag pointer-events-auto h-8 w-8 rounded-full text-muted-foreground/85 hover:bg-accent/40 hover:text-foreground"
aria-label={t("thread.header.toggleSidebar")}
data-testid="host-sidebar-toggle"
onClick={onToggleSidebar}
onFocus={!sidebarOpen ? onSidebarPreviewEnter : undefined}
onBlur={!sidebarOpen ? onSidebarPreviewLeave : undefined}
onMouseEnter={!sidebarOpen ? onSidebarPreviewEnter : undefined}
onMouseLeave={!sidebarOpen ? onSidebarPreviewLeave : undefined}
className="host-no-drag pointer-events-auto absolute left-[88px] top-[8px] h-7 w-7 rounded-lg bg-transparent text-muted-foreground/85 shadow-none hover:bg-transparent hover:text-foreground"
>
{theme === "dark" ? (
<Sun className="h-4 w-4" />
) : (
<Moon className="h-4 w-4" />
)}
<PanelLeft className="h-[15px] w-[15px]" strokeWidth={1.75} />
</Button>
) : (
<div aria-hidden className="host-no-drag pointer-events-none h-8 w-8" />
)}
) : null}
{rightAction ? (
<div className="host-no-drag pointer-events-auto absolute right-3 top-2">
{rightAction}
</div>
) : null}
</header>
);
}
@@ -318,6 +332,36 @@ export default function App() {
const [state, setState] = useState<BootState>({ status: "loading" });
const bootstrapSecretRef = useRef("");
const refreshReadyClient = useCallback(
async (client: NanobotClient, fallbackSurface: RuntimeSurface) => {
const boot = await fetchBootstrap("", bootstrapSecretRef.current);
const url = deriveWsUrl(boot.ws_path, boot.token, boot.ws_url);
const runtimeSurface = boot.runtime_surface
? toRuntimeSurface(boot.runtime_surface)
: fallbackSurface;
const runtimeHost = createRuntimeHost(runtimeSurface, boot.runtime_capabilities);
const tokenExpiresAt = bootstrapTokenExpiresAt(boot.expires_in);
if (runtimeHost.socketFactory) {
client.updateUrl(url, runtimeHost.socketFactory);
} else {
client.updateUrl(url);
}
setState((current) =>
current.status === "ready" && current.client === client
? {
...current,
token: boot.token,
tokenExpiresAt,
modelName: boot.model_name ?? current.modelName,
runtimeSurface,
}
: current,
);
return { token: boot.token, url };
},
[],
);
const bootstrapWithSecret = useCallback(
(secret: string) => {
let cancelled = false;
@@ -335,37 +379,8 @@ export default function App() {
socketFactory: runtimeHost.socketFactory,
onReauth: async () => {
try {
const refreshed = await fetchBootstrap("", bootstrapSecretRef.current);
const refreshedUrl = deriveWsUrl(
refreshed.ws_path,
refreshed.token,
refreshed.ws_url,
);
const refreshedSurface = refreshed.runtime_surface
? toRuntimeSurface(refreshed.runtime_surface)
: runtimeSurface;
const refreshedHost = createRuntimeHost(
refreshedSurface,
refreshed.runtime_capabilities,
);
const tokenExpiresAt = bootstrapTokenExpiresAt(refreshed.expires_in);
if (refreshedHost.socketFactory) {
client.updateUrl(refreshedUrl, refreshedHost.socketFactory);
} else {
client.updateUrl(refreshedUrl);
}
setState((current) =>
current.status === "ready" && current.client === client
? {
...current,
token: refreshed.token,
tokenExpiresAt,
modelName: refreshed.model_name ?? current.modelName,
runtimeSurface: refreshedSurface,
}
: current,
);
return refreshedUrl;
const refreshed = await refreshReadyClient(client, runtimeSurface);
return refreshed.url;
} catch {
return null;
}
@@ -395,7 +410,7 @@ export default function App() {
cancelled = true;
};
},
[],
[refreshReadyClient],
);
useEffect(() => {
@@ -403,29 +418,7 @@ export default function App() {
const client = state.client;
const timer = window.setTimeout(async () => {
try {
const boot = await fetchBootstrap("", bootstrapSecretRef.current);
const url = deriveWsUrl(boot.ws_path, boot.token, boot.ws_url);
const runtimeSurface = boot.runtime_surface
? toRuntimeSurface(boot.runtime_surface)
: state.runtimeSurface;
const runtimeHost = createRuntimeHost(runtimeSurface, boot.runtime_capabilities);
const tokenExpiresAt = bootstrapTokenExpiresAt(boot.expires_in);
if (runtimeHost.socketFactory) {
client.updateUrl(url, runtimeHost.socketFactory);
} else {
client.updateUrl(url);
}
setState((current) =>
current.status === "ready" && current.client === client
? {
...current,
token: boot.token,
tokenExpiresAt,
modelName: boot.model_name ?? current.modelName,
runtimeSurface,
}
: current,
);
await refreshReadyClient(client, state.runtimeSurface);
} catch (e) {
const msg = (e as Error).message;
if (msg.includes("HTTP 401") || msg.includes("HTTP 403")) {
@@ -434,7 +427,7 @@ export default function App() {
}
}, tokenRefreshDelayMs(state.tokenExpiresAt));
return () => window.clearTimeout(timer);
}, [state]);
}, [refreshReadyClient, state]);
useEffect(() => {
const saved = loadSavedSecret();
@@ -492,6 +485,16 @@ export default function App() {
setState({ status: "auth" });
};
const handleNativeEngineRestart = async (): Promise<string> => {
const hostApi = getHostApi();
if (!hostApi?.restartEngine) {
throw new Error("native engine restart is unavailable");
}
await hostApi.restartEngine();
const refreshed = await refreshReadyClient(state.client, state.runtimeSurface);
return refreshed.token;
};
return (
<ClientProvider
client={state.client}
@@ -502,6 +505,7 @@ export default function App() {
runtimeSurface={state.runtimeSurface}
onModelNameChange={handleModelNameChange}
onLogout={handleLogout}
onNativeEngineRestart={handleNativeEngineRestart}
/>
</ClientProvider>
);
@@ -511,10 +515,12 @@ function Shell({
runtimeSurface,
onModelNameChange,
onLogout,
onNativeEngineRestart,
}: {
runtimeSurface: RuntimeSurface;
onModelNameChange: (modelName: string | null) => void;
onLogout: () => void;
onNativeEngineRestart: () => Promise<string>;
}) {
const { t, i18n } = useTranslation();
const { client, token } = useClient();
@@ -532,6 +538,7 @@ function Shell({
useState<SettingsSectionKey>(initialRouteRef.current.settingsSection);
const [hostSidebarOpen, setHostSidebarOpen] =
useState<boolean>(readSidebarOpen);
const [hostSidebarPreviewOpen, setHostSidebarPreviewOpen] = useState(false);
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
const [sessionSearchOpen, setSessionSearchOpen] = useState(false);
const [pendingDelete, setPendingDelete] = useState<{
@@ -552,6 +559,7 @@ function Shell({
const [runningChatIds, setRunningChatIds] = useState<Set<string>>(() => new Set());
const [completedChatIds, setCompletedChatIds] = useState<Set<string>>(readCompletedRunChatIds);
const [workspaces, setWorkspaces] = useState<WorkspacesPayload | null>(null);
const skills = useSkills(token);
const [settingsSnapshot, setSettingsSnapshot] = useState<SettingsPayload | null>(null);
const [workspaceError, setWorkspaceError] = useState<string | null>(null);
const [draftWorkspaceScope, setDraftWorkspaceScope] =
@@ -560,6 +568,11 @@ function Shell({
useState<Record<string, WorkspaceScopePayload>>({});
const runningChatIdsRef = useRef<Set<string>>(new Set());
const activeChatIdRef = useRef<string | null>(null);
const hostSidebarPreviewCloseTimerRef = useRef<number | null>(null);
const effectiveRuntimeSurface =
settingsSnapshot?.surface ?? settingsSnapshot?.runtime_surface ?? runtimeSurface;
const showHostChrome = effectiveRuntimeSurface === "native";
const showMainSidebar = view !== "settings";
const navigate = useCallback(
(route: ShellRoute, options?: { replace?: boolean }) => {
@@ -745,13 +758,74 @@ function Shell({
});
}, [client, loading, sessions]);
const closeHostSidebar = useCallback(() => {
setHostSidebarOpen(false);
const clearHostSidebarPreviewCloseTimer = useCallback(() => {
if (hostSidebarPreviewCloseTimerRef.current === null) return;
window.clearTimeout(hostSidebarPreviewCloseTimerRef.current);
hostSidebarPreviewCloseTimerRef.current = null;
}, []);
const closeHostSidebarPreview = useCallback(() => {
clearHostSidebarPreviewCloseTimer();
setHostSidebarPreviewOpen(false);
}, [clearHostSidebarPreviewCloseTimer]);
const openHostSidebarPreview = useCallback(() => {
if (!showHostChrome || !showMainSidebar || hostSidebarOpen) return;
clearHostSidebarPreviewCloseTimer();
setHostSidebarPreviewOpen(true);
}, [
clearHostSidebarPreviewCloseTimer,
hostSidebarOpen,
showHostChrome,
showMainSidebar,
]);
const scheduleHostSidebarPreviewClose = useCallback(() => {
clearHostSidebarPreviewCloseTimer();
if (!showHostChrome || !showMainSidebar || hostSidebarOpen) {
setHostSidebarPreviewOpen(false);
return;
}
hostSidebarPreviewCloseTimerRef.current = window.setTimeout(() => {
setHostSidebarPreviewOpen(false);
hostSidebarPreviewCloseTimerRef.current = null;
}, 160);
}, [
clearHostSidebarPreviewCloseTimer,
hostSidebarOpen,
showHostChrome,
showMainSidebar,
]);
useEffect(() => {
return () => clearHostSidebarPreviewCloseTimer();
}, [clearHostSidebarPreviewCloseTimer]);
useEffect(() => {
if (!showHostChrome || !showMainSidebar || hostSidebarOpen) {
closeHostSidebarPreview();
}
}, [
closeHostSidebarPreview,
hostSidebarOpen,
showHostChrome,
showMainSidebar,
]);
const closeHostSidebar = useCallback(() => {
closeHostSidebarPreview();
setHostSidebarOpen(false);
}, [closeHostSidebarPreview]);
const openHostSidebar = useCallback(() => {
closeHostSidebarPreview();
setHostSidebarOpen(true);
}, []);
}, [closeHostSidebarPreview]);
const toggleHostSidebar = useCallback(() => {
closeHostSidebarPreview();
setHostSidebarOpen((v) => !v);
}, [closeHostSidebarPreview]);
const closeMobileSidebar = useCallback(() => {
setMobileSidebarOpen(false);
@@ -762,11 +836,12 @@ function Shell({
typeof window !== "undefined" &&
window.matchMedia("(min-width: 1024px)").matches;
if (isNativeHost) {
closeHostSidebarPreview();
setHostSidebarOpen((v) => !v);
} else {
setMobileSidebarOpen((v) => !v);
}
}, []);
}, [closeHostSidebarPreview]);
const applyWorkspaceScope = useCallback(
(scope: WorkspaceScopePayload) => {
@@ -1041,16 +1116,26 @@ function Shell({
setMobileSidebarOpen(false);
}, [activeKey, navigate]);
const onOpenModelSettings = useCallback(() => {
onOpenSettings("models");
}, [onOpenSettings]);
const onOpenApps = useCallback(() => {
setSessionSearchOpen(false);
navigate({ view: "apps", activeKey, settingsSection: "apps" });
setMobileSidebarOpen(false);
}, [activeKey, navigate]);
const onOpenSkills = useCallback(() => {
setSessionSearchOpen(false);
navigate({ view: "skills", activeKey, settingsSection: "skills" });
setMobileSidebarOpen(false);
}, [activeKey, navigate]);
const onSettingsSectionChange = useCallback(
(section: SettingsSectionKey) => {
navigate({
view: section === "apps" ? "apps" : "settings",
view: shellViewForSettingsSection(section),
activeKey,
settingsSection: section,
});
@@ -1202,6 +1287,12 @@ function Shell({
});
return;
}
if (view === "skills") {
document.title = t("app.documentTitle.chat", {
title: t("settings.nav.skills", { defaultValue: "Skills" }),
});
return;
}
document.title = activeSession
? t("app.documentTitle.chat", { title: headerTitle })
: t("app.documentTitle.base");
@@ -1223,8 +1314,9 @@ function Shell({
onNewChatInProject,
onOpenSettings,
onOpenApps,
onOpenSkills,
onOpenSearch: onOpenSessionSearch,
activeUtility: view === "apps" ? "apps" as const : null,
activeUtility: view === "apps" || view === "skills" ? view : null,
onToggleArchived,
pinnedKeys: sidebarState.pinned_keys,
archivedKeys: sidebarState.archived_keys,
@@ -1238,11 +1330,13 @@ function Shell({
archivedCount: sidebarState.archived_keys.length,
defaultWorkspacePath: workspaces?.default_scope.project_path ?? null,
};
const effectiveRuntimeSurface =
settingsSnapshot?.surface ?? settingsSnapshot?.runtime_surface ?? runtimeSurface;
const isNativeHostSetupSurface = effectiveRuntimeSurface === "native";
const showHostChrome = isNativeHostSetupSurface;
const showMainSidebar = view !== "settings";
const hostSidebarCollapsed = showHostChrome && !hostSidebarOpen;
const showHostSidebarPreview =
showMainSidebar && hostSidebarCollapsed && hostSidebarPreviewOpen;
const hostSidebarFlowWidth = showHostChrome
? (hostSidebarOpen ? SIDEBAR_WIDTH : 0)
: (hostSidebarOpen ? SIDEBAR_WIDTH : SIDEBAR_RAIL_WIDTH);
const renderHostSidebarFlowContent = !showHostChrome || hostSidebarOpen;
useEffect(() => {
document.documentElement.classList.toggle("native-host", showHostChrome);
@@ -1261,9 +1355,28 @@ function Shell({
>
{showHostChrome ? (
<HostChrome
onToggleSidebar={showMainSidebar ? toggleSidebar : undefined}
theme={theme}
onToggleTheme={toggle}
onToggleSidebar={showMainSidebar ? toggleHostSidebar : undefined}
onSidebarPreviewEnter={openHostSidebarPreview}
onSidebarPreviewLeave={scheduleHostSidebarPreviewClose}
sidebarOpen={hostSidebarOpen}
rightAction={
view === "chat" ? undefined : (
<Button
type="button"
variant="ghost"
size="icon"
aria-label={t("thread.header.toggleTheme")}
onClick={toggle}
className="h-8 w-8 rounded-full text-muted-foreground/85 hover:bg-accent/40 hover:text-foreground"
>
{theme === "dark" ? (
<Sun className="h-4 w-4" />
) : (
<Moon className="h-4 w-4" />
)}
</Button>
)
}
/>
) : null}
<div
@@ -1274,25 +1387,47 @@ function Shell({
{/* Host sidebar: in normal flow, so the thread area width stays honest. */}
{showMainSidebar ? (
<aside
data-testid="host-sidebar-flow"
className={cn(
"relative z-20 hidden shrink-0 overflow-hidden lg:block",
"transition-[width] duration-300 ease-out",
)}
style={{
width: hostSidebarOpen ? SIDEBAR_WIDTH : SIDEBAR_RAIL_WIDTH,
width: hostSidebarFlowWidth,
}}
>
<div
className={cn(
"absolute inset-y-0 left-0 h-full w-full overflow-hidden",
showHostChrome
? "host-sidebar-glass"
: "bg-sidebar shadow-inner-right",
)}
>
{renderHostSidebarFlowContent ? (
<div
className={cn(
"absolute inset-y-0 left-0 h-full w-full overflow-hidden",
showHostChrome
? "host-sidebar-glass"
: "bg-sidebar shadow-inner-right",
)}
>
<Sidebar
{...sidebarProps}
collapsed={!showHostChrome && !hostSidebarOpen}
hostChromeInset={showHostChrome}
onCollapse={closeHostSidebar}
onExpand={openHostSidebar}
/>
</div>
) : null}
</aside>
) : null}
{showHostSidebarPreview ? (
<aside
data-testid="host-sidebar-preview"
className="absolute inset-y-0 left-0 z-30 hidden overflow-hidden lg:block animate-in fade-in-0 slide-in-from-left-2 duration-150"
style={{ width: SIDEBAR_WIDTH }}
onMouseEnter={openHostSidebarPreview}
onMouseLeave={scheduleHostSidebarPreviewClose}
>
<div className="h-full w-full overflow-hidden host-sidebar-glass shadow-2xl">
<Sidebar
{...sidebarProps}
collapsed={!hostSidebarOpen}
hostChromeInset={showHostChrome}
onCollapse={closeHostSidebar}
onExpand={openHostSidebar}
@@ -1335,7 +1470,7 @@ function Shell({
<main
className={cn(
"relative flex h-full min-w-0 flex-1 flex-col overflow-hidden bg-background",
showHostChrome && "border-l border-border/55",
showHostChrome && hostSidebarOpen && "border-l border-border/55",
)}
>
<div
@@ -1354,7 +1489,7 @@ function Shell({
theme={theme}
onToggleTheme={toggle}
hideSidebarToggleForHostChrome
hideThemeButton={showHostChrome}
hostChromeTitleInset={hostSidebarCollapsed}
hideHeader={false}
workspaceScope={activeWorkspaceScope}
workspaceDefaultScope={workspaces?.default_scope ?? null}
@@ -1363,6 +1498,7 @@ function Shell({
workspaceError={workspaceError}
onWorkspaceScopeChange={applyWorkspaceScope}
settingsSnapshot={settingsSnapshot}
onOpenModelSettings={onOpenModelSettings}
/>
</div>
{view !== "chat" && (
@@ -1370,15 +1506,18 @@ function Shell({
<SettingsView
theme={theme}
initialSection={settingsInitialSection}
initialSettings={settingsSnapshot}
showSidebar={view === "settings"}
onToggleTheme={toggle}
onBackToChat={onBackToChat}
onModelNameChange={onModelNameChange}
onSettingsChange={setSettingsSnapshot}
skills={skills}
onWorkspaceSettingsChange={refreshWorkspaces}
onSectionChange={onSettingsSectionChange}
onLogout={onLogout}
onRestart={onRestart}
onNativeEngineRestart={onNativeEngineRestart}
isRestarting={isRestarting}
hostChromeInset={showHostChrome}
/>
+147 -40
View File
@@ -9,15 +9,33 @@ interface CodeBlockProps {
language?: string;
code: string;
className?: string;
chrome?: "default" | "none";
highlight?: boolean;
showLineNumbers?: boolean;
wrapLongLines?: boolean;
}
interface HighlightedCodeProps {
language?: string;
code: string;
isDark: boolean;
chrome: "default" | "none";
showLineNumbers: boolean;
wrapLongLines: boolean;
}
const CODE_FONT_STACK = [
'"JetBrains Mono"',
'"SFMono-Regular"',
'"SF Mono"',
'"Fira Code"',
'"Cascadia Code"',
'"Source Code Pro"',
"Menlo",
"Consolas",
"monospace",
].join(", ");
const LazyHighlightedCode = lazy(async () => {
const [
{ default: SyntaxHighlighter },
@@ -30,19 +48,56 @@ const LazyHighlightedCode = lazy(async () => {
]);
return {
default({ language, code, isDark }: HighlightedCodeProps) {
default({
language,
code,
isDark,
chrome,
showLineNumbers,
wrapLongLines,
}: HighlightedCodeProps) {
const theme = isDark ? oneDark : oneLight;
const transparentTheme = chrome === "none" ? {
...theme,
'pre[class*="language-"]': {
...theme['pre[class*="language-"]'],
background: "transparent",
},
'code[class*="language-"]': {
...theme['code[class*="language-"]'],
background: "transparent",
},
} : theme;
return (
<SyntaxHighlighter
language={language || "text"}
style={isDark ? oneDark : oneLight}
style={transparentTheme}
customStyle={{
background: chrome === "none" ? "transparent" : undefined,
margin: 0,
padding: "1rem",
fontSize: "0.875rem",
lineHeight: 1.6,
padding: chrome === "none" ? "0.75rem 1rem" : "1rem",
fontFamily: CODE_FONT_STACK,
fontSize: chrome === "none" ? "13px" : "0.875rem",
lineHeight: chrome === "none" ? 1.55 : 1.6,
tabSize: 2,
}}
codeTagProps={{
style: chrome === "none" ? {
background: "transparent",
fontFamily: CODE_FONT_STACK,
} : undefined,
}}
lineNumberStyle={{
minWidth: "2.6em",
paddingRight: "1.15rem",
color: isDark ? "rgba(212, 212, 216, 0.45)" : "rgba(63, 63, 70, 0.68)",
fontFamily: CODE_FONT_STACK,
userSelect: "none",
}}
PreTag="pre"
wrapLongLines
showLineNumbers={showLineNumbers}
wrapLongLines={wrapLongLines}
>
{code}
</SyntaxHighlighter>
@@ -51,13 +106,39 @@ const LazyHighlightedCode = lazy(async () => {
};
});
function PlainCodeFallback({ code }: { code: string }) {
function PlainCodeFallback({
code,
chrome,
showLineNumbers,
}: {
code: string;
chrome: "default" | "none";
showLineNumbers: boolean;
}) {
const lines = code.split("\n");
return (
<pre
className="m-0 overflow-x-auto whitespace-pre-wrap bg-background p-4 font-mono text-sm leading-[1.6] text-foreground/90"
className={cn(
"m-0 overflow-x-auto p-4 font-mono text-sm leading-[1.6] text-foreground/90",
showLineNumbers ? "whitespace-pre" : "whitespace-pre-wrap",
chrome === "default" ? "bg-background" : "bg-transparent",
chrome === "none" && "p-3 text-[13px] leading-[1.55]",
)}
data-testid="plain-code-fallback"
>
<code className="text-inherit">{code}</code>
<code className="text-inherit">
{showLineNumbers ? (
lines.map((line, index) => (
<span key={index} className="flex min-w-max">
<span className="w-10 shrink-0 select-none pr-4 text-right text-muted-foreground/60">
{index + 1}
</span>
<span className="whitespace-pre">{line || " "}</span>
{index < lines.length - 1 ? "\n" : null}
</span>
))
) : code}
</code>
</pre>
);
}
@@ -66,11 +147,15 @@ export function CodeBlock({
language,
code,
className,
chrome = "default",
highlight = true,
showLineNumbers = false,
wrapLongLines = true,
}: CodeBlockProps) {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
const isDark = useThemeValue() === "dark";
const hasChrome = chrome === "default";
const onCopy = useCallback(() => {
if (!navigator.clipboard) return;
@@ -83,47 +168,69 @@ export function CodeBlock({
return (
<div
className={cn(
"overflow-hidden rounded-lg border",
isDark ? "border-white/10" : "border-black/10",
"overflow-hidden",
hasChrome && "rounded-lg border",
hasChrome && (isDark ? "border-white/10" : "border-black/10"),
className,
)}
>
<div
className={cn(
"flex items-center justify-between px-4 py-1.5 text-xs font-medium",
isDark
? "bg-zinc-800 text-zinc-300"
: "bg-zinc-100 text-zinc-600",
)}
>
<span className="lowercase font-mono">
{language || t("code.fallbackLanguage")}
</span>
<button
type="button"
onClick={onCopy}
{hasChrome ? (
<div
className={cn(
"inline-flex items-center gap-1 rounded px-1.5 py-0.5 font-mono transition-colors",
"flex items-center justify-between px-4 py-1.5 text-xs font-medium",
isDark
? "text-zinc-400 hover:bg-zinc-700 hover:text-zinc-200"
: "text-zinc-500 hover:bg-zinc-200 hover:text-zinc-700",
? "bg-zinc-800 text-zinc-300"
: "bg-zinc-100 text-zinc-600",
)}
aria-label={t("code.copyAria")}
>
{copied ? (
<Check className="h-3.5 w-3.5" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
<span>{copied ? t("code.copied") : t("code.copy")}</span>
</button>
</div>
<span className="lowercase font-mono">
{language || t("code.fallbackLanguage")}
</span>
<button
type="button"
onClick={onCopy}
className={cn(
"inline-flex items-center gap-1 rounded px-1.5 py-0.5 font-mono transition-colors",
isDark
? "text-zinc-400 hover:bg-zinc-700 hover:text-zinc-200"
: "text-zinc-500 hover:bg-zinc-200 hover:text-zinc-700",
)}
aria-label={t("code.copyAria")}
>
{copied ? (
<Check className="h-3.5 w-3.5" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
<span>{copied ? t("code.copied") : t("code.copy")}</span>
</button>
</div>
) : null}
{highlight ? (
<Suspense fallback={<PlainCodeFallback code={code} />}>
<LazyHighlightedCode language={language} code={code} isDark={isDark} />
<Suspense
fallback={
<PlainCodeFallback
code={code}
chrome={chrome}
showLineNumbers={showLineNumbers}
/>
}
>
<LazyHighlightedCode
language={language}
code={code}
isDark={isDark}
chrome={chrome}
showLineNumbers={showLineNumbers}
wrapLongLines={wrapLongLines}
/>
</Suspense>
) : (
<PlainCodeFallback code={code} />
<PlainCodeFallback
code={code}
chrome={chrome}
showLineNumbers={showLineNumbers}
/>
)}
</div>
);
+287
View File
@@ -0,0 +1,287 @@
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>
);
}
+64 -4
View File
@@ -1,3 +1,5 @@
import type { KeyboardEvent, MouseEvent } from "react";
import {
Tooltip,
TooltipContent,
@@ -6,10 +8,11 @@ import {
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
type FileReferenceKind =
export type FileReferenceKind =
| "default"
| "css"
| "html"
| "javascript"
| "json"
| "markdown"
| "notebook"
@@ -24,6 +27,8 @@ interface FileReferenceChipProps {
active?: boolean;
className?: string;
textClassName?: string;
previewPath?: string;
onOpen?: (path: string) => void;
testId?: string;
}
@@ -34,12 +39,26 @@ export function FileReferenceChip({
active = false,
className,
textClassName,
previewPath,
onOpen,
testId = "inline-file-path",
}: FileReferenceChipProps) {
const { directory, name } = splitFilePath(path);
const kind = fileKindForPath(path);
const displayText = display === "path" ? path.replace(/\\/g, "/") : name;
const fullPath = tooltipPath || path;
const targetPath = previewPath || tooltipPath || path;
const interactive = Boolean(onOpen);
const openPreview = (event: MouseEvent | KeyboardEvent) => {
if (!onOpen) return;
event.preventDefault();
event.stopPropagation();
onOpen(targetPath);
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Enter" && event.key !== " ") return;
openPreview(event);
};
return (
<TooltipProvider delayDuration={500} skipDelayDuration={100}>
<Tooltip>
@@ -50,10 +69,18 @@ export function FileReferenceChip({
<span
data-testid={testId}
aria-label={fullPath}
role={interactive ? "button" : undefined}
tabIndex={interactive ? 0 : undefined}
onClick={interactive ? openPreview : undefined}
onKeyDown={interactive ? onKeyDown : undefined}
className={cn(
"inline-flex max-w-full items-baseline gap-[0.28em] font-medium leading-[inherit]",
"text-sky-600 transition-colors hover:text-sky-700",
"dark:text-sky-300 dark:hover:text-sky-200",
interactive && [
"cursor-pointer rounded-[5px]",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-400/45",
],
)}
>
<FileReferenceIcon kind={kind} />
@@ -100,6 +127,7 @@ export function isLikelyFilePath(value: string): boolean {
const raw = value.trim();
if (!raw || raw.includes("\n")) return false;
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(raw)) return false;
if (isFilePatternReference(raw)) return false;
if (!/[\\/]/.test(raw) && !/^(dockerfile|makefile|readme|package-lock\.json)$/i.test(raw)) {
return false;
}
@@ -110,7 +138,11 @@ export function isLikelyFilePath(value: string): boolean {
return /\.[a-z0-9][a-z0-9_-]{0,12}$/i.test(name);
}
function splitFilePath(path: string): { directory: string; name: string } {
export function isFilePatternReference(value: string): boolean {
return /[*?[\]{}]/.test(value.trim());
}
export function splitFilePath(path: string): { directory: string; name: string } {
const normalized = path.replace(/\\/g, "/");
const slash = normalized.lastIndexOf("/");
if (slash < 0) return { directory: "", name: path };
@@ -120,7 +152,7 @@ function splitFilePath(path: string): { directory: string; name: string } {
};
}
function fileKindForPath(path: string): FileReferenceKind {
export function fileKindForPath(path: string): FileReferenceKind {
const normalized = path.toLowerCase();
const name = normalized.split(/[\\/]/).pop() ?? normalized;
const ext = name.includes(".") ? name.split(".").pop() ?? "" : "";
@@ -134,7 +166,13 @@ function fileKindForPath(path: string): FileReferenceKind {
case "jsx":
case "tsx":
return "react";
case "js":
case "mjs":
case "cjs":
return "javascript";
case "ts":
case "mts":
case "cts":
return "typescript";
case "html":
case "htm":
@@ -156,7 +194,27 @@ function fileKindForPath(path: string): FileReferenceKind {
}
}
function FileReferenceIcon({ kind }: { kind: FileReferenceKind }) {
export function FileReferenceIcon({ kind }: { kind: FileReferenceKind }) {
if (kind === "python") {
return (
<svg
aria-hidden
className="h-[1em] w-[1em] shrink-0 translate-y-[0.12em]"
viewBox="0 0 24 24"
>
<path
d="M11.9 2.3c-3 0-4.5.8-4.5 2.3v2.1h4.8v.8H5.5C4 7.5 3 8.8 3 10.8v2.1c0 1.8 1.1 3 2.7 3h1.6v-2.3c0-1.7 1.4-3.1 3.1-3.1h4.2c1.3 0 2.3-1 2.3-2.3V4.6c0-1.4-1.5-2.3-4.6-2.3h-.4Z"
fill="#3776AB"
/>
<path
d="M12.1 21.7c3 0 4.5-.8 4.5-2.3v-2.1h-4.8v-.8h6.7c1.5 0 2.5-1.3 2.5-3.3v-2.1c0-1.8-1.1-3-2.7-3h-1.6v2.3c0 1.7-1.4 3.1-3.1 3.1H9.4c-1.3 0-2.3 1-2.3 2.3v3.6c0 1.4 1.5 2.3 4.6 2.3h.4Z"
fill="#FFD43B"
/>
<circle cx="9" cy="5.1" r="0.8" fill="#fff" />
<circle cx="15" cy="18.9" r="0.8" fill="#5C3B00" opacity="0.85" />
</svg>
);
}
if (kind === "react") {
return (
<svg
@@ -234,6 +292,8 @@ function fileKindLabel(kind: FileReferenceKind): string {
return "#";
case "html":
return "H";
case "javascript":
return "JS";
case "json":
return "{}";
case "markdown":
+10 -1
View File
@@ -16,6 +16,7 @@ interface MarkdownTextProps {
children: string;
className?: string;
streaming?: boolean;
onOpenFilePreview?: (path: string) => void;
}
const loadMarkdownRenderer = () => import("@/components/MarkdownTextRenderer");
@@ -25,13 +26,19 @@ const MemoizedMarkdownRenderer = memo(function MemoizedMarkdownRenderer({
source,
className,
highlightCode,
onOpenFilePreview,
}: {
source: string;
className?: string;
highlightCode: boolean;
onOpenFilePreview?: (path: string) => void;
}) {
return (
<LazyMarkdownRenderer className={className} highlightCode={highlightCode}>
<LazyMarkdownRenderer
className={className}
highlightCode={highlightCode}
onOpenFilePreview={onOpenFilePreview}
>
{source}
</LazyMarkdownRenderer>
);
@@ -55,6 +62,7 @@ export function MarkdownText({
children,
className,
streaming = false,
onOpenFilePreview,
}: MarkdownTextProps) {
const renderedSource = useStreamingMarkdownSource(children, streaming);
const highlightCode = streaming
@@ -82,6 +90,7 @@ export function MarkdownText({
source={renderedSource}
className={className}
highlightCode={highlightCode}
onOpenFilePreview={onOpenFilePreview}
/>
</Suspense>
);
+108 -30
View File
@@ -1,16 +1,29 @@
import { Children, isValidElement, useMemo, type ReactNode } from "react";
import {
Children,
isValidElement,
useCallback,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import type { Components, Options as ReactMarkdownOptions } from "react-markdown";
import ReactMarkdown from "react-markdown";
import rehypeKatex from "rehype-katex";
import { Check } from "lucide-react";
import { Check, Globe2 } from "lucide-react";
import remarkBreaks from "remark-breaks";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import { AttachmentTile } from "@/components/AttachmentTile";
import { CodeBlock } from "@/components/CodeBlock";
import { FileReferenceChip, isLikelyFilePath } from "@/components/FileReferenceChip";
import {
FileReferenceChip,
isFilePatternReference,
isLikelyFilePath,
} from "@/components/FileReferenceChip";
import { inferMediaKind } from "@/lib/media";
import { faviconUrls } from "@/lib/provider-brand";
import { cn } from "@/lib/utils";
import "katex/dist/katex.min.css";
@@ -19,6 +32,7 @@ interface MarkdownTextRendererProps {
children: string;
className?: string;
highlightCode?: boolean;
onOpenFilePreview?: (path: string) => void;
}
type MarkdownAstNode = {
@@ -32,10 +46,9 @@ type MarkdownAstNode = {
type InlineLinkPreview = {
href: string;
origin: string;
host: string;
prefix?: string;
title: string;
initials: string;
};
const SAFE_INLINE_HTML_TAGS = new Set(["mark", "sub", "sup"]);
@@ -187,6 +200,45 @@ function nodeText(value: ReactNode): string {
.join("");
}
function cleanFileReferenceTarget(value: string): string {
let target = value.trim();
if (!target) return "";
try {
if (/^file:\/\//i.test(target)) {
target = decodeURIComponent(new URL(target).pathname);
} else {
target = decodeURIComponent(target);
}
} catch {
// Keep the raw value when URL/path decoding is not possible.
}
target = target.split("?", 1)[0]?.split("#", 1)[0]?.trim() ?? "";
if (!/^[A-Za-z]:[\\/]/.test(target)) {
target = target.replace(/:\d+(?::\d+)?$/, "");
}
return target;
}
function isPreviewableFileTarget(value: string): boolean {
if (isFilePatternReference(value)) return false;
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 isNonNavigableFilePatternLink(href: string | undefined): boolean {
if (!href || /^https?:\/\//i.test(href) || href.startsWith("#")) return false;
const target = cleanFileReferenceTarget(href);
return Boolean(target && isFilePatternReference(target));
}
function fileReferenceFromLink(href: string | undefined): string | null {
if (!href || /^https?:\/\//i.test(href) || href.startsWith("#")) return null;
const target = cleanFileReferenceTarget(href);
return isPreviewableFileTarget(target) ? target : null;
}
function linkPreviewParts(value: ReactNode): { text: string; href?: string } {
let text = "";
let href: string | undefined;
@@ -216,16 +268,6 @@ function cleanLinkPreviewText(value: string): string {
.trim();
}
function linkPreviewInitials(value: string): string {
const clean = value
.replace(/^https?:\/\//i, "")
.replace(/^www\./i, "")
.replace(/\.[a-z]{2,}$/i, "");
const parts = clean.split(/[\s.-]+/).filter(Boolean);
return (parts.length > 1 ? parts.slice(0, 2).map((part) => part[0]).join("") : clean.slice(0, 2))
.toUpperCase();
}
function inlineLinkPreviewFromChildren(children: ReactNode): InlineLinkPreview | null {
const { text: rawText, href } = linkPreviewParts(children);
if (!href) return null;
@@ -253,17 +295,18 @@ function inlineLinkPreviewFromChildren(children: ReactNode): InlineLinkPreview |
return {
href,
origin: url.origin,
host: url.hostname,
prefix,
title,
initials: linkPreviewInitials(prefix || url.hostname),
};
}
function InlineLinkPreviewRow({ link }: { link: InlineLinkPreview }) {
const { favicon, onFaviconError } = useFaviconFallback(link.host);
const label = link.prefix
? `${link.prefix}${link.title}`
: link.title;
return (
<a
href={link.href}
@@ -278,20 +321,21 @@ function InlineLinkPreviewRow({ link }: { link: InlineLinkPreview }) {
<span
className={cn(
"relative grid h-4 w-4 shrink-0 place-items-center overflow-hidden rounded-[4px]",
"border border-border/65 bg-background text-[0.5rem] font-semibold text-muted-foreground",
"border border-border/65 bg-background text-muted-foreground",
)}
aria-hidden
>
{link.initials}
<img
src={`${link.origin}/favicon.ico`}
alt=""
className="absolute h-3 w-3 rounded-[2px] object-contain"
loading="lazy"
onError={(event) => {
event.currentTarget.style.display = "none";
}}
/>
{favicon ? (
<img
src={favicon}
alt=""
className="h-3 w-3 rounded-[2px] object-contain"
loading="lazy"
onError={onFaviconError}
/>
) : (
<Globe2 className="h-3 w-3" />
)}
</span>
<span className="min-w-0 truncate leading-normal">
{label}
@@ -300,6 +344,24 @@ function InlineLinkPreviewRow({ link }: { link: InlineLinkPreview }) {
);
}
function useFaviconFallback(host: string) {
const faviconCandidates = useMemo(() => faviconUrls(host), [host]);
const [faviconIndex, setFaviconIndex] = useState(0);
useEffect(() => {
setFaviconIndex(0);
}, [host]);
const onFaviconError = useCallback(() => {
setFaviconIndex((index) => Math.min(index + 1, faviconCandidates.length));
}, [faviconCandidates.length]);
return {
favicon: faviconCandidates[faviconIndex] ?? null,
onFaviconError,
};
}
function isRenderedCodeBlock(value: ReactNode): boolean {
if (!isValidElement(value)) return false;
const props = value.props as { code?: unknown };
@@ -326,6 +388,7 @@ export default function MarkdownTextRenderer({
children,
className,
highlightCode = true,
onOpenFilePreview,
}: MarkdownTextRendererProps) {
const components = useMemo<Components>(
() => ({
@@ -344,7 +407,7 @@ export default function MarkdownTextRenderer({
}
const raw = String(kids).replace(/\n$/, "");
if (isLikelyFilePath(raw)) {
return <FileReferenceChip path={raw} />;
return <FileReferenceChip path={raw} onOpen={onOpenFilePreview} />;
}
/** Plain fenced ``` blocks (no language) & wide one-liners: block monospace, not inline pill. */
const widePlainBlock = raw.includes("\n") || raw.length > 120;
@@ -405,6 +468,21 @@ export default function MarkdownTextRenderer({
);
},
a({ href, children: markdownChildren, ...props }) {
const filePath = fileReferenceFromLink(href);
if (filePath) {
const label = nodeText(markdownChildren).trim();
return (
<FileReferenceChip
path={label || filePath}
tooltipPath={filePath}
previewPath={filePath}
onOpen={onOpenFilePreview}
/>
);
}
if (isNonNavigableFilePatternLink(href)) {
return <>{markdownChildren}</>;
}
return (
<a
href={href}
@@ -495,7 +573,7 @@ export default function MarkdownTextRenderer({
);
},
}),
[highlightCode],
[highlightCode, onOpenFilePreview],
);
return (
+47 -3
View File
@@ -6,7 +6,7 @@ import {
useState,
type ReactNode,
} from "react";
import { Check, ChevronRight, Copy, ImageIcon, Sparkles, Wrench } from "lucide-react";
import { Check, ChevronRight, Clock3, Copy, ImageIcon, Sparkles, Wrench } from "lucide-react";
import { useTranslation } from "react-i18next";
import { AttachmentTile } from "@/components/AttachmentTile";
@@ -33,6 +33,7 @@ interface MessageBubbleProps {
showAssistantCopyAction?: boolean;
cliApps?: CliAppInfo[];
mcpPresets?: McpPresetInfo[];
onOpenFilePreview?: (path: string) => void;
}
/**
@@ -49,6 +50,7 @@ export function MessageBubble({
showAssistantCopyAction = true,
cliApps = [],
mcpPresets = [],
onOpenFilePreview,
}: MessageBubbleProps) {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
@@ -129,6 +131,10 @@ export function MessageBubble({
const reasoning = message.role === "assistant" ? message.reasoning ?? "" : "";
const reasoningStreaming = !!(message.role === "assistant" && message.reasoningStreaming);
const hasReasoning = reasoning.length > 0 || reasoningStreaming;
const automationSourceLabel = message.source?.kind === "cron"
? (message.source.label?.trim() || t("message.automationSourceFallback"))
: "";
const automationTriggeredLabel = t("message.automationTriggered");
const showAssistantActions = message.role === "assistant" && !message.isStreaming && !empty;
const showCopyButton = showAssistantCopyAction && showAssistantActions;
@@ -142,13 +148,29 @@ export function MessageBubble({
return (
<div className={cn("w-full text-[15px]", baseAnim)} style={{ lineHeight: "var(--cjk-line-height)" }}>
{hasReasoning ? (
<ReasoningBubble text={reasoning} streaming={reasoningStreaming} hasBodyBelow={!empty} />
<ReasoningBubble
text={reasoning}
streaming={reasoningStreaming}
hasBodyBelow={!empty}
onOpenFilePreview={onOpenFilePreview}
/>
) : null}
{empty && message.isStreaming && !hasReasoning ? (
<TypingDots />
) : empty && message.isStreaming ? null : (
<>
<MarkdownText streaming={!!message.isStreaming}>{message.content}</MarkdownText>
{automationSourceLabel ? (
<AutomationSourceBadge
label={automationSourceLabel}
triggerLabel={automationTriggeredLabel}
/>
) : null}
<MarkdownText
streaming={!!message.isStreaming}
onOpenFilePreview={onOpenFilePreview}
>
{message.content}
</MarkdownText>
{media.length > 0 ? <MessageMedia media={media} align="left" /> : null}
{showAssistantFooterRow ? (
<div className="mt-2 flex min-h-8 flex-wrap items-center gap-x-2 gap-y-1 text-muted-foreground">
@@ -187,6 +209,25 @@ export function MessageBubble({
);
}
function AutomationSourceBadge({ label, triggerLabel }: { label: string; triggerLabel: string }) {
return (
<div
className={cn(
"mb-2 inline-flex max-w-full items-center gap-1.5 rounded-full px-2 py-1",
"border border-sky-500/15 bg-sky-500/[0.06]",
"text-[11px] font-medium leading-none text-sky-700",
"dark:border-sky-300/15 dark:bg-sky-300/[0.08] dark:text-sky-200/80",
)}
title={triggerLabel}
>
<Clock3 className="h-3 w-3 shrink-0" aria-hidden />
<span className="min-w-0 truncate">{label}</span>
<span className="text-current/45" aria-hidden>·</span>
<span className="shrink-0">{triggerLabel}</span>
</div>
);
}
function mergeMcpMentionPresets(
presets: McpPresetInfo[],
attachments: UIMcpPresetAttachment[] | undefined,
@@ -488,6 +529,7 @@ interface ReasoningBubbleProps {
hasBodyBelow: boolean;
/** When true, skip the slide-in wrapper (used inside ``AgentActivityCluster``). */
embeddedInCluster?: boolean;
onOpenFilePreview?: (path: string) => void;
}
/**
@@ -509,6 +551,7 @@ export function ReasoningBubble({
streaming,
hasBodyBelow,
embeddedInCluster = false,
onOpenFilePreview,
}: ReasoningBubbleProps) {
const { t } = useTranslation();
const [userToggled, setUserToggled] = useState(false);
@@ -567,6 +610,7 @@ export function ReasoningBubble({
>
<MarkdownText
streaming={streaming}
onOpenFilePreview={onOpenFilePreview}
className={cn(
"text-[12.5px] italic text-muted-foreground/88",
"prose-p:my-1.5 prose-li:my-0.5",
+10 -1
View File
@@ -1,6 +1,7 @@
import { useState, type ReactNode } from "react";
import {
Archive,
Brain,
Menu,
Search,
Settings,
@@ -34,8 +35,9 @@ interface SidebarProps {
onNewChatInProject: (projectPath: string, projectName: string) => void;
onOpenSettings: () => void;
onOpenApps: () => void;
onOpenSkills: () => void;
onOpenSearch: () => void;
activeUtility?: "apps" | null;
activeUtility?: "apps" | "skills" | null;
onToggleArchived: () => void;
onCollapse: () => void;
onExpand?: () => void;
@@ -157,6 +159,13 @@ export function Sidebar(props: SidebarProps) {
active={props.activeUtility === "apps"}
icon={<Blocks className="h-4 w-4" />}
/>
<SidebarActionButton
collapsed={collapsed}
label={t("sidebar.skills.title")}
onClick={props.onOpenSkills}
active={props.activeUtility === "skills"}
icon={<Brain className="h-4 w-4" />}
/>
{props.archivedCount ? (
<SidebarActionButton
collapsed={collapsed}
+351 -197
View File
@@ -13,6 +13,7 @@ import {
Bot,
Brain,
Check,
CircleAlert,
ChevronDown,
ChevronLeft,
ChevronRight,
@@ -52,6 +53,8 @@ import {
import { useTranslation } from "react-i18next";
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
import { SkillsCatalogSettings } from "@/components/settings/SkillsCatalogSettings";
import { TokenUsageHeatmap } from "@/components/settings/TokenUsageHeatmap";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
@@ -73,6 +76,7 @@ import { Textarea } from "@/components/ui/textarea";
import {
createModelConfiguration,
fetchSettings,
fetchSettingsUsage,
fetchCliApps,
fetchMcpPresets,
fetchProviderModels,
@@ -99,6 +103,7 @@ import {
providerDisplayLabel,
} from "@/lib/provider-brand";
import { cn } from "@/lib/utils";
import { shortWorkspacePath } from "@/lib/workspace";
import { useClient } from "@/providers/ClientProvider";
import type {
CliAppInfo,
@@ -109,6 +114,7 @@ import type {
NetworkSafetySettingsUpdate,
ProviderModelsPayload,
SettingsPayload,
SkillSummary,
WebSearchSettingsUpdate,
WebuiDefaultAccessMode,
} from "@/lib/types";
@@ -120,6 +126,7 @@ export type SettingsSectionKey =
| "image"
| "browser"
| "apps"
| "skills"
| "runtime"
| "advanced";
@@ -167,7 +174,6 @@ type ProviderApiType = "auto" | "chat_completions" | "responses";
type ProviderForm = { apiKey: string; apiBase: string; apiType: ProviderApiType };
type CustomMcpTransport = "stdio" | "streamableHttp" | "sse";
const NANOBOT_ICON_SRC = "/brand/nanobot_icon.png";
const CONTEXT_WINDOW_TOKEN_OPTIONS = [65_536, 262_144] as const;
const DEFERRED_MODEL_LIST_PROVIDERS = new Set([
"aihubmix",
@@ -265,15 +271,18 @@ const DEFAULT_CUSTOM_MCP_FORM: CustomMcpForm = {
interface SettingsViewProps {
theme: "light" | "dark";
initialSection?: SettingsSectionKey;
initialSettings?: SettingsPayload | null;
showSidebar?: boolean;
onToggleTheme: () => void;
onBackToChat: () => void;
onModelNameChange: (modelName: string | null) => void;
onSettingsChange?: (payload: SettingsPayload) => void;
skills?: SkillSummary[];
onWorkspaceSettingsChange?: () => void | Promise<void>;
onSectionChange?: (section: SettingsSectionKey) => void;
onLogout?: () => void;
onRestart?: () => void;
onNativeEngineRestart?: () => Promise<string>;
isRestarting?: boolean;
hostChromeInset?: boolean;
}
@@ -311,27 +320,150 @@ function editableDefaultProvider(payload: SettingsPayload): string {
return base?.provider ?? payload.agent.provider ?? payload.agent.resolved_provider ?? "";
}
function settingsProviderRow(
payload: SettingsPayload,
provider: string | null | undefined,
): SettingsPayload["providers"][number] | null {
if (!provider) return null;
return payload.providers.find((row) => row.name === provider) ?? null;
}
function settingsProviderConfigured(
payload: SettingsPayload,
provider: string | null | undefined,
): boolean {
const row = settingsProviderRow(payload, provider);
if (row) return row.configured;
return payload.agent.has_api_key;
}
const DEFAULT_AGENT_SETTINGS_DRAFT: AgentSettingsDraft = {
model: "",
provider: "",
modelPreset: "default",
presetLabel: "Default",
contextWindowTokens: 65_536,
timezone: "UTC",
botName: "nanobot",
botIcon: "",
toolHintMaxLength: 40,
};
const DEFAULT_WEB_SEARCH_FORM: WebSearchSettingsUpdate = {
provider: "duckduckgo",
apiKey: "",
baseUrl: "",
maxResults: 5,
timeout: 30,
useJinaReader: true,
};
const DEFAULT_IMAGE_GENERATION_FORM: ImageGenerationSettingsUpdate = {
enabled: false,
provider: "openrouter",
model: "openai/gpt-5.4-image-2",
defaultAspectRatio: "1:1",
defaultImageSize: "1K",
maxImagesPerTurn: 4,
};
const DEFAULT_NETWORK_SAFETY_FORM: NetworkSafetySettingsUpdate = {
webuiAllowLocalServiceAccess: true,
webuiDefaultAccessMode: "default",
};
function agentDraftFromPayload(payload: SettingsPayload): AgentSettingsDraft {
const fallbackDefault = defaultPreset(payload);
const activePresetName = modelPresetValue(payload);
const activePreset =
payload.model_presets.find((preset) => preset.name === activePresetName) ?? fallbackDefault;
return {
model: activePreset?.model ?? payload.agent.model,
provider: activePreset?.is_default
? editableDefaultProvider(payload)
: activePreset?.provider ?? editableDefaultProvider(payload),
modelPreset: activePresetName,
presetLabel: activePreset?.label ?? activePresetName,
contextWindowTokens: normalizeContextWindowTokens(
activePreset?.context_window_tokens ?? payload.agent.context_window_tokens,
),
timezone: payload.agent.timezone,
botName: payload.agent.bot_name,
botIcon: payload.agent.bot_icon,
toolHintMaxLength: payload.agent.tool_hint_max_length,
};
}
function webSearchFormFromPayload(
payload: SettingsPayload,
previous?: WebSearchSettingsUpdate,
): WebSearchSettingsUpdate {
return {
provider: payload.web_search.provider,
apiKey: previous?.provider === payload.web_search.provider ? previous.apiKey ?? "" : "",
baseUrl: payload.web_search.base_url ?? "",
maxResults: payload.web_search.max_results,
timeout: payload.web_search.timeout,
useJinaReader: payload.web.fetch.use_jina_reader,
};
}
function imageGenerationFormFromPayload(payload: SettingsPayload): ImageGenerationSettingsUpdate {
return {
enabled: payload.image_generation.enabled,
provider: payload.image_generation.provider,
model: payload.image_generation.model,
defaultAspectRatio: payload.image_generation.default_aspect_ratio,
defaultImageSize: payload.image_generation.default_image_size,
maxImagesPerTurn: payload.image_generation.max_images_per_turn,
};
}
function networkSafetyFormFromPayload(payload: SettingsPayload): NetworkSafetySettingsUpdate {
return {
webuiAllowLocalServiceAccess:
payload.advanced.webui_allow_local_service_access ??
payload.advanced.allow_local_preview_access ??
true,
webuiDefaultAccessMode: visibleWebuiDefaultAccessMode(
payload.advanced.webui_default_access_mode,
),
};
}
function pendingRestartSectionsFromPayload(payload: SettingsPayload): PendingRestartSections {
const sections = payload.restart_required_sections ?? [];
return {
runtime: sections.includes("runtime"),
browser: sections.includes("browser"),
image: sections.includes("image"),
};
}
export function SettingsView({
theme,
initialSection = "overview",
initialSettings = null,
showSidebar = true,
onToggleTheme,
onBackToChat,
onModelNameChange,
onSettingsChange,
skills = [],
onWorkspaceSettingsChange,
onSectionChange,
onLogout,
onRestart,
onNativeEngineRestart,
isRestarting = false,
hostChromeInset = false,
}: SettingsViewProps) {
const { t } = useTranslation();
const { token } = useClient();
const [settings, setSettings] = useState<SettingsPayload | null>(null);
const [settings, setSettings] = useState<SettingsPayload | null>(() => initialSettings);
const [cliApps, setCliApps] = useState<CliAppsPayload | null>(null);
const [mcpPresets, setMcpPresets] = useState<McpPresetsPayload | null>(null);
const [loading, setLoading] = useState(true);
const [loading, setLoading] = useState(() => initialSettings === null);
const [cliAppsLoading, setCliAppsLoading] = useState(true);
const [mcpPresetsLoading, setMcpPresetsLoading] = useState(true);
const [saving, setSaving] = useState(false);
@@ -370,26 +502,18 @@ export function SettingsView({
EMPTY_PENDING_RESTART_SECTIONS,
);
const [localPrefs, setLocalPrefs] = useState<LocalPreferences>(() => readLocalPreferences());
const [webSearchForm, setWebSearchForm] = useState<WebSearchSettingsUpdate>({
provider: "duckduckgo",
apiKey: "",
baseUrl: "",
maxResults: 5,
timeout: 30,
useJinaReader: true,
});
const [imageGenerationForm, setImageGenerationForm] = useState<ImageGenerationSettingsUpdate>({
enabled: false,
provider: "openrouter",
model: "openai/gpt-5.4-image-2",
defaultAspectRatio: "1:1",
defaultImageSize: "1K",
maxImagesPerTurn: 4,
});
const [networkSafetyForm, setNetworkSafetyForm] = useState<NetworkSafetySettingsUpdate>({
webuiAllowLocalServiceAccess: true,
webuiDefaultAccessMode: "default",
});
const [webSearchForm, setWebSearchForm] = useState<WebSearchSettingsUpdate>(() =>
initialSettings ? webSearchFormFromPayload(initialSettings) : DEFAULT_WEB_SEARCH_FORM,
);
const [imageGenerationForm, setImageGenerationForm] = useState<ImageGenerationSettingsUpdate>(
() =>
initialSettings
? imageGenerationFormFromPayload(initialSettings)
: DEFAULT_IMAGE_GENERATION_FORM,
);
const [networkSafetyForm, setNetworkSafetyForm] = useState<NetworkSafetySettingsUpdate>(() =>
initialSettings ? networkSafetyFormFromPayload(initialSettings) : DEFAULT_NETWORK_SAFETY_FORM,
);
useEffect(() => {
setActiveSection(initialSection);
@@ -404,17 +528,9 @@ export function SettingsView({
);
const [webSearchKeyVisible, setWebSearchKeyVisible] = useState(false);
const [webSearchKeyEditing, setWebSearchKeyEditing] = useState(false);
const [form, setForm] = useState<AgentSettingsDraft>({
model: "",
provider: "",
modelPreset: "default",
presetLabel: "Default",
contextWindowTokens: 65_536,
timezone: "UTC",
botName: "nanobot",
botIcon: "",
toolHintMaxLength: 40,
});
const [form, setForm] = useState<AgentSettingsDraft>(() =>
initialSettings ? agentDraftFromPayload(initialSettings) : DEFAULT_AGENT_SETTINGS_DRAFT,
);
const text = useCallback(
(key: string, fallback: string, options?: Record<string, unknown>) =>
@@ -423,59 +539,27 @@ export function SettingsView({
);
const applyPayload = useCallback((payload: SettingsPayload) => {
const fallbackDefault = defaultPreset(payload);
const activePresetName = modelPresetValue(payload);
const activePreset =
payload.model_presets.find((preset) => preset.name === activePresetName) ?? fallbackDefault;
setSettings(payload);
setForm({
model: activePreset?.model ?? payload.agent.model,
provider: activePreset?.is_default
? editableDefaultProvider(payload)
: activePreset?.provider ?? editableDefaultProvider(payload),
modelPreset: activePresetName,
presetLabel: activePreset?.label ?? activePresetName,
contextWindowTokens: normalizeContextWindowTokens(
activePreset?.context_window_tokens ?? payload.agent.context_window_tokens,
),
timezone: payload.agent.timezone,
botName: payload.agent.bot_name,
botIcon: payload.agent.bot_icon,
toolHintMaxLength: payload.agent.tool_hint_max_length,
});
setWebSearchForm((prev) => ({
provider: payload.web_search.provider,
apiKey: prev.provider === payload.web_search.provider ? prev.apiKey ?? "" : "",
baseUrl: payload.web_search.base_url ?? "",
maxResults: payload.web_search.max_results,
timeout: payload.web_search.timeout,
useJinaReader: payload.web.fetch.use_jina_reader,
}));
setImageGenerationForm({
enabled: payload.image_generation.enabled,
provider: payload.image_generation.provider,
model: payload.image_generation.model,
defaultAspectRatio: payload.image_generation.default_aspect_ratio,
defaultImageSize: payload.image_generation.default_image_size,
maxImagesPerTurn: payload.image_generation.max_images_per_turn,
});
setNetworkSafetyForm({
webuiAllowLocalServiceAccess: payload.advanced.webui_allow_local_service_access ?? payload.advanced.allow_local_preview_access ?? true,
webuiDefaultAccessMode: visibleWebuiDefaultAccessMode(payload.advanced.webui_default_access_mode),
});
setForm(agentDraftFromPayload(payload));
setWebSearchForm((prev) => webSearchFormFromPayload(payload, prev));
setImageGenerationForm(imageGenerationFormFromPayload(payload));
setNetworkSafetyForm(networkSafetyFormFromPayload(payload));
if (payload.restart_required_sections) {
setPendingRestartSections({
runtime: payload.restart_required_sections.includes("runtime"),
browser: payload.restart_required_sections.includes("browser"),
image: payload.restart_required_sections.includes("image"),
});
setPendingRestartSections(pendingRestartSectionsFromPayload(payload));
}
onSettingsChange?.(payload);
}, [onSettingsChange]);
useEffect(() => {
if (!initialSettings || settings !== null) return;
applyPayload(initialSettings);
setLoading(false);
}, [applyPayload, initialSettings, settings]);
useEffect(() => {
let cancelled = false;
setLoading(true);
const showLoading = settings === null;
if (showLoading) setLoading(true);
fetchSettings(token)
.then((payload) => {
if (!cancelled) {
@@ -484,7 +568,7 @@ export function SettingsView({
}
})
.catch((err) => {
if (!cancelled) setError((err as Error).message);
if (!cancelled && showLoading) setError((err as Error).message);
})
.finally(() => {
if (!cancelled) setLoading(false);
@@ -494,6 +578,34 @@ export function SettingsView({
};
}, [applyPayload, token]);
const hasSettings = settings !== null;
useEffect(() => {
if (activeSection !== "overview" || !hasSettings) return;
let cancelled = false;
const refresh = () => {
fetchSettingsUsage(token)
.then((usage) => {
if (cancelled) return;
setSettings((current) => (current ? { ...current, usage } : current));
})
.catch(() => {});
};
void refresh();
const interval = window.setInterval(refresh, 5000);
const onFocus = () => refresh();
const onVisibilityChange = () => {
if (document.visibilityState === "visible") refresh();
};
window.addEventListener("focus", onFocus);
document.addEventListener("visibilitychange", onVisibilityChange);
return () => {
cancelled = true;
window.clearInterval(interval);
window.removeEventListener("focus", onFocus);
document.removeEventListener("visibilitychange", onVisibilityChange);
};
}, [activeSection, hasSettings, token]);
useEffect(() => {
if (activeSection !== "apps") return;
let cancelled = false;
@@ -629,12 +741,15 @@ export function SettingsView({
const restartViaSettingsSurface = useCallback(async () => {
const isNativeHost = (settings?.surface ?? settings?.runtime_surface) === "native";
const hostApi = getHostApi();
if (isNativeHost && settings?.runtime_capabilities?.can_restart_engine && hostApi) {
if (
isNativeHost &&
settings?.runtime_capabilities?.can_restart_engine &&
onNativeEngineRestart
) {
setHostEngineApplying(true);
try {
await hostApi.restartEngine();
const payload = await fetchSettings(token);
const nextToken = await onNativeEngineRestart();
const payload = await fetchSettings(nextToken);
applyPayload(payload);
setPendingRestartSections(EMPTY_PENDING_RESTART_SECTIONS);
setError(null);
@@ -646,21 +761,25 @@ export function SettingsView({
return;
}
onRestart?.();
}, [applyPayload, onRestart, settings, token]);
}, [applyPayload, onNativeEngineRestart, onRestart, settings]);
const maybeRestartHostEngine = useCallback(
async (payload: RestartAwarePayload) => {
const surface = payload.surface ?? payload.runtime_surface ?? settings?.surface ?? settings?.runtime_surface;
const capabilities = payload.runtime_capabilities ?? settings?.runtime_capabilities;
const isNativeHost = surface === "native";
const hostApi = getHostApi();
if (!payload.requires_restart || !isNativeHost || !capabilities?.can_restart_engine || !hostApi) {
if (
!payload.requires_restart ||
!isNativeHost ||
!capabilities?.can_restart_engine ||
!onNativeEngineRestart
) {
return;
}
setHostEngineApplying(true);
try {
await hostApi.restartEngine();
const refreshed = await fetchSettings(token);
const nextToken = await onNativeEngineRestart();
const refreshed = await fetchSettings(nextToken);
applyPayload(refreshed);
setPendingRestartSections(EMPTY_PENDING_RESTART_SECTIONS);
setError(null);
@@ -670,7 +789,7 @@ export function SettingsView({
setHostEngineApplying(false);
}
},
[applyPayload, settings, token],
[applyPayload, onNativeEngineRestart, settings],
);
const saveModelSettings = async () => {
@@ -1135,8 +1254,6 @@ export function SettingsView({
<OverviewSettings
settings={settings}
requiresRestart={hasPendingRestart}
onRestart={restartViaSettingsSurface}
isRestarting={isRestarting || hostEngineApplying}
showBrandLogos={localPrefs.brandLogos}
onSelectSection={selectSection}
/>
@@ -1290,6 +1407,8 @@ export function SettingsView({
isRestarting={isRestarting || hostEngineApplying}
/>
);
case "skills":
return <SkillsCatalogSettings skills={skills} />;
case "runtime":
return (
<RuntimeSettings
@@ -1354,10 +1473,20 @@ export function SettingsView({
)}
>
<div className="mb-7">
<p className="mb-2 text-[13px] font-medium text-muted-foreground">
{!showSidebar ? (
<button
type="button"
onClick={onBackToChat}
className="mb-4 inline-flex items-center gap-1.5 rounded-full px-2.5 py-1.5 text-[12px] font-medium text-muted-foreground transition-colors hover:bg-muted/70 hover:text-foreground lg:hidden"
>
<ChevronLeft className="h-3.5 w-3.5" aria-hidden />
{t("settings.backToChat")}
</button>
) : null}
<p className="mb-2 text-[12px] font-normal text-muted-foreground">
{t("settings.sidebar.title")}
</p>
<h1 className="text-[28px] font-semibold leading-tight tracking-[-0.02em] text-foreground sm:text-[34px]">
<h1 className="text-[24px] font-normal leading-tight tracking-normal text-foreground sm:text-[28px]">
{text(`settings.nav.${activeSection}`, titleForSection(activeSection))}
</h1>
</div>
@@ -1437,7 +1566,7 @@ function SettingsSidebar({
{t("settings.backToChat")}
</button>
<div className="mb-3 px-1 md:mb-4 md:px-2">
<h2 className="text-[21px] font-semibold tracking-[-0.02em] text-foreground">
<h2 className="text-[18px] font-normal tracking-normal text-foreground">
{t("settings.sidebar.title")}
</h2>
</div>
@@ -1488,15 +1617,11 @@ function SettingsSidebar({
function OverviewSettings({
settings,
requiresRestart,
onRestart,
isRestarting,
onSelectSection,
showBrandLogos,
}: {
settings: SettingsPayload;
requiresRestart: boolean;
onRestart?: () => void;
isRestarting?: boolean;
onSelectSection: (section: SettingsSectionKey) => void;
showBrandLogos: boolean;
}) {
@@ -1504,6 +1629,16 @@ function OverviewSettings({
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const activePreset = settings.agent.model_preset || "default";
const activeProvider = settings.agent.resolved_provider ?? settings.agent.provider;
const activeProviderConfigured = settingsProviderConfigured(settings, activeProvider);
const activeProviderLabel = providerDisplayLabel(settings.providers, activeProvider);
const activeModelValue = activeProviderConfigured
? settings.agent.model
: tx("settings.values.notConfigured", "Not configured");
const activeModelCaption = activeProviderConfigured
? `${activeProvider} · ${activePreset}`
: activeProviderLabel || settings.agent.model
? [activeProviderLabel, settings.agent.model].filter(Boolean).join(" · ")
: tx("settings.byok.noConfiguredProviders", "No configured providers");
const webStatus = settings.web.enable
? tx("settings.values.enabled", "Enabled")
: tx("settings.values.disabled", "Disabled");
@@ -1515,48 +1650,23 @@ function OverviewSettings({
? tx("settings.values.configured", "Configured")
: tx("settings.values.notConfigured", "Not configured")
}`;
const isNativeHost = (settings.surface ?? settings.runtime_surface) === "native";
const workspaceCaption = shortWorkspacePath(settings.runtime.workspace_path);
const runtimeTitle = isNativeHost
? tx("settings.rows.engine", "Engine")
: tx("settings.rows.gateway", "Gateway");
const runtimeValue = isNativeHost
? tx("settings.values.privateEngine", "Private engine")
: `${settings.runtime.gateway_host}:${settings.runtime.gateway_port}`;
const runtimeCaption = isNativeHost
? tx("settings.values.unixSocket", "Unix socket")
: requiresRestart
? tx("settings.values.restartPending", "Restart pending")
: tx("settings.values.ready", "Ready");
return (
<div className="space-y-7">
<section>
<div className="overflow-hidden rounded-[22px] border border-border/45 bg-card/86 shadow-[0_18px_65px_rgba(15,23,42,0.075)] backdrop-blur-xl dark:border-white/10 dark:shadow-[0_18px_65px_rgba(0,0,0,0.24)]">
<div className="flex flex-col gap-4 px-5 py-5 sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-w-0 items-center gap-3">
<NanobotBrandLogo size="lg" testId="overview-nanobot-logo" />
<div className="min-w-0">
<div className="text-[12px] font-medium text-muted-foreground">nanobot</div>
<div className="mt-0.5 truncate text-[18px] font-semibold leading-6 text-foreground">
{settings.agent.model}
</div>
<div className="mt-0.5 truncate text-[13px] leading-5 text-muted-foreground">
{activeProvider} · {activePreset}
</div>
</div>
</div>
<div className="flex flex-wrap items-center gap-2 sm:justify-end">
<StatusPill tone={requiresRestart ? "neutral" : "success"}>
{requiresRestart
? tx("settings.values.restartPending", "Restart pending")
: tx("settings.values.ready", "Ready")}
</StatusPill>
{requiresRestart && onRestart ? (
<Button
size="sm"
variant="ghost"
onClick={onRestart}
disabled={isRestarting}
className="rounded-full"
>
{isRestarting ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
) : (
<RotateCcw className="mr-1.5 h-3.5 w-3.5" aria-hidden />
)}
{isRestarting ? t("app.system.restarting") : t("app.system.restart")}
</Button>
) : null}
</div>
</div>
</div>
<TokenUsageHeatmap usage={settings.usage} />
</section>
<section>
@@ -1566,8 +1676,8 @@ function OverviewSettings({
icon={Bot}
valueLogoProvider={activeProvider}
title={tx("settings.overview.model", "Current model")}
value={settings.agent.model}
caption={`${activeProvider} · ${activePreset}`}
value={activeModelValue}
caption={activeModelCaption}
showBrandLogos={showBrandLogos}
onClick={() => onSelectSection("models")}
/>
@@ -1603,20 +1713,16 @@ function OverviewSettings({
<SettingsGroup>
<OverviewListRow
icon={Server}
title={tx("settings.rows.gateway", "Gateway")}
value={`${settings.runtime.gateway_host}:${settings.runtime.gateway_port}`}
caption={
requiresRestart
? tx("settings.values.restartPending", "Restart pending")
: tx("settings.values.ready", "Ready")
}
title={runtimeTitle}
value={runtimeValue}
caption={runtimeCaption}
onClick={() => onSelectSection("runtime")}
/>
<OverviewListRow
icon={HardDrive}
title={tx("settings.overview.workspace", "Workspace")}
value={settings.runtime.workspace_path}
caption={settings.runtime.config_path}
value={tx("settings.values.defaultWorkspace", "Default workspace")}
caption={workspaceCaption}
onClick={() => onSelectSection("runtime")}
/>
</SettingsGroup>
@@ -1885,9 +1991,8 @@ function ModelsSettings({
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const configuredProviders = settings.providers.filter((provider) => provider.configured);
const oauthProviders = settings.providers.filter((provider) => provider.auth_type === "oauth");
const showAutoProvider = defaultPreset(settings)?.provider === "auto" || form.provider === "auto";
const selectableProviders = uniqueProviders([...configuredProviders, ...oauthProviders]);
const selectableProviders = uniqueProviders(configuredProviders);
const providerOptions = showAutoProvider
? [{ name: "auto", label: tx("settings.values.auto", "Auto") }, ...selectableProviders]
: selectableProviders;
@@ -1900,6 +2005,7 @@ function ModelsSettings({
const selectedProviderNeedsSignIn =
selectedProvider?.auth_type === "oauth" && !selectedProvider.configured;
const selectedProviderSigningIn = providerSaving === selectedProvider?.name;
const selectedProviderConfigured = settingsProviderConfigured(settings, form.provider);
const modelFieldsMissing =
!form.model.trim() ||
!form.provider.trim() ||
@@ -1918,6 +2024,7 @@ function ModelsSettings({
settings={settings}
draftModel={form.model}
draftProvider={form.provider}
providerConfigured={selectedProviderConfigured}
showProviderLogos={showBrandLogos}
onChange={(modelPreset) => {
const nextPreset = settings.model_presets.find((preset) => preset.name === modelPreset);
@@ -2871,9 +2978,11 @@ function AppsCatalogSettings({
const loading = (cliAppsLoading || mcpPresetsLoading) && !cliApps && !mcpPresets;
const statusMessage = cliError || mcpError || (!focusedApp ? cliMessage || mcpMessage : null);
const statusIsError = Boolean(cliError || mcpError);
const caption = tx("settings.apps.caption", "{{cli}} CLI · {{mcp}} MCP")
.replace("{{cli}}", String(cliApps?.installed_count ?? 0))
.replace("{{mcp}}", String(mcpPresets?.installed_count ?? 0));
const caption = t("settings.apps.caption", {
cli: cliApps?.installed_count ?? 0,
mcp: mcpPresets?.installed_count ?? 0,
defaultValue: "{{cli}} CLI · {{mcp}} MCP",
});
return (
<div className="space-y-7">
@@ -3255,7 +3364,10 @@ function McpAppsCatalogRow({
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-[12.5px] font-semibold text-foreground">
{tx("settings.mcp.connectTitle", "Connect {{name}}").replace("{{name}}", preset.display_name)}
{t("settings.mcp.connectTitle", {
name: preset.display_name,
defaultValue: "Connect {{name}}",
})}
</div>
<p className="mt-0.5 text-[11.5px] text-muted-foreground">
{tx("settings.mcp.connectHint", "Add the key from your account settings.")}
@@ -4060,10 +4172,12 @@ function RuntimeSettings({
<section>
<SettingsSectionTitle>{t("settings.sections.system")}</SettingsSectionTitle>
<SettingsGroup>
<ReadOnlyRow
title={tx("settings.rows.gateway", "Gateway")}
value={`${settings.runtime.gateway_host}:${settings.runtime.gateway_port}`}
/>
{!isNativeHost ? (
<ReadOnlyRow
title={tx("settings.rows.gateway", "Gateway")}
value={`${settings.runtime.gateway_host}:${settings.runtime.gateway_port}`}
/>
) : null}
<ReadOnlyRow title={t("settings.rows.configPath")} value={settings.runtime.config_path} />
<ReadOnlyRow title={tx("settings.rows.workspacePath", "Default workspace")} value={settings.runtime.workspace_path} />
{onRestart && !requiresRestartPending ? (
@@ -4369,7 +4483,14 @@ function ModelIdPicker({
const [error, setError] = useState<string | null>(null);
const effectiveProvider =
provider === "auto" ? settings.agent.resolved_provider ?? provider : provider;
const canFetchModels = Boolean(effectiveProvider && effectiveProvider !== "auto");
const hasConcreteProvider = Boolean(effectiveProvider && effectiveProvider !== "auto");
const providerRow = settingsProviderRow(settings, effectiveProvider);
const providerConfigured = settingsProviderConfigured(settings, effectiveProvider);
const providerRequiresConfiguration = hasConcreteProvider && !providerConfigured;
const providerUsesManualModelIds =
hasConcreteProvider && providerConfigured && providerRow?.auth_type === "oauth";
const canFetchModels =
hasConcreteProvider && providerConfigured && !providerUsesManualModelIds;
const normalizedQuery = query.trim().toLowerCase();
const providerModels = payload?.models ?? [];
const visibleModels = providerModels
@@ -4390,13 +4511,15 @@ function ModelIdPicker({
const hasModelList = payload?.status === "available";
const showModels = Boolean(hasModelList && payload && (!isCatalog || normalizedQuery));
const customCandidate = query.trim();
const allowCustomModel = !providerRequiresConfiguration;
const exactQueryMatch = providerModels.some((model) => model.id === customCandidate);
const providerModelCount = payload?.model_count ?? providerModels.length;
const modelUnconfigured = !value.trim() || !providerConfigured;
useEffect(() => {
if (!open) return;
setQuery("");
}, [open, effectiveProvider]);
setQuery(providerUsesManualModelIds || !hasConcreteProvider ? value : "");
}, [open, effectiveProvider, hasConcreteProvider, providerUsesManualModelIds, value]);
useEffect(() => {
if (!open || !shouldFetchModels) {
@@ -4443,7 +4566,11 @@ function ModelIdPicker({
)}
>
<span className="flex min-w-0 items-center gap-2">
<ProviderPickerIcon provider={effectiveProvider} showBrandLogos={showProviderLogos} />
<ProviderPickerIcon
provider={effectiveProvider}
showBrandLogos={showProviderLogos}
unconfigured={!providerConfigured}
/>
<span className="min-w-0 truncate font-medium text-foreground">
{model.label ?? model.id}
</span>
@@ -4467,7 +4594,11 @@ function ModelIdPicker({
)}
>
<span className="flex min-w-0 items-center gap-2">
<ProviderPickerIcon provider={effectiveProvider} showBrandLogos={showProviderLogos} />
<ProviderPickerIcon
provider={effectiveProvider}
showBrandLogos={showProviderLogos}
unconfigured={modelUnconfigured}
/>
<span
className={cn(
"min-w-0 truncate font-medium",
@@ -4500,7 +4631,15 @@ function ModelIdPicker({
</div>
</div>
{!canFetchModels ? (
{providerRequiresConfiguration ? (
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
{tx("settings.models.providerNotConfigured", "Configure this provider before loading models.")}
</div>
) : providerUsesManualModelIds ? (
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
{tx("settings.models.unsupportedModelList", "Type a model ID manually.")}
</div>
) : !canFetchModels ? (
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
{tx("settings.models.autoProviderCustomOnly", "Auto provider mode uses custom model IDs.")}
</div>
@@ -4544,7 +4683,7 @@ function ModelIdPicker({
</div>
) : null}
{customCandidate && !exactQueryMatch && customCandidate !== value ? (
{allowCustomModel && customCandidate && !exactQueryMatch && customCandidate !== value ? (
<>
{showModels ? <DropdownMenuSeparator /> : null}
<DropdownMenuItem
@@ -4581,17 +4720,31 @@ function formatContextWindow(tokens: number): string {
function ProviderPickerIcon({
provider,
showBrandLogos,
unconfigured = false,
}: {
provider: string;
showBrandLogos: boolean;
unconfigured?: boolean;
}) {
const [logoIndex, setLogoIndex] = useState(0);
const brand = providerBrand(provider);
const Icon = PROVIDER_ICONS[provider] ?? Sparkles;
const Icon = PROVIDER_ICONS[provider] ?? Hexagon;
const logoUrl = brand?.logoUrls[logoIndex];
useEffect(() => setLogoIndex(0), [provider]);
if (unconfigured) {
return (
<span
data-testid="provider-picker-unconfigured-icon"
className="grid h-5 w-5 shrink-0 place-items-center text-amber-700 dark:text-amber-200"
aria-hidden
>
<CircleAlert className="h-4 w-4" strokeWidth={1.8} />
</span>
);
}
if (showBrandLogos && logoUrl) {
return (
<span
@@ -4901,32 +5054,6 @@ function ProviderIcon({
);
}
function NanobotBrandLogo({
size = "sm",
testId,
}: {
size?: "sm" | "lg";
testId?: string;
}) {
return (
<span
data-testid={testId}
className={cn(
"grid shrink-0 place-items-center overflow-hidden border border-border/45 bg-background shadow-[inset_0_0_0_1px_rgba(0,0,0,0.025)]",
size === "lg" ? "h-12 w-12 rounded-[16px]" : "h-9 w-9 rounded-[12px]",
)}
aria-hidden
>
<img
src={NANOBOT_ICON_SRC}
alt=""
className={cn("select-none object-contain", size === "lg" ? "h-10 w-10" : "h-7 w-7")}
draggable={false}
/>
</span>
);
}
function OverviewRowIcon({
icon: Icon,
}: {
@@ -5090,6 +5217,7 @@ function ModelPresetPicker({
settings,
draftModel,
draftProvider,
providerConfigured,
showProviderLogos,
onChange,
onCreateConfiguration,
@@ -5099,6 +5227,7 @@ function ModelPresetPicker({
settings: SettingsPayload;
draftModel: string;
draftProvider: string;
providerConfigured: boolean;
showProviderLogos: boolean;
onChange: (preset: string) => void;
onCreateConfiguration: () => void;
@@ -5126,6 +5255,7 @@ function ModelPresetPicker({
settings={settings}
draftModel={draftModel}
draftProvider={draftProvider}
forceUnconfigured={selectedPreset?.is_default ? !providerConfigured : undefined}
showProviderLogos={showProviderLogos}
compact
/>
@@ -5190,6 +5320,7 @@ function ModelPresetOptionContent({
settings,
draftModel,
draftProvider,
forceUnconfigured,
showProviderLogos,
compact = false,
}: {
@@ -5197,27 +5328,50 @@ function ModelPresetOptionContent({
settings: SettingsPayload;
draftModel: string;
draftProvider: string;
forceUnconfigured?: boolean;
showProviderLogos: boolean;
compact?: boolean;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const provider = modelPresetProviderKey(preset, settings, {
draftProvider: preset.is_default ? draftProvider : undefined,
});
const model = preset.is_default ? draftModel : preset.model;
const providerName = providerDisplayLabel(settings.providers, provider);
const providerConfigured =
forceUnconfigured === undefined
? settingsProviderConfigured(settings, provider)
: !forceUnconfigured;
const title = providerConfigured ? model || preset.label : tx("settings.values.notConfigured", "Not configured");
const caption = providerConfigured
? `${providerName}${preset.label ? ` · ${preset.label}` : ""}`
: providerName || model || preset.label
? [providerName, model || preset.label].filter(Boolean).join(" · ")
: tx("settings.byok.noConfiguredProviders", "No configured providers");
return (
<span className="flex min-w-0 items-center gap-2.5">
<ProviderPickerIcon provider={provider} showBrandLogos={showProviderLogos} />
<ProviderPickerIcon
provider={provider}
showBrandLogos={showProviderLogos}
unconfigured={!providerConfigured}
/>
<span className="min-w-0 text-left leading-tight">
<span className="block truncate font-medium text-foreground">{model || preset.label}</span>
<span
className={cn(
"block truncate font-medium",
providerConfigured ? "text-foreground" : "text-amber-800 dark:text-amber-200",
)}
>
{title}
</span>
<span
className={cn(
"mt-0.5 block truncate text-muted-foreground",
compact ? "text-[11.5px]" : "text-[12px]",
)}
>
{providerName}
{preset.label ? ` · ${preset.label}` : ""}
{caption}
</span>
</span>
</span>
@@ -0,0 +1,417 @@
import { useEffect, useState, type ReactNode } from "react";
import type { TFunction } from "i18next";
import { Brain, Check, CircleAlert, KeyRound, Loader2, Terminal } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Sheet, SheetContent, SheetDescription, SheetTitle } from "@/components/ui/sheet";
import { fetchSkillDetail } from "@/lib/api";
import type { SkillDetail, SkillSummary } from "@/lib/types";
import { cn } from "@/lib/utils";
import { useClient } from "@/providers/ClientProvider";
export function SkillsCatalogSettings({ skills }: { skills: SkillSummary[] }) {
const { t } = useTranslation();
const availableCount = skills.filter((skill) => skill.available).length;
const [selectedSkill, setSelectedSkill] = useState<SkillSummary | null>(null);
return (
<div className="space-y-7">
<section className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
<p className="max-w-[680px] text-[13px] leading-5 text-muted-foreground">
{t("settings.skills.description", {
defaultValue: "Review the instruction skills this agent can load during a conversation.",
})}
</p>
<span className="text-[12px] font-medium text-muted-foreground">
{t("settings.skills.caption", {
available: availableCount,
total: skills.length,
defaultValue: "{{available}} available · {{total}} total",
})}
</span>
</section>
<section>
<div className="flex items-center justify-between border-b border-border/45 pb-3">
<h2 className="mb-2 px-1 text-[13px] font-semibold tracking-[-0.01em] text-foreground/85">
{t("settings.skills.featured", { defaultValue: "Agent skills" })}
</h2>
<span className="rounded-full bg-muted px-2.5 py-1 text-[12px] font-medium text-muted-foreground">
{skills.length}
</span>
</div>
{skills.length ? (
<div className="grid gap-x-10 gap-y-1 py-3 md:grid-cols-2">
{skills.map((skill) => (
<SkillCatalogRow
key={`${skill.source}:${skill.name}`}
skill={skill}
onSelect={setSelectedSkill}
/>
))}
</div>
) : (
<div className="px-3 py-12 text-center text-sm text-muted-foreground">
{t("settings.skills.empty", { defaultValue: "No skills are available." })}
</div>
)}
</section>
<SkillDetailSheet
skill={selectedSkill}
open={selectedSkill !== null}
onOpenChange={(open) => {
if (!open) setSelectedSkill(null);
}}
/>
</div>
);
}
function SkillCatalogRow({
skill,
onSelect,
}: {
skill: SkillSummary;
onSelect: (skill: SkillSummary) => void;
}) {
const { t } = useTranslation();
const sourceLabel = skillSourceLabel(skill.source, t);
const StatusIcon = skill.available ? Check : CircleAlert;
const statusLabel = skill.available
? t("settings.skills.statusAvailable", { defaultValue: "Available" })
: t("settings.skills.statusUnavailable", { defaultValue: "Unavailable" });
return (
<button
type="button"
aria-label={t("settings.skills.openDetails", {
name: skill.name,
defaultValue: "Open details for {{name}}",
})}
onClick={() => onSelect(skill)}
className={cn(
"group flex min-w-0 items-center gap-3 rounded-[16px] px-3 py-3 text-left transition-colors",
"hover:bg-muted/45 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
!skill.available && "opacity-65",
)}
>
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-[14px] bg-muted/70 text-muted-foreground">
<Brain className="h-5 w-5" strokeWidth={1.8} aria-hidden />
</div>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<h3 className="truncate text-[15px] font-semibold leading-5 text-foreground">
{skill.name}
</h3>
<span className="shrink-0 rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-semibold leading-none text-muted-foreground">
{sourceLabel}
</span>
</div>
<p className="mt-1 line-clamp-2 text-[13px] leading-5 text-muted-foreground">
{skill.description}
</p>
{!skill.available && skill.unavailable_reason ? (
<p className="mt-1 truncate text-[12px] leading-4 text-muted-foreground/80">
{t("settings.skills.unavailableReason", {
reason: skill.unavailable_reason,
defaultValue: "Missing: {{reason}}",
})}
</p>
) : null}
</div>
<span
title={!skill.available && skill.unavailable_reason ? skill.unavailable_reason : undefined}
className={cn(
"hidden shrink-0 items-center gap-1 rounded-full px-2.5 py-1 text-[12px] font-medium sm:inline-flex",
skill.available
? "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"
: "bg-muted text-muted-foreground",
)}
>
<StatusIcon className="h-3.5 w-3.5" aria-hidden />
{statusLabel}
</span>
</button>
);
}
function SkillDetailSheet({
skill,
open,
onOpenChange,
}: {
skill: SkillSummary | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const { token } = useClient();
const { t } = useTranslation();
const [detail, setDetail] = useState<SkillDetail | null>(null);
const [loading, setLoading] = useState(false);
const [loadFailed, setLoadFailed] = useState(false);
useEffect(() => {
if (!open || !skill) return;
let cancelled = false;
setDetail(null);
setLoading(true);
setLoadFailed(false);
fetchSkillDetail(token, skill.name)
.then((payload) => {
if (!cancelled) setDetail(payload);
})
.catch(() => {
if (!cancelled) setLoadFailed(true);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [open, skill, token]);
if (!skill) return null;
const activeSkill = detail ?? skill;
const sourceLabel = skillSourceLabel(activeSkill.source, t);
const statusLabel = activeSkill.available
? t("settings.skills.statusAvailable", { defaultValue: "Available" })
: t("settings.skills.statusUnavailable", { defaultValue: "Unavailable" });
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
className="w-[min(34rem,calc(100vw-1rem))] max-w-none gap-0 overflow-hidden p-0 sm:max-w-none"
>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-5">
<div className="flex items-start gap-3 pr-8">
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-[15px] bg-muted/70 text-muted-foreground">
<Brain className="h-5 w-5" strokeWidth={1.8} aria-hidden />
</div>
<div className="min-w-0">
<SheetTitle className="truncate text-[20px] font-semibold">
{activeSkill.name}
</SheetTitle>
<SheetDescription className="sr-only">
{t("settings.skills.detailDescription", {
name: activeSkill.name,
defaultValue: "Details for {{name}}.",
})}
</SheetDescription>
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-[12px] text-muted-foreground">
<Pill>{sourceLabel}</Pill>
<Pill tone={activeSkill.available ? "success" : "muted"}>{statusLabel}</Pill>
</div>
</div>
</div>
{loading ? (
<div className="mt-8 flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
{t("settings.skills.loadingDetail", { defaultValue: "Loading skill details..." })}
</div>
) : loadFailed ? (
<div className="mt-8 rounded-[16px] bg-destructive/10 px-3 py-3 text-sm text-destructive">
{t("settings.skills.loadFailed", { defaultValue: "Could not load skill details." })}
</div>
) : (
<div className="mt-7 space-y-6">
<DetailSection title={t("settings.skills.descriptionTitle", { defaultValue: "Description" })}>
<p className="text-[14px] leading-6 text-muted-foreground">{activeSkill.description}</p>
</DetailSection>
<div className="grid grid-cols-2 gap-2">
<MetaItem
label={t("settings.skills.source", { defaultValue: "Source" })}
value={sourceLabel}
/>
<MetaItem
label={t("settings.skills.status", { defaultValue: "Status" })}
value={statusLabel}
/>
</div>
{!activeSkill.available && activeSkill.unavailable_reason ? (
<DetailSection
title={t("settings.skills.unavailableReasonLabel", {
defaultValue: "Unavailable reason",
})}
>
<p className="text-[13px] leading-5 text-destructive/85">
{activeSkill.unavailable_reason}
</p>
</DetailSection>
) : null}
{detail ? <RequirementsSection detail={detail} /> : null}
{detail ? <RawInstructionsBlock markdown={detail.raw_markdown} /> : null}
</div>
)}
</div>
</SheetContent>
</Sheet>
);
}
function RawInstructionsBlock({ markdown }: { markdown: string }) {
const { t } = useTranslation();
const content =
markdown ||
t("settings.skills.rawInstructionsEmpty", {
defaultValue: "No raw instructions.",
});
return (
<details className="group rounded-[18px] border border-border/45 bg-muted/20 px-3 py-3">
<summary className="cursor-pointer select-none text-[13px] font-medium text-foreground/90 transition-colors hover:text-foreground">
{t("settings.skills.rawInstructions", { defaultValue: "Raw SKILL.md" })}
</summary>
<div className="mt-3 overflow-hidden rounded-[14px] border border-border/35 bg-background/70">
<pre
className={cn(
"max-h-[min(42vh,32rem)] overflow-auto overscroll-contain px-3.5 py-3 pr-4",
"whitespace-pre-wrap break-words font-mono text-[12px] leading-[1.7] text-foreground/62",
"scrollbar-thin scrollbar-track-transparent",
"[&::-webkit-scrollbar]:h-1.5 [&::-webkit-scrollbar]:w-1.5",
"[&::-webkit-scrollbar-thumb]:bg-muted-foreground/25",
)}
>
{content}
</pre>
</div>
</details>
);
}
function MetaItem({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-[16px] bg-muted/35 px-3 py-2.5">
<div className="text-[11px] text-muted-foreground">{label}</div>
<div className="mt-0.5 truncate text-[13px] font-medium text-foreground">{value}</div>
</div>
);
}
function RequirementsSection({ detail }: { detail: SkillDetail }) {
const { t } = useTranslation();
const { bins, env, missing_bins, missing_env } = detail.requirements;
const hasRequirements = bins.length > 0 || env.length > 0;
return (
<DetailSection title={t("settings.skills.requirements", { defaultValue: "Requirements" })}>
{hasRequirements ? (
<div className="space-y-3">
{missing_bins.length ? (
<RequirementLine
title={t("settings.skills.missingCommands", { defaultValue: "Missing CLI" })}
items={missing_bins}
tone="danger"
icon={<Terminal className="h-3.5 w-3.5" aria-hidden />}
/>
) : null}
{missing_env.length ? (
<RequirementLine
title={t("settings.skills.missingEnvironment", { defaultValue: "Missing ENV" })}
items={missing_env}
tone="danger"
icon={<KeyRound className="h-3.5 w-3.5" aria-hidden />}
/>
) : null}
{bins.length ? (
<RequirementLine
title={t("settings.skills.commands", { defaultValue: "Commands" })}
items={bins}
icon={<Terminal className="h-3.5 w-3.5" aria-hidden />}
/>
) : null}
{env.length ? (
<RequirementLine
title={t("settings.skills.environment", { defaultValue: "Environment variables" })}
items={env}
icon={<KeyRound className="h-3.5 w-3.5" aria-hidden />}
/>
) : null}
</div>
) : (
<p className="text-[13px] text-muted-foreground">
{t("settings.skills.noRequirements", { defaultValue: "No explicit requirements." })}
</p>
)}
</DetailSection>
);
}
function DetailSection({ title, children }: { title: string; children: ReactNode }) {
return (
<section>
<h3 className="mb-2 text-[12px] font-medium text-muted-foreground">{title}</h3>
{children}
</section>
);
}
function RequirementLine({
title,
items,
icon,
tone = "muted",
}: {
title: string;
items: string[];
icon: ReactNode;
tone?: "muted" | "danger";
}) {
return (
<div className="space-y-1.5">
<div
className={cn(
"flex items-center gap-1.5 text-[12px]",
tone === "danger" ? "text-destructive" : "text-muted-foreground",
)}
>
{icon}
{title}
</div>
<div className="flex flex-wrap gap-1.5">
{items.map((item) => (
<Pill key={item}>{item}</Pill>
))}
</div>
</div>
);
}
function Pill({
children,
tone = "muted",
}: {
children: ReactNode;
tone?: "muted" | "success";
}) {
return (
<span
className={cn(
"inline-flex max-w-full items-center rounded-full px-2 py-0.5 text-[11px] font-medium",
tone === "success"
? "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"
: "bg-muted text-muted-foreground",
)}
>
{children}
</span>
);
}
function skillSourceLabel(source: string, t: TFunction): string {
if (source === "workspace") {
return t("settings.skills.sourceWorkspace", { defaultValue: "Custom" });
}
if (source === "builtin") {
return t("settings.skills.sourceBuiltin", { defaultValue: "Built-in" });
}
return source;
}
@@ -0,0 +1,224 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import type { SettingsPayload } from "@/lib/types";
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]";
}
export 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>
);
}
@@ -173,6 +173,7 @@ interface AgentActivityClusterProps {
turnLatencyMs?: number;
cliApps?: CliAppInfo[];
mcpPresets?: McpPresetInfo[];
onOpenFilePreview?: (path: string) => void;
}
/**
@@ -186,6 +187,7 @@ export function AgentActivityCluster({
turnLatencyMs,
cliApps = [],
mcpPresets = [],
onOpenFilePreview,
}: AgentActivityClusterProps) {
const { t } = useTranslation();
const fileEdits = useMemo(
@@ -423,6 +425,7 @@ export function AgentActivityCluster({
added={added}
deleted={deleted}
hasDiffStats={hasDiffStats}
onOpenFilePreview={onOpenFilePreview}
/>
);
}
@@ -449,6 +452,8 @@ export function AgentActivityCluster({
<FileReferenceChip
path={singleFilePath}
tooltipPath={singleFileTooltipPath}
previewPath={singleFileTooltipPath || singleFilePath}
onOpen={onOpenFilePreview}
active={hasLiveEditingFiles}
className="-my-0.5 min-w-0"
textClassName="text-xs"
@@ -494,6 +499,7 @@ export function AgentActivityCluster({
key={m.id}
text={m.reasoning ?? ""}
streaming={isTurnStreaming && !!m.reasoningStreaming}
onOpenFilePreview={onOpenFilePreview}
/>
);
}
@@ -510,7 +516,12 @@ export function AgentActivityCluster({
}
return null;
})}
{fileEdits.length ? <FileEditGroup edits={fileEdits} /> : null}
{fileEdits.length ? (
<FileEditGroup
edits={fileEdits}
onOpenFilePreview={onOpenFilePreview}
/>
) : null}
</div>
</div>
</div>
@@ -537,6 +548,7 @@ function FileEditFlatActivity({
added,
deleted,
hasDiffStats,
onOpenFilePreview,
}: {
edits: FileEditSummary[];
active: boolean;
@@ -550,6 +562,7 @@ function FileEditFlatActivity({
added: number;
deleted: number;
hasDiffStats: boolean;
onOpenFilePreview?: (path: string) => void;
}) {
const showRows = edits.length > 1 || edits.some((edit) => edit.status === "error" || edit.pending);
return (
@@ -569,6 +582,8 @@ function FileEditFlatActivity({
<FileReferenceChip
path={singleFilePath}
tooltipPath={singleFileTooltipPath}
previewPath={singleFileTooltipPath || singleFilePath}
onOpen={onOpenFilePreview}
active={hasLiveEditingFiles}
className="-my-0.5 min-w-0"
textClassName="text-xs"
@@ -583,7 +598,7 @@ function FileEditFlatActivity({
</div>
{showRows ? (
<div className="mt-0.5 pl-4">
<FileEditGroup edits={edits} />
<FileEditGroup edits={edits} onOpenFilePreview={onOpenFilePreview} />
</div>
) : null}
</div>
@@ -0,0 +1,149 @@
import { useMemo, useState } from "react";
import { ListTree, Search } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import {
Sheet,
SheetContent,
SheetTitle,
} from "@/components/ui/sheet";
import {
type PromptAnchor,
userPromptAnchors,
} from "@/components/thread/promptNavigation";
import { fmtDateTime } from "@/lib/format";
import type { UIMessage } from "@/lib/types";
import { cn } from "@/lib/utils";
interface PromptNavigatorProps {
messages: UIMessage[];
onJumpToPrompt: (promptId: string) => void;
}
export function PromptNavigator({
messages,
onJumpToPrompt,
}: PromptNavigatorProps) {
const { i18n, t } = useTranslation();
const prompts = useMemo(() => userPromptAnchors(messages), [messages]);
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const filteredPrompts = useMemo(() => {
const needle = query.trim().toLocaleLowerCase();
if (!needle) return prompts;
return prompts.filter((prompt) =>
`${prompt.label}\n${prompt.preview}`.toLocaleLowerCase().includes(needle),
);
}, [prompts, query]);
if (prompts.length === 0) return null;
const jump = (promptId: string) => {
setOpen(false);
onJumpToPrompt(promptId);
};
return (
<>
<Button
type="button"
variant="ghost"
size="icon"
className={cn(
"host-no-drag h-8 w-8 rounded-full text-muted-foreground/80",
"hover:bg-accent/40 hover:text-foreground",
)}
aria-label={t("thread.promptNavigator.open")}
onClick={() => setOpen(true)}
>
<ListTree className="h-4 w-4" />
</Button>
<Sheet open={open} onOpenChange={setOpen}>
<SheetContent
side="right"
aria-describedby={undefined}
className="w-[min(92vw,24rem)] gap-0 p-0 sm:max-w-[24rem]"
>
<div className="border-b px-5 pb-4 pt-5">
<SheetTitle className="text-base font-medium">
{t("thread.promptNavigator.title")}
</SheetTitle>
<div className="relative mt-4">
<Search
aria-hidden
className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
/>
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
aria-label={t("thread.promptNavigator.search")}
placeholder={t("thread.promptNavigator.search")}
className={cn(
"h-10 w-full rounded-full border border-border bg-background pl-9 pr-3 text-sm",
"outline-none transition focus:border-ring focus:ring-2 focus:ring-ring/20",
)}
/>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-2 py-2">
{filteredPrompts.length > 0 ? (
<div className="space-y-1">
{filteredPrompts.map((prompt) => (
<PromptNavigatorRow
key={prompt.id}
locale={i18n.resolvedLanguage || i18n.language}
prompt={prompt}
onJump={jump}
/>
))}
</div>
) : (
<div className="px-3 py-10 text-center text-sm text-muted-foreground">
{t("thread.promptNavigator.noResults")}
</div>
)}
</div>
</SheetContent>
</Sheet>
</>
);
}
interface PromptNavigatorRowProps {
locale: string;
onJump: (promptId: string) => void;
prompt: PromptAnchor;
}
function PromptNavigatorRow({
locale,
onJump,
prompt,
}: PromptNavigatorRowProps) {
const { t } = useTranslation();
const timestamp = fmtDateTime(prompt.createdAt, locale);
return (
<button
type="button"
className={cn(
"w-full rounded-xl px-3 py-3 text-left transition",
"hover:bg-accent focus-visible:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/30",
)}
aria-label={t("thread.promptNavigator.jumpTo", { label: prompt.label })}
onClick={() => onJump(prompt.id)}
>
<div className="max-h-20 overflow-hidden whitespace-pre-wrap break-words text-sm leading-5 text-foreground">
{prompt.preview}
</div>
{timestamp ? (
<div className="mt-1 text-[10px] leading-4 text-muted-foreground/75">
{timestamp}
</div>
) : null}
</button>
);
}
+95 -62
View File
@@ -9,6 +9,13 @@ import {
import { cn } from "@/lib/utils";
import type { UIMessage } from "@/lib/types";
import {
findPromptElement,
jumpToPrompt,
type PromptAnchor,
promptTop,
userPromptAnchors,
} from "@/components/thread/promptNavigation";
interface PromptRailProps {
bottomOffset: number;
@@ -16,11 +23,6 @@ interface PromptRailProps {
scrollRef: RefObject<HTMLDivElement>;
}
interface PromptAnchor {
id: string;
label: string;
}
interface MeasuredPrompt extends PromptAnchor {
top: number;
topPercent: number;
@@ -30,18 +32,21 @@ interface PromptMarker {
count: number;
ids: string[];
label: string;
preview: string;
topPercent: number;
}
const MIN_PROMPTS_FOR_RAIL = 3;
const RAIL_MIN_SCROLL_RANGE_PX = 240;
const RAIL_MIN_SCROLL_RANGE_PX = 80;
const DENSE_PROMPT_THRESHOLD = 30;
const DENSE_BUCKET_HEIGHT_PX = 12;
const DENSE_BUCKET_FALLBACK_COUNT = 32;
const DENSE_BUCKET_MAX_COUNT = 42;
const MARKER_MIN_GAP_PX = 9;
const MARKER_BASE_WIDTH_PX = 26;
const MARKER_MAX_WIDTH_PX = 42;
const MARKER_BASE_WIDTH_PX = 16;
const MARKER_MAX_WIDTH_PX = 28;
const MEASURE_RETRY_FRAMES = 4;
const RAIL_REVEAL_MS = 1400;
export function PromptRail({
bottomOffset,
@@ -52,6 +57,19 @@ export function PromptRail({
const promptAnchors = useMemo(() => userPromptAnchors(messages), [messages]);
const [markers, setMarkers] = useState<PromptMarker[]>([]);
const [activePromptId, setActivePromptId] = useState<string | null>(null);
const [revealed, setRevealed] = useState(false);
const revealTimeoutRef = useRef<number | null>(null);
const revealTemporarily = useCallback(() => {
setRevealed(true);
if (revealTimeoutRef.current !== null) {
window.clearTimeout(revealTimeoutRef.current);
}
revealTimeoutRef.current = window.setTimeout(() => {
setRevealed(false);
revealTimeoutRef.current = null;
}, RAIL_REVEAL_MS);
}, []);
const updateMarkers = useCallback(() => {
const scrollEl = scrollRef.current;
@@ -74,8 +92,18 @@ export function PromptRail({
}, [promptAnchors, scrollRef]);
useEffect(() => {
updateMarkers();
}, [updateMarkers]);
let frame = 0;
let remainingFrames = MEASURE_RETRY_FRAMES;
const measure = () => {
updateMarkers();
remainingFrames -= 1;
if (remainingFrames > 0) {
frame = window.requestAnimationFrame(measure);
}
};
measure();
return () => window.cancelAnimationFrame(frame);
}, [bottomOffset, updateMarkers]);
useEffect(() => {
const scrollEl = scrollRef.current;
@@ -84,6 +112,7 @@ export function PromptRail({
let frame = 0;
const schedule = () => {
window.cancelAnimationFrame(frame);
revealTemporarily();
frame = window.requestAnimationFrame(updateMarkers);
};
@@ -94,7 +123,7 @@ export function PromptRail({
scrollEl.removeEventListener("scroll", schedule);
window.removeEventListener("resize", schedule);
};
}, [scrollRef, updateMarkers]);
}, [revealTemporarily, scrollRef, updateMarkers]);
useEffect(() => {
const scrollEl = scrollRef.current;
@@ -105,63 +134,85 @@ export function PromptRail({
return () => observer.disconnect();
}, [scrollRef, updateMarkers]);
useEffect(() => {
return () => {
if (revealTimeoutRef.current !== null) {
window.clearTimeout(revealTimeoutRef.current);
}
};
}, []);
if (markers.length === 0) return null;
const maxMarkerCount = Math.max(...markers.map((marker) => marker.count));
const activeMarkerIndex = markers.findIndex((marker) =>
marker.ids.includes(activePromptId ?? ""),
);
return (
<div
ref={railRef}
aria-label="User prompt navigation"
className={cn(
"pointer-events-none absolute right-6 top-12 z-20 hidden w-12 md:block",
"group pointer-events-auto absolute right-4 top-14 z-20 hidden w-8 opacity-70 md:block",
"transition-opacity duration-200 hover:opacity-100",
"motion-safe:animate-in motion-safe:fade-in-0 motion-safe:duration-200",
)}
style={{ bottom: Math.max(80, bottomOffset) }}
>
{markers.map((marker) => {
{markers.map((marker, index) => {
const active = marker.ids.includes(activePromptId ?? "");
const nearActive = activeMarkerIndex < 0 || Math.abs(index - activeMarkerIndex) <= 1;
return (
<button
key={marker.ids.join("|")}
type="button"
title={marker.label}
aria-label={`Jump to prompt: ${marker.label}`}
onClick={() => jumpToPrompt(scrollRef.current, marker.ids[marker.ids.length - 1])}
className={cn(
"pointer-events-auto absolute right-0 h-1.5 -translate-y-1/2 rounded-full",
"bg-muted-foreground/30 transition-all duration-150",
"hover:bg-blue-500/80 focus-visible:bg-blue-500",
"group/marker absolute right-0 h-5 -translate-y-1/2 overflow-visible rounded-full",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-400/60",
marker.count > 1 && "bg-muted-foreground/45",
active && "bg-foreground shadow-sm",
)}
style={{
top: `${marker.topPercent}%`,
width: markerWidth(marker.count, maxMarkerCount, active),
}}
/>
>
<span
aria-hidden
className={cn(
"absolute right-0 top-1/2 h-[3px] w-full -translate-y-1/2 rounded-full",
"bg-foreground/20 transition-[background-color,opacity,transform,height] duration-200",
"group-hover/marker:bg-blue-500/70 group-hover/marker:opacity-100 group-hover/marker:scale-x-110",
"group-focus-visible/marker:bg-blue-500 group-focus-visible/marker:opacity-100 group-focus-visible/marker:scale-x-110",
marker.count > 1 && "bg-foreground/30",
active && "h-1 bg-foreground/65 opacity-80 shadow-sm",
!active && nearActive && "opacity-25 group-hover:opacity-55",
!active && !nearActive && !revealed && "opacity-0 group-hover:opacity-40",
!active && !nearActive && revealed && "opacity-35",
)}
/>
<span
aria-hidden
className={cn(
"pointer-events-none absolute right-9 top-1/2 z-30 w-64 -translate-y-1/2 rounded-lg px-3 py-2 text-left",
"bg-background/95 text-xs leading-5 text-foreground shadow-lg ring-1 ring-border/80 backdrop-blur",
"opacity-0 translate-x-1 transition-[opacity,transform] duration-150",
"group-hover/marker:opacity-100 group-hover/marker:translate-x-0",
"group-focus-visible/marker:opacity-100 group-focus-visible/marker:translate-x-0",
)}
>
<span className="block max-h-24 overflow-hidden whitespace-pre-wrap break-words">
{marker.preview}
</span>
</span>
</button>
);
})}
</div>
);
}
function userPromptAnchors(messages: UIMessage[]): PromptAnchor[] {
return messages
.filter((message) => message.role === "user")
.map((message, index) => ({
id: message.id,
label: promptLabel(message.content, index),
}));
}
function promptLabel(content: string, index: number): string {
const text = content.replace(/\s+/g, " ").trim();
if (!text) return `Prompt ${index + 1}`;
return text.length > 80 ? `${text.slice(0, 77)}...` : text;
}
function measurePrompts(
scrollEl: HTMLElement,
anchors: PromptAnchor[],
@@ -199,12 +250,14 @@ function groupPromptMarkers(
last.count += 1;
last.ids.push(prompt.id);
last.label = groupedPromptLabel(last.count, prompt.label);
last.preview = groupedPromptPreview(last.count, prompt.preview);
continue;
}
groups.push({
count: 1,
ids: [prompt.id],
label: prompt.label,
preview: prompt.preview,
topPercent: prompt.topPercent,
});
}
@@ -245,6 +298,9 @@ function bucketPromptMarkers(
label: bucket.length === 1
? latest.label
: groupedPromptLabel(bucket.length, latest.label),
preview: bucket.length === 1
? latest.preview
: groupedPromptPreview(bucket.length, latest.preview),
topPercent,
}];
});
@@ -271,6 +327,10 @@ function groupedPromptLabel(count: number, latestLabel: string): string {
return `${count} prompts, latest: ${latestLabel}`;
}
function groupedPromptPreview(count: number, latestPreview: string): string {
return `${count} prompts\n\n${latestPreview}`;
}
function markerWidth(count: number, maxCount: number, active: boolean): number {
if (maxCount <= 1) return active ? 34 : MARKER_BASE_WIDTH_PX;
const density = Math.log2(count + 1) / Math.log2(maxCount + 1);
@@ -279,33 +339,6 @@ function markerWidth(count: number, maxCount: number, active: boolean): number {
return Math.round(active ? width + 4 : width);
}
function jumpToPrompt(scrollEl: HTMLElement | null, promptId: string | undefined): void {
if (!scrollEl || !promptId) return;
const target = findPromptElement(scrollEl, promptId);
if (!target) return;
scrollEl.scrollTo({
top: Math.max(0, promptTop(scrollEl, target) - 16),
behavior: "smooth",
});
}
function findPromptElement(scrollEl: HTMLElement, promptId: string): HTMLElement | null {
const candidates = scrollEl.querySelectorAll<HTMLElement>("[data-user-prompt-id]");
return Array.from(candidates).find(
(candidate) => candidate.dataset.userPromptId === promptId,
) ?? null;
}
function promptTop(scrollEl: HTMLElement, target: HTMLElement): number {
const scrollRect = scrollEl.getBoundingClientRect();
const targetRect = target.getBoundingClientRect();
const hasLayoutRect = scrollRect.top !== 0 || targetRect.top !== 0;
if (hasLayoutRect) {
return targetRect.top - scrollRect.top + scrollEl.scrollTop;
}
return target.offsetTop;
}
function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}
@@ -0,0 +1,224 @@
import { useState } from "react";
import {
CalendarClock,
CircleAlert,
ListTodo,
RefreshCcw,
} from "lucide-react";
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useSessionAutomationJobs } from "@/hooks/useSessionAutomationJobs";
import { currentLocale } from "@/i18n";
import { fmtDateTime } from "@/lib/format";
import type { SessionAutomationJob } from "@/lib/types";
import { cn } from "@/lib/utils";
const RELATIVE_THRESHOLDS: [number, Intl.RelativeTimeFormatUnit][] = [
[60, "second"],
[60, "minute"],
[24, "hour"],
[7, "day"],
[4.345, "week"],
[12, "month"],
[Number.POSITIVE_INFINITY, "year"],
];
interface SessionInfoPopoverProps {
sessionKey: string;
token: string;
title: string;
}
export function SessionInfoPopover({ sessionKey, token, title }: SessionInfoPopoverProps) {
const { t } = useTranslation("common");
const [open, setOpen] = useState(false);
const { jobs, loading, loadFailed, now } = useSessionAutomationJobs(open, token, sessionKey);
const automationContent = loading ? (
<div className="flex items-center gap-2 rounded-[16px] bg-muted/45 px-3 py-3 text-[12.5px] text-muted-foreground">
<RefreshCcw className="h-3.5 w-3.5 animate-spin" />
{t("thread.sessionInfo.loading")}
</div>
) : loadFailed ? (
<div className="flex items-center gap-2 rounded-[16px] bg-destructive/10 px-3 py-3 text-[12.5px] text-destructive">
<CircleAlert className="h-3.5 w-3.5" />
{t("thread.sessionInfo.loadFailed")}
</div>
) : jobs.length ? (
<div className="space-y-1.5">
{jobs.map((job) => (
<AutomationRow key={job.id} job={job} now={now} />
))}
</div>
) : (
<div className="rounded-[16px] bg-muted/35 px-3 py-3 text-[12.5px] leading-relaxed text-muted-foreground">
{t("thread.sessionInfo.empty")}
</div>
);
return (
<DropdownMenu modal={false} open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
aria-label={t("thread.header.sessionInfo")}
className={cn(
"host-no-drag h-8 w-8 rounded-full text-muted-foreground/85",
"hover:bg-accent/40 hover:text-foreground",
)}
>
<ListTodo className="h-4 w-4 stroke-[1.75]" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
sideOffset={8}
className="w-[min(23rem,calc(100vw-1.5rem))] rounded-[24px] p-0"
>
<div className="space-y-3 px-4 py-3.5">
<div className="min-w-0">
<div className="text-[12px] font-normal text-muted-foreground/75">
{t("thread.sessionInfo.title")}
</div>
<div className="mt-0.5 truncate text-[14px] font-medium text-foreground">
{title || t("thread.sessionInfo.untitled")}
</div>
</div>
<div className="h-px bg-border/45" />
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2">
<CalendarClock className="h-3.5 w-3.5 shrink-0 text-muted-foreground/80" />
<span className="truncate text-[13px] font-medium text-foreground">
{t("thread.sessionInfo.automations")}
</span>
</div>
<span className="rounded-full bg-muted/70 px-2 py-0.5 text-[11px] text-muted-foreground">
{t("thread.sessionInfo.count", { count: jobs.length })}
</span>
</div>
{automationContent}
</div>
</DropdownMenuContent>
</DropdownMenu>
);
}
function AutomationRow({ job, now }: { job: SessionAutomationJob; now: number }) {
const { t } = useTranslation("common");
const schedule = formatSchedule(job, t);
const nextRun = formatNextRun(job, t, now);
const statusClass = job.enabled
? job.state.last_status === "error"
? "bg-destructive"
: "bg-emerald-500"
: "bg-muted-foreground/35";
return (
<div className="rounded-[16px] px-3 py-2.5 transition-colors hover:bg-muted/40">
<div className="flex items-start gap-2.5">
<span className={cn("mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full", statusClass)} />
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate text-[13px] font-medium text-foreground">{job.name}</span>
{!job.enabled ? (
<span className="shrink-0 rounded-full bg-muted px-1.5 py-0.5 text-[10.5px] text-muted-foreground">
{t("thread.sessionInfo.disabled")}
</span>
) : null}
</div>
<div className="mt-1 line-clamp-2 text-[12px] leading-snug text-muted-foreground">
{job.payload.message}
</div>
<div className="mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 text-[11.5px] text-muted-foreground/80">
<span>{schedule}</span>
<span aria-hidden>·</span>
<span title={nextRun.title}>{nextRun.label}</span>
</div>
</div>
</div>
</div>
);
}
function formatSchedule(job: SessionAutomationJob, t: TFunction) {
const locale = currentLocale();
if (job.schedule.kind === "at" && job.schedule.at_ms) {
return t("thread.sessionInfo.schedule.at", { time: fmtDateTime(job.schedule.at_ms, locale) });
}
if (job.schedule.kind === "every" && job.schedule.every_ms) {
return t("thread.sessionInfo.schedule.every", {
duration: formatDuration(job.schedule.every_ms, locale),
});
}
if (job.schedule.kind === "cron" && job.schedule.expr) {
return job.schedule.tz
? t("thread.sessionInfo.schedule.cronWithTz", {
expr: job.schedule.expr,
tz: job.schedule.tz,
})
: t("thread.sessionInfo.schedule.cron", { expr: job.schedule.expr });
}
return t("thread.sessionInfo.schedule.unknown");
}
function formatNextRun(job: SessionAutomationJob, t: TFunction, now: number) {
const locale = currentLocale();
if (!job.enabled) {
return { label: t("thread.sessionInfo.next.disabled"), title: "" };
}
const next = job.state.next_run_at_ms;
if (!next) {
return { label: t("thread.sessionInfo.next.none"), title: "" };
}
return {
label: t("thread.sessionInfo.next.label", { time: relativeTimeFrom(next, now, locale) }),
title: fmtDateTime(next, locale),
};
}
function relativeTimeFrom(value: number, now: number, locale: string): string {
let delta = (value - now) / 1000;
const formatter = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
for (const [step, unit] of RELATIVE_THRESHOLDS) {
if (Math.abs(delta) < step) {
return formatter.format(Math.round(delta), unit);
}
delta /= step;
}
return formatter.format(Math.round(delta), "year");
}
function formatDuration(ms: number, locale: string): string {
const units: Array<[Intl.NumberFormatOptions["unit"], number]> = [
["day", 86_400_000],
["hour", 3_600_000],
["minute", 60_000],
["second", 1000],
];
for (const [unit, size] of units) {
if (ms >= size && ms % size === 0) {
return new Intl.NumberFormat(locale, {
style: "unit",
unit,
unitDisplay: "long",
maximumFractionDigits: 0,
}).format(ms / size);
}
}
return new Intl.NumberFormat(locale, {
style: "unit",
unit: "minute",
unitDisplay: "long",
maximumFractionDigits: 1,
}).format(ms / 60_000);
}
+54 -17
View File
@@ -94,6 +94,8 @@ interface ThreadComposerProps {
modelLabel?: string | null;
modelProvider?: string | null;
modelProviderLabel?: string | null;
modelNeedsSetup?: boolean;
onModelBadgeClick?: () => void;
variant?: "thread" | "hero";
slashCommands?: SlashCommand[];
cliApps?: CliAppInfo[];
@@ -647,6 +649,8 @@ export function ThreadComposer({
modelLabel = null,
modelProvider = null,
modelProviderLabel = null,
modelNeedsSetup = false,
onModelBadgeClick,
variant = "thread",
slashCommands = [],
cliApps = [],
@@ -759,17 +763,21 @@ export function ThreadComposer({
);
const hasErrors = images.some((img) => img.status === "error");
const hasComposerContent = value.trim().length > 0 || readyImages.length > 0;
const canSend =
!disabled
&& !modelNeedsSetup
&& !encoding
&& !hasErrors
&& (value.trim().length > 0 || readyImages.length > 0);
&& hasComposerContent;
const canOpenModelSettings = Boolean(modelNeedsSetup && onModelBadgeClick && !disabled);
const canQueueGuidance =
isStreaming
&& !disabled
&& !modelNeedsSetup
&& !encoding
&& !hasErrors
&& (value.trim().length > 0 || readyImages.length > 0)
&& hasComposerContent
&& !value.trimStart().startsWith("/");
const slashQuery = useMemo(() => {
@@ -1181,6 +1189,10 @@ export function ThreadComposer({
}, [onStop, queuedPrompts.length]);
const submit = useCallback(() => {
if (modelNeedsSetup) {
onModelBadgeClick?.();
return;
}
if (!canSend) return;
const trimmed = value.trim();
const content = trimmed;
@@ -1219,6 +1231,8 @@ export function ThreadComposer({
canSend,
clear,
clearComposerText,
modelNeedsSetup,
onModelBadgeClick,
onSend,
readyImages,
value,
@@ -1533,24 +1547,32 @@ export function ThreadComposer({
label={modelLabel}
provider={modelProvider}
providerLabel={modelProviderLabel}
needsSetup={modelNeedsSetup}
isHero={isHero}
onClick={modelNeedsSetup ? onModelBadgeClick : undefined}
/>
) : null}
<Button
type={showStopButton ? "button" : "submit"}
type={showStopButton || modelNeedsSetup ? "button" : "submit"}
size="icon"
disabled={showStopButton ? disabled : !canSend}
aria-label={showStopButton ? t("thread.composer.stop") : t("thread.composer.send")}
onClick={showStopButton ? handleStop : undefined}
disabled={showStopButton ? disabled : !canSend && !canOpenModelSettings}
aria-label={
showStopButton
? t("thread.composer.stop")
: modelNeedsSetup
? t("thread.composer.configureModel", { defaultValue: "Configure model" })
: t("thread.composer.send")
}
onClick={showStopButton ? handleStop : modelNeedsSetup ? onModelBadgeClick : undefined}
className={cn(
"rounded-full transition-transform",
showStopButton
? "border border-border/70 bg-card text-foreground/85 shadow-[0_3px_10px_rgba(15,23,42,0.08)] hover:bg-muted/65 hover:text-foreground disabled:text-muted-foreground/50"
: isHero
? "border border-foreground bg-foreground text-background shadow-[0_4px_12px_rgba(15,23,42,0.20)] hover:bg-foreground/90 disabled:border-foreground/35 disabled:bg-foreground/35 disabled:text-background/80"
: "border border-foreground bg-foreground text-background shadow-[0_3px_10px_rgba(15,23,42,0.18)] hover:bg-foreground/90 disabled:border-foreground/35 disabled:bg-foreground/35 disabled:text-background/80",
? "border border-foreground bg-foreground text-background shadow-[0_4px_12px_rgba(15,23,42,0.20)] hover:bg-foreground/90 disabled:border-foreground disabled:bg-foreground disabled:text-background"
: "border border-foreground bg-foreground text-background shadow-[0_3px_10px_rgba(15,23,42,0.18)] hover:bg-foreground/90 disabled:border-foreground disabled:bg-foreground disabled:text-background",
isHero ? "h-8 w-8" : "h-9 w-9",
(canSend || showStopButton) && "hover:scale-[1.03] active:scale-95",
(canSend || canOpenModelSettings || showStopButton) && "hover:scale-[1.03] active:scale-95",
)}
>
{showStopButton ? (
@@ -1766,44 +1788,59 @@ function ComposerModelBadge({
label,
provider,
providerLabel,
needsSetup,
isHero,
onClick,
}: {
label: string;
provider?: string | null;
providerLabel?: string | null;
needsSetup?: boolean;
isHero: boolean;
onClick?: () => void;
}) {
const inferredProvider = provider || inferProviderFromModelName(label);
const inferredProvider = needsSetup ? null : provider || inferProviderFromModelName(label);
const brand = providerBrand(inferredProvider);
const [logoIndex, setLogoIndex] = useState(0);
const logoUrl = brand?.logoUrls[logoIndex];
const showLogo = !!logoUrl;
const title = providerLabel ? `${label} · ${providerLabel}` : label;
const interactive = Boolean(onClick);
const Container = interactive ? "button" : "span";
useEffect(() => setLogoIndex(0), [inferredProvider]);
return (
<span
<Container
title={title}
type={interactive ? "button" : undefined}
onClick={onClick}
className={cn(
"inline-flex min-w-0 items-center rounded-full border border-border/55 bg-card font-medium text-foreground/82",
"shadow-[0_2px_8px_rgba(15,23,42,0.045)]",
interactive && "cursor-pointer hover:bg-accent/55 hover:text-foreground",
needsSetup && "border-amber-500/35 bg-amber-50/70 text-amber-900 dark:bg-amber-500/10 dark:text-amber-200",
isHero ? "h-8 max-w-[12.5rem] gap-1.5 px-2 text-[11.5px]" : "h-9 max-w-[12rem] gap-2 px-2.5 text-[12px]",
)}
>
<span
data-testid={inferredProvider ? `composer-model-logo-${inferredProvider}` : "composer-model-logo"}
data-testid={needsSetup ? "composer-model-setup-icon" : inferredProvider ? `composer-model-logo-${inferredProvider}` : "composer-model-logo"}
className={cn(
"grid shrink-0 place-items-center overflow-hidden rounded-full border bg-background",
"grid shrink-0 place-items-center overflow-hidden",
needsSetup
? "text-amber-800 dark:text-amber-200"
: "rounded-full border bg-background",
isHero ? "h-[18px] w-[18px]" : "h-5 w-5",
)}
style={{
borderColor: brand ? `${brand.color}28` : undefined,
boxShadow: brand ? `inset 0 0 0 1px ${brand.color}18` : undefined,
borderColor: !needsSetup && brand ? `${brand.color}28` : undefined,
boxShadow: !needsSetup && brand ? `inset 0 0 0 1px ${brand.color}18` : undefined,
}}
aria-hidden
>
{showLogo ? (
{needsSetup ? (
<CircleHelp className={cn(isHero ? "h-3 w-3" : "h-3.5 w-3.5")} strokeWidth={1.8} />
) : showLogo ? (
<img
src={logoUrl}
alt=""
@@ -1825,7 +1862,7 @@ function ComposerModelBadge({
)}
</span>
<span className="truncate">{label}</span>
</span>
</Container>
);
}
+33 -39
View File
@@ -1,4 +1,5 @@
import { Menu, Moon, Sun } from "lucide-react";
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
@@ -10,8 +11,11 @@ interface ThreadHeaderProps {
theme: "light" | "dark";
onToggleTheme: () => void;
hideSidebarToggleForHostChrome?: boolean;
hostChromeTitleInset?: boolean;
hideThemeButton?: boolean;
minimal?: boolean;
promptNavigatorAction?: ReactNode;
sessionInfoAction?: ReactNode;
}
export function ThreadHeader({
@@ -20,39 +24,22 @@ export function ThreadHeader({
theme,
onToggleTheme,
hideSidebarToggleForHostChrome = false,
hostChromeTitleInset = false,
hideThemeButton = false,
minimal = false,
promptNavigatorAction,
sessionInfoAction,
}: ThreadHeaderProps) {
const { t } = useTranslation();
if (minimal) {
return (
<div className="relative z-10 flex h-11 items-center justify-between gap-3 px-3 py-2">
<Button
variant="ghost"
size="icon"
aria-label={t("thread.header.toggleSidebar")}
onClick={onToggleSidebar}
className={cn(
"h-7 w-7 rounded-md text-muted-foreground hover:bg-accent/35 hover:text-foreground",
hideSidebarToggleForHostChrome && "lg:hidden",
)}
>
<Menu className="h-3.5 w-3.5" />
</Button>
{!hideThemeButton ? (
<ThemeButton
theme={theme}
onToggleTheme={onToggleTheme}
label={t("thread.header.toggleTheme")}
className="ml-auto"
/>
) : null}
</div>
);
}
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",
minimal && "h-11",
!minimal && hostChromeTitleInset && "lg:pl-[128px]",
)}
>
<div className="relative flex min-w-0 items-center gap-2">
<Button
variant="ghost"
@@ -66,21 +53,28 @@ export function ThreadHeader({
>
<Menu className="h-3.5 w-3.5" />
</Button>
<div className="flex min-w-0 items-center rounded-md px-1.5 py-1 text-[12px] font-medium text-muted-foreground">
<span className="max-w-[min(60vw,32rem)] truncate">{title}</span>
</div>
{!minimal ? (
<div className="flex min-w-0 items-center rounded-md px-1.5 py-1 text-[12px] font-medium text-muted-foreground">
<span className="max-w-[min(60vw,32rem)] truncate">{title}</span>
</div>
) : null}
</div>
{!hideThemeButton ? (
<ThemeButton
theme={theme}
onToggleTheme={onToggleTheme}
label={t("thread.header.toggleTheme")}
className="ml-auto shrink-0"
/>
) : null}
<div className="ml-auto flex shrink-0 items-center gap-1">
{sessionInfoAction}
{promptNavigatorAction}
{!hideThemeButton ? (
<ThemeButton
theme={theme}
onToggleTheme={onToggleTheme}
label={t("thread.header.toggleTheme")}
/>
) : null}
</div>
<div aria-hidden className="pointer-events-none absolute inset-x-0 top-full h-4" />
{!minimal ? (
<div aria-hidden className="pointer-events-none absolute inset-x-0 top-full h-4" />
) : null}
</div>
);
}
+12 -3
View File
@@ -14,6 +14,7 @@ interface ThreadMessagesProps {
onLoadEarlier?: () => void;
cliApps?: CliAppInfo[];
mcpPresets?: McpPresetInfo[];
onOpenFilePreview?: (path: string) => void;
}
export type DisplayUnit = TurnUnit;
@@ -33,8 +34,13 @@ export function isFinalAssistantSliceBeforeNextUser(
return true;
}
export function buildDisplayUnits(messages: UIMessage[]): DisplayUnit[] {
return normalizeActivityTimeline(messages);
export function buildDisplayUnits(
messages: UIMessage[],
isStreaming = false,
): DisplayUnit[] {
return normalizeActivityTimeline(messages, {
preserveTrailingActivity: isStreaming,
});
}
export function assistantCopyFlags(units: DisplayUnit[]): boolean[] {
@@ -61,9 +67,10 @@ export function ThreadMessages({
onLoadEarlier,
cliApps = [],
mcpPresets = [],
onOpenFilePreview,
}: ThreadMessagesProps) {
const { t } = useTranslation();
const units = useMemo(() => buildDisplayUnits(messages), [messages]);
const units = useMemo(() => buildDisplayUnits(messages, isStreaming), [isStreaming, messages]);
const copyFlags = useMemo(() => assistantCopyFlags(units), [units]);
const liveActivityClusterIndices = useMemo(
() => isStreaming ? currentActivityClusterIndices(units) : new Set<number>(),
@@ -117,6 +124,7 @@ export function ThreadMessages({
turnLatencyMs={unit.turnLatencyMs}
cliApps={cliApps}
mcpPresets={mcpPresets}
onOpenFilePreview={onOpenFilePreview}
/>
) : (
<MessageBubble
@@ -128,6 +136,7 @@ export function ThreadMessages({
}
cliApps={cliApps}
mcpPresets={mcpPresets}
onOpenFilePreview={onOpenFilePreview}
/>
)}
</div>
+281 -118
View File
@@ -1,10 +1,14 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import type { PointerEvent as ReactPointerEvent } from "react";
import { useTranslation } from "react-i18next";
import { FilePreviewPanel } from "@/components/FilePreviewPanel";
import { PromptNavigator } from "@/components/thread/PromptNavigator";
import { SessionInfoPopover } from "@/components/thread/SessionInfoPopover";
import { ThreadComposer } from "@/components/thread/ThreadComposer";
import { ThreadHeader } from "@/components/thread/ThreadHeader";
import { StreamErrorNotice } from "@/components/thread/StreamErrorNotice";
import { ThreadViewport } from "@/components/thread/ThreadViewport";
import { ThreadViewport, type ThreadViewportHandle } from "@/components/thread/ThreadViewport";
import { useNanobotStream, type SendImage, type SendOptions } from "@/hooks/useNanobotStream";
import { useSessionHistory } from "@/hooks/useSessions";
import { fetchCliApps, fetchMcpPresets, fetchSettings, listSlashCommands } from "@/lib/api";
@@ -21,8 +25,6 @@ import {
import { inferProviderFromModelName, providerDisplayLabel } from "@/lib/provider-brand";
import type {
ChatSummary,
CliAppInfo,
McpPresetInfo,
SettingsPayload,
SlashCommand,
UIMessage,
@@ -51,6 +53,23 @@ function isStaleThreadSnapshot(current: UIMessage[], snapshot: UIMessage[]): boo
return snapshot.every((message, index) => sameMessageShape(current[index], message));
}
const FILE_PREVIEW_DEFAULT_WIDTH = 544;
const FILE_PREVIEW_MIN_WIDTH = 360;
const FILE_PREVIEW_MAX_WIDTH = 860;
const FILE_PREVIEW_MIN_MAIN_WIDTH = 420;
const FILE_PREVIEW_CLOSE_ANIMATION_MS = 320;
function clampFilePreviewWidth(width: number, maxWidth: number): number {
return Math.min(Math.max(width, FILE_PREVIEW_MIN_WIDTH), maxWidth);
}
function maxFilePreviewWidth(containerWidth: number): number {
return Math.max(
FILE_PREVIEW_MIN_WIDTH,
Math.min(FILE_PREVIEW_MAX_WIDTH, containerWidth - FILE_PREVIEW_MIN_MAIN_WIDTH),
);
}
interface ThreadShellProps {
session: ChatSummary | null;
title: string;
@@ -62,6 +81,7 @@ interface ThreadShellProps {
theme?: "light" | "dark";
onToggleTheme?: () => void;
hideSidebarToggleForHostChrome?: boolean;
hostChromeTitleInset?: boolean;
hideThemeButton?: boolean;
hideHeader?: boolean;
workspaceScope?: WorkspaceScopePayload | null;
@@ -71,6 +91,7 @@ interface ThreadShellProps {
workspaceError?: string | null;
onWorkspaceScopeChange?: (scope: WorkspaceScopePayload) => void;
settingsSnapshot?: SettingsPayload | null;
onOpenModelSettings?: () => void;
}
function toModelBadgeLabel(modelName: string | null): string | null {
@@ -85,6 +106,7 @@ interface ModelBadgeInfo {
label: string | null;
provider: string | null;
providerLabel: string | null;
needsSetup: boolean;
}
function activeModelPreset(settings: SettingsPayload | null): SettingsPayload["model_presets"][number] | null {
@@ -107,12 +129,20 @@ function resolvedModelProvider(settings: SettingsPayload | null, modelName: stri
}
function toModelBadgeInfo(modelName: string | null, settings: SettingsPayload | null): ModelBadgeInfo {
const label = toModelBadgeLabel(modelName || settings?.agent.model || null);
const provider = resolvedModelProvider(settings, modelName || settings?.agent.model || null);
const model = modelName || settings?.agent.model || null;
const label = toModelBadgeLabel(model);
const provider = resolvedModelProvider(settings, model);
const providerRow = provider
? settings?.providers.find((item) => item.name === provider)
: null;
const needsSetup = Boolean(
settings && (!model || !provider || !providerRow || !providerRow.configured),
);
return {
label,
provider,
providerLabel: provider ? providerDisplayLabel(settings?.providers ?? [], provider) : null,
needsSetup,
};
}
@@ -134,6 +164,63 @@ interface PendingFirstMessage {
options?: SendOptions;
}
interface InstalledSettingItemsOptions<Payload, Item> {
token: string;
eventName: string;
fetchPayload: (token: string) => Promise<Payload>;
isPayload: (value: unknown) => value is Payload;
selectItems: (payload: Payload) => Item[];
}
function useInstalledSettingItems<Payload, Item>({
token,
eventName,
fetchPayload,
isPayload,
selectItems,
}: InstalledSettingItemsOptions<Payload, Item>): Item[] {
const [items, setItems] = useState<Item[]>([]);
const refresh = useCallback(async (isCancelled?: () => boolean) => {
try {
const payload = await fetchPayload(token);
if (!isCancelled?.()) setItems(selectItems(payload));
} catch {
if (!isCancelled?.()) setItems([]);
}
}, [fetchPayload, selectItems, token]);
useEffect(() => {
let cancelled = false;
void refresh(() => cancelled);
const refreshOnFocus = () => {
if (document.visibilityState === "hidden") return;
void refresh();
};
const refreshOnChanged = (event: Event) => {
const payload = (event as CustomEvent<unknown>).detail;
if (isPayload(payload)) {
setItems(selectItems(payload));
return;
}
void refresh();
};
window.addEventListener("focus", refreshOnFocus);
document.addEventListener("visibilitychange", refreshOnFocus);
window.addEventListener(eventName, refreshOnChanged);
return () => {
cancelled = true;
window.removeEventListener("focus", refreshOnFocus);
document.removeEventListener("visibilitychange", refreshOnFocus);
window.removeEventListener(eventName, refreshOnChanged);
};
}, [eventName, isPayload, refresh, selectItems]);
return items;
}
export function ThreadShell({
session,
title,
@@ -143,6 +230,7 @@ export function ThreadShell({
theme = "light",
onToggleTheme = () => {},
hideSidebarToggleForHostChrome = false,
hostChromeTitleInset = false,
hideThemeButton = false,
hideHeader = false,
workspaceScope = null,
@@ -152,6 +240,7 @@ export function ThreadShell({
workspaceError = null,
onWorkspaceScopeChange,
settingsSnapshot = null,
onOpenModelSettings,
}: ThreadShellProps) {
const { t } = useTranslation();
const chatId = session?.chatId ?? null;
@@ -166,12 +255,31 @@ export function ThreadShell({
const { client, modelName, token } = useClient();
const [booting, setBooting] = useState(false);
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
const [cliApps, setCliApps] = useState<CliAppInfo[]>([]);
const [mcpPresets, setMcpPresets] = useState<McpPresetInfo[]>([]);
const cliApps = useInstalledSettingItems({
token,
eventName: CLI_APPS_CHANGED_EVENT,
fetchPayload: fetchCliApps,
isPayload: isCliAppsPayload,
selectItems: installedCliAppsFromPayload,
});
const mcpPresets = useInstalledSettingItems({
token,
eventName: MCP_PRESETS_CHANGED_EVENT,
fetchPayload: fetchMcpPresets,
isPayload: isMcpPresetsPayload,
selectItems: installedMcpPresetsFromPayload,
});
const [settings, setSettings] = useState<SettingsPayload | null>(settingsSnapshot);
const [heroGreetingKey, setHeroGreetingKey] = useState(randomHeroGreetingKey);
const [scrollToBottomSignal, setScrollToBottomSignal] = useState(0);
const [filePreviewPath, setFilePreviewPath] = useState<string | null>(null);
const [filePreviewClosing, setFilePreviewClosing] = useState(false);
const [filePreviewWidth, setFilePreviewWidth] = useState(FILE_PREVIEW_DEFAULT_WIDTH);
const shellRef = useRef<HTMLElement | null>(null);
const filePreviewWidthRef = useRef(FILE_PREVIEW_DEFAULT_WIDTH);
const filePreviewCloseTimerRef = useRef<number | null>(null);
const pendingFirstRef = useRef<PendingFirstMessage | null>(null);
const viewportRef = useRef<ThreadViewportHandle | null>(null);
const messageCacheRef = useRef<Map<string, UIMessage[]>>(new Map());
/** Last chatId we associated with the in-memory thread (for cache-on-switch). */
const prevChatIdForCacheRef = useRef<string | null>(null);
@@ -204,6 +312,27 @@ export function ThreadShell({
if (chatId && historyKey) sessionKeyByChatIdRef.current.set(chatId, historyKey);
}, [chatId, historyKey]);
useEffect(() => {
filePreviewWidthRef.current = filePreviewWidth;
}, [filePreviewWidth]);
useEffect(() => {
if (filePreviewCloseTimerRef.current !== null) {
window.clearTimeout(filePreviewCloseTimerRef.current);
filePreviewCloseTimerRef.current = null;
}
setFilePreviewClosing(false);
setFilePreviewPath(null);
}, [historyKey]);
useEffect(() => {
return () => {
if (filePreviewCloseTimerRef.current !== null) {
window.clearTimeout(filePreviewCloseTimerRef.current);
}
};
}, []);
const displayMessages = useMemo(() => projectWebuiThreadMessages(messages), [messages]);
const showHeroComposer = messages.length === 0 && !loading;
@@ -212,6 +341,9 @@ export function ThreadShell({
() => toModelBadgeInfo(modelName, settings),
[modelName, settings],
);
const modelBadgeLabel = modelBadge.needsSetup
? t("thread.composer.modelNotConfigured", { defaultValue: "Model not configured" })
: modelBadge.label;
useEffect(() => {
if (showHeroComposer && !wasShowingHeroComposerRef.current) {
setHeroGreetingKey(randomHeroGreetingKey());
@@ -372,94 +504,6 @@ export function ThreadShell({
};
}, [token]);
const refreshCliApps = useCallback(async () => {
try {
const payload = await fetchCliApps(token);
setCliApps(installedCliAppsFromPayload(payload));
} catch {
setCliApps([]);
}
}, [token]);
const refreshMcpPresets = useCallback(async () => {
try {
const payload = await fetchMcpPresets(token);
setMcpPresets(installedMcpPresetsFromPayload(payload));
} catch {
setMcpPresets([]);
}
}, [token]);
useEffect(() => {
let cancelled = false;
const load = async () => {
try {
const payload = await fetchCliApps(token);
if (!cancelled) setCliApps(installedCliAppsFromPayload(payload));
} catch {
if (!cancelled) setCliApps([]);
}
};
load();
const refreshOnFocus = () => {
if (document.visibilityState === "hidden") return;
void refreshCliApps();
};
window.addEventListener("focus", refreshOnFocus);
document.addEventListener("visibilitychange", refreshOnFocus);
const refreshOnCliAppsChanged = (event: Event) => {
const payload = (event as CustomEvent<unknown>).detail;
if (isCliAppsPayload(payload)) {
setCliApps(installedCliAppsFromPayload(payload));
return;
}
void refreshCliApps();
};
window.addEventListener(CLI_APPS_CHANGED_EVENT, refreshOnCliAppsChanged);
return () => {
cancelled = true;
window.removeEventListener("focus", refreshOnFocus);
document.removeEventListener("visibilitychange", refreshOnFocus);
window.removeEventListener(CLI_APPS_CHANGED_EVENT, refreshOnCliAppsChanged);
};
}, [refreshCliApps, token]);
useEffect(() => {
let cancelled = false;
const load = async () => {
try {
const payload = await fetchMcpPresets(token);
if (!cancelled) setMcpPresets(installedMcpPresetsFromPayload(payload));
} catch {
if (!cancelled) setMcpPresets([]);
}
};
load();
const refreshOnFocus = () => {
if (document.visibilityState === "hidden") return;
void refreshMcpPresets();
};
window.addEventListener("focus", refreshOnFocus);
document.addEventListener("visibilitychange", refreshOnFocus);
const refreshOnMcpPresetsChanged = (event: Event) => {
const payload = (event as CustomEvent<unknown>).detail;
if (isMcpPresetsPayload(payload)) {
setMcpPresets(installedMcpPresetsFromPayload(payload));
return;
}
void refreshMcpPresets();
};
window.addEventListener(MCP_PRESETS_CHANGED_EVENT, refreshOnMcpPresetsChanged);
return () => {
cancelled = true;
window.removeEventListener("focus", refreshOnFocus);
document.removeEventListener("visibilitychange", refreshOnFocus);
window.removeEventListener(MCP_PRESETS_CHANGED_EVENT, refreshOnMcpPresetsChanged);
};
}, [refreshMcpPresets, token]);
const handleWelcomeSend = useCallback(
async (content: string, images?: SendImage[], options?: SendOptions) => {
if (booting) return;
@@ -482,6 +526,94 @@ export function ThreadShell({
[send, withWorkspaceScope],
);
const handleOpenFilePreview = useCallback((path: string) => {
if (filePreviewCloseTimerRef.current !== null) {
window.clearTimeout(filePreviewCloseTimerRef.current);
filePreviewCloseTimerRef.current = null;
}
setFilePreviewClosing(false);
setFilePreviewPath(path);
}, []);
const handleCloseFilePreview = useCallback(() => {
if (!filePreviewPath || filePreviewClosing) return;
setFilePreviewClosing(true);
filePreviewCloseTimerRef.current = window.setTimeout(() => {
filePreviewCloseTimerRef.current = null;
setFilePreviewPath(null);
setFilePreviewClosing(false);
}, FILE_PREVIEW_CLOSE_ANIMATION_MS);
}, [filePreviewClosing, filePreviewPath]);
const handleFilePreviewResizeStart = useCallback((event: ReactPointerEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
const panel = event.currentTarget.closest<HTMLElement>("[data-file-preview-panel]");
const shellRect = shellRef.current?.getBoundingClientRect();
const rightEdge = shellRect?.right ?? window.innerWidth;
const maxWidth = maxFilePreviewWidth(shellRect?.width ?? window.innerWidth);
const originalBodyCursor = document.body.style.cursor;
const originalBodyUserSelect = document.body.style.userSelect;
const originalPanelTransition = panel?.style.transition ?? "";
let nextWidth = filePreviewWidthRef.current;
let frame: number | null = null;
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
if (panel) panel.style.transition = "none";
const applyWidth = (clientX: number) => {
nextWidth = clampFilePreviewWidth(rightEdge - clientX, maxWidth);
filePreviewWidthRef.current = nextWidth;
if (frame !== null) return;
frame = window.requestAnimationFrame(() => {
frame = null;
panel?.style.setProperty("--file-preview-width", `${nextWidth}px`);
panel?.style.setProperty("--file-preview-slot-width", `${nextWidth}px`);
});
};
const handlePointerMove = (moveEvent: PointerEvent) => {
moveEvent.preventDefault();
applyWidth(moveEvent.clientX);
};
const handlePointerUp = () => {
if (frame !== null) {
window.cancelAnimationFrame(frame);
frame = null;
}
panel?.style.setProperty("--file-preview-width", `${nextWidth}px`);
panel?.style.setProperty("--file-preview-slot-width", `${nextWidth}px`);
if (panel) panel.style.transition = originalPanelTransition;
setFilePreviewWidth(nextWidth);
document.body.style.cursor = originalBodyCursor;
document.body.style.userSelect = originalBodyUserSelect;
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", handlePointerUp);
window.removeEventListener("pointercancel", handlePointerUp);
};
applyWidth(event.clientX);
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", handlePointerUp);
window.addEventListener("pointercancel", handlePointerUp);
}, []);
useEffect(() => {
if (!filePreviewPath) return;
const clampToShell = () => {
const shellWidth = shellRef.current?.getBoundingClientRect().width ?? window.innerWidth;
const maxWidth = maxFilePreviewWidth(shellWidth);
const nextWidth = clampFilePreviewWidth(filePreviewWidthRef.current, maxWidth);
filePreviewWidthRef.current = nextWidth;
setFilePreviewWidth(nextWidth);
};
clampToShell();
window.addEventListener("resize", clampToShell);
return () => {
window.removeEventListener("resize", clampToShell);
};
}, [filePreviewPath]);
const composer = (
<>
{streamError ? (
@@ -500,9 +632,11 @@ export function ThreadShell({
? t("thread.composer.placeholderHero")
: t("thread.composer.placeholderThread")
}
modelLabel={modelBadge.label}
modelLabel={modelBadgeLabel}
modelProvider={modelBadge.provider}
modelProviderLabel={modelBadge.providerLabel}
modelNeedsSetup={modelBadge.needsSetup}
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
variant={showHeroComposer ? "hero" : "thread"}
slashCommands={slashCommands}
cliApps={cliApps}
@@ -528,9 +662,11 @@ export function ThreadShell({
? t("thread.composer.placeholderOpening")
: t("thread.composer.placeholderHero")
}
modelLabel={modelBadge.label}
modelLabel={modelBadgeLabel}
modelProvider={modelBadge.provider}
modelProviderLabel={modelBadge.providerLabel}
modelNeedsSetup={modelBadge.needsSetup}
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
variant="hero"
slashCommands={slashCommands}
cliApps={cliApps}
@@ -559,31 +695,58 @@ export function ThreadShell({
</h1>
</div>
);
const sessionInfoAction = historyKey ? (
<SessionInfoPopover sessionKey={historyKey} token={token} title={title} />
) : undefined;
const promptNavigatorAction = historyKey ? (
<PromptNavigator
messages={displayMessages}
onJumpToPrompt={(promptId) => viewportRef.current?.jumpToUserPrompt(promptId)}
/>
) : undefined;
return (
<section className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
{!hideHeader ? (
<ThreadHeader
title={title}
onToggleSidebar={onToggleSidebar}
theme={theme}
onToggleTheme={onToggleTheme}
hideSidebarToggleForHostChrome={hideSidebarToggleForHostChrome}
hideThemeButton={hideThemeButton}
minimal={!session && !loading}
<section ref={shellRef} className="relative flex min-h-0 flex-1 overflow-hidden">
<div className="relative flex min-w-0 flex-1 flex-col overflow-hidden">
{!hideHeader ? (
<ThreadHeader
title={title}
onToggleSidebar={onToggleSidebar}
theme={theme}
onToggleTheme={onToggleTheme}
hideSidebarToggleForHostChrome={hideSidebarToggleForHostChrome}
hostChromeTitleInset={hostChromeTitleInset}
hideThemeButton={hideThemeButton}
minimal={!session && !loading}
promptNavigatorAction={promptNavigatorAction}
sessionInfoAction={sessionInfoAction}
/>
) : null}
<ThreadViewport
ref={viewportRef}
messages={displayMessages}
isStreaming={isStreaming}
emptyState={emptyState}
composer={composer}
scrollToBottomSignal={scrollToBottomSignal}
conversationKey={historyKey}
showScrollToBottomButton={!!session}
cliApps={cliApps}
mcpPresets={mcpPresets}
onOpenFilePreview={historyKey ? handleOpenFilePreview : undefined}
/>
</div>
{filePreviewPath && historyKey ? (
<FilePreviewPanel
sessionKey={historyKey}
path={filePreviewPath}
token={token}
desktopWidth={filePreviewWidth}
isClosing={filePreviewClosing}
onResizeStart={handleFilePreviewResizeStart}
onClose={handleCloseFilePreview}
/>
) : null}
<ThreadViewport
messages={displayMessages}
isStreaming={isStreaming}
emptyState={emptyState}
composer={composer}
scrollToBottomSignal={scrollToBottomSignal}
conversationKey={historyKey}
showScrollToBottomButton={!!session}
cliApps={cliApps}
mcpPresets={mcpPresets}
/>
</section>
);
}
+58 -16
View File
@@ -1,7 +1,9 @@
import {
forwardRef,
type ReactNode,
useCallback,
useEffect,
useImperativeHandle,
useLayoutEffect,
useMemo,
useRef,
@@ -14,9 +16,17 @@ import { PromptRail } from "@/components/thread/PromptRail";
import { ThreadMessages } from "@/components/thread/ThreadMessages";
import { isAgentActivityMember } from "@/components/thread/AgentActivityCluster";
import { Button } from "@/components/ui/button";
import {
findPromptElement,
jumpToPrompt,
} from "@/components/thread/promptNavigation";
import { cn } from "@/lib/utils";
import type { CliAppInfo, McpPresetInfo, UIMessage } from "@/lib/types";
export interface ThreadViewportHandle {
jumpToUserPrompt: (promptId: string) => void;
}
interface ThreadViewportProps {
messages: UIMessage[];
isStreaming: boolean;
@@ -27,6 +37,7 @@ interface ThreadViewportProps {
showScrollToBottomButton?: boolean;
cliApps?: CliAppInfo[];
mcpPresets?: McpPresetInfo[];
onOpenFilePreview?: (path: string) => void;
}
const NEAR_BOTTOM_PX = 48;
@@ -48,7 +59,7 @@ export function windowMessages(messages: UIMessage[], visibleCount: number): UIM
return messages.slice(start);
}
export function ThreadViewport({
export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportProps>(function ThreadViewport({
messages,
isStreaming,
composer,
@@ -58,7 +69,8 @@ export function ThreadViewport({
showScrollToBottomButton = true,
cliApps = [],
mcpPresets = [],
}: ThreadViewportProps) {
onOpenFilePreview,
}, ref) {
const { t } = useTranslation();
const scrollRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
@@ -66,6 +78,7 @@ export function ThreadViewport({
const bottomRef = useRef<HTMLDivElement>(null);
const lastConversationKeyRef = useRef<string | null>(conversationKey);
const pendingConversationScrollRef = useRef(true);
const pendingPromptJumpRef = useRef<string | null>(null);
const scrollFrameIdsRef = useRef<number[]>([]);
const restoreScrollAfterPrependRef =
useRef<{ height: number; top: number } | null>(null);
@@ -139,6 +152,22 @@ export function ThreadViewport({
);
}, [messages.length]);
const jumpToUserPrompt = useCallback((promptId: string) => {
const scrollEl = scrollRef.current;
if (scrollEl && findPromptElement(scrollEl, promptId)) {
jumpToPrompt(scrollEl, promptId);
return;
}
const index = messages.findIndex((message) => message.id === promptId);
if (index < 0) return;
pendingPromptJumpRef.current = promptId;
userReadingHistoryRef.current = true;
setAtBottom(false);
setVisibleMessageCount((count) => Math.max(count, messages.length - index));
}, [messages]);
useImperativeHandle(ref, () => ({ jumpToUserPrompt }), [jumpToUserPrompt]);
const measureComposerDock = useCallback(() => {
const el = composerDockRef.current;
if (!el) return;
@@ -180,6 +209,15 @@ export function ThreadViewport({
el.scrollTop = pending.top + delta;
}, [visibleMessages.length]);
useLayoutEffect(() => {
const promptId = pendingPromptJumpRef.current;
const scrollEl = scrollRef.current;
if (!promptId || !scrollEl || !findPromptElement(scrollEl, promptId)) return;
pendingPromptJumpRef.current = null;
const frame = window.requestAnimationFrame(() => jumpToPrompt(scrollEl, promptId));
return () => window.cancelAnimationFrame(frame);
}, [visibleMessages.length]);
useLayoutEffect(() => {
if (!pendingConversationScrollRef.current) return;
if (!conversationKey) {
@@ -256,6 +294,7 @@ export function ThreadViewport({
onLoadEarlier={loadEarlierMessages}
cliApps={cliApps}
mcpPresets={mcpPresets}
onOpenFilePreview={onOpenFilePreview}
/>
</div>
</div>
@@ -299,22 +338,25 @@ export function ThreadViewport({
) : null}
{showScrollToBottomButton && !atBottom && (
<Button
variant="outline"
size="icon"
onClick={() => scrollToBottom(true, 1, { force: true })}
className={cn(
/* Keep clear of sticky composer (textarea + toolbar + optional goal strip). */
"absolute left-1/2 z-20 h-8 w-8 -translate-x-1/2 rounded-full shadow-md",
"bg-background/90 backdrop-blur",
"animate-in fade-in-0 zoom-in-95",
)}
<div
className="absolute left-1/2 z-20 -translate-x-1/2"
style={{ bottom: scrollButtonBottom }}
aria-label={t("thread.scrollToBottom")}
>
<ArrowDown className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
onClick={() => scrollToBottom(true, 1, { force: true })}
className={cn(
"h-8 w-8 rounded-full shadow-md",
"bg-background/90 backdrop-blur",
"animate-in fade-in-0 zoom-in-95",
)}
aria-label={t("thread.scrollToBottom")}
>
<ArrowDown className="h-4 w-4" />
</Button>
</div>
)}
</div>
);
}
});
@@ -106,7 +106,7 @@ export function WorkspaceProjectPicker({
if (nativeProjectPicker) {
return (
<div className="flex items-center border-t border-border/25 bg-muted/60 px-4 py-1.5 dark:bg-white/[0.055]">
<div className="flex items-center rounded-b-[28px] border-t border-border/25 bg-muted/60 px-4 py-1.5 dark:bg-white/[0.055]">
<button
type="button"
disabled={disabled || pickingFolder}
@@ -133,7 +133,7 @@ export function WorkspaceProjectPicker({
}
return (
<div className="flex items-center border-t border-border/25 bg-muted/60 px-4 py-1.5 dark:bg-white/[0.055]">
<div className="flex items-center rounded-b-[28px] border-t border-border/25 bg-muted/60 px-4 py-1.5 dark:bg-white/[0.055]">
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<button
@@ -22,18 +22,34 @@ export interface FileEditSummary {
error?: string;
}
export function FileEditGroup({ edits }: { edits: FileEditSummary[] }) {
export function FileEditGroup({
edits,
onOpenFilePreview,
}: {
edits: FileEditSummary[];
onOpenFilePreview?: (path: string) => void;
}) {
if (edits.length === 0) return null;
return (
<ul className="space-y-1">
{edits.map((edit) => (
<FileEditRow key={edit.key} edit={edit} />
<FileEditRow
key={edit.key}
edit={edit}
onOpenFilePreview={onOpenFilePreview}
/>
))}
</ul>
);
}
function FileEditRow({ edit }: { edit: FileEditSummary }) {
function FileEditRow({
edit,
onOpenFilePreview,
}: {
edit: FileEditSummary;
onOpenFilePreview?: (path: string) => void;
}) {
const { t } = useTranslation();
const editing = edit.status === "editing";
const failed = edit.status === "error";
@@ -76,6 +92,8 @@ function FileEditRow({ edit }: { edit: FileEditSummary }) {
<FileReferenceChip
path={edit.path}
tooltipPath={edit.absolute_path}
previewPath={edit.absolute_path || edit.path}
onOpen={onOpenFilePreview}
display="path"
active={editing}
className="min-w-0"
@@ -10,9 +10,11 @@ import { ActivityStep } from "./ActivityStep";
export function ReasoningRow({
text,
streaming,
onOpenFilePreview,
}: {
text: string;
streaming: boolean;
onOpenFilePreview?: (path: string) => void;
}) {
const { t } = useTranslation();
useEffect(() => {
@@ -30,6 +32,7 @@ export function ReasoningRow({
{text.trim() ? (
<MarkdownText
streaming={streaming}
onOpenFilePreview={onOpenFilePreview}
className={cn(
"min-w-0 text-[12.5px] italic text-muted-foreground/78",
"prose-p:my-1 prose-li:my-0.5",
@@ -0,0 +1,64 @@
import type { UIMessage } from "@/lib/types";
export interface PromptAnchor {
id: string;
label: string;
preview: string;
createdAt: number;
index: number;
}
export function userPromptAnchors(messages: UIMessage[]): PromptAnchor[] {
let index = 0;
return messages.flatMap((message) => {
if (message.role !== "user") return [];
const anchor: PromptAnchor = {
id: message.id,
label: promptLabel(message.content, index),
preview: promptPreview(message.content, index),
createdAt: message.createdAt,
index,
};
index += 1;
return [anchor];
});
}
export function promptLabel(content: string, index: number): string {
const text = content.replace(/\s+/g, " ").trim();
if (!text) return `Prompt ${index + 1}`;
return text.length > 80 ? `${text.slice(0, 77)}...` : text;
}
export function promptPreview(content: string, index: number): string {
const text = content.replace(/\n{3,}/g, "\n\n").trim();
if (!text) return `Prompt ${index + 1}`;
return text.length > 320 ? `${text.slice(0, 317)}...` : text;
}
export function jumpToPrompt(scrollEl: HTMLElement | null, promptId: string | undefined): void {
if (!scrollEl || !promptId) return;
const target = findPromptElement(scrollEl, promptId);
if (!target) return;
scrollEl.scrollTo({
top: Math.max(0, promptTop(scrollEl, target) - 16),
behavior: "smooth",
});
}
export function findPromptElement(scrollEl: HTMLElement, promptId: string): HTMLElement | null {
const candidates = scrollEl.querySelectorAll<HTMLElement>("[data-user-prompt-id]");
return Array.from(candidates).find(
(candidate) => candidate.dataset.userPromptId === promptId,
) ?? null;
}
export function promptTop(scrollEl: HTMLElement, target: HTMLElement): number {
const scrollRect = scrollEl.getBoundingClientRect();
const targetRect = target.getBoundingClientRect();
const hasLayoutRect = scrollRect.top !== 0 || targetRect.top !== 0;
if (hasLayoutRect) {
return targetRect.top - scrollRect.top + scrollEl.scrollTop;
}
return target.offsetTop;
}
+13 -1
View File
@@ -100,4 +100,16 @@ const SheetTitle = React.forwardRef<
));
SheetTitle.displayName = DialogPrimitive.Title.displayName;
export { Sheet, SheetContent, SheetTitle };
const SheetDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
SheetDescription.displayName = DialogPrimitive.Description.displayName;
export { Sheet, SheetContent, SheetDescription, SheetTitle };
+41 -4
View File
@@ -5,6 +5,7 @@
/* Design tokens — HSL form, sourced from shadcn/ui's "neutral" palette. */
@layer base {
:root {
color-scheme: light;
--background: 0 0% 100%;
--foreground: 240 3% 12%;
--card: 0 0% 100%;
@@ -30,9 +31,12 @@
--sidebar-accent: 0 0% 95.8%;
--sidebar-accent-foreground: 0 0% 9%;
--sidebar-border: 0 0% 89.8%;
--scrollbar-thumb: hsl(var(--muted-foreground) / 0.26);
--scrollbar-thumb-hover: hsl(var(--muted-foreground) / 0.42);
}
.dark {
color-scheme: dark;
--background: 0 0% 10%;
--foreground: 240 4% 96%;
--card: 0 0% 12%;
@@ -57,6 +61,8 @@
--sidebar-accent: 0 0% 15.5%;
--sidebar-accent-foreground: 0 0% 98%;
--sidebar-border: 0 0% 18%;
--scrollbar-thumb: hsl(var(--muted-foreground) / 0.28);
--scrollbar-thumb-hover: hsl(var(--muted-foreground) / 0.44);
}
}
@@ -75,6 +81,33 @@
@apply bg-background text-foreground font-sans antialiased;
}
* {
scrollbar-color: var(--scrollbar-thumb) transparent;
scrollbar-width: thin;
}
*::-webkit-scrollbar {
width: 8px;
height: 8px;
}
*::-webkit-scrollbar-track {
background: transparent;
}
*::-webkit-scrollbar-thumb {
background-color: var(--scrollbar-thumb);
border-radius: 9999px;
}
*::-webkit-scrollbar-thumb:hover {
background-color: var(--scrollbar-thumb-hover);
}
*::-webkit-scrollbar-corner {
background: transparent;
}
::selection {
@apply bg-primary/15;
}
@@ -121,10 +154,13 @@
}
.host-sidebar-glass {
background: hsl(var(--sidebar) / 0.94);
-webkit-backdrop-filter: saturate(145%) blur(18px);
backdrop-filter: saturate(145%) blur(18px);
box-shadow:
inset -1px 0 0 hsl(var(--border) / 0.36),
inset 1px 0 0 hsl(var(--background) / 0.34),
18px 0 44px -42px rgb(0 0 0 / 0.42);
inset -1px 0 0 hsl(var(--border) / 0.32),
inset 1px 0 0 hsl(var(--background) / 0.52),
14px 0 32px -30px rgb(0 0 0 / 0.22);
}
.dark .host-window-shell,
@@ -135,10 +171,11 @@
}
.dark .host-sidebar-glass {
background: hsl(var(--sidebar) / 0.96);
box-shadow:
inset -1px 0 0 hsl(var(--border) / 0.42),
inset 1px 0 0 hsl(var(--foreground) / 0.05),
18px 0 46px -42px rgb(0 0 0 / 0.72);
14px 0 34px -30px rgb(0 0 0 / 0.62);
}
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
+118 -38
View File
@@ -20,6 +20,7 @@ import type {
UIImage,
UIFileEdit,
UIMessage,
UITurnPhase,
WorkspaceScopePayload,
} from "@/lib/types";
@@ -34,22 +35,50 @@ interface ActiveAssistantCursor {
}
type PendingStreamEvent =
| { kind: "delta"; text: string }
| { kind: "reasoning"; text: string };
| { kind: "delta"; text: string; turn: UIMessageTurnFields }
| { kind: "reasoning"; text: string; turn: UIMessageTurnFields };
type UIMessageTurnFields = Pick<UIMessage, "turnId" | "turnPhase" | "turnSeq">;
const FILE_EDIT_TOOL_NAMES = new Set(["write_file", "edit_file", "apply_patch"]);
function turnFieldsFromEvent(
ev: { turn_id?: string; turn_phase?: UITurnPhase; turn_seq?: number },
fallbackPhase?: UITurnPhase,
): UIMessageTurnFields {
const fields: UIMessageTurnFields = {};
if (typeof ev.turn_id === "string" && ev.turn_id.length > 0) {
fields.turnId = ev.turn_id;
}
const phase = ev.turn_phase ?? fallbackPhase;
if (phase) fields.turnPhase = phase;
if (typeof ev.turn_seq === "number" && Number.isFinite(ev.turn_seq)) {
fields.turnSeq = ev.turn_seq;
}
return fields;
}
function matchesTurn(message: UIMessage, turn: UIMessageTurnFields): boolean {
return !turn.turnId || !message.turnId || message.turnId === turn.turnId;
}
/** Find a still-open streamed assistant turn. Closed stream segments stay visible
* as streaming until ``turn_end`` for visual continuity, but they must not
* receive later delta segments. */
function findStreamingAssistantIndex(
prev: UIMessage[],
closedStreamIds: ReadonlySet<string>,
turn: UIMessageTurnFields = {},
): number | null {
for (let i = prev.length - 1; i >= 0; i -= 1) {
const m = prev[i];
if (m.kind === "trace") continue;
if (m.role === "assistant" && m.isStreaming && !closedStreamIds.has(m.id)) return i;
if (
m.role === "assistant"
&& m.isStreaming
&& !closedStreamIds.has(m.id)
&& matchesTurn(m, turn)
) return i;
if (m.role === "user") break;
}
return null;
@@ -69,6 +98,7 @@ function attachReasoningChunk(
segments?: {
ensure: () => string;
},
turn: UIMessageTurnFields = {},
): UIMessage[] {
for (let i = prev.length - 1; i >= 0; i -= 1) {
const candidate = prev[i];
@@ -80,6 +110,7 @@ function attachReasoningChunk(
// that produced those tool calls.
if (candidate.kind === "trace") break;
if (candidate.role !== "assistant") continue;
if (!matchesTurn(candidate, turn)) break;
const activitySegmentId = candidate.activitySegmentId ?? segments?.ensure();
const hasAnswer = candidate.content.length > 0;
if (hasAnswer) break;
@@ -93,6 +124,7 @@ function attachReasoningChunk(
reasoning: (candidate.reasoning ?? "") + chunk,
reasoningStreaming: true,
...(activitySegmentId ? { activitySegmentId } : {}),
...turn,
};
return [...prev.slice(0, i), merged, ...prev.slice(i + 1)];
}
@@ -109,6 +141,7 @@ function attachReasoningChunk(
reasoning: chunk,
reasoningStreaming: true,
...(activitySegmentId ? { activitySegmentId } : {}),
...turn,
createdAt: Date.now(),
},
];
@@ -122,12 +155,16 @@ function attachReasoningChunk(
* the model already produced an answer in a previous turn, so the new
* delta belongs in a fresh row.
*/
function findActiveAssistantPlaceholderIndex(prev: UIMessage[]): number | null {
function findActiveAssistantPlaceholderIndex(
prev: UIMessage[],
turn: UIMessageTurnFields = {},
): number | null {
const last = prev[prev.length - 1];
if (!last) return null;
if (last.role !== "assistant" || last.kind === "trace") return null;
if (last.content.length > 0) return null;
if (!last.isStreaming) return null;
if (!matchesTurn(last, turn)) return null;
return prev.length - 1;
}
@@ -187,10 +224,18 @@ function pruneReasoningOnlyPlaceholders(prev: UIMessage[]): UIMessage[] {
});
}
function stampLastAssistantLatency(prev: UIMessage[], latencyMs: number): UIMessage[] {
function stampLastAssistantLatency(
prev: UIMessage[],
latencyMs: number,
turnId?: string,
): UIMessage[] {
for (let i = prev.length - 1; i >= 0; i -= 1) {
const m = prev[i];
if (m.role === "assistant" && m.kind !== "trace") {
if (
m.role === "assistant"
&& m.kind !== "trace"
&& (!turnId || !m.turnId || m.turnId === turnId)
) {
const merged: UIMessage = { ...m, latencyMs, isStreaming: false };
return [...prev.slice(0, i), merged, ...prev.slice(i + 1)];
}
@@ -203,7 +248,7 @@ function absorbCompleteAssistantMessage(
message: Omit<UIMessage, "id" | "role" | "createdAt">,
): UIMessage[] {
const last = prev[prev.length - 1];
if (!last || !isReasoningOnlyPlaceholder(last)) {
if (!last || !isReasoningOnlyPlaceholder(last) || !matchesTurn(last, message)) {
return [
...prev,
{
@@ -482,7 +527,10 @@ export function useNanobotStream(
return !!closedStreamId;
}, []);
const resolveActiveAssistantIndex = useCallback((prev: UIMessage[]): number | null => {
const resolveActiveAssistantIndex = useCallback((
prev: UIMessage[],
turn: UIMessageTurnFields = {},
): number | null => {
const cursor = activeAssistantRef.current;
if (!cursor) return null;
const indexed = prev[cursor.index];
@@ -491,6 +539,7 @@ export function useNanobotStream(
&& indexed.role === "assistant"
&& indexed.kind !== "trace"
&& indexed.isStreaming
&& matchesTurn(indexed, turn)
) {
return cursor.index;
}
@@ -500,7 +549,12 @@ export function useNanobotStream(
return null;
}
const found = prev[idx];
if (found.role !== "assistant" || found.kind === "trace" || !found.isStreaming) {
if (
found.role !== "assistant"
|| found.kind === "trace"
|| !found.isStreaming
|| !matchesTurn(found, turn)
) {
activeAssistantRef.current = null;
return null;
}
@@ -509,15 +563,15 @@ export function useNanobotStream(
}, []);
const appendAnswerChunk = useCallback(
(prev: UIMessage[], chunk: string): UIMessage[] => {
(prev: UIMessage[], chunk: string, turn: UIMessageTurnFields = {}): UIMessage[] => {
let next = prev;
let targetIndex = resolveActiveAssistantIndex(next);
let targetIndex = resolveActiveAssistantIndex(next, turn);
if (targetIndex === null) {
targetIndex = findActiveAssistantPlaceholderIndex(next);
targetIndex = findActiveAssistantPlaceholderIndex(next, turn);
}
if (targetIndex === null) {
targetIndex = findStreamingAssistantIndex(next, closedAssistantStreamIdsRef.current);
targetIndex = findStreamingAssistantIndex(next, closedAssistantStreamIdsRef.current, turn);
}
if (targetIndex === null) {
const id = crypto.randomUUID();
@@ -539,6 +593,7 @@ export function useNanobotStream(
...target,
content: target.content + chunk,
isStreaming: true,
...turn,
};
closedAssistantStreamIdsRef.current.delete(merged.id);
activeAssistantRef.current = { id: merged.id, index: targetIndex };
@@ -551,20 +606,17 @@ export function useNanobotStream(
const applyPendingStreamEvents = useCallback(
(prev: UIMessage[], events: PendingStreamEvent[]): UIMessage[] => {
let next = prev;
for (let i = 0; i < events.length;) {
const kind = events[i].kind;
let text = "";
while (i < events.length && events[i].kind === kind) {
text += events[i].text;
i += 1;
}
if (kind === "delta") {
next = appendAnswerChunk(next, text);
for (const event of events) {
if (event.kind === "delta") {
next = appendAnswerChunk(next, event.text, event.turn);
} else {
if (closeActiveAssistantStream()) clearActivitySegment();
next = attachReasoningChunk(next, text, {
ensure: ensureActivitySegmentId,
});
next = attachReasoningChunk(
next,
event.text,
{ ensure: ensureActivitySegmentId },
event.turn,
);
}
}
return next;
@@ -575,6 +627,7 @@ export function useNanobotStream(
const flushPendingStreamEvents = useCallback((options?: {
closeAnswerSegment?: boolean;
finalAnswerText?: string;
turn?: UIMessageTurnFields;
}) => {
if (streamFrameRef.current !== null) {
window.cancelAnimationFrame(streamFrameRef.current);
@@ -582,6 +635,7 @@ export function useNanobotStream(
}
const events = pendingStreamEventsRef.current;
const finalAnswerText = options?.finalAnswerText;
const turn = options?.turn ?? {};
if (events.length === 0 && finalAnswerText === undefined) {
if (options?.closeAnswerSegment) closeActiveAssistantStream();
return;
@@ -591,14 +645,15 @@ export function useNanobotStream(
let next = events.length > 0 ? applyPendingStreamEvents(prev, events) : prev;
if (finalAnswerText !== undefined) {
const targetIndex =
resolveActiveAssistantIndex(next)
?? findStreamingAssistantIndex(next, closedAssistantStreamIdsRef.current);
resolveActiveAssistantIndex(next, turn)
?? findStreamingAssistantIndex(next, closedAssistantStreamIdsRef.current, turn);
if (targetIndex !== null) {
const target = next[targetIndex];
next = replaceMessageAt(next, targetIndex, {
...target,
content: finalAnswerText,
isStreaming: true,
...turn,
});
} else {
const id = crypto.randomUUID();
@@ -610,6 +665,7 @@ export function useNanobotStream(
role: "assistant",
content: finalAnswerText,
isStreaming: true,
...turn,
createdAt: Date.now(),
},
];
@@ -679,7 +735,11 @@ export function useNanobotStream(
if (!chunk) return;
clearActivitySegment();
setIsStreaming(true);
pendingStreamEventsRef.current.push({ kind: "delta", text: chunk });
pendingStreamEventsRef.current.push({
kind: "delta",
text: chunk,
turn: turnFieldsFromEvent(ev, "answer"),
});
schedulePendingStreamFlush();
return;
}
@@ -690,7 +750,11 @@ export function useNanobotStream(
if (!chunk) return;
if (fileEditSegmentRef.current) clearActivitySegment();
setIsStreaming(true);
pendingStreamEventsRef.current.push({ kind: "reasoning", text: chunk });
pendingStreamEventsRef.current.push({
kind: "reasoning",
text: chunk,
turn: turnFieldsFromEvent(ev, "reasoning"),
});
schedulePendingStreamFlush();
return;
}
@@ -699,6 +763,7 @@ export function useNanobotStream(
flushPendingStreamEvents({
closeAnswerSegment: true,
...(typeof ev.text === "string" ? { finalAnswerText: ev.text } : {}),
turn: turnFieldsFromEvent(ev, "answer"),
});
if (suppressStreamUntilTurnEndRef.current) return;
// stream_end only means the text segment finished — the model may
@@ -751,7 +816,11 @@ export function useNanobotStream(
let finalized = prev.map((m) => (m.isStreaming ? { ...m, isStreaming: false } : m));
finalized = pruneReasoningOnlyPlaceholders(finalized);
if (typeof ev.latency_ms === "number" && ev.latency_ms >= 0) {
finalized = stampLastAssistantLatency(finalized, Math.round(ev.latency_ms));
finalized = stampLastAssistantLatency(
finalized,
Math.round(ev.latency_ms),
ev.turn_id,
);
}
buffer.current = null;
activeAssistantRef.current = null;
@@ -778,9 +847,12 @@ export function useNanobotStream(
const line = ev.text;
if (!line) return;
if (fileEditSegmentRef.current) clearActivitySegment();
setMessages((prev) => closeReasoningStream(attachReasoningChunk(prev, line, {
ensure: ensureActivitySegmentId,
})));
setMessages((prev) => closeReasoningStream(attachReasoningChunk(
prev,
line,
{ ensure: ensureActivitySegmentId },
turnFieldsFromEvent(ev, "reasoning"),
)));
return;
}
// Intermediate agent breadcrumbs (tool-call hints, raw progress).
@@ -788,6 +860,7 @@ export function useNanobotStream(
// so a sequence of calls collapses into one compact trace group.
if (ev.kind === "tool_hint" || ev.kind === "progress") {
const structuredEvents = normalizeToolProgressEvents(ev.tool_events);
const turn = turnFieldsFromEvent(ev, "activity");
setMessages((prev) => {
const segmentId = ensureActivitySegmentId();
const base = prev;
@@ -826,6 +899,7 @@ export function useNanobotStream(
? mergeToolProgressEvents(last.toolEvents, visibleStructuredEvents)
: last.toolEvents,
activitySegmentId: last.activitySegmentId ?? segmentId,
...turn,
};
return [...base.slice(0, -1), merged];
}
@@ -839,6 +913,7 @@ export function useNanobotStream(
traces: lines,
...(visibleStructuredEvents.length ? { toolEvents: visibleStructuredEvents } : {}),
activitySegmentId: segmentId,
...turn,
createdAt: Date.now(),
},
];
@@ -870,6 +945,8 @@ export function useNanobotStream(
content,
...(hasMedia ? { media } : {}),
...(lat !== undefined ? { latencyMs: lat } : {}),
...(ev.source ? { source: ev.source } : {}),
...turnFieldsFromEvent(ev, "answer"),
});
});
if (hasMedia) {
@@ -882,6 +959,7 @@ export function useNanobotStream(
if (edits.length === 0) return;
const normalized = mergeFileEdits(undefined, edits);
if (normalized.length === 0) return;
const turn = turnFieldsFromEvent(ev, "activity");
const opensFileEditPhase = normalized.some(
(edit) => edit.status === "editing" || edit.phase === "start",
);
@@ -903,6 +981,7 @@ export function useNanobotStream(
...cleanedTarget,
fileEdits: mergeFileEdits(cleanedTarget.fileEdits, normalized),
activitySegmentId: segmentId,
...turn,
};
return replaceMessageAt(base, targetIndex, merged);
}
@@ -918,6 +997,7 @@ export function useNanobotStream(
traces: [],
fileEdits: normalized,
activitySegmentId: segmentId,
...turn,
createdAt: Date.now(),
},
];
@@ -962,6 +1042,7 @@ export function useNanobotStream(
if (!hasImages && !content.trim()) return;
flushPendingStreamEvents();
const turnId = crypto.randomUUID();
const previews = hasImages ? images!.map((i) => i.preview) : undefined;
setMessages((prev) => {
buffer.current = null;
@@ -974,6 +1055,9 @@ export function useNanobotStream(
id: crypto.randomUUID(),
role: "user",
content,
turnId,
turnPhase: "user",
turnSeq: 0,
createdAt: Date.now(),
...(previews ? { images: previews } : {}),
...(options?.cliApps?.length ? { cliApps: options.cliApps } : {}),
@@ -985,11 +1069,7 @@ export function useNanobotStream(
// right away, before the first delta arrives from the server.
setIsStreaming(true);
const wireMedia = hasImages ? images!.map((i) => i.media) : undefined;
if (options) {
client.sendMessage(chatId, content, wireMedia, options);
} else {
client.sendMessage(chatId, content, wireMedia);
}
client.sendMessage(chatId, content, wireMedia, { ...options, turnId });
},
[chatId, clearActivitySegment, client, flushPendingStreamEvents],
);
@@ -0,0 +1,61 @@
import { useEffect, useState } from "react";
import { fetchSessionAutomations } from "@/lib/api";
import type { SessionAutomationJob } from "@/lib/types";
const AUTOMATIONS_REFRESH_MS = 3000;
export function useSessionAutomationJobs(open: boolean, token: string, sessionKey: string) {
const [jobs, setJobs] = useState<SessionAutomationJob[]>([]);
const [loading, setLoading] = useState(false);
const [loadFailed, setLoadFailed] = useState(false);
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (!open) return;
let cancelled = false;
let loadedOnce = false;
const refresh = async (showLoading = false) => {
if (showLoading) {
setLoading(true);
setLoadFailed(false);
setJobs([]);
}
try {
const next = await fetchSessionAutomations(token, sessionKey);
if (cancelled) return;
setJobs(next.jobs);
setLoadFailed(false);
loadedOnce = true;
} catch {
if (!cancelled && !loadedOnce) setLoadFailed(true);
} finally {
if (!cancelled && showLoading) setLoading(false);
}
};
void refresh(true);
const refreshId = window.setInterval(() => void refresh(false), AUTOMATIONS_REFRESH_MS);
const refreshOnFocus = () => {
if (document.visibilityState !== "hidden") void refresh(false);
};
window.addEventListener("focus", refreshOnFocus);
document.addEventListener("visibilitychange", refreshOnFocus);
return () => {
cancelled = true;
window.clearInterval(refreshId);
window.removeEventListener("focus", refreshOnFocus);
document.removeEventListener("visibilitychange", refreshOnFocus);
};
}, [open, sessionKey, token]);
useEffect(() => {
if (!open) return;
setNow(Date.now());
const tickId = window.setInterval(() => setNow(Date.now()), 1000);
return () => window.clearInterval(tickId);
}, [open]);
return { jobs, loading, loadFailed, now };
}
+20
View File
@@ -0,0 +1,20 @@
import { useEffect, useState } from "react";
import { fetchSkills } from "@/lib/api";
import type { SkillSummary } from "@/lib/types";
export function useSkills(token: string): SkillSummary[] {
const [skills, setSkills] = useState<SkillSummary[]>([]);
useEffect(() => {
let cancelled = false;
fetchSkills(token)
.then(({ skills: nextSkills }) => !cancelled && setSkills(nextSkills))
.catch(() => !cancelled && setSkills([]));
return () => {
cancelled = true;
};
}, [token]);
return skills;
}
+106 -4
View File
@@ -54,7 +54,10 @@
"label": "Language",
"ariaLabel": "Change language"
},
"apps": "Apps"
"apps": "Apps",
"skills": {
"title": "Skills"
}
},
"settings": {
"backToChat": "Back to chat",
@@ -75,7 +78,8 @@
"mcp": "MCP",
"runtime": "System",
"advanced": "Security",
"apps": "Apps"
"apps": "Apps",
"skills": "Skills"
},
"sections": {
"interface": "Interface",
@@ -295,6 +299,9 @@
"disabled": "Disabled",
"restartPending": "Restart pending",
"ready": "Ready",
"privateEngine": "Private engine",
"unixSocket": "Unix socket",
"defaultWorkspace": "Default workspace",
"comfortable": "Comfortable",
"compact": "Compact",
"auto": "Auto",
@@ -386,6 +393,31 @@
"imageGeneration": "Image generation",
"workspace": "Workspace"
},
"usage": {
"title": "Token activity",
"shortTitle": "Token Usage",
"subtitle": "Provider-reported usage over the last 12 months.",
"empty": "Token activity will appear after new model replies.",
"totalTokens": "Total tokens",
"peakTokens": "Peak tokens",
"thirtyDayTokens": "30-day tokens",
"currentStreak": "Current streak",
"longestStreak": "Longest streak",
"daysValue": "{{count}}d",
"last30": "30 days",
"activeDays": "Active days",
"requests": "Requests",
"estimated": "estimated",
"includesEstimates": "includes estimates",
"cellTitle": "{{date}}: {{tokens}} tokens, {{requests}} requests",
"sources": {
"user": "Chat",
"api": "API",
"cron": "Automations",
"dream": "Memory",
"system": "System"
}
},
"providers": {
"searchPlaceholder": "Search providers",
"noMatches": "No providers match this search.",
@@ -427,6 +459,33 @@
"signInBeforeSaving": "Sign in before saving this OAuth provider as the active model provider.",
"signedIn": "Signed in",
"notSignedIn": "Not signed in"
},
"skills": {
"description": "Review the instruction skills this agent can load during a conversation.",
"caption": "{{available}} available · {{total}} total",
"featured": "Agent skills",
"empty": "No skills are available.",
"sourceWorkspace": "Custom",
"sourceBuiltin": "Built-in",
"statusAvailable": "Available",
"statusUnavailable": "Unavailable",
"unavailableReason": "Missing: {{reason}}",
"openDetails": "Open details for {{name}}",
"loadingDetail": "Loading skill details...",
"loadFailed": "Could not load skill details.",
"descriptionTitle": "Description",
"source": "Source",
"status": "Status",
"requirements": "Requirements",
"noRequirements": "No explicit requirements.",
"commands": "Commands",
"environment": "Environment variables",
"missingCommands": "Missing CLI",
"missingEnvironment": "Missing ENV",
"unavailableReasonLabel": "Unavailable reason",
"rawInstructions": "Raw SKILL.md",
"rawInstructionsEmpty": "No raw instructions.",
"detailDescription": "Details for {{name}}."
}
},
"chat": {
@@ -548,7 +607,30 @@
"toggleSidebar": "Toggle sidebar",
"newChat": "Start a new chat",
"toggleTheme": "Toggle theme from header",
"settings": "Open settings"
"settings": "Open settings",
"sessionInfo": "Session details"
},
"sessionInfo": {
"title": "Session",
"untitled": "Untitled chat",
"automations": "Automations",
"count": "{{count}}",
"loading": "Loading automations...",
"loadFailed": "Could not load automations.",
"empty": "No automations in this session yet.",
"disabled": "Off",
"schedule": {
"at": "{{time}}",
"every": "Every {{duration}}",
"cron": "Cron {{expr}}",
"cronWithTz": "Cron {{expr}} · {{tz}}",
"unknown": "Custom schedule"
},
"next": {
"label": "{{time}}",
"disabled": "Paused",
"none": "No next run"
}
},
"composer": {
"placeholderThread": "Type your message…",
@@ -565,6 +647,8 @@
"goalStateCloseAria": "Close goal",
"send": "Send message",
"stop": "Stop response",
"modelNotConfigured": "Model not configured",
"configureModel": "Configure model",
"queued": {
"label": "Queued guidance",
"guide": "Guide",
@@ -691,7 +775,14 @@
}
},
"scrollToBottom": "Scroll to bottom",
"loadEarlier": "Load earlier messages"
"loadEarlier": "Load earlier messages",
"promptNavigator": {
"open": "Open prompt navigator",
"title": "Prompts",
"search": "Search prompts",
"noResults": "No matching prompts.",
"jumpTo": "Jump to prompt: {{label}}"
}
},
"message": {
"streaming": "streaming",
@@ -722,6 +813,8 @@
"cliRunRan": "Used",
"cliRunFailed": "Failed",
"imageAttachment": "Image attachment",
"automationSourceFallback": "Automation",
"automationTriggered": "Triggered automatically",
"copyReply": "Copy reply",
"copiedReply": "Copied reply",
"turnLatencyTitle": "Response time (end-to-end)"
@@ -733,6 +826,15 @@
"next": "Next image",
"close": "Close image preview"
},
"filePreview": {
"aria": "File preview",
"close": "Close file preview",
"loading": "Loading preview...",
"failed": "Could not preview this file.",
"routeMissing": "File preview needs the latest gateway. Restart nanobot gateway and try again.",
"resize": "Resize file preview",
"truncated": "Preview is truncated because this file is large."
},
"code": {
"fallbackLanguage": "code",
"copyAria": "Copy code",
+107 -5
View File
@@ -54,7 +54,10 @@
"label": "Idioma",
"ariaLabel": "Cambiar idioma"
},
"apps": "Apps"
"apps": "Apps",
"skills": {
"title": "Habilidades"
}
},
"settings": {
"backToChat": "Volver al chat",
@@ -75,7 +78,8 @@
"advanced": "Seguridad",
"cliApps": "Apps CLI",
"mcp": "MCP",
"apps": "Aplicaciones"
"apps": "Aplicaciones",
"skills": "Habilidades"
},
"sections": {
"interface": "Interfaz",
@@ -187,6 +191,9 @@
"disabled": "Desactivado",
"restartPending": "Reinicio pendiente",
"ready": "Listo",
"privateEngine": "Motor privado",
"unixSocket": "Socket Unix",
"defaultWorkspace": "Espacio predeterminado",
"comfortable": "Cómodo",
"compact": "Compacto",
"auto": "Automático",
@@ -278,6 +285,31 @@
"imageGeneration": "Generación de imágenes",
"workspace": "Espacio de trabajo"
},
"usage": {
"title": "Actividad de tokens",
"shortTitle": "Token Usage",
"subtitle": "Uso reportado por el proveedor durante los últimos 12 meses.",
"empty": "La actividad de tokens aparecerá después de nuevas respuestas del modelo.",
"totalTokens": "Tokens totales",
"peakTokens": "Pico de tokens",
"thirtyDayTokens": "Tokens en 30 días",
"currentStreak": "Racha actual",
"longestStreak": "Racha más larga",
"daysValue": "{{count}} d",
"last30": "30 días",
"activeDays": "Días activos",
"requests": "Solicitudes",
"estimated": "estimado",
"includesEstimates": "incluye estimaciones",
"cellTitle": "{{date}}: {{tokens}} tokens, {{requests}} solicitudes",
"sources": {
"user": "Chat",
"api": "API",
"cron": "Automatizaciones",
"dream": "Memoria",
"system": "Sistema"
}
},
"providers": {
"searchPlaceholder": "Buscar proveedores",
"noMatches": "Ningún proveedor coincide con esta búsqueda.",
@@ -427,6 +459,33 @@
"signInBeforeSaving": "Inicia sesión antes de guardar este proveedor OAuth como proveedor activo.",
"signedIn": "Sesión iniciada",
"notSignedIn": "Sin sesión"
},
"skills": {
"description": "Revisa las habilidades de instrucciones que este agente puede cargar durante una conversación.",
"caption": "{{available}} disponibles · {{total}} en total",
"featured": "Habilidades del agente",
"empty": "No hay habilidades disponibles.",
"sourceWorkspace": "Personalizada",
"sourceBuiltin": "Integradas",
"statusAvailable": "Disponible",
"statusUnavailable": "No disponible",
"unavailableReason": "Falta: {{reason}}",
"openDetails": "Abrir detalles de {{name}}",
"loadingDetail": "Cargando detalles de la habilidad...",
"loadFailed": "No se pudieron cargar los detalles.",
"descriptionTitle": "Descripción",
"source": "Origen",
"status": "Estado",
"requirements": "Requisitos",
"noRequirements": "Sin requisitos explícitos.",
"commands": "Comandos",
"environment": "Variables de entorno",
"missingCommands": "Falta CLI",
"missingEnvironment": "Falta ENV",
"unavailableReasonLabel": "Motivo de indisponibilidad",
"rawInstructions": "SKILL.md original",
"rawInstructionsEmpty": "No hay instrucciones originales.",
"detailDescription": "Detalles de {{name}}."
}
},
"chat": {
@@ -548,7 +607,30 @@
"toggleSidebar": "Mostrar u ocultar la barra lateral",
"newChat": "Iniciar un chat nuevo",
"toggleTheme": "Cambiar tema desde el encabezado",
"settings": "Abrir configuración"
"settings": "Abrir configuración",
"sessionInfo": "Detalles de la sesión"
},
"sessionInfo": {
"title": "Sesión",
"untitled": "Chat sin título",
"automations": "Automatizaciones",
"count": "{{count}}",
"loading": "Cargando automatizaciones...",
"loadFailed": "No se pudieron cargar las automatizaciones.",
"empty": "Esta sesión aún no tiene automatizaciones.",
"disabled": "Desactivado",
"schedule": {
"at": "A las {{time}}",
"every": "Cada {{duration}}",
"cron": "Cron {{expr}}",
"cronWithTz": "Cron {{expr}} · {{tz}}",
"unknown": "Programación personalizada"
},
"next": {
"label": "Siguiente {{time}}",
"disabled": "En pausa",
"none": "Sin próxima ejecución"
}
},
"composer": {
"placeholderThread": "Escribe tu mensaje…",
@@ -565,6 +647,8 @@
"goalStateCloseAria": "Cerrar objetivo",
"send": "Enviar mensaje",
"stop": "Detener respuesta",
"modelNotConfigured": "Modelo no configurado",
"configureModel": "Configurar modelo",
"queued": {
"label": "Guía en cola",
"guide": "Guiar",
@@ -691,7 +775,14 @@
}
},
"scrollToBottom": "Desplazarse al final",
"loadEarlier": "Cargar mensajes anteriores"
"loadEarlier": "Cargar mensajes anteriores",
"promptNavigator": {
"open": "Abrir navegador de prompts",
"title": "Prompts",
"search": "Buscar prompts",
"noResults": "No hay prompts coincidentes.",
"jumpTo": "Ir al prompt: {{label}}"
}
},
"message": {
"streaming": "transmitiendo",
@@ -724,7 +815,9 @@
"cliActivityFailedMany": "Fallaron {{count}} apps CLI",
"cliRunRunning": "Usando",
"cliRunRan": "Usado",
"cliRunFailed": "Falló"
"cliRunFailed": "Falló",
"automationSourceFallback": "Automatización",
"automationTriggered": "Activada automáticamente"
},
"lightbox": {
"title": "Vista previa de imagen",
@@ -733,6 +826,15 @@
"next": "Imagen siguiente",
"close": "Cerrar vista previa"
},
"filePreview": {
"aria": "Vista previa de archivo",
"close": "Cerrar vista previa de archivo",
"loading": "Cargando vista previa...",
"failed": "No se pudo previsualizar este archivo.",
"routeMissing": "La vista previa necesita el gateway más reciente. Reinicia nanobot gateway e inténtalo de nuevo.",
"resize": "Cambiar el tamaño de la vista previa",
"truncated": "La vista previa está truncada porque el archivo es grande."
},
"code": {
"fallbackLanguage": "código",
"copyAria": "Copiar código",
+107 -5
View File
@@ -54,7 +54,10 @@
"label": "Langue",
"ariaLabel": "Changer de langue"
},
"apps": "Apps"
"apps": "Apps",
"skills": {
"title": "Compétences"
}
},
"settings": {
"backToChat": "Retour au chat",
@@ -75,7 +78,8 @@
"advanced": "Sécurité",
"cliApps": "Apps CLI",
"mcp": "MCP",
"apps": "Applications"
"apps": "Applications",
"skills": "Compétences"
},
"sections": {
"interface": "Interface utilisateur",
@@ -187,6 +191,9 @@
"disabled": "Désactivé",
"restartPending": "Redémarrage en attente",
"ready": "Prêt",
"privateEngine": "Moteur privé",
"unixSocket": "Socket Unix",
"defaultWorkspace": "Espace par défaut",
"comfortable": "Confortable",
"compact": "Compacte",
"auto": "Automatique",
@@ -278,6 +285,31 @@
"imageGeneration": "Génération dimages",
"workspace": "Espace de travail"
},
"usage": {
"title": "Activité des tokens",
"shortTitle": "Token Usage",
"subtitle": "Usage signalé par le fournisseur sur les 12 derniers mois.",
"empty": "Lactivité des tokens apparaîtra après les nouvelles réponses du modèle.",
"totalTokens": "Tokens cumulés",
"peakTokens": "Pic de tokens",
"thirtyDayTokens": "Tokens sur 30 jours",
"currentStreak": "Série actuelle",
"longestStreak": "Plus longue série",
"daysValue": "{{count}} j",
"last30": "30 jours",
"activeDays": "Jours actifs",
"requests": "Requêtes",
"estimated": "estimé",
"includesEstimates": "inclut des estimations",
"cellTitle": "{{date}} : {{tokens}} tokens, {{requests}} requêtes",
"sources": {
"user": "Chat",
"api": "API",
"cron": "Automatisations",
"dream": "Mémoire",
"system": "Système"
}
},
"providers": {
"searchPlaceholder": "Rechercher des fournisseurs",
"noMatches": "Aucun fournisseur ne correspond.",
@@ -427,6 +459,33 @@
"signInBeforeSaving": "Inicia sesión antes de guardar este proveedor OAuth como proveedor activo.",
"signedIn": "Connecté",
"notSignedIn": "Non connecté"
},
"skills": {
"description": "Consultez les compétences dinstruction que cet agent peut charger pendant une conversation.",
"caption": "{{available}} disponibles · {{total}} au total",
"featured": "Compétences agent",
"empty": "Aucune compétence disponible.",
"sourceWorkspace": "Personnalisée",
"sourceBuiltin": "Intégrée",
"statusAvailable": "Disponible",
"statusUnavailable": "Indisponible",
"unavailableReason": "Manquant : {{reason}}",
"openDetails": "Ouvrir les détails de {{name}}",
"loadingDetail": "Chargement des détails...",
"loadFailed": "Impossible de charger les détails.",
"descriptionTitle": "Description",
"source": "Source",
"status": "Statut",
"requirements": "Prérequis",
"noRequirements": "Aucun prérequis explicite.",
"commands": "Commandes",
"environment": "Variables denvironnement",
"missingCommands": "CLI manquant",
"missingEnvironment": "ENV manquant",
"unavailableReasonLabel": "Raison dindisponibilité",
"rawInstructions": "SKILL.md brut",
"rawInstructionsEmpty": "Aucune instruction brute.",
"detailDescription": "Détails de {{name}}."
}
},
"chat": {
@@ -548,7 +607,30 @@
"toggleSidebar": "Afficher ou masquer la barre latérale",
"newChat": "Démarrer un nouveau chat",
"toggleTheme": "Changer le thème depuis len-tête",
"settings": "Ouvrir les paramètres"
"settings": "Ouvrir les paramètres",
"sessionInfo": "Détails de la session"
},
"sessionInfo": {
"title": "Session",
"untitled": "Chat sans titre",
"automations": "Automatisations",
"count": "{{count}}",
"loading": "Chargement des automatisations...",
"loadFailed": "Impossible de charger les automatisations.",
"empty": "Aucune automatisation dans cette session pour le moment.",
"disabled": "Désactivé",
"schedule": {
"at": "À {{time}}",
"every": "Toutes les {{duration}}",
"cron": "Cron {{expr}}",
"cronWithTz": "Cron {{expr}} · {{tz}}",
"unknown": "Planification personnalisée"
},
"next": {
"label": "Prochaine {{time}}",
"disabled": "En pause",
"none": "Aucune prochaine exécution"
}
},
"composer": {
"placeholderThread": "Saisissez votre message…",
@@ -565,6 +647,8 @@
"goalStateCloseAria": "Fermer lobjectif",
"send": "Envoyer le message",
"stop": "Arrêter la réponse",
"modelNotConfigured": "Modèle non configuré",
"configureModel": "Configurer le modèle",
"queued": {
"label": "Guidage en attente",
"guide": "Guider",
@@ -691,7 +775,14 @@
}
},
"scrollToBottom": "Faire défiler vers le bas",
"loadEarlier": "Charger les messages précédents"
"loadEarlier": "Charger les messages précédents",
"promptNavigator": {
"open": "Ouvrir le navigateur de prompts",
"title": "Prompts",
"search": "Rechercher des prompts",
"noResults": "Aucun prompt correspondant.",
"jumpTo": "Aller au prompt : {{label}}"
}
},
"message": {
"streaming": "en cours de génération",
@@ -724,7 +815,9 @@
"cliActivityFailedMany": "Échec de {{count}} apps CLI",
"cliRunRunning": "Utilisation",
"cliRunRan": "Utilisé",
"cliRunFailed": "Échec"
"cliRunFailed": "Échec",
"automationSourceFallback": "Automatisation",
"automationTriggered": "Déclenché automatiquement"
},
"lightbox": {
"title": "Aperçu de limage",
@@ -733,6 +826,15 @@
"next": "Image suivante",
"close": "Fermer laperçu"
},
"filePreview": {
"aria": "Aperçu du fichier",
"close": "Fermer laperçu du fichier",
"loading": "Chargement de laperçu...",
"failed": "Impossible de prévisualiser ce fichier.",
"routeMissing": "Laperçu du fichier nécessite le dernier gateway. Redémarrez nanobot gateway puis réessayez.",
"resize": "Redimensionner laperçu du fichier",
"truncated": "Laperçu est tronqué car le fichier est volumineux."
},
"code": {
"fallbackLanguage": "code",
"copyAria": "Copier le code",
+107 -5
View File
@@ -54,7 +54,10 @@
"label": "Bahasa",
"ariaLabel": "Ganti bahasa"
},
"apps": "Aplikasi"
"apps": "Aplikasi",
"skills": {
"title": "Skill"
}
},
"settings": {
"backToChat": "Kembali ke chat",
@@ -75,7 +78,8 @@
"advanced": "Keamanan",
"cliApps": "Aplikasi CLI",
"mcp": "MCP",
"apps": "Aplikasi"
"apps": "Aplikasi",
"skills": "Skill"
},
"sections": {
"interface": "Antarmuka",
@@ -187,6 +191,9 @@
"disabled": "Nonaktif",
"restartPending": "Menunggu mulai ulang",
"ready": "Siap",
"privateEngine": "Mesin privat",
"unixSocket": "Soket Unix",
"defaultWorkspace": "Workspace default",
"comfortable": "Nyaman",
"compact": "Ringkas",
"auto": "Otomatis",
@@ -278,6 +285,31 @@
"imageGeneration": "Pembuatan gambar",
"workspace": "Ruang kerja"
},
"usage": {
"title": "Aktivitas token",
"shortTitle": "Token Usage",
"subtitle": "Penggunaan yang dilaporkan penyedia selama 12 bulan terakhir.",
"empty": "Aktivitas token akan muncul setelah balasan model baru.",
"totalTokens": "Total token",
"peakTokens": "Puncak token",
"thirtyDayTokens": "Token 30 hari",
"currentStreak": "Rentetan saat ini",
"longestStreak": "Rentetan terpanjang",
"daysValue": "{{count}} h",
"last30": "30 hari",
"activeDays": "Hari aktif",
"requests": "Permintaan",
"estimated": "perkiraan",
"includesEstimates": "termasuk perkiraan",
"cellTitle": "{{date}}: {{tokens}} token, {{requests}} permintaan",
"sources": {
"user": "Chat",
"api": "API",
"cron": "Otomasi",
"dream": "Memori",
"system": "Sistem"
}
},
"providers": {
"searchPlaceholder": "Cari penyedia",
"noMatches": "Tidak ada penyedia yang cocok.",
@@ -427,6 +459,33 @@
"signInBeforeSaving": "Inicia sesión antes de guardar este proveedor OAuth como proveedor activo.",
"signedIn": "Sudah masuk",
"notSignedIn": "Belum masuk"
},
"skills": {
"description": "Tinjau skill instruksi yang dapat dimuat agent ini selama percakapan.",
"caption": "{{available}} tersedia · {{total}} total",
"featured": "Skill agent",
"empty": "Tidak ada skill yang tersedia.",
"sourceWorkspace": "Kustom",
"sourceBuiltin": "Bawaan",
"statusAvailable": "Tersedia",
"statusUnavailable": "Tidak tersedia",
"unavailableReason": "Kurang: {{reason}}",
"openDetails": "Buka detail {{name}}",
"loadingDetail": "Memuat detail skill...",
"loadFailed": "Tidak dapat memuat detail skill.",
"descriptionTitle": "Deskripsi",
"source": "Sumber",
"status": "Status",
"requirements": "Kebutuhan",
"noRequirements": "Tidak ada kebutuhan eksplisit.",
"commands": "Perintah",
"environment": "Variabel lingkungan",
"missingCommands": "CLI hilang",
"missingEnvironment": "ENV hilang",
"unavailableReasonLabel": "Alasan tidak tersedia",
"rawInstructions": "SKILL.md mentah",
"rawInstructionsEmpty": "Tidak ada instruksi mentah.",
"detailDescription": "Detail untuk {{name}}."
}
},
"chat": {
@@ -548,7 +607,30 @@
"toggleSidebar": "Tampilkan atau sembunyikan sidebar",
"newChat": "Mulai chat baru",
"toggleTheme": "Alihkan tema dari header",
"settings": "Buka pengaturan"
"settings": "Buka pengaturan",
"sessionInfo": "Detail sesi"
},
"sessionInfo": {
"title": "Sesi",
"untitled": "Chat tanpa judul",
"automations": "Otomasi",
"count": "{{count}}",
"loading": "Memuat otomasi...",
"loadFailed": "Tidak dapat memuat otomasi.",
"empty": "Belum ada otomasi dalam sesi ini.",
"disabled": "Mati",
"schedule": {
"at": "Pada {{time}}",
"every": "Setiap {{duration}}",
"cron": "Cron {{expr}}",
"cronWithTz": "Cron {{expr}} · {{tz}}",
"unknown": "Jadwal khusus"
},
"next": {
"label": "Berikutnya {{time}}",
"disabled": "Dijeda",
"none": "Tidak ada jadwal berikutnya"
}
},
"composer": {
"placeholderThread": "Ketik pesan Anda…",
@@ -565,6 +647,8 @@
"goalStateCloseAria": "Tutup tujuan",
"send": "Kirim pesan",
"stop": "Hentikan respons",
"modelNotConfigured": "Model belum dikonfigurasi",
"configureModel": "Konfigurasi model",
"queued": {
"label": "Panduan antrean",
"guide": "Pandu",
@@ -691,7 +775,14 @@
}
},
"scrollToBottom": "Gulir ke bawah",
"loadEarlier": "Muat pesan sebelumnya"
"loadEarlier": "Muat pesan sebelumnya",
"promptNavigator": {
"open": "Buka navigator prompt",
"title": "Prompt",
"search": "Cari prompt",
"noResults": "Tidak ada prompt yang cocok.",
"jumpTo": "Lompat ke prompt: {{label}}"
}
},
"message": {
"streaming": "sedang mengalir",
@@ -724,7 +815,9 @@
"cliActivityFailedMany": "{{count}} aplikasi CLI gagal",
"cliRunRunning": "Menggunakan",
"cliRunRan": "Digunakan",
"cliRunFailed": "Gagal"
"cliRunFailed": "Gagal",
"automationSourceFallback": "Otomatisasi",
"automationTriggered": "Dipicu otomatis"
},
"lightbox": {
"title": "Pratinjau gambar",
@@ -733,6 +826,15 @@
"next": "Gambar berikutnya",
"close": "Tutup pratinjau"
},
"filePreview": {
"aria": "Pratinjau file",
"close": "Tutup pratinjau file",
"loading": "Memuat pratinjau...",
"failed": "Tidak dapat mempratinjau file ini.",
"routeMissing": "Pratinjau file memerlukan gateway terbaru. Mulai ulang nanobot gateway lalu coba lagi.",
"resize": "Ubah ukuran pratinjau file",
"truncated": "Pratinjau dipotong karena file ini besar."
},
"code": {
"fallbackLanguage": "kode",
"copyAria": "Salin kode",
+107 -5
View File
@@ -54,7 +54,10 @@
"label": "言語",
"ariaLabel": "言語を変更"
},
"apps": "アプリ"
"apps": "アプリ",
"skills": {
"title": "スキル"
}
},
"settings": {
"backToChat": "チャットに戻る",
@@ -75,7 +78,8 @@
"advanced": "セキュリティ",
"cliApps": "CLI アプリ",
"mcp": "MCP",
"apps": "アプリ"
"apps": "アプリ",
"skills": "スキル"
},
"sections": {
"interface": "インターフェース",
@@ -187,6 +191,9 @@
"disabled": "無効",
"restartPending": "再起動待ち",
"ready": "準備完了",
"privateEngine": "プライベートエンジン",
"unixSocket": "Unix ソケット",
"defaultWorkspace": "デフォルトワークスペース",
"comfortable": "標準",
"compact": "コンパクト",
"auto": "自動",
@@ -278,6 +285,31 @@
"imageGeneration": "画像生成",
"workspace": "ワークスペース"
},
"usage": {
"title": "Token アクティビティ",
"shortTitle": "Token Usage",
"subtitle": "直近 12 か月にプロバイダーが報告した使用量。",
"empty": "新しいモデル返信の後に token アクティビティが表示されます。",
"totalTokens": "累計 Token 数",
"peakTokens": "ピーク Token 数",
"thirtyDayTokens": "30 日 Token 数",
"currentStreak": "現在の連続日数",
"longestStreak": "最長連続日数",
"daysValue": "{{count}} 日",
"last30": "30 日",
"activeDays": "アクティブ日数",
"requests": "リクエスト",
"estimated": "推定",
"includesEstimates": "推定を含む",
"cellTitle": "{{date}}: {{tokens}} tokens, {{requests}} 件のリクエスト",
"sources": {
"user": "チャット",
"api": "API",
"cron": "自動タスク",
"dream": "メモリ整理",
"system": "システム"
}
},
"providers": {
"searchPlaceholder": "プロバイダーを検索",
"noMatches": "一致するプロバイダーはありません。",
@@ -427,6 +459,33 @@
"signInBeforeSaving": "この OAuth プロバイダーをアクティブなモデルプロバイダーとして保存する前にサインインしてください。",
"signedIn": "サインイン済み",
"notSignedIn": "未サインイン"
},
"skills": {
"description": "このエージェントが会話中に読み込める指示スキルを確認します。",
"caption": "{{available}} 利用可能 · 合計 {{total}}",
"featured": "エージェントスキル",
"empty": "利用可能なスキルはありません。",
"sourceWorkspace": "カスタム",
"sourceBuiltin": "組み込み",
"statusAvailable": "利用可能",
"statusUnavailable": "利用不可",
"unavailableReason": "不足: {{reason}}",
"openDetails": "{{name}} の詳細を開く",
"loadingDetail": "スキル詳細を読み込み中...",
"loadFailed": "スキル詳細を読み込めませんでした。",
"descriptionTitle": "説明",
"source": "ソース",
"status": "状態",
"requirements": "要件",
"noRequirements": "明示的な要件はありません。",
"commands": "コマンド",
"environment": "環境変数",
"missingCommands": "CLI 不足",
"missingEnvironment": "ENV 不足",
"unavailableReasonLabel": "利用不可の理由",
"rawInstructions": "元の SKILL.md",
"rawInstructionsEmpty": "元の説明はありません。",
"detailDescription": "{{name}} の詳細。"
}
},
"chat": {
@@ -548,7 +607,30 @@
"toggleSidebar": "サイドバーを切り替える",
"newChat": "新しいチャットを開始",
"toggleTheme": "ヘッダーからテーマを切り替える",
"settings": "設定を開く"
"settings": "設定を開く",
"sessionInfo": "セッション詳細"
},
"sessionInfo": {
"title": "セッション",
"untitled": "無題のチャット",
"automations": "自動タスク",
"count": "{{count}}",
"loading": "自動タスクを読み込み中...",
"loadFailed": "自動タスクを読み込めませんでした。",
"empty": "このセッションにはまだ自動タスクがありません。",
"disabled": "オフ",
"schedule": {
"at": "{{time}}",
"every": "{{duration}}ごと",
"cron": "Cron {{expr}}",
"cronWithTz": "Cron {{expr}} · {{tz}}",
"unknown": "カスタムスケジュール"
},
"next": {
"label": "次回 {{time}}",
"disabled": "一時停止",
"none": "次回実行なし"
}
},
"composer": {
"placeholderThread": "メッセージを入力…",
@@ -565,6 +647,8 @@
"goalStateCloseAria": "目標を閉じる",
"send": "メッセージを送信",
"stop": "応答を停止",
"modelNotConfigured": "モデルが未設定です",
"configureModel": "モデルを設定",
"queued": {
"label": "保留中のガイド",
"guide": "ガイド",
@@ -691,7 +775,14 @@
}
},
"scrollToBottom": "一番下へスクロール",
"loadEarlier": "以前のメッセージを読み込む"
"loadEarlier": "以前のメッセージを読み込む",
"promptNavigator": {
"open": "プロンプトナビゲーターを開く",
"title": "プロンプト",
"search": "プロンプトを検索",
"noResults": "一致するプロンプトがありません。",
"jumpTo": "プロンプトへ移動: {{label}}"
}
},
"message": {
"streaming": "生成中",
@@ -724,7 +815,9 @@
"cliActivityFailedMany": "{{count}} 個の CLI アプリが失敗しました",
"cliRunRunning": "使用中",
"cliRunRan": "使用済み",
"cliRunFailed": "失敗"
"cliRunFailed": "失敗",
"automationSourceFallback": "自動化",
"automationTriggered": "自動実行"
},
"lightbox": {
"title": "画像プレビュー",
@@ -733,6 +826,15 @@
"next": "次の画像",
"close": "プレビューを閉じる"
},
"filePreview": {
"aria": "ファイルプレビュー",
"close": "ファイルプレビューを閉じる",
"loading": "プレビューを読み込み中...",
"failed": "このファイルをプレビューできませんでした。",
"routeMissing": "ファイルプレビューには最新の gateway が必要です。nanobot gateway を再起動してから再試行してください。",
"resize": "ファイルプレビューの幅を変更",
"truncated": "ファイルが大きいため、プレビューは途中まで表示されています。"
},
"code": {
"fallbackLanguage": "コード",
"copyAria": "コードをコピー",
+107 -5
View File
@@ -54,7 +54,10 @@
"label": "언어",
"ariaLabel": "언어 변경"
},
"apps": "앱"
"apps": "앱",
"skills": {
"title": "스킬"
}
},
"settings": {
"backToChat": "채팅으로 돌아가기",
@@ -75,7 +78,8 @@
"advanced": "보안",
"cliApps": "CLI 앱",
"mcp": "MCP",
"apps": "앱"
"apps": "앱",
"skills": "스킬"
},
"sections": {
"interface": "인터페이스",
@@ -187,6 +191,9 @@
"disabled": "비활성화됨",
"restartPending": "재시작 대기",
"ready": "준비됨",
"privateEngine": "비공개 엔진",
"unixSocket": "Unix 소켓",
"defaultWorkspace": "기본 작업 공간",
"comfortable": "편안함",
"compact": "컴팩트",
"auto": "자동",
@@ -278,6 +285,31 @@
"imageGeneration": "이미지 생성",
"workspace": "작업공간"
},
"usage": {
"title": "Token 활동",
"shortTitle": "Token Usage",
"subtitle": "최근 12개월 동안 제공자가 보고한 사용량입니다.",
"empty": "새 모델 응답 이후 token 활동이 표시됩니다.",
"totalTokens": "누적 Token 수",
"peakTokens": "최고 Token 수",
"thirtyDayTokens": "30일 Token 수",
"currentStreak": "현재 연속 일수",
"longestStreak": "최장 연속 일수",
"daysValue": "{{count}}일",
"last30": "30일",
"activeDays": "활성 일수",
"requests": "요청",
"estimated": "추정",
"includesEstimates": "추정 포함",
"cellTitle": "{{date}}: {{tokens}} tokens, 요청 {{requests}}회",
"sources": {
"user": "채팅",
"api": "API",
"cron": "자동화",
"dream": "메모리 정리",
"system": "시스템"
}
},
"providers": {
"searchPlaceholder": "제공자 검색",
"noMatches": "일치하는 제공자가 없습니다.",
@@ -427,6 +459,33 @@
"signInBeforeSaving": "이 OAuth 제공자를 활성 모델 제공자로 저장하기 전에 로그인하세요.",
"signedIn": "로그인됨",
"notSignedIn": "로그인 안 됨"
},
"skills": {
"description": "이 에이전트가 대화 중에 불러올 수 있는 지시 스킬을 확인합니다.",
"caption": "{{available}}개 사용 가능 · 총 {{total}}개",
"featured": "에이전트 스킬",
"empty": "사용 가능한 스킬이 없습니다.",
"sourceWorkspace": "사용자 지정",
"sourceBuiltin": "내장",
"statusAvailable": "사용 가능",
"statusUnavailable": "사용 불가",
"unavailableReason": "누락: {{reason}}",
"openDetails": "{{name}} 상세 열기",
"loadingDetail": "스킬 상세를 불러오는 중...",
"loadFailed": "스킬 상세를 불러올 수 없습니다.",
"descriptionTitle": "설명",
"source": "출처",
"status": "상태",
"requirements": "요구 사항",
"noRequirements": "명시된 요구 사항이 없습니다.",
"commands": "명령",
"environment": "환경 변수",
"missingCommands": "CLI 누락",
"missingEnvironment": "ENV 누락",
"unavailableReasonLabel": "사용 불가 이유",
"rawInstructions": "원본 SKILL.md",
"rawInstructionsEmpty": "원본 지침이 없습니다.",
"detailDescription": "{{name}} 세부 정보."
}
},
"chat": {
@@ -548,7 +607,30 @@
"toggleSidebar": "사이드바 전환",
"newChat": "새 채팅 시작",
"toggleTheme": "헤더에서 테마 전환",
"settings": "설정 열기"
"settings": "설정 열기",
"sessionInfo": "세션 세부 정보"
},
"sessionInfo": {
"title": "세션",
"untitled": "제목 없는 채팅",
"automations": "자동화",
"count": "{{count}}",
"loading": "자동화를 불러오는 중...",
"loadFailed": "자동화를 불러오지 못했습니다.",
"empty": "이 세션에는 아직 자동화가 없습니다.",
"disabled": "꺼짐",
"schedule": {
"at": "{{time}}",
"every": "{{duration}}마다",
"cron": "Cron {{expr}}",
"cronWithTz": "Cron {{expr}} · {{tz}}",
"unknown": "사용자 지정 일정"
},
"next": {
"label": "다음 {{time}}",
"disabled": "일시 중지됨",
"none": "다음 실행 없음"
}
},
"composer": {
"placeholderThread": "메시지를 입력하세요…",
@@ -565,6 +647,8 @@
"goalStateCloseAria": "목표 닫기",
"send": "메시지 보내기",
"stop": "응답 중지",
"modelNotConfigured": "모델이 설정되지 않음",
"configureModel": "모델 설정",
"queued": {
"label": "대기 중인 안내",
"guide": "안내",
@@ -691,7 +775,14 @@
}
},
"scrollToBottom": "맨 아래로 스크롤",
"loadEarlier": "이전 메시지 불러오기"
"loadEarlier": "이전 메시지 불러오기",
"promptNavigator": {
"open": "프롬프트 탐색기 열기",
"title": "프롬프트",
"search": "프롬프트 검색",
"noResults": "일치하는 프롬프트가 없습니다.",
"jumpTo": "프롬프트로 이동: {{label}}"
}
},
"message": {
"streaming": "생성 중",
@@ -724,7 +815,9 @@
"cliActivityFailedMany": "CLI 앱 {{count}}개 실패",
"cliRunRunning": "사용 중",
"cliRunRan": "사용함",
"cliRunFailed": "실패"
"cliRunFailed": "실패",
"automationSourceFallback": "자동화",
"automationTriggered": "자동 실행됨"
},
"lightbox": {
"title": "이미지 미리보기",
@@ -733,6 +826,15 @@
"next": "다음 이미지",
"close": "미리보기 닫기"
},
"filePreview": {
"aria": "파일 미리보기",
"close": "파일 미리보기 닫기",
"loading": "미리보기 로딩 중...",
"failed": "이 파일을 미리 볼 수 없습니다.",
"routeMissing": "파일 미리보기에는 최신 gateway가 필요합니다. nanobot gateway를 다시 시작한 뒤 다시 시도하세요.",
"resize": "파일 미리보기 크기 조절",
"truncated": "파일이 커서 미리보기가 잘렸습니다."
},
"code": {
"fallbackLanguage": "코드",
"copyAria": "코드 복사",
+107 -5
View File
@@ -54,7 +54,10 @@
"label": "Ngôn ngữ",
"ariaLabel": "Đổi ngôn ngữ"
},
"apps": "Ứng dụng"
"apps": "Ứng dụng",
"skills": {
"title": "Kỹ năng"
}
},
"settings": {
"backToChat": "Quay lại chat",
@@ -75,7 +78,8 @@
"advanced": "Bảo mật",
"cliApps": "Ứng dụng CLI",
"mcp": "MCP",
"apps": "Ứng dụng"
"apps": "Ứng dụng",
"skills": "Kỹ năng"
},
"sections": {
"interface": "Giao diện",
@@ -187,6 +191,9 @@
"disabled": "Đã tắt",
"restartPending": "Chờ khởi động lại",
"ready": "Sẵn sàng",
"privateEngine": "Bộ máy riêng",
"unixSocket": "Socket Unix",
"defaultWorkspace": "Workspace mặc định",
"comfortable": "Thoải mái",
"compact": "Gọn",
"auto": "Tự động",
@@ -278,6 +285,31 @@
"imageGeneration": "Tạo hình ảnh",
"workspace": "Không gian làm việc"
},
"usage": {
"title": "Hoạt động token",
"shortTitle": "Token Usage",
"subtitle": "Mức dùng do nhà cung cấp báo cáo trong 12 tháng gần nhất.",
"empty": "Hoạt động token sẽ xuất hiện sau các phản hồi mô hình mới.",
"totalTokens": "Tổng token",
"peakTokens": "Đỉnh token",
"thirtyDayTokens": "Token 30 ngày",
"currentStreak": "Chuỗi hiện tại",
"longestStreak": "Chuỗi dài nhất",
"daysValue": "{{count}} ngày",
"last30": "30 ngày",
"activeDays": "Ngày hoạt động",
"requests": "Yêu cầu",
"estimated": "ước tính",
"includesEstimates": "bao gồm ước tính",
"cellTitle": "{{date}}: {{tokens}} tokens, {{requests}} yêu cầu",
"sources": {
"user": "Trò chuyện",
"api": "API",
"cron": "Tự động hóa",
"dream": "Bộ nhớ",
"system": "Hệ thống"
}
},
"providers": {
"searchPlaceholder": "Tìm nhà cung cấp",
"noMatches": "Không có nhà cung cấp phù hợp.",
@@ -427,6 +459,33 @@
"signInBeforeSaving": "Inicia sesión antes de guardar este proveedor OAuth como proveedor activo.",
"signedIn": "Đã đăng nhập",
"notSignedIn": "Chưa đăng nhập"
},
"skills": {
"description": "Xem các kỹ năng chỉ dẫn mà agent này có thể tải trong cuộc trò chuyện.",
"caption": "{{available}} khả dụng · tổng {{total}}",
"featured": "Kỹ năng agent",
"empty": "Không có kỹ năng nào khả dụng.",
"sourceWorkspace": "Tùy chỉnh",
"sourceBuiltin": "Tích hợp",
"statusAvailable": "Khả dụng",
"statusUnavailable": "Không khả dụng",
"unavailableReason": "Thiếu: {{reason}}",
"openDetails": "Mở chi tiết {{name}}",
"loadingDetail": "Đang tải chi tiết kỹ năng...",
"loadFailed": "Không tải được chi tiết kỹ năng.",
"descriptionTitle": "Mô tả",
"source": "Nguồn",
"status": "Trạng thái",
"requirements": "Yêu cầu",
"noRequirements": "Không có yêu cầu rõ ràng.",
"commands": "Lệnh",
"environment": "Biến môi trường",
"missingCommands": "Thiếu CLI",
"missingEnvironment": "Thiếu ENV",
"unavailableReasonLabel": "Lý do không khả dụng",
"rawInstructions": "SKILL.md gốc",
"rawInstructionsEmpty": "Không có hướng dẫn gốc.",
"detailDescription": "Chi tiết cho {{name}}."
}
},
"chat": {
@@ -548,7 +607,30 @@
"toggleSidebar": "Bật/tắt thanh bên",
"newChat": "Bắt đầu chat mới",
"toggleTheme": "Chuyển chủ đề từ header",
"settings": "Mở cài đặt"
"settings": "Mở cài đặt",
"sessionInfo": "Chi tiết phiên"
},
"sessionInfo": {
"title": "Phiên",
"untitled": "Chat chưa đặt tên",
"automations": "Tự động hóa",
"count": "{{count}}",
"loading": "Đang tải tự động hóa...",
"loadFailed": "Không thể tải tự động hóa.",
"empty": "Phiên này chưa có tự động hóa.",
"disabled": "Tắt",
"schedule": {
"at": "Lúc {{time}}",
"every": "Mỗi {{duration}}",
"cron": "Cron {{expr}}",
"cronWithTz": "Cron {{expr}} · {{tz}}",
"unknown": "Lịch tùy chỉnh"
},
"next": {
"label": "Tiếp theo {{time}}",
"disabled": "Đã tạm dừng",
"none": "Không có lần chạy tiếp theo"
}
},
"composer": {
"placeholderThread": "Nhập tin nhắn…",
@@ -565,6 +647,8 @@
"goalStateCloseAria": "Đóng mục tiêu",
"send": "Gửi tin nhắn",
"stop": "Dừng phản hồi",
"modelNotConfigured": "Chưa cấu hình mô hình",
"configureModel": "Cấu hình mô hình",
"queued": {
"label": "Hướng dẫn đang chờ",
"guide": "Hướng dẫn",
@@ -691,7 +775,14 @@
}
},
"scrollToBottom": "Cuộn xuống cuối",
"loadEarlier": "Tải tin nhắn trước đó"
"loadEarlier": "Tải tin nhắn trước đó",
"promptNavigator": {
"open": "Mở trình điều hướng prompt",
"title": "Prompt",
"search": "Tìm prompt",
"noResults": "Không có prompt phù hợp.",
"jumpTo": "Nhảy tới prompt: {{label}}"
}
},
"message": {
"streaming": "đang truyền",
@@ -724,7 +815,9 @@
"cliActivityFailedMany": "{{count}} ứng dụng CLI thất bại",
"cliRunRunning": "Đang dùng",
"cliRunRan": "Đã dùng",
"cliRunFailed": "Thất bại"
"cliRunFailed": "Thất bại",
"automationSourceFallback": "Tự động hóa",
"automationTriggered": "Tự động kích hoạt"
},
"lightbox": {
"title": "Xem trước ảnh",
@@ -733,6 +826,15 @@
"next": "Ảnh tiếp theo",
"close": "Đóng xem trước"
},
"filePreview": {
"aria": "Xem trước tệp",
"close": "Đóng xem trước tệp",
"loading": "Đang tải bản xem trước...",
"failed": "Không thể xem trước tệp này.",
"routeMissing": "Xem trước tệp cần gateway mới nhất. Hãy khởi động lại nanobot gateway rồi thử lại.",
"resize": "Đổi kích thước bản xem trước tệp",
"truncated": "Bản xem trước bị cắt vì tệp này lớn."
},
"code": {
"fallbackLanguage": "mã",
"copyAria": "Sao chép mã",
+106 -4
View File
@@ -54,7 +54,10 @@
"label": "语言",
"ariaLabel": "切换语言"
},
"apps": "应用"
"apps": "应用",
"skills": {
"title": "技能"
}
},
"settings": {
"backToChat": "返回聊天",
@@ -75,7 +78,8 @@
"mcp": "MCP",
"runtime": "系统",
"advanced": "安全",
"apps": "应用"
"apps": "应用",
"skills": "技能"
},
"sections": {
"interface": "界面",
@@ -295,6 +299,9 @@
"disabled": "已禁用",
"restartPending": "等待重启",
"ready": "就绪",
"privateEngine": "私有引擎",
"unixSocket": "Unix socket",
"defaultWorkspace": "默认工作区",
"comfortable": "舒适",
"compact": "紧凑",
"auto": "自动",
@@ -386,6 +393,31 @@
"imageGeneration": "图片生成",
"workspace": "工作区"
},
"usage": {
"title": "Token 活动",
"shortTitle": "Token Usage",
"subtitle": "最近 12 个月由提供商上报的 token 用量。",
"empty": "新的模型回复产生后,这里会显示 token 活动。",
"totalTokens": "累计 Token 数",
"peakTokens": "峰值 Token 数",
"thirtyDayTokens": "30 天 Token 数",
"currentStreak": "当前连续天数",
"longestStreak": "最长连续天数",
"daysValue": "{{count}} 天",
"last30": "30 天",
"activeDays": "活跃天数",
"requests": "请求数",
"estimated": "估算",
"includesEstimates": "包含估算",
"cellTitle": "{{date}}{{tokens}} tokens{{requests}} 次请求",
"sources": {
"user": "对话",
"api": "API",
"cron": "自动任务",
"dream": "记忆整理",
"system": "系统"
}
},
"providers": {
"searchPlaceholder": "搜索提供商",
"noMatches": "没有匹配的提供商。",
@@ -427,6 +459,33 @@
"signInBeforeSaving": "将此 OAuth 提供商设为当前模型提供商前,请先登录。",
"signedIn": "已登录",
"notSignedIn": "未登录"
},
"skills": {
"description": "查看此 agent 在对话中可以加载的指令技能。",
"caption": "{{available}} 个可用 · 共 {{total}} 个",
"featured": "Agent 技能",
"empty": "暂无可用技能。",
"sourceWorkspace": "自定义",
"sourceBuiltin": "内置",
"statusAvailable": "可用",
"statusUnavailable": "不可用",
"unavailableReason": "缺少:{{reason}}",
"openDetails": "查看 {{name}} 详情",
"loadingDetail": "正在加载技能详情...",
"loadFailed": "无法加载技能详情。",
"descriptionTitle": "完整描述",
"source": "来源",
"status": "状态",
"requirements": "需求",
"noRequirements": "没有显式需求。",
"commands": "命令",
"environment": "环境变量",
"missingCommands": "缺 CLI",
"missingEnvironment": "缺 ENV",
"unavailableReasonLabel": "不可用原因",
"rawInstructions": "原始 SKILL.md",
"rawInstructionsEmpty": "没有原始说明。",
"detailDescription": "{{name}} 的详情。"
}
},
"chat": {
@@ -548,7 +607,30 @@
"toggleSidebar": "切换侧边栏",
"newChat": "从顶部新建对话",
"toggleTheme": "从顶部切换主题",
"settings": "打开设置"
"settings": "打开设置",
"sessionInfo": "会话详情"
},
"sessionInfo": {
"title": "会话",
"untitled": "未命名对话",
"automations": "自动任务",
"count": "{{count}}",
"loading": "正在加载自动任务...",
"loadFailed": "无法加载自动任务。",
"empty": "这个会话暂时没有自动任务。",
"disabled": "已关闭",
"schedule": {
"at": "{{time}}",
"every": "每 {{duration}}",
"cron": "Cron {{expr}}",
"cronWithTz": "Cron {{expr}} · {{tz}}",
"unknown": "自定义计划"
},
"next": {
"label": "下次 {{time}}",
"disabled": "已暂停",
"none": "没有下次运行"
}
},
"composer": {
"placeholderThread": "输入消息…",
@@ -564,6 +646,8 @@
"goalStateSheetTitle": "目标",
"send": "发送消息",
"stop": "停止响应",
"modelNotConfigured": "模型未配置",
"configureModel": "配置模型",
"queued": {
"label": "待引导提示",
"guide": "引导",
@@ -691,7 +775,14 @@
}
},
"scrollToBottom": "滚动到底部",
"loadEarlier": "加载更早消息"
"loadEarlier": "加载更早消息",
"promptNavigator": {
"open": "打开输入导航",
"title": "输入列表",
"search": "搜索输入",
"noResults": "没有匹配的输入。",
"jumpTo": "跳转到输入:{{label}}"
}
},
"message": {
"streaming": "流式输出中",
@@ -722,6 +813,8 @@
"cliRunRan": "已使用",
"cliRunFailed": "失败",
"imageAttachment": "图片附件",
"automationSourceFallback": "自动化",
"automationTriggered": "自动触发",
"copyReply": "复制回复",
"copiedReply": "已复制回复",
"turnLatencyTitle": "本轮耗时(端到端)"
@@ -733,6 +826,15 @@
"next": "下一张",
"close": "关闭预览"
},
"filePreview": {
"aria": "文件预览",
"close": "关闭文件预览",
"loading": "正在加载预览...",
"failed": "无法预览这个文件。",
"routeMissing": "文件预览需要最新的 gateway。请重启 nanobot gateway 后再试。",
"resize": "调整文件预览宽度",
"truncated": "文件较大,当前只显示前半部分预览。"
},
"code": {
"fallbackLanguage": "代码",
"copyAria": "复制代码",
+107 -5
View File
@@ -54,7 +54,10 @@
"label": "語言",
"ariaLabel": "切換語言"
},
"apps": "應用"
"apps": "應用",
"skills": {
"title": "技能"
}
},
"settings": {
"backToChat": "返回聊天",
@@ -75,7 +78,8 @@
"advanced": "安全",
"cliApps": "CLI 應用",
"mcp": "MCP",
"apps": "應用"
"apps": "應用",
"skills": "技能"
},
"sections": {
"interface": "介面",
@@ -187,6 +191,9 @@
"disabled": "已停用",
"restartPending": "等待重啟",
"ready": "就緒",
"privateEngine": "私有引擎",
"unixSocket": "Unix socket",
"defaultWorkspace": "預設工作區",
"comfortable": "舒適",
"compact": "緊湊",
"auto": "自動",
@@ -278,6 +285,31 @@
"imageGeneration": "圖片生成",
"workspace": "工作區"
},
"usage": {
"title": "Token 活動",
"shortTitle": "Token Usage",
"subtitle": "最近 12 個月由供應商回報的 token 用量。",
"empty": "新的模型回覆產生後,這裡會顯示 token 活動。",
"totalTokens": "累計 Token 數",
"peakTokens": "峰值 Token 數",
"thirtyDayTokens": "30 天 Token 數",
"currentStreak": "目前連續天數",
"longestStreak": "最長連續天數",
"daysValue": "{{count}} 天",
"last30": "30 天",
"activeDays": "活躍天數",
"requests": "請求數",
"estimated": "估算",
"includesEstimates": "包含估算",
"cellTitle": "{{date}}{{tokens}} tokens{{requests}} 次請求",
"sources": {
"user": "對話",
"api": "API",
"cron": "自動任務",
"dream": "記憶整理",
"system": "系統"
}
},
"providers": {
"searchPlaceholder": "搜尋供應商",
"noMatches": "沒有符合的供應商。",
@@ -427,6 +459,33 @@
"signInBeforeSaving": "將此 OAuth 供應商設為目前模型供應商前,請先登入。",
"signedIn": "已登入",
"notSignedIn": "未登入"
},
"skills": {
"description": "查看此 agent 在對話中可以載入的指令技能。",
"caption": "{{available}} 個可用 · 共 {{total}} 個",
"featured": "Agent 技能",
"empty": "暫無可用技能。",
"sourceWorkspace": "自訂",
"sourceBuiltin": "內建",
"statusAvailable": "可用",
"statusUnavailable": "不可用",
"unavailableReason": "缺少:{{reason}}",
"openDetails": "查看 {{name}} 詳情",
"loadingDetail": "正在載入技能詳情...",
"loadFailed": "無法載入技能詳情。",
"descriptionTitle": "完整描述",
"source": "來源",
"status": "狀態",
"requirements": "需求",
"noRequirements": "沒有明確需求。",
"commands": "命令",
"environment": "環境變數",
"missingCommands": "缺 CLI",
"missingEnvironment": "缺 ENV",
"unavailableReasonLabel": "不可用原因",
"rawInstructions": "原始 SKILL.md",
"rawInstructionsEmpty": "沒有原始說明。",
"detailDescription": "{{name}} 的詳細資訊。"
}
},
"chat": {
@@ -548,7 +607,30 @@
"toggleSidebar": "切換側邊欄",
"newChat": "開始新對話",
"toggleTheme": "從頂部切換主題",
"settings": "開啟設定"
"settings": "開啟設定",
"sessionInfo": "會話詳情"
},
"sessionInfo": {
"title": "會話",
"untitled": "未命名對話",
"automations": "自動任務",
"count": "{{count}}",
"loading": "正在載入自動任務...",
"loadFailed": "無法載入自動任務。",
"empty": "這個會話暫時沒有自動任務。",
"disabled": "已關閉",
"schedule": {
"at": "{{time}}",
"every": "每 {{duration}}",
"cron": "Cron {{expr}}",
"cronWithTz": "Cron {{expr}} · {{tz}}",
"unknown": "自訂計畫"
},
"next": {
"label": "下次 {{time}}",
"disabled": "已暫停",
"none": "沒有下次執行"
}
},
"composer": {
"placeholderThread": "輸入訊息…",
@@ -565,6 +647,8 @@
"goalStateCloseAria": "關閉目標",
"send": "送出訊息",
"stop": "停止回覆",
"modelNotConfigured": "模型未配置",
"configureModel": "配置模型",
"queued": {
"label": "待引導提示",
"guide": "引導",
@@ -691,7 +775,14 @@
}
},
"scrollToBottom": "捲動到底部",
"loadEarlier": "載入更早訊息"
"loadEarlier": "載入更早訊息",
"promptNavigator": {
"open": "開啟輸入導覽",
"title": "輸入列表",
"search": "搜尋輸入",
"noResults": "沒有符合的輸入。",
"jumpTo": "跳到輸入:{{label}}"
}
},
"message": {
"streaming": "串流輸出中",
@@ -724,7 +815,9 @@
"cliActivityFailedMany": "{{count}} 個 CLI 應用失敗",
"cliRunRunning": "使用中",
"cliRunRan": "已使用",
"cliRunFailed": "失敗"
"cliRunFailed": "失敗",
"automationSourceFallback": "自動化",
"automationTriggered": "自動觸發"
},
"lightbox": {
"title": "圖片預覽",
@@ -733,6 +826,15 @@
"next": "下一張",
"close": "關閉預覽"
},
"filePreview": {
"aria": "檔案預覽",
"close": "關閉檔案預覽",
"loading": "正在載入預覽...",
"failed": "無法預覽這個檔案。",
"routeMissing": "檔案預覽需要最新的 gateway。請重啟 nanobot gateway 後再試。",
"resize": "調整檔案預覽寬度",
"truncated": "檔案較大,目前只顯示前半部分預覽。"
},
"code": {
"fallbackLanguage": "程式碼",
"copyAria": "複製程式碼",
+71 -8
View File
@@ -38,6 +38,10 @@ export type TurnUnit =
| { type: "activity"; messages: UIMessage[]; items: ActivityItem[]; turnLatencyMs?: number }
| { type: "message"; message: UIMessage };
interface NormalizeActivityTimelineOptions {
preserveTrailingActivity?: boolean;
}
export function isReasoningOnlyAssistant(message: UIMessage): boolean {
if (message.role !== "assistant" || message.kind === "trace") return false;
if (message.content.trim().length > 0) return false;
@@ -48,24 +52,30 @@ export function isAgentActivityMember(message: UIMessage): boolean {
return isReasoningOnlyAssistant(message) || message.kind === "trace";
}
export function normalizeActivityTimeline(messages: UIMessage[]): TurnUnit[] {
export function normalizeActivityTimeline(
messages: UIMessage[],
options: NormalizeActivityTimelineOptions = {},
): TurnUnit[] {
const units: TurnUnit[] = [];
let turnMessages: UIMessage[] = [];
let activeTurnId: string | undefined;
const flushTurn = () => {
const flushTurn = (flushOptions: NormalizeActivityTimelineOptions = {}) => {
if (turnMessages.length === 0) return;
const visibleMessages = visibleMessagesForTurn(turnMessages);
const turnUnits: TurnUnit[] = [];
const orderedTurnMessages = orderMessagesByTurnSeq(turnMessages);
const visibleMessages = visibleMessagesForTurn(orderedTurnMessages);
let visibleIndex = 0;
let activityMessages: UIMessage[] = [];
const flushActivityMessages = () => {
if (!activityMessages.length) return;
pushActivityUnits(units, activityMessages, visibleMessages.slice(visibleIndex));
pushActivityUnits(turnUnits, activityMessages, visibleMessages.slice(visibleIndex));
activityMessages = [];
};
for (const message of turnMessages) {
for (const message of orderedTurnMessages) {
if (isAgentActivityMember(message)) {
activityMessages.push(message);
continue;
@@ -74,34 +84,87 @@ export function normalizeActivityTimeline(messages: UIMessage[]): TurnUnit[] {
if (assistantHasInlineReasoning(message)) {
activityMessages.push(reasoningOnlyMessageFromAnswer(message));
flushActivityMessages();
units.push({ type: "message", message: stripInlineReasoning(message) });
turnUnits.push({ type: "message", message: stripInlineReasoning(message) });
visibleIndex += 1;
continue;
}
flushActivityMessages();
units.push({ type: "message", message });
turnUnits.push({ type: "message", message });
visibleIndex += 1;
}
flushActivityMessages();
units.push(...normalizeCompletedTurnUnits(turnUnits, flushOptions));
turnMessages = [];
activeTurnId = undefined;
};
for (const message of messages) {
if (message.role === "user") {
flushTurn();
units.push({ type: "message", message });
activeTurnId = message.turnId;
continue;
}
if (message.turnId && activeTurnId && message.turnId !== activeTurnId) {
flushTurn();
}
if (message.turnId) {
activeTurnId = message.turnId;
}
turnMessages.push(message);
}
flushTurn();
flushTurn(options);
return units;
}
function orderMessagesByTurnSeq(messages: UIMessage[]): UIMessage[] {
if (
messages.length < 2
|| !messages.every((message) => Number.isFinite(message.turnSeq))
) {
return messages;
}
return messages
.map((message, index) => ({ message, index }))
.sort((left, right) => {
const bySeq = (left.message.turnSeq ?? 0) - (right.message.turnSeq ?? 0);
return bySeq || left.index - right.index;
})
.map(({ message }) => message);
}
function normalizeCompletedTurnUnits(
turnUnits: TurnUnit[],
options: NormalizeActivityTimelineOptions,
): TurnUnit[] {
if (options.preserveTrailingActivity || turnUnits.length < 2) return turnUnits;
if (turnUnits[turnUnits.length - 1]?.type !== "activity") return turnUnits;
let trailingStart = turnUnits.length - 1;
while (trailingStart > 0 && turnUnits[trailingStart - 1]?.type === "activity") {
trailingStart -= 1;
}
const previous = turnUnits[trailingStart - 1];
if (
!previous
|| previous.type !== "message"
|| previous.message.role !== "assistant"
) {
return turnUnits;
}
return [
...turnUnits.slice(0, trailingStart - 1),
...turnUnits.slice(trailingStart),
previous,
];
}
function visibleMessagesForTurn(messages: UIMessage[]): UIMessage[] {
const visibleMessages: UIMessage[] = [];
for (const message of messages) {
+70
View File
@@ -1,6 +1,7 @@
import type {
ChatSummary,
CliAppsPayload,
FilePreviewPayload,
ImageGenerationSettingsUpdate,
McpPresetsPayload,
ModelConfigurationCreate,
@@ -8,9 +9,12 @@ import type {
NetworkSafetySettingsUpdate,
ProviderModelsPayload,
ProviderSettingsUpdate,
SessionAutomationsPayload,
SettingsPayload,
SettingsUpdate,
SidebarStatePayload,
SkillDetail,
SkillsPayload,
SlashCommand,
WebSearchSettingsUpdate,
WorkspacesPayload,
@@ -134,6 +138,60 @@ export async function fetchWebuiThread(
return (await res.json()) as WebuiThreadPersistedPayload;
}
export async function fetchFilePreview(
token: string,
key: string,
path: string,
base: string = "",
): Promise<FilePreviewPayload> {
const query = new URLSearchParams();
query.set("path", path);
return request<FilePreviewPayload>(
`${base}/api/sessions/${encodeURIComponent(key)}/file-preview?${query}`,
token,
undefined,
API_READ_TIMEOUT_MS,
);
}
export async function fetchSessionAutomations(
token: string,
key: string,
base: string = "",
): Promise<SessionAutomationsPayload> {
return request<SessionAutomationsPayload>(
`${base}/api/sessions/${encodeURIComponent(key)}/automations`,
token,
undefined,
API_READ_TIMEOUT_MS,
);
}
export async function fetchSkills(
token: string,
base: string = "",
): Promise<SkillsPayload> {
return request<SkillsPayload>(
`${base}/api/webui/skills`,
token,
undefined,
API_READ_TIMEOUT_MS,
);
}
export async function fetchSkillDetail(
token: string,
name: string,
base: string = "",
): Promise<SkillDetail> {
return request<SkillDetail>(
`${base}/api/webui/skills/${encodeURIComponent(name)}`,
token,
undefined,
API_READ_TIMEOUT_MS,
);
}
export async function deleteSession(
token: string,
key: string,
@@ -158,6 +216,18 @@ export async function fetchSettings(
);
}
export async function fetchSettingsUsage(
token: string,
base: string = "",
): Promise<NonNullable<SettingsPayload["usage"]>> {
return request<NonNullable<SettingsPayload["usage"]>>(
`${base}/api/settings/usage`,
token,
undefined,
API_READ_TIMEOUT_MS,
);
}
export async function fetchWorkspaces(
token: string,
base: string = "",
+2
View File
@@ -336,6 +336,7 @@ export class NanobotClient {
cliApps?: OutboundCliAppMention[];
mcpPresets?: OutboundMcpPresetMention[];
workspaceScope?: WorkspaceScopePayload | null;
turnId?: string;
},
): void {
this.knownChats.add(chatId);
@@ -348,6 +349,7 @@ export class NanobotClient {
...(options?.cliApps?.length ? { cli_apps: options.cliApps } : {}),
...(options?.mcpPresets?.length ? { mcp_presets: options.mcpPresets } : {}),
...(options?.workspaceScope ? { workspace_scope: options.workspaceScope } : {}),
...(options?.turnId ? { turn_id: options.turnId } : {}),
webui: true,
};
this.queueSend(frame);
+124 -14
View File
@@ -4,6 +4,8 @@ export type Role = "user" | "assistant" | "tool" | "system";
* progress pings) that should not be rendered as conversational replies. */
export type MessageKind = "message" | "trace";
export type UITurnPhase = "user" | "reasoning" | "activity" | "answer" | "complete";
/** One image attached to a UIMessage.
*
* ``url`` can arrive in three different shapes, which the bubble renders
@@ -30,6 +32,8 @@ export interface UIMediaAttachment {
name?: string;
}
export interface UIMessageSource { kind: "cron"; label?: string; }
export interface UIMessage {
id: string;
role: Role;
@@ -64,6 +68,12 @@ export interface UIMessage {
reasoningStreaming?: boolean;
/** End-to-end wall time for this assistant turn (persisted ``latency_ms`` / ``turn_end``). */
latencyMs?: number;
/** Lightweight provenance for proactive assistant messages. */
source?: UIMessageSource;
/** Stable protocol metadata for grouping all activity emitted by one user turn. */
turnId?: string;
turnPhase?: UITurnPhase;
turnSeq?: number;
}
export interface UICliAppAttachment {
@@ -86,6 +96,50 @@ export interface UIMcpPresetAttachment {
brand_color?: string | null;
}
export interface SessionAutomationJob {
id: string;
name: string;
enabled: boolean;
schedule: {
kind: "at" | "every" | "cron" | string;
at_ms?: number | null;
every_ms?: number | null;
expr?: string | null;
tz?: string | null;
};
payload: {
message: string;
};
state: {
next_run_at_ms?: number | null;
last_status?: "ok" | "error" | "skipped" | string | null;
};
}
export interface SessionAutomationsPayload { jobs: SessionAutomationJob[]; }
export interface SkillSummary {
name: string;
description: string;
source: "workspace" | "builtin" | string;
available: boolean;
unavailable_reason?: string;
}
export interface SkillRequirements {
bins: string[];
env: string[];
missing_bins: string[];
missing_env: string[];
}
export interface SkillDetail extends SkillSummary {
requirements: SkillRequirements;
raw_markdown: string;
}
export interface SkillsPayload { skills: SkillSummary[]; }
/** Structured UI blob on ``progress`` WS frames; channels may add more ``kind`` values later. */
export interface AgentUIBlob {
kind: string;
@@ -352,6 +406,43 @@ export interface SettingsPayload {
};
unified_session: boolean;
};
usage?: {
days: Array<{
date: string;
prompt_tokens: number;
completion_tokens: number;
cached_tokens: number;
total_tokens: number;
provider_tokens?: number;
estimated_tokens?: number;
requests: number;
provider_requests?: number;
estimated_requests?: number;
sources?: Record<
"user" | "api" | "cron" | "dream" | "system" | string,
{
prompt_tokens: number;
completion_tokens: number;
cached_tokens: number;
total_tokens: number;
provider_tokens?: number;
estimated_tokens?: number;
requests: number;
provider_requests?: number;
estimated_requests?: number;
}
>;
}>;
total_tokens: number;
total_tokens_30d: number;
total_tokens_365d: number;
peak_day_tokens: number;
current_streak_days: number;
longest_streak_days: number;
active_days_30d: number;
requests_30d: number;
updated_at?: string | null;
};
advanced: {
restrict_to_workspace: boolean;
workspace_sandbox?: {
@@ -605,10 +696,16 @@ export type ConnectionStatus =
| "closed"
| "error";
export interface InboundTurnMetadata {
turn_id?: string;
turn_phase?: UITurnPhase;
turn_seq?: number;
}
export type InboundEvent =
| { event: "ready"; chat_id: string; client_id: string }
| { event: "attached"; chat_id: string }
| {
| ({
event: "message";
chat_id: string;
text: string;
@@ -621,49 +718,51 @@ export type InboundEvent =
kind?: "tool_hint" | "progress" | "reasoning";
/** Server-measured turn wall time when this frame finishes an assistant reply. */
latency_ms?: number;
/** Lightweight provenance for proactive assistant messages. */
source?: UIMessageSource;
/** Optional structured payload on progress frames (channel-specific). */
agent_ui?: AgentUIBlob;
}
| {
} & InboundTurnMetadata)
| ({
event: "file_edit";
chat_id: string;
edits: UIFileEdit[];
}
| {
} & InboundTurnMetadata)
| ({
event: "delta";
chat_id: string;
text: string;
stream_id?: string;
}
| {
} & InboundTurnMetadata)
| ({
event: "stream_end";
chat_id: string;
stream_id?: string;
text?: string;
}
| {
} & InboundTurnMetadata)
| ({
event: "reasoning_delta";
chat_id: string;
text: string;
stream_id?: string;
}
| {
} & InboundTurnMetadata)
| ({
event: "reasoning_end";
chat_id: string;
stream_id?: string;
}
} & InboundTurnMetadata)
| {
event: "runtime_model_updated";
model_name: string;
model_preset?: string | null;
}
| {
| ({
event: "turn_end";
chat_id: string;
latency_ms?: number;
/** Authoritative sustained-goal snapshot for this chat (same shape as ``goal_state`` events). */
goal_state?: GoalStateWsPayload;
}
} & InboundTurnMetadata)
| {
event: "goal_status";
chat_id: string;
@@ -732,6 +831,16 @@ export interface WebuiThreadPersistedPayload {
workspace_scope?: WorkspaceScopePayload;
}
export interface FilePreviewPayload {
path: string;
display_path: string;
project_path: string;
language: string;
content: string;
size: number;
truncated: boolean;
}
export type Outbound =
| { type: "new_chat"; workspace_scope?: WorkspaceScopePayload }
| { type: "attach"; chat_id: string }
@@ -745,6 +854,7 @@ export type Outbound =
cli_apps?: OutboundCliAppMention[];
mcp_presets?: OutboundMcpPresetMention[];
workspace_scope?: WorkspaceScopePayload;
turn_id?: string;
/** Marks messages sent by the embedded WebUI, without changing the
* generic websocket protocol for other clients. */
webui?: true;
+61
View File
@@ -3,10 +3,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
createModelConfiguration,
deleteSession,
fetchFilePreview,
fetchCliApps,
fetchMcpPresets,
fetchProviderModels,
fetchSessionAutomations,
fetchSettingsUsage,
fetchSidebarState,
fetchSkillDetail,
fetchSkills,
fetchWebuiThread,
fetchWorkspaces,
importMcpConfig,
@@ -55,6 +60,51 @@ 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 fetching session automations", async () => {
await fetchSessionAutomations("tok", "websocket:chat-1");
expect(fetch).toHaveBeenCalledWith(
"/api/sessions/websocket%3Achat-1/automations",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
);
});
it("fetches the WebUI skill summary", async () => {
await fetchSkills("tok");
expect(fetch).toHaveBeenCalledWith(
"/api/webui/skills",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
);
});
it("percent-encodes skill names when fetching skill details", async () => {
await fetchSkillDetail("tok", "current web");
expect(fetch).toHaveBeenCalledWith(
"/api/webui/skills/current%20web",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
);
});
it("percent-encodes websocket keys when deleting a session", async () => {
await deleteSession("tok", "websocket:chat-1");
@@ -86,6 +136,17 @@ describe("webui API helpers", () => {
);
});
it("fetches token usage through the lightweight settings endpoint", async () => {
await fetchSettingsUsage("tok");
expect(fetch).toHaveBeenCalledWith(
"/api/settings/usage",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
);
});
it("serializes model configuration creation", async () => {
await createModelConfiguration("tok", {
label: "Fast writing",
+143 -35
View File
@@ -30,6 +30,18 @@ function jsonResponse(body: unknown): Response {
} as Response;
}
function mockFetchRoutes(routes: Record<string, unknown>): void {
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const body = routes[String(input)];
return body === undefined
? ({ ok: false, status: 404, json: async () => ({}) } as Response)
: jsonResponse(body);
}),
);
}
function baseSettingsPayload() {
return {
agent: {
@@ -208,6 +220,7 @@ describe("App layout", () => {
runStatusHandlers.clear();
window.history.replaceState(null, "", "/");
setNavigatorPlatform("Linux x86_64");
localStorage.removeItem("nanobot-webui.sidebar");
localStorage.removeItem("nanobot-webui.sidebar.completed-runs.v1");
vi.mocked(fetchBootstrap).mockReset().mockResolvedValue({
token: "tok",
@@ -243,6 +256,129 @@ describe("App layout", () => {
expect(asideClassNames.some((cls) => cls.includes("lg:block"))).toBe(true);
});
it("opens Skills from the main sidebar", async () => {
mockFetchRoutes({
"/api/settings": baseSettingsPayload(),
"/api/settings/cli-apps": { apps: [], installed_count: 0, catalog_updated_at: "2026-04-18" },
"/api/settings/mcp-presets": { presets: [], installed_count: 0 },
"/api/webui/skills": {
skills: [
{ name: "cron", description: "Schedule reminders.", source: "builtin", available: true },
{
name: "github",
description: "Work with GitHub.",
source: "builtin",
available: false,
unavailable_reason: "CLI: gh",
},
],
},
"/api/webui/skills/github": {
name: "github",
description: "Work with GitHub.",
source: "builtin",
available: false,
unavailable_reason: "CLI: gh",
requirements: {
bins: ["gh"],
env: [],
missing_bins: ["gh"],
missing_env: [],
},
raw_markdown: "---\nname: github\n---\nUse GitHub CLI.",
},
});
render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
const skillsButton = within(sidebar).getByRole("button", { name: "Skills" });
fireEvent.click(skillsButton);
expect(await screen.findByRole("heading", { name: "Skills" })).toBeInTheDocument();
expect(screen.getByText("cron")).toBeInTheDocument();
expect(screen.getByText("github")).toBeInTheDocument();
expect(screen.getByText("Missing: CLI: gh")).toBeInTheDocument();
expect(screen.getByRole("navigation", { name: "Sidebar navigation" })).toBeInTheDocument();
expect(screen.queryByRole("navigation", { name: "Settings sections" })).not.toBeInTheDocument();
expect(within(sidebar).getByRole("button", { name: "Skills" })).toHaveAttribute(
"aria-current",
"page",
);
expect(document.title).toBe("Skills · nanobot");
fireEvent.click(screen.getByRole("button", { name: "Back to chat" }));
expect(await screen.findByText(HERO_GREETING_PATTERN)).toBeInTheDocument();
fireEvent.click(within(sidebar).getByRole("button", { name: "Skills" }));
expect(await screen.findByRole("heading", { name: "Skills" })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Open details for github" }));
expect(await screen.findByRole("heading", { name: "github" })).toBeInTheDocument();
expect(screen.getByText("Unavailable reason")).toBeInTheDocument();
expect(screen.getAllByText("CLI: gh").length).toBeGreaterThan(0);
expect(screen.getByText("Missing CLI")).toBeInTheDocument();
fireEvent.click(screen.getByText("Raw SKILL.md"));
expect(screen.getByText(/Use GitHub CLI/)).toBeInTheDocument();
});
it("fully collapses the native host sidebar and previews it on hover", async () => {
mockSessions = [
{
key: "websocket:chat-a",
channel: "websocket",
chatId: "chat-a",
createdAt: "2026-04-16T10:00:00Z",
updatedAt: "2026-04-16T10:00:00Z",
preview: "Desktop chat",
},
];
vi.mocked(fetchBootstrap).mockResolvedValue({
token: "tok",
ws_path: "/",
expires_in: 300,
runtime_surface: "native",
});
render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
const flowSidebar = screen.getByTestId("host-sidebar-flow");
const toggle = screen.getByTestId("host-sidebar-toggle");
expect(flowSidebar).toHaveStyle({ width: "272px" });
expect(
screen.getByRole("navigation", { name: "Sidebar navigation" }),
).toBeInTheDocument();
fireEvent.click(toggle);
await waitFor(() => expect(flowSidebar).toHaveStyle({ width: "0px" }));
expect(
screen.queryByRole("navigation", { name: "Sidebar navigation" }),
).not.toBeInTheDocument();
fireEvent.mouseEnter(toggle);
const previewSidebar = await screen.findByTestId("host-sidebar-preview");
expect(flowSidebar).toHaveStyle({ width: "0px" });
expect(previewSidebar).toHaveStyle({ width: "272px" });
expect(
within(previewSidebar).getByRole("navigation", {
name: "Sidebar navigation",
}),
).toBeInTheDocument();
fireEvent.click(toggle);
await waitFor(() =>
expect(screen.queryByTestId("host-sidebar-preview")).not.toBeInTheDocument(),
);
expect(flowSidebar).toHaveStyle({ width: "272px" });
expect(
screen.getByRole("navigation", { name: "Sidebar navigation" }),
).toBeInTheDocument();
});
it("switches to the next session when deleting the active chat", async () => {
mockSessions = [
{
@@ -907,7 +1043,6 @@ describe("App layout", () => {
expect(await screen.findByRole("heading", { name: "Overview" })).toBeInTheDocument();
expect(document.title).toBe("Settings · nanobot");
expect(screen.getByTestId("overview-nanobot-logo")).toBeInTheDocument();
expect(screen.getByTestId("overview-logo-openai")).toBeInTheDocument();
expect(screen.getByTestId("overview-logo-brave")).toBeInTheDocument();
expect(screen.getByTestId("overview-logo-openrouter")).toBeInTheDocument();
@@ -1036,15 +1171,7 @@ describe("App layout", () => {
});
it("restores the settings section from the URL hash after a page reload", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
if (String(input) === "/api/settings") {
return jsonResponse(baseSettingsPayload());
}
return { ok: false, status: 404, json: async () => ({}) } as Response;
}),
);
mockFetchRoutes({ "/api/settings": baseSettingsPayload() });
window.history.replaceState(null, "", "/#/settings?section=models");
render(<App />);
@@ -1055,15 +1182,7 @@ describe("App layout", () => {
});
it("updates the URL hash when switching settings sections", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
if (String(input) === "/api/settings") {
return jsonResponse(baseSettingsPayload());
}
return { ok: false, status: 404, json: async () => ({}) } as Response;
}),
);
mockFetchRoutes({ "/api/settings": baseSettingsPayload() });
render(<App />);
@@ -1081,22 +1200,11 @@ describe("App layout", () => {
});
it("opens Apps from the main sidebar without replacing the sidebar", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const href = String(input);
if (href === "/api/settings") {
return jsonResponse(baseSettingsPayload());
}
if (href === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0, catalog_updated_at: "2026-04-18" });
}
if (href === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
return { ok: false, status: 404, json: async () => ({}) } as Response;
}),
);
mockFetchRoutes({
"/api/settings": baseSettingsPayload(),
"/api/settings/cli-apps": { apps: [], installed_count: 0, catalog_updated_at: "2026-04-18" },
"/api/settings/mcp-presets": { presets: [], installed_count: 0 },
});
render(<App />);
+19
View File
@@ -51,6 +51,25 @@ describe("CodeBlock", () => {
expect(screen.getByTestId("plain-code-fallback")).toHaveClass("text-foreground/90");
});
it("can render without chat-style chrome for file previews", () => {
render(
<ThemeProvider theme="light">
<CodeBlock
language="html"
code="<main />"
chrome="none"
highlight={false}
showLineNumbers
/>
</ThemeProvider>,
);
expect(screen.queryByText("html")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /copy/i })).not.toBeInTheDocument();
expect(screen.getByText("1")).toBeInTheDocument();
expect(screen.getByTestId("plain-code-fallback")).toHaveClass("bg-transparent");
});
it("falls back to 'text' language when language is undefined", async () => {
render(
<ThemeProvider theme="dark">
@@ -1,5 +1,5 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import MarkdownTextRenderer from "@/components/MarkdownTextRenderer";
@@ -12,6 +12,67 @@ describe("MarkdownTextRenderer", () => {
expect(link).toHaveClass("text-blue-500", "dark:text-blue-300");
});
it("renders local file links as previewable file references", () => {
const onOpenFilePreview = vi.fn();
render(
<MarkdownTextRenderer onOpenFilePreview={onOpenFilePreview}>
{"Edited [hook.py](/Users/test/project/nanobot/agent/hook.py:12)"}
</MarkdownTextRenderer>,
);
const reference = screen.getByTestId("inline-file-path");
expect(reference).toHaveTextContent("hook.py");
expect(reference).toHaveAttribute(
"aria-label",
"/Users/test/project/nanobot/agent/hook.py",
);
fireEvent.click(reference);
expect(onOpenFilePreview).toHaveBeenCalledWith(
"/Users/test/project/nanobot/agent/hook.py",
);
});
it("does not treat non-file hrefs as previews just because the label looks like a file", () => {
const onOpenFilePreview = vi.fn();
render(
<MarkdownTextRenderer onOpenFilePreview={onOpenFilePreview}>
{"Download [index.html](/api/media/sig/html)"}
</MarkdownTextRenderer>,
);
expect(screen.queryByTestId("inline-file-path")).not.toBeInTheDocument();
expect(screen.getByRole("link", { name: "index.html" })).toHaveAttribute(
"href",
"/api/media/sig/html",
);
});
it("renders glob file links as plain text instead of preview targets", () => {
const onOpenFilePreview = vi.fn();
const { container } = render(
<MarkdownTextRenderer onOpenFilePreview={onOpenFilePreview}>
{"原始对话通常还在 [*.json](*.json)。"}
</MarkdownTextRenderer>,
);
expect(screen.queryByTestId("inline-file-path")).not.toBeInTheDocument();
expect(screen.queryByRole("link", { name: "*.json" })).not.toBeInTheDocument();
expect(container).toHaveTextContent("*.json");
});
it("keeps glob inline code as code instead of a file preview chip", () => {
render(
<MarkdownTextRenderer>
{"检查 `src/**/*.json`。"}
</MarkdownTextRenderer>,
);
expect(screen.queryByTestId("inline-file-path")).not.toBeInTheDocument();
expect(screen.getByText("src/**/*.json").tagName).toBe("CODE");
});
it("does not wrap complete fenced code blocks in an extra pre", () => {
const { container } = render(
<MarkdownTextRenderer highlightCode={false}>
@@ -117,6 +178,42 @@ describe("MarkdownTextRenderer", () => {
).toHaveAttribute("href", "https://polymarket.com/event/when-will-gpt-5pt6-be-released");
});
it("falls back through favicon sources before showing a globe for compact link rows", () => {
const { container } = render(
<MarkdownTextRenderer>
{
"Useful links:\n\n- Savills Hong Kong Corporate Relocation — Corporate relocation services\n https://www.savills.com.hk/services/corporate-relocation.aspx"
}
</MarkdownTextRenderer>,
);
const link = screen.getByRole("link", {
name: "Open link: Savills Hong Kong Corporate Relocation — Corporate relocation services",
});
const favicon = () => link.querySelector("img");
expect(favicon()).toHaveAttribute(
"src",
"https://www.savills.com.hk/favicon.ico",
);
fireEvent.error(favicon()!);
expect(favicon()).toHaveAttribute(
"src",
"https://icons.duckduckgo.com/ip3/www.savills.com.hk.ico",
);
fireEvent.error(favicon()!);
expect(favicon()).toHaveAttribute(
"src",
"https://www.google.com/s2/favicons?domain=www.savills.com.hk&sz=64",
);
fireEvent.error(favicon()!);
expect(favicon()).not.toBeInTheDocument();
expect(link.querySelector("svg")).toBeInTheDocument();
expect(container).not.toHaveTextContent("SC");
});
it("renders media attachments without an extra preview/code wrapper", () => {
render(<MarkdownTextRenderer>![Diagram](/api/media/sig/payload)</MarkdownTextRenderer>);
+16
View File
@@ -101,6 +101,22 @@ describe("MessageBubble", () => {
expect(screen.getByText(/not @krita/)).toBeInTheDocument();
});
it("renders a lightweight automation source label for cron replies", () => {
const message: UIMessage = {
id: "a-cron",
role: "assistant",
content: "Time to drink water.",
source: { kind: "cron", label: "drink water" },
createdAt: Date.now(),
};
render(<MessageBubble message={message} />);
expect(screen.getByText("drink water")).toBeInTheDocument();
expect(screen.getByText("Triggered automatically")).toBeInTheDocument();
expect(screen.getByText("Time to drink water.")).toBeInTheDocument();
});
it("renders structured CLI app attachments even without the installed catalog", () => {
const message: UIMessage = {
id: "u-cli-attached",
+18
View File
@@ -429,6 +429,24 @@ describe("NanobotClient", () => {
);
});
it("includes an explicit turn id on outbound WebUI messages", () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
client.connect();
lastSocket().fakeOpen();
client.sendMessage("chat-x", "hello", undefined, { turnId: "turn-1" });
expect(JSON.parse(lastSocket().sent.at(-1) as string)).toEqual({
type: "message",
chat_id: "chat-x",
content: "hello",
turn_id: "turn-1",
webui: true,
});
});
it("includes image generation options in outbound messages", () => {
const client = new NanobotClient({
url: "ws://test",
@@ -0,0 +1,117 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SessionInfoPopover } from "@/components/thread/SessionInfoPopover";
import { setAppLanguage } from "@/i18n";
function automationJob(nextRunAt = Date.now() + 3_600_000) {
return {
id: "job-1",
name: "Morning check",
enabled: true,
schedule: { kind: "every", every_ms: 3_600_000 },
payload: { message: "Check the project status" },
state: { next_run_at_ms: nextRunAt },
};
}
function automationsResponse(jobs: unknown[]) {
return {
ok: true,
headers: new Headers({ "content-type": "application/json" }),
json: async () => ({
jobs,
}),
} as Response;
}
describe("SessionInfoPopover", () => {
beforeEach(() => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(automationsResponse([automationJob()])),
);
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
it("loads and displays session automations when opened", async () => {
const user = userEvent.setup();
render(
<SessionInfoPopover
sessionKey="websocket:chat-1"
token="tok"
title="Release work"
/>,
);
await user.click(screen.getByRole("button", { name: "Session details" }));
await waitFor(() => {
expect(fetch).toHaveBeenCalledWith(
"/api/sessions/websocket%3Achat-1/automations",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
);
});
expect(await screen.findByText("Morning check")).toBeInTheDocument();
expect(screen.getByText("Check the project status")).toBeInTheDocument();
});
it("localizes the panel chrome in Simplified Chinese", async () => {
await setAppLanguage("zh-CN");
const user = userEvent.setup();
render(
<SessionInfoPopover
sessionKey="websocket:chat-1"
token="tok"
title="@hyperframes 使用指南"
/>,
);
await user.click(screen.getByRole("button", { name: "会话详情" }));
expect(await screen.findByText("会话")).toBeInTheDocument();
expect(screen.getByText("自动任务")).toBeInTheDocument();
expect(screen.getByText("Morning check")).toBeInTheDocument();
expect(screen.getByText(/下次/)).toBeInTheDocument();
expect(screen.queryByText("Session")).not.toBeInTheDocument();
expect(screen.queryByText("Automations")).not.toBeInTheDocument();
});
it("refreshes while open so completed one-shot automations disappear", async () => {
vi.stubGlobal(
"fetch",
vi.fn()
.mockResolvedValueOnce(automationsResponse([automationJob(Date.now() + 1000)]))
.mockResolvedValue(automationsResponse([])),
);
const user = userEvent.setup();
render(
<SessionInfoPopover
sessionKey="websocket:chat-1"
token="tok"
title="Release work"
/>,
);
await user.click(screen.getByRole("button", { name: "Session details" }));
expect(await screen.findByText("Morning check")).toBeInTheDocument();
await waitFor(
() => {
expect(screen.queryByText("Morning check")).not.toBeInTheDocument();
},
{ timeout: 4500 },
);
expect(screen.getByText("No automations in this session yet.")).toBeInTheDocument();
}, 8000);
});
+386 -1
View File
@@ -118,8 +118,9 @@ const installedAnyGen = {
function renderSettingsView(
options: {
initialSection?: "apps" | "advanced" | "models";
initialSection?: "overview" | "apps" | "advanced" | "models";
onSettingsChange?: (payload: SettingsPayload) => void;
onNativeEngineRestart?: () => Promise<string>;
} = {},
) {
render(
@@ -131,6 +132,7 @@ function renderSettingsView(
onBackToChat={() => {}}
onModelNameChange={() => {}}
onSettingsChange={options.onSettingsChange}
onNativeEngineRestart={options.onNativeEngineRestart}
/>
</ClientProvider>,
);
@@ -219,6 +221,55 @@ describe("SettingsView Apps catalog", () => {
await waitFor(() => expect(onSettingsChange).toHaveBeenCalledWith(payload));
});
it("shows token activity on the overview", async () => {
const payload: SettingsPayload = {
...settingsPayload(),
usage: {
days: [
{
date: "2026-06-03",
prompt_tokens: 1200,
completion_tokens: 300,
cached_tokens: 500,
total_tokens: 1500,
requests: 2,
},
],
total_tokens: 1500,
total_tokens_30d: 1500,
total_tokens_365d: 1500,
peak_day_tokens: 1500,
current_streak_days: 1,
longest_streak_days: 1,
active_days_30d: 1,
requests_30d: 2,
updated_at: "2026-06-03T00:00:00Z",
},
};
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(payload);
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
return { ok: false, status: 404, json: async () => ({}) } as Response;
}),
);
renderSettingsView({ initialSection: "overview" });
expect(await screen.findByLabelText("Token activity")).toBeInTheDocument();
expect(screen.getByText("Token Usage")).toBeInTheDocument();
expect(screen.queryByText("Token activity")).not.toBeInTheDocument();
expect(screen.queryByText("Total tokens")).not.toBeInTheDocument();
expect(screen.queryByText("Peak tokens")).not.toBeInTheDocument();
});
it("shows context window options in model settings", async () => {
vi.stubGlobal(
"fetch",
@@ -242,6 +293,280 @@ describe("SettingsView Apps catalog", () => {
expect(screen.getByRole("button", { name: "256K" })).toBeInTheDocument();
});
it("marks the current model as unconfigured when its provider needs setup", async () => {
const payload: SettingsPayload = {
...settingsPayload(),
agent: {
...settingsPayload().agent,
model: "openai-codex/gpt-5.1-codex",
provider: "openai_codex",
resolved_provider: "openai_codex",
has_api_key: false,
},
model_presets: [
{
...settingsPayload().model_presets[0],
model: "openai-codex/gpt-5.1-codex",
provider: "openai_codex",
},
],
providers: [
{
name: "openai_codex",
label: "OpenAI Codex",
configured: false,
auth_type: "oauth",
api_key_required: false,
api_key_hint: null,
api_base: null,
default_api_base: null,
oauth_account: null,
oauth_expires_at: null,
oauth_login_supported: true,
},
],
};
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(payload);
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
return { ok: false, status: 404, json: async () => ({}) } as Response;
}),
);
renderSettingsView({ initialSection: "models" });
const configurationButton = await screen.findByRole("button", {
name: "Current configuration",
});
expect(configurationButton).toHaveTextContent("Not configured");
expect(configurationButton).toHaveTextContent("OpenAI Codex · openai-codex/gpt-5.1-codex");
expect(await screen.findByRole("button", { name: "Sign in" })).toBeInTheDocument();
});
it("keeps unsigned OAuth providers out of the active provider picker", async () => {
const payload: SettingsPayload = {
...settingsPayload(),
agent: {
...settingsPayload().agent,
model: "deepseek-chat",
provider: "deepseek",
resolved_provider: "deepseek",
},
model_presets: [
{
...settingsPayload().model_presets[0],
model: "deepseek-chat",
provider: "deepseek",
},
],
providers: [
{
name: "deepseek",
label: "DeepSeek",
configured: true,
auth_type: "api_key",
api_key_required: true,
api_key_hint: "sk-...",
api_base: "https://api.deepseek.com",
default_api_base: "https://api.deepseek.com",
},
{
name: "openai_codex",
label: "OpenAI Codex",
configured: false,
auth_type: "oauth",
api_key_required: false,
api_key_hint: null,
api_base: null,
default_api_base: null,
oauth_account: null,
oauth_expires_at: null,
oauth_login_supported: true,
},
{
name: "github_copilot",
label: "GitHub Copilot",
configured: false,
auth_type: "oauth",
api_key_required: false,
api_key_hint: null,
api_base: null,
default_api_base: "https://api.githubcopilot.com",
oauth_account: null,
oauth_expires_at: null,
oauth_login_supported: true,
},
],
};
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(payload);
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
return { ok: false, status: 404, json: async () => ({}) } as Response;
}),
);
renderSettingsView({ initialSection: "models" });
const deepseekButtons = await screen.findAllByRole("button", { name: /DeepSeek/ });
const providerPicker = deepseekButtons.find(
(button) => button.getAttribute("aria-haspopup") === "menu",
);
if (!providerPicker) throw new Error("provider picker was not found");
fireEvent.pointerDown(providerPicker);
expect(await screen.findByRole("menuitem", { name: /DeepSeek/ })).toBeInTheDocument();
expect(screen.queryByRole("menuitem", { name: /OpenAI Codex/ })).not.toBeInTheDocument();
expect(screen.queryByRole("menuitem", { name: /GitHub Copilot/ })).not.toBeInTheDocument();
});
it("does not fetch model lists for unsigned OAuth providers", async () => {
const payload: SettingsPayload = {
...settingsPayload(),
agent: {
...settingsPayload().agent,
model: "",
provider: "openai_codex",
resolved_provider: "openai_codex",
},
model_presets: [
{
...settingsPayload().model_presets[0],
model: "",
provider: "openai_codex",
},
],
providers: [
{
name: "openai_codex",
label: "OpenAI Codex",
configured: false,
auth_type: "oauth",
api_key_required: false,
api_key_hint: null,
api_base: null,
default_api_base: null,
oauth_account: null,
oauth_expires_at: null,
oauth_login_supported: true,
},
{
name: "github_copilot",
label: "GitHub Copilot",
configured: false,
auth_type: "oauth",
api_key_required: false,
api_key_hint: null,
api_base: null,
default_api_base: "https://api.githubcopilot.com",
oauth_account: null,
oauth_expires_at: null,
oauth_login_supported: true,
},
],
};
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(payload);
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
return { ok: false, status: 404, json: async () => ({}) } as Response;
});
vi.stubGlobal("fetch", fetchMock);
renderSettingsView({ initialSection: "models" });
fireEvent.pointerDown(await screen.findByRole("button", { name: /Select model/i }));
expect(
await screen.findByText("Configure this provider before loading models."),
).toBeInTheDocument();
expect(
fetchMock.mock.calls.some(([input]) =>
String(input).startsWith("/api/settings/provider-models"),
),
).toBe(false);
});
it("prefills manual model ids for configured OAuth providers", async () => {
const payload: SettingsPayload = {
...settingsPayload(),
agent: {
...settingsPayload().agent,
model: "open-codex/gpt-5.5",
provider: "openai_codex",
resolved_provider: "openai_codex",
},
model_presets: [
{
...settingsPayload().model_presets[0],
model: "open-codex/gpt-5.5",
provider: "openai_codex",
},
],
providers: [
{
name: "openai_codex",
label: "OpenAI Codex",
configured: true,
auth_type: "oauth",
api_key_required: false,
api_key_hint: null,
api_base: null,
default_api_base: null,
oauth_account: "acct-test",
oauth_expires_at: null,
oauth_login_supported: true,
},
],
};
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(payload);
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
return { ok: false, status: 404, json: async () => ({}) } as Response;
});
vi.stubGlobal("fetch", fetchMock);
renderSettingsView({ initialSection: "models" });
const modelButtons = await screen.findAllByRole("button", { name: /open-codex\/gpt-5\.5/i });
fireEvent.pointerDown(modelButtons[modelButtons.length - 1]);
const input = (await screen.findByPlaceholderText("Search or type model ID")) as HTMLInputElement;
expect(input.value).toBe("open-codex/gpt-5.5");
fireEvent.change(input, { target: { value: "openai-codex/gpt-5.5" } });
expect(await screen.findByText("“openai-codex/gpt-5.5”")).toBeInTheDocument();
expect(
fetchMock.mock.calls.some(([input]) =>
String(input).startsWith("/api/settings/provider-models"),
),
).toBe(false);
});
it("can close the new configuration dialog without trapping the settings page", async () => {
vi.stubGlobal(
"fetch",
@@ -443,4 +768,64 @@ describe("SettingsView Apps catalog", () => {
expect(screen.queryByText("Web safety")).not.toBeInTheDocument();
expect(screen.getByText("Allow Full Access shell commands to reach services on this Mac.")).toBeInTheDocument();
});
it("refreshes settings with a fresh token after native engine restart", async () => {
const payload = {
...settingsPayload(),
surface: "native" as const,
runtime_surface: "native" as const,
runtime_capabilities: {
can_restart_engine: true,
can_pick_folder: true,
can_open_logs: true,
can_export_diagnostics: true,
},
};
const restartedPayload = {
...payload,
advanced: { ...payload.advanced, webui_allow_local_service_access: false },
requires_restart: true,
restart_required_sections: ["runtime"],
};
const refreshedPayload = {
...restartedPayload,
requires_restart: false,
restart_required_sections: [],
};
const restartEngine = vi.fn(async () => "fresh-token");
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
const auth = (init?.headers as Record<string, string> | undefined)?.Authorization;
if (url === "/api/settings" && auth === "Bearer fresh-token") {
return jsonResponse(refreshedPayload);
}
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 });
if (url === "/api/settings/network-safety/update?webui_allow_local_service_access=false&webui_default_access_mode=default") {
return jsonResponse(restartedPayload);
}
return { ok: false, status: 404, json: async () => ({}) } as Response;
});
vi.stubGlobal("fetch", fetchMock);
renderSettingsView({
initialSection: "advanced",
onNativeEngineRestart: restartEngine,
});
expect(await screen.findByText("App safety")).toBeInTheDocument();
fireEvent.click(screen.getByRole("switch", { name: "Local services" }));
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => expect(restartEngine).toHaveBeenCalledTimes(1));
await waitFor(() =>
expect(fetchMock).toHaveBeenCalledWith(
"/api/settings",
expect.objectContaining({
headers: { Authorization: "Bearer fresh-token" },
}),
),
);
});
});
+175 -8
View File
@@ -151,7 +151,7 @@ describe("ThreadMessages", () => {
]);
});
it("renders a later tool segment after the visible answer that preceded it", () => {
it("moves orphan trailing activity before the completed assistant answer", () => {
const messages: UIMessage[] = [
{
id: "r1",
@@ -182,14 +182,14 @@ describe("ThreadMessages", () => {
expect(units).toHaveLength(3);
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual(["r1"]);
expect(units[1]).toMatchObject({
expect(units[1].type === "activity" ? units[1].messages.map((m) => m.id) : []).toEqual(["t1"]);
expect(units[2]).toMatchObject({
type: "message",
message: {
id: "a1",
content: "Let me search the latest data.",
},
});
expect(units[2].type === "activity" ? units[2].messages.map((m) => m.id) : []).toEqual(["t1"]);
});
it("only marks the current activity timeline as live while streaming", () => {
@@ -324,7 +324,7 @@ describe("ThreadMessages", () => {
},
];
const units = buildDisplayUnits(messages);
const units = buildDisplayUnits(messages, true);
expect(units).toHaveLength(3);
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual(["t0"]);
@@ -344,7 +344,7 @@ describe("ThreadMessages", () => {
expect(answer.compareDocumentPosition(liveActivity) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
it("keeps late activity after a completed assistant answer", () => {
it("moves late activity before a completed assistant answer", () => {
const messages: UIMessage[] = [
{
id: "r1",
@@ -376,21 +376,164 @@ describe("ThreadMessages", () => {
expect(units).toHaveLength(3);
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual(["r1"]);
expect(units[1]).toMatchObject({
expect(units[1].type === "activity" ? units[1].messages.map((m) => m.id) : []).toEqual(["t1"]);
expect(units[2]).toMatchObject({
type: "message",
message: {
id: "a1",
content: "Hong Kong is hot today.",
},
});
expect(units[2].type === "activity" ? units[2].messages.map((m) => m.id) : []).toEqual(["t1"]);
render(<ThreadMessages messages={messages} isStreaming={false} />);
const answer = screen.getByText("Hong Kong is hot today.");
const laterActivity = screen.getAllByText(/thought/i).at(-1);
expect(laterActivity).toBeTruthy();
expect(answer.compareDocumentPosition(laterActivity!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(laterActivity!.compareDocumentPosition(answer) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
it("does not leave a completed web-search thought below the final answer", () => {
const messages: UIMessage[] = [
{
id: "user",
role: "user",
content: "最近科隆major开打了,你知道不?",
createdAt: 1,
},
{
id: "thought",
role: "assistant",
content: "",
reasoning: "I should verify the current event details.",
activitySegmentId: "seg-major",
createdAt: 2,
},
{
id: "answer",
role: "assistant",
content: "知道,IEM Cologne Major 2026 今天开打了。",
latencyMs: 18_000,
createdAt: 3,
},
{
id: "web",
role: "tool",
kind: "trace",
content: "Searching query: 2026 Cologne Major esports started 科隆 Major 开打了 2026",
traces: ["Searching query: 2026 Cologne Major esports started 科隆 Major 开打了 2026"],
activitySegmentId: "seg-major",
createdAt: 4,
},
];
render(<ThreadMessages messages={messages} isStreaming={false} />);
const thought = screen.getAllByText(/thought/i).at(-1);
const answer = screen.getByText("知道,IEM Cologne Major 2026 今天开打了。");
expect(thought).toBeTruthy();
expect(thought!.compareDocumentPosition(answer) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
it("normalizes completed prior turns while the next user turn is streaming", () => {
const messages: UIMessage[] = [
{
id: "thought",
role: "assistant",
content: "",
reasoning: "I should verify the current event details.",
activitySegmentId: "seg-major",
createdAt: 1,
},
{
id: "answer",
role: "assistant",
content: "Yep — IEM Cologne Major 2026 is in Cologne.",
latencyMs: 20_000,
createdAt: 2,
},
{
id: "web",
role: "tool",
kind: "trace",
content: "Searching query: site:counter-strike.net majors 2026",
traces: ["Searching query: site:counter-strike.net majors 2026"],
activitySegmentId: "seg-major",
createdAt: 3,
},
{
id: "next-user",
role: "user",
content: "看一下目前的赛果,整个表哥",
createdAt: 4,
},
];
const units = buildDisplayUnits(messages, true);
expect(units).toHaveLength(4);
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual([
"thought",
]);
expect(units[1].type === "activity" ? units[1].messages.map((m) => m.id) : []).toEqual([
"web",
]);
expect(units[2]).toMatchObject({
type: "message",
message: { id: "answer" },
});
expect(units[3]).toMatchObject({
type: "message",
message: { id: "next-user" },
});
});
it("orders live turn activity by causal turn sequence before the final answer", () => {
const messages: UIMessage[] = [
{
id: "web-1",
role: "tool",
kind: "trace",
content: "Searching query: 2026 Counter-Strike 2 Major location",
traces: ["Searching query: 2026 Counter-Strike 2 Major location"],
turnId: "turn-major",
turnSeq: 3,
activitySegmentId: "seg-1",
createdAt: 1,
},
{
id: "answer",
role: "assistant",
content: "Yep — IEM Cologne Major 2026 is in Cologne.",
isStreaming: true,
turnId: "turn-major",
turnSeq: 84,
createdAt: 3,
},
{
id: "web-2",
role: "tool",
kind: "trace",
content: "Searching query: site:counter-strike.net majors 2026",
traces: ["Searching query: site:counter-strike.net majors 2026"],
turnId: "turn-major",
turnSeq: 83,
activitySegmentId: "seg-2",
createdAt: 2,
},
];
const units = buildDisplayUnits(messages, true);
expect(units).toHaveLength(2);
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual([
"web-1",
"web-2",
]);
expect(units[1]).toMatchObject({
type: "message",
message: { id: "answer" },
});
});
it("renders interrupted pre-tool text as activity before the final answer", () => {
@@ -509,6 +652,30 @@ describe("ThreadMessages", () => {
expect(screen.getAllByRole("button", { name: "Copy reply" })).toHaveLength(1);
});
it("uses turn ids as activity grouping boundaries when available", () => {
const units = buildDisplayUnits([
{ id: "u1", role: "user", content: "one", turnId: "turn-1", createdAt: 1 },
{ id: "a1", role: "assistant", content: "answer one", turnId: "turn-1", createdAt: 2 },
{
id: "t2",
role: "tool",
kind: "trace",
content: "search()",
traces: ["search()"],
turnId: "turn-2",
createdAt: 3,
},
{ id: "a2", role: "assistant", content: "answer two", turnId: "turn-2", createdAt: 4 },
]);
expect(units.map((unit) => unit.type === "message" ? unit.message.id : "activity")).toEqual([
"u1",
"a1",
"activity",
"a2",
]);
});
it("computes final assistant copy flags with user-boundary semantics", () => {
const units = buildDisplayUnits([
{ id: "u1", role: "user", content: "one", createdAt: 1 },
+58 -21
View File
@@ -78,6 +78,20 @@ function wrap(client: ReturnType<typeof makeClient>, children: ReactNode, modelN
);
}
function expectSendMessageWithTurn(
client: ReturnType<typeof makeClient>,
chatId: string,
content: string,
options: unknown = undefined,
) {
expect(client.sendMessage).toHaveBeenCalledWith(
chatId,
content,
options,
expect.objectContaining({ turnId: expect.any(String) }),
);
}
function session(chatId: string) {
return {
key: `websocket:${chatId}`,
@@ -270,6 +284,45 @@ describe("ThreadShell", () => {
expect(await screen.findByTestId("composer-model-logo-openai_codex")).toBeInTheDocument();
});
it("opens model settings from the unconfigured model badge", async () => {
const client = makeClient();
const settings = modelSettings("openai-codex/gpt-5.1-codex", "openai_codex");
settings.agent.has_api_key = false;
settings.providers = settings.providers.map((provider) =>
provider.name === "openai_codex"
? { ...provider, auth_type: "oauth", configured: false }
: provider,
);
const onOpenModelSettings = vi.fn();
render(
wrap(
client,
<ThreadShell
session={session("unconfigured-model")}
title="Unconfigured model"
onToggleSidebar={() => {}}
settingsSnapshot={settings}
onOpenModelSettings={onOpenModelSettings}
/>,
"openai-codex/gpt-5.1-codex",
),
);
const badge = await screen.findByRole("button", { name: "Model not configured" });
expect(screen.getByTestId("composer-model-setup-icon")).toBeInTheDocument();
expect(screen.queryByTestId("composer-model-logo-openai_codex")).not.toBeInTheDocument();
fireEvent.click(badge);
expect(onOpenModelSettings).toHaveBeenCalledTimes(1);
fireEvent.change(screen.getByRole("textbox", { name: "Message input" }), {
target: { value: "hello" },
});
fireEvent.click(screen.getByRole("button", { name: "Configure model" }));
expect(onOpenModelSettings).toHaveBeenCalledTimes(2);
expect(client.sendMessage).not.toHaveBeenCalled();
});
it("keeps image generation controls out of the composer", async () => {
const client = makeClient();
const disabledSettings = modelSettings("deepseek-v4-pro", "deepseek");
@@ -339,11 +392,7 @@ describe("ThreadShell", () => {
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
await waitFor(() =>
expect(client.sendMessage).toHaveBeenCalledWith(
"chat-a",
"persist me across tabs",
undefined,
),
expectSendMessageWithTurn(client, "chat-a", "persist me across tabs"),
);
expect(screen.getByText("persist me across tabs")).toBeInTheDocument();
@@ -403,11 +452,7 @@ describe("ThreadShell", () => {
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
await waitFor(() =>
expect(client.sendMessage).toHaveBeenCalledWith(
"chat-a",
"delete me cleanly",
undefined,
),
expectSendMessageWithTurn(client, "chat-a", "delete me cleanly"),
);
expect(screen.getByText("delete me cleanly")).toBeInTheDocument();
@@ -506,11 +551,7 @@ describe("ThreadShell", () => {
});
await waitFor(() =>
expect(client.sendMessage).toHaveBeenCalledWith(
"chat-new",
"first message should stay",
undefined,
),
expectSendMessageWithTurn(client, "chat-new", "first message should stay"),
);
await waitFor(() =>
expect(screen.getByText("first message should stay")).toBeInTheDocument(),
@@ -575,7 +616,7 @@ describe("ThreadShell", () => {
});
await waitFor(() =>
expect(client.sendMessage).toHaveBeenCalledWith("chat-new", "/model", undefined),
expectSendMessageWithTurn(client, "chat-new", "/model"),
);
await act(async () => {
@@ -703,11 +744,7 @@ describe("ThreadShell", () => {
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
await waitFor(() =>
expect(client.sendMessage).toHaveBeenCalledWith(
"chat-a",
"only in chat a",
undefined,
),
expectSendMessageWithTurn(client, "chat-a", "only in chat a"),
);
expect(screen.getByText("only in chat a")).toBeInTheDocument();
+120 -4
View File
@@ -1,10 +1,13 @@
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { useRef } from "react";
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { PromptNavigator } from "@/components/thread/PromptNavigator";
import {
HISTORY_WINDOW_INCREMENT,
INITIAL_HISTORY_WINDOW,
ThreadViewport,
type ThreadViewportHandle,
windowMessages,
} from "@/components/thread/ThreadViewport";
import type { UIMessage } from "@/lib/types";
@@ -35,6 +38,24 @@ function makeLongMessages(count: number): UIMessage[] {
}));
}
function ViewportWithPromptNavigator({ messages }: { messages: UIMessage[] }) {
const viewportRef = useRef<ThreadViewportHandle | null>(null);
return (
<div>
<PromptNavigator
messages={messages}
onJumpToPrompt={(promptId) => viewportRef.current?.jumpToUserPrompt(promptId)}
/>
<ThreadViewport
ref={viewportRef}
messages={messages}
isStreaming={false}
composer={<div />}
/>
</div>
);
}
describe("ThreadViewport", () => {
it("keeps the scroll-to-bottom button above a growing composer", () => {
const originalResizeObserver = globalThis.ResizeObserver;
@@ -75,7 +96,9 @@ describe("ThreadViewport", () => {
});
const button = screen.getByRole("button", { name: "Scroll to bottom" });
expect(button).toHaveStyle({ bottom: "192px" });
const buttonPositioner = button.parentElement as HTMLElement;
expect(button).not.toHaveClass("-translate-x-1/2");
expect(buttonPositioner).toHaveStyle({ bottom: "192px" });
const composerDock = screen.getByTestId("thread-composer-dock");
composerDock.getBoundingClientRect = () =>
@@ -100,7 +123,7 @@ describe("ThreadViewport", () => {
composerObserver!.callback([], composerObserver as unknown as ResizeObserver);
});
expect(button).toHaveStyle({ bottom: "256px" });
expect(buttonPositioner).toHaveStyle({ bottom: "256px" });
} finally {
vi.stubGlobal("ResizeObserver", originalResizeObserver);
}
@@ -207,7 +230,10 @@ describe("ThreadViewport", () => {
expect(screen.getByLabelText("User prompt navigation")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Jump to prompt: message 3" }));
const targetPrompt = screen.getByRole("button", { name: "Jump to prompt: message 3" });
expect(within(targetPrompt).getByText("message 3")).toBeInTheDocument();
fireEvent.click(targetPrompt);
expect(scrollTo).toHaveBeenCalledWith({
top: 1064,
@@ -215,6 +241,96 @@ describe("ThreadViewport", () => {
});
});
it("opens a prompt navigator list and jumps to a selected prompt", async () => {
const promptMessages = makeLongMessages(5);
const { container } = render(<ViewportWithPromptNavigator messages={promptMessages} />);
const scroller = container.querySelector(".thread-viewport-scrollbar") as HTMLElement;
const scrollTo = vi.fn();
Object.defineProperties(scroller, {
scrollHeight: { configurable: true, value: 1800 },
clientHeight: { configurable: true, value: 600 },
scrollTop: { configurable: true, value: 0 },
scrollTo: { configurable: true, value: scrollTo },
});
const promptEls = Array.from(
container.querySelectorAll<HTMLElement>("[data-user-prompt-id]"),
);
promptEls.forEach((el, index) => {
Object.defineProperty(el, "offsetTop", {
configurable: true,
value: index * 360,
});
});
fireEvent.click(screen.getByRole("button", { name: "Open prompt navigator" }));
const dialog = screen.getByRole("dialog");
expect(within(dialog).getByText("Prompts")).toBeInTheDocument();
expect(within(dialog).getByText("message 4")).toBeInTheDocument();
fireEvent.change(within(dialog).getByRole("textbox", { name: "Search prompts" }), {
target: { value: "message 4" },
});
expect(within(dialog).queryByText("message 1")).not.toBeInTheDocument();
fireEvent.click(within(dialog).getByRole("button", { name: "Jump to prompt: message 4" }));
expect(scrollTo).toHaveBeenCalledWith({
top: 1424,
behavior: "smooth",
});
});
it("expands the history window before jumping to an older prompt from the navigator", async () => {
const longMessages = makeLongMessages(300);
render(<ViewportWithPromptNavigator messages={longMessages} />);
expect(screen.queryByText("message 20")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Open prompt navigator" }));
const dialog = screen.getByRole("dialog");
fireEvent.click(within(dialog).getByRole("button", { name: "Jump to prompt: message 20" }));
await waitFor(() => expect(screen.getByText("message 20")).toBeInTheDocument());
});
it("renders the prompt rail for compact scroll ranges", async () => {
const promptMessages = makeLongMessages(3);
const { container } = render(
<ThreadViewport
messages={promptMessages}
isStreaming={false}
composer={<div />}
/>,
);
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
Object.defineProperties(scroller, {
scrollHeight: { configurable: true, value: 700 },
clientHeight: { configurable: true, value: 600 },
scrollTop: { configurable: true, value: 0 },
});
const promptEls = Array.from(
container.querySelectorAll<HTMLElement>("[data-user-prompt-id]"),
);
expect(promptEls).toHaveLength(3);
promptEls.forEach((el, index) => {
Object.defineProperty(el, "offsetTop", {
configurable: true,
value: index * 50,
});
});
await act(async () => {
window.dispatchEvent(new Event("resize"));
await new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve()));
});
expect(screen.getByLabelText("User prompt navigation")).toBeInTheDocument();
});
it("buckets dense prompt rails without rendering every prompt as a marker", async () => {
const promptMessages = makeLongMessages(100);
const { container } = render(
+28 -1
View File
@@ -157,6 +157,28 @@ describe("useNanobotStream", () => {
expect(result.current.isStreaming).toBe(false);
});
it("preserves proactive automation source metadata on complete assistant messages", () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-cron", EMPTY_MESSAGES), {
wrapper: wrap(fake.client),
});
act(() => {
fake.emit("chat-cron", {
event: "message",
chat_id: "chat-cron",
text: "Time to drink water.",
source: { kind: "cron", label: "drink water" },
});
});
expect(result.current.messages[0]).toMatchObject({
role: "assistant",
content: "Time to drink water.",
source: { kind: "cron", label: "drink water" },
});
});
it("drops pending stream work when switching chats", async () => {
const fake = fakeClient();
const { result, rerender } = renderHook(
@@ -1342,6 +1364,8 @@ describe("useNanobotStream", () => {
expect(result.current.messages).toHaveLength(1);
expect(result.current.messages[0].role).toBe("user");
expect(result.current.messages[0].content).toBe("fine");
expect(result.current.messages[0].turnId).toEqual(expect.any(String));
expect(result.current.messages[0].turnPhase).toBe("user");
});
it("attaches assistant media_urls to complete messages", () => {
@@ -1482,7 +1506,10 @@ describe("useNanobotStream", () => {
"chat-img",
"draw a square icon",
undefined,
{ imageGeneration: { enabled: true, aspect_ratio: "1:1" } },
expect.objectContaining({
imageGeneration: { enabled: true, aspect_ratio: "1:1" },
turnId: expect.any(String),
}),
);
});