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:
+258
-119
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -9,15 +9,33 @@ interface CodeBlockProps {
|
||||
language?: string;
|
||||
code: string;
|
||||
className?: string;
|
||||
chrome?: "default" | "none";
|
||||
highlight?: boolean;
|
||||
showLineNumbers?: boolean;
|
||||
wrapLongLines?: boolean;
|
||||
}
|
||||
|
||||
interface HighlightedCodeProps {
|
||||
language?: string;
|
||||
code: string;
|
||||
isDark: boolean;
|
||||
chrome: "default" | "none";
|
||||
showLineNumbers: boolean;
|
||||
wrapLongLines: boolean;
|
||||
}
|
||||
|
||||
const CODE_FONT_STACK = [
|
||||
'"JetBrains Mono"',
|
||||
'"SFMono-Regular"',
|
||||
'"SF Mono"',
|
||||
'"Fira Code"',
|
||||
'"Cascadia Code"',
|
||||
'"Source Code Pro"',
|
||||
"Menlo",
|
||||
"Consolas",
|
||||
"monospace",
|
||||
].join(", ");
|
||||
|
||||
const LazyHighlightedCode = lazy(async () => {
|
||||
const [
|
||||
{ default: SyntaxHighlighter },
|
||||
@@ -30,19 +48,56 @@ const LazyHighlightedCode = lazy(async () => {
|
||||
]);
|
||||
|
||||
return {
|
||||
default({ language, code, isDark }: HighlightedCodeProps) {
|
||||
default({
|
||||
language,
|
||||
code,
|
||||
isDark,
|
||||
chrome,
|
||||
showLineNumbers,
|
||||
wrapLongLines,
|
||||
}: HighlightedCodeProps) {
|
||||
const theme = isDark ? oneDark : oneLight;
|
||||
const transparentTheme = chrome === "none" ? {
|
||||
...theme,
|
||||
'pre[class*="language-"]': {
|
||||
...theme['pre[class*="language-"]'],
|
||||
background: "transparent",
|
||||
},
|
||||
'code[class*="language-"]': {
|
||||
...theme['code[class*="language-"]'],
|
||||
background: "transparent",
|
||||
},
|
||||
} : theme;
|
||||
|
||||
return (
|
||||
<SyntaxHighlighter
|
||||
language={language || "text"}
|
||||
style={isDark ? oneDark : oneLight}
|
||||
style={transparentTheme}
|
||||
customStyle={{
|
||||
background: chrome === "none" ? "transparent" : undefined,
|
||||
margin: 0,
|
||||
padding: "1rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: 1.6,
|
||||
padding: chrome === "none" ? "0.75rem 1rem" : "1rem",
|
||||
fontFamily: CODE_FONT_STACK,
|
||||
fontSize: chrome === "none" ? "13px" : "0.875rem",
|
||||
lineHeight: chrome === "none" ? 1.55 : 1.6,
|
||||
tabSize: 2,
|
||||
}}
|
||||
codeTagProps={{
|
||||
style: chrome === "none" ? {
|
||||
background: "transparent",
|
||||
fontFamily: CODE_FONT_STACK,
|
||||
} : undefined,
|
||||
}}
|
||||
lineNumberStyle={{
|
||||
minWidth: "2.6em",
|
||||
paddingRight: "1.15rem",
|
||||
color: isDark ? "rgba(212, 212, 216, 0.45)" : "rgba(63, 63, 70, 0.68)",
|
||||
fontFamily: CODE_FONT_STACK,
|
||||
userSelect: "none",
|
||||
}}
|
||||
PreTag="pre"
|
||||
wrapLongLines
|
||||
showLineNumbers={showLineNumbers}
|
||||
wrapLongLines={wrapLongLines}
|
||||
>
|
||||
{code}
|
||||
</SyntaxHighlighter>
|
||||
@@ -51,13 +106,39 @@ const LazyHighlightedCode = lazy(async () => {
|
||||
};
|
||||
});
|
||||
|
||||
function PlainCodeFallback({ code }: { code: string }) {
|
||||
function PlainCodeFallback({
|
||||
code,
|
||||
chrome,
|
||||
showLineNumbers,
|
||||
}: {
|
||||
code: string;
|
||||
chrome: "default" | "none";
|
||||
showLineNumbers: boolean;
|
||||
}) {
|
||||
const lines = code.split("\n");
|
||||
return (
|
||||
<pre
|
||||
className="m-0 overflow-x-auto whitespace-pre-wrap bg-background p-4 font-mono text-sm leading-[1.6] text-foreground/90"
|
||||
className={cn(
|
||||
"m-0 overflow-x-auto p-4 font-mono text-sm leading-[1.6] text-foreground/90",
|
||||
showLineNumbers ? "whitespace-pre" : "whitespace-pre-wrap",
|
||||
chrome === "default" ? "bg-background" : "bg-transparent",
|
||||
chrome === "none" && "p-3 text-[13px] leading-[1.55]",
|
||||
)}
|
||||
data-testid="plain-code-fallback"
|
||||
>
|
||||
<code className="text-inherit">{code}</code>
|
||||
<code className="text-inherit">
|
||||
{showLineNumbers ? (
|
||||
lines.map((line, index) => (
|
||||
<span key={index} className="flex min-w-max">
|
||||
<span className="w-10 shrink-0 select-none pr-4 text-right text-muted-foreground/60">
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className="whitespace-pre">{line || " "}</span>
|
||||
{index < lines.length - 1 ? "\n" : null}
|
||||
</span>
|
||||
))
|
||||
) : code}
|
||||
</code>
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
@@ -66,11 +147,15 @@ export function CodeBlock({
|
||||
language,
|
||||
code,
|
||||
className,
|
||||
chrome = "default",
|
||||
highlight = true,
|
||||
showLineNumbers = false,
|
||||
wrapLongLines = true,
|
||||
}: CodeBlockProps) {
|
||||
const { t } = useTranslation();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const isDark = useThemeValue() === "dark";
|
||||
const hasChrome = chrome === "default";
|
||||
|
||||
const onCopy = useCallback(() => {
|
||||
if (!navigator.clipboard) return;
|
||||
@@ -83,47 +168,69 @@ export function CodeBlock({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden rounded-lg border",
|
||||
isDark ? "border-white/10" : "border-black/10",
|
||||
"overflow-hidden",
|
||||
hasChrome && "rounded-lg border",
|
||||
hasChrome && (isDark ? "border-white/10" : "border-black/10"),
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between px-4 py-1.5 text-xs font-medium",
|
||||
isDark
|
||||
? "bg-zinc-800 text-zinc-300"
|
||||
: "bg-zinc-100 text-zinc-600",
|
||||
)}
|
||||
>
|
||||
<span className="lowercase font-mono">
|
||||
{language || t("code.fallbackLanguage")}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCopy}
|
||||
{hasChrome ? (
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded px-1.5 py-0.5 font-mono transition-colors",
|
||||
"flex items-center justify-between px-4 py-1.5 text-xs font-medium",
|
||||
isDark
|
||||
? "text-zinc-400 hover:bg-zinc-700 hover:text-zinc-200"
|
||||
: "text-zinc-500 hover:bg-zinc-200 hover:text-zinc-700",
|
||||
? "bg-zinc-800 text-zinc-300"
|
||||
: "bg-zinc-100 text-zinc-600",
|
||||
)}
|
||||
aria-label={t("code.copyAria")}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
)}
|
||||
<span>{copied ? t("code.copied") : t("code.copy")}</span>
|
||||
</button>
|
||||
</div>
|
||||
<span className="lowercase font-mono">
|
||||
{language || t("code.fallbackLanguage")}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCopy}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded px-1.5 py-0.5 font-mono transition-colors",
|
||||
isDark
|
||||
? "text-zinc-400 hover:bg-zinc-700 hover:text-zinc-200"
|
||||
: "text-zinc-500 hover:bg-zinc-200 hover:text-zinc-700",
|
||||
)}
|
||||
aria-label={t("code.copyAria")}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
)}
|
||||
<span>{copied ? t("code.copied") : t("code.copy")}</span>
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{highlight ? (
|
||||
<Suspense fallback={<PlainCodeFallback code={code} />}>
|
||||
<LazyHighlightedCode language={language} code={code} isDark={isDark} />
|
||||
<Suspense
|
||||
fallback={
|
||||
<PlainCodeFallback
|
||||
code={code}
|
||||
chrome={chrome}
|
||||
showLineNumbers={showLineNumbers}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<LazyHighlightedCode
|
||||
language={language}
|
||||
code={code}
|
||||
isDark={isDark}
|
||||
chrome={chrome}
|
||||
showLineNumbers={showLineNumbers}
|
||||
wrapLongLines={wrapLongLines}
|
||||
/>
|
||||
</Suspense>
|
||||
) : (
|
||||
<PlainCodeFallback code={code} />
|
||||
<PlainCodeFallback
|
||||
code={code}
|
||||
chrome={chrome}
|
||||
showLineNumbers={showLineNumbers}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,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>
|
||||
);
|
||||
}
|
||||
@@ -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":
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ interface ThreadMessagesProps {
|
||||
onLoadEarlier?: () => void;
|
||||
cliApps?: CliAppInfo[];
|
||||
mcpPresets?: McpPresetInfo[];
|
||||
onOpenFilePreview?: (path: string) => void;
|
||||
}
|
||||
|
||||
export type DisplayUnit = TurnUnit;
|
||||
@@ -33,8 +34,13 @@ export function isFinalAssistantSliceBeforeNextUser(
|
||||
return true;
|
||||
}
|
||||
|
||||
export function buildDisplayUnits(messages: UIMessage[]): DisplayUnit[] {
|
||||
return normalizeActivityTimeline(messages);
|
||||
export function buildDisplayUnits(
|
||||
messages: UIMessage[],
|
||||
isStreaming = false,
|
||||
): DisplayUnit[] {
|
||||
return normalizeActivityTimeline(messages, {
|
||||
preserveTrailingActivity: isStreaming,
|
||||
});
|
||||
}
|
||||
|
||||
export function assistantCopyFlags(units: DisplayUnit[]): boolean[] {
|
||||
@@ -61,9 +67,10 @@ export function ThreadMessages({
|
||||
onLoadEarlier,
|
||||
cliApps = [],
|
||||
mcpPresets = [],
|
||||
onOpenFilePreview,
|
||||
}: ThreadMessagesProps) {
|
||||
const { t } = useTranslation();
|
||||
const units = useMemo(() => buildDisplayUnits(messages), [messages]);
|
||||
const units = useMemo(() => buildDisplayUnits(messages, isStreaming), [isStreaming, messages]);
|
||||
const copyFlags = useMemo(() => assistantCopyFlags(units), [units]);
|
||||
const liveActivityClusterIndices = useMemo(
|
||||
() => isStreaming ? currentActivityClusterIndices(units) : new Set<number>(),
|
||||
@@ -117,6 +124,7 @@ export function ThreadMessages({
|
||||
turnLatencyMs={unit.turnLatencyMs}
|
||||
cliApps={cliApps}
|
||||
mcpPresets={mcpPresets}
|
||||
onOpenFilePreview={onOpenFilePreview}
|
||||
/>
|
||||
) : (
|
||||
<MessageBubble
|
||||
@@ -128,6 +136,7 @@ export function ThreadMessages({
|
||||
}
|
||||
cliApps={cliApps}
|
||||
mcpPresets={mcpPresets}
|
||||
onOpenFilePreview={onOpenFilePreview}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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))) {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 d’images",
|
||||
"workspace": "Espace de travail"
|
||||
},
|
||||
"usage": {
|
||||
"title": "Activité des tokens",
|
||||
"shortTitle": "Token Usage",
|
||||
"subtitle": "Usage signalé par le fournisseur sur les 12 derniers mois.",
|
||||
"empty": "L’activité des tokens apparaîtra après les nouvelles réponses du modèle.",
|
||||
"totalTokens": "Tokens cumulés",
|
||||
"peakTokens": "Pic de tokens",
|
||||
"thirtyDayTokens": "Tokens sur 30 jours",
|
||||
"currentStreak": "Série actuelle",
|
||||
"longestStreak": "Plus longue série",
|
||||
"daysValue": "{{count}} j",
|
||||
"last30": "30 jours",
|
||||
"activeDays": "Jours actifs",
|
||||
"requests": "Requêtes",
|
||||
"estimated": "estimé",
|
||||
"includesEstimates": "inclut des estimations",
|
||||
"cellTitle": "{{date}} : {{tokens}} tokens, {{requests}} requêtes",
|
||||
"sources": {
|
||||
"user": "Chat",
|
||||
"api": "API",
|
||||
"cron": "Automatisations",
|
||||
"dream": "Mémoire",
|
||||
"system": "Système"
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"searchPlaceholder": "Rechercher des fournisseurs",
|
||||
"noMatches": "Aucun fournisseur ne correspond.",
|
||||
@@ -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 d’instruction 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 d’environnement",
|
||||
"missingCommands": "CLI manquant",
|
||||
"missingEnvironment": "ENV manquant",
|
||||
"unavailableReasonLabel": "Raison d’indisponibilité",
|
||||
"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 l’en-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 l’objectif",
|
||||
"send": "Envoyer le message",
|
||||
"stop": "Arrêter la réponse",
|
||||
"modelNotConfigured": "Modèle non configuré",
|
||||
"configureModel": "Configurer le modèle",
|
||||
"queued": {
|
||||
"label": "Guidage en attente",
|
||||
"guide": "Guider",
|
||||
@@ -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 l’image",
|
||||
@@ -733,6 +826,15 @@
|
||||
"next": "Image suivante",
|
||||
"close": "Fermer l’aperçu"
|
||||
},
|
||||
"filePreview": {
|
||||
"aria": "Aperçu du fichier",
|
||||
"close": "Fermer l’aperçu du fichier",
|
||||
"loading": "Chargement de l’aperçu...",
|
||||
"failed": "Impossible de prévisualiser ce fichier.",
|
||||
"routeMissing": "L’aperçu du fichier nécessite le dernier gateway. Redémarrez nanobot gateway puis réessayez.",
|
||||
"resize": "Redimensionner l’aperçu du fichier",
|
||||
"truncated": "L’aperçu est tronqué car le fichier est volumineux."
|
||||
},
|
||||
"code": {
|
||||
"fallbackLanguage": "code",
|
||||
"copyAria": "Copier le code",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "コードをコピー",
|
||||
|
||||
@@ -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": "코드 복사",
|
||||
|
||||
@@ -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ã",
|
||||
|
||||
@@ -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": "复制代码",
|
||||
|
||||
@@ -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": "複製程式碼",
|
||||
|
||||
@@ -38,6 +38,10 @@ export type TurnUnit =
|
||||
| { type: "activity"; messages: UIMessage[]; items: ActivityItem[]; turnLatencyMs?: number }
|
||||
| { type: "message"; message: UIMessage };
|
||||
|
||||
interface NormalizeActivityTimelineOptions {
|
||||
preserveTrailingActivity?: boolean;
|
||||
}
|
||||
|
||||
export function isReasoningOnlyAssistant(message: UIMessage): boolean {
|
||||
if (message.role !== "assistant" || message.kind === "trace") return false;
|
||||
if (message.content.trim().length > 0) return false;
|
||||
@@ -48,24 +52,30 @@ export function isAgentActivityMember(message: UIMessage): boolean {
|
||||
return isReasoningOnlyAssistant(message) || message.kind === "trace";
|
||||
}
|
||||
|
||||
export function normalizeActivityTimeline(messages: UIMessage[]): TurnUnit[] {
|
||||
export function normalizeActivityTimeline(
|
||||
messages: UIMessage[],
|
||||
options: NormalizeActivityTimelineOptions = {},
|
||||
): TurnUnit[] {
|
||||
const units: TurnUnit[] = [];
|
||||
let turnMessages: UIMessage[] = [];
|
||||
let activeTurnId: string | undefined;
|
||||
|
||||
const flushTurn = () => {
|
||||
const flushTurn = (flushOptions: NormalizeActivityTimelineOptions = {}) => {
|
||||
if (turnMessages.length === 0) return;
|
||||
|
||||
const visibleMessages = visibleMessagesForTurn(turnMessages);
|
||||
const turnUnits: TurnUnit[] = [];
|
||||
const orderedTurnMessages = orderMessagesByTurnSeq(turnMessages);
|
||||
const visibleMessages = visibleMessagesForTurn(orderedTurnMessages);
|
||||
let visibleIndex = 0;
|
||||
let activityMessages: UIMessage[] = [];
|
||||
|
||||
const flushActivityMessages = () => {
|
||||
if (!activityMessages.length) return;
|
||||
pushActivityUnits(units, activityMessages, visibleMessages.slice(visibleIndex));
|
||||
pushActivityUnits(turnUnits, activityMessages, visibleMessages.slice(visibleIndex));
|
||||
activityMessages = [];
|
||||
};
|
||||
|
||||
for (const message of turnMessages) {
|
||||
for (const message of orderedTurnMessages) {
|
||||
if (isAgentActivityMember(message)) {
|
||||
activityMessages.push(message);
|
||||
continue;
|
||||
@@ -74,34 +84,87 @@ export function normalizeActivityTimeline(messages: UIMessage[]): TurnUnit[] {
|
||||
if (assistantHasInlineReasoning(message)) {
|
||||
activityMessages.push(reasoningOnlyMessageFromAnswer(message));
|
||||
flushActivityMessages();
|
||||
units.push({ type: "message", message: stripInlineReasoning(message) });
|
||||
turnUnits.push({ type: "message", message: stripInlineReasoning(message) });
|
||||
visibleIndex += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
flushActivityMessages();
|
||||
units.push({ type: "message", message });
|
||||
turnUnits.push({ type: "message", message });
|
||||
visibleIndex += 1;
|
||||
}
|
||||
|
||||
flushActivityMessages();
|
||||
units.push(...normalizeCompletedTurnUnits(turnUnits, flushOptions));
|
||||
turnMessages = [];
|
||||
activeTurnId = undefined;
|
||||
};
|
||||
|
||||
for (const message of messages) {
|
||||
if (message.role === "user") {
|
||||
flushTurn();
|
||||
units.push({ type: "message", message });
|
||||
activeTurnId = message.turnId;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (message.turnId && activeTurnId && message.turnId !== activeTurnId) {
|
||||
flushTurn();
|
||||
}
|
||||
if (message.turnId) {
|
||||
activeTurnId = message.turnId;
|
||||
}
|
||||
turnMessages.push(message);
|
||||
}
|
||||
|
||||
flushTurn();
|
||||
flushTurn(options);
|
||||
return units;
|
||||
}
|
||||
|
||||
function orderMessagesByTurnSeq(messages: UIMessage[]): UIMessage[] {
|
||||
if (
|
||||
messages.length < 2
|
||||
|| !messages.every((message) => Number.isFinite(message.turnSeq))
|
||||
) {
|
||||
return messages;
|
||||
}
|
||||
return messages
|
||||
.map((message, index) => ({ message, index }))
|
||||
.sort((left, right) => {
|
||||
const bySeq = (left.message.turnSeq ?? 0) - (right.message.turnSeq ?? 0);
|
||||
return bySeq || left.index - right.index;
|
||||
})
|
||||
.map(({ message }) => message);
|
||||
}
|
||||
|
||||
function normalizeCompletedTurnUnits(
|
||||
turnUnits: TurnUnit[],
|
||||
options: NormalizeActivityTimelineOptions,
|
||||
): TurnUnit[] {
|
||||
if (options.preserveTrailingActivity || turnUnits.length < 2) return turnUnits;
|
||||
if (turnUnits[turnUnits.length - 1]?.type !== "activity") return turnUnits;
|
||||
|
||||
let trailingStart = turnUnits.length - 1;
|
||||
while (trailingStart > 0 && turnUnits[trailingStart - 1]?.type === "activity") {
|
||||
trailingStart -= 1;
|
||||
}
|
||||
|
||||
const previous = turnUnits[trailingStart - 1];
|
||||
if (
|
||||
!previous
|
||||
|| previous.type !== "message"
|
||||
|| previous.message.role !== "assistant"
|
||||
) {
|
||||
return turnUnits;
|
||||
}
|
||||
|
||||
return [
|
||||
...turnUnits.slice(0, trailingStart - 1),
|
||||
...turnUnits.slice(trailingStart),
|
||||
previous,
|
||||
];
|
||||
}
|
||||
|
||||
function visibleMessagesForTurn(messages: UIMessage[]): UIMessage[] {
|
||||
const visibleMessages: UIMessage[] = [];
|
||||
for (const message of messages) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type {
|
||||
ChatSummary,
|
||||
CliAppsPayload,
|
||||
FilePreviewPayload,
|
||||
ImageGenerationSettingsUpdate,
|
||||
McpPresetsPayload,
|
||||
ModelConfigurationCreate,
|
||||
@@ -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 = "",
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 />);
|
||||
|
||||
|
||||
@@ -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></MarkdownTextRenderer>);
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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" },
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -151,7 +151,7 @@ describe("ThreadMessages", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("renders a later tool segment after the visible answer that preceded it", () => {
|
||||
it("moves orphan trailing activity before the completed assistant answer", () => {
|
||||
const messages: UIMessage[] = [
|
||||
{
|
||||
id: "r1",
|
||||
@@ -182,14 +182,14 @@ describe("ThreadMessages", () => {
|
||||
|
||||
expect(units).toHaveLength(3);
|
||||
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual(["r1"]);
|
||||
expect(units[1]).toMatchObject({
|
||||
expect(units[1].type === "activity" ? units[1].messages.map((m) => m.id) : []).toEqual(["t1"]);
|
||||
expect(units[2]).toMatchObject({
|
||||
type: "message",
|
||||
message: {
|
||||
id: "a1",
|
||||
content: "Let me search the latest data.",
|
||||
},
|
||||
});
|
||||
expect(units[2].type === "activity" ? units[2].messages.map((m) => m.id) : []).toEqual(["t1"]);
|
||||
});
|
||||
|
||||
it("only marks the current activity timeline as live while streaming", () => {
|
||||
@@ -324,7 +324,7 @@ describe("ThreadMessages", () => {
|
||||
},
|
||||
];
|
||||
|
||||
const units = buildDisplayUnits(messages);
|
||||
const units = buildDisplayUnits(messages, true);
|
||||
|
||||
expect(units).toHaveLength(3);
|
||||
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual(["t0"]);
|
||||
@@ -344,7 +344,7 @@ describe("ThreadMessages", () => {
|
||||
expect(answer.compareDocumentPosition(liveActivity) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
});
|
||||
|
||||
it("keeps late activity after a completed assistant answer", () => {
|
||||
it("moves late activity before a completed assistant answer", () => {
|
||||
const messages: UIMessage[] = [
|
||||
{
|
||||
id: "r1",
|
||||
@@ -376,21 +376,164 @@ describe("ThreadMessages", () => {
|
||||
|
||||
expect(units).toHaveLength(3);
|
||||
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual(["r1"]);
|
||||
expect(units[1]).toMatchObject({
|
||||
expect(units[1].type === "activity" ? units[1].messages.map((m) => m.id) : []).toEqual(["t1"]);
|
||||
expect(units[2]).toMatchObject({
|
||||
type: "message",
|
||||
message: {
|
||||
id: "a1",
|
||||
content: "Hong Kong is hot today.",
|
||||
},
|
||||
});
|
||||
expect(units[2].type === "activity" ? units[2].messages.map((m) => m.id) : []).toEqual(["t1"]);
|
||||
|
||||
render(<ThreadMessages messages={messages} isStreaming={false} />);
|
||||
|
||||
const answer = screen.getByText("Hong Kong is hot today.");
|
||||
const laterActivity = screen.getAllByText(/thought/i).at(-1);
|
||||
expect(laterActivity).toBeTruthy();
|
||||
expect(answer.compareDocumentPosition(laterActivity!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(laterActivity!.compareDocumentPosition(answer) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not leave a completed web-search thought below the final answer", () => {
|
||||
const messages: UIMessage[] = [
|
||||
{
|
||||
id: "user",
|
||||
role: "user",
|
||||
content: "最近科隆major开打了,你知道不?",
|
||||
createdAt: 1,
|
||||
},
|
||||
{
|
||||
id: "thought",
|
||||
role: "assistant",
|
||||
content: "",
|
||||
reasoning: "I should verify the current event details.",
|
||||
activitySegmentId: "seg-major",
|
||||
createdAt: 2,
|
||||
},
|
||||
{
|
||||
id: "answer",
|
||||
role: "assistant",
|
||||
content: "知道,IEM Cologne Major 2026 今天开打了。",
|
||||
latencyMs: 18_000,
|
||||
createdAt: 3,
|
||||
},
|
||||
{
|
||||
id: "web",
|
||||
role: "tool",
|
||||
kind: "trace",
|
||||
content: "Searching query: 2026 Cologne Major esports started 科隆 Major 开打了 2026",
|
||||
traces: ["Searching query: 2026 Cologne Major esports started 科隆 Major 开打了 2026"],
|
||||
activitySegmentId: "seg-major",
|
||||
createdAt: 4,
|
||||
},
|
||||
];
|
||||
|
||||
render(<ThreadMessages messages={messages} isStreaming={false} />);
|
||||
|
||||
const thought = screen.getAllByText(/thought/i).at(-1);
|
||||
const answer = screen.getByText("知道,IEM Cologne Major 2026 今天开打了。");
|
||||
expect(thought).toBeTruthy();
|
||||
expect(thought!.compareDocumentPosition(answer) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
});
|
||||
|
||||
it("normalizes completed prior turns while the next user turn is streaming", () => {
|
||||
const messages: UIMessage[] = [
|
||||
{
|
||||
id: "thought",
|
||||
role: "assistant",
|
||||
content: "",
|
||||
reasoning: "I should verify the current event details.",
|
||||
activitySegmentId: "seg-major",
|
||||
createdAt: 1,
|
||||
},
|
||||
{
|
||||
id: "answer",
|
||||
role: "assistant",
|
||||
content: "Yep — IEM Cologne Major 2026 is in Cologne.",
|
||||
latencyMs: 20_000,
|
||||
createdAt: 2,
|
||||
},
|
||||
{
|
||||
id: "web",
|
||||
role: "tool",
|
||||
kind: "trace",
|
||||
content: "Searching query: site:counter-strike.net majors 2026",
|
||||
traces: ["Searching query: site:counter-strike.net majors 2026"],
|
||||
activitySegmentId: "seg-major",
|
||||
createdAt: 3,
|
||||
},
|
||||
{
|
||||
id: "next-user",
|
||||
role: "user",
|
||||
content: "看一下目前的赛果,整个表哥",
|
||||
createdAt: 4,
|
||||
},
|
||||
];
|
||||
|
||||
const units = buildDisplayUnits(messages, true);
|
||||
|
||||
expect(units).toHaveLength(4);
|
||||
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual([
|
||||
"thought",
|
||||
]);
|
||||
expect(units[1].type === "activity" ? units[1].messages.map((m) => m.id) : []).toEqual([
|
||||
"web",
|
||||
]);
|
||||
expect(units[2]).toMatchObject({
|
||||
type: "message",
|
||||
message: { id: "answer" },
|
||||
});
|
||||
expect(units[3]).toMatchObject({
|
||||
type: "message",
|
||||
message: { id: "next-user" },
|
||||
});
|
||||
});
|
||||
|
||||
it("orders live turn activity by causal turn sequence before the final answer", () => {
|
||||
const messages: UIMessage[] = [
|
||||
{
|
||||
id: "web-1",
|
||||
role: "tool",
|
||||
kind: "trace",
|
||||
content: "Searching query: 2026 Counter-Strike 2 Major location",
|
||||
traces: ["Searching query: 2026 Counter-Strike 2 Major location"],
|
||||
turnId: "turn-major",
|
||||
turnSeq: 3,
|
||||
activitySegmentId: "seg-1",
|
||||
createdAt: 1,
|
||||
},
|
||||
{
|
||||
id: "answer",
|
||||
role: "assistant",
|
||||
content: "Yep — IEM Cologne Major 2026 is in Cologne.",
|
||||
isStreaming: true,
|
||||
turnId: "turn-major",
|
||||
turnSeq: 84,
|
||||
createdAt: 3,
|
||||
},
|
||||
{
|
||||
id: "web-2",
|
||||
role: "tool",
|
||||
kind: "trace",
|
||||
content: "Searching query: site:counter-strike.net majors 2026",
|
||||
traces: ["Searching query: site:counter-strike.net majors 2026"],
|
||||
turnId: "turn-major",
|
||||
turnSeq: 83,
|
||||
activitySegmentId: "seg-2",
|
||||
createdAt: 2,
|
||||
},
|
||||
];
|
||||
|
||||
const units = buildDisplayUnits(messages, true);
|
||||
|
||||
expect(units).toHaveLength(2);
|
||||
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual([
|
||||
"web-1",
|
||||
"web-2",
|
||||
]);
|
||||
expect(units[1]).toMatchObject({
|
||||
type: "message",
|
||||
message: { id: "answer" },
|
||||
});
|
||||
});
|
||||
|
||||
it("renders interrupted pre-tool text as activity before the final answer", () => {
|
||||
@@ -509,6 +652,30 @@ describe("ThreadMessages", () => {
|
||||
expect(screen.getAllByRole("button", { name: "Copy reply" })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("uses turn ids as activity grouping boundaries when available", () => {
|
||||
const units = buildDisplayUnits([
|
||||
{ id: "u1", role: "user", content: "one", turnId: "turn-1", createdAt: 1 },
|
||||
{ id: "a1", role: "assistant", content: "answer one", turnId: "turn-1", createdAt: 2 },
|
||||
{
|
||||
id: "t2",
|
||||
role: "tool",
|
||||
kind: "trace",
|
||||
content: "search()",
|
||||
traces: ["search()"],
|
||||
turnId: "turn-2",
|
||||
createdAt: 3,
|
||||
},
|
||||
{ id: "a2", role: "assistant", content: "answer two", turnId: "turn-2", createdAt: 4 },
|
||||
]);
|
||||
|
||||
expect(units.map((unit) => unit.type === "message" ? unit.message.id : "activity")).toEqual([
|
||||
"u1",
|
||||
"a1",
|
||||
"activity",
|
||||
"a2",
|
||||
]);
|
||||
});
|
||||
|
||||
it("computes final assistant copy flags with user-boundary semantics", () => {
|
||||
const units = buildDisplayUnits([
|
||||
{ id: "u1", role: "user", content: "one", createdAt: 1 },
|
||||
|
||||
@@ -78,6 +78,20 @@ function wrap(client: ReturnType<typeof makeClient>, children: ReactNode, modelN
|
||||
);
|
||||
}
|
||||
|
||||
function expectSendMessageWithTurn(
|
||||
client: ReturnType<typeof makeClient>,
|
||||
chatId: string,
|
||||
content: string,
|
||||
options: unknown = undefined,
|
||||
) {
|
||||
expect(client.sendMessage).toHaveBeenCalledWith(
|
||||
chatId,
|
||||
content,
|
||||
options,
|
||||
expect.objectContaining({ turnId: expect.any(String) }),
|
||||
);
|
||||
}
|
||||
|
||||
function session(chatId: string) {
|
||||
return {
|
||||
key: `websocket:${chatId}`,
|
||||
@@ -270,6 +284,45 @@ describe("ThreadShell", () => {
|
||||
expect(await screen.findByTestId("composer-model-logo-openai_codex")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens model settings from the unconfigured model badge", async () => {
|
||||
const client = makeClient();
|
||||
const settings = modelSettings("openai-codex/gpt-5.1-codex", "openai_codex");
|
||||
settings.agent.has_api_key = false;
|
||||
settings.providers = settings.providers.map((provider) =>
|
||||
provider.name === "openai_codex"
|
||||
? { ...provider, auth_type: "oauth", configured: false }
|
||||
: provider,
|
||||
);
|
||||
const onOpenModelSettings = vi.fn();
|
||||
|
||||
render(
|
||||
wrap(
|
||||
client,
|
||||
<ThreadShell
|
||||
session={session("unconfigured-model")}
|
||||
title="Unconfigured model"
|
||||
onToggleSidebar={() => {}}
|
||||
settingsSnapshot={settings}
|
||||
onOpenModelSettings={onOpenModelSettings}
|
||||
/>,
|
||||
"openai-codex/gpt-5.1-codex",
|
||||
),
|
||||
);
|
||||
|
||||
const badge = await screen.findByRole("button", { name: "Model not configured" });
|
||||
expect(screen.getByTestId("composer-model-setup-icon")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("composer-model-logo-openai_codex")).not.toBeInTheDocument();
|
||||
fireEvent.click(badge);
|
||||
expect(onOpenModelSettings).toHaveBeenCalledTimes(1);
|
||||
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "Message input" }), {
|
||||
target: { value: "hello" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Configure model" }));
|
||||
expect(onOpenModelSettings).toHaveBeenCalledTimes(2);
|
||||
expect(client.sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps image generation controls out of the composer", async () => {
|
||||
const client = makeClient();
|
||||
const disabledSettings = modelSettings("deepseek-v4-pro", "deepseek");
|
||||
@@ -339,11 +392,7 @@ describe("ThreadShell", () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(client.sendMessage).toHaveBeenCalledWith(
|
||||
"chat-a",
|
||||
"persist me across tabs",
|
||||
undefined,
|
||||
),
|
||||
expectSendMessageWithTurn(client, "chat-a", "persist me across tabs"),
|
||||
);
|
||||
expect(screen.getByText("persist me across tabs")).toBeInTheDocument();
|
||||
|
||||
@@ -403,11 +452,7 @@ describe("ThreadShell", () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(client.sendMessage).toHaveBeenCalledWith(
|
||||
"chat-a",
|
||||
"delete me cleanly",
|
||||
undefined,
|
||||
),
|
||||
expectSendMessageWithTurn(client, "chat-a", "delete me cleanly"),
|
||||
);
|
||||
expect(screen.getByText("delete me cleanly")).toBeInTheDocument();
|
||||
|
||||
@@ -506,11 +551,7 @@ describe("ThreadShell", () => {
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(client.sendMessage).toHaveBeenCalledWith(
|
||||
"chat-new",
|
||||
"first message should stay",
|
||||
undefined,
|
||||
),
|
||||
expectSendMessageWithTurn(client, "chat-new", "first message should stay"),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("first message should stay")).toBeInTheDocument(),
|
||||
@@ -575,7 +616,7 @@ describe("ThreadShell", () => {
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(client.sendMessage).toHaveBeenCalledWith("chat-new", "/model", undefined),
|
||||
expectSendMessageWithTurn(client, "chat-new", "/model"),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
@@ -703,11 +744,7 @@ describe("ThreadShell", () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(client.sendMessage).toHaveBeenCalledWith(
|
||||
"chat-a",
|
||||
"only in chat a",
|
||||
undefined,
|
||||
),
|
||||
expectSendMessageWithTurn(client, "chat-a", "only in chat a"),
|
||||
);
|
||||
expect(screen.getByText("only in chat a")).toBeInTheDocument();
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user