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:
+449
-117
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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: "" };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,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,
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
));
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
]);
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -71,7 +71,7 @@ export function detectNavigatorLocale(): SupportedLocale {
|
||||
}
|
||||
|
||||
export function resolveInitialLocale(): SupportedLocale {
|
||||
return readStoredLocale() ?? detectNavigatorLocale();
|
||||
return readStoredLocale() ?? defaultLocale;
|
||||
}
|
||||
|
||||
export function persistLocale(locale: SupportedLocale): void {
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,9 @@
|
||||
"section": "Système",
|
||||
"restartHint": "Redémarrez nanobot pour appliquer les changements d’exé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 à l’heure.",
|
||||
"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 d’images enregistrées. Redémarrez lorsque vous êtes prêt."
|
||||
"imageProviderRestart": "Modifications du fournisseur d’images 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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "请输入这台机器上的绝对文件夹路径。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "請輸入這台機器上的絕對資料夾路徑。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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("网页搜索");
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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[] = [
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user