feat(webui): add project workspaces and access controls (#4007)

* feat(webui): add project workspaces and access controls

* feat(webui): add project workspaces and access controls

* refactor(tools): centralize workspace access resolution

* refactor(webui): remove unused workspace host state

* fix(webui): hide estimated file edit label

* fix(webui): clarify file edit deletion feedback

* fix(webui): label deleted file activity

* fix(webui): flatten file edit activity rows

* fix(core): remove path-only patch deletion

* fix(core): keep apply patch non-destructive

* refactor(webui): trim workspace host plumbing

* fix(tools): register exec with tools config
This commit is contained in:
Xubin Ren
2026-05-29 03:42:53 +08:00
committed by GitHub
parent 84428136e6
commit 3a420136bb
111 changed files with 9972 additions and 1822 deletions
+449 -117
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Menu, Moon, Sun } from "lucide-react";
import { useTranslation } from "react-i18next";
import { DeleteConfirm } from "@/components/DeleteConfirm";
import { RenameChatDialog } from "@/components/RenameChatDialog";
@@ -23,9 +24,21 @@ import {
import { deriveTitle } from "@/lib/format";
import { NanobotClient } from "@/lib/nanobot-client";
import { ClientProvider, useClient } from "@/providers/ClientProvider";
import type { ChatSummary } from "@/lib/types";
import type {
ChatSummary,
RuntimeSurface,
SettingsPayload,
WorkspaceScopePayload,
WorkspacesPayload,
} from "@/lib/types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { fetchSettings, fetchWorkspaces } from "@/lib/api";
import {
createRuntimeHost,
toRuntimeSurface,
} from "@/lib/runtime";
import { projectNameFromPath } from "@/lib/workspace";
type BootState =
| { status: "loading" }
@@ -37,6 +50,7 @@ type BootState =
token: string;
tokenExpiresAt: number;
modelName: string | null;
runtimeSurface: RuntimeSurface;
};
const SIDEBAR_STORAGE_KEY = "nanobot-webui.sidebar";
@@ -149,6 +163,67 @@ function writeCompletedRunChatIds(chatIds: Set<string>): void {
}
}
function normalizeWorkspaceScope(scope: WorkspaceScopePayload): WorkspaceScopePayload {
const accessMode = scope.access_mode === "restricted" ? "restricted" : "full";
return {
...scope,
project_name: scope.project_name ?? projectNameFromPath(scope.project_path),
access_mode: accessMode,
restrict_to_workspace: accessMode === "restricted",
};
}
function HostChrome({
onToggleSidebar,
theme,
onToggleTheme,
showThemeButton = true,
}: {
onToggleSidebar?: () => void;
theme: "light" | "dark";
onToggleTheme: () => void;
showThemeButton?: boolean;
}) {
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 ? (
<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"
>
{theme === "dark" ? (
<Sun className="h-4 w-4" />
) : (
<Moon className="h-4 w-4" />
)}
</Button>
) : (
<div aria-hidden className="h-8 w-8" />
)}
</header>
);
}
export default function App() {
const { t } = useTranslation();
const [state, setState] = useState<BootState>({ status: "loading" });
@@ -163,13 +238,20 @@ export default function App() {
const boot = await fetchBootstrap("", secret);
if (cancelled) return;
if (secret) saveSecret(secret);
const url = deriveWsUrl(boot.ws_path, boot.token);
const url = deriveWsUrl(boot.ws_path, boot.token, boot.ws_url);
const runtimeSurface = toRuntimeSurface(boot.runtime_surface);
const runtimeHost = createRuntimeHost(runtimeSurface, boot.runtime_capabilities);
const client = new NanobotClient({
url,
socketFactory: runtimeHost.socketFactory,
onReauth: async () => {
try {
const refreshed = await fetchBootstrap("", bootstrapSecretRef.current);
const refreshedUrl = deriveWsUrl(refreshed.ws_path, refreshed.token);
const refreshedUrl = deriveWsUrl(
refreshed.ws_path,
refreshed.token,
refreshed.ws_url,
);
const tokenExpiresAt = bootstrapTokenExpiresAt(refreshed.expires_in);
setState((current) =>
current.status === "ready" && current.client === client
@@ -178,6 +260,10 @@ export default function App() {
token: refreshed.token,
tokenExpiresAt,
modelName: refreshed.model_name ?? current.modelName,
runtimeSurface:
refreshed.runtime_surface
? toRuntimeSurface(refreshed.runtime_surface)
: current.runtimeSurface,
}
: current,
);
@@ -195,6 +281,7 @@ export default function App() {
token: boot.token,
tokenExpiresAt: bootstrapTokenExpiresAt(boot.expires_in),
modelName: boot.model_name ?? null,
runtimeSurface,
});
} catch (e) {
if (cancelled) return;
@@ -219,7 +306,7 @@ export default function App() {
const timer = window.setTimeout(async () => {
try {
const boot = await fetchBootstrap("", bootstrapSecretRef.current);
const url = deriveWsUrl(boot.ws_path, boot.token);
const url = deriveWsUrl(boot.ws_path, boot.token, boot.ws_url);
const tokenExpiresAt = bootstrapTokenExpiresAt(boot.expires_in);
client.updateUrl(url);
setState((current) =>
@@ -229,6 +316,9 @@ export default function App() {
token: boot.token,
tokenExpiresAt,
modelName: boot.model_name ?? current.modelName,
runtimeSurface: boot.runtime_surface
? toRuntimeSurface(boot.runtime_surface)
: current.runtimeSurface,
}
: current,
);
@@ -304,20 +394,26 @@ export default function App() {
token={state.token}
modelName={state.modelName}
>
<Shell onModelNameChange={handleModelNameChange} onLogout={handleLogout} />
<Shell
runtimeSurface={state.runtimeSurface}
onModelNameChange={handleModelNameChange}
onLogout={handleLogout}
/>
</ClientProvider>
);
}
function Shell({
runtimeSurface,
onModelNameChange,
onLogout,
}: {
runtimeSurface: RuntimeSurface;
onModelNameChange: (modelName: string | null) => void;
onLogout: () => void;
}) {
const { t, i18n } = useTranslation();
const { client } = useClient();
const { client, token } = useClient();
const { theme, toggle } = useTheme();
const { sessions, loading, refresh, createChat, deleteChat } = useSessions();
const { state: sidebarState, update: updateSidebarState } =
@@ -325,7 +421,7 @@ function Shell({
const [activeKey, setActiveKey] = useState<string | null>(null);
const [view, setView] = useState<ShellView>("chat");
const [settingsInitialSection, setSettingsInitialSection] = useState<SettingsSectionKey>("overview");
const [desktopSidebarOpen, setDesktopSidebarOpen] =
const [hostSidebarOpen, setHostSidebarOpen] =
useState<boolean>(readSidebarOpen);
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
const [sessionSearchOpen, setSessionSearchOpen] = useState(false);
@@ -337,23 +433,48 @@ function Shell({
key: string;
label: string;
} | null>(null);
const [pendingProjectRename, setPendingProjectRename] = useState<{
key: string;
label: string;
} | null>(null);
const restartSawDisconnectRef = useRef(false);
const [restartToast, setRestartToast] = useState<string | null>(null);
const [isRestarting, setIsRestarting] = useState(false);
const [runningChatIds, setRunningChatIds] = useState<Set<string>>(() => new Set());
const [completedChatIds, setCompletedChatIds] = useState<Set<string>>(readCompletedRunChatIds);
const [workspaces, setWorkspaces] = useState<WorkspacesPayload | null>(null);
const [settingsSnapshot, setSettingsSnapshot] = useState<SettingsPayload | null>(null);
const [workspaceError, setWorkspaceError] = useState<string | null>(null);
const [draftWorkspaceScope, setDraftWorkspaceScope] =
useState<WorkspaceScopePayload | null>(null);
const [workspaceOverrides, setWorkspaceOverrides] =
useState<Record<string, WorkspaceScopePayload>>({});
const runningChatIdsRef = useRef<Set<string>>(new Set());
useEffect(() => {
let cancelled = false;
fetchSettings(token)
.then((payload) => {
if (!cancelled) setSettingsSnapshot(payload);
})
.catch(() => {
if (!cancelled) setSettingsSnapshot(null);
});
return () => {
cancelled = true;
};
}, [token]);
useEffect(() => {
try {
window.localStorage.setItem(
SIDEBAR_STORAGE_KEY,
desktopSidebarOpen ? "1" : "0",
hostSidebarOpen ? "1" : "0",
);
} catch {
// ignore storage errors (private mode, etc.)
}
}, [desktopSidebarOpen]);
}, [hostSidebarOpen]);
useEffect(() => {
writeCompletedRunChatIds(completedChatIds);
@@ -365,6 +486,36 @@ function Shell({
}, [sessions, activeKey]);
const runningChatIdList = useMemo(() => Array.from(runningChatIds), [runningChatIds]);
const completedChatIdList = useMemo(() => Array.from(completedChatIds), [completedChatIds]);
const activeChatId = activeSession?.chatId ?? null;
const activeWorkspaceScope = useMemo<WorkspaceScopePayload | null>(() => {
if (activeChatId && workspaceOverrides[activeChatId]) {
return workspaceOverrides[activeChatId];
}
if (activeSession?.workspaceScope) {
return activeSession.workspaceScope;
}
return draftWorkspaceScope ?? workspaces?.default_scope ?? null;
}, [
activeChatId,
activeSession?.workspaceScope,
draftWorkspaceScope,
workspaceOverrides,
workspaces?.default_scope,
]);
const activeChatRunning = activeChatId ? runningChatIds.has(activeChatId) : false;
const refreshWorkspaces = useCallback(async () => {
try {
const payload = await fetchWorkspaces(token);
setWorkspaces(payload);
} catch {
setWorkspaces(null);
}
}, [token]);
useEffect(() => {
void refreshWorkspaces();
}, [refreshWorkspaces]);
useEffect(() => {
if (loading) return;
@@ -375,8 +526,34 @@ function Shell({
);
return next.size === current.size ? current : next;
});
setWorkspaceOverrides((current) => {
const entries = Object.entries(current).filter(([chatId]) => knownChatIds.has(chatId));
return entries.length === Object.keys(current).length ? current : Object.fromEntries(entries);
});
}, [loading, sessions]);
useEffect(() => {
return client.onSessionUpdate((_chatId, _scope, workspaceScope) => {
if (!workspaceScope) return;
const next = normalizeWorkspaceScope(workspaceScope);
setWorkspaceOverrides((current) => ({
...current,
[_chatId]: next,
}));
setDraftWorkspaceScope(next);
setWorkspaceError(null);
void refreshWorkspaces();
});
}, [client, refreshWorkspaces]);
useEffect(() => {
return client.onError((error) => {
if (error.kind !== "workspace_scope_rejected") return;
setWorkspaceError(t("errors.workspaceScopeRejected.body"));
void refreshWorkspaces();
});
}, [client, refreshWorkspaces, t]);
useEffect(() => {
if (loading) return;
const activeRunIds = sessions
@@ -408,12 +585,12 @@ function Shell({
});
}, [client, loading, sessions]);
const closeDesktopSidebar = useCallback(() => {
setDesktopSidebarOpen(false);
const closeHostSidebar = useCallback(() => {
setHostSidebarOpen(false);
}, []);
const openDesktopSidebar = useCallback(() => {
setDesktopSidebarOpen(true);
const openHostSidebar = useCallback(() => {
setHostSidebarOpen(true);
}, []);
const closeMobileSidebar = useCallback(() => {
@@ -421,38 +598,88 @@ function Shell({
}, []);
const toggleSidebar = useCallback(() => {
const isDesktop =
const isNativeHost =
typeof window !== "undefined" &&
window.matchMedia("(min-width: 1024px)").matches;
if (isDesktop) {
setDesktopSidebarOpen((v) => !v);
if (isNativeHost) {
setHostSidebarOpen((v) => !v);
} else {
setMobileSidebarOpen((v) => !v);
}
}, []);
const onCreateChat = useCallback(async () => {
const applyWorkspaceScope = useCallback(
(scope: WorkspaceScopePayload) => {
const next = normalizeWorkspaceScope(scope);
setWorkspaceError(null);
if (activeChatId) {
if (!activeChatRunning) {
client.setWorkspaceScope(activeChatId, next);
}
return;
}
setDraftWorkspaceScope(next);
},
[activeChatId, activeChatRunning, client],
);
const onCreateChat = useCallback(async (workspaceScope?: WorkspaceScopePayload | null) => {
try {
const chatId = await createChat();
const scope = workspaceScope ?? activeWorkspaceScope;
const chatId = await createChat(scope);
setActiveKey(`websocket:${chatId}`);
setView("chat");
setMobileSidebarOpen(false);
if (scope) {
setWorkspaceOverrides((current) => ({
...current,
[chatId]: normalizeWorkspaceScope(scope),
}));
}
return chatId;
} catch (e) {
console.error("Failed to create chat", e);
if (e instanceof Error && e.message.startsWith("workspace_scope_rejected:")) {
setWorkspaceError(t("errors.workspaceScopeRejected.body"));
}
return null;
}
}, [createChat]);
}, [activeWorkspaceScope, createChat, t]);
const onNewChat = useCallback(() => {
setActiveKey(null);
setDraftWorkspaceScope(null);
setWorkspaceError(null);
setView("chat");
setMobileSidebarOpen(false);
}, []);
const onNewChatInProject = useCallback(
(projectPath: string, projectName: string) => {
const base = workspaces?.default_scope ?? activeWorkspaceScope;
const trimmed = projectPath.trim();
if (!base || !trimmed) {
onNewChat();
return;
}
setActiveKey(null);
setDraftWorkspaceScope(normalizeWorkspaceScope({
project_path: trimmed,
project_name: projectName || projectNameFromPath(trimmed),
access_mode: base.access_mode,
restrict_to_workspace: base.access_mode === "restricted",
}));
setWorkspaceError(null);
setView("chat");
setMobileSidebarOpen(false);
},
[activeWorkspaceScope, onNewChat, workspaces?.default_scope],
);
const onSelectChat = useCallback(
(key: string) => {
const selectedChatId = sessions.find((session) => session.key === key)?.chatId;
const selected = sessions.find((session) => session.key === key);
const selectedChatId = selected?.chatId;
if (selectedChatId) {
setCompletedChatIds((current) => {
if (!current.has(selectedChatId)) return current;
@@ -461,6 +688,12 @@ function Shell({
return next;
});
}
if (selected?.workspaceScope) {
setDraftWorkspaceScope(normalizeWorkspaceScope(selected.workspaceScope));
} else {
setDraftWorkspaceScope(null);
}
setWorkspaceError(null);
setActiveKey(key);
setView("chat");
setMobileSidebarOpen(false);
@@ -512,6 +745,61 @@ function Shell({
[pendingRename, updateSidebarState],
);
const onToggleGroup = useCallback(
(groupId: string) => {
void updateSidebarState((current) => {
const collapsedGroups = { ...current.collapsed_groups };
if (groupId === "workspace:chats" || groupId === "date:all") {
if (collapsedGroups[groupId] === false) {
delete collapsedGroups[groupId];
} else {
collapsedGroups[groupId] = false;
}
return {
...current,
collapsed_groups: collapsedGroups,
};
}
if (collapsedGroups[groupId]) {
delete collapsedGroups[groupId];
} else {
collapsedGroups[groupId] = true;
}
return {
...current,
collapsed_groups: collapsedGroups,
};
});
},
[updateSidebarState],
);
const onRequestRenameProject = useCallback((key: string, label: string) => {
setPendingProjectRename({ key, label });
}, []);
const onConfirmProjectRename = useCallback(
(title: string) => {
if (!pendingProjectRename) return;
const key = pendingProjectRename.key;
setPendingProjectRename(null);
void updateSidebarState((current) => {
const projectNameOverrides = { ...current.project_name_overrides };
const cleaned = title.trim();
if (cleaned) {
projectNameOverrides[key] = cleaned;
} else {
delete projectNameOverrides[key];
}
return {
...current,
project_name_overrides: projectNameOverrides,
};
});
},
[pendingProjectRename, updateSidebarState],
);
const onToggleArchive = useCallback(
(key: string) => {
void updateSidebarState((current) => {
@@ -547,19 +835,6 @@ function Shell({
}));
}, [updateSidebarState]);
const onUpdateSidebarView = useCallback(
(viewUpdate: Partial<typeof sidebarState.view>) => {
void updateSidebarState((current) => ({
...current,
view: {
...current.view,
...viewUpdate,
},
}));
},
[updateSidebarState],
);
const onOpenSessionSearch = useCallback(() => {
setMobileSidebarOpen(false);
setSessionSearchOpen(true);
@@ -742,117 +1017,165 @@ function Shell({
onTogglePin,
onRequestRename,
onToggleArchive,
onToggleGroup,
onRequestRenameProject,
onNewChatInProject,
onOpenSettings,
onOpenApps,
onOpenSearch: onOpenSessionSearch,
activeUtility: view === "apps" ? "apps" as const : null,
onToggleArchived,
onUpdateView: onUpdateSidebarView,
pinnedKeys: sidebarState.pinned_keys,
archivedKeys: sidebarState.archived_keys,
titleOverrides: sidebarState.title_overrides,
projectNameOverrides: sidebarState.project_name_overrides,
collapsedGroups: sidebarState.collapsed_groups,
runningChatIds: runningChatIdList,
completedChatIds: completedChatIdList,
viewState: sidebarState.view,
showArchived: sidebarState.view.show_archived,
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";
return (
<ThemeProvider theme={theme}>
<div className="relative flex h-full w-full overflow-hidden">
{/* Desktop sidebar: in normal flow, so the thread area width stays honest. */}
{showMainSidebar ? (
<aside
<div
className={cn(
"relative h-full w-full overflow-hidden",
showHostChrome && "bg-sidebar",
)}
>
{showHostChrome ? (
<HostChrome
onToggleSidebar={showMainSidebar ? toggleSidebar : undefined}
theme={theme}
onToggleTheme={toggle}
showThemeButton={view !== "chat"}
/>
) : null}
<div
className={cn(
"relative flex h-full w-full overflow-hidden",
)}
>
{/* Host sidebar: in normal flow, so the thread area width stays honest. */}
{showMainSidebar ? (
<aside
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,
}}
>
<div
className={cn(
"absolute inset-y-0 left-0 h-full w-full overflow-hidden bg-sidebar",
!showHostChrome && "shadow-inner-right",
)}
>
<Sidebar
{...sidebarProps}
collapsed={!hostSidebarOpen}
hostChromeInset={showHostChrome}
onCollapse={closeHostSidebar}
onExpand={openHostSidebar}
/>
</div>
</aside>
) : null}
{showMainSidebar ? (
<Sheet
open={mobileSidebarOpen}
onOpenChange={(open) => setMobileSidebarOpen(open)}
>
<SheetContent
side="left"
showCloseButton={false}
aria-describedby={undefined}
className="p-0 lg:hidden"
style={{ width: SIDEBAR_WIDTH, maxWidth: SIDEBAR_WIDTH }}
>
<SheetTitle className="sr-only">{t("sidebar.navigation")}</SheetTitle>
<Sidebar
{...sidebarProps}
onCollapse={closeMobileSidebar}
containActionMenus
/>
</SheetContent>
</Sheet>
) : null}
<SessionSearchDialog
open={sessionSearchOpen}
onOpenChange={setSessionSearchOpen}
sessions={sessions}
activeKey={activeKey}
loading={loading}
titleOverrides={sidebarState.title_overrides}
onSelect={onSelectSearchResult}
/>
<main
className={cn(
"relative z-20 hidden shrink-0 overflow-hidden lg:block",
"transition-[width] duration-300 ease-out",
"relative flex h-full min-w-0 flex-1 flex-col overflow-hidden bg-background",
showHostChrome &&
"rounded-l-[28px] shadow-[-18px_0_32px_-30px_rgb(0_0_0/0.45)] dark:shadow-[-18px_0_32px_-30px_rgb(0_0_0/0.85)]",
)}
style={{
width: desktopSidebarOpen ? SIDEBAR_WIDTH : SIDEBAR_RAIL_WIDTH,
}}
>
<div
className="absolute inset-y-0 left-0 h-full w-full overflow-hidden bg-sidebar shadow-inner-right"
className={cn(
"absolute inset-0 flex flex-col",
view !== "chat" && "invisible pointer-events-none",
)}
>
<Sidebar
{...sidebarProps}
collapsed={!desktopSidebarOpen}
onCollapse={closeDesktopSidebar}
onExpand={openDesktopSidebar}
/>
</div>
</aside>
) : null}
{showMainSidebar ? (
<Sheet
open={mobileSidebarOpen}
onOpenChange={(open) => setMobileSidebarOpen(open)}
>
<SheetContent
side="left"
showCloseButton={false}
aria-describedby={undefined}
className="p-0 lg:hidden"
style={{ width: SIDEBAR_WIDTH, maxWidth: SIDEBAR_WIDTH }}
>
<SheetTitle className="sr-only">{t("sidebar.navigation")}</SheetTitle>
<Sidebar
{...sidebarProps}
onCollapse={closeMobileSidebar}
containActionMenus
/>
</SheetContent>
</Sheet>
) : null}
<SessionSearchDialog
open={sessionSearchOpen}
onOpenChange={setSessionSearchOpen}
sessions={sessions}
activeKey={activeKey}
loading={loading}
titleOverrides={sidebarState.title_overrides}
onSelect={onSelectSearchResult}
/>
<main className="relative flex h-full min-w-0 flex-1 flex-col">
<div
className={cn(
"absolute inset-0 flex flex-col",
view !== "chat" && "invisible pointer-events-none",
)}
>
<ThreadShell
session={activeSession}
title={headerTitle}
onToggleSidebar={toggleSidebar}
onNewChat={onNewChat}
onCreateChat={onCreateChat}
onTurnEnd={onTurnEnd}
theme={theme}
onToggleTheme={toggle}
hideSidebarToggleOnDesktop
/>
</div>
{view !== "chat" && (
<div className="absolute inset-0 flex flex-col">
<SettingsView
<ThreadShell
session={activeSession}
title={headerTitle}
onToggleSidebar={toggleSidebar}
onNewChat={onNewChat}
onCreateChat={onCreateChat}
onTurnEnd={onTurnEnd}
theme={theme}
initialSection={settingsInitialSection}
showSidebar={view === "settings"}
onToggleTheme={toggle}
onBackToChat={onBackToChat}
onModelNameChange={onModelNameChange}
onLogout={onLogout}
onRestart={onRestart}
isRestarting={isRestarting}
hideSidebarToggleForHostChrome
hideHeader={false}
workspaceScope={activeWorkspaceScope}
workspaceDefaultScope={workspaces?.default_scope ?? null}
workspaceControls={workspaces?.controls ?? null}
workspaceScopeDisabled={activeChatRunning}
workspaceError={workspaceError}
onWorkspaceScopeChange={applyWorkspaceScope}
settingsSnapshot={settingsSnapshot}
/>
</div>
)}
</main>
{view !== "chat" && (
<div className="absolute inset-0 flex flex-col">
<SettingsView
theme={theme}
initialSection={settingsInitialSection}
showSidebar={view === "settings"}
onToggleTheme={toggle}
onBackToChat={onBackToChat}
onModelNameChange={onModelNameChange}
onSettingsChange={setSettingsSnapshot}
onWorkspaceSettingsChange={refreshWorkspaces}
onLogout={onLogout}
onRestart={onRestart}
isRestarting={isRestarting}
hostChromeInset={showHostChrome}
/>
</div>
)}
</main>
</div>
<DeleteConfirm
open={!!pendingDelete}
@@ -866,6 +1189,15 @@ function Shell({
onCancel={() => setPendingRename(null)}
onConfirm={onConfirmRename}
/>
<RenameChatDialog
open={!!pendingProjectRename}
title={pendingProjectRename?.label ?? ""}
dialogTitle={t("chat.renameProjectTitle")}
description={t("chat.renameProjectDescription")}
placeholder={t("chat.renameProjectPlaceholder")}
onCancel={() => setPendingProjectRename(null)}
onConfirm={onConfirmProjectRename}
/>
{restartToast ? (
<div
role="status"
+348 -316
View File
@@ -7,10 +7,12 @@ import {
import {
Archive,
ArchiveRestore,
Folder,
MoreHorizontal,
Pencil,
Pin,
PinOff,
Plus,
Trash2,
} from "lucide-react";
import { useTranslation } from "react-i18next";
@@ -22,6 +24,17 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { deriveTitle, relativeTime } from "@/lib/format";
import {
COLLAPSED_CHATS_VISIBLE_COUNT,
displayTitle,
groupSessions,
isCollapsedProject,
isFoldableChatsGroup,
isFoldedChatsGroup,
limitGroups,
visibleSessionsForGroup,
type ChatGroupLabels,
} from "@/lib/chat-groups";
import { cn } from "@/lib/utils";
import type { ChatSummary, SidebarDensity, SidebarSortMode } from "@/lib/types";
@@ -36,9 +49,14 @@ interface ChatListProps {
onTogglePin: (key: string) => void;
onRequestRename: (key: string, label: string) => void;
onToggleArchive: (key: string) => void;
onToggleGroup?: (groupId: string) => void;
onRequestRenameProject?: (projectKey: string, label: string) => void;
onNewChatInProject?: (projectPath: string, projectName: string) => void;
pinnedKeys?: string[];
archivedKeys?: string[];
titleOverrides?: Record<string, string>;
projectNameOverrides?: Record<string, string>;
collapsedGroups?: Record<string, boolean>;
runningChatIds?: string[];
completedChatIds?: string[];
density?: SidebarDensity;
@@ -46,6 +64,7 @@ interface ChatListProps {
showTimestamps?: boolean;
sort?: SidebarSortMode;
showArchived?: boolean;
defaultWorkspacePath?: string | null;
actionMenuPortalContainer?: HTMLElement | null;
loading?: boolean;
emptyLabel?: string;
@@ -59,9 +78,14 @@ export const ChatList = memo(function ChatList({
onTogglePin,
onRequestRename,
onToggleArchive,
onToggleGroup,
onRequestRenameProject,
onNewChatInProject,
pinnedKeys = [],
archivedKeys = [],
titleOverrides = {},
projectNameOverrides = {},
collapsedGroups = {},
runningChatIds = [],
completedChatIds = [],
density = "comfortable",
@@ -69,19 +93,21 @@ export const ChatList = memo(function ChatList({
showTimestamps = false,
sort = "updated_desc",
showArchived = false,
defaultWorkspacePath,
actionMenuPortalContainer,
loading,
emptyLabel,
}: ChatListProps) {
const { t } = useTranslation();
const [visibleLimit, setVisibleLimit] = useState(INITIAL_VISIBLE_SESSIONS);
const labels = useMemo(() => ({
const labels = useMemo<ChatGroupLabels>(() => ({
pinned: t("chat.groups.pinned"),
all: t("chat.groups.all"),
today: t("chat.groups.today"),
yesterday: t("chat.groups.yesterday"),
earlier: t("chat.groups.earlier"),
archived: t("chat.groups.archived"),
projects: t("chat.groups.projects"),
fallbackTitle: t("chat.newChat"),
}), [t]);
const groups = useMemo(
@@ -89,8 +115,10 @@ export const ChatList = memo(function ChatList({
pinnedKeys,
archivedKeys,
titleOverrides,
projectNameOverrides,
showArchived,
sort,
defaultWorkspacePath,
}),
[
archivedKeys,
@@ -100,15 +128,21 @@ export const ChatList = memo(function ChatList({
showArchived,
sort,
titleOverrides,
projectNameOverrides,
defaultWorkspacePath,
],
);
const limitedGroups = useMemo(
() => limitGroups(groups, visibleLimit, activeKey),
[activeKey, groups, visibleLimit],
() => limitGroups(groups, visibleLimit, activeKey, collapsedGroups),
[activeKey, collapsedGroups, groups, visibleLimit],
);
const totalSessionCount = useMemo(
() => groups.reduce((total, group) => total + group.sessions.length, 0),
[groups],
() => groups.reduce(
(total, group) =>
total + (isCollapsedProject(group, collapsedGroups) ? 0 : group.sessions.length),
0,
),
[collapsedGroups, groups],
);
const visibleSessionCount = useMemo(
() => limitedGroups.reduce((total, group) => total + group.sessions.length, 0),
@@ -143,131 +177,194 @@ export const ChatList = memo(function ChatList({
const compact = density === "compact";
return (
<div className="h-full min-h-0 min-w-0 overflow-x-hidden overflow-y-auto overscroll-contain">
<div className="h-full min-h-0 min-w-0 overflow-x-hidden overflow-y-auto overscroll-contain scrollbar-thin scrollbar-track-transparent">
<div className="min-w-0 space-y-3 px-2 py-1.5">
{limitedGroups.map((group) => (
<section key={group.label} aria-label={group.label}>
<div className="px-2 pb-1 text-[12px] font-medium text-muted-foreground/65">
{group.label}
</div>
<ul className="space-y-0.5">
{group.sessions.map((s) => {
const active = s.key === activeKey;
const fallbackTitle = t("chat.fallbackTitle", {
id: s.chatId.slice(0, 6),
});
const generatedTitle = s.title?.trim() || "";
const title = displayTitle(s, titleOverrides, t("chat.newChat"));
const tooltipTitle =
titleOverrides[s.key]?.trim() ||
generatedTitle ||
deriveTitle(s.preview, fallbackTitle);
const isPinned = pinned.has(s.key);
const isArchived = archived.has(s.key);
const preview = s.preview.trim();
const showPreview = showPreviews && preview && preview !== title;
const timestamp = showTimestamps
? relativeTime(s.updatedAt ?? s.createdAt)
: "";
const activityState = running.has(s.chatId)
? "running"
: completed.has(s.chatId)
? "complete"
: null;
return (
<li key={s.key} className="min-w-0">
<div
className={cn(
"group flex min-w-0 max-w-full items-center gap-2 rounded-xl px-2 text-[13px] transition-colors",
compact ? "min-h-7" : "min-h-8",
active
? "bg-sidebar-accent/70 text-sidebar-accent-foreground shadow-[inset_0_0_0_1px_hsl(var(--sidebar-border)/0.28)]"
: "text-sidebar-foreground/82 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground",
)}
>
<button
type="button"
onClick={() => onSelect(s.key)}
title={tooltipTitle}
className={cn(
"min-w-0 flex-1 overflow-hidden text-left",
compact ? "py-1" : "py-1.5",
)}
>
<span className="block w-full truncate font-medium leading-5">{title}</span>
{showPreview ? (
<span className="block w-full truncate text-[11.5px] leading-4 text-muted-foreground/72">
{preview}
</span>
) : null}
{timestamp ? (
<span className="block w-full truncate text-[11px] leading-4 text-muted-foreground/58">
{timestamp}
</span>
) : null}
</button>
<SessionActivityIndicator state={activityState} />
<DropdownMenu modal={false}>
<DropdownMenuTrigger
{limitedGroups.map((group, index) => {
const foldableChatsGroup = isFoldableChatsGroup(group);
const foldedChatsGroup = isFoldedChatsGroup(group, collapsedGroups);
const visibleSessions = visibleSessionsForGroup(
group,
activeKey,
collapsedGroups,
);
const hiddenInGroup = Math.max(0, group.sessions.length - visibleSessions.length);
const canToggleFold = group.sessions.length > COLLAPSED_CHATS_VISIBLE_COUNT;
return (
<section key={group.id} aria-label={group.label}>
{group.kind === "project"
&& limitedGroups[index - 1]?.kind !== "project" ? (
<div className="px-2 pb-1 text-[12px] font-medium text-muted-foreground/65">
{labels.projects}
</div>
) : null}
{group.kind === "project" ? (
<ProjectGroupHeader
label={group.label}
path={group.projectPath}
collapsed={Boolean(collapsedGroups[group.id])}
onToggle={() => onToggleGroup?.(group.id)}
onRequestRename={
group.projectKey && onRequestRenameProject
? () => onRequestRenameProject(group.projectKey ?? "", group.label)
: undefined
}
onNewChat={
group.projectPath && onNewChatInProject
? () => onNewChatInProject(group.projectPath ?? "", group.label)
: undefined
}
actionMenuPortalContainer={actionMenuPortalContainer}
updatedAt={showTimestamps ? group.updatedAt : null}
/>
) : (
<ChatsGroupHeader label={group.label} />
)}
{group.kind === "project" && collapsedGroups[group.id] ? null : (
<ul className="space-y-0.5">
{visibleSessions.map((s) => {
const active = s.key === activeKey;
const fallbackTitle = t("chat.fallbackTitle", {
id: s.chatId.slice(0, 6),
});
const generatedTitle = s.title?.trim() || "";
const title = displayTitle(s, titleOverrides, t("chat.newChat"));
const tooltipTitle =
titleOverrides[s.key]?.trim() ||
generatedTitle ||
deriveTitle(s.preview, fallbackTitle);
const isPinned = pinned.has(s.key);
const isArchived = archived.has(s.key);
const preview = s.preview.trim();
const showPreview = showPreviews && preview && preview !== title;
const timestamp = showTimestamps
? relativeTime(s.updatedAt ?? s.createdAt)
: "";
const projectMode = group.kind === "project";
const activityState = running.has(s.chatId)
? "running"
: completed.has(s.chatId) && !active
? "complete"
: null;
return (
<li key={s.key} className="min-w-0">
<div
className={cn(
"inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-muted-foreground/75 opacity-40 transition-opacity",
"hover:bg-sidebar-accent hover:text-sidebar-foreground group-hover:opacity-100",
"focus-visible:opacity-100",
active && "opacity-100",
"group flex min-w-0 max-w-full items-center gap-2 rounded-xl px-2 text-[13px] transition-colors",
compact ? "min-h-7" : "min-h-8",
active
? "bg-sidebar-accent/70 text-sidebar-accent-foreground shadow-[inset_0_0_0_1px_hsl(var(--sidebar-border)/0.28)]"
: "text-sidebar-foreground/82 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground",
)}
aria-label={t("chat.actions", { title })}
>
<MoreHorizontal className="h-3.5 w-3.5" />
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
portalContainer={actionMenuPortalContainer}
onCloseAutoFocus={(event) => event.preventDefault()}
>
<DropdownMenuItem
onSelect={() => onTogglePin(s.key)}
>
{isPinned ? (
<PinOff className="mr-2 h-4 w-4" />
) : (
<Pin className="mr-2 h-4 w-4" />
<button
type="button"
onClick={() => onSelect(s.key)}
title={tooltipTitle}
className={cn(
"min-w-0 flex-1 overflow-hidden text-left",
compact ? "py-1" : "py-1.5",
projectMode && "pl-7",
)}
{isPinned ? t("chat.unpin") : t("chat.pin")}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => onRequestRename(s.key, title)}
>
<Pencil className="mr-2 h-4 w-4" />
{t("chat.rename")}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => onToggleArchive(s.key)}
>
{isArchived ? (
<ArchiveRestore className="mr-2 h-4 w-4" />
{projectMode ? (
<span className="flex w-full min-w-0 items-baseline gap-2">
<span className="min-w-0 flex-1 truncate font-medium leading-5">
{title}
</span>
{timestamp ? (
<span className="shrink-0 text-[11.5px] font-medium text-muted-foreground/58">
{timestamp}
</span>
) : null}
</span>
) : (
<Archive className="mr-2 h-4 w-4" />
<span className="block w-full truncate font-medium leading-5">
{title}
</span>
)}
{isArchived ? t("chat.unarchive") : t("chat.archive")}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
window.setTimeout(() => onRequestDelete(s.key, title), 0);
}}
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
{t("chat.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</li>
);
})}
</ul>
</section>
))}
{showPreview ? (
<span className="block w-full truncate text-[11.5px] leading-4 text-muted-foreground/72">
{preview}
</span>
) : null}
{timestamp && !projectMode ? (
<span className="block w-full truncate text-[11px] leading-4 text-muted-foreground/58">
{timestamp}
</span>
) : null}
</button>
<SessionActivityIndicator state={activityState} />
<DropdownMenu modal={false}>
<DropdownMenuTrigger
className={cn(
"inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-muted-foreground/75 opacity-40 transition-opacity",
"hover:bg-sidebar-accent hover:text-sidebar-foreground group-hover:opacity-100",
"focus-visible:opacity-100",
active && "opacity-100",
)}
aria-label={t("chat.actions", { title })}
>
<MoreHorizontal className="h-3.5 w-3.5" />
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
portalContainer={actionMenuPortalContainer}
onCloseAutoFocus={(event) => event.preventDefault()}
>
<DropdownMenuItem
onSelect={() => onTogglePin(s.key)}
>
{isPinned ? (
<PinOff className="mr-2 h-4 w-4" />
) : (
<Pin className="mr-2 h-4 w-4" />
)}
{isPinned ? t("chat.unpin") : t("chat.pin")}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => onRequestRename(s.key, title)}
>
<Pencil className="mr-2 h-4 w-4" />
{t("chat.rename")}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => onToggleArchive(s.key)}
>
{isArchived ? (
<ArchiveRestore className="mr-2 h-4 w-4" />
) : (
<Archive className="mr-2 h-4 w-4" />
)}
{isArchived ? t("chat.unarchive") : t("chat.archive")}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
window.setTimeout(() => onRequestDelete(s.key, title), 0);
}}
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
{t("chat.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</li>
);
})}
</ul>
)}
{foldableChatsGroup && canToggleFold ? (
<ChatsFoldFooter
folded={foldedChatsGroup}
hiddenCount={hiddenInGroup}
onToggle={() => onToggleGroup?.(group.id)}
/>
) : null}
</section>
);
})}
{hiddenSessionCount > 0 ? (
<div className="px-2 pb-2 pt-1">
<button
@@ -277,7 +374,7 @@ export const ChatList = memo(function ChatList({
Math.min(totalSessionCount, limit + VISIBLE_SESSIONS_INCREMENT),
)
}
className="h-8 w-full rounded-full text-[12px] font-medium text-muted-foreground transition-colors hover:bg-sidebar-accent/65 hover:text-sidebar-foreground"
className="h-8 w-full rounded-full text-[12px] font-medium text-muted-foreground/65 transition-colors hover:bg-sidebar-accent/65 hover:text-muted-foreground"
>
{t("chat.showMore", { count: hiddenSessionCount })}
</button>
@@ -288,6 +385,133 @@ export const ChatList = memo(function ChatList({
);
});
function ProjectGroupHeader({
label,
path,
collapsed,
onToggle,
onRequestRename,
onNewChat,
actionMenuPortalContainer,
updatedAt,
}: {
label: string;
path?: string;
collapsed: boolean;
onToggle: () => void;
onRequestRename?: () => void;
onNewChat?: () => void;
actionMenuPortalContainer?: HTMLElement | null;
updatedAt?: string | null;
}) {
const { t } = useTranslation();
return (
<div
title={path}
className="group flex min-w-0 items-center gap-1 px-1 pb-1 pt-1 text-[12px] font-medium text-muted-foreground/78"
>
<button
type="button"
aria-expanded={!collapsed}
onClick={onToggle}
className="flex min-w-0 flex-1 items-center gap-2 rounded-lg px-1.5 py-1 text-left transition-colors hover:bg-sidebar-accent/45 hover:text-sidebar-foreground"
>
<Folder className="h-3.5 w-3.5 shrink-0" aria-hidden />
<span className="min-w-0 flex-1 truncate">{label}</span>
</button>
{updatedAt ? (
<span className="shrink-0 text-[11px] text-muted-foreground/55">
{relativeTime(updatedAt)}
</span>
) : null}
{onRequestRename ? (
<DropdownMenu modal={false}>
<DropdownMenuTrigger
className={cn(
"inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-muted-foreground/70 opacity-40 transition-opacity",
"hover:bg-sidebar-accent hover:text-sidebar-foreground group-hover:opacity-100 focus-visible:opacity-100",
)}
aria-label={t("chat.actions", { title: label })}
onClick={(event) => event.stopPropagation()}
>
<MoreHorizontal className="h-3.5 w-3.5" />
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
portalContainer={actionMenuPortalContainer}
onCloseAutoFocus={(event) => event.preventDefault()}
>
<DropdownMenuItem onSelect={onRequestRename}>
<Pencil className="mr-2 h-4 w-4" />
{t("chat.rename")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null}
{onNewChat ? (
<button
type="button"
aria-label={t("chat.newInProject", { project: label })}
title={t("chat.newInProject", { project: label })}
onClick={(event) => {
event.stopPropagation();
onNewChat();
}}
className={cn(
"inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-muted-foreground/70 opacity-40 transition-opacity",
"hover:bg-sidebar-accent hover:text-sidebar-foreground group-hover:opacity-100 focus-visible:opacity-100",
)}
>
<Plus className="h-3.5 w-3.5" />
</button>
) : null}
</div>
);
}
function ChatsGroupHeader({ label }: { label: string }) {
return (
<div className="px-2 pb-1 text-[12px] font-medium text-muted-foreground/65">
{label}
</div>
);
}
function ChatsFoldFooter({
folded,
hiddenCount,
onToggle,
}: {
folded: boolean;
hiddenCount: number;
onToggle: () => void;
}) {
const { t, i18n } = useTranslation();
const collapsedFallback = i18n.resolvedLanguage?.startsWith("zh")
? `已折叠 ${hiddenCount} 个对话`
: `${hiddenCount} hidden chats`;
return (
<div className="px-2 pb-1 pt-1">
<button
type="button"
onClick={onToggle}
className="h-7 w-full rounded-xl text-left text-[12px] font-medium text-muted-foreground/65 transition-colors hover:bg-sidebar-accent/50 hover:text-muted-foreground"
>
<span className="px-2">
{folded
? t("chat.collapsed", {
count: hiddenCount,
defaultValue: collapsedFallback,
})
: t("chat.showLess")}
</span>
</button>
</div>
);
}
function SessionActivityIndicator({
state,
}: {
@@ -316,202 +540,10 @@ function SessionActivityIndicator({
title={label}
className="grid h-4 w-4 shrink-0 place-items-center"
>
<span className="h-1.5 w-1.5 rounded-full bg-blue-500 shadow-[0_0_0_3px_rgba(59,130,246,0.14)] dark:bg-blue-400 dark:shadow-[0_0_0_3px_rgba(96,165,250,0.18)]" />
<span className="h-1.5 w-1.5 rounded-full bg-blue-500 dark:bg-blue-400" />
</span>
);
}
return <span className="h-4 w-4 shrink-0" aria-hidden="true" />;
}
function groupSessions(
sessions: ChatSummary[],
labels: {
pinned: string;
all: string;
today: string;
yesterday: string;
earlier: string;
archived: string;
fallbackTitle: string;
},
options: {
pinnedKeys: string[];
archivedKeys: string[];
titleOverrides: Record<string, string>;
showArchived: boolean;
sort: SidebarSortMode;
},
): Array<{ label: string; sessions: ChatSummary[] }> {
const now = new Date();
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
const startOfYesterday = startOfToday - 24 * 60 * 60 * 1000;
const buckets = new Map<string, ChatSummary[]>();
const pinned = new Set(options.pinnedKeys);
const archived = new Set(options.archivedKeys);
const pinnedSessions: ChatSummary[] = [];
const archivedSessions: ChatSummary[] = [];
const normalSessions: ChatSummary[] = [];
for (const session of sessions) {
if (archived.has(session.key)) {
if (options.showArchived) archivedSessions.push(session);
continue;
}
if (pinned.has(session.key)) {
pinnedSessions.push(session);
continue;
}
if (options.sort === "title_asc") {
normalSessions.push(session);
continue;
}
const timestamp = Date.parse(session.updatedAt ?? session.createdAt ?? "");
const label = Number.isFinite(timestamp) && timestamp >= startOfToday
? labels.today
: Number.isFinite(timestamp) && timestamp >= startOfYesterday
? labels.yesterday
: labels.earlier;
const bucket = buckets.get(label) ?? [];
bucket.push(session);
buckets.set(label, bucket);
}
const groups = [labels.today, labels.yesterday, labels.earlier]
.map((label) => ({
label,
sessions: sortSessions(
buckets.get(label) ?? [],
options.sort,
options.titleOverrides,
),
}))
.filter((group) => group.sessions.length > 0);
if (options.sort === "title_asc" && normalSessions.length) {
groups.push({
label: labels.all,
sessions: sortSessions(
normalSessions,
options.sort,
options.titleOverrides,
),
});
}
if (pinnedSessions.length) {
groups.unshift({
label: labels.pinned,
sessions: sortSessions(
pinnedSessions,
options.sort,
options.titleOverrides,
),
});
}
if (archivedSessions.length) {
groups.push({
label: labels.archived,
sessions: sortSessions(
archivedSessions,
options.sort,
options.titleOverrides,
),
});
}
return groups;
}
function limitGroups(
groups: Array<{ label: string; sessions: ChatSummary[] }>,
limit: number,
activeKey: string | null,
): Array<{ label: string; sessions: ChatSummary[] }> {
let remaining = Math.max(0, limit);
let activeVisible = !activeKey;
const out: Array<{ label: string; sessions: ChatSummary[] }> = [];
for (const group of groups) {
const visible = remaining > 0
? group.sessions.slice(0, remaining)
: [];
remaining -= visible.length;
if (activeKey && visible.some((session) => session.key === activeKey)) {
activeVisible = true;
}
if (visible.length > 0) {
out.push({ label: group.label, sessions: visible });
}
}
if (activeVisible || !activeKey) return out;
for (const group of groups) {
const active = group.sessions.find((session) => session.key === activeKey);
if (!active) continue;
const existing = out.find((item) => item.label === group.label);
if (existing) {
existing.sessions = [...existing.sessions, active];
} else {
out.push({ label: group.label, sessions: [active] });
}
return out;
}
return out;
}
function sortSessions(
sessions: ChatSummary[],
sort: SidebarSortMode,
titleOverrides: Record<string, string>,
): ChatSummary[] {
const copy = [...sessions];
copy.sort((a, b) => {
if (sort === "title_asc") {
const titleOrder = titleForSort(a, titleOverrides).localeCompare(
titleForSort(b, titleOverrides),
"en",
{ numeric: true, sensitivity: "base" },
);
if (titleOrder !== 0) return titleOrder;
return sessionTime(b, "updatedAt") - sessionTime(a, "updatedAt");
}
const aTime = sessionTime(a, sort === "created_desc" ? "createdAt" : "updatedAt");
const bTime = sessionTime(b, sort === "created_desc" ? "createdAt" : "updatedAt");
return bTime - aTime;
});
return copy;
}
function titleForSort(
session: ChatSummary,
titleOverrides: Record<string, string>,
): string {
return (
titleOverrides[session.key]?.trim() ||
session.title?.trim() ||
deriveTitle(session.preview, "new chat")
).toLocaleLowerCase("en");
}
function displayTitle(
session: ChatSummary,
titleOverrides: Record<string, string>,
fallbackTitle: string,
): string {
return (
titleOverrides[session.key]?.trim() ||
session.title?.trim() ||
deriveTitle(session.preview, fallbackTitle)
);
}
function sessionTime(
session: ChatSummary,
field: "createdAt" | "updatedAt",
): number {
const primary = Date.parse(session[field] ?? "");
if (Number.isFinite(primary)) return primary;
const fallback = Date.parse(session.updatedAt ?? session.createdAt ?? "");
return Number.isFinite(fallback) ? fallback : 0;
}
+9 -3
View File
@@ -15,6 +15,9 @@ import { Input } from "@/components/ui/input";
interface RenameChatDialogProps {
open: boolean;
title: string;
dialogTitle?: string;
description?: string;
placeholder?: string;
onCancel: () => void;
onConfirm: (title: string) => void;
}
@@ -22,6 +25,9 @@ interface RenameChatDialogProps {
export function RenameChatDialog({
open,
title,
dialogTitle,
description,
placeholder,
onCancel,
onConfirm,
}: RenameChatDialogProps) {
@@ -48,15 +54,15 @@ export function RenameChatDialog({
}}
>
<DialogHeader className="text-left">
<DialogTitle>{t("chat.renameTitle")}</DialogTitle>
<DialogTitle>{dialogTitle ?? t("chat.renameTitle")}</DialogTitle>
<DialogDescription>
{t("chat.renameDescription")}
{description ?? t("chat.renameDescription")}
</DialogDescription>
</DialogHeader>
<Input
value={value}
onChange={(event) => setValue(event.target.value)}
placeholder={t("chat.renamePlaceholder")}
placeholder={placeholder ?? t("chat.renamePlaceholder")}
autoFocus
maxLength={160}
/>
+20 -120
View File
@@ -1,7 +1,6 @@
import { useState, type ReactNode } from "react";
import {
Archive,
ListFilter,
Menu,
Search,
Settings,
@@ -13,20 +12,9 @@ import { useTranslation } from "react-i18next";
import { ChatList } from "@/components/ChatList";
import { ConnectionBadge } from "@/components/ConnectionBadge";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Separator } from "@/components/ui/separator";
import type {
ChatSummary,
SidebarSortMode,
SidebarViewState,
} from "@/lib/types";
import { cn } from "@/lib/utils";
@@ -41,12 +29,14 @@ interface SidebarProps {
onTogglePin: (key: string) => void;
onRequestRename: (key: string, label: string) => void;
onToggleArchive: (key: string) => void;
onToggleGroup: (groupId: string) => void;
onRequestRenameProject: (projectKey: string, label: string) => void;
onNewChatInProject: (projectPath: string, projectName: string) => void;
onOpenSettings: () => void;
onOpenApps: () => void;
onOpenSearch: () => void;
activeUtility?: "apps" | null;
onToggleArchived: () => void;
onUpdateView: (view: Partial<SidebarViewState>) => void;
onCollapse: () => void;
onExpand?: () => void;
containActionMenus?: boolean;
@@ -54,11 +44,15 @@ interface SidebarProps {
pinnedKeys?: string[];
archivedKeys?: string[];
titleOverrides?: Record<string, string>;
projectNameOverrides?: Record<string, string>;
collapsedGroups?: Record<string, boolean>;
runningChatIds?: string[];
completedChatIds?: string[];
viewState?: SidebarViewState;
showArchived?: boolean;
archivedCount?: number;
defaultWorkspacePath?: string | null;
hostChromeInset?: boolean;
}
export function Sidebar(props: SidebarProps) {
@@ -72,11 +66,15 @@ export function Sidebar(props: SidebarProps) {
<nav
ref={props.containActionMenus ? setMenuPortalContainer : undefined}
aria-label={t("sidebar.navigation")}
className="flex h-full w-full min-w-0 flex-col border-r border-sidebar-border/60 bg-sidebar text-sidebar-foreground"
className={cn(
"flex h-full w-full min-w-0 flex-col bg-sidebar text-sidebar-foreground",
!props.hostChromeInset && "border-r border-sidebar-border/60",
)}
>
<div
className={cn(
"flex items-center px-3 pb-2.5 pt-3",
"flex items-center px-3 pb-2.5",
props.hostChromeInset ? "pt-[2.85rem]" : "pt-3",
collapsed ? "w-14 justify-start" : "justify-between",
)}
>
@@ -101,7 +99,7 @@ export function Sidebar(props: SidebarProps) {
draggable={false}
/>
</button>
{!collapsed && (
{!collapsed && !props.hostChromeInset && (
<Button
variant="ghost"
size="icon"
@@ -139,11 +137,6 @@ export function Sidebar(props: SidebarProps) {
active={props.activeUtility === "apps"}
icon={<Blocks className="h-4 w-4" />}
/>
<SidebarViewMenu
compact={collapsed}
view={props.viewState}
onUpdateView={props.onUpdateView}
/>
{props.archivedCount ? (
<SidebarActionButton
collapsed={collapsed}
@@ -170,9 +163,14 @@ export function Sidebar(props: SidebarProps) {
onTogglePin={props.onTogglePin}
onRequestRename={props.onRequestRename}
onToggleArchive={props.onToggleArchive}
onToggleGroup={props.onToggleGroup}
onRequestRenameProject={props.onRequestRenameProject}
onNewChatInProject={props.onNewChatInProject}
pinnedKeys={props.pinnedKeys}
archivedKeys={props.archivedKeys}
titleOverrides={props.titleOverrides}
projectNameOverrides={props.projectNameOverrides}
collapsedGroups={props.collapsedGroups}
runningChatIds={props.runningChatIds}
completedChatIds={props.completedChatIds}
density={props.viewState?.density}
@@ -180,6 +178,7 @@ export function Sidebar(props: SidebarProps) {
showTimestamps={props.viewState?.show_timestamps}
sort={props.viewState?.sort}
showArchived={props.showArchived}
defaultWorkspacePath={props.defaultWorkspacePath}
actionMenuPortalContainer={
props.containActionMenus ? menuPortalContainer : undefined
}
@@ -261,102 +260,3 @@ function SidebarActionButton({
</Button>
);
}
function SidebarViewMenu({
compact = false,
view,
onUpdateView,
}: {
compact?: boolean;
view?: SidebarViewState;
onUpdateView: (view: Partial<SidebarViewState>) => void;
}) {
const { t } = useTranslation();
const sort = view?.sort ?? "updated_desc";
const setSort = (value: string) => {
if (isSidebarSortMode(value)) onUpdateView({ sort: value });
};
return (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
type="button"
aria-label={t("sidebar.viewOptions")}
title={compact ? t("sidebar.viewOptions") : undefined}
className={cn(
"h-8 min-w-0 overflow-hidden font-medium text-sidebar-foreground/75 hover:bg-sidebar-accent/75 hover:text-sidebar-foreground",
"transition-[width,padding,border-radius,color,background-color] duration-300 ease-out",
compact
? "w-9 justify-center gap-0 rounded-xl px-0"
: "w-full justify-start gap-2 rounded-full px-3 text-[12.5px]",
)}
variant="ghost"
>
<ListFilter className="h-4 w-4 shrink-0" aria-hidden />
<span
className={cn(
"min-w-0 overflow-hidden truncate whitespace-nowrap transition-[max-width,opacity,transform] duration-200 ease-out",
compact
? "max-w-0 -translate-x-1 opacity-0"
: "max-w-[12rem] translate-x-0 opacity-100",
)}
>
{t("sidebar.viewOptions")}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-52">
<DropdownMenuLabel className="text-xs text-muted-foreground">
{t("sidebar.viewOptions")}
</DropdownMenuLabel>
<DropdownMenuCheckboxItem
checked={view?.density === "compact"}
onCheckedChange={(checked) =>
onUpdateView({ density: checked ? "compact" : "comfortable" })
}
onSelect={(event) => event.preventDefault()}
>
{t("sidebar.compactList")}
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={Boolean(view?.show_previews)}
onCheckedChange={(checked) =>
onUpdateView({ show_previews: Boolean(checked) })
}
onSelect={(event) => event.preventDefault()}
>
{t("sidebar.showPreviews")}
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={Boolean(view?.show_timestamps)}
onCheckedChange={(checked) =>
onUpdateView({ show_timestamps: Boolean(checked) })
}
onSelect={(event) => event.preventDefault()}
>
{t("sidebar.showTimestamps")}
</DropdownMenuCheckboxItem>
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-xs text-muted-foreground">
{t("sidebar.sortLabel")}
</DropdownMenuLabel>
<DropdownMenuRadioGroup value={sort} onValueChange={setSort}>
<DropdownMenuRadioItem value="updated_desc">
{t("sidebar.sortUpdated")}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="created_desc">
{t("sidebar.sortCreated")}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="title_asc">
{t("sidebar.sortTitle")}
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}
function isSidebarSortMode(value: string): value is SidebarSortMode {
return value === "updated_desc" || value === "created_desc" || value === "title_asc";
}
File diff suppressed because it is too large Load Diff
@@ -48,6 +48,7 @@ interface ActivityCounts {
hasDiffStats: boolean;
hasEditingFiles: boolean;
hasFailedFiles: boolean;
hasDeletedFiles: boolean;
primaryFilePath?: string;
primaryFileTooltipPath?: string;
primaryCliName?: string;
@@ -66,6 +67,7 @@ interface FileEditSummary {
approximate: boolean;
binary: boolean;
status: UIFileEdit["status"];
operation?: UIFileEdit["operation"];
pending: boolean;
error?: string;
}
@@ -126,6 +128,7 @@ function countActivity(
let hasDiffStats = false;
let hasEditingFiles = false;
let failedFileCount = 0;
let deletedFileCount = 0;
let primaryFilePath: string | undefined;
let primaryFileTooltipPath: string | undefined;
for (const edit of fileEdits) {
@@ -137,6 +140,9 @@ function countActivity(
if (edit.status === "error") {
failedFileCount += 1;
}
if (edit.operation === "delete") {
deletedFileCount += 1;
}
if (edit.status === "error" || edit.binary) {
continue;
}
@@ -158,6 +164,7 @@ function countActivity(
hasDiffStats,
hasEditingFiles,
hasFailedFiles: fileEdits.length > 0 && failedFileCount === fileEdits.length,
hasDeletedFiles: fileEdits.length > 0 && deletedFileCount === fileEdits.length,
primaryFilePath,
primaryFileTooltipPath,
primaryCliName,
@@ -217,6 +224,7 @@ export function AgentActivityCluster({
hasDiffStats,
hasEditingFiles,
hasFailedFiles,
hasDeletedFiles,
primaryFilePath,
primaryFileTooltipPath,
primaryCliName,
@@ -245,6 +253,7 @@ export function AgentActivityCluster({
const singleFilePath = fileCount === 1 ? primaryFilePath : undefined;
const singleFileTooltipPath = fileCount === 1 ? primaryFileTooltipPath : undefined;
const hasVisibleActivity = reasoningSteps > 0 || toolCalls > 0 || cliCount > 0 || mcpCount > 0 || fileCount > 0;
const hasOnlyFileActivity = fileCount > 0 && messages.every(messageHasOnlyFileActivity);
const durationMs = activityDurationMs(messages, isTurnStreaming, now, turnLatencyMs);
const activityDuration = formatActivityDuration(durationMs);
const thoughtLabel = isTurnStreaming
@@ -263,13 +272,13 @@ export function AgentActivityCluster({
? hasPendingFileEdit && !singleFilePath
? t("message.fileActivityPreparing", { defaultValue: "Preparing edit…" })
: singleFilePath
? t(fileActivitySummaryKey(hasLiveEditingFiles, hasFailedFiles), {
? t(fileActivitySummaryKey(hasLiveEditingFiles, hasFailedFiles, hasDeletedFiles), {
file: shortFileName(singleFilePath),
defaultValue: `${fileActivityVerb(hasLiveEditingFiles, hasFailedFiles)} {{file}}`,
defaultValue: `${fileActivityVerb(hasLiveEditingFiles, hasFailedFiles, hasDeletedFiles)} {{file}}`,
})
: t(fileActivityManySummaryKey(hasLiveEditingFiles, hasFailedFiles), {
: t(fileActivityManySummaryKey(hasLiveEditingFiles, hasFailedFiles, hasDeletedFiles), {
count: fileCount,
defaultValue: `${fileActivityVerb(hasLiveEditingFiles, hasFailedFiles)} {{count}} files`,
defaultValue: `${fileActivityVerb(hasLiveEditingFiles, hasFailedFiles, hasDeletedFiles)} {{count}} files`,
})
: "";
@@ -410,6 +419,25 @@ export function AgentActivityCluster({
if (!hasVisibleActivity) return null;
if (hasOnlyFileActivity) {
return (
<FileEditFlatActivity
edits={fileEdits}
active={isTurnStreaming}
hasBodyBelow={hasBodyBelow}
summary={summary}
singleFilePath={singleFilePath}
singleFileTooltipPath={singleFileTooltipPath}
hasLiveEditingFiles={hasLiveEditingFiles}
hasFailedFiles={hasFailedFiles}
hasDeletedFiles={hasDeletedFiles}
added={added}
deleted={deleted}
hasDiffStats={hasDiffStats}
/>
);
}
return (
<div className={cn("w-full", hasBodyBelow && "mb-2")}>
<button
@@ -426,7 +454,7 @@ export function AgentActivityCluster({
active={isTurnStreaming}
className="min-w-0"
>
{singleFilePath ? fileActivityVerb(hasLiveEditingFiles, hasFailedFiles) : thoughtLabel}
{singleFilePath ? fileActivityVerb(hasLiveEditingFiles, hasFailedFiles, hasDeletedFiles) : thoughtLabel}
</StreamingLabelSheen>
{singleFilePath ? (
<FileReferenceChip
@@ -502,6 +530,77 @@ export function AgentActivityCluster({
);
}
function messageHasOnlyFileActivity(message: UIMessage): boolean {
if (message.kind !== "trace" || !message.fileEdits?.length) return false;
return traceLines(message).every((line) => !line.trim() || isFileEditTraceLine(line));
}
function FileEditFlatActivity({
edits,
active,
hasBodyBelow,
summary,
singleFilePath,
singleFileTooltipPath,
hasLiveEditingFiles,
hasFailedFiles,
hasDeletedFiles,
added,
deleted,
hasDiffStats,
}: {
edits: FileEditSummary[];
active: boolean;
hasBodyBelow: boolean;
summary: string;
singleFilePath?: string;
singleFileTooltipPath?: string;
hasLiveEditingFiles: boolean;
hasFailedFiles: boolean;
hasDeletedFiles: boolean;
added: number;
deleted: number;
hasDiffStats: boolean;
}) {
const showRows = edits.length > 1 || edits.some((edit) => edit.status === "error" || edit.pending);
return (
<div className={cn("w-full", hasBodyBelow && "mb-2")} aria-label={summary}>
<div
className={cn(
"flex max-w-full items-center gap-1.5 px-1 py-1",
"text-[12.5px] text-muted-foreground/72",
)}
>
<StreamingLabelSheen active={active} className="min-w-0">
{singleFilePath
? fileActivityVerb(hasLiveEditingFiles, hasFailedFiles, hasDeletedFiles)
: summary}
</StreamingLabelSheen>
{singleFilePath ? (
<FileReferenceChip
path={singleFilePath}
tooltipPath={singleFileTooltipPath}
active={hasLiveEditingFiles}
className="-my-0.5 min-w-0"
textClassName="text-xs"
testId="activity-header-file-reference"
/>
) : null}
{hasDiffStats ? (
<span className="inline-flex min-w-0 items-center gap-1 text-muted-foreground/85">
<DiffPair added={added} deleted={deleted} />
</span>
) : null}
</div>
{showRows ? (
<div className="mt-0.5 pl-4">
<FileEditGroup edits={edits} />
</div>
) : null}
</div>
);
}
function shortFileName(path: string): string {
return path.split(/[\\/]/).pop() || path;
}
@@ -1039,6 +1138,10 @@ function isMcpRunTraceLine(line: string): boolean {
return MCP_TOOL_NAME_RE.test(line.trim().split("(", 1)[0] ?? "");
}
function isFileEditTraceLine(line: string): boolean {
return /^(write_file|edit_file|apply_patch)\(/.test(line.trim());
}
function parseCliRunTrace(line: string, status: CliRunStatus = "running"): CliRunSummary | null {
const match = /^(run_cli_app|cli_anything_run)\((.*)\)$/.exec(line.trim());
if (!match) return null;
@@ -1365,18 +1468,21 @@ function mcpRunLabelDefault(run: McpRunSummary, active: boolean): string {
return active && run.status === "running" ? "Using" : "Used";
}
function fileActivityVerb(editing: boolean, failed: boolean): string {
function fileActivityVerb(editing: boolean, failed: boolean, deleted: boolean): string {
if (failed) return "Failed";
if (deleted) return editing ? "Deleting" : "Deleted";
return editing ? "Editing" : "Edited";
}
function fileActivitySummaryKey(editing: boolean, failed: boolean): string {
function fileActivitySummaryKey(editing: boolean, failed: boolean, deleted: boolean): string {
if (failed) return "message.fileActivityFailedOne";
if (deleted) return editing ? "message.fileActivityDeletingOne" : "message.fileActivityDeletedOne";
return editing ? "message.fileActivityEditingOne" : "message.fileActivityEditedOne";
}
function fileActivityManySummaryKey(editing: boolean, failed: boolean): string {
function fileActivityManySummaryKey(editing: boolean, failed: boolean, deleted: boolean): string {
if (failed) return "message.fileActivityFailedMany";
if (deleted) return editing ? "message.fileActivityDeletingMany" : "message.fileActivityDeletedMany";
return editing ? "message.fileActivityEditingMany" : "message.fileActivityEditedMany";
}
@@ -1419,6 +1525,7 @@ function summarizeFileEdits(edits: UIFileEdit[], active: boolean): FileEditSumma
hasSuccessfulChange: boolean;
hasActiveEditing: boolean;
hasFailed: boolean;
operation?: UIFileEdit["operation"];
error?: string;
}
@@ -1440,6 +1547,7 @@ function summarizeFileEdits(edits: UIFileEdit[], active: boolean): FileEditSumma
hasSuccessfulChange: false,
hasActiveEditing: false,
hasFailed: false,
operation: undefined,
};
byPath.set(key, summary);
order.push(key);
@@ -1451,6 +1559,9 @@ function summarizeFileEdits(edits: UIFileEdit[], active: boolean): FileEditSumma
if (edit.absolute_path) {
summary.absolute_path = edit.absolute_path;
}
if (edit.operation === "delete") {
summary.operation = "delete";
}
summary.pending = summary.pending || !!edit.pending || !edit.path;
if (!edit.path && edit.pending) {
if (active && edit.status === "editing") {
@@ -1515,6 +1626,7 @@ function summarizeFileEdits(edits: UIFileEdit[], active: boolean): FileEditSumma
approximate: summary.approximate,
binary: summary.binary,
status,
operation: summary.operation,
pending: summary.pending && !summary.path,
error: summary.error,
}];
@@ -1525,6 +1637,23 @@ function hasVisibleDiffStats(edit: Pick<FileEditSummary, "added" | "deleted">):
return edit.added > 0 || edit.deleted > 0;
}
function formatFileEditError(error?: string): string {
const firstLine = (error || "").replace(/\s+/g, " ").trim();
if (!firstLine) return "";
const cleaned = firstLine
.replace(/^Error applying patch:\s*/i, "")
.replace(/^Error writing file:\s*/i, "")
.replace(/^Error editing file:\s*/i, "")
.replace(/^Error:\s*/i, "");
return cleaned
.replace(/^old_text not found in (.+)$/i, "Target text was not found in $1.")
.replace(/^old_text appears multiple times in (.+)$/i, "Target text matched multiple places in $1.")
.replace(/^file to (?:update|delete) does not exist: (.+)$/i, "File does not exist: $1.")
.replace(/^path to (?:update|delete) is not a file: (.+)$/i, "Path is not a file: $1.")
.slice(0, 180);
}
function CliRunGroup({
runs,
active,
@@ -1758,8 +1887,15 @@ function FileEditRow({ edit }: { edit: FileEditSummary }) {
const editing = edit.status === "editing";
const failed = edit.status === "error";
const hasCountedDiff = !failed && !edit.binary && hasVisibleDiffStats(edit);
const failureDetail = failed
? formatFileEditError(edit.error)
|| t("message.fileEditFailedFallback", { defaultValue: "File change was not applied." })
: "";
return (
<li className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3 py-0.5 text-xs">
<li
className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3 py-0.5 text-xs"
title={failureDetail || edit.absolute_path || edit.path}
>
<div className="flex min-w-0 items-center gap-2">
<span className="grid h-5 w-5 shrink-0 place-items-center text-muted-foreground/50">
{failed ? (
@@ -1789,13 +1925,8 @@ function FileEditRow({ edit }: { edit: FileEditSummary }) {
/>
)}
{failed ? (
<span className="inline-flex shrink-0 items-center gap-1 text-[10.5px] font-medium text-destructive/75">
{t("message.fileEditFailed", { defaultValue: "Failed" })}
</span>
) : null}
{edit.approximate && !failed ? (
<span className="shrink-0 text-[10.5px] font-medium text-muted-foreground/55">
{t("message.fileEditApproximate", { defaultValue: "estimated" })}
<span className="min-w-0 truncate text-[11px] leading-4 text-destructive/75">
{failureDetail}
</span>
) : null}
</div>
@@ -62,10 +62,15 @@ function resolveCopy(
title: t("errors.messageTooBig.title"),
body: t("errors.messageTooBig.body"),
};
case "workspace_scope_rejected":
return {
title: t("errors.workspaceScopeRejected.title"),
body: t("errors.workspaceScopeRejected.body"),
};
default: {
// Exhaustiveness guard: if a new StreamError kind is added, TS will
// complain here until we add a corresponding i18n branch.
const _exhaustive: never = error.kind;
const _exhaustive: never = error;
return { title: String(_exhaustive), body: "" };
}
}
+178 -101
View File
@@ -43,6 +43,10 @@ import {
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import {
WorkspaceAccessMenu,
WorkspaceProjectPicker,
} from "@/components/thread/WorkspaceControls";
import {
useAttachedImages,
type AttachedImage,
@@ -58,6 +62,8 @@ import type {
OutboundCliAppMention,
OutboundMcpPresetMention,
SlashCommand,
WorkspaceScopePayload,
WorkspacesPayload,
} from "@/lib/types";
import {
inferProviderFromModelName,
@@ -88,6 +94,7 @@ interface ThreadComposerProps {
slashCommands?: SlashCommand[];
cliApps?: CliAppInfo[];
mcpPresets?: McpPresetInfo[];
imageGenerationEnabled?: boolean;
imageMode?: boolean;
onImageModeChange?: (enabled: boolean) => void;
onStop?: () => void;
@@ -95,6 +102,12 @@ interface ThreadComposerProps {
runStartedAt?: number | null;
/** Sustained objective for this chat (WebSocket ``goal_state``). */
goalState?: GoalStateWsPayload;
workspaceScope?: WorkspaceScopePayload | null;
workspaceDefaultScope?: WorkspaceScopePayload | null;
workspaceControls?: WorkspacesPayload["controls"] | null;
workspaceScopeDisabled?: boolean;
workspaceError?: string | null;
onWorkspaceScopeChange?: (scope: WorkspaceScopePayload) => void;
}
const COMMAND_ICONS: Record<string, LucideIcon> = {
@@ -471,11 +484,18 @@ export function ThreadComposer({
slashCommands = [],
cliApps = [],
mcpPresets = [],
imageGenerationEnabled = true,
imageMode: controlledImageMode,
onImageModeChange,
onStop,
runStartedAt = null,
goalState,
workspaceScope = null,
workspaceDefaultScope = null,
workspaceControls = null,
workspaceScopeDisabled = false,
workspaceError = null,
onWorkspaceScopeChange,
}: ThreadComposerProps) {
const { t } = useTranslation();
const [value, setValue] = useState("");
@@ -495,7 +515,13 @@ export function ThreadComposer({
const aspectControlRef = useRef<HTMLDivElement>(null);
const chipRefs = useRef(new Map<string, HTMLButtonElement>());
const isHero = variant === "hero";
const imageMode = controlledImageMode ?? uncontrolledImageMode;
const showProjectPicker =
isHero
&& !!workspaceDefaultScope
&& !!onWorkspaceScopeChange
&& workspaceControls?.can_change_project !== false;
const requestedImageMode = controlledImageMode ?? uncontrolledImageMode;
const imageMode = imageGenerationEnabled && requestedImageMode;
const setImageMode = useCallback(
(enabled: boolean) => {
if (controlledImageMode === undefined) {
@@ -505,6 +531,13 @@ export function ThreadComposer({
},
[controlledImageMode, onImageModeChange],
);
useEffect(() => {
if (imageGenerationEnabled || !requestedImageMode) return;
setImageMode(false);
setAspectMenuOpen(false);
}, [imageGenerationEnabled, requestedImageMode, setImageMode]);
const resolvedPlaceholder = isStreaming
? t("thread.composer.placeholderStreaming")
: imageMode
@@ -574,16 +607,17 @@ export function ThreadComposer({
}, [disabled, slashMenuDismissed, value]);
const visibleSlashCommands = useMemo(() => {
if (!(isStreaming && onStop)) return slashCommands;
if (slashCommands.some((command) => command.command === "/stop")) return slashCommands;
const baseCommands = slashCommands.filter((command) => command.command !== "/stop");
if (!(isStreaming && onStop)) return baseCommands;
const stopCommand = slashCommands.find((command) => command.command === "/stop") ?? {
command: "/stop",
title: "Stop current task",
description: "Cancel the active agent turn for this chat.",
icon: "square",
};
return [
{
command: "/stop",
title: "Stop current task",
description: "Cancel the active agent turn for this chat.",
icon: "square",
},
...slashCommands,
stopCommand,
...baseCommands,
];
}, [isStreaming, onStop, slashCommands]);
@@ -845,13 +879,6 @@ export function ThreadComposer({
const chooseSlashCommand = useCallback(
(command: SlashCommand) => {
const nextRecents = [
command.command,
...recentSlashCommands.filter((item) => item !== command.command),
].slice(0, SLASH_RECENTS_LIMIT);
setRecentSlashCommands(nextRecents);
storeSlashRecents(nextRecents);
if (command.command === "/stop" && isStreaming && onStop) {
onStop();
setValue("");
@@ -862,6 +889,13 @@ export function ThreadComposer({
return;
}
const nextRecents = [
command.command,
...recentSlashCommands.filter((item) => item !== command.command),
].slice(0, SLASH_RECENTS_LIMIT);
setRecentSlashCommands(nextRecents);
storeSlashRecents(nextRecents);
setValue(command.argHint ? `${command.command} ` : command.command);
setSlashMenuDismissed(true);
setCliAppMenuDismissed(false);
@@ -1051,10 +1085,15 @@ export function ThreadComposer({
const attachButtonDisabled = disabled || full;
const showStopButton = isStreaming && !!onStop;
const centerHeroPlaceholder =
isHero && value.length === 0 && images.length === 0 && !isStreaming;
const inputTextClasses = cn(
"w-full resize-none bg-transparent",
isHero
? "min-h-[78px] px-5 pb-2 pt-5 text-[15px] leading-6"
? cn(
"min-h-[78px] px-5 text-[15px] leading-6",
centerHeroPlaceholder ? "pb-2 pt-[27px]" : "pb-1.5 pt-4",
)
: "min-h-[50px] px-4 pb-1.5 pt-3 text-[13.5px] leading-5",
);
@@ -1093,11 +1132,12 @@ export function ThreadComposer({
) : null}
<div
className={cn(
"relative mx-auto flex w-full flex-col overflow-visible transition-all duration-200",
"group/composer relative mx-auto flex w-full flex-col overflow-visible transition-all duration-200",
"after:pointer-events-none after:absolute after:inset-[-1px] after:rounded-[inherit] after:border after:border-blue-300/75 after:opacity-0 after:transition-opacity after:duration-200 focus-within:after:opacity-100 dark:after:border-blue-400/55",
isHero
? "max-w-[58rem] rounded-[28px] border border-black/[0.035] bg-card shadow-[0_20px_55px_rgba(15,23,42,0.08)] dark:border-white/[0.06] dark:shadow-[0_24px_55px_rgba(0,0,0,0.34)]"
: "max-w-[49.5rem] rounded-[22px] border border-black/[0.035] bg-card shadow-[0_12px_30px_rgba(15,23,42,0.07)] dark:border-white/[0.06] dark:shadow-[0_16px_34px_rgba(0,0,0,0.28)]",
"focus-within:ring-1 focus-within:ring-foreground/8",
"focus-within:border-blue-300/75 dark:focus-within:border-blue-400/55",
disabled && "opacity-60",
isDragging && "ring-2 ring-primary/40 motion-reduce:ring-0 motion-reduce:border-primary",
goalState?.active &&
@@ -1184,11 +1224,11 @@ export function ThreadComposer({
) : null}
<div
className={cn(
"flex items-center justify-between gap-2",
isHero ? "px-4 pb-4" : "px-3 pb-2",
"flex items-center justify-between",
isHero ? cn("gap-1.5 px-4", showProjectPicker ? "pb-1.5" : "pb-3.5") : "gap-2 px-3 pb-2",
)}
>
<div className="flex min-w-0 items-center gap-2">
<div className={cn("flex min-w-0 flex-1 items-center", isHero ? "gap-1.5" : "gap-2")}>
<input
ref={fileInputRef}
type="file"
@@ -1207,36 +1247,46 @@ export function ThreadComposer({
className={cn(
"rounded-full text-muted-foreground hover:text-foreground",
isHero
? "h-9 w-9 border border-border/55 bg-card shadow-[0_2px_8px_rgba(15,23,42,0.05)] hover:bg-card"
? "h-8 w-8 border border-border/55 bg-card shadow-[0_2px_8px_rgba(15,23,42,0.05)] hover:bg-card"
: "h-9 w-9 border border-border/55 bg-card shadow-[0_2px_8px_rgba(15,23,42,0.05)] hover:bg-card",
)}
>
<Plus className={cn(isHero ? "h-5 w-5" : "h-4 w-4")} />
<Plus className={cn(isHero ? "h-[18px] w-[18px]" : "h-4 w-4")} />
</Button>
<div ref={aspectControlRef} className="relative flex items-center gap-1">
<Button
type="button"
variant="ghost"
disabled={disabled}
aria-pressed={imageMode}
aria-label={t("thread.composer.imageMode.toggle")}
onClick={() => {
setImageMode(!imageMode);
setAspectMenuOpen(false);
textareaRef.current?.focus();
}}
className={cn(
"rounded-full border border-border/55 px-2.5 font-medium shadow-[0_2px_8px_rgba(15,23,42,0.04)]",
"h-9 text-[12px]",
imageMode
? "border-primary/30 bg-primary/10 text-primary hover:bg-primary/12"
: "bg-card text-muted-foreground hover:bg-card hover:text-foreground",
)}
>
<ImageIcon className={cn("mr-1.5", isHero ? "h-4 w-4" : "h-3.5 w-3.5")} />
{t("thread.composer.imageMode.label")}
</Button>
{imageMode ? (
{workspaceScope ? (
<WorkspaceAccessMenu
scope={workspaceScope}
disabled={disabled || workspaceScopeDisabled}
canUseFullAccess={workspaceControls?.can_use_full_access !== false}
isHero={isHero}
onChange={onWorkspaceScopeChange}
/>
) : null}
{imageGenerationEnabled ? (
<div ref={aspectControlRef} className="relative flex items-center gap-1">
<Button
type="button"
variant="ghost"
disabled={disabled}
aria-pressed={imageMode}
aria-label={t("thread.composer.imageMode.toggle")}
onClick={() => {
setImageMode(!imageMode);
setAspectMenuOpen(false);
textareaRef.current?.focus();
}}
className={cn(
"max-w-[11rem] rounded-full border border-border/55 px-2.5 font-medium shadow-[0_2px_8px_rgba(15,23,42,0.04)]",
isHero ? "h-8 text-[11.5px]" : "h-9 text-[12px]",
imageMode
? "border-primary/30 bg-primary/10 text-primary hover:bg-primary/12"
: "bg-card text-muted-foreground hover:bg-card hover:text-foreground",
)}
>
<ImageIcon className={cn("mr-1.5", isHero ? "h-3.5 w-3.5" : "h-3.5 w-3.5")} />
<span className="truncate">{t("thread.composer.imageMode.label")}</span>
</Button>
{imageMode ? (
<Button
type="button"
variant="ghost"
@@ -1247,25 +1297,28 @@ export function ThreadComposer({
onClick={() => setAspectMenuOpen((open) => !open)}
className={cn(
"rounded-full border border-border/55 bg-card px-2.5 font-medium text-foreground/80 shadow-[0_2px_8px_rgba(15,23,42,0.04)] hover:bg-card",
"h-9 text-[12px]",
isHero ? "h-8 text-[11.5px]" : "h-9 text-[12px]",
)}
>
<span>{t(`thread.composer.imageMode.aspect.${imageAspectRatio.replace(":", "_")}`)}</span>
<ChevronDown className={cn("ml-1.5", isHero ? "h-3.5 w-3.5" : "h-3 w-3")} />
</Button>
) : null}
{imageMode && aspectMenuOpen ? (
<ImageAspectMenu
selected={imageAspectRatio}
isHero={isHero}
onSelect={(ratio) => {
setImageAspectRatio(ratio);
setAspectMenuOpen(false);
textareaRef.current?.focus();
}}
/>
) : null}
</div>
) : null}
{imageMode && aspectMenuOpen ? (
<ImageAspectMenu
selected={imageAspectRatio}
isHero={isHero}
onSelect={(ratio) => {
setImageAspectRatio(ratio);
setAspectMenuOpen(false);
textareaRef.current?.focus();
}}
/>
) : null}
</div>
) : null}
</div>
<div className={cn("flex shrink-0 items-center", isHero ? "gap-1.5" : "gap-2")}>
{modelLabel ? (
<ComposerModelBadge
label={modelLabel}
@@ -1274,39 +1327,42 @@ export function ThreadComposer({
isHero={isHero}
/>
) : null}
{!isHero ? (
<span className="hidden select-none text-[10.5px] text-muted-foreground/60 sm:inline">
{t("thread.composer.sendHint")}
</span>
) : null}
<Button
type={showStopButton ? "button" : "submit"}
size="icon"
disabled={showStopButton ? disabled : !canSend}
aria-label={showStopButton ? t("thread.composer.stop") : t("thread.composer.send")}
onClick={showStopButton ? onStop : 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",
isHero ? "h-8 w-8" : "h-9 w-9",
(canSend || showStopButton) && "hover:scale-[1.03] active:scale-95",
)}
>
{showStopButton ? (
<Square className={cn("fill-current stroke-current", isHero ? "h-3 w-3" : "h-3.5 w-3.5")} />
) : isStreaming ? (
<Loader2 className={cn(isHero ? "h-4 w-4" : "h-4 w-4", "animate-spin")} />
) : (
<ArrowUp className={cn(isHero ? "h-4 w-4" : "h-4 w-4")} />
)}
</Button>
</div>
<span className={cn(isHero ? "hidden" : "sm:hidden")} aria-hidden />
<Button
type={showStopButton ? "button" : "submit"}
size="icon"
disabled={showStopButton ? disabled : !canSend}
aria-label={showStopButton ? t("thread.composer.stop") : t("thread.composer.send")}
onClick={showStopButton ? onStop : 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",
"h-9 w-9",
(canSend || showStopButton) && "hover:scale-[1.03] active:scale-95",
)}
>
{showStopButton ? (
<Square className={cn("fill-current stroke-current", isHero ? "h-3 w-3" : "h-2.5 w-2.5")} />
) : isStreaming ? (
<Loader2 className={cn(isHero ? "h-4.5 w-4.5" : "h-4 w-4", "animate-spin")} />
) : (
<ArrowUp className={cn(isHero ? "h-4.5 w-4.5" : "h-4 w-4")} />
)}
</Button>
</div>
<WorkspaceProjectPicker
isHero={isHero}
disabled={disabled || workspaceScopeDisabled}
scope={workspaceScope}
defaultScope={workspaceDefaultScope}
controls={workspaceControls}
error={workspaceError}
onChange={onWorkspaceScopeChange}
/>
</div>
</form>
);
@@ -1338,14 +1394,14 @@ function ComposerModelBadge({
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)]",
isHero ? "h-9 max-w-[13.5rem] gap-2 px-2.5 text-[12px]" : "h-9 max-w-[12rem] gap-2 px-2.5 text-[12px]",
isHero ? "h-8 max-w-[12.5rem] gap-1.5 px-2 text-[11.5px]" : "h-9 max-w-[12rem] gap-2 px-2.5 text-[12px]",
)}
>
<span
data-testid={inferredProvider ? `composer-model-logo-${inferredProvider}` : "composer-model-logo"}
className={cn(
"grid shrink-0 place-items-center overflow-hidden rounded-full border bg-background",
"h-5 w-5",
isHero ? "h-[18px] w-[18px]" : "h-5 w-5",
)}
style={{
borderColor: brand ? `${brand.color}28` : undefined,
@@ -1357,21 +1413,21 @@ function ComposerModelBadge({
<img
src={logoUrl}
alt=""
className="h-3.5 w-3.5 object-contain"
className={cn("object-contain", isHero ? "h-3 w-3" : "h-3.5 w-3.5")}
onError={() => setLogoIndex((index) => index + 1)}
/>
) : brand ? (
<span
className={cn(
"grid h-full w-full place-items-center rounded-full text-white",
"text-[8px]",
isHero ? "text-[7.5px]" : "text-[8px]",
)}
style={{ backgroundColor: brand.color }}
>
{brand.initials.slice(0, 2)}
</span>
) : (
<Sparkles className={cn("text-muted-foreground/65", isHero ? "h-3.5 w-3.5" : "h-3 w-3")} />
<Sparkles className={cn("text-muted-foreground/65", isHero ? "h-3 w-3" : "h-3 w-3")} />
)}
</span>
<span className="truncate">{label}</span>
@@ -1440,6 +1496,23 @@ interface CliAppMentionPaletteProps {
onChoose: (candidate: MentionCandidate) => void;
}
function useSelectedOptionScroll(selectedIndex: number) {
const containerRef = useRef<HTMLDivElement | null>(null);
useLayoutEffect(() => {
const container = containerRef.current;
if (!container) return;
const option = container.querySelector<HTMLElement>(
`[data-palette-index="${selectedIndex}"]`,
);
if (typeof option?.scrollIntoView === "function") {
option.scrollIntoView({ block: "nearest" });
}
}, [selectedIndex]);
return containerRef;
}
function ImageAspectMenu({
selected,
isHero,
@@ -1506,6 +1579,7 @@ function CliAppMentionPalette({
0,
layout.maxHeight - SLASH_PALETTE_CHROME_PX,
);
const listRef = useSelectedOptionScroll(selectedIndex);
return (
<div
role="listbox"
@@ -1522,7 +1596,7 @@ function CliAppMentionPalette({
<div className="px-2 pb-1.5 pt-0.5 text-[13px] font-semibold text-muted-foreground/78">
{t("thread.composer.mentions.label")}
</div>
<div className="overflow-y-auto" style={{ maxHeight: listMaxHeight }}>
<div ref={listRef} className="overflow-y-auto" style={{ maxHeight: listMaxHeight }}>
{candidates.map((candidate, index) => {
const selected = index === selectedIndex;
const name = candidate.name;
@@ -1540,6 +1614,7 @@ function CliAppMentionPalette({
key={`${candidate.kind}-${name}`}
type="button"
role="option"
data-palette-index={index}
aria-selected={selected}
aria-label={`${displayName} @${name} ${ariaDescription} ${typeLabel}`}
onMouseEnter={() => onHover(index)}
@@ -1640,6 +1715,7 @@ function SlashCommandPalette({
0,
layout.maxHeight - SLASH_PALETTE_CHROME_PX,
);
const listRef = useSelectedOptionScroll(selectedIndex);
return (
<div
role="listbox"
@@ -1653,7 +1729,7 @@ function SlashCommandPalette({
isHero ? "max-w-[58rem]" : "max-w-[49.5rem]",
)}
>
<div className="overflow-y-auto pr-0.5" style={{ maxHeight: listMaxHeight }}>
<div ref={listRef} className="overflow-y-auto pr-0.5" style={{ maxHeight: listMaxHeight }}>
{commands.map((command, index) => {
const Icon = COMMAND_ICONS[command.icon] ?? CircleHelp;
const selected = index === selectedIndex;
@@ -1669,6 +1745,7 @@ function SlashCommandPalette({
key={command.command}
type="button"
role="option"
data-palette-index={index}
aria-selected={selected}
onMouseEnter={() => onHover(index)}
onMouseDown={(e) => {
+4 -4
View File
@@ -9,7 +9,7 @@ interface ThreadHeaderProps {
onToggleSidebar: () => void;
theme: "light" | "dark";
onToggleTheme: () => void;
hideSidebarToggleOnDesktop?: boolean;
hideSidebarToggleForHostChrome?: boolean;
minimal?: boolean;
}
@@ -18,7 +18,7 @@ export function ThreadHeader({
onToggleSidebar,
theme,
onToggleTheme,
hideSidebarToggleOnDesktop = false,
hideSidebarToggleForHostChrome = false,
minimal = false,
}: ThreadHeaderProps) {
const { t } = useTranslation();
@@ -32,7 +32,7 @@ export function ThreadHeader({
onClick={onToggleSidebar}
className={cn(
"h-7 w-7 rounded-md text-muted-foreground hover:bg-accent/35 hover:text-foreground",
hideSidebarToggleOnDesktop && "lg:hidden",
hideSidebarToggleForHostChrome && "lg:hidden",
)}
>
<Menu className="h-3.5 w-3.5" />
@@ -57,7 +57,7 @@ export function ThreadHeader({
onClick={onToggleSidebar}
className={cn(
"h-7 w-7 rounded-md text-muted-foreground hover:bg-accent/35 hover:text-foreground",
hideSidebarToggleOnDesktop && "lg:hidden",
hideSidebarToggleForHostChrome && "lg:hidden",
)}
>
<Menu className="h-3.5 w-3.5" />
+58 -3
View File
@@ -59,7 +59,7 @@ export function buildDisplayUnits(messages: UIMessage[]): DisplayUnit[] {
cluster.push(current);
i += 1;
}
out.push({ type: "cluster", messages: cluster });
pushActivityCluster(out, cluster);
continue;
}
const previous = out[out.length - 1];
@@ -85,6 +85,42 @@ export function buildDisplayUnits(messages: UIMessage[]): DisplayUnit[] {
return out;
}
function pushActivityCluster(out: DisplayUnit[], cluster: UIMessage[]) {
const previous = out[out.length - 1];
if (
previous?.type !== "single"
|| !shouldPlaceLateActivityBeforeAssistant(out, previous.message)
) {
out.push({ type: "cluster", messages: cluster });
return;
}
const beforeAssistant = out[out.length - 2];
if (beforeAssistant?.type === "cluster" && canMergeActivityClusters(beforeAssistant.messages, cluster)) {
beforeAssistant.messages.push(...cluster);
return;
}
out.splice(out.length - 1, 0, { type: "cluster", messages: cluster });
}
function shouldPlaceLateActivityBeforeAssistant(out: DisplayUnit[], message: UIMessage): boolean {
if (message.role !== "assistant" || message.kind === "trace") return false;
if (message.isStreaming) return true;
if (hasTurnLatency(message)) return true;
const beforeAssistant = out[out.length - 2];
return beforeAssistant?.type === "cluster";
}
function hasTurnLatency(message: UIMessage): boolean {
return (
typeof message.latencyMs === "number"
&& Number.isFinite(message.latencyMs)
&& message.latencyMs >= 0
);
}
function clusterSegmentId(messages: UIMessage[]): string | undefined {
return messages.find((message) => message.activitySegmentId)?.activitySegmentId;
}
@@ -115,6 +151,19 @@ function canFoldInlineReasoning(cluster: UIMessage[], message: UIMessage): boole
return segmentId === message.activitySegmentId;
}
function canMergeActivityClusters(target: UIMessage[], incoming: UIMessage[]): boolean {
let segmentId = clusterSegmentId(target);
let includesFileEdits = clusterHasFileEdits(target);
for (const message of incoming) {
if (!canJoinActivityCluster(segmentId, includesFileEdits, message)) return false;
if (!segmentId && message.activitySegmentId) {
segmentId = message.activitySegmentId;
}
includesFileEdits = includesFileEdits || hasFileEdits(message);
}
return true;
}
function assistantHasInlineReasoning(message: UIMessage): boolean {
return (
message.role === "assistant"
@@ -261,8 +310,14 @@ function activityClusterTurnLatencyMs(
}
function currentActivityClusterIndex(units: DisplayUnit[]): number {
const last = units.length - 1;
return units[last]?.type === "cluster" ? last : -1;
for (let i = units.length - 1; i >= 0; i -= 1) {
const unit = units[i];
if (unit.type === "cluster") return i;
if (unit.message.role === "assistant" && unit.message.isStreaming) continue;
if (unit.message.role === "user") break;
return -1;
}
return -1;
}
function unitKey(unit: DisplayUnit, index: number): string {
+97 -92
View File
@@ -1,16 +1,4 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import {
BarChart3,
BookOpen,
ChevronRight,
Code2,
ImageIcon,
LayoutGrid,
Lightbulb,
MoreHorizontal,
Palette,
Sparkles,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { ThreadComposer } from "@/components/thread/ThreadComposer";
@@ -31,7 +19,16 @@ import {
isMcpPresetsPayload,
} from "@/lib/mcp-preset-events";
import { inferProviderFromModelName, providerDisplayLabel } from "@/lib/provider-brand";
import type { ChatSummary, CliAppInfo, McpPresetInfo, SettingsPayload, SlashCommand, UIMessage } from "@/lib/types";
import type {
ChatSummary,
CliAppInfo,
McpPresetInfo,
SettingsPayload,
SlashCommand,
UIMessage,
WorkspaceScopePayload,
WorkspacesPayload,
} from "@/lib/types";
import { normalizeLegacyLongTaskMessages } from "@/lib/thread-display-compat";
import { scrubSubagentUiMessages } from "@/lib/subagent-channel-display";
import { useClient } from "@/providers/ClientProvider";
@@ -60,11 +57,19 @@ interface ThreadShellProps {
onToggleSidebar: () => void;
onGoHome?: () => void;
onNewChat?: () => void;
onCreateChat?: () => Promise<string | null>;
onCreateChat?: (workspaceScope?: WorkspaceScopePayload | null) => Promise<string | null>;
onTurnEnd?: () => void;
theme?: "light" | "dark";
onToggleTheme?: () => void;
hideSidebarToggleOnDesktop?: boolean;
hideSidebarToggleForHostChrome?: boolean;
hideHeader?: boolean;
workspaceScope?: WorkspaceScopePayload | null;
workspaceDefaultScope?: WorkspaceScopePayload | null;
workspaceControls?: WorkspacesPayload["controls"] | null;
workspaceScopeDisabled?: boolean;
workspaceError?: string | null;
onWorkspaceScopeChange?: (scope: WorkspaceScopePayload) => void;
settingsSnapshot?: SettingsPayload | null;
}
function toModelBadgeLabel(modelName: string | null): string | null {
@@ -110,23 +115,17 @@ function toModelBadgeInfo(modelName: string | null, settings: SettingsPayload |
};
}
const QUICK_ACTION_KEYS = [
{ key: "plan", icon: LayoutGrid, tone: "text-[#f25b8f]" },
{ key: "analyze", icon: BarChart3, tone: "text-[#4f9de8]" },
{ key: "brainstorm", icon: Lightbulb, tone: "text-[#53c59d]" },
{ key: "code", icon: Code2, tone: "text-[#eba45d]" },
{ key: "summarize", icon: BookOpen, tone: "text-[#a877e7]" },
{ key: "more", icon: MoreHorizontal, tone: "text-muted-foreground/65" },
const HERO_GREETING_KEYS = [
"thread.empty.greetings.workOn",
"thread.empty.greetings.start",
"thread.empty.greetings.build",
"thread.empty.greetings.tackle",
] as const;
const IMAGE_QUICK_ACTION_KEYS = [
{ key: "icon", icon: ImageIcon, tone: "text-[#4f9de8]" },
{ key: "sticker", icon: Sparkles, tone: "text-[#f25b8f]" },
{ key: "poster", icon: Palette, tone: "text-[#eba45d]" },
{ key: "product", icon: LayoutGrid, tone: "text-[#53c59d]" },
{ key: "portrait", icon: ImageIcon, tone: "text-[#a877e7]" },
{ key: "edit", icon: MoreHorizontal, tone: "text-muted-foreground/65" },
] as const;
function randomHeroGreetingKey(): (typeof HERO_GREETING_KEYS)[number] {
const index = Math.floor(Math.random() * HERO_GREETING_KEYS.length);
return HERO_GREETING_KEYS[index] ?? HERO_GREETING_KEYS[0];
}
interface PendingFirstMessage {
content: string;
@@ -142,7 +141,15 @@ export function ThreadShell({
onTurnEnd,
theme = "light",
onToggleTheme = () => {},
hideSidebarToggleOnDesktop = false,
hideSidebarToggleForHostChrome = false,
hideHeader = false,
workspaceScope = null,
workspaceDefaultScope = null,
workspaceControls = null,
workspaceScopeDisabled = false,
workspaceError = null,
onWorkspaceScopeChange,
settingsSnapshot = null,
}: ThreadShellProps) {
const { t } = useTranslation();
const chatId = session?.chatId ?? null;
@@ -159,8 +166,9 @@ export function ThreadShell({
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
const [cliApps, setCliApps] = useState<CliAppInfo[]>([]);
const [mcpPresets, setMcpPresets] = useState<McpPresetInfo[]>([]);
const [settings, setSettings] = useState<SettingsPayload | null>(null);
const [settings, setSettings] = useState<SettingsPayload | null>(settingsSnapshot);
const [heroImageMode, setHeroImageMode] = useState(false);
const [heroGreetingKey, setHeroGreetingKey] = useState(randomHeroGreetingKey);
const [scrollToBottomSignal, setScrollToBottomSignal] = useState(0);
const pendingFirstRef = useRef<PendingFirstMessage | null>(null);
const messageCacheRef = useRef<Map<string, UIMessage[]>>(new Map());
@@ -198,22 +206,46 @@ export function ThreadShell({
const displayMessages = useMemo(() => projectWebuiThreadMessages(messages), [messages]);
const showHeroComposer = messages.length === 0 && !loading;
const wasShowingHeroComposerRef = useRef(showHeroComposer);
const modelBadge = useMemo(
() => toModelBadgeInfo(modelName, settings),
[modelName, settings],
);
const imageGenerationEnabled = settings?.image_generation.enabled === true;
useEffect(() => {
if (showHeroComposer && !wasShowingHeroComposerRef.current) {
setHeroGreetingKey(randomHeroGreetingKey());
}
wasShowingHeroComposerRef.current = showHeroComposer;
}, [showHeroComposer]);
const withWorkspaceScope = useCallback(
(options?: SendOptions): SendOptions | undefined => {
if (!workspaceScope) return options;
return {
...(options ?? {}),
workspaceScope,
};
},
[workspaceScope],
);
const refreshModelSettings = useCallback(async () => {
try {
setSettings(await fetchSettings(token));
} catch {
setSettings(null);
if (!settingsSnapshot) setSettings(null);
}
}, [token]);
}, [settingsSnapshot, token]);
useEffect(() => {
if (settingsSnapshot) {
setSettings(settingsSnapshot);
return;
}
void refreshModelSettings();
}, [refreshModelSettings]);
}, [refreshModelSettings, settingsSnapshot]);
useEffect(() => {
return client.onRuntimeModelUpdate(() => {
@@ -433,64 +465,22 @@ export function ThreadShell({
async (content: string, images?: SendImage[], options?: SendOptions) => {
if (booting) return;
setBooting(true);
pendingFirstRef.current = { content, images, options };
const newId = await onCreateChat?.();
pendingFirstRef.current = { content, images, options: withWorkspaceScope(options) };
const newId = await onCreateChat?.(workspaceScope);
if (!newId) {
pendingFirstRef.current = null;
setBooting(false);
}
},
[booting, onCreateChat],
[booting, onCreateChat, withWorkspaceScope, workspaceScope],
);
const handleThreadSend = useCallback(
(content: string, images?: SendImage[], options?: SendOptions) => {
setScrollToBottomSignal((value) => value + 1);
send(content, images, options);
send(content, images, withWorkspaceScope(options));
},
[send],
);
const handleQuickAction = useCallback(
(prompt: string) => {
const options: SendOptions | undefined = heroImageMode
? { imageGeneration: { enabled: true, aspect_ratio: null } }
: undefined;
if (session) {
handleThreadSend(prompt, undefined, options);
return;
}
void handleWelcomeSend(prompt, undefined, options);
},
[handleThreadSend, handleWelcomeSend, heroImageMode, session],
);
const quickActionItems = heroImageMode ? IMAGE_QUICK_ACTION_KEYS : QUICK_ACTION_KEYS;
const quickActionPrefix = heroImageMode
? "thread.empty.imageQuickActions"
: "thread.empty.quickActions";
const quickActions = (
<div className="mx-auto grid w-full max-w-[58rem] grid-cols-2 gap-3 pt-4 sm:grid-cols-3 lg:grid-cols-6 lg:gap-4">
{quickActionItems.map(({ key, icon: Icon, tone }) => {
const title = t(`${quickActionPrefix}.${key}.title`);
const prompt = t(`${quickActionPrefix}.${key}.prompt`);
return (
<button
key={key}
type="button"
onClick={() => handleQuickAction(prompt)}
disabled={booting || isStreaming}
className="group flex min-h-[136px] flex-col justify-between rounded-[20px] border border-black/[0.035] bg-card px-5 py-5 text-left shadow-[0_14px_34px_rgba(15,23,42,0.07)] transition-all hover:-translate-y-0.5 hover:shadow-[0_18px_42px_rgba(15,23,42,0.10)] disabled:pointer-events-none disabled:opacity-60 dark:border-white/[0.06] dark:shadow-[0_16px_34px_rgba(0,0,0,0.28)]"
>
<Icon className={`h-[18px] w-[18px] ${tone}`} strokeWidth={2} />
<span className="max-w-[7.5rem] text-[15px] font-medium leading-[1.28] tracking-[-0.01em] text-foreground/82">
{title}
</span>
<ChevronRight className="h-4 w-4 self-end text-muted-foreground/45 transition-colors group-hover:text-muted-foreground" />
</button>
);
})}
</div>
[send, withWorkspaceScope],
);
const composer = (
@@ -518,11 +508,18 @@ export function ThreadShell({
slashCommands={slashCommands}
cliApps={cliApps}
mcpPresets={mcpPresets}
imageGenerationEnabled={imageGenerationEnabled}
imageMode={showHeroComposer ? heroImageMode : undefined}
onImageModeChange={showHeroComposer ? setHeroImageMode : undefined}
onStop={stop}
runStartedAt={runStartedAt}
goalState={goalState}
workspaceScope={workspaceScope}
workspaceDefaultScope={workspaceDefaultScope}
workspaceControls={workspaceControls}
workspaceScopeDisabled={workspaceScopeDisabled}
workspaceError={workspaceError}
onWorkspaceScopeChange={onWorkspaceScopeChange}
/>
) : (
<ThreadComposer
@@ -541,13 +538,19 @@ export function ThreadShell({
slashCommands={slashCommands}
cliApps={cliApps}
mcpPresets={mcpPresets}
imageGenerationEnabled={imageGenerationEnabled}
imageMode={heroImageMode}
onImageModeChange={setHeroImageMode}
runStartedAt={runStartedAt}
goalState={goalState}
workspaceScope={workspaceScope}
workspaceDefaultScope={workspaceDefaultScope}
workspaceControls={workspaceControls}
workspaceScopeDisabled={workspaceScopeDisabled}
workspaceError={workspaceError}
onWorkspaceScopeChange={onWorkspaceScopeChange}
/>
)}
{showHeroComposer ? quickActions : null}
</>
);
@@ -558,21 +561,23 @@ export function ThreadShell({
) : (
<div className="flex w-full flex-col items-center text-center animate-in fade-in-0 slide-in-from-bottom-2 duration-500">
<h1 className="text-balance text-[40px] font-normal leading-tight tracking-[-0.045em] text-foreground sm:text-[48px]">
{t("thread.empty.greeting")}
{t(heroGreetingKey)}
</h1>
</div>
);
return (
<section className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
<ThreadHeader
title={title}
onToggleSidebar={onToggleSidebar}
theme={theme}
onToggleTheme={onToggleTheme}
hideSidebarToggleOnDesktop={hideSidebarToggleOnDesktop}
minimal={!session && !loading}
/>
{!hideHeader ? (
<ThreadHeader
title={title}
onToggleSidebar={onToggleSidebar}
theme={theme}
onToggleTheme={onToggleTheme}
hideSidebarToggleForHostChrome={hideSidebarToggleForHostChrome}
minimal={!session && !loading}
/>
) : null}
<ThreadViewport
messages={displayMessages}
isStreaming={isStreaming}
@@ -271,9 +271,11 @@ export function ThreadViewport({
</div>
) : (
<div ref={contentRef} className="mx-auto flex min-h-full w-full max-w-[72rem] flex-col px-4">
<div className="flex w-full flex-1 items-center justify-center pb-[7vh] pt-8">
<div className="flex w-full max-w-[58rem] flex-col gap-6">
{emptyState}
<div className="flex w-full flex-1 items-center justify-center py-10 sm:py-12">
<div className="relative w-full max-w-[58rem]">
<div className="absolute inset-x-0 bottom-[calc(100%+1.5rem)] flex justify-center">
{emptyState}
</div>
<div className="w-full">{composer}</div>
</div>
</div>
@@ -0,0 +1,326 @@
import { useCallback, useEffect, useState, type ReactNode } from "react";
import { AlertTriangle, Check, ChevronDown, Folder, Hand } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import type {
WorkspaceAccessMode,
WorkspaceScopePayload,
WorkspacesPayload,
} from "@/lib/types";
import { getHostApi } from "@/lib/runtime";
import { cn } from "@/lib/utils";
import {
isAbsoluteWorkspacePath,
projectNameFromPath,
scopeWithAccessMode,
selectedProjectScope,
shortWorkspacePath,
} from "@/lib/workspace";
export function WorkspaceProjectPicker({
isHero,
disabled,
scope,
defaultScope,
controls,
error,
onChange,
}: {
isHero: boolean;
disabled?: boolean;
scope: WorkspaceScopePayload | null;
defaultScope: WorkspaceScopePayload | null;
controls: WorkspacesPayload["controls"] | null;
error?: string | null;
onChange?: (scope: WorkspaceScopePayload) => void;
}) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [pathDraft, setPathDraft] = useState("");
const [pathError, setPathError] = useState<string | null>(null);
const [pickingFolder, setPickingFolder] = useState(false);
const currentProjectScope = selectedProjectScope(scope, defaultScope);
const projectLabel = currentProjectScope
? currentProjectScope.project_name || projectNameFromPath(currentProjectScope.project_path)
: t("thread.composer.workspace.projectPlaceholder");
const visible = isHero
&& !!defaultScope
&& !!onChange
&& controls?.can_change_project !== false;
const hostApi = getHostApi();
const nativeProjectPicker = !!hostApi;
useEffect(() => {
if (!open) return;
setPathDraft(currentProjectScope?.project_path ?? "");
setPathError(null);
}, [currentProjectScope?.project_path, open]);
useEffect(() => {
if (error && visible) setOpen(true);
}, [error, visible]);
const applyProjectPath = useCallback(
(projectPath: string, projectName?: string) => {
const base = scope ?? defaultScope;
const trimmed = projectPath.trim();
if (!base || !onChange) return;
if (!trimmed || !isAbsoluteWorkspacePath(trimmed)) {
setPathError(t("workspace.dialog.absolutePathRequired"));
return;
}
onChange({
...base,
project_path: trimmed,
project_name: projectName || projectNameFromPath(trimmed),
restrict_to_workspace: base.access_mode === "restricted",
});
setPathError(null);
setOpen(false);
},
[defaultScope, onChange, scope, t],
);
const pickNativeFolder = useCallback(async () => {
if (!hostApi || disabled) return;
setPickingFolder(true);
try {
const picked = await hostApi.pickFolder();
if (picked) applyProjectPath(picked);
} catch (err) {
setPathError((err as Error).message);
} finally {
setPickingFolder(false);
}
}, [applyProjectPath, disabled, hostApi]);
if (!visible || !defaultScope || !onChange) return null;
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]">
<button
type="button"
disabled={disabled || pickingFolder}
aria-label={t("thread.composer.workspace.projectAria")}
title={currentProjectScope?.project_path}
onClick={() => void pickNativeFolder()}
className={cn(
"inline-flex h-7 max-w-[18rem] items-center gap-2 rounded-full px-2.5",
"text-[12px] font-medium text-muted-foreground/90 transition-colors",
"hover:bg-background/70 hover:text-foreground disabled:pointer-events-none disabled:opacity-55",
currentProjectScope && "text-foreground/82",
)}
>
<Folder className={cn("h-3.5 w-3.5 shrink-0", currentProjectScope && "text-primary")} />
<span className="truncate">{projectLabel}</span>
</button>
{pathError || error ? (
<span role="alert" className="ml-2 truncate text-[11.5px] font-medium text-destructive">
{pathError ?? error}
</span>
) : null}
</div>
);
}
return (
<div className="flex items-center 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
type="button"
disabled={disabled}
aria-label={t("thread.composer.workspace.projectAria")}
className={cn(
"inline-flex h-7 max-w-[18rem] items-center gap-2 rounded-full px-2.5",
"text-[12px] font-medium text-muted-foreground/90 transition-colors",
"hover:bg-background/70 hover:text-foreground disabled:pointer-events-none disabled:opacity-55",
currentProjectScope && "text-foreground/82",
)}
>
<Folder className={cn("h-3.5 w-3.5 shrink-0", currentProjectScope && "text-primary")} />
<span className="truncate">{projectLabel}</span>
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
side="bottom"
sideOffset={8}
className="w-[min(25rem,calc(100vw-2rem))] rounded-[22px]"
>
<DropdownMenuItem
onSelect={() => applyProjectPath(defaultScope.project_path, defaultScope.project_name)}
className="flex min-h-[48px] cursor-default gap-3 rounded-[16px] px-3 py-2.5 focus:bg-muted/55"
>
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-[12px] bg-muted text-foreground/80">
<Folder className="h-4 w-4" />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[13px] font-semibold text-foreground">
{t("workspace.dialog.defaultProject")}
</span>
<span className="block truncate text-[11.5px] text-muted-foreground">
{shortWorkspacePath(defaultScope.project_path)}
</span>
</span>
{!currentProjectScope ? <Check className="h-4 w-4 text-foreground/80" /> : null}
</DropdownMenuItem>
<div className="my-1 h-px bg-border/45" />
<div
className="space-y-1.5 px-1.5 py-1.5"
onKeyDown={(event) => {
if (event.key !== "Escape") event.stopPropagation();
}}
>
<form
className="flex items-center gap-2"
onSubmit={(event) => {
event.preventDefault();
applyProjectPath(pathDraft);
}}
>
<Input
value={pathDraft}
disabled={disabled}
onChange={(event) => {
setPathDraft(event.target.value);
setPathError(null);
}}
placeholder={t("workspace.dialog.manualPlaceholder")}
aria-label={t("workspace.dialog.manual")}
className={cn(
"h-9 rounded-full border-border/55 bg-background/80 px-3 text-[12.5px]",
"focus-visible:ring-1 focus-visible:ring-foreground/10 focus-visible:ring-offset-0",
)}
/>
<Button
type="submit"
disabled={disabled || !pathDraft.trim()}
className="h-9 shrink-0 rounded-full px-3 text-[12px]"
>
{t("workspace.dialog.usePath")}
</Button>
</form>
{pathError || error ? (
<p role="alert" className="px-1 text-[11.5px] font-medium text-destructive">
{pathError ?? error}
</p>
) : null}
</div>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
export function WorkspaceAccessMenu({
scope,
disabled,
canUseFullAccess,
isHero,
onChange,
}: {
scope: WorkspaceScopePayload;
disabled?: boolean;
canUseFullAccess: boolean;
isHero: boolean;
onChange?: (scope: WorkspaceScopePayload) => void;
}) {
const { t } = useTranslation();
const mode = scope.access_mode;
const isFull = mode === "full";
const setMode = (value: WorkspaceAccessMode) => {
if (value === "full" && !canUseFullAccess) return;
if (value === mode) return;
onChange?.(scopeWithAccessMode(scope, value));
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild disabled={disabled || !onChange}>
<Button
type="button"
variant="ghost"
aria-label={t("thread.composer.workspace.accessAria")}
className={cn(
"max-w-[12.5rem] rounded-[10px] border border-transparent font-semibold shadow-none",
isHero ? "h-8 px-2.5 text-[12px]" : "h-9 px-3 text-[12.5px]",
isFull
? "bg-transparent text-orange-600 hover:bg-orange-500/8 dark:text-orange-300 dark:hover:bg-orange-400/10"
: "bg-transparent text-muted-foreground hover:bg-foreground/[0.045] hover:text-foreground dark:hover:bg-white/[0.06]",
)}
>
{isFull ? (
<AlertTriangle className={cn("mr-1.5 shrink-0", isHero ? "h-3.5 w-3.5" : "h-3.5 w-3.5")} />
) : (
<Hand className={cn("mr-1.5 shrink-0", isHero ? "h-3.5 w-3.5" : "h-3.5 w-3.5")} />
)}
<span className="truncate">
{t(isFull ? "thread.composer.workspace.full" : "thread.composer.workspace.default")}
</span>
<ChevronDown className={cn("ml-1.5 shrink-0", isHero ? "h-3 w-3" : "h-3 w-3")} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56">
<AccessMenuItem
icon={<Hand className="h-4 w-4" />}
label={t("thread.composer.workspace.default")}
selected={mode === "restricted"}
onSelect={() => setMode("restricted")}
/>
<AccessMenuItem
icon={<AlertTriangle className="h-4 w-4" />}
label={t("thread.composer.workspace.full")}
selected={mode === "full"}
disabled={!canUseFullAccess}
warning
onSelect={() => setMode("full")}
/>
</DropdownMenuContent>
</DropdownMenu>
);
}
function AccessMenuItem({
icon,
label,
selected,
disabled,
warning,
onSelect,
}: {
icon: ReactNode;
label: string;
selected: boolean;
disabled?: boolean;
warning?: boolean;
onSelect: () => void;
}) {
return (
<DropdownMenuItem
disabled={disabled}
onSelect={onSelect}
className={cn(
"flex h-10 items-center gap-3 rounded-xl px-3 text-[13.5px] font-semibold",
warning && "text-orange-600 focus:text-orange-600 dark:text-orange-300 dark:focus:text-orange-300",
)}
>
<span className="grid h-5 w-5 shrink-0 place-items-center text-current" aria-hidden>
{icon}
</span>
<span className="min-w-0 flex-1 truncate">{label}</span>
{selected ? <Check className="h-4 w-4 shrink-0" aria-hidden /> : null}
</DropdownMenuItem>
);
}
-4
View File
@@ -5,7 +5,6 @@ import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button";
const AlertDialog = AlertDialogPrimitive.Root;
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
const AlertDialogPortal = AlertDialogPrimitive.Portal;
const AlertDialogOverlay = React.forwardRef<
@@ -128,8 +127,5 @@ export {
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
};
-5
View File
@@ -5,9 +5,7 @@ import { X } from "lucide-react";
import { cn } from "@/lib/utils";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
@@ -114,12 +112,9 @@ DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogPortal,
DialogTitle,
DialogTrigger,
};
+22 -12
View File
@@ -11,6 +11,12 @@ const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const menuContentClassName =
"z-50 max-h-[min(var(--radix-dropdown-menu-content-available-height),28rem)] min-w-[10rem] overflow-x-hidden overflow-y-auto overscroll-contain rounded-[18px] border border-border/65 bg-popover/96 p-1.5 text-popover-foreground shadow-[0_18px_55px_rgba(15,23,42,0.18)] backdrop-blur-xl dark:border-white/10 dark:shadow-[0_22px_55px_rgba(0,0,0,0.45)]";
const menuItemClassName =
"relative flex min-h-8 cursor-default select-none items-center gap-2 rounded-[12px] px-2.5 py-2 text-[13px] outline-none transition-colors focus:bg-foreground/[0.055] focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-white/[0.08]";
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
@@ -20,14 +26,15 @@ const DropdownMenuSubTrigger = React.forwardRef<
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
menuItemClassName,
"data-[state=open]:bg-foreground/[0.055] dark:data-[state=open]:bg-white/[0.08]",
inset && "pl-8",
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto" />
<ChevronRight className="ml-auto h-3.5 w-3.5 text-muted-foreground" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
@@ -39,7 +46,7 @@ const DropdownMenuSubContent = React.forwardRef<
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg",
menuContentClassName,
className,
)}
{...props}
@@ -61,7 +68,8 @@ const DropdownMenuContent = React.forwardRef<
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
menuContentClassName,
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
@@ -79,7 +87,7 @@ const DropdownMenuItem = React.forwardRef<
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
menuItemClassName,
inset && "pl-8",
className,
)}
@@ -95,15 +103,16 @@ const DropdownMenuCheckboxItem = React.forwardRef<
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground",
menuItemClassName,
"pl-8 pr-2.5",
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<span className="absolute left-2.5 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
<Check className="h-3.5 w-3.5" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
@@ -119,12 +128,13 @@ const DropdownMenuRadioItem = React.forwardRef<
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground",
menuItemClassName,
"pl-8 pr-2.5",
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<span className="absolute left-2.5 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
@@ -143,7 +153,7 @@ const DropdownMenuLabel = React.forwardRef<
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
"px-2.5 pb-1.5 pt-1 text-[12px] font-semibold text-muted-foreground",
inset && "pl-8",
className,
)}
@@ -158,7 +168,7 @@ const DropdownMenuSeparator = React.forwardRef<
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
className={cn("-mx-1.5 my-1.5 h-px bg-border/50", className)}
{...props}
/>
));
+1 -11
View File
@@ -6,8 +6,6 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const Sheet = DialogPrimitive.Root;
const SheetTrigger = DialogPrimitive.Trigger;
const SheetClose = DialogPrimitive.Close;
const SheetPortal = DialogPrimitive.Portal;
const SheetOverlay = React.forwardRef<
@@ -90,14 +88,6 @@ const SheetContent = React.forwardRef<
));
SheetContent.displayName = DialogPrimitive.Content.displayName;
const SheetHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-2 text-center sm:text-left", className)} {...props} />
);
SheetHeader.displayName = "SheetHeader";
const SheetTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
@@ -110,4 +100,4 @@ const SheetTitle = React.forwardRef<
));
SheetTitle.displayName = DialogPrimitive.Title.displayName;
export { Sheet, SheetTrigger, SheetClose, SheetPortal, SheetOverlay, SheetContent, SheetHeader, SheetTitle };
export { Sheet, SheetContent, SheetTitle };
+8
View File
@@ -81,6 +81,14 @@
}
@layer utilities {
.host-drag-region {
-webkit-app-region: drag;
}
.host-no-drag {
-webkit-app-region: no-drag;
}
.shadow-inner-right {
box-shadow: inset -9px 0 6px -1px rgb(0 0 0 / 0.02);
}
+141 -35
View File
@@ -16,9 +16,11 @@ import type {
OutboundMcpPresetMention,
OutboundMedia,
GoalStateWsPayload,
ToolProgressEvent,
UIImage,
UIFileEdit,
UIMessage,
WorkspaceScopePayload,
} from "@/lib/types";
interface StreamBuffer {
@@ -35,6 +37,8 @@ type PendingStreamEvent =
| { kind: "delta"; text: string }
| { kind: "reasoning"; text: string };
const FILE_EDIT_TOOL_NAMES = new Set(["write_file", "edit_file", "apply_patch"]);
/** 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. */
@@ -194,15 +198,6 @@ function stampLastAssistantLatency(prev: UIMessage[], latencyMs: number): UIMess
return prev;
}
function findLatestAssistantAnswerIndex(prev: UIMessage[]): number | null {
for (let i = prev.length - 1; i >= 0; i -= 1) {
const m = prev[i];
if (m.role === "assistant" && m.kind !== "trace") return i;
if (m.role === "user") break;
}
return null;
}
function absorbCompleteAssistantMessage(
prev: UIMessage[],
message: Omit<UIMessage, "id" | "role" | "createdAt">,
@@ -235,6 +230,101 @@ function fileEditKey(edit: Pick<UIFileEdit, "call_id" | "tool" | "path">): strin
return `${edit.tool}|${edit.path}`;
}
function toolEventFileEditKey(event: ToolProgressEvent): string | null {
const fn = (event as { function?: { name?: unknown } }).function;
const name = typeof event.name === "string"
? event.name
: typeof fn?.name === "string"
? fn.name
: "";
const callId = typeof event.call_id === "string" ? event.call_id : "";
if (!name || !callId || !FILE_EDIT_TOOL_NAMES.has(name)) return null;
return `${callId}|${name}`;
}
function hasFileEditForToolEvent(messages: UIMessage[], event: ToolProgressEvent): boolean {
const key = toolEventFileEditKey(event);
if (!key) return false;
return messages.some((message) =>
message.fileEdits?.some((edit) => fileEditKey(edit) === key),
);
}
function filterCoveredFileEditToolEvents(
messages: UIMessage[],
events: ToolProgressEvent[],
): ToolProgressEvent[] {
if (events.length === 0) return events;
return events.filter((event) => !hasFileEditForToolEvent(messages, event));
}
function stripCoveredFileEditToolHints(message: UIMessage, edits: UIFileEdit[]): UIMessage {
const incomingKeys = new Set(edits.map(fileEditKey));
const events = message.toolEvents ?? [];
if (!events.length || incomingKeys.size === 0) return message;
const removedTraceLines = new Set<string>();
const keptEvents: ToolProgressEvent[] = [];
let changed = false;
for (const event of events) {
const key = toolEventFileEditKey(event);
if (key && incomingKeys.has(key)) {
changed = true;
for (const line of toolTraceLinesFromEvents([event])) {
removedTraceLines.add(line);
}
continue;
}
keptEvents.push(event);
}
if (!changed) return message;
const previousTraces = message.traces?.length
? message.traces
: message.content
? [message.content]
: [];
const nextTraces = previousTraces.filter((line) => !removedTraceLines.has(line));
return {
...message,
traces: nextTraces,
content: nextTraces[nextTraces.length - 1] ?? "",
toolEvents: keptEvents.length ? keptEvents : undefined,
};
}
function demoteInterruptedAssistantToActivity(
prev: UIMessage[],
segmentId: string,
): UIMessage[] {
for (let i = prev.length - 1; i >= 0; i -= 1) {
const message = prev[i];
if (message.role === "user") break;
if (
message.role !== "assistant"
|| message.kind === "trace"
|| !message.isStreaming
|| !message.content.trim()
|| message.media?.length
) {
continue;
}
const reasoning = [message.reasoning, message.content]
.filter((part): part is string => typeof part === "string" && part.trim().length > 0)
.join("\n\n");
const demoted: UIMessage = {
...message,
content: "",
reasoning,
reasoningStreaming: false,
isStreaming: false,
activitySegmentId: message.activitySegmentId ?? segmentId,
};
return replaceMessageAt(prev, i, demoted);
}
return prev;
}
function normalizeFileEdit(edit: UIFileEdit): UIFileEdit | null {
if (!edit || !edit.tool || (!edit.path && !edit.pending)) return null;
const inferredStatus =
@@ -285,11 +375,15 @@ function findFileEditTraceIndex(
for (let i = prev.length - 1; i >= 0; i -= 1) {
const candidate = prev[i];
if (candidate.role === "user") break;
if (candidate.kind !== "trace" || !candidate.fileEdits?.length) continue;
if (candidate.kind !== "trace") continue;
if (segmentId && candidate.activitySegmentId === segmentId) return i;
for (const existing of candidate.fileEdits) {
for (const existing of candidate.fileEdits ?? []) {
if (incomingKeys.has(fileEditKey(existing))) return i;
}
for (const event of candidate.toolEvents ?? []) {
const key = toolEventFileEditKey(event);
if (key && incomingKeys.has(key)) return i;
}
}
return null;
}
@@ -315,6 +409,7 @@ export interface SendOptions {
imageGeneration?: OutboundImageGeneration;
cliApps?: OutboundCliAppMention[];
mcpPresets?: OutboundMcpPresetMention[];
workspaceScope?: WorkspaceScopePayload | null;
}
export function useNanobotStream(
@@ -422,7 +517,12 @@ export function useNanobotStream(
const cursor = activeAssistantRef.current;
if (!cursor) return null;
const indexed = prev[cursor.index];
if (indexed?.id === cursor.id && indexed.role === "assistant" && indexed.kind !== "trace") {
if (
indexed?.id === cursor.id
&& indexed.role === "assistant"
&& indexed.kind !== "trace"
&& indexed.isStreaming
) {
return cursor.index;
}
const idx = prev.findIndex((m) => m.id === cursor.id);
@@ -431,7 +531,7 @@ export function useNanobotStream(
return null;
}
const found = prev[idx];
if (found.role !== "assistant" || found.kind === "trace") {
if (found.role !== "assistant" || found.kind === "trace" || !found.isStreaming) {
activeAssistantRef.current = null;
return null;
}
@@ -520,8 +620,7 @@ export function useNanobotStream(
if (finalAnswerText !== undefined) {
const targetIndex =
resolveActiveAssistantIndex(next)
?? findStreamingAssistantIndex(next, closedAssistantStreamIdsRef.current)
?? findLatestAssistantAnswerIndex(next);
?? findStreamingAssistantIndex(next, closedAssistantStreamIdsRef.current);
if (targetIndex !== null) {
const target = next[targetIndex];
next = replaceMessageAt(next, targetIndex, {
@@ -662,6 +761,7 @@ export function useNanobotStream(
if ("goal_state" in ev && ev.goal_state != null && typeof ev.goal_state === "object") {
setGoalState(ev.goal_state);
}
setRunStartedAt(null);
// Definitive signal that the turn is fully complete. Cancel any
// pending debounce timer and stop the loading indicator immediately.
if (streamEndTimerRef.current !== null) {
@@ -710,16 +810,20 @@ 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 structuredLines = toolTraceLinesFromEvents(ev.tool_events);
const lines = structuredLines.length > 0
? structuredLines
: ev.text
? [ev.text]
: [];
if (lines.length === 0) return;
setMessages((prev) => {
const segmentId = ensureActivitySegmentId();
const last = prev[prev.length - 1];
const base = demoteInterruptedAssistantToActivity(prev, segmentId);
const visibleStructuredEvents = filterCoveredFileEditToolEvents(base, structuredEvents);
const structuredLines = toolTraceLinesFromEvents(visibleStructuredEvents);
const lines = structuredLines.length > 0
? structuredLines
: structuredEvents.length > 0
? []
: ev.text
? [ev.text]
: [];
if (lines.length === 0) return base;
const last = base[base.length - 1];
if (
last
&& last.kind === "trace"
@@ -731,7 +835,7 @@ export function useNanobotStream(
: last.content
? [last.content]
: [];
const mergedLines = structuredLines.length > 0
const mergedLines = visibleStructuredEvents.length > 0
? mergeUniqueToolTraceLines(previousTraces, structuredLines)
: null;
const merged: UIMessage = {
@@ -740,22 +844,22 @@ export function useNanobotStream(
content: mergedLines
? mergedLines.traces[mergedLines.traces.length - 1]
: lines[lines.length - 1],
toolEvents: structuredEvents.length
? mergeToolProgressEvents(last.toolEvents, structuredEvents)
toolEvents: visibleStructuredEvents.length
? mergeToolProgressEvents(last.toolEvents, visibleStructuredEvents)
: last.toolEvents,
activitySegmentId: last.activitySegmentId ?? segmentId,
};
return [...prev.slice(0, -1), merged];
return [...base.slice(0, -1), merged];
}
return [
...prev,
...base,
{
id: crypto.randomUUID(),
role: "tool",
kind: "trace",
content: lines[lines.length - 1],
traces: lines,
...(structuredEvents.length ? { toolEvents: structuredEvents } : {}),
...(visibleStructuredEvents.length ? { toolEvents: visibleStructuredEvents } : {}),
activitySegmentId: segmentId,
createdAt: Date.now(),
},
@@ -810,22 +914,24 @@ export function useNanobotStream(
}
setMessages((prev) => {
let segmentId = eventSegmentId;
const targetIndex = findFileEditTraceIndex(prev, segmentId, normalized);
const base = segmentId ? demoteInterruptedAssistantToActivity(prev, segmentId) : prev;
const targetIndex = findFileEditTraceIndex(base, segmentId, normalized);
if (targetIndex !== null) {
const target = prev[targetIndex];
const target = base[targetIndex];
segmentId = target.activitySegmentId ?? segmentId ?? detachedActivitySegmentId();
if (opensFileEditPhase) fileEditSegmentRef.current = segmentId;
const cleanedTarget = stripCoveredFileEditToolHints(target, normalized);
const merged: UIMessage = {
...target,
fileEdits: mergeFileEdits(target.fileEdits, normalized),
...cleanedTarget,
fileEdits: mergeFileEdits(cleanedTarget.fileEdits, normalized),
activitySegmentId: segmentId,
};
return replaceMessageAt(prev, targetIndex, merged);
return replaceMessageAt(base, targetIndex, merged);
}
segmentId = segmentId ?? detachedActivitySegmentId();
if (opensFileEditPhase) fileEditSegmentRef.current = segmentId;
return [
...prev,
...base,
{
id: crypto.randomUUID(),
role: "tool",
+5 -4
View File
@@ -9,7 +9,7 @@ import {
listSessions,
} from "@/lib/api";
import { deriveTitle } from "@/lib/format";
import type { ChatSummary, UIMessage } from "@/lib/types";
import type { ChatSummary, UIMessage, WorkspaceScopePayload } from "@/lib/types";
const EMPTY_MESSAGES: UIMessage[] = [];
@@ -19,7 +19,7 @@ export function useSessions(): {
loading: boolean;
error: string | null;
refresh: () => Promise<void>;
createChat: () => Promise<string>;
createChat: (workspaceScope?: WorkspaceScopePayload | null) => Promise<string>;
deleteChat: (key: string) => Promise<void>;
} {
const { client, token } = useClient();
@@ -66,8 +66,8 @@ export function useSessions(): {
});
}, [client, refresh]);
const createChat = useCallback(async (): Promise<string> => {
const chatId = await client.newChat();
const createChat = useCallback(async (workspaceScope?: WorkspaceScopePayload | null): Promise<string> => {
const chatId = await client.newChat(5_000, workspaceScope);
const key = `websocket:${chatId}`;
optimisticKeysRef.current.add(key);
// Optimistic insert; a subsequent refresh will replace it with the
@@ -81,6 +81,7 @@ export function useSessions(): {
updatedAt: new Date().toISOString(),
title: "",
preview: "",
workspaceScope: workspaceScope ?? null,
},
...prev.filter((s) => s.key !== key),
]);
+2
View File
@@ -12,6 +12,7 @@ export const DEFAULT_SIDEBAR_STATE: SidebarStatePayload = {
pinned_keys: [],
archived_keys: [],
title_overrides: {},
project_name_overrides: {},
tags_by_key: {},
collapsed_groups: {},
view: {
@@ -90,6 +91,7 @@ export function normalizeSidebarState(raw: unknown): SidebarStatePayload {
pinned_keys: uniqueStrings(value.pinned_keys),
archived_keys: uniqueStrings(value.archived_keys),
title_overrides: stringMap(value.title_overrides),
project_name_overrides: stringMap(value.project_name_overrides),
tags_by_key: tagsMap(value.tags_by_key),
collapsed_groups: boolMap(value.collapsed_groups),
view: {
+1 -1
View File
@@ -71,7 +71,7 @@ export function detectNavigatorLocale(): SupportedLocale {
}
export function resolveInitialLocale(): SupportedLocale {
return readStoredLocale() ?? detectNavigatorLocale();
return readStoredLocale() ?? defaultLocale;
}
export function persistLocale(locale: SupportedLocale): void {
+91 -41
View File
@@ -25,7 +25,9 @@
"section": "System",
"restartHint": "Restart nanobot to apply runtime changes.",
"restart": "Restart nanobot",
"restarting": "Restarting..."
"restarting": "Restarting...",
"restartEngine": "Restart engine",
"restartingEngine": "Restarting engine..."
},
"restart": {
"completed": "Restart completed in {{seconds}}s."
@@ -40,25 +42,13 @@
},
"sidebar": {
"navigation": "Sidebar navigation",
"globalActions": "Global actions",
"collapse": "Collapse sidebar",
"toggleTheme": "Toggle theme",
"home": "Home",
"newChat": "New chat",
"searchAria": "Search",
"viewOptions": "View",
"compactList": "Compact list",
"showPreviews": "Show previews",
"showTimestamps": "Show time",
"sortLabel": "Sort",
"sortUpdated": "Recently updated",
"sortCreated": "Recently created",
"sortTitle": "Title A-Z",
"searchPlaceholder": "Search",
"searchResults": "Results",
"noSearchResults": "No matching chats.",
"recent": "Recent",
"refreshSessions": "Refresh sessions",
"settings": "Settings",
"language": {
"label": "Language",
@@ -80,11 +70,11 @@
"models": "Models",
"providers": "Providers",
"image": "Image",
"web": "Web",
"browser": "Web",
"cliApps": "CLI Apps",
"mcp": "MCP",
"runtime": "Runtime",
"advanced": "Advanced",
"runtime": "System",
"advanced": "Security",
"apps": "Apps"
},
"sections": {
@@ -101,10 +91,11 @@
"cliApps": "CLI apps",
"mcp": "MCP services",
"identity": "Identity",
"safety": "Safety",
"webuiSafety": "Web safety",
"capabilities": "Capabilities",
"integrations": "Integrations",
"apps": "Apps"
"apps": "Apps",
"nativeHost": "Native host",
"hostSafety": "App safety"
},
"models": {
"selectModel": "Select model",
@@ -112,6 +103,7 @@
"newConfiguration": "New model configuration",
"newConfigurationHelp": "Save a provider and model as a one-click option.",
"configurationName": "Name",
"configurationNameHelp": "Rename this saved model configuration.",
"configurationNamePlaceholder": "Fast writing"
},
"rows": {
@@ -147,20 +139,14 @@
"botName": "Bot name",
"botIcon": "Bot icon",
"timezone": "Timezone",
"toolHintMaxLength": "Tool hint length",
"workspacePath": "Workspace path",
"heartbeat": "Heartbeat",
"dream": "Dream",
"unifiedSession": "Unified session",
"restrictWorkspace": "Restrict to workspace",
"execTool": "Exec tool",
"execSandbox": "Exec sandbox",
"ssrfWhitelist": "SSRF whitelist",
"mcpServers": "MCP servers",
"pathAppend": "PATH append",
"workspacePath": "Default workspace",
"localServiceAccess": "Local services",
"webuiDefaultAccess": "Default access",
"cliAppsCatalog": "Catalog",
"cliAppsFilter": "Filter",
"configurationDocs": "Configuration docs"
"engine": "Engine",
"logs": "Logs",
"diagnostics": "Diagnostics"
},
"help": {
"theme": "Switch between light and dark appearance.",
@@ -187,13 +173,18 @@
"defaultAspectRatio": "Used when the prompt does not choose an aspect ratio.",
"defaultImageSize": "Size hint sent to providers that support it.",
"maxImagesPerTurn": "Upper bound for one generate_image request.",
"botName": "Shown in runtime surfaces that use the configured bot identity.",
"botIcon": "Short emoji or text shown beside the bot name.",
"timezone": "IANA timezone used by runtime context and schedules.",
"toolHintMaxLength": "Maximum characters shown in tool progress hints.",
"cliAppsCatalog": "Install only the app-specific CLI adapters nanobot can run locally; desktop apps stay untouched.",
"botName": "Shown wherever nanobot uses a display name.",
"botIcon": "Short emoji or text shown with the bot name.",
"timezone": "Used for schedules and time-aware replies.",
"cliAppsCatalog": "Install only the app-specific CLI adapters nanobot can run locally; native apps stay untouched.",
"cliAppsFilter": "Search by app, category, or capability.",
"advancedReadOnly": "Advanced safety controls are read-only in WebUI. Edit config.json intentionally when needed."
"localServiceAccess": "Allow Full Access shell commands to reach localhost services.",
"webuiDefaultAccess": "Used by web chats without a project-specific permission.",
"securityManagedControls": "Web fetches always protect local, private, and metadata services. Core channel safety stays in config.json.",
"logs": "Open the native engine log folder.",
"diagnostics": "Export a small runtime report for support.",
"localServiceAccessNative": "Allow Full Access shell commands to reach services on this Mac.",
"webuiDefaultAccessNative": "Used by native chats without a project-specific permission."
},
"timezone": {
"select": "Select timezone",
@@ -298,8 +289,12 @@
"expanded": "Expanded",
"on": "On",
"off": "Off",
"defaultPermission": "Default Permission",
"fullAccess": "Full Access",
"configured": "Configured",
"notConfigured": "Not configured"
"notConfigured": "Not configured",
"pending": "Pending",
"restartingEngine": "Restarting"
},
"status": {
"loading": "Loading settings...",
@@ -309,14 +304,24 @@
"savedRestart": "Saved. Restart nanobot to apply.",
"restartAfterSaving": "Save changes, then restart when ready.",
"savedRestartApply": "Saved. Restart when ready.",
"imageProviderRestart": "Image provider changes saved. Restart when ready."
"imageProviderRestart": "Image provider changes saved. Restart when ready.",
"hostRestartAfterSaving": "Save changes and nanobot will restart its engine.",
"hostRestartPending": "Saved. Restarting engine when ready.",
"hostApiUnavailable": "Host actions are only available inside the native app.",
"logsOpened": "Opened logs folder.",
"logsOpenFailed": "Could not open logs folder.",
"diagnosticsExported": "Diagnostics exported to {{path}}.",
"diagnosticsExportFailed": "Could not export diagnostics."
},
"actions": {
"save": "Save",
"saving": "Saving",
"edit": "Edit",
"cancel": "Cancel",
"openDocs": "Open docs"
"open": "Open",
"export": "Export",
"opening": "Opening...",
"exporting": "Exporting..."
},
"byok": {
"description": "Bring your own provider keys. Nanobot reads these values from the current config and only configured providers can be selected in General.",
@@ -397,6 +402,19 @@
"featured": "Featured",
"loading": "Loading Apps...",
"empty": "No apps match this filter."
},
"oauth": {
"authentication": "OAuth authentication",
"signIn": "Sign in",
"signingIn": "Signing in...",
"signInAgain": "Sign in again",
"signOut": "Sign out",
"signedInAs": "Signed in as {{account}}",
"signInHelp": "Sign in from this device; no API key is stored in config.",
"signInRequired": "Sign in required",
"signInBeforeSaving": "Sign in before saving this OAuth provider as the active model provider.",
"signedIn": "Signed in",
"notSignedIn": "Not signed in"
}
},
"chat": {
@@ -404,7 +422,10 @@
"loading": "Loading…",
"noSessions": "No sessions yet.",
"showMore": "Show {{count}} more",
"collapsed": "{{count}} hidden chats",
"showLess": "Show less",
"actions": "Chat actions for {{title}}",
"newInProject": "Start a new chat in {{project}}",
"activity": {
"running": "Agent running",
"complete": "Agent finished"
@@ -415,6 +436,9 @@
"renameTitle": "Rename chat",
"renameDescription": "Choose a local sidebar name for this chat.",
"renamePlaceholder": "Chat name",
"renameProjectTitle": "Rename project",
"renameProjectDescription": "Choose a local sidebar name for this project.",
"renameProjectPlaceholder": "Project name",
"renameSave": "Save",
"archive": "Archive",
"unarchive": "Unarchive",
@@ -425,6 +449,7 @@
"groups": {
"pinned": "Pinned",
"all": "Chats",
"projects": "Projects",
"today": "Today",
"yesterday": "Yesterday",
"earlier": "Earlier",
@@ -448,7 +473,12 @@
"thread": {
"loadingConversation": "Loading conversation…",
"empty": {
"greeting": "What can I do for you?",
"greetings": {
"workOn": "What should we work on?",
"start": "Where should we start?",
"build": "What are we building today?",
"tackle": "What should we tackle together?"
},
"quickActions": {
"plan": {
"title": "Create a project plan",
@@ -632,6 +662,13 @@
"decode_failed": "Couldn't decode this image",
"too_large": "Image is too large — try a smaller one",
"io": "Couldn't read this file"
},
"workspace": {
"accessAria": "Workspace access mode",
"projectAria": "Choose project",
"projectPlaceholder": "Select project",
"default": "Default Permission",
"full": "Full Access"
}
},
"scrollToBottom": "Scroll to bottom",
@@ -690,6 +727,19 @@
"messageTooBig": {
"title": "Message too large",
"body": "The server rejected your last message because it exceeded the size limit. Remove some images or try smaller files, then send again."
},
"workspaceScopeRejected": {
"title": "Workspace was not changed",
"body": "Nanobot kept the previous workspace because the requested project or access mode was rejected by the gateway."
}
},
"workspace": {
"dialog": {
"defaultProject": "Default workspace",
"manual": "Paste path",
"manualPlaceholder": "/Users/name/project",
"usePath": "Use Path",
"absolutePathRequired": "Enter an absolute folder path on this machine."
}
}
}
+92 -42
View File
@@ -25,7 +25,9 @@
"section": "Sistema",
"restartHint": "Reinicia nanobot para aplicar los cambios de ejecución.",
"restart": "Reiniciar nanobot",
"restarting": "Reiniciando..."
"restarting": "Reiniciando...",
"restartEngine": "Reiniciar motor",
"restartingEngine": "Reiniciando motor..."
},
"restart": {
"completed": "Reinicio completado en {{seconds}} s."
@@ -40,25 +42,13 @@
},
"sidebar": {
"navigation": "Navegación de la barra lateral",
"globalActions": "Acciones globales",
"collapse": "Contraer barra lateral",
"toggleTheme": "Cambiar tema",
"home": "Inicio",
"newChat": "Nuevo chat",
"searchAria": "Buscar",
"viewOptions": "View",
"compactList": "Compact list",
"showPreviews": "Show previews",
"showTimestamps": "Show time",
"sortLabel": "Sort",
"sortUpdated": "Recently updated",
"sortCreated": "Recently created",
"sortTitle": "Title A-Z",
"searchPlaceholder": "Buscar",
"searchResults": "Resultados",
"noSearchResults": "No hay chats coincidentes.",
"recent": "Recientes",
"refreshSessions": "Actualizar sesiones",
"settings": "Configuración",
"language": {
"label": "Idioma",
@@ -80,9 +70,9 @@
"models": "Models",
"providers": "Providers",
"image": "Image",
"web": "Web",
"runtime": "Runtime",
"advanced": "Advanced",
"browser": "Web",
"runtime": "Sistema",
"advanced": "Security",
"cliApps": "Apps CLI",
"mcp": "MCP",
"apps": "Apps"
@@ -99,12 +89,13 @@
"webSearch": "Web search",
"webBehavior": "Behavior",
"identity": "Identity",
"safety": "Safety",
"webuiSafety": "Web safety",
"capabilities": "Capacidades",
"integrations": "Integrations",
"cliApps": "Apps CLI",
"mcp": "Servicios MCP",
"apps": "Apps"
"apps": "Apps",
"nativeHost": "App",
"hostSafety": "App safety"
},
"rows": {
"theme": "Tema",
@@ -137,22 +128,16 @@
"botName": "Bot name",
"botIcon": "Bot icon",
"timezone": "Timezone",
"toolHintMaxLength": "Tool hint length",
"workspacePath": "Workspace path",
"heartbeat": "Heartbeat",
"dream": "Dream",
"unifiedSession": "Unified session",
"restrictWorkspace": "Restrict to workspace",
"execTool": "Exec tool",
"execSandbox": "Exec sandbox",
"ssrfWhitelist": "SSRF whitelist",
"mcpServers": "MCP servers",
"pathAppend": "PATH append",
"configurationDocs": "Configuration docs",
"workspacePath": "Workspace predeterminado",
"localServiceAccess": "Local services",
"webuiDefaultAccess": "Default access",
"currentModel": "Modelo actual",
"brandLogos": "Logotipos de marca",
"cliAppsCatalog": "Catálogo de apps CLI",
"cliAppsFilter": "Filtro de apps CLI"
"cliAppsFilter": "Filtro de apps CLI",
"engine": "Motor",
"logs": "Registros",
"diagnostics": "Diagnóstico"
},
"help": {
"theme": "Cambia entre apariencia clara y oscura.",
@@ -175,17 +160,22 @@
"defaultAspectRatio": "Se usa cuando el prompt no elige una relación de aspecto.",
"defaultImageSize": "Sugerencia de tamaño enviada a los proveedores que la admiten.",
"maxImagesPerTurn": "Límite superior para una solicitud generate_image.",
"botName": "Shown in runtime surfaces that use the configured bot identity.",
"botIcon": "Short emoji or text shown beside the bot name.",
"timezone": "IANA timezone used by runtime context and schedules.",
"toolHintMaxLength": "Maximum characters shown in tool progress hints.",
"advancedReadOnly": "Advanced safety controls are read-only in WebUI. Edit config.json intentionally when needed.",
"botName": "Se muestra donde nanobot usa un nombre visible.",
"botIcon": "Emoji o texto corto mostrado junto al nombre del bot.",
"timezone": "Se usa para programaciones y respuestas sensibles al tiempo.",
"localServiceAccess": "Allow Full Access shell commands to reach localhost services.",
"webuiDefaultAccess": "Used by web chats without a project-specific permission.",
"securityManagedControls": "Web fetches always protect local, private, and metadata services. Core channel safety stays in config.json.",
"currentModel": "Elige el modelo que nanobot usará para las próximas respuestas.",
"selectedModelProvider": "Lo define el modelo seleccionado.",
"selectedModelValue": "Lo define el modelo seleccionado.",
"brandLogos": "Los logotipos se cargan desde los dominios de las marcas con una reserva de icono local.",
"cliAppsCatalog": "Explora CLIs de apps que nanobot puede ejecutar localmente.",
"cliAppsFilter": "Busca por app, categoría o capacidad."
"cliAppsFilter": "Busca por app, categoría o capacidad.",
"logs": "Abre la carpeta de registros del motor de escritorio.",
"diagnostics": "Exporta un pequeño informe de runtime para soporte.",
"localServiceAccessNative": "Allow Full Access shell commands to reach services on this Mac.",
"webuiDefaultAccessNative": "Used by native chats without a project-specific permission."
},
"values": {
"light": "Claro",
@@ -201,8 +191,12 @@
"expanded": "Expanded",
"on": "On",
"off": "Off",
"defaultPermission": "Default Permission",
"fullAccess": "Full Access",
"configured": "Configured",
"notConfigured": "Not configured"
"notConfigured": "Not configured",
"pending": "Pendiente",
"restartingEngine": "Reiniciando"
},
"status": {
"loading": "Cargando configuración...",
@@ -212,14 +206,24 @@
"savedRestart": "Guardado. Reinicia nanobot para aplicar.",
"restartAfterSaving": "Guarda los cambios y reinicia cuando estés listo.",
"savedRestartApply": "Guardado. Reinicia cuando estés listo.",
"imageProviderRestart": "Cambios del proveedor de imágenes guardados. Reinicia cuando estés listo."
"imageProviderRestart": "Cambios del proveedor de imágenes guardados. Reinicia cuando estés listo.",
"hostRestartAfterSaving": "Guarda los cambios y nanobot reiniciará su motor.",
"hostRestartPending": "Guardado. Reiniciando el motor cuando esté listo.",
"hostApiUnavailable": "Host actions are only available inside the native app.",
"logsOpened": "Opened logs folder.",
"logsOpenFailed": "Could not open logs folder.",
"diagnosticsExported": "Diagnostics exported to {{path}}.",
"diagnosticsExportFailed": "Could not export diagnostics."
},
"actions": {
"save": "Guardar",
"saving": "Guardando",
"edit": "Editar",
"cancel": "Cancelar",
"openDocs": "Open docs"
"open": "Abrir",
"export": "Exportar",
"opening": "Opening...",
"exporting": "Exporting..."
},
"byok": {
"description": "Usa tus propias claves de proveedor. Nanobot lee estos valores desde la configuración actual, y solo los proveedores configurados se pueden elegir en General.",
@@ -290,6 +294,7 @@
"newConfiguration": "Nueva configuración de modelo",
"newConfigurationHelp": "Guarda un proveedor y un modelo como una opción de un clic.",
"configurationName": "Nombre",
"configurationNameHelp": "Cambia el nombre de esta configuración de modelo guardada.",
"configurationNamePlaceholder": "Escritura rápida"
},
"timezone": {
@@ -397,6 +402,19 @@
"featured": "Destacadas",
"loading": "Cargando apps...",
"empty": "Ninguna app coincide con este filtro."
},
"oauth": {
"authentication": "OAuth authentication",
"signIn": "Sign in",
"signingIn": "Signing in...",
"signInAgain": "Sign in again",
"signOut": "Sign out",
"signedInAs": "Signed in as {{account}}",
"signInHelp": "Sign in from this device; no API key is stored in config.",
"signInRequired": "Sign in required",
"signInBeforeSaving": "Sign in before saving this OAuth provider as the active model provider.",
"signedIn": "Signed in",
"notSignedIn": "Not signed in"
}
},
"chat": {
@@ -404,7 +422,10 @@
"loading": "Cargando…",
"noSessions": "Todavía no hay sesiones.",
"showMore": "Mostrar {{count}} más",
"collapsed": "{{count}} chats ocultos",
"showLess": "Mostrar menos",
"actions": "Acciones del chat {{title}}",
"newInProject": "Iniciar un chat nuevo en {{project}}",
"activity": {
"running": "Agent running",
"complete": "Agent finished"
@@ -415,6 +436,9 @@
"renameTitle": "Rename chat",
"renameDescription": "Choose a local sidebar name for this chat.",
"renamePlaceholder": "Chat name",
"renameProjectTitle": "Rename project",
"renameProjectDescription": "Choose a local sidebar name for this project.",
"renameProjectPlaceholder": "Project name",
"renameSave": "Save",
"archive": "Archive",
"unarchive": "Unarchive",
@@ -425,6 +449,7 @@
"groups": {
"pinned": "Pinned",
"all": "Chats",
"projects": "Projects",
"today": "Today",
"yesterday": "Yesterday",
"earlier": "Earlier",
@@ -448,7 +473,12 @@
"thread": {
"loadingConversation": "Cargando conversación…",
"empty": {
"greeting": "¿Qué puedo hacer por ti?",
"greetings": {
"workOn": "¿En qué trabajamos juntos?",
"start": "¿Por dónde empezamos?",
"build": "¿Qué construimos hoy?",
"tackle": "¿Qué resolvemos juntos?"
},
"quickActions": {
"plan": {
"title": "Crear un plan de proyecto",
@@ -632,6 +662,13 @@
"mcpBadge": "MCP",
"cliDescription": "Usar @{{name}} como app CLI local",
"mcpDescription": "Usar @{{name}} como servidor MCP"
},
"workspace": {
"accessAria": "Workspace access mode",
"projectAria": "Elegir proyecto",
"projectPlaceholder": "Seleccionar proyecto",
"default": "Default Permission",
"full": "Full Access"
}
},
"scrollToBottom": "Desplazarse al final",
@@ -690,6 +727,19 @@
"messageTooBig": {
"title": "Mensaje demasiado grande",
"body": "El servidor rechazó tu último mensaje por superar el tamaño permitido. Quita algunas imágenes o usa archivos más pequeños y vuelve a enviarlo."
},
"workspaceScopeRejected": {
"title": "Workspace was not changed",
"body": "Nanobot kept the previous workspace because the requested project or access mode was rejected by the gateway."
}
},
"workspace": {
"dialog": {
"defaultProject": "Default workspace",
"manual": "Pegar ruta",
"manualPlaceholder": "/Users/name/project",
"usePath": "Use Path",
"absolutePathRequired": "Enter an absolute folder path on this machine."
}
}
}
+92 -42
View File
@@ -25,7 +25,9 @@
"section": "Système",
"restartHint": "Redémarrez nanobot pour appliquer les changements dexécution.",
"restart": "Redémarrer nanobot",
"restarting": "Redémarrage..."
"restarting": "Redémarrage...",
"restartEngine": "Redémarrer le moteur",
"restartingEngine": "Redémarrage du moteur..."
},
"restart": {
"completed": "Redémarrage terminé en {{seconds}} s."
@@ -40,25 +42,13 @@
},
"sidebar": {
"navigation": "Navigation de la barre latérale",
"globalActions": "Actions globales",
"collapse": "Réduire la barre latérale",
"toggleTheme": "Changer de thème",
"home": "Accueil",
"newChat": "Nouvelle discussion",
"searchAria": "Rechercher",
"viewOptions": "View",
"compactList": "Compact list",
"showPreviews": "Show previews",
"showTimestamps": "Show time",
"sortLabel": "Sort",
"sortUpdated": "Recently updated",
"sortCreated": "Recently created",
"sortTitle": "Title A-Z",
"searchPlaceholder": "Rechercher",
"searchResults": "Résultats",
"noSearchResults": "Aucun chat correspondant.",
"recent": "Récentes",
"refreshSessions": "Actualiser les sessions",
"settings": "Paramètres",
"language": {
"label": "Langue",
@@ -80,9 +70,9 @@
"models": "Models",
"providers": "Providers",
"image": "Image",
"web": "Web",
"runtime": "Runtime",
"advanced": "Advanced",
"browser": "Web",
"runtime": "Système",
"advanced": "Security",
"cliApps": "Apps CLI",
"mcp": "MCP",
"apps": "Apps"
@@ -99,12 +89,13 @@
"webSearch": "Web search",
"webBehavior": "Behavior",
"identity": "Identity",
"safety": "Safety",
"webuiSafety": "Web safety",
"capabilities": "Capacités",
"integrations": "Integrations",
"cliApps": "Apps CLI",
"mcp": "Services MCP",
"apps": "Apps"
"apps": "Apps",
"nativeHost": "App",
"hostSafety": "App safety"
},
"rows": {
"theme": "Thème",
@@ -137,22 +128,16 @@
"botName": "Bot name",
"botIcon": "Bot icon",
"timezone": "Timezone",
"toolHintMaxLength": "Tool hint length",
"workspacePath": "Workspace path",
"heartbeat": "Heartbeat",
"dream": "Dream",
"unifiedSession": "Unified session",
"restrictWorkspace": "Restrict to workspace",
"execTool": "Exec tool",
"execSandbox": "Exec sandbox",
"ssrfWhitelist": "SSRF whitelist",
"mcpServers": "MCP servers",
"pathAppend": "PATH append",
"configurationDocs": "Configuration docs",
"workspacePath": "Espace de travail par défaut",
"localServiceAccess": "Local services",
"webuiDefaultAccess": "Default access",
"currentModel": "Modèle actuel",
"brandLogos": "Logos de marque",
"cliAppsCatalog": "Catalogue d'apps CLI",
"cliAppsFilter": "Filtre des apps CLI"
"cliAppsFilter": "Filtre des apps CLI",
"engine": "Moteur",
"logs": "Journaux",
"diagnostics": "Diagnostics"
},
"help": {
"theme": "Basculer entre les apparences claire et sombre.",
@@ -175,17 +160,22 @@
"defaultAspectRatio": "Utilisé lorsque le prompt ne choisit pas de format.",
"defaultImageSize": "Indication de taille envoyée aux fournisseurs qui la prennent en charge.",
"maxImagesPerTurn": "Limite supérieure pour une requête generate_image.",
"botName": "Shown in runtime surfaces that use the configured bot identity.",
"botIcon": "Short emoji or text shown beside the bot name.",
"timezone": "IANA timezone used by runtime context and schedules.",
"toolHintMaxLength": "Maximum characters shown in tool progress hints.",
"advancedReadOnly": "Advanced safety controls are read-only in WebUI. Edit config.json intentionally when needed.",
"botName": "Affiché partout où nanobot utilise un nom visible.",
"botIcon": "Emoji ou texte court affiché avec le nom du bot.",
"timezone": "Utilisé pour les planifications et les réponses sensibles à lheure.",
"localServiceAccess": "Allow Full Access shell commands to reach localhost services.",
"webuiDefaultAccess": "Used by web chats without a project-specific permission.",
"securityManagedControls": "Web fetches always protect local, private, and metadata services. Core channel safety stays in config.json.",
"currentModel": "Choisissez le modèle que nanobot utilisera pour les prochaines réponses.",
"selectedModelProvider": "Défini par le modèle sélectionné.",
"selectedModelValue": "Défini par le modèle sélectionné.",
"brandLogos": "Les logos sont chargés depuis les domaines des marques avec une icône locale en secours.",
"cliAppsCatalog": "Parcourez les CLIs d'apps que nanobot peut exécuter localement.",
"cliAppsFilter": "Recherchez par app, catégorie ou capacité."
"cliAppsFilter": "Recherchez par app, catégorie ou capacité.",
"logs": "Ouvrir le dossier des journaux du moteur natif.",
"diagnostics": "Exporter un petit rapport runtime pour le support.",
"localServiceAccessNative": "Allow Full Access shell commands to reach services on this Mac.",
"webuiDefaultAccessNative": "Used by native chats without a project-specific permission."
},
"values": {
"light": "Clair",
@@ -201,8 +191,12 @@
"expanded": "Expanded",
"on": "On",
"off": "Off",
"defaultPermission": "Default Permission",
"fullAccess": "Full Access",
"configured": "Configured",
"notConfigured": "Not configured"
"notConfigured": "Not configured",
"pending": "En attente",
"restartingEngine": "Redémarrage"
},
"status": {
"loading": "Chargement des paramètres...",
@@ -212,14 +206,24 @@
"savedRestart": "Enregistré. Redémarrez nanobot pour appliquer.",
"restartAfterSaving": "Enregistrez les modifications, puis redémarrez lorsque vous êtes prêt.",
"savedRestartApply": "Enregistré. Redémarrez lorsque vous êtes prêt.",
"imageProviderRestart": "Modifications du fournisseur dimages enregistrées. Redémarrez lorsque vous êtes prêt."
"imageProviderRestart": "Modifications du fournisseur dimages enregistrées. Redémarrez lorsque vous êtes prêt.",
"hostRestartAfterSaving": "Enregistrez les changements et nanobot redémarrera son moteur.",
"hostRestartPending": "Enregistré. Redémarrage du moteur quand il sera prêt.",
"hostApiUnavailable": "Host actions are only available inside the native app.",
"logsOpened": "Opened logs folder.",
"logsOpenFailed": "Could not open logs folder.",
"diagnosticsExported": "Diagnostics exported to {{path}}.",
"diagnosticsExportFailed": "Could not export diagnostics."
},
"actions": {
"save": "Enregistrer",
"saving": "Enregistrement",
"edit": "Modifier",
"cancel": "Annuler",
"openDocs": "Open docs"
"open": "Ouvrir",
"export": "Exporter",
"opening": "Opening...",
"exporting": "Exporting..."
},
"byok": {
"description": "Utilisez vos propres clés de fournisseur. Nanobot lit ces valeurs depuis la configuration actuelle, et seuls les fournisseurs configurés peuvent être sélectionnés dans Général.",
@@ -290,6 +294,7 @@
"newConfiguration": "Nouvelle configuration de modèle",
"newConfigurationHelp": "Enregistrez un fournisseur et un modèle comme option en un clic.",
"configurationName": "Nom",
"configurationNameHelp": "Renommez cette configuration de modèle enregistrée.",
"configurationNamePlaceholder": "Rédaction rapide"
},
"timezone": {
@@ -397,6 +402,19 @@
"featured": "En vedette",
"loading": "Chargement des apps...",
"empty": "Aucune app ne correspond à ce filtre."
},
"oauth": {
"authentication": "OAuth authentication",
"signIn": "Sign in",
"signingIn": "Signing in...",
"signInAgain": "Sign in again",
"signOut": "Sign out",
"signedInAs": "Signed in as {{account}}",
"signInHelp": "Sign in from this device; no API key is stored in config.",
"signInRequired": "Sign in required",
"signInBeforeSaving": "Sign in before saving this OAuth provider as the active model provider.",
"signedIn": "Signed in",
"notSignedIn": "Not signed in"
}
},
"chat": {
@@ -404,7 +422,10 @@
"loading": "Chargement…",
"noSessions": "Aucune session pour le moment.",
"showMore": "Afficher {{count}} de plus",
"collapsed": "{{count}} discussions masquées",
"showLess": "Afficher moins",
"actions": "Actions de la discussion {{title}}",
"newInProject": "Démarrer une nouvelle discussion dans {{project}}",
"activity": {
"running": "Agent running",
"complete": "Agent finished"
@@ -415,6 +436,9 @@
"renameTitle": "Rename chat",
"renameDescription": "Choose a local sidebar name for this chat.",
"renamePlaceholder": "Chat name",
"renameProjectTitle": "Rename project",
"renameProjectDescription": "Choose a local sidebar name for this project.",
"renameProjectPlaceholder": "Project name",
"renameSave": "Save",
"archive": "Archive",
"unarchive": "Unarchive",
@@ -425,6 +449,7 @@
"groups": {
"pinned": "Pinned",
"all": "Chats",
"projects": "Projects",
"today": "Today",
"yesterday": "Yesterday",
"earlier": "Earlier",
@@ -448,7 +473,12 @@
"thread": {
"loadingConversation": "Chargement de la conversation…",
"empty": {
"greeting": "Que puis-je faire pour vous ?",
"greetings": {
"workOn": "Sur quoi travaillons-nous ensemble ?",
"start": "Par où commence-t-on ?",
"build": "Que construisons-nous aujourd'hui ?",
"tackle": "Que résout-on ensemble ?"
},
"quickActions": {
"plan": {
"title": "Créer un plan de projet",
@@ -632,6 +662,13 @@
"mcpBadge": "MCP",
"cliDescription": "Utiliser @{{name}} comme app CLI locale",
"mcpDescription": "Utiliser @{{name}} comme serveur MCP"
},
"workspace": {
"accessAria": "Workspace access mode",
"projectAria": "Choisir un projet",
"projectPlaceholder": "Sélectionner un projet",
"default": "Default Permission",
"full": "Full Access"
}
},
"scrollToBottom": "Faire défiler vers le bas",
@@ -690,6 +727,19 @@
"messageTooBig": {
"title": "Message trop volumineux",
"body": "Le serveur a rejeté votre dernier message car il dépasse la taille autorisée. Retirez des images ou choisissez des fichiers plus légers, puis renvoyez-le."
},
"workspaceScopeRejected": {
"title": "Workspace was not changed",
"body": "Nanobot kept the previous workspace because the requested project or access mode was rejected by the gateway."
}
},
"workspace": {
"dialog": {
"defaultProject": "Default workspace",
"manual": "Coller un chemin",
"manualPlaceholder": "/Users/name/project",
"usePath": "Use Path",
"absolutePathRequired": "Enter an absolute folder path on this machine."
}
}
}
+92 -42
View File
@@ -25,7 +25,9 @@
"section": "Sistem",
"restartHint": "Mulai ulang nanobot untuk menerapkan perubahan runtime.",
"restart": "Mulai ulang nanobot",
"restarting": "Memulai ulang..."
"restarting": "Memulai ulang...",
"restartEngine": "Mulai ulang engine",
"restartingEngine": "Memulai ulang engine..."
},
"restart": {
"completed": "Mulai ulang selesai dalam {{seconds}} dtk."
@@ -40,25 +42,13 @@
},
"sidebar": {
"navigation": "Navigasi bilah samping",
"globalActions": "Aksi global",
"collapse": "Ciutkan sidebar",
"toggleTheme": "Ganti tema",
"home": "Beranda",
"newChat": "Obrolan baru",
"searchAria": "Cari",
"viewOptions": "View",
"compactList": "Compact list",
"showPreviews": "Show previews",
"showTimestamps": "Show time",
"sortLabel": "Sort",
"sortUpdated": "Recently updated",
"sortCreated": "Recently created",
"sortTitle": "Title A-Z",
"searchPlaceholder": "Cari",
"searchResults": "Hasil",
"noSearchResults": "Tidak ada chat yang cocok.",
"recent": "Terbaru",
"refreshSessions": "Segarkan sesi",
"settings": "Pengaturan",
"language": {
"label": "Bahasa",
@@ -80,9 +70,9 @@
"models": "Models",
"providers": "Providers",
"image": "Image",
"web": "Web",
"runtime": "Runtime",
"advanced": "Advanced",
"browser": "Web",
"runtime": "Sistem",
"advanced": "Security",
"cliApps": "Aplikasi CLI",
"mcp": "MCP",
"apps": "Aplikasi"
@@ -99,12 +89,13 @@
"webSearch": "Web search",
"webBehavior": "Behavior",
"identity": "Identity",
"safety": "Safety",
"webuiSafety": "Web safety",
"capabilities": "Kapabilitas",
"integrations": "Integrations",
"cliApps": "App CLI",
"mcp": "Layanan MCP",
"apps": "Aplikasi"
"apps": "Aplikasi",
"nativeHost": "Native host",
"hostSafety": "App safety"
},
"rows": {
"theme": "Tema",
@@ -137,22 +128,16 @@
"botName": "Bot name",
"botIcon": "Bot icon",
"timezone": "Timezone",
"toolHintMaxLength": "Tool hint length",
"workspacePath": "Workspace path",
"heartbeat": "Heartbeat",
"dream": "Dream",
"unifiedSession": "Unified session",
"restrictWorkspace": "Restrict to workspace",
"execTool": "Exec tool",
"execSandbox": "Exec sandbox",
"ssrfWhitelist": "SSRF whitelist",
"mcpServers": "MCP servers",
"pathAppend": "PATH append",
"configurationDocs": "Configuration docs",
"workspacePath": "Workspace default",
"localServiceAccess": "Local services",
"webuiDefaultAccess": "Default access",
"currentModel": "Model saat ini",
"brandLogos": "Logo merek",
"cliAppsCatalog": "Katalog aplikasi CLI",
"cliAppsFilter": "Filter aplikasi CLI"
"cliAppsFilter": "Filter aplikasi CLI",
"engine": "Engine",
"logs": "Log",
"diagnostics": "Diagnostik"
},
"help": {
"theme": "Beralih antara tampilan terang dan gelap.",
@@ -175,17 +160,22 @@
"defaultAspectRatio": "Digunakan saat prompt tidak memilih rasio aspek.",
"defaultImageSize": "Petunjuk ukuran yang dikirim ke penyedia yang mendukungnya.",
"maxImagesPerTurn": "Batas atas untuk satu permintaan generate_image.",
"botName": "Shown in runtime surfaces that use the configured bot identity.",
"botIcon": "Short emoji or text shown beside the bot name.",
"timezone": "IANA timezone used by runtime context and schedules.",
"toolHintMaxLength": "Maximum characters shown in tool progress hints.",
"advancedReadOnly": "Advanced safety controls are read-only in WebUI. Edit config.json intentionally when needed.",
"botName": "Ditampilkan di tempat nanobot memakai nama tampilan.",
"botIcon": "Emoji atau teks pendek yang tampil bersama nama bot.",
"timezone": "Dipakai untuk jadwal dan balasan yang peka waktu.",
"localServiceAccess": "Allow Full Access shell commands to reach localhost services.",
"webuiDefaultAccess": "Used by web chats without a project-specific permission.",
"securityManagedControls": "Web fetches always protect local, private, and metadata services. Core channel safety stays in config.json.",
"currentModel": "Pilih model yang digunakan nanobot untuk balasan berikutnya.",
"selectedModelProvider": "Ditentukan oleh model yang dipilih.",
"selectedModelValue": "Ditentukan oleh model yang dipilih.",
"brandLogos": "Logo dimuat dari domain merek dengan ikon lokal sebagai cadangan.",
"cliAppsCatalog": "Jelajahi CLI aplikasi yang dapat dijalankan nanobot secara lokal.",
"cliAppsFilter": "Cari berdasarkan aplikasi, kategori, atau kemampuan."
"cliAppsFilter": "Cari berdasarkan aplikasi, kategori, atau kemampuan.",
"logs": "Buka folder log native engine.",
"diagnostics": "Ekspor laporan runtime kecil untuk dukungan.",
"localServiceAccessNative": "Allow Full Access shell commands to reach services on this Mac.",
"webuiDefaultAccessNative": "Used by native chats without a project-specific permission."
},
"values": {
"light": "Terang",
@@ -201,8 +191,12 @@
"expanded": "Expanded",
"on": "On",
"off": "Off",
"defaultPermission": "Default Permission",
"fullAccess": "Full Access",
"configured": "Configured",
"notConfigured": "Not configured"
"notConfigured": "Not configured",
"pending": "Tertunda",
"restartingEngine": "Memulai ulang"
},
"status": {
"loading": "Memuat pengaturan...",
@@ -212,14 +206,24 @@
"savedRestart": "Tersimpan. Mulai ulang nanobot untuk menerapkan.",
"restartAfterSaving": "Simpan perubahan, lalu restart saat siap.",
"savedRestartApply": "Tersimpan. Restart saat siap.",
"imageProviderRestart": "Perubahan penyedia gambar tersimpan. Restart saat siap."
"imageProviderRestart": "Perubahan penyedia gambar tersimpan. Restart saat siap.",
"hostRestartAfterSaving": "Simpan perubahan dan nanobot akan memulai ulang engine.",
"hostRestartPending": "Tersimpan. Engine akan dimulai ulang saat siap.",
"hostApiUnavailable": "Host actions are only available inside the native app.",
"logsOpened": "Opened logs folder.",
"logsOpenFailed": "Could not open logs folder.",
"diagnosticsExported": "Diagnostics exported to {{path}}.",
"diagnosticsExportFailed": "Could not export diagnostics."
},
"actions": {
"save": "Simpan",
"saving": "Menyimpan",
"edit": "Edit",
"cancel": "Batal",
"openDocs": "Open docs"
"open": "Buka",
"export": "Ekspor",
"opening": "Opening...",
"exporting": "Exporting..."
},
"byok": {
"description": "Gunakan kunci provider Anda sendiri. Nanobot membaca nilai ini dari config saat ini, dan hanya provider yang sudah dikonfigurasi yang bisa dipilih di Umum.",
@@ -290,6 +294,7 @@
"newConfiguration": "Konfigurasi model baru",
"newConfigurationHelp": "Simpan penyedia dan model sebagai opsi sekali klik.",
"configurationName": "Nama",
"configurationNameHelp": "Ganti nama konfigurasi model yang tersimpan ini.",
"configurationNamePlaceholder": "Penulisan cepat"
},
"timezone": {
@@ -397,6 +402,19 @@
"featured": "Unggulan",
"loading": "Memuat aplikasi...",
"empty": "Tidak ada aplikasi yang cocok dengan filter ini."
},
"oauth": {
"authentication": "OAuth authentication",
"signIn": "Sign in",
"signingIn": "Signing in...",
"signInAgain": "Sign in again",
"signOut": "Sign out",
"signedInAs": "Signed in as {{account}}",
"signInHelp": "Sign in from this device; no API key is stored in config.",
"signInRequired": "Sign in required",
"signInBeforeSaving": "Sign in before saving this OAuth provider as the active model provider.",
"signedIn": "Signed in",
"notSignedIn": "Not signed in"
}
},
"chat": {
@@ -404,7 +422,10 @@
"loading": "Memuat…",
"noSessions": "Belum ada sesi.",
"showMore": "Tampilkan {{count}} lagi",
"collapsed": "{{count}} obrolan diciutkan",
"showLess": "Tampilkan lebih sedikit",
"actions": "Aksi obrolan untuk {{title}}",
"newInProject": "Mulai obrolan baru di {{project}}",
"activity": {
"running": "Agent running",
"complete": "Agent finished"
@@ -415,6 +436,9 @@
"renameTitle": "Rename chat",
"renameDescription": "Choose a local sidebar name for this chat.",
"renamePlaceholder": "Chat name",
"renameProjectTitle": "Rename project",
"renameProjectDescription": "Choose a local sidebar name for this project.",
"renameProjectPlaceholder": "Project name",
"renameSave": "Save",
"archive": "Archive",
"unarchive": "Unarchive",
@@ -425,6 +449,7 @@
"groups": {
"pinned": "Pinned",
"all": "Chats",
"projects": "Projects",
"today": "Today",
"yesterday": "Yesterday",
"earlier": "Earlier",
@@ -448,7 +473,12 @@
"thread": {
"loadingConversation": "Memuat percakapan…",
"empty": {
"greeting": "Apa yang bisa saya bantu?",
"greetings": {
"workOn": "Apa yang kita kerjakan bersama?",
"start": "Kita mulai dari mana?",
"build": "Apa yang kita bangun hari ini?",
"tackle": "Apa yang kita selesaikan bersama?"
},
"quickActions": {
"plan": {
"title": "Buat rencana proyek",
@@ -632,6 +662,13 @@
"mcpBadge": "MCP",
"cliDescription": "Gunakan @{{name}} sebagai aplikasi CLI lokal",
"mcpDescription": "Gunakan @{{name}} sebagai server MCP"
},
"workspace": {
"accessAria": "Workspace access mode",
"projectAria": "Pilih proyek",
"projectPlaceholder": "Pilih proyek",
"default": "Default Permission",
"full": "Full Access"
}
},
"scrollToBottom": "Gulir ke bawah",
@@ -690,6 +727,19 @@
"messageTooBig": {
"title": "Pesan terlalu besar",
"body": "Server menolak pesan terakhir karena melebihi batas ukuran. Hapus beberapa gambar atau gunakan berkas yang lebih kecil, lalu coba lagi."
},
"workspaceScopeRejected": {
"title": "Workspace was not changed",
"body": "Nanobot kept the previous workspace because the requested project or access mode was rejected by the gateway."
}
},
"workspace": {
"dialog": {
"defaultProject": "Default workspace",
"manual": "Tempel path",
"manualPlaceholder": "/Users/name/project",
"usePath": "Use Path",
"absolutePathRequired": "Enter an absolute folder path on this machine."
}
}
}
+92 -42
View File
@@ -25,7 +25,9 @@
"section": "システム",
"restartHint": "実行時の変更を適用するには nanobot を再起動します。",
"restart": "nanobot を再起動",
"restarting": "再起動中..."
"restarting": "再起動中...",
"restartEngine": "エンジンを再起動",
"restartingEngine": "エンジンを再起動中..."
},
"restart": {
"completed": "{{seconds}} 秒で再起動が完了しました。"
@@ -40,25 +42,13 @@
},
"sidebar": {
"navigation": "サイドバーのナビゲーション",
"globalActions": "グローバル操作",
"collapse": "サイドバーを閉じる",
"toggleTheme": "テーマを切り替える",
"home": "ホーム",
"newChat": "新しいチャット",
"searchAria": "検索",
"viewOptions": "View",
"compactList": "Compact list",
"showPreviews": "Show previews",
"showTimestamps": "Show time",
"sortLabel": "Sort",
"sortUpdated": "Recently updated",
"sortCreated": "Recently created",
"sortTitle": "Title A-Z",
"searchPlaceholder": "検索",
"searchResults": "検索結果",
"noSearchResults": "一致するチャットはありません。",
"recent": "最近のチャット",
"refreshSessions": "セッションを更新",
"settings": "設定",
"language": {
"label": "言語",
@@ -80,9 +70,9 @@
"models": "Models",
"providers": "Providers",
"image": "Image",
"web": "Web",
"runtime": "Runtime",
"advanced": "Advanced",
"browser": "Web",
"runtime": "システム",
"advanced": "Security",
"cliApps": "CLI アプリ",
"mcp": "MCP",
"apps": "アプリ"
@@ -99,12 +89,13 @@
"webSearch": "Web search",
"webBehavior": "Behavior",
"identity": "Identity",
"safety": "Safety",
"webuiSafety": "Web safety",
"capabilities": "機能",
"integrations": "Integrations",
"cliApps": "CLI アプリ",
"mcp": "MCP サービス",
"apps": "アプリ"
"apps": "アプリ",
"nativeHost": "App",
"hostSafety": "App safety"
},
"rows": {
"theme": "テーマ",
@@ -137,22 +128,16 @@
"botName": "Bot name",
"botIcon": "Bot icon",
"timezone": "Timezone",
"toolHintMaxLength": "Tool hint length",
"workspacePath": "Workspace path",
"heartbeat": "Heartbeat",
"dream": "Dream",
"unifiedSession": "Unified session",
"restrictWorkspace": "Restrict to workspace",
"execTool": "Exec tool",
"execSandbox": "Exec sandbox",
"ssrfWhitelist": "SSRF whitelist",
"mcpServers": "MCP servers",
"pathAppend": "PATH append",
"configurationDocs": "Configuration docs",
"workspacePath": "デフォルトワークスペース",
"localServiceAccess": "Local services",
"webuiDefaultAccess": "Default access",
"currentModel": "現在のモデル",
"brandLogos": "ブランドロゴ",
"cliAppsCatalog": "CLI アプリカタログ",
"cliAppsFilter": "CLI アプリフィルター"
"cliAppsFilter": "CLI アプリフィルター",
"engine": "エンジン",
"logs": "ログ",
"diagnostics": "診断"
},
"help": {
"theme": "ライト表示とダーク表示を切り替えます。",
@@ -175,17 +160,22 @@
"defaultAspectRatio": "プロンプトでアスペクト比が指定されていない場合に使用します。",
"defaultImageSize": "対応しているプロバイダーへ送信するサイズ指定です。",
"maxImagesPerTurn": "1 回の generate_image リクエストで生成できる画像数の上限です。",
"botName": "Shown in runtime surfaces that use the configured bot identity.",
"botIcon": "Short emoji or text shown beside the bot name.",
"timezone": "IANA timezone used by runtime context and schedules.",
"toolHintMaxLength": "Maximum characters shown in tool progress hints.",
"advancedReadOnly": "Advanced safety controls are read-only in WebUI. Edit config.json intentionally when needed.",
"botName": "nanobot が表示名を使う場所に表示されます。",
"botIcon": "Bot 名の横に表示する短い emoji またはテキストです。",
"timezone": "スケジュールと時刻を考慮する返信に使用します。",
"localServiceAccess": "Allow Full Access shell commands to reach localhost services.",
"webuiDefaultAccess": "Used by web chats without a project-specific permission.",
"securityManagedControls": "Web fetches always protect local, private, and metadata services. Core channel safety stays in config.json.",
"currentModel": "今後の返信で nanobot が使用するモデルを選択します。",
"selectedModelProvider": "選択したモデルによって設定されます。",
"selectedModelValue": "選択したモデルによって設定されます。",
"brandLogos": "ロゴはブランドのドメインから読み込まれ、ローカルアイコンにフォールバックします。",
"cliAppsCatalog": "nanobot がローカルで実行できるアプリ CLI を探します。",
"cliAppsFilter": "アプリ、カテゴリ、機能で検索します。"
"cliAppsFilter": "アプリ、カテゴリ、機能で検索します。",
"logs": "Appエンジンのログフォルダを開きます。",
"diagnostics": "サポート用の小さなランタイムレポートを書き出します。",
"localServiceAccessNative": "Allow Full Access shell commands to reach services on this Mac.",
"webuiDefaultAccessNative": "Used by native chats without a project-specific permission."
},
"values": {
"light": "ライト",
@@ -201,8 +191,12 @@
"expanded": "Expanded",
"on": "On",
"off": "Off",
"defaultPermission": "Default Permission",
"fullAccess": "Full Access",
"configured": "Configured",
"notConfigured": "Not configured"
"notConfigured": "Not configured",
"pending": "保留中",
"restartingEngine": "再起動中"
},
"status": {
"loading": "設定を読み込んでいます...",
@@ -212,14 +206,24 @@
"savedRestart": "保存しました。反映するには nanobot を再起動してください。",
"restartAfterSaving": "変更を保存してから、準備ができたら再起動してください。",
"savedRestartApply": "保存しました。準備ができたら再起動してください。",
"imageProviderRestart": "画像プロバイダーの変更を保存しました。準備ができたら再起動してください。"
"imageProviderRestart": "画像プロバイダーの変更を保存しました。準備ができたら再起動してください。",
"hostRestartAfterSaving": "保存すると nanobot がエンジンを再起動します。",
"hostRestartPending": "保存しました。準備ができたらエンジンを再起動します。",
"hostApiUnavailable": "Host actions are only available inside the native app.",
"logsOpened": "Opened logs folder.",
"logsOpenFailed": "Could not open logs folder.",
"diagnosticsExported": "Diagnostics exported to {{path}}.",
"diagnosticsExportFailed": "Could not export diagnostics."
},
"actions": {
"save": "保存",
"saving": "保存中",
"edit": "編集",
"cancel": "キャンセル",
"openDocs": "Open docs"
"open": "開く",
"export": "書き出す",
"opening": "Opening...",
"exporting": "Exporting..."
},
"byok": {
"description": "自分の provider キーを使います。Nanobot は現在の config から値を読み込み、設定済みの provider だけを一般設定で選択できます。",
@@ -290,6 +294,7 @@
"newConfiguration": "新しいモデル設定",
"newConfigurationHelp": "プロバイダーとモデルをワンクリックの選択肢として保存します。",
"configurationName": "名前",
"configurationNameHelp": "保存済みのモデル設定の名前を変更します。",
"configurationNamePlaceholder": "高速ライティング"
},
"timezone": {
@@ -397,6 +402,19 @@
"featured": "おすすめ",
"loading": "アプリを読み込み中...",
"empty": "このフィルターに一致するアプリはありません。"
},
"oauth": {
"authentication": "OAuth authentication",
"signIn": "Sign in",
"signingIn": "Signing in...",
"signInAgain": "Sign in again",
"signOut": "Sign out",
"signedInAs": "Signed in as {{account}}",
"signInHelp": "Sign in from this device; no API key is stored in config.",
"signInRequired": "Sign in required",
"signInBeforeSaving": "Sign in before saving this OAuth provider as the active model provider.",
"signedIn": "Signed in",
"notSignedIn": "Not signed in"
}
},
"chat": {
@@ -404,7 +422,10 @@
"loading": "読み込み中…",
"noSessions": "まだセッションがありません。",
"showMore": "さらに {{count}} 件表示",
"collapsed": "{{count}} 件のチャットを折りたたみ中",
"showLess": "折りたたむ",
"actions": "「{{title}}」のチャット操作",
"newInProject": "「{{project}}」で新しいチャットを開始",
"activity": {
"running": "Agent running",
"complete": "Agent finished"
@@ -415,6 +436,9 @@
"renameTitle": "Rename chat",
"renameDescription": "Choose a local sidebar name for this chat.",
"renamePlaceholder": "Chat name",
"renameProjectTitle": "Rename project",
"renameProjectDescription": "Choose a local sidebar name for this project.",
"renameProjectPlaceholder": "Project name",
"renameSave": "Save",
"archive": "Archive",
"unarchive": "Unarchive",
@@ -425,6 +449,7 @@
"groups": {
"pinned": "Pinned",
"all": "Chats",
"projects": "Projects",
"today": "Today",
"yesterday": "Yesterday",
"earlier": "Earlier",
@@ -448,7 +473,12 @@
"thread": {
"loadingConversation": "会話を読み込み中…",
"empty": {
"greeting": "何をお手伝いしましょうか?",
"greetings": {
"workOn": "一緒に何に取り組みましょうか?",
"start": "今日はどこから始めましょう?",
"build": "今日は何を作りましょうか?",
"tackle": "一緒に何を解決しましょう?"
},
"quickActions": {
"plan": {
"title": "プロジェクト計画を作成",
@@ -632,6 +662,13 @@
"mcpBadge": "MCP",
"cliDescription": "@{{name}} をローカル CLI アプリとして使用",
"mcpDescription": "@{{name}} を MCP サーバーとして使用"
},
"workspace": {
"accessAria": "Workspace access mode",
"projectAria": "プロジェクトを選択",
"projectPlaceholder": "プロジェクトを選択",
"default": "Default Permission",
"full": "Full Access"
}
},
"scrollToBottom": "一番下へスクロール",
@@ -690,6 +727,19 @@
"messageTooBig": {
"title": "メッセージが大きすぎます",
"body": "サイズ上限を超えたため、直前のメッセージはサーバーに拒否されました。画像を減らすか、より小さいファイルに差し替えて再送してください。"
},
"workspaceScopeRejected": {
"title": "Workspace was not changed",
"body": "Nanobot kept the previous workspace because the requested project or access mode was rejected by the gateway."
}
},
"workspace": {
"dialog": {
"defaultProject": "Default workspace",
"manual": "パスを貼り付け",
"manualPlaceholder": "/Users/name/project",
"usePath": "Use Path",
"absolutePathRequired": "Enter an absolute folder path on this machine."
}
}
}
+92 -42
View File
@@ -25,7 +25,9 @@
"section": "시스템",
"restartHint": "런타임 변경 사항을 적용하려면 nanobot을 다시 시작하세요.",
"restart": "nanobot 다시 시작",
"restarting": "다시 시작 중..."
"restarting": "다시 시작 중...",
"restartEngine": "엔진 다시 시작",
"restartingEngine": "엔진 다시 시작 중..."
},
"restart": {
"completed": "{{seconds}}초 만에 다시 시작되었습니다."
@@ -40,25 +42,13 @@
},
"sidebar": {
"navigation": "사이드바 탐색",
"globalActions": "전역 작업",
"collapse": "사이드바 접기",
"toggleTheme": "테마 전환",
"home": "홈",
"newChat": "새 채팅",
"searchAria": "검색",
"viewOptions": "View",
"compactList": "Compact list",
"showPreviews": "Show previews",
"showTimestamps": "Show time",
"sortLabel": "Sort",
"sortUpdated": "Recently updated",
"sortCreated": "Recently created",
"sortTitle": "Title A-Z",
"searchPlaceholder": "검색",
"searchResults": "결과",
"noSearchResults": "일치하는 채팅이 없습니다.",
"recent": "최근 대화",
"refreshSessions": "세션 새로고침",
"settings": "설정",
"language": {
"label": "언어",
@@ -80,9 +70,9 @@
"models": "Models",
"providers": "Providers",
"image": "Image",
"web": "Web",
"runtime": "Runtime",
"advanced": "Advanced",
"browser": "Web",
"runtime": "시스템",
"advanced": "Security",
"cliApps": "CLI 앱",
"mcp": "MCP",
"apps": "앱"
@@ -99,12 +89,13 @@
"webSearch": "Web search",
"webBehavior": "Behavior",
"identity": "Identity",
"safety": "Safety",
"webuiSafety": "Web safety",
"capabilities": "기능",
"integrations": "Integrations",
"cliApps": "CLI 앱",
"mcp": "MCP 서비스",
"apps": "앱"
"apps": "앱",
"nativeHost": "App",
"hostSafety": "App safety"
},
"rows": {
"theme": "테마",
@@ -137,22 +128,16 @@
"botName": "Bot name",
"botIcon": "Bot icon",
"timezone": "Timezone",
"toolHintMaxLength": "Tool hint length",
"workspacePath": "Workspace path",
"heartbeat": "Heartbeat",
"dream": "Dream",
"unifiedSession": "Unified session",
"restrictWorkspace": "Restrict to workspace",
"execTool": "Exec tool",
"execSandbox": "Exec sandbox",
"ssrfWhitelist": "SSRF whitelist",
"mcpServers": "MCP servers",
"pathAppend": "PATH append",
"configurationDocs": "Configuration docs",
"workspacePath": "기본 작업공간",
"localServiceAccess": "Local services",
"webuiDefaultAccess": "Default access",
"currentModel": "현재 모델",
"brandLogos": "브랜드 로고",
"cliAppsCatalog": "CLI 앱 카탈로그",
"cliAppsFilter": "CLI 앱 필터"
"cliAppsFilter": "CLI 앱 필터",
"engine": "엔진",
"logs": "로그",
"diagnostics": "진단"
},
"help": {
"theme": "밝은 모드와 어두운 모드를 전환합니다.",
@@ -175,17 +160,22 @@
"defaultAspectRatio": "프롬프트에서 가로세로 비율을 선택하지 않았을 때 사용됩니다.",
"defaultImageSize": "지원하는 제공자에게 보내는 크기 힌트입니다.",
"maxImagesPerTurn": "한 번의 generate_image 요청에 대한 상한입니다.",
"botName": "Shown in runtime surfaces that use the configured bot identity.",
"botIcon": "Short emoji or text shown beside the bot name.",
"timezone": "IANA timezone used by runtime context and schedules.",
"toolHintMaxLength": "Maximum characters shown in tool progress hints.",
"advancedReadOnly": "Advanced safety controls are read-only in WebUI. Edit config.json intentionally when needed.",
"botName": "nanobot이 표시 이름을 쓰는 곳에 표시됩니다.",
"botIcon": "Bot 이름 옆에 표시할 짧은 emoji 또는 텍스트입니다.",
"timezone": "예약과 시간 인식 답변에 사용됩니다.",
"localServiceAccess": "Allow Full Access shell commands to reach localhost services.",
"webuiDefaultAccess": "Used by web chats without a project-specific permission.",
"securityManagedControls": "Web fetches always protect local, private, and metadata services. Core channel safety stays in config.json.",
"currentModel": "nanobot이 새 답변에 사용할 모델을 선택합니다.",
"selectedModelProvider": "선택한 모델에서 설정됩니다.",
"selectedModelValue": "선택한 모델에서 설정됩니다.",
"brandLogos": "로고는 브랜드 도메인에서 불러오며, 실패하면 로컬 아이콘을 사용합니다.",
"cliAppsCatalog": "nanobot이 로컬에서 실행할 수 있는 앱 CLI를 살펴봅니다.",
"cliAppsFilter": "앱, 카테고리 또는 기능으로 검색합니다."
"cliAppsFilter": "앱, 카테고리 또는 기능으로 검색합니다.",
"logs": "App 엔진 로그 폴더를 엽니다.",
"diagnostics": "지원용 작은 런타임 보고서를 내보냅니다.",
"localServiceAccessNative": "Allow Full Access shell commands to reach services on this Mac.",
"webuiDefaultAccessNative": "Used by native chats without a project-specific permission."
},
"values": {
"light": "라이트",
@@ -201,8 +191,12 @@
"expanded": "Expanded",
"on": "On",
"off": "Off",
"defaultPermission": "Default Permission",
"fullAccess": "Full Access",
"configured": "Configured",
"notConfigured": "Not configured"
"notConfigured": "Not configured",
"pending": "대기 중",
"restartingEngine": "다시 시작 중"
},
"status": {
"loading": "설정을 불러오는 중...",
@@ -212,14 +206,24 @@
"savedRestart": "저장되었습니다. 적용하려면 nanobot을 재시작하세요.",
"restartAfterSaving": "변경 사항을 저장한 뒤 준비되면 재시작하세요.",
"savedRestartApply": "저장되었습니다. 준비되면 재시작하세요.",
"imageProviderRestart": "이미지 제공자 변경 사항이 저장되었습니다. 준비되면 재시작하세요."
"imageProviderRestart": "이미지 제공자 변경 사항이 저장되었습니다. 준비되면 재시작하세요.",
"hostRestartAfterSaving": "저장하면 nanobot이 엔진을 다시 시작합니다.",
"hostRestartPending": "저장되었습니다. 준비되면 엔진을 다시 시작합니다.",
"hostApiUnavailable": "Host actions are only available inside the native app.",
"logsOpened": "Opened logs folder.",
"logsOpenFailed": "Could not open logs folder.",
"diagnosticsExported": "Diagnostics exported to {{path}}.",
"diagnosticsExportFailed": "Could not export diagnostics."
},
"actions": {
"save": "저장",
"saving": "저장 중",
"edit": "편집",
"cancel": "취소",
"openDocs": "Open docs"
"open": "열기",
"export": "내보내기",
"opening": "Opening...",
"exporting": "Exporting..."
},
"byok": {
"description": "직접 provider 키를 가져옵니다. Nanobot은 현재 config에서 값을 읽고, 설정된 provider만 일반 설정에서 선택할 수 있습니다.",
@@ -290,6 +294,7 @@
"newConfiguration": "새 모델 구성",
"newConfigurationHelp": "제공자와 모델을 한 번에 선택할 수 있는 옵션으로 저장합니다.",
"configurationName": "이름",
"configurationNameHelp": "저장된 모델 구성의 이름을 변경합니다.",
"configurationNamePlaceholder": "빠른 글쓰기"
},
"timezone": {
@@ -397,6 +402,19 @@
"featured": "추천",
"loading": "앱 불러오는 중...",
"empty": "이 필터와 일치하는 앱이 없습니다."
},
"oauth": {
"authentication": "OAuth authentication",
"signIn": "Sign in",
"signingIn": "Signing in...",
"signInAgain": "Sign in again",
"signOut": "Sign out",
"signedInAs": "Signed in as {{account}}",
"signInHelp": "Sign in from this device; no API key is stored in config.",
"signInRequired": "Sign in required",
"signInBeforeSaving": "Sign in before saving this OAuth provider as the active model provider.",
"signedIn": "Signed in",
"notSignedIn": "Not signed in"
}
},
"chat": {
@@ -404,7 +422,10 @@
"loading": "불러오는 중…",
"noSessions": "아직 세션이 없습니다.",
"showMore": "{{count}}개 더 보기",
"collapsed": "{{count}}개 채팅 접힘",
"showLess": "접기",
"actions": "{{title}} 채팅 작업",
"newInProject": "{{project}}에서 새 채팅 시작",
"activity": {
"running": "Agent running",
"complete": "Agent finished"
@@ -415,6 +436,9 @@
"renameTitle": "Rename chat",
"renameDescription": "Choose a local sidebar name for this chat.",
"renamePlaceholder": "Chat name",
"renameProjectTitle": "Rename project",
"renameProjectDescription": "Choose a local sidebar name for this project.",
"renameProjectPlaceholder": "Project name",
"renameSave": "Save",
"archive": "Archive",
"unarchive": "Unarchive",
@@ -425,6 +449,7 @@
"groups": {
"pinned": "Pinned",
"all": "Chats",
"projects": "Projects",
"today": "Today",
"yesterday": "Yesterday",
"earlier": "Earlier",
@@ -448,7 +473,12 @@
"thread": {
"loadingConversation": "대화 불러오는 중…",
"empty": {
"greeting": "무엇을 도와드릴까요?",
"greetings": {
"workOn": "우리 무엇을 함께 해볼까요?",
"start": "오늘은 어디서 시작할까요?",
"build": "오늘은 무엇을 만들어볼까요?",
"tackle": "함께 무엇을 해결할까요?"
},
"quickActions": {
"plan": {
"title": "프로젝트 계획 만들기",
@@ -632,6 +662,13 @@
"mcpBadge": "MCP",
"cliDescription": "@{{name}}을 로컬 CLI 앱으로 사용",
"mcpDescription": "@{{name}}을 MCP 서버로 사용"
},
"workspace": {
"accessAria": "Workspace access mode",
"projectAria": "프로젝트 선택",
"projectPlaceholder": "프로젝트 선택",
"default": "Default Permission",
"full": "Full Access"
}
},
"scrollToBottom": "맨 아래로 스크롤",
@@ -690,6 +727,19 @@
"messageTooBig": {
"title": "메시지가 너무 큽니다",
"body": "마지막 메시지가 서버의 크기 제한을 초과하여 거부되었습니다. 이미지를 줄이거나 더 작은 파일로 바꿔서 다시 보내 주세요."
},
"workspaceScopeRejected": {
"title": "Workspace was not changed",
"body": "Nanobot kept the previous workspace because the requested project or access mode was rejected by the gateway."
}
},
"workspace": {
"dialog": {
"defaultProject": "Default workspace",
"manual": "경로 붙여넣기",
"manualPlaceholder": "/Users/name/project",
"usePath": "Use Path",
"absolutePathRequired": "Enter an absolute folder path on this machine."
}
}
}
+92 -42
View File
@@ -25,7 +25,9 @@
"section": "Hệ thống",
"restartHint": "Khởi động lại nanobot để áp dụng thay đổi runtime.",
"restart": "Khởi động lại nanobot",
"restarting": "Đang khởi động lại..."
"restarting": "Đang khởi động lại...",
"restartEngine": "Khởi động lại engine",
"restartingEngine": "Đang khởi động lại engine..."
},
"restart": {
"completed": "Khởi động lại hoàn tất sau {{seconds}} giây."
@@ -40,25 +42,13 @@
},
"sidebar": {
"navigation": "Điều hướng thanh bên",
"globalActions": "Hành động toàn cục",
"collapse": "Thu gọn thanh bên",
"toggleTheme": "Chuyển giao diện",
"home": "Trang chủ",
"newChat": "Cuộc trò chuyện mới",
"searchAria": "Tìm kiếm",
"viewOptions": "View",
"compactList": "Compact list",
"showPreviews": "Show previews",
"showTimestamps": "Show time",
"sortLabel": "Sort",
"sortUpdated": "Recently updated",
"sortCreated": "Recently created",
"sortTitle": "Title A-Z",
"searchPlaceholder": "Tìm kiếm",
"searchResults": "Kết quả",
"noSearchResults": "Không có cuộc trò chuyện phù hợp.",
"recent": "Gần đây",
"refreshSessions": "Làm mới phiên",
"settings": "Cài đặt",
"language": {
"label": "Ngôn ngữ",
@@ -80,9 +70,9 @@
"models": "Models",
"providers": "Providers",
"image": "Image",
"web": "Web",
"runtime": "Runtime",
"advanced": "Advanced",
"browser": "Web",
"runtime": "Hệ thống",
"advanced": "Security",
"cliApps": "Ứng dụng CLI",
"mcp": "MCP",
"apps": "Ứng dụng"
@@ -99,12 +89,13 @@
"webSearch": "Web search",
"webBehavior": "Behavior",
"identity": "Identity",
"safety": "Safety",
"webuiSafety": "Web safety",
"capabilities": "Khả năng",
"integrations": "Integrations",
"cliApps": "Ứng dụng CLI",
"mcp": "Dịch vụ MCP",
"apps": "Ứng dụng"
"apps": "Ứng dụng",
"nativeHost": "Native host",
"hostSafety": "App safety"
},
"rows": {
"theme": "Giao diện",
@@ -137,22 +128,16 @@
"botName": "Bot name",
"botIcon": "Bot icon",
"timezone": "Timezone",
"toolHintMaxLength": "Tool hint length",
"workspacePath": "Workspace path",
"heartbeat": "Heartbeat",
"dream": "Dream",
"unifiedSession": "Unified session",
"restrictWorkspace": "Restrict to workspace",
"execTool": "Exec tool",
"execSandbox": "Exec sandbox",
"ssrfWhitelist": "SSRF whitelist",
"mcpServers": "MCP servers",
"pathAppend": "PATH append",
"configurationDocs": "Configuration docs",
"workspacePath": "Workspace mặc định",
"localServiceAccess": "Local services",
"webuiDefaultAccess": "Default access",
"currentModel": "Mô hình hiện tại",
"brandLogos": "Logo thương hiệu",
"cliAppsCatalog": "Danh mục ứng dụng CLI",
"cliAppsFilter": "Bộ lọc ứng dụng CLI"
"cliAppsFilter": "Bộ lọc ứng dụng CLI",
"engine": "Engine",
"logs": "Nhật ký",
"diagnostics": "Chẩn đoán"
},
"help": {
"theme": "Chuyển giữa giao diện sáng và tối.",
@@ -175,17 +160,22 @@
"defaultAspectRatio": "Được dùng khi prompt không chọn tỷ lệ khung hình.",
"defaultImageSize": "Gợi ý kích thước gửi tới các nhà cung cấp hỗ trợ.",
"maxImagesPerTurn": "Giới hạn trên cho một yêu cầu generate_image.",
"botName": "Shown in runtime surfaces that use the configured bot identity.",
"botIcon": "Short emoji or text shown beside the bot name.",
"timezone": "IANA timezone used by runtime context and schedules.",
"toolHintMaxLength": "Maximum characters shown in tool progress hints.",
"advancedReadOnly": "Advanced safety controls are read-only in WebUI. Edit config.json intentionally when needed.",
"botName": "Hiển thị ở nơi nanobot dùng tên hiển thị.",
"botIcon": "Emoji hoặc văn bản ngắn hiển thị cùng tên bot.",
"timezone": "Dùng cho lịch hẹn và câu trả lời có yếu tố thời gian.",
"localServiceAccess": "Allow Full Access shell commands to reach localhost services.",
"webuiDefaultAccess": "Used by web chats without a project-specific permission.",
"securityManagedControls": "Web fetches always protect local, private, and metadata services. Core channel safety stays in config.json.",
"currentModel": "Chọn mô hình nanobot dùng cho các câu trả lời mới.",
"selectedModelProvider": "Được đặt bởi mô hình đã chọn.",
"selectedModelValue": "Được đặt bởi mô hình đã chọn.",
"brandLogos": "Logo được tải từ tên miền thương hiệu, có biểu tượng cục bộ làm dự phòng.",
"cliAppsCatalog": "Duyệt các CLI ứng dụng mà nanobot có thể chạy cục bộ.",
"cliAppsFilter": "Tìm theo ứng dụng, danh mục hoặc khả năng."
"cliAppsFilter": "Tìm theo ứng dụng, danh mục hoặc khả năng.",
"logs": "Mở thư mục nhật ký native engine.",
"diagnostics": "Xuất báo cáo runtime nhỏ để hỗ trợ.",
"localServiceAccessNative": "Allow Full Access shell commands to reach services on this Mac.",
"webuiDefaultAccessNative": "Used by native chats without a project-specific permission."
},
"values": {
"light": "Sáng",
@@ -201,8 +191,12 @@
"expanded": "Expanded",
"on": "On",
"off": "Off",
"defaultPermission": "Default Permission",
"fullAccess": "Full Access",
"configured": "Configured",
"notConfigured": "Not configured"
"notConfigured": "Not configured",
"pending": "Đang chờ",
"restartingEngine": "Đang khởi động lại"
},
"status": {
"loading": "Đang tải cài đặt...",
@@ -212,14 +206,24 @@
"savedRestart": "Đã lưu. Khởi động lại nanobot để áp dụng.",
"restartAfterSaving": "Lưu thay đổi, rồi khởi động lại khi sẵn sàng.",
"savedRestartApply": "Đã lưu. Khởi động lại khi sẵn sàng.",
"imageProviderRestart": "Đã lưu thay đổi nhà cung cấp ảnh. Khởi động lại khi sẵn sàng."
"imageProviderRestart": "Đã lưu thay đổi nhà cung cấp ảnh. Khởi động lại khi sẵn sàng.",
"hostRestartAfterSaving": "Lưu thay đổi và nanobot sẽ khởi động lại engine.",
"hostRestartPending": "Đã lưu. Sẽ khởi động lại engine khi sẵn sàng.",
"hostApiUnavailable": "Host actions are only available inside the native app.",
"logsOpened": "Opened logs folder.",
"logsOpenFailed": "Could not open logs folder.",
"diagnosticsExported": "Diagnostics exported to {{path}}.",
"diagnosticsExportFailed": "Could not export diagnostics."
},
"actions": {
"save": "Lưu",
"saving": "Đang lưu",
"edit": "Sửa",
"cancel": "Hủy",
"openDocs": "Open docs"
"open": "Mở",
"export": "Xuất",
"opening": "Opening...",
"exporting": "Exporting..."
},
"byok": {
"description": "Dùng key provider của riêng bạn. Nanobot đọc các giá trị này từ config hiện tại, và chỉ provider đã cấu hình mới có thể chọn trong Chung.",
@@ -290,6 +294,7 @@
"newConfiguration": "Cấu hình mô hình mới",
"newConfigurationHelp": "Lưu nhà cung cấp và mô hình thành một lựa chọn một lần nhấp.",
"configurationName": "Tên",
"configurationNameHelp": "Đổi tên cấu hình mô hình đã lưu này.",
"configurationNamePlaceholder": "Viết nhanh"
},
"timezone": {
@@ -397,6 +402,19 @@
"featured": "Nổi bật",
"loading": "Đang tải ứng dụng...",
"empty": "Không có ứng dụng nào khớp với bộ lọc này."
},
"oauth": {
"authentication": "OAuth authentication",
"signIn": "Sign in",
"signingIn": "Signing in...",
"signInAgain": "Sign in again",
"signOut": "Sign out",
"signedInAs": "Signed in as {{account}}",
"signInHelp": "Sign in from this device; no API key is stored in config.",
"signInRequired": "Sign in required",
"signInBeforeSaving": "Sign in before saving this OAuth provider as the active model provider.",
"signedIn": "Signed in",
"notSignedIn": "Not signed in"
}
},
"chat": {
@@ -404,7 +422,10 @@
"loading": "Đang tải…",
"noSessions": "Chưa có phiên nào.",
"showMore": "Hiển thị thêm {{count}}",
"collapsed": "Đã thu gọn {{count}} cuộc trò chuyện",
"showLess": "Thu gọn",
"actions": "Tác vụ cho cuộc trò chuyện {{title}}",
"newInProject": "Bắt đầu cuộc trò chuyện mới trong {{project}}",
"activity": {
"running": "Agent running",
"complete": "Agent finished"
@@ -415,6 +436,9 @@
"renameTitle": "Rename chat",
"renameDescription": "Choose a local sidebar name for this chat.",
"renamePlaceholder": "Chat name",
"renameProjectTitle": "Rename project",
"renameProjectDescription": "Choose a local sidebar name for this project.",
"renameProjectPlaceholder": "Project name",
"renameSave": "Save",
"archive": "Archive",
"unarchive": "Unarchive",
@@ -425,6 +449,7 @@
"groups": {
"pinned": "Pinned",
"all": "Chats",
"projects": "Projects",
"today": "Today",
"yesterday": "Yesterday",
"earlier": "Earlier",
@@ -448,7 +473,12 @@
"thread": {
"loadingConversation": "Đang tải cuộc trò chuyện…",
"empty": {
"greeting": "Tôi có thể giúp gì cho bạn?",
"greetings": {
"workOn": "Mình cùng làm gì tiếp?",
"start": "Hôm nay bắt đầu từ đâu?",
"build": "Hôm nay mình xây dựng gì?",
"tackle": "Mình cùng xử lý việc gì?"
},
"quickActions": {
"plan": {
"title": "Tạo kế hoạch dự án",
@@ -632,6 +662,13 @@
"mcpBadge": "MCP",
"cliDescription": "Dùng @{{name}} như ứng dụng CLI cục bộ",
"mcpDescription": "Dùng @{{name}} như máy chủ MCP"
},
"workspace": {
"accessAria": "Workspace access mode",
"projectAria": "Chọn dự án",
"projectPlaceholder": "Chọn dự án",
"default": "Default Permission",
"full": "Full Access"
}
},
"scrollToBottom": "Cuộn xuống cuối",
@@ -690,6 +727,19 @@
"messageTooBig": {
"title": "Tin nhắn quá lớn",
"body": "Máy chủ đã từ chối tin nhắn trước vì vượt quá giới hạn kích thước. Hãy bớt ảnh hoặc chọn tệp nhỏ hơn rồi thử lại."
},
"workspaceScopeRejected": {
"title": "Workspace was not changed",
"body": "Nanobot kept the previous workspace because the requested project or access mode was rejected by the gateway."
}
},
"workspace": {
"dialog": {
"defaultProject": "Default workspace",
"manual": "Dán đường dẫn",
"manualPlaceholder": "/Users/name/project",
"usePath": "Use Path",
"absolutePathRequired": "Enter an absolute folder path on this machine."
}
}
}
+90 -40
View File
@@ -25,7 +25,9 @@
"section": "系统",
"restartHint": "重启 nanobot 以应用运行时更改。",
"restart": "重启 nanobot",
"restarting": "正在重启..."
"restarting": "正在重启...",
"restartEngine": "重启引擎",
"restartingEngine": "正在重启引擎..."
},
"restart": {
"completed": "重启已完成,用时 {{seconds}} 秒。"
@@ -40,25 +42,13 @@
},
"sidebar": {
"navigation": "侧边栏导航",
"globalActions": "全局操作",
"collapse": "收起侧边栏",
"toggleTheme": "切换主题",
"home": "首页",
"newChat": "新建对话",
"searchAria": "搜索",
"viewOptions": "视图",
"compactList": "紧凑列表",
"showPreviews": "显示预览",
"showTimestamps": "显示时间",
"sortLabel": "排序",
"sortUpdated": "最近更新",
"sortCreated": "最近创建",
"sortTitle": "标题 A-Z",
"searchPlaceholder": "搜索",
"searchResults": "搜索结果",
"noSearchResults": "没有匹配的会话。",
"recent": "最近对话",
"refreshSessions": "刷新会话",
"settings": "设置",
"language": {
"label": "语言",
@@ -80,11 +70,11 @@
"models": "模型",
"providers": "提供商",
"image": "图片",
"web": "网页",
"browser": "网页",
"cliApps": "CLI 应用",
"mcp": "MCP",
"runtime": "运行时",
"advanced": "高级",
"runtime": "系统",
"advanced": "安全",
"apps": "应用"
},
"sections": {
@@ -101,10 +91,11 @@
"cliApps": "CLI 应用",
"mcp": "MCP 服务",
"identity": "身份",
"safety": "安全",
"webuiSafety": "网页端安全",
"capabilities": "能力",
"integrations": "集成",
"apps": "应用"
"apps": "应用",
"nativeHost": "App",
"hostSafety": "App 安全"
},
"models": {
"selectModel": "选择模型",
@@ -112,6 +103,7 @@
"newConfiguration": "新建模型配置",
"newConfigurationHelp": "把服务商和模型保存为一个可直接切换的选项。",
"configurationName": "名称",
"configurationNameHelp": "重命名这个已保存的模型配置。",
"configurationNamePlaceholder": "快速写作"
},
"rows": {
@@ -147,20 +139,14 @@
"botName": "Bot 名称",
"botIcon": "Bot 图标",
"timezone": "时区",
"toolHintMaxLength": "工具提示长度",
"workspacePath": "工作区路径",
"heartbeat": "Heartbeat",
"dream": "Dream",
"unifiedSession": "统一会话",
"restrictWorkspace": "限制在工作区内",
"execTool": "Exec 工具",
"execSandbox": "Exec 沙箱",
"ssrfWhitelist": "SSRF 白名单",
"mcpServers": "MCP 服务器",
"pathAppend": "PATH 追加",
"workspacePath": "默认工作区",
"localServiceAccess": "本机服务",
"webuiDefaultAccess": "默认权限",
"cliAppsCatalog": "目录",
"cliAppsFilter": "筛选",
"configurationDocs": "配置文档"
"engine": "引擎",
"logs": "日志",
"diagnostics": "诊断"
},
"help": {
"theme": "在浅色和深色外观之间切换。",
@@ -187,13 +173,18 @@
"defaultAspectRatio": "当提示词没有选择比例时使用。",
"defaultImageSize": "发送给支持该能力的服务商的尺寸提示。",
"maxImagesPerTurn": "单次 generate_image 请求允许的图片上限。",
"botName": "显示在使用 bot 身份的运行时界面里。",
"botName": "显示在 nanobot 使用名称的地方。",
"botIcon": "显示在 bot 名称旁的短 emoji 或文本。",
"timezone": "运行时上下文和计划任务使用的 IANA 时区。",
"toolHintMaxLength": "工具进度提示显示的最大字符数。",
"timezone": "用于计划任务和需要时间感知的回复。",
"cliAppsCatalog": "只安装 nanobot 在本机调用应用时需要的 CLI 适配层,不触碰应用本体。",
"cliAppsFilter": "按应用、分类或能力搜索。",
"advancedReadOnly": "高级安全控制在 WebUI 中只读;需要时请谨慎编辑 config.json。"
"localServiceAccess": "允许完全访问模式下的 shell 命令访问 localhost 服务。",
"webuiDefaultAccess": "用于没有单独选择权限的网页端对话。",
"securityManagedControls": "网页抓取始终保护本机、内网和元数据服务。核心渠道安全仍由 config.json 管理。",
"logs": "打开App引擎日志文件夹。",
"diagnostics": "导出一份用于支持排查的运行时报告。",
"localServiceAccessNative": "允许完全访问模式下的 shell 命令访问这台 Mac 上的服务。",
"webuiDefaultAccessNative": "用于没有单独选择权限的原生 App 对话。"
},
"timezone": {
"select": "选择时区",
@@ -298,8 +289,12 @@
"expanded": "展开",
"on": "开",
"off": "关",
"defaultPermission": "默认权限",
"fullAccess": "完全访问",
"configured": "已配置",
"notConfigured": "未配置"
"notConfigured": "未配置",
"pending": "待应用",
"restartingEngine": "正在重启"
},
"status": {
"loading": "正在加载设置...",
@@ -309,14 +304,24 @@
"savedRestart": "已保存。重启 nanobot 后生效。",
"restartAfterSaving": "保存后,可在合适时重启。",
"savedRestartApply": "已保存,可稍后重启。",
"imageProviderRestart": "图片服务商改动已保存,可稍后重启。"
"imageProviderRestart": "图片服务商改动已保存,可稍后重启。",
"hostRestartAfterSaving": "保存后,nanobot 会自动重启引擎。",
"hostRestartPending": "已保存,将在合适时重启引擎。",
"hostApiUnavailable": "宿主操作只能在原生 App 内使用。",
"logsOpened": "已打开日志文件夹。",
"logsOpenFailed": "无法打开日志文件夹。",
"diagnosticsExported": "诊断已导出到 {{path}}。",
"diagnosticsExportFailed": "无法导出诊断。"
},
"actions": {
"save": "保存",
"saving": "保存中",
"edit": "编辑",
"cancel": "取消",
"openDocs": "打开文档"
"open": "打开",
"export": "导出",
"opening": "打开中...",
"exporting": "导出中..."
},
"byok": {
"description": "自带服务商密钥。Nanobot 会从当前 config 读取这些值,只有已配置的服务商才能在通用设置里选择。",
@@ -397,6 +402,19 @@
"featured": "精选",
"loading": "正在加载应用...",
"empty": "没有符合筛选条件的应用。"
},
"oauth": {
"authentication": "OAuth 认证",
"signIn": "登录",
"signingIn": "正在登录…",
"signInAgain": "重新登录",
"signOut": "退出登录",
"signedInAs": "已登录为 {{account}}",
"signInHelp": "在这台设备上登录;不会把 API key 写入配置。",
"signInRequired": "需要登录",
"signInBeforeSaving": "先登录这个 OAuth 提供商,然后再保存为当前模型提供商。",
"signedIn": "已登录",
"notSignedIn": "未登录"
}
},
"chat": {
@@ -404,7 +422,10 @@
"loading": "加载中…",
"noSessions": "还没有会话。",
"showMore": "再显示 {{count}} 个",
"collapsed": "已折叠 {{count}} 个对话",
"showLess": "收起",
"actions": "“{{title}}” 的会话操作",
"newInProject": "在 {{project}} 中开始新对话",
"activity": {
"running": "Agent 正在运行",
"complete": "Agent 已完成"
@@ -415,6 +436,9 @@
"renameTitle": "重命名对话",
"renameDescription": "为这个对话设置一个仅用于 WebUI 侧边栏的名称。",
"renamePlaceholder": "对话名称",
"renameProjectTitle": "重命名项目",
"renameProjectDescription": "为这个项目设置一个仅用于 WebUI 侧边栏的名称。",
"renameProjectPlaceholder": "项目名称",
"renameSave": "保存",
"archive": "归档",
"unarchive": "取消归档",
@@ -425,6 +449,7 @@
"groups": {
"pinned": "置顶",
"all": "对话",
"projects": "项目",
"today": "今天",
"yesterday": "昨天",
"earlier": "更早",
@@ -448,7 +473,12 @@
"thread": {
"loadingConversation": "正在加载对话…",
"empty": {
"greeting": "我可以帮你做什么?",
"greetings": {
"workOn": "我们要一起做点什么?",
"start": "今天从哪里开始?",
"build": "今天一起构建什么?",
"tackle": "我们要一起解决什么?"
},
"quickActions": {
"plan": {
"title": "创建项目计划",
@@ -632,7 +662,14 @@
"too_large": "图片太大,请换一张小一点的",
"io": "无法读取该文件"
},
"goalStateCloseAria": "关闭目标"
"goalStateCloseAria": "关闭目标",
"workspace": {
"accessAria": "工作区访问权限",
"projectAria": "选择项目",
"projectPlaceholder": "选择项目",
"default": "默认权限",
"full": "完全访问权限"
}
},
"scrollToBottom": "滚动到底部",
"loadEarlier": "加载更早消息"
@@ -690,6 +727,19 @@
"messageTooBig": {
"title": "消息过大",
"body": "服务端因超过大小限制拒收了上一条消息。可移除部分图片或使用更小的图片后重试。"
},
"workspaceScopeRejected": {
"title": "工作区未更改",
"body": "网关拒绝了请求的项目或访问权限,Nanobot 已继续使用之前的工作区。"
}
},
"workspace": {
"dialog": {
"defaultProject": "默认工作区",
"manual": "粘贴路径",
"manualPlaceholder": "/Users/name/project",
"usePath": "使用路径",
"absolutePathRequired": "请输入这台机器上的绝对文件夹路径。"
}
}
}
+92 -42
View File
@@ -25,7 +25,9 @@
"section": "系統",
"restartHint": "重新啟動 nanobot 以套用執行階段變更。",
"restart": "重新啟動 nanobot",
"restarting": "正在重新啟動..."
"restarting": "正在重新啟動...",
"restartEngine": "重新啟動引擎",
"restartingEngine": "正在重新啟動引擎..."
},
"restart": {
"completed": "重新啟動已完成,耗時 {{seconds}} 秒。"
@@ -40,25 +42,13 @@
},
"sidebar": {
"navigation": "側邊欄導覽",
"globalActions": "全域操作",
"collapse": "收合側邊欄",
"toggleTheme": "切換主題",
"home": "首頁",
"newChat": "新增對話",
"searchAria": "搜尋",
"viewOptions": "檢視",
"compactList": "緊湊列表",
"showPreviews": "顯示預覽",
"showTimestamps": "顯示時間",
"sortLabel": "排序",
"sortUpdated": "最近更新",
"sortCreated": "最近建立",
"sortTitle": "標題 A-Z",
"searchPlaceholder": "搜尋",
"searchResults": "搜尋結果",
"noSearchResults": "沒有符合的對話。",
"recent": "最近對話",
"refreshSessions": "重新整理會話",
"settings": "設定",
"language": {
"label": "語言",
@@ -80,9 +70,9 @@
"models": "Models",
"providers": "Providers",
"image": "Image",
"web": "Web",
"runtime": "Runtime",
"advanced": "Advanced",
"browser": "Web",
"runtime": "系統",
"advanced": "Security",
"cliApps": "CLI 應用",
"mcp": "MCP",
"apps": "應用"
@@ -99,12 +89,13 @@
"webSearch": "Web search",
"webBehavior": "Behavior",
"identity": "Identity",
"safety": "Safety",
"webuiSafety": "網頁端安全",
"capabilities": "功能",
"integrations": "Integrations",
"cliApps": "CLI 應用",
"mcp": "MCP 服務",
"apps": "應用"
"apps": "應用",
"nativeHost": "App",
"hostSafety": "App 安全"
},
"rows": {
"theme": "主題",
@@ -137,22 +128,16 @@
"botName": "Bot name",
"botIcon": "Bot icon",
"timezone": "Timezone",
"toolHintMaxLength": "Tool hint length",
"workspacePath": "Workspace path",
"heartbeat": "Heartbeat",
"dream": "Dream",
"unifiedSession": "Unified session",
"restrictWorkspace": "Restrict to workspace",
"execTool": "Exec tool",
"execSandbox": "Exec sandbox",
"ssrfWhitelist": "SSRF whitelist",
"mcpServers": "MCP servers",
"pathAppend": "PATH append",
"configurationDocs": "Configuration docs",
"workspacePath": "預設工作區",
"localServiceAccess": "Local services",
"webuiDefaultAccess": "Default access",
"currentModel": "目前模型",
"brandLogos": "品牌標誌",
"cliAppsCatalog": "CLI 應用目錄",
"cliAppsFilter": "CLI 應用篩選"
"cliAppsFilter": "CLI 應用篩選",
"engine": "引擎",
"logs": "日誌",
"diagnostics": "診斷"
},
"help": {
"theme": "在淺色與深色外觀之間切換。",
@@ -175,17 +160,22 @@
"defaultAspectRatio": "當提示詞未指定長寬比時使用。",
"defaultImageSize": "傳送給支援此功能的服務商的尺寸提示。",
"maxImagesPerTurn": "單次 generate_image 請求的上限。",
"botName": "Shown in runtime surfaces that use the configured bot identity.",
"botIcon": "Short emoji or text shown beside the bot name.",
"timezone": "IANA timezone used by runtime context and schedules.",
"toolHintMaxLength": "Maximum characters shown in tool progress hints.",
"advancedReadOnly": "Advanced safety controls are read-only in WebUI. Edit config.json intentionally when needed.",
"botName": "顯示在 nanobot 使用名稱的地方。",
"botIcon": "顯示在 bot 名稱旁的短 emoji 或文字。",
"timezone": "用於排程與需要時間感知的回覆。",
"localServiceAccess": "允許完全存取模式下的 shell 命令存取 localhost 服務。",
"webuiDefaultAccess": "用於沒有單獨選擇權限的網頁端對話。",
"securityManagedControls": "Web fetches always protect local, private, and metadata services. Core channel safety stays in config.json.",
"currentModel": "選擇 nanobot 接下來回覆時使用的模型。",
"selectedModelProvider": "由目前模型決定。",
"selectedModelValue": "由目前模型決定。",
"brandLogos": "標誌會從品牌網域載入,並提供本地圖示作為備援。",
"cliAppsCatalog": "瀏覽 nanobot 可在本機執行的應用 CLI。",
"cliAppsFilter": "按應用、分類或能力搜尋。"
"cliAppsFilter": "按應用、分類或能力搜尋。",
"logs": "開啟App引擎日誌資料夾。",
"diagnostics": "匯出一份供支援排查用的執行階段報告。",
"localServiceAccessNative": "允許完全存取模式下的 shell 命令存取這台 Mac 上的服務。",
"webuiDefaultAccessNative": "用於沒有單獨選擇權限的原生 App 對話。"
},
"values": {
"light": "淺色",
@@ -201,8 +191,12 @@
"expanded": "Expanded",
"on": "On",
"off": "Off",
"defaultPermission": "Default Permission",
"fullAccess": "Full Access",
"configured": "Configured",
"notConfigured": "Not configured"
"notConfigured": "Not configured",
"pending": "待套用",
"restartingEngine": "正在重新啟動"
},
"status": {
"loading": "正在載入設定...",
@@ -212,14 +206,24 @@
"savedRestart": "已儲存。重新啟動 nanobot 後生效。",
"restartAfterSaving": "儲存變更後,可在準備好時重新啟動。",
"savedRestartApply": "已儲存,可在準備好時重新啟動。",
"imageProviderRestart": "圖片服務商變更已儲存,可在準備好時重新啟動。"
"imageProviderRestart": "圖片服務商變更已儲存,可在準備好時重新啟動。",
"hostRestartAfterSaving": "儲存後,nanobot 會自動重新啟動引擎。",
"hostRestartPending": "已儲存,將在適當時重新啟動引擎。",
"hostApiUnavailable": "宿主操作只能在原生 App 內使用。",
"logsOpened": "已開啟日誌資料夾。",
"logsOpenFailed": "無法開啟日誌資料夾。",
"diagnosticsExported": "診斷已匯出到 {{path}}。",
"diagnosticsExportFailed": "無法匯出診斷。"
},
"actions": {
"save": "儲存",
"saving": "儲存中",
"edit": "編輯",
"cancel": "取消",
"openDocs": "Open docs"
"open": "開啟",
"export": "匯出",
"opening": "開啟中...",
"exporting": "匯出中..."
},
"byok": {
"description": "自帶 provider key。Nanobot 會從目前 config 讀取這些值,只有已設定的 provider 才能在一般設定中選擇。",
@@ -290,6 +294,7 @@
"newConfiguration": "新增模型設定",
"newConfigurationHelp": "把服務商和模型儲存為一個可直接切換的選項。",
"configurationName": "名稱",
"configurationNameHelp": "重新命名這個已儲存的模型配置。",
"configurationNamePlaceholder": "快速寫作"
},
"timezone": {
@@ -397,6 +402,19 @@
"featured": "精選",
"loading": "正在載入應用...",
"empty": "沒有符合篩選條件的應用。"
},
"oauth": {
"authentication": "OAuth 驗證",
"signIn": "登入",
"signingIn": "正在登入…",
"signInAgain": "重新登入",
"signOut": "登出",
"signedInAs": "已登入為 {{account}}",
"signInHelp": "在這台裝置上登入;不會把 API key 寫入設定。",
"signInRequired": "需要登入",
"signInBeforeSaving": "請先登入這個 OAuth 提供商,再儲存為目前模型提供商。",
"signedIn": "已登入",
"notSignedIn": "未登入"
}
},
"chat": {
@@ -404,7 +422,10 @@
"loading": "載入中…",
"noSessions": "目前還沒有會話。",
"showMore": "再顯示 {{count}} 個",
"collapsed": "已折疊 {{count}} 個對話",
"showLess": "收起",
"actions": "「{{title}}」的會話操作",
"newInProject": "在 {{project}} 中開始新對話",
"activity": {
"running": "Agent 正在執行",
"complete": "Agent 已完成"
@@ -415,6 +436,9 @@
"renameTitle": "重新命名對話",
"renameDescription": "為這個對話設定僅用於 WebUI 側邊欄的名稱。",
"renamePlaceholder": "對話名稱",
"renameProjectTitle": "重新命名專案",
"renameProjectDescription": "為這個專案設定僅用於 WebUI 側邊欄的名稱。",
"renameProjectPlaceholder": "專案名稱",
"renameSave": "儲存",
"archive": "封存",
"unarchive": "取消封存",
@@ -425,6 +449,7 @@
"groups": {
"pinned": "置頂",
"all": "對話",
"projects": "專案",
"today": "今天",
"yesterday": "昨天",
"earlier": "更早",
@@ -448,7 +473,12 @@
"thread": {
"loadingConversation": "正在載入對話…",
"empty": {
"greeting": "我可以幫你做什麼?",
"greetings": {
"workOn": "我們要一起做點什麼?",
"start": "今天從哪裡開始?",
"build": "今天一起構建什麼?",
"tackle": "我們要一起解決什麼?"
},
"quickActions": {
"plan": {
"title": "建立專案計畫",
@@ -632,6 +662,13 @@
"mcpBadge": "MCP",
"cliDescription": "使用 @{{name}} 呼叫本機 CLI",
"mcpDescription": "使用 @{{name}} 呼叫 MCP 服務"
},
"workspace": {
"accessAria": "工作區存取權限",
"projectAria": "選擇專案",
"projectPlaceholder": "選擇專案",
"default": "預設權限",
"full": "完全存取權限"
}
},
"scrollToBottom": "捲動到底部",
@@ -690,6 +727,19 @@
"messageTooBig": {
"title": "訊息過大",
"body": "伺服器因超過大小限制拒收了上一則訊息。可移除部分圖片或改用較小的圖片後再試。"
},
"workspaceScopeRejected": {
"title": "工作區未變更",
"body": "閘道拒絕了要求的專案或存取權限,Nanobot 已繼續使用先前的工作區。"
}
},
"workspace": {
"dialog": {
"defaultProject": "預設工作區",
"manual": "貼上路徑",
"manualPlaceholder": "/Users/name/project",
"usePath": "使用路徑",
"absolutePathRequired": "請輸入這台機器上的絕對資料夾路徑。"
}
}
}
+80
View File
@@ -4,13 +4,17 @@ import type {
ImageGenerationSettingsUpdate,
McpPresetsPayload,
ModelConfigurationCreate,
ModelConfigurationUpdate,
NetworkSafetySettingsUpdate,
ProviderSettingsUpdate,
SettingsPayload,
SettingsUpdate,
SidebarStatePayload,
SlashCommand,
WebSearchSettingsUpdate,
WorkspacesPayload,
WebuiThreadPersistedPayload,
WorkspaceScopePayload,
} from "./types";
export class ApiError extends Error {
@@ -38,6 +42,17 @@ async function request<T>(
if (!res.ok) {
throw new ApiError(res.status, `HTTP ${res.status}`);
}
const contentType = res.headers?.get?.("content-type") ?? "";
if (contentType && !contentType.toLowerCase().includes("application/json")) {
const text = typeof res.text === "function" ? await res.text() : "";
const isHtml = text.trimStart().toLowerCase().startsWith("<!doctype");
throw new ApiError(
res.status,
isHtml
? "Gateway returned WebUI HTML instead of JSON. Restart nanobot gateway and try again."
: "Gateway returned a non-JSON response.",
);
}
return (await res.json()) as T;
}
@@ -73,6 +88,7 @@ export async function listSessions(
title?: string;
preview?: string;
run_started_at?: number | null;
workspace_scope?: WorkspaceScopePayload | null;
};
const body = await request<{ sessions: Row[] }>(
`${base}/api/sessions`,
@@ -86,6 +102,7 @@ export async function listSessions(
title: s.title ?? "",
preview: s.preview ?? "",
runStartedAt: s.run_started_at ?? null,
workspaceScope: s.workspace_scope ?? null,
}));
}
@@ -124,6 +141,13 @@ export async function fetchSettings(
return request<SettingsPayload>(`${base}/api/settings`, token);
}
export async function fetchWorkspaces(
token: string,
base: string = "",
): Promise<WorkspacesPayload> {
return request<WorkspacesPayload>(`${base}/api/workspaces`, token);
}
export async function fetchCliApps(
token: string,
base: string = "",
@@ -281,6 +305,22 @@ export async function createModelConfiguration(
);
}
export async function updateModelConfiguration(
token: string,
configuration: ModelConfigurationUpdate,
base: string = "",
): Promise<SettingsPayload> {
const query = new URLSearchParams();
query.set("name", configuration.name);
if (configuration.label !== undefined) query.set("label", configuration.label);
if (configuration.provider !== undefined) query.set("provider", configuration.provider);
if (configuration.model !== undefined) query.set("model", configuration.model);
return request<SettingsPayload>(
`${base}/api/settings/model-configurations/update?${query}`,
token,
);
}
export async function updateProviderSettings(
token: string,
update: ProviderSettingsUpdate,
@@ -297,6 +337,32 @@ export async function updateProviderSettings(
);
}
export async function loginProviderOAuth(
token: string,
provider: string,
base: string = "",
): Promise<SettingsPayload> {
const query = new URLSearchParams();
query.set("provider", provider);
return request<SettingsPayload>(
`${base}/api/settings/provider/oauth-login?${query}`,
token,
);
}
export async function logoutProviderOAuth(
token: string,
provider: string,
base: string = "",
): Promise<SettingsPayload> {
const query = new URLSearchParams();
query.set("provider", provider);
return request<SettingsPayload>(
`${base}/api/settings/provider/oauth-logout?${query}`,
token,
);
}
export async function updateWebSearchSettings(
token: string,
update: WebSearchSettingsUpdate,
@@ -317,6 +383,20 @@ export async function updateWebSearchSettings(
);
}
export async function updateNetworkSafetySettings(
token: string,
update: NetworkSafetySettingsUpdate,
base: string = "",
): Promise<SettingsPayload> {
const query = new URLSearchParams();
query.set("webui_allow_local_service_access", String(update.webuiAllowLocalServiceAccess));
query.set("webui_default_access_mode", update.webuiDefaultAccessMode);
return request<SettingsPayload>(
`${base}/api/settings/network-safety/update?${query}`,
token,
);
}
export async function updateImageGenerationSettings(
token: string,
update: ImageGenerationSettingsUpdate,
+16 -2
View File
@@ -64,12 +64,26 @@ export async function fetchBootstrap(
* matters because some WS servers dispatch handshakes based on the literal
* path, not a normalised form.
*/
export function deriveWsUrl(wsPath: string, token: string): string {
const path = wsPath && wsPath.startsWith("/") ? wsPath : `/${wsPath || ""}`;
export function deriveWsUrl(
wsPath: string,
token: string,
wsUrl?: string | null,
): string {
const query = `?token=${encodeURIComponent(token)}`;
if (wsUrl && /^(wss?|nanobot-host):\/\//i.test(wsUrl)) {
const join = wsUrl.includes("?") ? "&" : "?";
return `${wsUrl}${join}token=${encodeURIComponent(token)}`;
}
const path = wsPath && wsPath.startsWith("/") ? wsPath : `/${wsPath || ""}`;
if (typeof window === "undefined") {
return `ws://127.0.0.1:8765${path}${query}`;
}
if (window.location.port === "5173") {
const host = window.location.hostname.includes(":")
? `[${window.location.hostname}]`
: window.location.hostname;
return `ws://${host}:8765${path}${query}`;
}
const scheme = window.location.protocol === "https:" ? "wss" : "ws";
const host = window.location.host;
return `${scheme}://${host}${path}${query}`;
+372
View File
@@ -0,0 +1,372 @@
import { deriveTitle } from "@/lib/format";
import type { ChatSummary, SidebarSortMode } from "@/lib/types";
import { normalizeWorkspacePath, projectNameFromPath, sameWorkspacePath } from "@/lib/workspace";
export const COLLAPSED_CHATS_VISIBLE_COUNT = 8;
export interface SessionGroup {
id: string;
label: string;
sessions: ChatSummary[];
kind?: "project";
projectPath?: string;
projectKey?: string;
updatedAt?: string | null;
}
export interface ChatGroupLabels {
pinned: string;
all: string;
today: string;
yesterday: string;
earlier: string;
archived: string;
projects: string;
fallbackTitle: string;
}
export interface ChatGroupingOptions {
pinnedKeys: string[];
archivedKeys: string[];
titleOverrides: Record<string, string>;
projectNameOverrides: Record<string, string>;
showArchived: boolean;
sort: SidebarSortMode;
defaultWorkspacePath?: string | null;
}
export function groupSessions(
sessions: ChatSummary[],
labels: ChatGroupLabels,
options: ChatGroupingOptions,
): SessionGroup[] {
if (sessions.some((session) => session.workspaceScope?.project_path)) {
return groupSessionsByProject(sessions, labels, options);
}
const now = new Date();
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
const startOfYesterday = startOfToday - 24 * 60 * 60 * 1000;
const buckets = new Map<string, ChatSummary[]>();
const pinned = new Set(options.pinnedKeys);
const archived = new Set(options.archivedKeys);
const pinnedSessions: ChatSummary[] = [];
const archivedSessions: ChatSummary[] = [];
const normalSessions: ChatSummary[] = [];
for (const session of sessions) {
if (archived.has(session.key)) {
if (options.showArchived) archivedSessions.push(session);
continue;
}
if (pinned.has(session.key)) {
pinnedSessions.push(session);
continue;
}
if (options.sort === "title_asc") {
normalSessions.push(session);
continue;
}
const timestamp = Date.parse(session.updatedAt ?? session.createdAt ?? "");
const label = Number.isFinite(timestamp) && timestamp >= startOfToday
? labels.today
: Number.isFinite(timestamp) && timestamp >= startOfYesterday
? labels.yesterday
: labels.earlier;
const bucket = buckets.get(label) ?? [];
bucket.push(session);
buckets.set(label, bucket);
}
const groups: SessionGroup[] = [labels.today, labels.yesterday, labels.earlier]
.map((label) => ({
id: `date:${label}`,
label,
sessions: sortSessions(
buckets.get(label) ?? [],
options.sort,
options.titleOverrides,
),
}))
.filter((group) => group.sessions.length > 0);
if (options.sort === "title_asc" && normalSessions.length) {
groups.push({
id: "date:all",
label: labels.all,
sessions: sortSessions(
normalSessions,
options.sort,
options.titleOverrides,
),
});
}
if (pinnedSessions.length) {
groups.unshift({
id: "pinned",
label: labels.pinned,
sessions: sortSessions(
pinnedSessions,
options.sort,
options.titleOverrides,
),
});
}
if (archivedSessions.length) {
groups.push({
id: "archived",
label: labels.archived,
sessions: sortSessions(
archivedSessions,
options.sort,
options.titleOverrides,
),
});
}
return groups;
}
export function limitGroups(
groups: SessionGroup[],
limit: number,
activeKey: string | null,
collapsedGroups: Record<string, boolean>,
): SessionGroup[] {
let remaining = Math.max(0, limit);
let activeVisible = !activeKey;
const out: SessionGroup[] = [];
for (const group of groups) {
if (isCollapsedProject(group, collapsedGroups)) {
out.push({ ...group, sessions: [] });
continue;
}
const visible = remaining > 0
? group.sessions.slice(0, remaining)
: [];
remaining -= visible.length;
if (activeKey && visible.some((session) => session.key === activeKey)) {
activeVisible = true;
}
if (visible.length > 0) {
out.push({ ...group, sessions: visible });
}
}
if (activeVisible || !activeKey) return out;
for (const group of groups) {
if (isCollapsedProject(group, collapsedGroups)) continue;
const active = group.sessions.find((session) => session.key === activeKey);
if (!active) continue;
const existing = out.find((item) => item.id === group.id);
if (existing) {
existing.sessions = [...existing.sessions, active];
} else {
out.push({ ...group, sessions: [active] });
}
return out;
}
return out;
}
export function isCollapsedProject(
group: SessionGroup,
collapsedGroups: Record<string, boolean>,
): boolean {
return group.kind === "project" && Boolean(collapsedGroups[group.id]);
}
export function isFoldableChatsGroup(group: SessionGroup): boolean {
return group.id === "workspace:chats" || group.id === "date:all";
}
export function isFoldedChatsGroup(
group: SessionGroup,
collapsedGroups: Record<string, boolean>,
): boolean {
return (
isFoldableChatsGroup(group)
&& group.sessions.length > COLLAPSED_CHATS_VISIBLE_COUNT
&& collapsedGroups[group.id] !== false
);
}
export function visibleSessionsForGroup(
group: SessionGroup,
activeKey: string | null,
collapsedGroups: Record<string, boolean>,
): ChatSummary[] {
if (!isFoldedChatsGroup(group, collapsedGroups)) {
return group.sessions;
}
const visible = group.sessions.slice(0, COLLAPSED_CHATS_VISIBLE_COUNT);
if (!activeKey || visible.some((session) => session.key === activeKey)) {
return visible;
}
const active = group.sessions.find((session) => session.key === activeKey);
return active ? [...visible, active] : visible;
}
export function displayTitle(
session: ChatSummary,
titleOverrides: Record<string, string>,
fallbackTitle: string,
): string {
return (
titleOverrides[session.key]?.trim()
|| session.title?.trim()
|| deriveTitle(session.preview, fallbackTitle)
);
}
function groupSessionsByProject(
sessions: ChatSummary[],
labels: Pick<ChatGroupLabels, "all">,
options: ChatGroupingOptions,
): SessionGroup[] {
const archived = new Set(options.archivedKeys);
const conversations: ChatSummary[] = [];
const buckets = new Map<string, {
path?: string;
label: string;
sessions: ChatSummary[];
updatedAt: string | null;
}>();
for (const session of sessions) {
if (archived.has(session.key) && !options.showArchived) {
continue;
}
const scope = session.workspaceScope;
const path = scope?.project_path || "";
if (!path || sameWorkspacePath(path, options.defaultWorkspacePath)) {
conversations.push(session);
continue;
}
const key = normalizeWorkspacePath(path);
const label = options.projectNameOverrides[key]?.trim()
|| scope?.project_name?.trim()
|| projectNameFromPath(path);
const bucket = buckets.get(key) ?? {
path,
label,
sessions: [],
updatedAt: null,
};
bucket.sessions.push(session);
const candidate = session.updatedAt ?? session.createdAt ?? null;
if (isNewerDate(candidate, bucket.updatedAt)) {
bucket.updatedAt = candidate;
}
buckets.set(key, bucket);
}
const pinned = new Set(options.pinnedKeys);
const groups: SessionGroup[] = Array.from(buckets.entries()).map(([key, bucket]) => ({
id: `project:${key}`,
label: bucket.label,
kind: "project" as const,
projectPath: bucket.path,
projectKey: key,
updatedAt: bucket.updatedAt,
sessions: sortProjectSessions(
bucket.sessions,
options.sort,
options.titleOverrides,
pinned,
archived,
),
}));
groups.sort((a, b) => {
const timeOrder = dateToTime(b.updatedAt) - dateToTime(a.updatedAt);
if (timeOrder !== 0) return timeOrder;
return a.label.localeCompare(b.label, "en", {
numeric: true,
sensitivity: "base",
});
});
if (conversations.length) {
groups.push({
id: "workspace:chats",
label: labels.all,
sessions: sortProjectSessions(
conversations,
options.sort,
options.titleOverrides,
pinned,
archived,
),
});
}
return groups;
}
function sortProjectSessions(
sessions: ChatSummary[],
sort: SidebarSortMode,
titleOverrides: Record<string, string>,
pinned: Set<string>,
archived: Set<string>,
): ChatSummary[] {
return sortSessions(sessions, sort, titleOverrides).sort((a, b) => {
const pinOrder = Number(pinned.has(b.key)) - Number(pinned.has(a.key));
if (pinOrder !== 0) return pinOrder;
const archiveOrder = Number(archived.has(a.key)) - Number(archived.has(b.key));
if (archiveOrder !== 0) return archiveOrder;
return 0;
});
}
function sortSessions(
sessions: ChatSummary[],
sort: SidebarSortMode,
titleOverrides: Record<string, string>,
): ChatSummary[] {
const copy = [...sessions];
copy.sort((a, b) => {
if (sort === "title_asc") {
const titleOrder = titleForSort(a, titleOverrides).localeCompare(
titleForSort(b, titleOverrides),
"en",
{ numeric: true, sensitivity: "base" },
);
if (titleOrder !== 0) return titleOrder;
return sessionTime(b, "updatedAt") - sessionTime(a, "updatedAt");
}
const aTime = sessionTime(a, sort === "created_desc" ? "createdAt" : "updatedAt");
const bTime = sessionTime(b, sort === "created_desc" ? "createdAt" : "updatedAt");
return bTime - aTime;
});
return copy;
}
function isNewerDate(a: string | null, b: string | null): boolean {
return dateToTime(a) > dateToTime(b);
}
function dateToTime(value: string | null | undefined): number {
const ts = Date.parse(value ?? "");
return Number.isFinite(ts) ? ts : 0;
}
function titleForSort(
session: ChatSummary,
titleOverrides: Record<string, string>,
): string {
return (
titleOverrides[session.key]?.trim()
|| session.title?.trim()
|| deriveTitle(session.preview, "new chat")
).toLocaleLowerCase("en");
}
function sessionTime(session: ChatSummary, field: "createdAt" | "updatedAt"): number {
const ts = Date.parse(session[field] ?? "");
return Number.isFinite(ts) ? ts : 0;
}
+55 -13
View File
@@ -7,6 +7,7 @@ import type {
OutboundMcpPresetMention,
OutboundMedia,
GoalStateWsPayload,
WorkspaceScopePayload,
} from "./types";
/** WebSocket readyState constants, referenced by value to stay portable
@@ -57,22 +58,25 @@ type EventHandler = (ev: InboundEvent) => void;
type StatusHandler = (status: ConnectionStatus) => void;
type RuntimeModelHandler = (modelName: string | null, modelPreset?: string | null) => void;
type SessionUpdateScope = "metadata" | "thread" | string;
type SessionUpdateHandler = (chatId: string, scope?: SessionUpdateScope) => void;
type SessionUpdateHandler = (
chatId: string,
scope?: SessionUpdateScope,
workspaceScope?: WorkspaceScopePayload,
) => void;
type RunStatusHandler = (chatId: string, startedAt: number | null) => void;
/** Structured connection-level errors surfaced to the UI.
/** Structured errors surfaced to the UI.
*
* These are *not* InboundEvent errors from the server application layer
* those arrive as ``{event: "error"}`` messages via ``onChat``. These are
* transport-level or protocol-level faults the UI should make visible so
* the user understands *why* their action failed (as opposed to silently
* reconnecting under the hood).
* Most entries are transport-level or protocol-level faults. Workspace scope
* rejections are server application errors promoted here because they affect
* controls outside the message stream and must be visible immediately.
*/
export type StreamError =
/** Server rejected the inbound frame as too large (WS close code 1009).
* Typically means the user attached images whose base64 size exceeded
* ``maxMessageBytes`` on the server. */
| { kind: "message_too_big" };
| { kind: "message_too_big" }
| { kind: "workspace_scope_rejected"; reason?: string; chatId?: string };
type ErrorHandler = (error: StreamError) => void;
@@ -206,6 +210,13 @@ export class NanobotClient {
}
private recordGoalStatusForRunStrip(chatId: string, ev: InboundEvent): void {
if (ev.event === "turn_end") {
if (this.runStartedAtByChatId.has(chatId)) {
this.runStartedAtByChatId.delete(chatId);
this.emitRunStatus(chatId, null);
}
return;
}
if (ev.event !== "goal_status") return;
if (ev.status === "running" && typeof ev.started_at === "number") {
const previous = this.runStartedAtByChatId.get(chatId);
@@ -281,7 +292,7 @@ export class NanobotClient {
}
/** Ask the server to provision a new chat_id; resolves with the assigned id. */
newChat(timeoutMs: number = 5_000): Promise<string> {
newChat(timeoutMs: number = 5_000, workspaceScope?: WorkspaceScopePayload | null): Promise<string> {
if (this.pendingNewChat) {
return Promise.reject(new Error("newChat already in flight"));
}
@@ -291,7 +302,10 @@ export class NanobotClient {
reject(new Error("newChat timed out"));
}, timeoutMs);
this.pendingNewChat = { resolve, reject, timer };
this.queueSend({ type: "new_chat" });
this.queueSend({
type: "new_chat",
...(workspaceScope ? { workspace_scope: workspaceScope } : {}),
});
});
}
@@ -310,6 +324,7 @@ export class NanobotClient {
imageGeneration?: OutboundImageGeneration;
cliApps?: OutboundCliAppMention[];
mcpPresets?: OutboundMcpPresetMention[];
workspaceScope?: WorkspaceScopePayload | null;
},
): void {
this.knownChats.add(chatId);
@@ -321,11 +336,21 @@ export class NanobotClient {
...(options?.imageGeneration ? { image_generation: options.imageGeneration } : {}),
...(options?.cliApps?.length ? { cli_apps: options.cliApps } : {}),
...(options?.mcpPresets?.length ? { mcp_presets: options.mcpPresets } : {}),
...(options?.workspaceScope ? { workspace_scope: options.workspaceScope } : {}),
webui: true,
};
this.queueSend(frame);
}
setWorkspaceScope(chatId: string, workspaceScope: WorkspaceScopePayload): void {
this.knownChats.add(chatId);
this.queueSend({
type: "set_workspace_scope",
chat_id: chatId,
workspace_scope: workspaceScope,
});
}
// -- internals ---------------------------------------------------------
private setStatus(status: ConnectionStatus): void {
@@ -388,10 +413,23 @@ export class NanobotClient {
}
if (parsed.event === "session_updated") {
this.emitSessionUpdate(parsed.chat_id, parsed.scope);
this.emitSessionUpdate(parsed.chat_id, parsed.scope, parsed.workspace_scope);
return;
}
if (parsed.event === "error" && parsed.detail === "workspace_scope_rejected") {
this.emitError({
kind: "workspace_scope_rejected",
reason: parsed.reason,
chatId: parsed.chat_id,
});
if (this.pendingNewChat) {
clearTimeout(this.pendingNewChat.timer);
this.pendingNewChat.reject(new Error(`workspace_scope_rejected:${parsed.reason || ""}`));
this.pendingNewChat = null;
}
}
const chatId = (parsed as { chat_id?: string }).chat_id;
if (chatId) {
this.recordGoalStatusForRunStrip(chatId, parsed);
@@ -406,9 +444,13 @@ export class NanobotClient {
}
}
private emitSessionUpdate(chatId: string, scope?: SessionUpdateScope): void {
private emitSessionUpdate(
chatId: string,
scope?: SessionUpdateScope,
workspaceScope?: WorkspaceScopePayload,
): void {
for (const handler of this.sessionUpdateHandlers) {
handler(chatId, scope);
handler(chatId, scope, workspaceScope);
}
}
+8 -2
View File
@@ -92,9 +92,11 @@ export function logoFallbackUrls(logoUrl: string | null | undefined): string[] {
export const PROVIDER_BRAND_ALIASES: Record<string, string> = {
brave_search: "brave",
byteplus_coding_plan: "byteplus",
mimo: "xiaomi_mimo",
minimaxAnthropic: "minimax",
minimax_anthropic: "minimax",
openai_codex: "openai",
xiaomi: "xiaomi_mimo",
volcengine_coding_plan: "volcengine",
};
@@ -127,7 +129,9 @@ const PROVIDER_BRANDS: Record<string, ProviderBrand> = {
jina: brand("jina.ai", "#7C3AED", "J"),
kagi: brand("kagi.com", "#FFB319", "K"),
lm_studio: brand("lmstudio.ai", "#111827", "LM"),
longcat: brand("longcat.chat", "#111827", "LC"),
longcat: brand("longcatai.org", "#4F8CFF", "LC", [
"https://www.longcatai.org/favicon.svg",
]),
minimax: brand("minimax.io", "#111827", "MM"),
mistral: brand("mistral.ai", "#FA520F", "M"),
moonshot: brand("moonshot.ai", "#111827", "MS"),
@@ -146,7 +150,9 @@ const PROVIDER_BRANDS: Record<string, ProviderBrand> = {
tavily: brand("tavily.com", "#111827", "T"),
volcengine: brand("volcengine.com", "#1664FF", "VE"),
vllm: brand("vllm.ai", "#2563EB", "VL"),
xiaomi_mimo: brand("xiaomimimo.com", "#FF6900", "MI"),
xiaomi_mimo: brand("mimo.xiaomi.com", "#FF6900", "MI", [
"https://mimo.xiaomi.com/mimo-v2-pro/assets/logo.svg",
]),
zhipu: brand("z.ai", "#155EEF", "Z", [
"https://z-cdn.chatglm.cn/z-ai/static/logo.svg",
"https://www.google.com/s2/favicons?domain=z.ai&sz=64",
+211
View File
@@ -0,0 +1,211 @@
import type { RuntimeCapabilities, RuntimeSurface } from "./types";
export interface RuntimeHost {
surface: RuntimeSurface;
capabilities: RuntimeCapabilities;
socketFactory?: (url: string) => WebSocket;
pickFolder?: () => Promise<string | null>;
restartEngine?: () => Promise<void>;
openLogs?: () => Promise<void>;
exportDiagnostics?: () => Promise<string>;
}
export interface HostRuntimeInfo {
surface: "native";
app_version: string;
engine_status: "starting" | "ready" | "restarting" | "stopped" | "crashed";
data_dir: string;
logs_dir: string;
config_path: string;
workspace_path: string;
python: string;
api_base?: string;
engine_transport?: "unix_socket";
}
export interface NanobotHostApi {
getRuntimeInfo(): Promise<HostRuntimeInfo>;
restartEngine(): Promise<void>;
pickFolder(): Promise<string | null>;
openLogs(): Promise<void>;
exportDiagnostics(): Promise<string>;
openSocket?(url: string): Promise<string>;
sendSocket?(id: string, data: string): Promise<void>;
closeSocket?(id: string): Promise<void>;
onSocketEvent?(
listener: (event: HostSocketEvent) => void,
): () => void;
onRuntimeStatus?(
listener: (status: HostRuntimeInfo["engine_status"]) => void,
): () => void;
}
export type HostSocketEvent =
| { id: string; type: "open" }
| { data: string; id: string; type: "message" }
| { id: string; message: string; type: "error" }
| { code?: number; id: string; reason?: string; type: "close" };
type HostSocketBridge = Required<Pick<
NanobotHostApi,
"closeSocket" | "onSocketEvent" | "openSocket" | "sendSocket"
>>;
declare global {
interface Window {
nanobotHost?: NanobotHostApi;
}
}
export function getHostApi(): NanobotHostApi | null {
if (typeof window === "undefined") return null;
return window.nanobotHost ?? null;
}
export function toRuntimeSurface(surface: string | null | undefined): RuntimeSurface {
return surface === "native" ? "native" : "browser";
}
export function createRuntimeHost(
surface: RuntimeSurface,
capabilities?: Partial<RuntimeCapabilities> | null,
): RuntimeHost {
const api = getHostApi();
const mergedCapabilities = {
can_export_diagnostics: false,
can_open_logs: false,
can_pick_folder: false,
can_restart_engine: false,
...(capabilities ?? {}),
};
const bridge = getHostSocketBridge();
return {
surface,
capabilities: mergedCapabilities,
socketFactory: bridge ? createHostWebSocket : undefined,
pickFolder: api?.pickFolder,
restartEngine: api?.restartEngine,
openLogs: api?.openLogs,
exportDiagnostics: api?.exportDiagnostics,
};
}
export function createHostWebSocket(url: string): WebSocket {
const api = getHostSocketBridge();
if (!api) {
throw new Error("Host WebSocket bridge is not available");
}
return new HostWebSocket(api, url) as unknown as WebSocket;
}
function getHostSocketBridge(): HostSocketBridge | null {
const api = getHostApi();
if (
!api?.openSocket
|| !api.sendSocket
|| !api.closeSocket
|| !api.onSocketEvent
) {
return null;
}
return {
closeSocket: api.closeSocket,
onSocketEvent: api.onSocketEvent,
openSocket: api.openSocket,
sendSocket: api.sendSocket,
};
}
class HostWebSocket {
binaryType: BinaryType = "blob";
onclose: ((this: WebSocket, ev: CloseEvent) => unknown) | null = null;
onerror: ((this: WebSocket, ev: Event) => unknown) | null = null;
onmessage: ((this: WebSocket, ev: MessageEvent) => unknown) | null = null;
onopen: ((this: WebSocket, ev: Event) => unknown) | null = null;
readyState: number = WebSocket.CONNECTING;
readonly url: string;
private id: string | null = null;
private readonly queued: string[] = [];
private readonly unsubscribe: () => void;
constructor(
private readonly api: HostSocketBridge,
url: string,
) {
this.url = url;
this.unsubscribe = api.onSocketEvent((event) => this.handleEvent(event));
void api.openSocket(url).then(
(id) => {
this.id = id;
},
() => {
this.readyState = WebSocket.CLOSED;
this.onerror?.call(this as unknown as WebSocket, new Event("error"));
this.onclose?.call(this as unknown as WebSocket, closeEvent());
this.unsubscribe();
},
);
}
close(): void {
if (this.readyState === WebSocket.CLOSING || this.readyState === WebSocket.CLOSED) {
return;
}
this.readyState = WebSocket.CLOSING;
if (this.id) {
void this.api.closeSocket(this.id);
} else {
this.readyState = WebSocket.CLOSED;
this.unsubscribe();
}
}
send(data: string | ArrayBufferLike | Blob | ArrayBufferView): void {
if (typeof data !== "string") {
throw new Error("Host WebSocket bridge only supports text frames");
}
if (this.readyState === WebSocket.OPEN && this.id) {
void this.api.sendSocket(this.id, data);
return;
}
this.queued.push(data);
}
private handleEvent(event: HostSocketEvent): void {
if (!this.id || event.id !== this.id) return;
if (event.type === "open") {
this.readyState = WebSocket.OPEN;
this.onopen?.call(this as unknown as WebSocket, new Event("open"));
while (this.queued.length > 0 && this.id) {
const data = this.queued.shift();
if (data !== undefined) void this.api.sendSocket(this.id, data);
}
return;
}
if (event.type === "message") {
this.onmessage?.call(
this as unknown as WebSocket,
new MessageEvent("message", { data: event.data }),
);
return;
}
if (event.type === "error") {
this.onerror?.call(this as unknown as WebSocket, new Event("error"));
return;
}
this.readyState = WebSocket.CLOSED;
this.onclose?.call(
this as unknown as WebSocket,
closeEvent(event.code, event.reason),
);
this.unsubscribe();
}
}
function closeEvent(code = 1006, reason = ""): CloseEvent {
if (typeof CloseEvent !== "undefined") {
return new CloseEvent("close", { code, reason });
}
return new Event("close") as CloseEvent;
}
+101 -4
View File
@@ -122,6 +122,7 @@ export interface UIFileEdit {
deleted: number;
approximate?: boolean;
status: "editing" | "done" | "error";
operation?: "edit" | "delete" | string;
binary?: boolean;
error?: string;
pending?: boolean;
@@ -139,6 +140,36 @@ export interface ChatSummary {
preview: string;
/** Unix epoch seconds when this session currently has a turn in flight. */
runStartedAt?: number | null;
workspaceScope?: WorkspaceScopePayload | null;
}
export type WorkspaceAccessMode = "restricted" | "full";
export type WebuiDefaultAccessMode = "default" | "full";
export interface WorkspaceScopePayload {
project_path: string;
project_name?: string;
access_mode: WorkspaceAccessMode;
restrict_to_workspace?: boolean;
sandbox_status?: {
restrict_to_workspace: boolean;
workspace_root: string;
level: string;
enforced: boolean;
provider: string;
provider_label: string;
summary: string;
};
}
export interface WorkspacesPayload {
schema_version: number;
default_access_mode: WebuiDefaultAccessMode;
default_scope: WorkspaceScopePayload;
controls: {
can_change_project: boolean;
can_use_full_access: boolean;
};
}
export type SidebarDensity = "comfortable" | "compact";
@@ -157,6 +188,7 @@ export interface SidebarStatePayload {
pinned_keys: string[];
archived_keys: string[];
title_overrides: Record<string, string>;
project_name_overrides: Record<string, string>;
tags_by_key: Record<string, string[]>;
collapsed_groups: Record<string, boolean>;
view: SidebarViewState;
@@ -166,11 +198,38 @@ export interface SidebarStatePayload {
export interface BootstrapResponse {
token: string;
ws_path: string;
ws_url?: string | null;
expires_in: number;
model_name?: string | null;
runtime_surface?: RuntimeSurface;
runtime_capabilities?: RuntimeCapabilities;
}
export type RuntimeSurface = "browser" | "native";
export type RestartBehavior = "none" | "nextTurn" | "engineRestart" | "appRestart";
export type SettingsApplyStatus =
| "idle"
| "pending"
| "applying"
| "restarting_engine"
| "requires_app_restart";
export interface RuntimeCapabilities {
can_restart_engine: boolean;
can_pick_folder: boolean;
can_open_logs: boolean;
can_export_diagnostics: boolean;
}
export interface SettingsPayload {
surface?: RuntimeSurface;
runtime_surface?: RuntimeSurface;
runtime_capabilities?: RuntimeCapabilities;
apply_state?: {
status: SettingsApplyStatus;
sections: string[];
};
restart_behavior_by_section?: Record<string, RestartBehavior>;
agent: {
model: string;
provider: string;
@@ -202,11 +261,15 @@ export interface SettingsPayload {
name: string;
label: string;
configured: boolean;
auth_type?: "api_key" | "oauth";
api_key_required?: boolean;
api_key_hint?: string | null;
api_base?: string | null;
default_api_base?: string | null;
api_type?: "auto" | "chat_completions" | "responses";
oauth_account?: string | null;
oauth_expires_at?: number | null;
oauth_login_supported?: boolean;
}>;
web_search: {
provider: string;
@@ -245,6 +308,7 @@ export interface SettingsPayload {
name: string;
label: string;
configured: boolean;
auth_type?: "api_key" | "oauth";
api_key_hint?: string | null;
api_base?: string | null;
default_api_base?: string | null;
@@ -270,14 +334,27 @@ export interface SettingsPayload {
};
advanced: {
restrict_to_workspace: boolean;
workspace_sandbox?: {
restrict_to_workspace: boolean;
workspace_root: string;
level: "off" | "application" | "system" | string;
enforced: boolean;
provider: string;
provider_label: string;
summary: string;
};
ssrf_whitelist_count: number;
webui_allow_local_service_access: boolean;
allow_local_preview_access?: boolean;
webui_default_access_mode: WebuiDefaultAccessMode;
private_service_protection_enabled: boolean;
mcp_server_count: number;
exec_enabled: boolean;
exec_sandbox?: string | null;
exec_path_append_set: boolean;
};
requires_restart: boolean;
restart_required_sections?: Array<"runtime" | "web" | "image">;
restart_required_sections?: Array<"runtime" | "browser" | "image">;
}
export interface AppPackageRef {
@@ -453,6 +530,13 @@ export interface ModelConfigurationCreate {
model: string;
}
export interface ModelConfigurationUpdate {
name: string;
label?: string;
provider?: string;
model?: string;
}
export interface ProviderSettingsUpdate {
provider: string;
apiKey?: string;
@@ -469,6 +553,11 @@ export interface WebSearchSettingsUpdate {
useJinaReader?: boolean;
}
export interface NetworkSafetySettingsUpdate {
webuiAllowLocalServiceAccess: boolean;
webuiDefaultAccessMode: WebuiDefaultAccessMode;
}
export interface ImageGenerationSettingsUpdate {
enabled: boolean;
provider: string;
@@ -566,8 +655,13 @@ export type InboundEvent =
chat_id: string;
goal_state: GoalStateWsPayload;
}
| { event: "session_updated"; chat_id: string; scope?: "metadata" | "thread" | string }
| { event: "error"; chat_id?: string; detail?: string };
| {
event: "session_updated";
chat_id: string;
scope?: "metadata" | "thread" | string;
workspace_scope?: WorkspaceScopePayload;
}
| { event: "error"; chat_id?: string; detail?: string; reason?: string };
/** Base64-encoded image attached to an outbound ``message`` envelope.
*
@@ -613,11 +707,13 @@ export interface WebuiThreadPersistedPayload {
sessionKey?: string;
savedAt?: string;
messages: UIMessage[];
workspace_scope?: WorkspaceScopePayload;
}
export type Outbound =
| { type: "new_chat" }
| { type: "new_chat"; workspace_scope?: WorkspaceScopePayload }
| { type: "attach"; chat_id: string }
| { type: "set_workspace_scope"; chat_id: string; workspace_scope: WorkspaceScopePayload }
| {
type: "message";
chat_id: string;
@@ -626,6 +722,7 @@ export type Outbound =
image_generation?: OutboundImageGeneration;
cli_apps?: OutboundCliAppMention[];
mcp_presets?: OutboundMcpPresetMention[];
workspace_scope?: WorkspaceScopePayload;
/** Marks messages sent by the embedded WebUI, without changing the
* generic websocket protocol for other clients. */
webui?: true;
+56
View File
@@ -0,0 +1,56 @@
import type { WorkspaceAccessMode, WorkspaceScopePayload } from "@/lib/types";
export function scopeWithAccessMode(
scope: WorkspaceScopePayload,
accessMode: WorkspaceAccessMode,
): WorkspaceScopePayload {
return {
...scope,
access_mode: accessMode,
restrict_to_workspace: accessMode === "restricted",
};
}
export function projectNameFromPath(path: string): string {
const normalized = path.replace(/\\/g, "/").replace(/\/+$/, "");
return normalized.split("/").filter(Boolean).pop() || path;
}
export function shortWorkspacePath(path: string): string {
const normalized = path.replace(/\\/g, "/");
const parts = normalized.split("/").filter(Boolean);
if (parts.length <= 3) return path;
return `.../${parts.slice(-3).join("/")}`;
}
export function isAbsoluteWorkspacePath(path: string): boolean {
const trimmed = path.trim();
return (
trimmed === "~"
|| trimmed.startsWith("~/")
|| trimmed.startsWith("~\\")
|| trimmed.startsWith("/")
|| /^[A-Za-z]:[\\/]/.test(trimmed)
);
}
export function selectedProjectScope(
scope: WorkspaceScopePayload | null,
defaultScope: WorkspaceScopePayload | null,
): WorkspaceScopePayload | null {
if (!scope || !defaultScope) return null;
return sameWorkspacePath(scope.project_path, defaultScope.project_path) ? null : scope;
}
export function normalizeWorkspacePath(path: string | null | undefined): string {
const normalized = (path ?? "").replace(/\\/g, "/").replace(/\/+$/, "");
return normalized || "/";
}
export function sameWorkspacePath(
a: string | null | undefined,
b: string | null | undefined,
): boolean {
if (!a || !b) return false;
return normalizeWorkspacePath(a) === normalizeWorkspacePath(b);
}
@@ -433,6 +433,72 @@ describe("AgentActivityCluster", () => {
}
});
it("labels whole-file deletes as deleted instead of edited", () => {
render(
<AgentActivityCluster
messages={activityMessages("", {
id: "t-delete",
role: "tool",
kind: "trace",
content: "apply_patch()",
traces: ["apply_patch()"],
fileEdits: [{
call_id: "call-delete",
tool: "apply_patch",
path: "angry-birds.html",
phase: "end",
added: 0,
deleted: 590,
approximate: false,
status: "done",
operation: "delete",
}],
createdAt: 3,
})}
isTurnStreaming={false}
hasBodyBelow={false}
/>,
);
expect(screen.getByRole("button", { name: /deleted angry-birds\.html/i })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /edited angry-birds\.html/i })).not.toBeInTheDocument();
});
it("renders file-only edits without a redundant disclosure", () => {
render(
<AgentActivityCluster
messages={[{
id: "t-file-only",
role: "tool",
kind: "trace",
content: "apply_patch()",
traces: ["apply_patch()"],
fileEdits: [{
call_id: "call-patch",
tool: "apply_patch",
path: "src/app.tsx",
absolute_path: "/Users/renxubin/project/src/app.tsx",
phase: "end",
added: 12,
deleted: 3,
approximate: false,
status: "done",
}],
createdAt: 3,
}]}
isTurnStreaming={false}
hasBodyBelow={false}
/>,
);
expect(screen.queryByRole("button", { name: /edited app\.tsx/i })).not.toBeInTheDocument();
expect(screen.queryByTestId("agent-activity-scroll")).not.toBeInTheDocument();
expect(screen.getByText("Edited")).toBeInTheDocument();
expect(screen.getByTestId("activity-header-file-reference")).toHaveTextContent("app.tsx");
expect(screen.getByText("+12")).toBeInTheDocument();
expect(screen.getByText("-3")).toBeInTheDocument();
});
it("renders CLI app runs as dedicated activity rows", () => {
const line = 'run_cli_app({"name":"blender","args":["--background","scene.blend"],"json":true})';
render(
@@ -771,6 +837,38 @@ describe("AgentActivityCluster", () => {
expect(screen.getByText("Preparing file edit…")).toBeInTheDocument();
});
it("shows the reason when a file edit fails", () => {
render(
<AgentActivityCluster
messages={activityMessages("", {
id: "t2",
role: "tool",
kind: "trace",
content: "apply_patch()",
traces: ["apply_patch()"],
fileEdits: [{
call_id: "call-patch",
tool: "apply_patch",
path: "angry-birds.html",
phase: "error",
added: 0,
deleted: 0,
approximate: false,
status: "error",
error: "Error applying patch: old_text not found in angry-birds.html",
}],
createdAt: 3,
})}
isTurnStreaming={false}
hasBodyBelow={false}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /failed angry-birds\.html/i }));
expect(screen.getByText("Target text was not found in angry-birds.html.")).toBeInTheDocument();
});
it("merges repeated edits for the same path and lets successful edits win over failures", async () => {
const restoreMotion = installReducedMotion();
try {
+106
View File
@@ -7,15 +7,20 @@ import {
fetchMcpPresets,
fetchSidebarState,
fetchWebuiThread,
fetchWorkspaces,
importMcpConfig,
listSessions,
listSlashCommands,
loginProviderOAuth,
logoutProviderOAuth,
runCliAppAction,
runMcpPresetAction,
saveCustomMcpServer,
updateSidebarState,
updateImageGenerationSettings,
updateModelConfiguration,
updateMcpServerTools,
updateNetworkSafetySettings,
updateProviderSettings,
updateSettings,
updateWebSearchSettings,
@@ -89,6 +94,44 @@ describe("webui API helpers", () => {
);
});
it("serializes model configuration updates", async () => {
await updateModelConfiguration("tok", {
name: "codex",
label: "Codex",
provider: "openai_codex",
model: "openai-codex/gpt-5.5",
});
expect(fetch).toHaveBeenCalledWith(
"/api/settings/model-configurations/update?name=codex&label=Codex&provider=openai_codex&model=openai-codex%2Fgpt-5.5",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
);
});
it("reports HTML API fallbacks as gateway mismatch errors", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
status: 200,
headers: new Headers({ "content-type": "text/html; charset=utf-8" }),
text: async () => "<!doctype html><html></html>",
}),
);
await expect(
updateModelConfiguration("tok", {
name: "codex",
model: "openai-codex/gpt-5.5",
}),
).rejects.toMatchObject({
status: 200,
message: "Gateway returned WebUI HTML instead of JSON. Restart nanobot gateway and try again.",
});
});
it("serializes provider settings updates without returning secrets", async () => {
await updateProviderSettings("tok", {
provider: "openrouter",
@@ -104,6 +147,24 @@ describe("webui API helpers", () => {
);
});
it("serializes provider OAuth login and logout actions", async () => {
await loginProviderOAuth("tok", "openai_codex");
expect(fetch).toHaveBeenCalledWith(
"/api/settings/provider/oauth-login?provider=openai_codex",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
);
await logoutProviderOAuth("tok", "openai_codex");
expect(fetch).toHaveBeenCalledWith(
"/api/settings/provider/oauth-logout?provider=openai_codex",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
);
});
it("serializes web search settings updates", async () => {
await updateWebSearchSettings("tok", {
provider: "searxng",
@@ -121,6 +182,20 @@ describe("webui API helpers", () => {
);
});
it("serializes network safety settings updates", async () => {
await updateNetworkSafetySettings("tok", {
webuiAllowLocalServiceAccess: false,
webuiDefaultAccessMode: "full",
});
expect(fetch).toHaveBeenCalledWith(
"/api/settings/network-safety/update?webui_allow_local_service_access=false&webui_default_access_mode=full",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
);
});
it("serializes image generation settings updates", async () => {
await updateImageGenerationSettings("tok", {
enabled: true,
@@ -257,6 +332,7 @@ describe("webui API helpers", () => {
pinned_keys: ["websocket:chat-1"],
archived_keys: ["websocket:old"],
title_overrides: { "websocket:chat-1": "Release" },
project_name_overrides: { "/Users/me/nanobot": "Core" },
tags_by_key: {},
collapsed_groups: {},
view: {
@@ -292,9 +368,39 @@ describe("webui API helpers", () => {
expect(JSON.parse(encodedState ?? "{}")).toMatchObject({
pinned_keys: ["websocket:chat-1"],
title_overrides: { "websocket:chat-1": "Release" },
project_name_overrides: { "/Users/me/nanobot": "Core" },
});
});
it("fetches workspace project state", async () => {
const payload = {
schema_version: 1,
default_access_mode: "default" as const,
default_scope: {
project_path: "/tmp/workspace",
project_name: "workspace",
access_mode: "restricted" as const,
restrict_to_workspace: true,
},
controls: {
can_change_project: true,
can_use_full_access: true,
},
};
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => payload,
} as Response);
await expect(fetchWorkspaces("tok")).resolves.toEqual(payload);
expect(fetch).toHaveBeenCalledWith(
"/api/workspaces",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
);
});
it("maps generated session titles from the sessions list", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
+25 -32
View File
@@ -12,6 +12,8 @@ const updateUrlSpy = vi.fn();
const attachSpy = vi.fn();
const runStatusHandlers = new Set<(chatId: string, startedAt: number | null) => void>();
let mockSessions: ChatSummary[] = [];
const HERO_GREETING_PATTERN =
/What should we work on\?|Where should we start\?|What are we building today\?|What should we tackle together\?/;
function jsonResponse(body: unknown): Response {
return {
@@ -97,6 +99,9 @@ function baseSettingsPayload() {
},
advanced: {
restrict_to_workspace: false,
webui_allow_local_service_access: true,
webui_default_access_mode: "default",
private_service_protection_enabled: true,
ssrf_whitelist_count: 0,
mcp_server_count: 0,
exec_enabled: true,
@@ -412,29 +417,7 @@ describe("App layout", () => {
const encoded = new URLSearchParams(updateUrl?.split("?", 2)[1]).get("state");
expect(JSON.parse(encoded ?? "{}").view.show_archived).toBe(true);
fireEvent.pointerDown(within(sidebar).getByRole("button", { name: "View" }), {
button: 0,
ctrlKey: false,
});
fireEvent.click(await screen.findByText("Compact list"));
await waitFor(() => {
const lastUpdateUrl = vi.mocked(fetch).mock.calls
.map(([url]) => String(url))
.filter((url) => url.startsWith("/api/webui/sidebar-state/update?"))
.at(-1);
const lastEncoded = new URLSearchParams(lastUpdateUrl?.split("?", 2)[1]).get("state");
expect(JSON.parse(lastEncoded ?? "{}").view.density).toBe("compact");
});
fireEvent.click(screen.getByText("Title A-Z"));
await waitFor(() => {
const lastUpdateUrl = vi.mocked(fetch).mock.calls
.map(([url]) => String(url))
.filter((url) => url.startsWith("/api/webui/sidebar-state/update?"))
.at(-1);
const lastEncoded = new URLSearchParams(lastUpdateUrl?.split("?", 2)[1]).get("state");
expect(JSON.parse(lastEncoded ?? "{}").view.sort).toBe("title_asc");
});
expect(within(sidebar).queryByRole("button", { name: "View" })).not.toBeInTheDocument();
});
it("sorts chats by displayed title when A-Z is persisted", async () => {
@@ -785,6 +768,9 @@ describe("App layout", () => {
},
advanced: {
restrict_to_workspace: false,
webui_allow_local_service_access: true,
webui_default_access_mode: "default",
private_service_protection_enabled: true,
ssrf_whitelist_count: 0,
mcp_server_count: 0,
exec_enabled: true,
@@ -828,8 +814,8 @@ describe("App layout", () => {
expect(within(settingsNav).queryByRole("button", { name: "Providers" })).not.toBeInTheDocument();
expect(within(settingsNav).getByRole("button", { name: "Image" })).toBeInTheDocument();
expect(within(settingsNav).getByRole("button", { name: "Web" })).toBeInTheDocument();
expect(within(settingsNav).getByRole("button", { name: "Apps" })).toBeInTheDocument();
expect(within(settingsNav).getByRole("button", { name: "Advanced" })).toBeInTheDocument();
expect(within(settingsNav).queryByRole("button", { name: "Apps" })).not.toBeInTheDocument();
expect(within(settingsNav).getByRole("button", { name: "Security" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Sign out" })).toBeInTheDocument();
fireEvent.click(within(settingsNav).getByRole("button", { name: "Appearance" }));
expect(screen.getByText("Brand logos")).toBeInTheDocument();
@@ -906,9 +892,13 @@ describe("App layout", () => {
expect(screen.getByText("BSAo••••ew20")).toBeInTheDocument();
expect(screen.queryByDisplayValue("unsaved-brave-key")).not.toBeInTheDocument();
fireEvent.click(within(settingsNav).getByRole("button", { name: "Runtime" }));
fireEvent.click(within(settingsNav).getByRole("button", { name: "System" }));
expect(screen.getByText("Bot name")).toBeInTheDocument();
expect(screen.queryByText("Tool hint length")).not.toBeInTheDocument();
expect(screen.queryByText("Heartbeat")).not.toBeInTheDocument();
expect(screen.queryByText("Dream")).not.toBeInTheDocument();
expect(screen.queryByText("Unified session")).not.toBeInTheDocument();
expect(screen.getByText("Default workspace")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
fireEvent.pointerDown(screen.getByRole("button", { name: "UTC" }));
expect(screen.getByPlaceholderText("Search timezone")).toBeInTheDocument();
@@ -1071,6 +1061,9 @@ describe("App layout", () => {
},
advanced: {
restrict_to_workspace: false,
webui_allow_local_service_access: true,
webui_default_access_mode: "default",
private_service_protection_enabled: true,
ssrf_whitelist_count: 0,
mcp_server_count: 0,
exec_enabled: true,
@@ -1097,7 +1090,7 @@ describe("App layout", () => {
fireEvent.click(screen.getByRole("button", { name: "Back to chat" }));
await waitFor(() => expect(document.title).toBe("nanobot"));
expect(screen.getByText("What can I do for you?")).toBeInTheDocument();
expect(screen.getByText(HERO_GREETING_PATTERN)).toBeInTheDocument();
});
it("filters sessions in the centered search dialog", async () => {
@@ -1266,23 +1259,23 @@ describe("App layout", () => {
expect(toggleThemeSpy).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole("button", { name: "Collapse sidebar" }));
const desktopAside = container.querySelector("aside.lg\\:block") as HTMLElement;
await waitFor(() => expect(desktopAside.style.width).toBe("56px"));
const sidebarAside = container.querySelector("aside.lg\\:block") as HTMLElement;
await waitFor(() => expect(sidebarAside.style.width).toBe("56px"));
expect(screen.queryByRole("button", { name: "Start a new chat" })).not.toBeInTheDocument();
const rail = screen.getByRole("navigation", { name: "Sidebar navigation" });
expect(within(rail).getByRole("button", { name: "New chat" })).toBeInTheDocument();
expect(within(rail).getByRole("button", { name: "Search" })).toBeInTheDocument();
expect(within(rail).getByRole("button", { name: "View" })).toBeInTheDocument();
expect(within(rail).queryByRole("button", { name: "View" })).not.toBeInTheDocument();
expect(within(rail).queryByText("Existing chat")).not.toBeInTheDocument();
fireEvent.click(within(rail).getByRole("button", { name: "Toggle sidebar" }));
await waitFor(() => expect(desktopAside.style.width).toBe("272px"));
await waitFor(() => expect(sidebarAside.style.width).toBe("272px"));
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
fireEvent.click(within(sidebar).getByRole("button", { name: "New chat" }));
expect(createChatSpy).not.toHaveBeenCalled();
expect(screen.getByText("What can I do for you?")).toBeInTheDocument();
expect(screen.getByText(HERO_GREETING_PATTERN)).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Start a new chat" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Toggle theme from header" })).toBeInTheDocument();
expect(within(sidebar).getByRole("button", { name: "Settings" })).toBeInTheDocument();
+23
View File
@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { deriveWsUrl } from "@/lib/bootstrap";
describe("bootstrap helpers", () => {
it("prefers the server-provided websocket URL over the current dev host", () => {
expect(deriveWsUrl("/", "tok en", "ws://127.0.0.1:8765/")).toBe(
"ws://127.0.0.1:8765/?token=tok%20en",
);
});
it("preserves the host socket bridge URL", () => {
expect(deriveWsUrl("/", "tok en", "nanobot-host://engine/")).toBe(
"nanobot-host://engine/?token=tok%20en",
);
});
it("falls back to the current window host for legacy bootstrap payloads", () => {
expect(deriveWsUrl("/", "tok")).toBe(
"ws://localhost:3000/?token=tok",
);
});
});
+257
View File
@@ -0,0 +1,257 @@
import { fireEvent, render, screen, within } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { ChatList } from "@/components/ChatList";
import type { ChatSummary } from "@/lib/types";
function session(overrides: Partial<ChatSummary>): ChatSummary {
const chatId = overrides.chatId ?? "chat";
return {
key: `websocket:${chatId}`,
channel: "websocket",
chatId,
createdAt: "2026-05-20T10:00:00Z",
updatedAt: "2026-05-20T10:00:00Z",
preview: "",
...overrides,
};
}
describe("ChatList", () => {
it("groups WebUI chats by workspace project while preserving in-project sorting and activity", () => {
const sessions = [
session({
chatId: "zeta",
title: "Zeta task",
updatedAt: "2026-05-20T12:00:00Z",
workspaceScope: {
project_path: "/Users/me/nanobot",
project_name: "nanobot",
access_mode: "restricted",
},
}),
session({
chatId: "alpha",
title: "Alpha task",
updatedAt: "2026-05-20T11:00:00Z",
workspaceScope: {
project_path: "/Users/me/nanobot",
project_name: "nanobot",
access_mode: "restricted",
},
}),
session({
chatId: "bench",
title: "Bench task",
updatedAt: "2026-05-21T09:00:00Z",
workspaceScope: {
project_path: "/Users/me/nanobot-bench",
project_name: "nanobot-bench",
access_mode: "full",
},
}),
];
render(
<ChatList
sessions={sessions}
activeKey="websocket:alpha"
onSelect={vi.fn()}
onRequestDelete={vi.fn()}
onTogglePin={vi.fn()}
onRequestRename={vi.fn()}
onToggleArchive={vi.fn()}
sort="title_asc"
showTimestamps
runningChatIds={["zeta"]}
/>,
);
const nanobotSection = screen.getByRole("region", { name: "nanobot" });
const nanobotText = nanobotSection.textContent ?? "";
expect(screen.getByRole("region", { name: "nanobot-bench" })).toBeInTheDocument();
expect(within(nanobotSection).getByText("Alpha task")).toBeInTheDocument();
expect(within(nanobotSection).getByText("Zeta task")).toBeInTheDocument();
expect(nanobotText.indexOf("Alpha task")).toBeLessThan(nanobotText.indexOf("Zeta task"));
expect(within(nanobotSection).getByLabelText("Agent running")).toBeInTheDocument();
expect(screen.queryByText("Today")).not.toBeInTheDocument();
});
it("keeps default workspace chats in the Chats section instead of a project folder", () => {
const sessions = [
session({
chatId: "default",
title: "Default workspace chat",
updatedAt: "2026-05-21T10:00:00Z",
workspaceScope: {
project_path: "/Users/me/.nanobot/workspace",
project_name: "workspace",
access_mode: "restricted",
},
}),
session({
chatId: "project",
title: "Project chat",
updatedAt: "2026-05-21T11:00:00Z",
workspaceScope: {
project_path: "/Users/me/nanobot",
project_name: "nanobot",
access_mode: "restricted",
},
}),
];
render(
<ChatList
sessions={sessions}
activeKey="websocket:default"
onSelect={vi.fn()}
onRequestDelete={vi.fn()}
onTogglePin={vi.fn()}
onRequestRename={vi.fn()}
onToggleArchive={vi.fn()}
defaultWorkspacePath="/Users/me/.nanobot/workspace"
showTimestamps
/>,
);
expect(screen.getByText("Projects")).toBeInTheDocument();
expect(screen.getByRole("region", { name: "nanobot" })).toBeInTheDocument();
expect(screen.queryByRole("region", { name: "workspace" })).not.toBeInTheDocument();
const chatsSection = screen.getByRole("region", { name: "Chats" });
expect(within(chatsSection).getByText("Default workspace chat")).toBeInTheDocument();
expect(within(chatsSection).queryByText("Project chat")).not.toBeInTheDocument();
});
it("can collapse a project group and keeps project rename separate from chat titles", async () => {
const onToggleGroup = vi.fn();
const onRequestRenameProject = vi.fn();
const onNewChatInProject = vi.fn();
const sessions = [
session({
chatId: "alpha",
title: "Alpha task",
workspaceScope: {
project_path: "/Users/me/nanobot",
project_name: "nanobot",
access_mode: "restricted",
},
}),
];
render(
<ChatList
sessions={sessions}
activeKey="websocket:alpha"
onSelect={vi.fn()}
onRequestDelete={vi.fn()}
onTogglePin={vi.fn()}
onRequestRename={vi.fn()}
onToggleArchive={vi.fn()}
onToggleGroup={onToggleGroup}
onRequestRenameProject={onRequestRenameProject}
onNewChatInProject={onNewChatInProject}
projectNameOverrides={{ "/Users/me/nanobot": "Photos" }}
collapsedGroups={{ "project:/Users/me/nanobot": true }}
/>,
);
const projectSection = screen.getByRole("region", { name: "Photos" });
fireEvent.click(within(projectSection).getByRole("button", { name: "Photos" }));
expect(onToggleGroup).toHaveBeenCalledWith("project:/Users/me/nanobot");
expect(within(projectSection).queryByText("Alpha task")).not.toBeInTheDocument();
fireEvent.click(
within(projectSection).getByRole("button", { name: "Start a new chat in Photos" }),
);
expect(onNewChatInProject).toHaveBeenCalledWith("/Users/me/nanobot", "Photos");
expect(onToggleGroup).toHaveBeenCalledTimes(1);
fireEvent.pointerDown(
within(projectSection).getByLabelText("Chat actions for Photos"),
{ button: 0 },
);
fireEvent.click(await screen.findByRole("menuitem", { name: "Rename" }));
expect(onRequestRenameProject).toHaveBeenCalledWith("/Users/me/nanobot", "Photos");
});
it("hides the completed dot for the active chat", () => {
const sessions = [
session({
chatId: "active",
title: "Active task",
}),
session({
chatId: "done",
title: "Done task",
}),
];
render(
<ChatList
sessions={sessions}
activeKey="websocket:active"
onSelect={vi.fn()}
onRequestDelete={vi.fn()}
onTogglePin={vi.fn()}
onRequestRename={vi.fn()}
onToggleArchive={vi.fn()}
completedChatIds={["active", "done"]}
/>,
);
expect(screen.getAllByLabelText("Agent finished")).toHaveLength(1);
});
it("folds long default workspace chats and can show all", () => {
const sessions = Array.from({ length: 10 }, (_, index) =>
session({
chatId: `chat-${index}`,
title: `Chat ${index}`,
updatedAt: `2026-05-21T10:${String(index).padStart(2, "0")}:00Z`,
workspaceScope: {
project_path: "/Users/me/.nanobot/workspace",
project_name: "workspace",
access_mode: "restricted",
},
}),
);
const onToggleGroup = vi.fn();
const baseProps = {
sessions,
activeKey: null,
onSelect: vi.fn(),
onRequestDelete: vi.fn(),
onTogglePin: vi.fn(),
onRequestRename: vi.fn(),
onToggleArchive: vi.fn(),
onToggleGroup,
defaultWorkspacePath: "/Users/me/.nanobot/workspace",
};
const { rerender } = render(<ChatList {...baseProps} />);
const chatsSection = screen.getByRole("region", { name: "Chats" });
expect(within(chatsSection).getByText("Chat 9")).toBeInTheDocument();
expect(within(chatsSection).getByText("Chat 2")).toBeInTheDocument();
expect(within(chatsSection).queryByText("Chat 1")).not.toBeInTheDocument();
expect(within(chatsSection).queryByRole("button", { name: "Show all" })).not.toBeInTheDocument();
fireEvent.click(within(chatsSection).getByRole("button", { name: "2 hidden chats" }));
expect(onToggleGroup).toHaveBeenCalledWith("workspace:chats");
rerender(
<ChatList
{...baseProps}
collapsedGroups={{ "workspace:chats": false }}
/>,
);
expect(within(chatsSection).getByText("Chat 0")).toBeInTheDocument();
expect(within(chatsSection).getByRole("button", { name: "Show less" })).toBeInTheDocument();
});
});
+16 -5
View File
@@ -5,9 +5,11 @@ import { describe, expect, it, vi } from "vitest";
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
import { ThreadComposer } from "@/components/thread/ThreadComposer";
import { resources } from "@/i18n";
import { LOCALE_STORAGE_KEY, resolveInitialLocale } from "@/i18n/config";
const QUICK_ACTION_KEYS = ["plan", "analyze", "brainstorm", "code", "summarize", "more"];
const IMAGE_QUICK_ACTION_KEYS = ["icon", "sticker", "poster", "product", "portrait", "edit"];
const HERO_GREETING_KEYS = ["workOn", "start", "build", "tackle"];
const SLASH_COMMAND_KEYS = [
"new",
"stop",
@@ -27,12 +29,11 @@ const SETTINGS_NAV_KEYS = [
"appearance",
"models",
"image",
"web",
"browser",
"apps",
"runtime",
"advanced",
];
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value);
}
@@ -61,6 +62,14 @@ function interpolationKeys(value: unknown): string[] {
}
describe("webui i18n", () => {
it("defaults to English until the user chooses another language", () => {
localStorage.removeItem(LOCALE_STORAGE_KEY);
expect(resolveInitialLocale()).toBe("en");
localStorage.setItem(LOCALE_STORAGE_KEY, "zh-CN");
expect(resolveInitialLocale()).toBe("zh-CN");
});
it("switches UI copy and document locale through the language switcher", async () => {
const user = userEvent.setup();
@@ -97,10 +106,12 @@ describe("webui i18n", () => {
expect(screen.getByLabelText("メッセージ入力欄")).toBeInTheDocument();
});
it("keeps welcome quick actions localized for every registered locale", () => {
it("keeps empty landing resources localized for every registered locale", () => {
for (const resource of Object.values(resources)) {
const empty = resource.common.thread.empty;
expect(empty.greeting).toBeTruthy();
for (const key of HERO_GREETING_KEYS) {
expect(empty.greetings[key as keyof typeof empty.greetings]).toBeTruthy();
}
for (const key of QUICK_ACTION_KEYS) {
const action = empty.quickActions[key as keyof typeof empty.quickActions];
expect(action.title).toBeTruthy();
@@ -182,7 +193,7 @@ describe("webui i18n", () => {
it("keeps Simplified Chinese settings overview copy localized", () => {
const settings = resources["zh-CN"].common.settings;
expect(settings.nav.web).toBe("网页");
expect(settings.nav.browser).toBe("网页");
expect(settings.sections.webSearch).toBe("网页搜索");
expect(settings.byok.tabs.webSearch).toBe("网页搜索");
expect(settings.overview.webSearch).toBe("网页搜索");
+115 -1
View File
@@ -132,6 +132,30 @@ describe("NanobotClient", () => {
expect(client.getRunStartedAt("chat-strip")).toBeNull();
});
it("clears run strip when a turn_end arrives without idle", () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
const handler = vi.fn();
client.onRunStatus(handler);
client.connect();
lastSocket().fakeOpen();
lastSocket().fakeMessage({
event: "goal_status",
chat_id: "chat-strip",
status: "running",
started_at: 12_345,
});
lastSocket().fakeMessage({
event: "turn_end",
chat_id: "chat-strip",
});
expect(client.getRunStartedAt("chat-strip")).toBeNull();
expect(handler).toHaveBeenLastCalledWith("chat-strip", null);
});
it("notifies run status subscribers and replays running chats", () => {
const client = new NanobotClient({
url: "ws://test",
@@ -268,9 +292,19 @@ describe("NanobotClient", () => {
event: "session_updated",
chat_id: "chat-title",
scope: "metadata",
workspace_scope: {
project_path: "/tmp/project",
project_name: "project",
access_mode: "restricted",
restrict_to_workspace: true,
},
});
expect(globalHandler).toHaveBeenCalledWith("chat-title", "metadata");
expect(globalHandler).toHaveBeenCalledWith(
"chat-title",
"metadata",
expect.objectContaining({ project_path: "/tmp/project" }),
);
expect(chatHandler).not.toHaveBeenCalled();
});
@@ -288,6 +322,40 @@ describe("NanobotClient", () => {
await expect(promise).resolves.toBe("fresh-id");
});
it("serializes workspace scope for new chats and messages", async () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
const workspaceScope = {
project_path: "/tmp/project",
project_name: "project",
access_mode: "full" as const,
restrict_to_workspace: false,
};
client.connect();
lastSocket().fakeOpen();
const promise = client.newChat(1_000, workspaceScope);
expect(lastSocket().sent).toContain(
JSON.stringify({ type: "new_chat", workspace_scope: workspaceScope }),
);
lastSocket().fakeMessage({ event: "attached", chat_id: "fresh-id" });
await expect(promise).resolves.toBe("fresh-id");
client.sendMessage("fresh-id", "hello", undefined, { workspaceScope });
expect(lastSocket().sent).toContain(
JSON.stringify({
type: "message",
chat_id: "fresh-id",
content: "hello",
workspace_scope: workspaceScope,
webui: true,
}),
);
});
it("queues sends while connecting and flushes on open", () => {
const client = new NanobotClient({
url: "ws://test",
@@ -536,6 +604,52 @@ describe("NanobotClient", () => {
expect(errors).toEqual([{ kind: "message_too_big" }]);
});
it("emits workspace scope rejection errors from server frames", () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
const errors: Array<{ kind: string; reason?: string; chatId?: string }> = [];
client.onError((e) => errors.push(e));
client.connect();
lastSocket().fakeOpen();
lastSocket().fakeMessage({
event: "error",
chat_id: "chat-a",
detail: "workspace_scope_rejected",
reason: "chat_running",
});
expect(errors).toEqual([
{
kind: "workspace_scope_rejected",
reason: "chat_running",
chatId: "chat-a",
},
]);
});
it("rejects pending new chats when workspace scope is rejected", async () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
client.connect();
lastSocket().fakeOpen();
const pending = client.newChat(5_000, {
project_path: "/missing",
project_name: "missing",
access_mode: "restricted",
});
lastSocket().fakeMessage({
event: "error",
detail: "workspace_scope_rejected",
reason: "project_path must be an existing directory",
});
await expect(pending).rejects.toThrow("workspace_scope_rejected");
});
it("isolates throwing error handlers so reconnect bookkeeping still runs", async () => {
const client = new NanobotClient({
url: "ws://test",
+6
View File
@@ -34,4 +34,10 @@ describe("provider brand logos", () => {
expect(providerBrand("zhipu")?.logoUrls).toContain("https://z.ai/favicon.ico");
expect(providerBrand("zhipu")?.initials).toBe("Z");
});
it("uses official first-party assets for LongCat and Xiaomi MIMO", () => {
expect(providerBrand("longcat")?.logoUrls[0]).toBe("https://www.longcatai.org/favicon.svg");
expect(providerBrand("xiaomi_mimo")?.logoUrls[0]).toBe("https://mimo.xiaomi.com/mimo-v2-pro/assets/logo.svg");
expect(providerBrand("mimo")?.logoUrls[0]).toBe("https://mimo.xiaomi.com/mimo-v2-pro/assets/logo.svg");
});
});
+106 -3
View File
@@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { SettingsView } from "@/components/settings/SettingsView";
import { ClientProvider } from "@/providers/ClientProvider";
import type { SettingsPayload } from "@/lib/types";
function jsonResponse(body: unknown): Response {
return {
@@ -12,7 +13,7 @@ function jsonResponse(body: unknown): Response {
} as Response;
}
function settingsPayload() {
function settingsPayload(): SettingsPayload {
return {
agent: {
model: "openai/gpt-4o",
@@ -88,6 +89,9 @@ function settingsPayload() {
},
advanced: {
restrict_to_workspace: false,
webui_allow_local_service_access: true,
webui_default_access_mode: "default",
private_service_protection_enabled: true,
ssrf_whitelist_count: 0,
mcp_server_count: 0,
exec_enabled: true,
@@ -115,15 +119,21 @@ const installedAnyGen = {
skill_installed: true,
};
function renderSettingsView() {
function renderSettingsView(
options: {
initialSection?: "apps" | "advanced";
onSettingsChange?: (payload: SettingsPayload) => void;
} = {},
) {
render(
<ClientProvider client={{} as never} token="tok">
<SettingsView
theme="light"
initialSection="apps"
initialSection={options.initialSection ?? "apps"}
onToggleTheme={() => {}}
onBackToChat={() => {}}
onModelNameChange={() => {}}
onSettingsChange={options.onSettingsChange}
/>
</ClientProvider>,
);
@@ -188,4 +198,97 @@ describe("SettingsView Apps catalog", () => {
expect(screen.queryByText("Uninstalled CLI for AnyGen.")).not.toBeInTheDocument();
});
it("publishes the latest settings payload to the shell", async () => {
const payload = settingsPayload();
const onSettingsChange = vi.fn();
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({ onSettingsChange });
await waitFor(() => expect(onSettingsChange).toHaveBeenCalledWith(payload));
});
it("saves network safety without exposing technical SSRF copy", async () => {
const payload = settingsPayload();
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 });
}
if (url === "/api/settings/network-safety/update?webui_allow_local_service_access=false&webui_default_access_mode=default") {
return jsonResponse({
...payload,
advanced: { ...payload.advanced, webui_allow_local_service_access: false },
requires_restart: true,
restart_required_sections: ["runtime"],
});
}
return { ok: false, status: 404, json: async () => ({}) } as Response;
});
vi.stubGlobal("fetch", fetchMock);
renderSettingsView({ initialSection: "advanced" });
expect(await screen.findByText("Web safety")).toBeInTheDocument();
expect(screen.queryByText(/SSRF/i)).not.toBeInTheDocument();
expect(screen.queryByText("Private Service Protection")).not.toBeInTheDocument();
expect(screen.getByText("Default access")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Restricted" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Default Permission" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Full Access" })).toBeInTheDocument();
fireEvent.click(screen.getByRole("switch", { name: "Local services" }));
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() =>
expect(fetchMock).toHaveBeenCalledWith(
"/api/settings/network-safety/update?webui_allow_local_service_access=false&webui_default_access_mode=default",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
),
);
});
it("uses native host safety copy on the native surface", async () => {
const payload = {
...settingsPayload(),
surface: "native" as const,
runtime_surface: "native" as const,
};
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: "advanced" });
expect(await screen.findByText("App safety")).toBeInTheDocument();
expect(screen.queryByText("Web safety")).not.toBeInTheDocument();
expect(screen.getByText("Allow Full Access shell commands to reach services on this Mac.")).toBeInTheDocument();
});
});
+247 -7
View File
@@ -98,7 +98,7 @@ const MCP_PRESETS: McpPresetInfo[] = [
description: "Design context",
docs_url: "https://figma.com",
transport: "streamableHttp",
requires: "Figma desktop",
requires: "Figma local app",
note: "",
install_supported: true,
installed: true,
@@ -115,6 +115,7 @@ const ORIGINAL_INNER_HEIGHT = window.innerHeight;
afterEach(() => {
vi.restoreAllMocks();
Reflect.deleteProperty(window, "nanobotHost");
window.localStorage.clear();
Object.defineProperty(window, "innerHeight", {
value: ORIGINAL_INNER_HEIGHT,
@@ -182,6 +183,167 @@ describe("ThreadComposer", () => {
expect(input.parentElement?.parentElement?.className).toContain("shadow-[0_12px_30px_rgba(15,23,42,0.07)]");
expect(screen.getByRole("button", { name: "Attach image" }).className).toContain("bg-card");
expect(screen.getByRole("button", { name: "Send message" }).className).toContain("bg-foreground");
expect(screen.queryByText(/Enter to send/)).not.toBeInTheDocument();
});
it("renders and changes workspace access mode", async () => {
const onWorkspaceScopeChange = vi.fn();
render(
<ThreadComposer
onSend={vi.fn()}
placeholder="Type your message..."
workspaceScope={{
project_path: "/tmp/project",
project_name: "project",
access_mode: "restricted",
restrict_to_workspace: true,
}}
workspaceControls={{ can_change_project: true, can_use_full_access: true }}
onWorkspaceScopeChange={onWorkspaceScopeChange}
/>,
);
fireEvent.pointerDown(screen.getByRole("button", { name: "Workspace access mode" }));
fireEvent.click(await screen.findByRole("menuitem", { name: /Full Access/ }));
expect(onWorkspaceScopeChange).toHaveBeenCalledWith(
expect.objectContaining({
project_path: "/tmp/project",
access_mode: "full",
restrict_to_workspace: false,
}),
);
});
it("keeps project selection as a compact composer dropdown", async () => {
const onWorkspaceScopeChange = vi.fn();
const defaultScope = {
project_path: "/Users/test/.nanobot/workspace",
project_name: "workspace",
access_mode: "restricted" as const,
restrict_to_workspace: true,
};
render(
<ThreadComposer
onSend={vi.fn()}
placeholder="Ask anything..."
variant="hero"
workspaceScope={{
...defaultScope,
access_mode: "full",
restrict_to_workspace: false,
}}
workspaceDefaultScope={defaultScope}
workspaceControls={{ can_change_project: true, can_use_full_access: true }}
onWorkspaceScopeChange={onWorkspaceScopeChange}
/>,
);
fireEvent.pointerDown(screen.getByRole("button", { name: "Choose project" }));
expect(await screen.findByRole("menuitem", { name: /Default workspace/ })).toBeInTheDocument();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
const input = screen.getByLabelText("Paste path");
fireEvent.change(input, { target: { value: "relative/project" } });
fireEvent.click(screen.getByRole("button", { name: "Use Path" }));
expect(screen.getByRole("alert")).toHaveTextContent(
"Enter an absolute folder path on this machine.",
);
expect(onWorkspaceScopeChange).not.toHaveBeenCalled();
fireEvent.change(input, { target: { value: "/Users/test/project-alpha" } });
fireEvent.click(screen.getByRole("button", { name: "Use Path" }));
expect(onWorkspaceScopeChange).toHaveBeenCalledWith(expect.objectContaining({
project_path: "/Users/test/project-alpha",
project_name: "project-alpha",
access_mode: "full",
restrict_to_workspace: false,
}));
fireEvent.pointerDown(screen.getByRole("button", { name: "Choose project" }));
const reopenedInput = await screen.findByLabelText("Paste path");
fireEvent.change(reopenedInput, { target: { value: "~/Pictures/Photos" } });
fireEvent.click(screen.getByRole("button", { name: "Use Path" }));
expect(onWorkspaceScopeChange).toHaveBeenLastCalledWith(expect.objectContaining({
project_path: "~/Pictures/Photos",
project_name: "Photos",
access_mode: "full",
restrict_to_workspace: false,
}));
});
it("uses the native folder picker for project selection on native host", async () => {
const onWorkspaceScopeChange = vi.fn();
const pickFolder = vi.fn().mockResolvedValue("/Users/test/native-project");
const defaultScope = {
project_path: "/Users/test/.nanobot/workspace",
project_name: "workspace",
access_mode: "full" as const,
restrict_to_workspace: false,
};
Object.defineProperty(window, "nanobotHost", {
configurable: true,
value: {
getRuntimeInfo: vi.fn(),
restartEngine: vi.fn(),
pickFolder,
openLogs: vi.fn(),
exportDiagnostics: vi.fn(),
},
});
render(
<ThreadComposer
onSend={vi.fn()}
placeholder="Ask anything..."
variant="hero"
workspaceScope={defaultScope}
workspaceDefaultScope={defaultScope}
workspaceControls={{ can_change_project: true, can_use_full_access: true }}
onWorkspaceScopeChange={onWorkspaceScopeChange}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Choose project" }));
await waitFor(() => expect(pickFolder).toHaveBeenCalled());
expect(screen.queryByRole("menuitem", { name: /Default workspace/ })).not.toBeInTheDocument();
expect(onWorkspaceScopeChange).toHaveBeenCalledWith(expect.objectContaining({
project_path: "/Users/test/native-project",
project_name: "native-project",
access_mode: "full",
restrict_to_workspace: false,
}));
});
it("uses the web path menu when no native host picker is available", async () => {
const defaultScope = {
project_path: "/Users/test/.nanobot/workspace",
project_name: "workspace",
access_mode: "full" as const,
restrict_to_workspace: false,
};
render(
<ThreadComposer
onSend={vi.fn()}
placeholder="Ask anything..."
variant="hero"
workspaceScope={defaultScope}
workspaceDefaultScope={defaultScope}
workspaceControls={{ can_change_project: true, can_use_full_access: true }}
onWorkspaceScopeChange={vi.fn()}
/>,
);
fireEvent.pointerDown(screen.getByRole("button", { name: "Choose project" }));
expect(await screen.findByRole("menuitem", { name: /Default workspace/ })).toBeInTheDocument();
expect(screen.getByLabelText("Paste path")).toBeInTheDocument();
});
it("shows turn run timer when runStartedAt is set", () => {
@@ -242,12 +404,7 @@ describe("ThreadComposer", () => {
const palette = screen.getByRole("listbox", { name: "Slash commands" });
expect(palette).toBeInTheDocument();
expect(palette).toHaveStyle({ maxHeight: "288px" });
expect(screen.getByRole("option", { name: /\/stop/i })).toHaveAttribute(
"aria-selected",
"true",
);
fireEvent.keyDown(input, { key: "ArrowDown" });
expect(screen.queryByRole("option", { name: /\/stop/i })).not.toBeInTheDocument();
expect(screen.getByRole("option", { name: /\/history/i })).toHaveAttribute(
"aria-selected",
"true",
@@ -310,6 +467,7 @@ describe("ThreadComposer", () => {
expect(onStop).toHaveBeenCalledTimes(1);
expect(input).toHaveValue("");
expect(window.localStorage.getItem("nanobot.webui.slashCommandRecents")).toBeNull();
});
it("orders recent slash commands first for the blank slash menu", () => {
@@ -333,6 +491,42 @@ describe("ThreadComposer", () => {
expect(screen.getByText("Recent")).toBeInTheDocument();
});
it("keeps keyboard-selected slash options visible while navigating", () => {
const scrollIntoView = vi.fn();
const originalScrollIntoView = HTMLElement.prototype.scrollIntoView;
HTMLElement.prototype.scrollIntoView = scrollIntoView;
try {
render(
<ThreadComposer
onSend={vi.fn()}
placeholder="Type your message..."
slashCommands={Array.from({ length: 8 }, (_, index) => ({
command: `/cmd-${index}`,
title: `Command ${index}`,
description: `Description ${index}`,
icon: "activity",
}))}
/>,
);
const input = screen.getByLabelText("Message input");
fireEvent.change(input, { target: { value: "/" } });
scrollIntoView.mockClear();
fireEvent.keyDown(input, { key: "ArrowDown" });
fireEvent.keyDown(input, { key: "ArrowDown" });
expect(screen.getByRole("option", { name: /\/cmd-2/i })).toHaveAttribute(
"aria-selected",
"true",
);
expect(scrollIntoView).toHaveBeenLastCalledWith({ block: "nearest" });
} finally {
HTMLElement.prototype.scrollIntoView = originalScrollIntoView;
}
});
it("opens the CLI app mention palette and inserts the selected app", () => {
const onSend = vi.fn();
render(
@@ -381,6 +575,52 @@ describe("ThreadComposer", () => {
});
});
it("keeps keyboard-selected mention options visible while navigating", () => {
const scrollIntoView = vi.fn();
const originalScrollIntoView = HTMLElement.prototype.scrollIntoView;
HTMLElement.prototype.scrollIntoView = scrollIntoView;
try {
render(
<ThreadComposer
onSend={vi.fn()}
placeholder="Type your message..."
cliApps={Array.from({ length: 8 }, (_, index) => ({
name: `app-${index}`,
display_name: `App ${index}`,
category: "test",
description: "Test app",
requires: "",
source: "harness",
entry_point: `app-${index}`,
install_supported: true,
installed: true,
available: true,
status: "installed",
logo_url: null,
brand_color: "#111827",
skill_installed: true,
}))}
/>,
);
const input = screen.getByLabelText("Message input");
fireEvent.change(input, { target: { value: "@", selectionStart: 1 } });
scrollIntoView.mockClear();
fireEvent.keyDown(input, { key: "ArrowDown" });
fireEvent.keyDown(input, { key: "ArrowDown" });
expect(screen.getByRole("option", { name: /@app-2/i })).toHaveAttribute(
"aria-selected",
"true",
);
expect(scrollIntoView).toHaveBeenLastCalledWith({ block: "nearest" });
} finally {
HTMLElement.prototype.scrollIntoView = originalScrollIntoView;
}
});
it("completes a CLI app mention with Tab and adds exactly one trailing space", () => {
render(
<ThreadComposer
+146
View File
@@ -258,6 +258,152 @@ describe("ThreadMessages", () => {
expect(screen.getByText("final answer")).toBeInTheDocument();
});
it("keeps late activity above the live assistant answer while streaming", () => {
const messages: UIMessage[] = [
{
id: "t0",
role: "tool",
kind: "trace",
content: "Thinking",
traces: ["Thinking"],
activitySegmentId: "seg-live",
createdAt: 1,
},
{
id: "a1",
role: "assistant",
content: "partial answer",
isStreaming: true,
createdAt: 2,
},
{
id: "t1",
role: "tool",
kind: "trace",
content: "Reading api.github.com/repos/NousResearch/hermes-agent",
traces: ["Reading api.github.com/repos/NousResearch/hermes-agent"],
activitySegmentId: "seg-live",
createdAt: 3,
},
];
const units = buildDisplayUnits(messages);
expect(units).toHaveLength(2);
expect(units[0].type === "cluster" ? units[0].messages.map((m) => m.id) : []).toEqual([
"t0",
"t1",
]);
expect(units[1]).toMatchObject({
type: "single",
message: {
id: "a1",
content: "partial answer",
},
});
render(<ThreadMessages messages={messages} isStreaming />);
const activity = screen.getByRole("button", { name: /working/i });
const answer = screen.getByText("partial answer");
expect(activity.compareDocumentPosition(answer) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
it("keeps late activity above a completed assistant answer", () => {
const messages: UIMessage[] = [
{
id: "r1",
role: "assistant",
content: "",
reasoning: "checking weather",
activitySegmentId: "seg-late",
createdAt: 1,
},
{
id: "a1",
role: "assistant",
content: "Hong Kong is hot today.",
latencyMs: 161_000,
createdAt: 2,
},
{
id: "t1",
role: "tool",
kind: "trace",
content: "Reading hko.gov.hk/en/wxinfo/currwx/current.htm",
traces: ["Reading hko.gov.hk/en/wxinfo/currwx/current.htm"],
activitySegmentId: "seg-late",
createdAt: 3,
},
];
const units = buildDisplayUnits(messages);
expect(units).toHaveLength(2);
expect(units[0].type === "cluster" ? units[0].messages.map((m) => m.id) : []).toEqual([
"r1",
"t1",
]);
expect(units[1]).toMatchObject({
type: "single",
message: {
id: "a1",
content: "Hong Kong is hot today.",
},
});
render(<ThreadMessages messages={messages} isStreaming={false} />);
const activity = screen.getByText("Thought for 2m 41s");
const answer = screen.getByText("Hong Kong is hot today.");
expect(activity.compareDocumentPosition(answer) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(screen.getAllByText(/thought/i)).toHaveLength(1);
});
it("renders interrupted pre-tool text as activity before the final answer", () => {
const messages: UIMessage[] = [
{
id: "prelude",
role: "assistant",
content: "",
reasoning: "I will inspect first.",
isStreaming: false,
activitySegmentId: "seg-1",
createdAt: 1,
},
{
id: "tool",
role: "tool",
kind: "trace",
content: 'exec({"cmd":"ls"})',
traces: ['exec({"cmd":"ls"})'],
activitySegmentId: "seg-1",
createdAt: 2,
},
{
id: "final",
role: "assistant",
content: "Done. Open index.html to play.",
createdAt: 3,
},
];
const units = buildDisplayUnits(messages);
expect(units).toHaveLength(2);
expect(units[0].type === "cluster" ? units[0].messages.map((m) => m.id) : []).toEqual([
"prelude",
"tool",
]);
expect(units[1]).toMatchObject({
type: "single",
message: {
id: "final",
content: "Done. Open index.html to play.",
},
});
});
it("passes assistant turn latency to the preceding completed activity cluster", () => {
const messages: UIMessage[] = [
{
+204 -27
View File
@@ -5,7 +5,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { ThreadShell } from "@/components/thread/ThreadShell";
import { CLI_APPS_CHANGED_EVENT } from "@/lib/cli-app-events";
import { ClientProvider } from "@/providers/ClientProvider";
import type { CliAppsPayload, UIMessage } from "@/lib/types";
import type { CliAppsPayload, SettingsPayload, UIMessage } from "@/lib/types";
const HERO_GREETING_PATTERN =
/What should we work on\?|Where should we start\?|What are we building today\?|What should we tackle together\?/;
function makeClient() {
const errorHandlers = new Set<(err: { kind: string }) => void>();
const chatHandlers = new Map<string, Set<(ev: import("@/lib/types").InboundEvent) => void>>();
@@ -62,11 +66,12 @@ function makeClient() {
};
}
function wrap(client: ReturnType<typeof makeClient>, children: ReactNode) {
function wrap(client: ReturnType<typeof makeClient>, children: ReactNode, modelName?: string | null) {
return (
<ClientProvider
client={client as unknown as import("@/lib/nanobot-client").NanobotClient}
token="tok"
modelName={modelName ?? null}
>
{children}
</ClientProvider>
@@ -106,6 +111,98 @@ function httpJson(body: unknown) {
};
}
function modelSettings(model: string, provider: string): SettingsPayload {
return {
agent: {
model,
provider,
resolved_provider: provider,
has_api_key: true,
model_preset: "default",
max_tokens: 4096,
context_window_tokens: 65536,
temperature: 0.7,
reasoning_effort: null,
timezone: "UTC",
bot_name: "nanobot",
bot_icon: "",
tool_hint_max_length: 40,
},
model_presets: [{
name: "default",
label: "Default",
active: true,
is_default: true,
model,
provider,
max_tokens: 4096,
context_window_tokens: 65536,
temperature: 0.7,
reasoning_effort: null,
}],
providers: [
{ name: "deepseek", label: "DeepSeek", configured: true },
{ name: "openai_codex", label: "OpenAI Codex", configured: true },
],
web_search: {
provider: "duckduckgo",
api_key_hint: null,
base_url: null,
max_results: 5,
timeout: 30,
providers: [],
},
web: {
enable: true,
proxy: null,
user_agent: null,
search: { max_results: 5, timeout: 30 },
fetch: { use_jina_reader: true },
},
image_generation: {
enabled: false,
provider: "openrouter",
provider_configured: false,
model: "openai/gpt-5.4-image-2",
default_aspect_ratio: "1:1",
default_image_size: "1K",
max_images_per_turn: 4,
save_dir: "generated",
providers: [],
},
runtime: {
config_path: "/tmp/config.json",
workspace_path: "/tmp/workspace",
gateway_host: "127.0.0.1",
gateway_port: 18790,
heartbeat: {
enabled: true,
interval_s: 1800,
keep_recent_messages: 8,
},
dream: {
schedule: "every 2h",
max_batch_size: 20,
max_iterations: 15,
annotate_line_ages: true,
},
unified_session: false,
},
advanced: {
restrict_to_workspace: false,
webui_allow_local_service_access: true,
webui_default_access_mode: "default",
private_service_protection_enabled: true,
ssrf_whitelist_count: 0,
mcp_server_count: 0,
exec_enabled: true,
exec_sandbox: null,
exec_path_append_set: false,
},
requires_restart: false,
};
}
describe("ThreadShell", () => {
beforeEach(() => {
vi.stubGlobal(
@@ -138,6 +235,87 @@ describe("ThreadShell", () => {
expect(onGoHome).not.toHaveBeenCalled();
});
it("updates the composer model logo when settings snapshot changes", async () => {
const client = makeClient();
const { rerender } = render(
wrap(
client,
<ThreadShell
session={session("model-logo")}
title="Model logo"
onToggleSidebar={() => {}}
settingsSnapshot={modelSettings("deepseek-v4-pro", "deepseek")}
/>,
"deepseek-v4-pro",
),
);
expect(await screen.findByTestId("composer-model-logo-deepseek")).toBeInTheDocument();
await act(async () => {
rerender(
wrap(
client,
<ThreadShell
session={session("model-logo")}
title="Model logo"
onToggleSidebar={() => {}}
settingsSnapshot={modelSettings("openai-codex/gpt-5.5", "openai_codex")}
/>,
"openai-codex/gpt-5.5",
),
);
});
expect(await screen.findByTestId("composer-model-logo-openai_codex")).toBeInTheDocument();
});
it("only shows image generation controls when the setting is enabled", async () => {
const client = makeClient();
const disabledSettings = modelSettings("deepseek-v4-pro", "deepseek");
const enabledSettings: SettingsPayload = {
...disabledSettings,
image_generation: {
...disabledSettings.image_generation,
enabled: true,
provider_configured: true,
},
};
const { rerender } = render(
wrap(
client,
<ThreadShell
session={session("image-generation-disabled")}
title="Image generation disabled"
onToggleSidebar={() => {}}
settingsSnapshot={disabledSettings}
/>,
"deepseek-v4-pro",
),
);
await screen.findByLabelText("Message input");
expect(screen.queryByRole("button", { name: "Toggle image generation mode" })).not.toBeInTheDocument();
await act(async () => {
rerender(
wrap(
client,
<ThreadShell
session={session("image-generation-disabled")}
title="Image generation disabled"
onToggleSidebar={() => {}}
settingsSnapshot={enabledSettings}
/>,
"deepseek-v4-pro",
),
);
});
expect(screen.getByRole("button", { name: "Toggle image generation mode" })).toBeInTheDocument();
});
it("restores in-memory messages when switching away and back to a session", async () => {
const client = makeClient();
const onNewChat = vi.fn().mockResolvedValue("chat-a");
@@ -337,7 +515,7 @@ describe("ThreadShell", () => {
await waitFor(() =>
expect(screen.getByText("first message should stay")).toBeInTheDocument(),
);
expect(screen.queryByText("What can I do for you?")).not.toBeInTheDocument();
expect(screen.queryByText(HERO_GREETING_PATTERN)).not.toBeInTheDocument();
});
it("keeps a live first command reply when the initial history snapshot is stale", async () => {
@@ -418,36 +596,26 @@ describe("ThreadShell", () => {
await waitFor(() => expect(screen.getByText(/Current model/)).toBeInTheDocument());
});
it("sends quick action prompts from the empty thread landing", async () => {
it("keeps the empty thread landing focused on the composer", async () => {
const client = makeClient();
const onNewChat = vi.fn().mockResolvedValue("chat-a");
render(
wrap(
client,
<ThreadShell
session={session("chat-a")}
title="Chat chat-a"
session={null}
title="nanobot"
onToggleSidebar={() => {}}
onGoHome={() => {}}
onNewChat={onNewChat}
onNewChat={() => {}}
/>,
),
);
await act(async () => {});
await waitFor(() => {
expect(screen.getByRole("button", { name: "Write code" })).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: "Write code" }));
await waitFor(() =>
expect(client.sendMessage).toHaveBeenCalledWith(
"chat-a",
"Help me write the code for this task, starting with the smallest useful change.",
undefined,
),
);
expect(screen.getByText(HERO_GREETING_PATTERN)).toBeInTheDocument();
expect(screen.getByPlaceholderText("Ask anything...")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Write code" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Create a project plan" })).not.toBeInTheDocument();
});
it("does not leak the previous thread when opening a brand-new chat", async () => {
@@ -653,7 +821,7 @@ describe("ThreadShell", () => {
});
expect(screen.queryByText("live assistant reply")).not.toBeInTheDocument();
expect(screen.getByText("What can I do for you?")).toBeInTheDocument();
expect(screen.getByText(HERO_GREETING_PATTERN)).toBeInTheDocument();
await act(async () => {
rerender(
@@ -814,7 +982,7 @@ describe("ThreadShell", () => {
),
);
expect(screen.getByText("What can I do for you?")).toBeInTheDocument();
expect(screen.getByText(HERO_GREETING_PATTERN)).toBeInTheDocument();
scrollIntoView.mockClear();
await act(async () => {
@@ -897,8 +1065,9 @@ describe("ThreadShell", () => {
expect(screen.getByRole("option", { name: /\/history/i })).toBeInTheDocument();
});
it("switches welcome quick actions when image mode is enabled", async () => {
it("does not bring back welcome cards when image mode is enabled", async () => {
const client = makeClient();
const settings = modelSettings("deepseek-v4-pro", "deepseek");
render(
wrap(
client,
@@ -907,17 +1076,25 @@ describe("ThreadShell", () => {
title="nanobot"
onToggleSidebar={() => {}}
onNewChat={() => {}}
settingsSnapshot={{
...settings,
image_generation: {
...settings.image_generation,
enabled: true,
provider_configured: true,
},
}}
/>,
),
);
await act(async () => {});
expect(screen.getByText("Write code")).toBeInTheDocument();
expect(screen.queryByText("Design an app icon")).not.toBeInTheDocument();
expect(screen.queryByText("Write code")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Toggle image generation mode" }));
expect(screen.getByText("Design an app icon")).toBeInTheDocument();
expect(screen.queryByText("Design an app icon")).not.toBeInTheDocument();
expect(screen.queryByText("Write code")).not.toBeInTheDocument();
});
+144 -2
View File
@@ -14,6 +14,10 @@ function fakeClient() {
const goalStateByChatId = new Map<string, GoalStateWsPayload>();
function recordGoalStatusForRunStrip(chatId: string, ev: InboundEvent) {
if (ev.event === "turn_end") {
runStartedAtByChatId.delete(chatId);
return;
}
if (ev.event !== "goal_status") return;
if (ev.status === "running" && typeof ev.started_at === "number") {
runStartedAtByChatId.set(chatId, ev.started_at);
@@ -476,6 +480,69 @@ describe("useNanobotStream", () => {
);
});
it("replaces matching write_file tool events with live file edit activity", () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-file-edit-events", EMPTY_MESSAGES), {
wrapper: wrap(fake.client),
});
act(() => {
fake.emit("chat-file-edit-events", {
event: "message",
chat_id: "chat-file-edit-events",
text: 'write_file({"path":"foo.txt"})',
kind: "tool_hint",
tool_events: [{
phase: "start",
call_id: "call-write",
name: "write_file",
arguments: { path: "foo.txt", content: "hello\n" },
}],
});
fake.emit("chat-file-edit-events", {
event: "file_edit",
chat_id: "chat-file-edit-events",
edits: [{
call_id: "call-write",
tool: "write_file",
path: "foo.txt",
phase: "start",
added: 1,
deleted: 0,
approximate: true,
status: "editing",
}],
});
fake.emit("chat-file-edit-events", {
event: "message",
chat_id: "chat-file-edit-events",
text: "",
kind: "progress",
tool_events: [{
phase: "end",
call_id: "call-write",
name: "write_file",
arguments: { path: "foo.txt", content: "hello\n" },
result: "ok",
}],
});
});
expect(result.current.messages).toHaveLength(1);
expect(result.current.messages[0]).toMatchObject({
role: "tool",
kind: "trace",
traces: [],
fileEdits: [{
call_id: "call-write",
tool: "write_file",
path: "foo.txt",
status: "editing",
}],
});
expect(result.current.messages[0].toolEvents).toBeUndefined();
});
it("upgrades pending file_edit placeholders when the path arrives", () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-file-edit-pending", EMPTY_MESSAGES), {
@@ -591,7 +658,7 @@ describe("useNanobotStream", () => {
}]);
});
it("starts a new assistant bubble for deltas after stream_end and activity", async () => {
it("keeps interrupted pre-tool text inside activity before the final answer", async () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-stream-segments", EMPTY_MESSAGES), {
wrapper: wrap(fake.client),
@@ -625,7 +692,9 @@ describe("useNanobotStream", () => {
expect(result.current.messages).toHaveLength(3);
expect(result.current.messages[0]).toMatchObject({
role: "assistant",
content: "I created the files.",
content: "",
reasoning: "I created the files.",
isStreaming: false,
});
expect(result.current.messages[1]).toMatchObject({
role: "tool",
@@ -638,6 +707,54 @@ describe("useNanobotStream", () => {
});
});
it("does not replace interrupted pre-tool text with final stream_end text", () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-stream-end-final", EMPTY_MESSAGES), {
wrapper: wrap(fake.client),
});
act(() => {
fake.emit("chat-stream-end-final", {
event: "delta",
chat_id: "chat-stream-end-final",
text: "I will inspect the project first.",
});
fake.emit("chat-stream-end-final", {
event: "stream_end",
chat_id: "chat-stream-end-final",
});
fake.emit("chat-stream-end-final", {
event: "message",
chat_id: "chat-stream-end-final",
text: 'exec({"cmd":"ls"})',
kind: "tool_hint",
});
fake.emit("chat-stream-end-final", {
event: "stream_end",
chat_id: "chat-stream-end-final",
text: "Done. Open index.html to play.",
});
});
expect(result.current.messages).toHaveLength(3);
expect(result.current.messages[0]).toMatchObject({
role: "assistant",
content: "",
reasoning: "I will inspect the project first.",
isStreaming: false,
});
expect(result.current.messages[1]).toMatchObject({
role: "tool",
kind: "trace",
traces: ['exec({"cmd":"ls"})'],
});
expect(result.current.messages[2]).toMatchObject({
role: "assistant",
content: "Done. Open index.html to play.",
isStreaming: true,
});
});
it("opens a new activity segment for reasoning after file edit activity", async () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-file-segments", EMPTY_MESSAGES), {
@@ -1374,6 +1491,31 @@ describe("useNanobotStream", () => {
expect(result.current.runStartedAt).toBeNull();
});
it("clears runStartedAt on turn_end even without idle", () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-g", EMPTY_MESSAGES), {
wrapper: wrap(fake.client),
});
act(() => {
fake.emit("chat-g", {
event: "goal_status",
chat_id: "chat-g",
status: "running",
started_at: 1700,
});
});
expect(result.current.runStartedAt).toBe(1700);
act(() => {
fake.emit("chat-g", {
event: "turn_end",
chat_id: "chat-g",
});
});
expect(result.current.runStartedAt).toBeNull();
});
it("restores runStartedAt after switching away and back when goal_status was recorded without a subscriber", () => {
const fake = fakeClient();
const { result, rerender } = renderHook(
+25
View File
@@ -186,6 +186,7 @@ describe("useSessions", () => {
await result.current.createChat();
});
expect(client.newChat).toHaveBeenCalledWith(5000, undefined);
expect(result.current.sessions.map((s) => s.key)).toEqual(["websocket:chat-new"]);
await act(async () => {
@@ -204,6 +205,30 @@ describe("useSessions", () => {
expect(result.current.sessions[0]?.title).toBe("Generated title");
});
it("stores optimistic workspace scope when creating a chat", async () => {
vi.mocked(api.listSessions).mockResolvedValue([]);
const client = fakeClient();
client.newChat.mockResolvedValue("chat-workspace");
const workspaceScope = {
project_path: "/tmp/project",
project_name: "project",
access_mode: "restricted" as const,
restrict_to_workspace: true,
};
const { result } = renderHook(() => useSessions(), {
wrapper: wrap(client),
});
await waitFor(() => expect(result.current.loading).toBe(false));
await act(async () => {
await result.current.createChat(workspaceScope);
});
expect(client.newChat).toHaveBeenCalledWith(5000, workspaceScope);
expect(result.current.sessions[0]?.workspaceScope).toEqual(workspaceScope);
});
it("passes through WebUI transcript user media as images and media", async () => {
vi.mocked(api.fetchWebuiThread).mockResolvedValue({
schemaVersion: 3,
+5 -18
View File
@@ -5,7 +5,7 @@ import path from "node:path";
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), "");
const target = env.NANOBOT_API_URL ?? "http://127.0.0.1:8765";
const wsTarget = target.replace(/^http/, "ws");
const hmrPath = "/__nanobot_vite_hmr";
return {
plugins: [react()],
@@ -60,30 +60,17 @@ export default defineConfig(({ mode }) => {
host: "127.0.0.1",
port: 5173,
strictPort: true,
// Move Vite's HMR socket to a dedicated port so it doesn't collide with
// the ``/`` proxy below (Vite HMR and the nanobot ws upgrade both sit on
// the root path, which triggers spurious write-after-end errors as each
// side tries to close the other's socket).
// Keep Vite's HMR socket on a dedicated path. Nanobot's app WebSocket is
// opened directly from the browser to the gateway, so the dev server
// should never proxy WebSocket upgrades.
hmr: {
host: "127.0.0.1",
port: 5174,
path: hmrPath,
},
proxy: {
"/webui": { target, changeOrigin: true },
"/api": { target, changeOrigin: true },
"/auth": { target, changeOrigin: true },
// Forward only WebSocket upgrades on ``/`` to the nanobot gateway;
// plain HTTP GETs on ``/`` must stay with Vite so it can serve the SPA.
// ``bypass`` returning the original URL skips the proxy for that
// request; returning undefined lets the proxy (and ws upgrade handler)
// take it.
"/": {
target: wsTarget,
ws: true,
changeOrigin: true,
bypass: (req) =>
req.headers.upgrade === "websocket" ? undefined : req.url,
},
},
},
test: {