-
{t("app.error.title")}
{state.message}
@@ -144,18 +216,26 @@ export default function App() {
);
};
+ const handleLogout = () => {
+ if (state.status === "ready") {
+ state.client.close();
+ }
+ clearSavedSecret();
+ setState({ status: "auth" });
+ };
+
return (
-
+
);
}
-function Shell({ onModelNameChange }: { onModelNameChange: (modelName: string | null) => void }) {
+function Shell({ onModelNameChange, onLogout }: { onModelNameChange: (modelName: string | null) => void; onLogout: () => void }) {
const { t, i18n } = useTranslation();
const { theme, toggle } = useTheme();
const { sessions, loading, refresh, createChat, deleteChat } = useSessions();
@@ -213,7 +293,7 @@ function Shell({ onModelNameChange }: { onModelNameChange: (modelName: string |
}
}, []);
- const onNewChat = useCallback(async () => {
+ const onCreateChat = useCallback(async () => {
try {
const chatId = await createChat();
setActiveKey(`websocket:${chatId}`);
@@ -226,6 +306,12 @@ function Shell({ onModelNameChange }: { onModelNameChange: (modelName: string |
}
}, [createChat]);
+ const onNewChat = useCallback(() => {
+ setActiveKey(null);
+ setView("chat");
+ setMobileSidebarOpen(false);
+ }, []);
+
const onSelectChat = useCallback(
(key: string) => {
setActiveKey(key);
@@ -235,6 +321,15 @@ function Shell({ onModelNameChange }: { onModelNameChange: (modelName: string |
[],
);
+ const onOpenSettings = useCallback(() => {
+ setView("settings");
+ setMobileSidebarOpen(false);
+ }, []);
+
+ const onTurnEnd = useCallback(() => {
+ void refresh();
+ }, [refresh]);
+
const onConfirmDelete = useCallback(async () => {
if (!pendingDelete) return;
const key = pendingDelete.key;
@@ -254,7 +349,8 @@ function Shell({ onModelNameChange }: { onModelNameChange: (modelName: string |
}, [pendingDelete, deleteChat, activeKey, sessions]);
const headerTitle = activeSession
- ? activeSession.preview ||
+ ? activeSession.title ||
+ activeSession.preview ||
t("chat.fallbackTitle", { id: activeSession.chatId.slice(0, 6) })
: t("app.brand");
@@ -268,20 +364,10 @@ function Shell({ onModelNameChange }: { onModelNameChange: (modelName: string |
sessions,
activeKey,
loading,
- theme,
- onToggleTheme: toggle,
- onNewChat: () => {
- void onNewChat();
- },
+ onNewChat,
onSelect: onSelectChat,
- onRefresh: () => void refresh(),
onRequestDelete: (key: string, label: string) =>
setPendingDelete({ key, label }),
- activeView: view,
- onOpenSettings: () => {
- setView("settings" as const);
- setMobileSidebarOpen(false);
- },
};
return (
@@ -296,10 +382,11 @@ function Shell({ onModelNameChange }: { onModelNameChange: (modelName: string |
>
@@ -312,7 +399,8 @@ function Shell({ onModelNameChange }: { onModelNameChange: (modelName: string |
@@ -325,14 +413,19 @@ function Shell({ onModelNameChange }: { onModelNameChange: (modelName: string |
onToggleTheme={toggle}
onBackToChat={() => setView("chat")}
onModelNameChange={onModelNameChange}
+ onLogout={onLogout}
/>
) : (
setActiveKey(null)}
onNewChat={onNewChat}
+ onCreateChat={onCreateChat}
+ onTurnEnd={onTurnEnd}
+ theme={theme}
+ onToggleTheme={toggle}
+ onOpenSettings={onOpenSettings}
hideSidebarToggleOnDesktop={desktopSidebarOpen}
/>
)}
diff --git a/webui/src/components/ChatList.tsx b/webui/src/components/ChatList.tsx
index f77f7c1b2..ce7bb17e0 100644
--- a/webui/src/components/ChatList.tsx
+++ b/webui/src/components/ChatList.tsx
@@ -8,7 +8,6 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ScrollArea } from "@/components/ui/scroll-area";
-import { relativeTime } from "@/lib/format";
import { cn } from "@/lib/utils";
import type { ChatSummary } from "@/lib/types";
@@ -18,10 +17,11 @@ interface ChatListProps {
onSelect: (key: string) => void;
onRequestDelete: (key: string, label: string) => void;
loading?: boolean;
+ emptyLabel?: string;
}
function titleFor(s: ChatSummary, fallbackTitle: string): string {
- const p = s.preview?.trim();
+ const p = (s.title || s.preview)?.trim();
if (p) return p.length > 48 ? `${p.slice(0, 45)}…` : p;
return fallbackTitle;
}
@@ -32,6 +32,7 @@ export function ChatList({
onSelect,
onRequestDelete,
loading,
+ emptyLabel,
}: ChatListProps) {
const { t } = useTranslation();
if (loading && sessions.length === 0) {
@@ -44,73 +45,111 @@ export function ChatList({
if (sessions.length === 0) {
return (
-
- {t("chat.noSessions")}
+
+ {emptyLabel ?? t("chat.noSessions")}
);
}
+ const groups = groupSessions(sessions, {
+ today: t("chat.groups.today"),
+ yesterday: t("chat.groups.yesterday"),
+ earlier: t("chat.groups.earlier"),
+ });
+
return (
-
- {sessions.map((s) => {
- const active = s.key === activeKey;
- const title = titleFor(
- s,
- t("chat.fallbackTitle", { id: s.chatId.slice(0, 6) }),
- );
- return (
-
-
-
onSelect(s.key)}
- className="flex min-w-0 flex-1 flex-col items-start text-left"
- >
- {title}
-
- {relativeTime(s.updatedAt ?? s.createdAt) || "—"}
-
-
-
-
-
-
- event.preventDefault()}
- >
- {
- window.setTimeout(() => onRequestDelete(s.key, title), 0);
- }}
- className="text-destructive focus:text-destructive"
+
+ {groups.map((group) => (
+
+
+ {group.label}
+
+
+ onSelect(s.key)}
+ className="min-w-0 flex-1 py-1.5 text-left"
+ >
+ {title}
+
+
+
+
+
+ event.preventDefault()}
+ >
+ {
+ window.setTimeout(() => onRequestDelete(s.key, title), 0);
+ }}
+ className="text-destructive focus:text-destructive"
+ >
+
+ {t("chat.delete")}
+
+
+
+
+
+ );
+ })}
+
+
+ ))}
+
);
}
+
+function groupSessions(
+ sessions: ChatSummary[],
+ labels: { today: string; yesterday: string; earlier: string },
+): 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
();
+
+ for (const session of sessions) {
+ 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);
+ }
+
+ return [labels.today, labels.yesterday, labels.earlier]
+ .map((label) => ({ label, sessions: buckets.get(label) ?? [] }))
+ .filter((group) => group.sessions.length > 0);
+}
diff --git a/webui/src/components/ChatPane.tsx b/webui/src/components/ChatPane.tsx
index 29d0df49f..43fe64914 100644
--- a/webui/src/components/ChatPane.tsx
+++ b/webui/src/components/ChatPane.tsx
@@ -22,7 +22,7 @@ interface ChatPaneProps {
export function ChatPane({ session, onNewChat }: ChatPaneProps) {
const chatId = session?.chatId ?? null;
const historyKey = session?.key ?? null;
- const { messages: historical, loading } = useSessionHistory(historyKey);
+ const { messages: historical, loading, hasPendingToolCalls } = useSessionHistory(historyKey);
const { client } = useClient();
const [booting, setBooting] = useState(false);
const pendingFirstRef = useRef(null);
@@ -31,6 +31,7 @@ export function ChatPane({ session, onNewChat }: ChatPaneProps) {
const { messages, isStreaming, send, setMessages } = useNanobotStream(
chatId,
initial,
+ hasPendingToolCalls,
);
useEffect(() => {
@@ -78,20 +79,8 @@ export function ChatPane({ session, onNewChat }: ChatPaneProps) {
-
-
-
-
- What's on your mind?
+ What can I do for you?
Your conversations are persisted locally under the nanobot
@@ -104,7 +93,7 @@ export function ChatPane({ session, onNewChat }: ChatPaneProps) {
disabled={booting}
onSend={handleWelcomeSend}
placeholder={
- booting ? "Opening a new chat…" : "Type your message…"
+ booting ? "Opening a new chat…" : "Ask anything..."
}
/>
diff --git a/webui/src/components/ConnectionBadge.tsx b/webui/src/components/ConnectionBadge.tsx
index 354be976f..7616ddbe5 100644
--- a/webui/src/components/ConnectionBadge.tsx
+++ b/webui/src/components/ConnectionBadge.tsx
@@ -6,21 +6,21 @@ import { useClient } from "@/providers/ClientProvider";
import type { ConnectionStatus } from "@/lib/types";
const COPY: Record
= {
- idle: { color: "bg-card/40 text-muted-foreground" },
+ idle: { color: "text-muted-foreground" },
connecting: {
- color: "bg-amber-500/10 text-amber-700 dark:text-amber-300",
+ color: "text-amber-700 dark:text-amber-300",
},
open: {
- color: "bg-emerald-500/10 text-emerald-700 dark:text-emerald-400",
+ color: "text-emerald-700 dark:text-emerald-400",
},
reconnecting: {
- color: "bg-amber-500/10 text-amber-700 dark:text-amber-300",
+ color: "text-amber-700 dark:text-amber-300",
},
closed: {
- color: "bg-card/40 text-muted-foreground",
+ color: "text-muted-foreground",
},
error: {
- color: "bg-destructive/10 text-destructive",
+ color: "text-destructive",
},
};
@@ -39,7 +39,7 @@ export function ConnectionBadge() {
return (
(null);
const baseAnim = "animate-in fade-in-0 slide-in-from-bottom-1 duration-300";
+ useEffect(() => {
+ return () => {
+ if (copyResetRef.current !== null) {
+ window.clearTimeout(copyResetRef.current);
+ }
+ };
+ }, []);
+
+ const onCopyAssistantReply = useCallback(() => {
+ if (!navigator.clipboard) return;
+ void navigator.clipboard.writeText(message.content).then(() => {
+ setCopied(true);
+ if (copyResetRef.current !== null) {
+ window.clearTimeout(copyResetRef.current);
+ }
+ copyResetRef.current = window.setTimeout(() => {
+ setCopied(false);
+ copyResetRef.current = null;
+ }, 1_500);
+ });
+ }, [message.content]);
+
if (message.kind === "trace") {
return ;
}
@@ -60,6 +85,7 @@ export function MessageBubble({ message }: MessageBubbleProps) {
const empty = message.content.trim().length === 0;
const media = message.media ?? [];
+ const showAssistantActions = message.role === "assistant" && !message.isStreaming && !empty;
return (
{empty && message.isStreaming ? (
@@ -69,6 +95,27 @@ export function MessageBubble({ message }: MessageBubbleProps) {
{message.content}
{message.isStreaming &&
}
{media.length > 0 ?
: null}
+ {showAssistantActions ? (
+
+
+ {copied ? (
+
+ ) : (
+
+ )}
+
+
+ ) : null}
>
)}
diff --git a/webui/src/components/Sidebar.tsx b/webui/src/components/Sidebar.tsx
index b544fd0ba..52c8de47c 100644
--- a/webui/src/components/Sidebar.tsx
+++ b/webui/src/components/Sidebar.tsx
@@ -1,109 +1,121 @@
-import { Moon, PanelLeftClose, RefreshCcw, Settings, SquarePen, Sun } from "lucide-react";
+import { useMemo, useState } from "react";
+import {
+ PanelLeftClose,
+ Search,
+ SquarePen,
+} from "lucide-react";
import { useTranslation } from "react-i18next";
import { ChatList } from "@/components/ChatList";
import { ConnectionBadge } from "@/components/ConnectionBadge";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
+import { cn } from "@/lib/utils";
import type { ChatSummary } from "@/lib/types";
interface SidebarProps {
sessions: ChatSummary[];
activeKey: string | null;
loading: boolean;
- theme: "light" | "dark";
- onToggleTheme: () => void;
onNewChat: () => void;
onSelect: (key: string) => void;
- onRefresh: () => void;
onRequestDelete: (key: string, label: string) => void;
onCollapse: () => void;
- activeView?: "chat" | "settings";
- onOpenSettings: () => void;
}
export function Sidebar(props: SidebarProps) {
const { t } = useTranslation();
+ const [query, setQuery] = useState("");
+ const normalizedQuery = query.trim().toLowerCase();
+ const filteredSessions = useMemo(() => {
+ if (!normalizedQuery) return props.sessions;
+ return props.sessions.filter((session) => {
+ const haystack = [
+ session.preview,
+ session.chatId,
+ session.channel,
+ session.key,
+ ]
+ .filter(Boolean)
+ .join(" ")
+ .toLowerCase();
+ return haystack.includes(normalizedQuery);
+ });
+ }, [normalizedQuery, props.sessions]);
+
return (
-
-
+
+
-
-
- {props.theme === "dark" ? (
-
- ) : (
-
- )}
-
-
-
-
-
+
+
+
-
+
+
+
+ {t("sidebar.searchAria")}
+
+ setQuery(event.target.value)}
+ placeholder={t("sidebar.searchPlaceholder")}
+ aria-label={t("sidebar.searchAria")}
+ className={cn(
+ "h-8 w-full rounded-full border border-transparent bg-sidebar-accent/45",
+ "pl-8 pr-3 text-[12.5px] text-sidebar-foreground outline-none",
+ "placeholder:text-muted-foreground/75",
+ "transition-colors hover:bg-sidebar-accent/65",
+ "focus:border-sidebar-border/80 focus:bg-sidebar-accent/70",
+ "focus:ring-1 focus:ring-sidebar-border/70",
+ )}
+ />
+
{t("sidebar.newChat")}
-
- {t("sidebar.recent")}
-
-
-
-
-
+
-
-
- Settings
-
-
+
);
}
diff --git a/webui/src/components/settings/SettingsView.tsx b/webui/src/components/settings/SettingsView.tsx
index c24ff97da..0f3b5b77d 100644
--- a/webui/src/components/settings/SettingsView.tsx
+++ b/webui/src/components/settings/SettingsView.tsx
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { ChevronLeft, Loader2 } from "lucide-react";
+import { useTranslation } from "react-i18next";
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
import { Button } from "@/components/ui/button";
@@ -14,11 +15,13 @@ interface SettingsViewProps {
onToggleTheme: () => void;
onBackToChat: () => void;
onModelNameChange: (modelName: string | null) => void;
+ onLogout?: () => void;
}
export function SettingsView({
onBackToChat,
onModelNameChange,
+ onLogout,
}: SettingsViewProps) {
const { token } = useClient();
const [settings, setSettings] = useState
(null);
@@ -115,6 +118,7 @@ export function SettingsView({
dirty={dirty}
saving={saving}
onSave={save}
+ onLogout={onLogout}
/>
) : null}
@@ -129,6 +133,7 @@ function SettingsSection({
dirty,
saving,
onSave,
+ onLogout,
}: {
form: {
model: string;
@@ -142,7 +147,9 @@ function SettingsSection({
dirty: boolean;
saving: boolean;
onSave: () => void;
+ onLogout?: () => void;
}) {
+ const { t } = useTranslation();
return (
@@ -192,6 +199,19 @@ function SettingsSection({
+
+ {onLogout && (
+
+ {t("app.account.section")}
+
+
+
+ {t("app.account.logout")}
+
+
+
+
+ )}
);
}
diff --git a/webui/src/components/thread/ThreadComposer.tsx b/webui/src/components/thread/ThreadComposer.tsx
index 105bb6c77..ac994f89e 100644
--- a/webui/src/components/thread/ThreadComposer.tsx
+++ b/webui/src/components/thread/ThreadComposer.tsx
@@ -7,11 +7,21 @@ import {
type KeyboardEvent as ReactKeyboardEvent,
} from "react";
import {
+ Activity,
ArrowUp,
+ BookOpen,
+ CircleHelp,
+ History,
ImageIcon,
Loader2,
- Paperclip,
+ Plus,
+ RotateCw,
+ Sparkles,
+ Square,
+ SquarePen,
+ Undo2,
X,
+ type LucideIcon,
} from "lucide-react";
import { useTranslation } from "react-i18next";
@@ -24,6 +34,7 @@ import {
} from "@/hooks/useAttachedImages";
import { useClipboardAndDrop } from "@/hooks/useClipboardAndDrop";
import type { SendImage } from "@/hooks/useNanobotStream";
+import type { SlashCommand } from "@/lib/types";
import { cn } from "@/lib/utils";
/** `` ``: aligned with the server's MIME whitelist. SVG is
@@ -40,26 +51,49 @@ interface ThreadComposerProps {
onSend: (content: string, images?: SendImage[]) => void;
disabled?: boolean;
placeholder?: string;
+ isStreaming?: boolean;
modelLabel?: string | null;
variant?: "thread" | "hero";
+ slashCommands?: SlashCommand[];
+}
+
+const COMMAND_ICONS: Record = {
+ activity: Activity,
+ "book-open": BookOpen,
+ "circle-help": CircleHelp,
+ history: History,
+ "rotate-cw": RotateCw,
+ sparkles: Sparkles,
+ square: Square,
+ "square-pen": SquarePen,
+ "undo-2": Undo2,
+};
+
+function slashCommandI18nKey(command: string): string {
+ return command.replace(/^\//, "").replace(/-/g, "_");
}
export function ThreadComposer({
onSend,
disabled,
placeholder,
+ isStreaming = false,
modelLabel = null,
variant = "thread",
+ slashCommands = [],
}: ThreadComposerProps) {
const { t } = useTranslation();
const [value, setValue] = useState("");
const [inlineError, setInlineError] = useState(null);
+ const [slashMenuDismissed, setSlashMenuDismissed] = useState(false);
+ const [selectedCommandIndex, setSelectedCommandIndex] = useState(0);
const textareaRef = useRef(null);
const fileInputRef = useRef(null);
const chipRefs = useRef(new Map());
const isHero = variant === "hero";
- const resolvedPlaceholder =
- placeholder ?? t("thread.composer.placeholderThread");
+ const resolvedPlaceholder = isStreaming
+ ? t("thread.composer.placeholderStreaming")
+ : placeholder ?? t("thread.composer.placeholderThread");
const { images, enqueue, remove, clear, encoding, full } =
useAttachedImages();
@@ -116,6 +150,66 @@ export function ThreadComposer({
&& !hasErrors
&& (value.trim().length > 0 || readyImages.length > 0);
+ const slashQuery = useMemo(() => {
+ if (disabled || slashMenuDismissed || !value.startsWith("/")) return null;
+ const commandToken = value.slice(1);
+ if (/\s/.test(commandToken)) return null;
+ return commandToken.toLowerCase();
+ }, [disabled, slashMenuDismissed, value]);
+
+ const filteredSlashCommands = useMemo(() => {
+ if (slashQuery === null) return [];
+ return slashCommands
+ .filter((command) => {
+ const haystack = [
+ command.command,
+ command.title,
+ command.description,
+ command.argHint ?? "",
+ t(`thread.composer.slash.commands.${slashCommandI18nKey(command.command)}.title`, {
+ defaultValue: "",
+ }),
+ t(`thread.composer.slash.commands.${slashCommandI18nKey(command.command)}.description`, {
+ defaultValue: "",
+ }),
+ ].join(" ").toLowerCase();
+ return haystack.includes(slashQuery);
+ })
+ .slice(0, 8);
+ }, [slashCommands, slashQuery, t]);
+
+ const showSlashMenu = filteredSlashCommands.length > 0;
+
+ useEffect(() => {
+ setSelectedCommandIndex(0);
+ }, [slashQuery]);
+
+ useEffect(() => {
+ if (selectedCommandIndex >= filteredSlashCommands.length) {
+ setSelectedCommandIndex(0);
+ }
+ }, [filteredSlashCommands.length, selectedCommandIndex]);
+
+ const resizeTextarea = useCallback(() => {
+ requestAnimationFrame(() => {
+ const el = textareaRef.current;
+ if (!el) return;
+ el.style.height = "auto";
+ el.style.height = `${Math.min(el.scrollHeight, 260)}px`;
+ el.focus();
+ });
+ }, []);
+
+ const chooseSlashCommand = useCallback(
+ (command: SlashCommand) => {
+ setValue(command.argHint ? `${command.command} ` : command.command);
+ setSlashMenuDismissed(true);
+ setInlineError(null);
+ resizeTextarea();
+ },
+ [resizeTextarea],
+ );
+
const submit = useCallback(() => {
if (!canSend) return;
const trimmed = value.trim();
@@ -139,16 +233,35 @@ export function ThreadComposer({
// Bubble owns the data URL copy; safe to revoke every staged blob
// preview here without affecting the rendered message.
clear();
- requestAnimationFrame(() => {
- const el = textareaRef.current;
- if (el) {
- el.style.height = "auto";
- el.focus();
- }
- });
- }, [canSend, clear, onSend, readyImages, value]);
+ setSlashMenuDismissed(false);
+ resizeTextarea();
+ }, [canSend, clear, onSend, readyImages, resizeTextarea, value]);
const onKeyDown = (e: ReactKeyboardEvent) => {
+ if (showSlashMenu) {
+ if (e.key === "ArrowDown") {
+ e.preventDefault();
+ setSelectedCommandIndex((idx) => (idx + 1) % filteredSlashCommands.length);
+ return;
+ }
+ if (e.key === "ArrowUp") {
+ e.preventDefault();
+ setSelectedCommandIndex(
+ (idx) => (idx - 1 + filteredSlashCommands.length) % filteredSlashCommands.length,
+ );
+ return;
+ }
+ if (e.key === "Tab" || (e.key === "Enter" && !e.shiftKey)) {
+ e.preventDefault();
+ chooseSlashCommand(filteredSlashCommands[selectedCommandIndex]);
+ return;
+ }
+ if (e.key === "Escape") {
+ e.preventDefault();
+ setSlashMenuDismissed(true);
+ return;
+ }
+ }
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault();
submit();
@@ -210,14 +323,23 @@ export function ThreadComposer({
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
- className={cn("w-full", isHero ? "px-0" : "px-1 pb-1.5 pt-1 sm:px-0")}
+ className={cn("relative w-full", isHero ? "px-0" : "px-1 pb-1.5 pt-1 sm:px-0")}
>
+ {showSlashMenu ? (
+
+ ) : null}
setValue(e.target.value)}
+ onChange={(e) => {
+ setValue(e.target.value);
+ setSlashMenuDismissed(false);
+ }}
onInput={onInput}
onKeyDown={onKeyDown}
onPaste={onPaste}
@@ -265,9 +390,9 @@ export function ThreadComposer({
className={cn(
"w-full resize-none bg-transparent",
isHero
- ? "min-h-[96px] px-4 pb-2 pt-4 text-[15px] leading-6"
+ ? "min-h-[78px] px-5 pb-2 pt-5 text-[16px] leading-6"
: "min-h-[50px] px-4 pb-1.5 pt-3 text-sm",
- "placeholder:text-muted-foreground",
+ "placeholder:text-muted-foreground/70",
"focus:outline-none focus-visible:outline-none",
"disabled:cursor-not-allowed",
)}
@@ -286,7 +411,7 @@ export function ThreadComposer({
@@ -307,10 +432,12 @@ export function ThreadComposer({
onClick={() => fileInputRef.current?.click()}
className={cn(
"rounded-full text-muted-foreground hover:text-foreground",
- isHero ? "h-8.5 w-8.5" : "h-7.5 w-7.5",
+ 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-7.5 w-7.5 border border-border/55 bg-card shadow-[0_2px_8px_rgba(15,23,42,0.05)] hover:bg-card",
)}
>
-
+
{modelLabel ? (
{modelLabel}
) : null}
-
- {t("thread.composer.sendHint")}
-
+ {!isHero ? (
+
+ {t("thread.composer.sendHint")}
+
+ ) : null}
-
+
-
+ {isStreaming ? (
+
+ ) : (
+
+ )}
@@ -352,6 +489,106 @@ export function ThreadComposer({
);
}
+interface SlashCommandPaletteProps {
+ commands: SlashCommand[];
+ selectedIndex: number;
+ isHero: boolean;
+ onHover: (index: number) => void;
+ onChoose: (command: SlashCommand) => void;
+}
+
+function SlashCommandPalette({
+ commands,
+ selectedIndex,
+ isHero,
+ onHover,
+ onChoose,
+}: SlashCommandPaletteProps) {
+ const { t } = useTranslation();
+ return (
+
+
+ {t("thread.composer.slash.label")}
+
+
+ {commands.map((command, index) => {
+ const Icon = COMMAND_ICONS[command.icon] ?? CircleHelp;
+ const selected = index === selectedIndex;
+ const commandKey = slashCommandI18nKey(command.command);
+ const title = t(`thread.composer.slash.commands.${commandKey}.title`, {
+ defaultValue: command.title,
+ });
+ const description = t(`thread.composer.slash.commands.${commandKey}.description`, {
+ defaultValue: command.description,
+ });
+ return (
+ onHover(index)}
+ onMouseDown={(e) => {
+ e.preventDefault();
+ onChoose(command);
+ }}
+ className={cn(
+ "flex w-full items-center gap-3 rounded-[13px] px-3 py-2.5 text-left transition-colors",
+ selected
+ ? "bg-primary/10 text-foreground"
+ : "text-foreground/86 hover:bg-accent/55",
+ )}
+ >
+
+
+
+
+
+
+ {command.command}
+
+ {command.argHint ? (
+
+ {command.argHint}
+
+ ) : null}
+
+ {title}
+
+
+
+ {description}
+
+
+
+ );
+ })}
+
+
+ {t("thread.composer.slash.navigateHint")}
+ {t("thread.composer.slash.selectHint")}
+ {t("thread.composer.slash.closeHint")}
+
+
+ );
+}
+
interface AttachmentChipProps {
image: AttachedImage;
labelRemove: string;
diff --git a/webui/src/components/thread/ThreadHeader.tsx b/webui/src/components/thread/ThreadHeader.tsx
index bdc00ac2c..9c23d4bc2 100644
--- a/webui/src/components/thread/ThreadHeader.tsx
+++ b/webui/src/components/thread/ThreadHeader.tsx
@@ -1,4 +1,4 @@
-import { PanelLeftOpen } from "lucide-react";
+import { Menu, Moon, PanelLeftOpen, Settings, Sun } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
@@ -7,17 +7,66 @@ import { cn } from "@/lib/utils";
interface ThreadHeaderProps {
title: string;
onToggleSidebar: () => void;
- onGoHome: () => void;
+ theme: "light" | "dark";
+ onToggleTheme: () => void;
+ onOpenSettings: () => void;
hideSidebarToggleOnDesktop?: boolean;
+ minimal?: boolean;
}
export function ThreadHeader({
title,
onToggleSidebar,
- onGoHome,
+ theme,
+ onToggleTheme,
+ onOpenSettings,
hideSidebarToggleOnDesktop = false,
+ minimal = false,
}: ThreadHeaderProps) {
const { t } = useTranslation();
+ if (minimal) {
+ return (
+
+
+
+
+
+
+ {theme === "dark" ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+ );
+ }
+
return (
@@ -33,19 +82,34 @@ export function ThreadHeader({
>
-
-
+
{title}
-
+
+
+
+
+
+ {theme === "dark" ? (
+
+ ) : (
+
+ )}
+
+
+
+
diff --git a/webui/src/components/thread/ThreadShell.tsx b/webui/src/components/thread/ThreadShell.tsx
index 801080bbf..f15551ce5 100644
--- a/webui/src/components/thread/ThreadShell.tsx
+++ b/webui/src/components/thread/ThreadShell.tsx
@@ -1,4 +1,13 @@
-import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
+import {
+ BarChart3,
+ BookOpen,
+ ChevronRight,
+ Code2,
+ LayoutGrid,
+ Lightbulb,
+ MoreHorizontal,
+} from "lucide-react";
import { useTranslation } from "react-i18next";
import { AskUserPrompt } from "@/components/thread/AskUserPrompt";
@@ -8,15 +17,21 @@ import { StreamErrorNotice } from "@/components/thread/StreamErrorNotice";
import { ThreadViewport } from "@/components/thread/ThreadViewport";
import { useNanobotStream } from "@/hooks/useNanobotStream";
import { useSessionHistory } from "@/hooks/useSessions";
-import type { ChatSummary, UIMessage } from "@/lib/types";
+import { listSlashCommands } from "@/lib/api";
+import type { ChatSummary, SlashCommand, UIMessage } from "@/lib/types";
import { useClient } from "@/providers/ClientProvider";
interface ThreadShellProps {
session: ChatSummary | null;
title: string;
onToggleSidebar: () => void;
- onGoHome: () => void;
- onNewChat: () => Promise
;
+ onGoHome?: () => void;
+ onNewChat?: () => void;
+ onCreateChat?: () => Promise;
+ onTurnEnd?: () => void;
+ theme?: "light" | "dark";
+ onToggleTheme?: () => void;
+ onOpenSettings?: () => void;
hideSidebarToggleOnDesktop?: boolean;
}
@@ -28,22 +43,36 @@ function toModelBadgeLabel(modelName: string | null): string | null {
return leaf || trimmed;
}
+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" },
+] as const;
+
export function ThreadShell({
session,
title,
onToggleSidebar,
- onGoHome,
- onNewChat,
+ onCreateChat,
+ onTurnEnd,
+ theme = "light",
+ onToggleTheme = () => {},
+ onOpenSettings = () => {},
hideSidebarToggleOnDesktop = false,
}: ThreadShellProps) {
const { t } = useTranslation();
const chatId = session?.chatId ?? null;
const historyKey = session?.key ?? null;
- const { messages: historical, loading } = useSessionHistory(historyKey);
- const { client, modelName } = useClient();
+ const { messages: historical, loading, hasPendingToolCalls } = useSessionHistory(historyKey);
+ const { client, modelName, token } = useClient();
const [booting, setBooting] = useState(false);
+ const [slashCommands, setSlashCommands] = useState([]);
const pendingFirstRef = useRef(null);
const messageCacheRef = useRef>(new Map());
+ const lastCachedChatIdRef = useRef(null);
const initial = useMemo(() => {
if (!chatId) return historical;
@@ -56,7 +85,7 @@ export function ThreadShell({
setMessages,
streamError,
dismissStreamError,
- } = useNanobotStream(chatId, initial);
+ } = useNanobotStream(chatId, initial, hasPendingToolCalls, onTurnEnd);
const showHeroComposer = messages.length === 0 && !loading;
const pendingAsk = useMemo(() => {
for (let index = messages.length - 1; index >= 0; index -= 1) {
@@ -89,10 +118,24 @@ export function ThreadShell({
setMessages(historical);
}, [chatId, historical, setMessages]);
- useEffect(() => {
- if (!chatId) return;
+ useLayoutEffect(() => {
+ if (!chatId) {
+ lastCachedChatIdRef.current = null;
+ return;
+ }
+ if (loading) return;
+ // Skip the first cache write after a chat switch. During that render,
+ // `messages` can still belong to the previous chat until the stream hook
+ // resets its local state for the new session.
+ if (lastCachedChatIdRef.current !== chatId) {
+ lastCachedChatIdRef.current = chatId;
+ if (messages.length > 0) {
+ messageCacheRef.current.set(chatId, messages);
+ }
+ return;
+ }
messageCacheRef.current.set(chatId, messages);
- }, [chatId, messages]);
+ }, [chatId, loading, messages]);
useEffect(() => {
if (!chatId) return;
@@ -112,18 +155,115 @@ export function ThreadShell({
setBooting(false);
}, [chatId, client, setMessages]);
+ useEffect(() => {
+ let cancelled = false;
+ (async () => {
+ try {
+ const commands = await listSlashCommands(token);
+ if (!cancelled) setSlashCommands(commands);
+ } catch {
+ if (!cancelled) setSlashCommands([]);
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [token]);
+
const handleWelcomeSend = useCallback(
async (content: string) => {
if (booting) return;
setBooting(true);
pendingFirstRef.current = content;
- const newId = await onNewChat();
+ const newId = await onCreateChat?.();
if (!newId) {
pendingFirstRef.current = null;
setBooting(false);
}
},
- [booting, onNewChat],
+ [booting, onCreateChat],
+ );
+
+ const handleQuickAction = useCallback(
+ (prompt: string) => {
+ if (session) {
+ send(prompt);
+ return;
+ }
+ void handleWelcomeSend(prompt);
+ },
+ [handleWelcomeSend, send, session],
+ );
+
+ const quickActions = (
+
+ {QUICK_ACTION_KEYS.map(({ key, icon: Icon, tone }) => {
+ const title = t(`thread.empty.quickActions.${key}.title`);
+ const prompt = t(`thread.empty.quickActions.${key}.prompt`);
+ return (
+ 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)]"
+ >
+
+
+ {title}
+
+
+
+ );
+ })}
+
+ );
+
+ const composer = (
+ <>
+ {streamError ? (
+
+ ) : null}
+ {pendingAsk ? (
+
+ ) : null}
+ {session ? (
+
+ ) : (
+
+ )}
+ {showHeroComposer ? quickActions : null}
+ >
);
const emptyState = loading ? (
@@ -131,20 +271,10 @@ export function ThreadShell({
{t("thread.loadingConversation")}
) : (
-
-
-
-
nanobot
-
-
- {t("thread.empty.description")}
-
+
+
+ {t("thread.empty.greeting")}
+
);
@@ -153,55 +283,17 @@ export function ThreadShell({
- {streamError ? (
-
- ) : null}
- {pendingAsk ? (
-
- ) : null}
- {session ? (
-
- ) : (
-
- )}
- >
- }
+ composer={composer}
/>
);
diff --git a/webui/src/components/thread/ThreadViewport.tsx b/webui/src/components/thread/ThreadViewport.tsx
index 5f4b8d01a..7d4a80f06 100644
--- a/webui/src/components/thread/ThreadViewport.tsx
+++ b/webui/src/components/thread/ThreadViewport.tsx
@@ -82,9 +82,9 @@ export function ThreadViewport({
) : (
-
-
-
+
+
+
diff --git a/webui/src/globals.css b/webui/src/globals.css
index 1c677432c..802009ee7 100644
--- a/webui/src/globals.css
+++ b/webui/src/globals.css
@@ -25,9 +25,9 @@
--input: 0 0% 89.8%;
--ring: 0 0% 3.9%;
--radius: 0.4375rem;
- --sidebar: 0 0% 98%;
+ --sidebar: 0 0% 98.5%;
--sidebar-foreground: 0 0% 3.9%;
- --sidebar-accent: 0 0% 96.1%;
+ --sidebar-accent: 0 0% 95.8%;
--sidebar-accent-foreground: 0 0% 9%;
--sidebar-border: 0 0% 89.8%;
}
@@ -52,9 +52,9 @@
--border: 0 0% 18%;
--input: 0 0% 18%;
--ring: 0 0% 83.1%;
- --sidebar: 0 0% 12%;
+ --sidebar: 0 0% 11.5%;
--sidebar-foreground: 0 0% 98%;
- --sidebar-accent: 0 0% 16%;
+ --sidebar-accent: 0 0% 15.5%;
--sidebar-accent-foreground: 0 0% 98%;
--sidebar-border: 0 0% 18%;
}
diff --git a/webui/src/hooks/useNanobotStream.ts b/webui/src/hooks/useNanobotStream.ts
index 7730c6812..b25f5981a 100644
--- a/webui/src/hooks/useNanobotStream.ts
+++ b/webui/src/hooks/useNanobotStream.ts
@@ -37,6 +37,8 @@ export interface SendImage {
export function useNanobotStream(
chatId: string | null,
initialMessages: UIMessage[] = [],
+ hasPendingToolCalls = false,
+ onTurnEnd?: () => void,
): {
messages: UIMessage[];
isStreaming: boolean;
@@ -51,9 +53,23 @@ export function useNanobotStream(
} {
const { client } = useClient();
const [messages, setMessages] = useState
(initialMessages);
- const [isStreaming, setIsStreaming] = useState(false);
+ /** If the last loaded message is a trace row (e.g. "Using 2 tools"),
+ * the model was still processing when the page loaded — keep the
+ * loading spinner alive so the user sees the model is active. */
+ const initialStreaming = initialMessages.length > 0
+ ? initialMessages[initialMessages.length - 1].kind === "trace"
+ : false;
+ const [isStreaming, setIsStreaming] = useState(initialStreaming || hasPendingToolCalls);
const [streamError, setStreamError] = useState(null);
const buffer = useRef(null);
+ /** Timer that defers ``isStreaming = false`` after ``stream_end``.
+ *
+ * When the model finishes a text segment and calls a tool, the server
+ * sends ``stream_end`` but the agent is still "thinking" while the tool
+ * executes. By deferring the flag reset by a short window (1 s) we keep
+ * the loading spinner alive across tool-call boundaries without needing
+ * backend changes. */
+ const streamEndTimerRef = useRef | null>(null);
useEffect(() => {
return client.onError((err) => setStreamError(err));
@@ -62,21 +78,43 @@ export function useNanobotStream(
const dismissStreamError = useCallback(() => setStreamError(null), []);
// Reset local state when switching chats. ``streamError`` is scoped to the
- // send that triggered it, so a chat swap should wipe it out: a stale
- // "Message too large" banner on a freshly-opened chat-B would confuse the
- // user about which send actually failed (and in which chat).
- useEffect(() => {
- setMessages(initialMessages);
- setIsStreaming(false);
- setStreamError(null);
- buffer.current = null;
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [chatId]);
+ // send that triggered it, so a chat swap should wipe it out: a stale
+ // "Message too large" banner on a freshly-opened chat-B would confuse the
+ // user about which send actually failed (and in which chat).
+ useEffect(() => {
+ setMessages(initialMessages);
+ // Check if the new chat's last message is a trace row — if so, the
+ // model may still be processing.
+ setIsStreaming(
+ initialMessages.length > 0
+ ? initialMessages[initialMessages.length - 1].kind === "trace"
+ : false,
+ );
+ // Also consider hasPendingToolCalls from session history.
+ if (hasPendingToolCalls) {
+ setIsStreaming(true);
+ }
+ setStreamError(null);
+ buffer.current = null;
+ if (streamEndTimerRef.current !== null) {
+ clearTimeout(streamEndTimerRef.current);
+ streamEndTimerRef.current = null;
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [chatId, initialMessages, hasPendingToolCalls]);
useEffect(() => {
if (!chatId) return;
const handle = (ev: InboundEvent) => {
+ // Any incoming event while the debounce timer is alive means the model
+ // is still working (e.g. tool result arrived, more text to stream).
+ // Cancel the pending "stream ended" timer so we don't hide the spinner.
+ if (streamEndTimerRef.current !== null) {
+ clearTimeout(streamEndTimerRef.current);
+ streamEndTimerRef.current = null;
+ }
+
if (ev.event === "delta") {
const id = buffer.current?.messageId ?? crypto.randomUUID();
if (!buffer.current) {
@@ -103,18 +141,31 @@ export function useNanobotStream(
}
if (ev.event === "stream_end") {
- if (!buffer.current) {
- setIsStreaming(false);
- return;
- }
- const finalId = buffer.current.messageId;
+ // stream_end only means the text segment finished — the model may
+ // still be executing tools. Do NOT reset isStreaming here; the
+ // definitive "turn is complete" signal is ``turn_end``.
+ if (!buffer.current) return;
buffer.current = null;
+ return;
+ }
+
+ if (ev.event === "turn_end") {
+ // Definitive signal that the turn is fully complete. Cancel any
+ // pending debounce timer and stop the loading indicator immediately.
+ if (streamEndTimerRef.current !== null) {
+ clearTimeout(streamEndTimerRef.current);
+ streamEndTimerRef.current = null;
+ }
setIsStreaming(false);
setMessages((prev) =>
- prev.map((m) =>
- m.id === finalId ? { ...m, isStreaming: false } : m,
- ),
+ prev.map((m) => (m.isStreaming ? { ...m, isStreaming: false } : m)),
);
+ onTurnEnd?.();
+ return;
+ }
+
+ if (ev.event === "session_updated") {
+ onTurnEnd?.();
return;
}
@@ -157,7 +208,8 @@ export function useNanobotStream(
// flight, drop the placeholder so we don't render the text twice.
const activeId = buffer.current?.messageId;
buffer.current = null;
- setIsStreaming(false);
+ // Do NOT reset isStreaming here — only ``turn_end`` signals that
+ // the full turn (all tool calls + final text) is complete.
setMessages((prev) => {
const filtered = activeId ? prev.filter((m) => m.id !== activeId) : prev;
const content = ev.buttons?.length ? (ev.button_prompt ?? ev.text) : ev.text;
@@ -183,8 +235,12 @@ export function useNanobotStream(
return () => {
unsub();
buffer.current = null;
+ if (streamEndTimerRef.current !== null) {
+ clearTimeout(streamEndTimerRef.current);
+ streamEndTimerRef.current = null;
+ }
};
- }, [chatId, client]);
+ }, [chatId, client, onTurnEnd]);
const send = useCallback(
(content: string, images?: SendImage[]) => {
@@ -205,6 +261,9 @@ export function useNanobotStream(
...(previews ? { images: previews } : {}),
},
]);
+ // Mark streaming immediately so the UI shows the loading indicator
+ // right away, before the first delta arrives from the server.
+ setIsStreaming(true);
const wireMedia = hasImages ? images!.map((i) => i.media) : undefined;
client.sendMessage(chatId, content, wireMedia);
},
diff --git a/webui/src/hooks/useSessions.ts b/webui/src/hooks/useSessions.ts
index 719d4ce16..e05e16a20 100644
--- a/webui/src/hooks/useSessions.ts
+++ b/webui/src/hooks/useSessions.ts
@@ -61,6 +61,7 @@ export function useSessions(): {
chatId,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
+ title: "",
preview: "",
},
...prev.filter((s) => s.key !== key),
@@ -84,6 +85,9 @@ export function useSessionHistory(key: string | null): {
messages: UIMessage[];
loading: boolean;
error: string | null;
+ /** ``true`` when the last persisted assistant turn has ``tool_calls`` but no
+ * final text yet — the model was still processing when the page loaded. */
+ hasPendingToolCalls: boolean;
} {
const { token } = useClient();
const [state, setState] = useState<{
@@ -91,11 +95,13 @@ export function useSessionHistory(key: string | null): {
messages: UIMessage[];
loading: boolean;
error: string | null;
+ hasPendingToolCalls: boolean;
}>({
key: null,
messages: [],
loading: false,
error: null,
+ hasPendingToolCalls: false,
});
useEffect(() => {
@@ -105,6 +111,7 @@ export function useSessionHistory(key: string | null): {
messages: [],
loading: false,
error: null,
+ hasPendingToolCalls: false,
});
return;
}
@@ -116,6 +123,7 @@ export function useSessionHistory(key: string | null): {
messages: [],
loading: true,
error: null,
+ hasPendingToolCalls: false,
});
(async () => {
try {
@@ -146,11 +154,21 @@ export function useSessionHistory(key: string | null): {
},
];
});
+ // Tool result rows can trail the assistant tool-call row while the turn
+ // is still running, so check the last conversational row.
+ const lastRaw = [...body.messages]
+ .reverse()
+ .find((m) => m.role === "user" || m.role === "assistant");
+ const hasPending =
+ lastRaw?.role === "assistant" &&
+ Array.isArray(lastRaw.tool_calls) &&
+ lastRaw.tool_calls.length > 0;
setState({
key,
messages: ui,
loading: false,
error: null,
+ hasPendingToolCalls: hasPending,
});
} catch (e) {
if (cancelled) return;
@@ -162,6 +180,7 @@ export function useSessionHistory(key: string | null): {
messages: [],
loading: false,
error: null,
+ hasPendingToolCalls: false,
});
} else {
setState({
@@ -169,6 +188,7 @@ export function useSessionHistory(key: string | null): {
messages: [],
loading: false,
error: (e as Error).message,
+ hasPendingToolCalls: false,
});
}
}
@@ -179,19 +199,20 @@ export function useSessionHistory(key: string | null): {
}, [key, token]);
if (!key) {
- return { messages: EMPTY_MESSAGES, loading: false, error: null };
+ return { messages: EMPTY_MESSAGES, loading: false, error: null, hasPendingToolCalls: false };
}
// Even before the effect above commits its loading state, never surface the
// previous session's payload for a brand-new key.
if (state.key !== key) {
- return { messages: EMPTY_MESSAGES, loading: true, error: null };
+ return { messages: EMPTY_MESSAGES, loading: true, error: null, hasPendingToolCalls: false };
}
return {
messages: state.messages,
loading: state.loading,
error: state.error,
+ hasPendingToolCalls: state.hasPendingToolCalls,
};
}
@@ -201,7 +222,7 @@ export function sessionTitle(
firstUserMessage?: string,
): string {
return deriveTitle(
- firstUserMessage || session.preview,
+ session.title || firstUserMessage || session.preview,
i18n.t("chat.newChat"),
);
}
diff --git a/webui/src/i18n/locales/en/common.json b/webui/src/i18n/locales/en/common.json
index aa6b3165b..8368d7ee7 100644
--- a/webui/src/i18n/locales/en/common.json
+++ b/webui/src/i18n/locales/en/common.json
@@ -9,6 +9,18 @@
"title": "Couldn't reach nanobot",
"gatewayHint": "Make sure the gateway is running (`nanobot gateway`) and that this page is open on the same machine."
},
+ "auth": {
+ "title": "Authentication required",
+ "hint": "Enter the secret configured as tokenIssueSecret in your gateway config.",
+ "placeholder": "Password",
+ "submit": "Connect",
+ "invalid": "Invalid password. Try again."
+ },
+ "account": {
+ "section": "Account",
+ "logoutHint": "Disconnect this browser from the gateway.",
+ "logout": "Sign out"
+ },
"documentTitle": {
"base": "nanobot",
"chat": "{{title}} · nanobot"
@@ -18,11 +30,19 @@
}
},
"sidebar": {
+ "navigation": "Sidebar navigation",
+ "globalActions": "Global actions",
"collapse": "Collapse sidebar",
"toggleTheme": "Toggle theme",
+ "home": "Home",
"newChat": "New chat",
+ "searchAria": "Search chats",
+ "searchPlaceholder": "Search chats",
+ "searchResults": "Results",
+ "noSearchResults": "No matching chats.",
"recent": "Recent",
"refreshSessions": "Refresh sessions",
+ "settings": "Settings",
"language": {
"label": "Language",
"ariaLabel": "Change language"
@@ -34,7 +54,12 @@
"noSessions": "No sessions yet.",
"actions": "Chat actions for {{title}}",
"delete": "Delete",
- "newChat": "New chat"
+ "newChat": "New chat",
+ "groups": {
+ "today": "Today",
+ "yesterday": "Yesterday",
+ "earlier": "Earlier"
+ }
},
"deleteConfirm": {
"title": "Delete “{{title}}”?",
@@ -53,19 +78,100 @@
"thread": {
"loadingConversation": "Loading conversation…",
"empty": {
- "description": "Ask questions, continue local work, or start a new thread."
+ "greeting": "What can I do for you?",
+ "quickActions": {
+ "plan": {
+ "title": "Create a project plan",
+ "prompt": "Create a concise project plan for what I should build next."
+ },
+ "analyze": {
+ "title": "Analyze this data",
+ "prompt": "Help me analyze this data and call out the most important patterns."
+ },
+ "brainstorm": {
+ "title": "Brainstorm ideas",
+ "prompt": "Brainstorm a few practical ideas and tradeoffs for this problem."
+ },
+ "code": {
+ "title": "Write code",
+ "prompt": "Help me write the code for this task, starting with the smallest useful change."
+ },
+ "summarize": {
+ "title": "Summarize this document",
+ "prompt": "Summarize this document and list the key takeaways."
+ },
+ "more": {
+ "title": "More",
+ "prompt": "Show me a few useful ways you can help in this workspace."
+ }
+ }
},
"header": {
- "toggleSidebar": "Toggle sidebar"
+ "toggleSidebar": "Toggle sidebar",
+ "newChat": "Start a new chat",
+ "toggleTheme": "Toggle theme from header",
+ "settings": "Open settings"
},
"composer": {
"placeholderThread": "Type your message…",
- "placeholderHero": "What's on your mind?",
+ "placeholderHero": "Ask anything...",
"placeholderOpening": "Opening a new chat…",
+ "placeholderStreaming": "Model is responding…",
"inputAria": "Message input",
"sendHint": "Enter to send · Shift+Enter for newline",
"send": "Send message",
"attachImage": "Attach image",
+ "tools": {
+ "search": "Search",
+ "reason": "Reason",
+ "deepResearch": "Deep research",
+ "voice": "Voice input"
+ },
+ "slash": {
+ "ariaLabel": "Slash commands",
+ "label": "commands",
+ "navigateHint": "↑↓ Navigate",
+ "selectHint": "Enter/Tab Select",
+ "closeHint": "Esc Close",
+ "commands": {
+ "new": {
+ "title": "New chat",
+ "description": "Stop the current task and start a fresh conversation."
+ },
+ "stop": {
+ "title": "Stop current task",
+ "description": "Cancel the active agent turn for this chat."
+ },
+ "restart": {
+ "title": "Restart nanobot",
+ "description": "Restart the bot process in place."
+ },
+ "status": {
+ "title": "Show status",
+ "description": "Display runtime, provider, and channel status."
+ },
+ "history": {
+ "title": "Show conversation history",
+ "description": "Print the last N persisted conversation messages."
+ },
+ "dream": {
+ "title": "Run Dream",
+ "description": "Manually trigger memory consolidation."
+ },
+ "dream_log": {
+ "title": "Show Dream log",
+ "description": "Show what the last Dream consolidation changed."
+ },
+ "dream_restore": {
+ "title": "Restore memory",
+ "description": "Revert memory to a previous Dream snapshot."
+ },
+ "help": {
+ "title": "Show help",
+ "description": "List available slash commands."
+ }
+ }
+ },
"encoding": "Encoding…",
"remove": "Remove attachment",
"normalizedSizeHint": "{{orig}} → {{current}} (auto)",
@@ -85,7 +191,9 @@
"assistantTyping": "Assistant is typing",
"toolSingle": "Using a tool",
"toolMany": "Used {{count}} tools",
- "imageAttachment": "Image attachment"
+ "imageAttachment": "Image attachment",
+ "copyReply": "Copy reply",
+ "copiedReply": "Copied reply"
},
"lightbox": {
"title": "Image preview",
diff --git a/webui/src/i18n/locales/es/common.json b/webui/src/i18n/locales/es/common.json
index 93bef843e..80f809ae5 100644
--- a/webui/src/i18n/locales/es/common.json
+++ b/webui/src/i18n/locales/es/common.json
@@ -53,7 +53,34 @@
"thread": {
"loadingConversation": "Cargando conversación…",
"empty": {
- "description": "Haz preguntas, continúa tu trabajo local o inicia un nuevo hilo."
+ "description": "Haz preguntas, continúa tu trabajo local o inicia un nuevo hilo.",
+ "greeting": "¿Qué puedo hacer por ti?",
+ "quickActions": {
+ "plan": {
+ "title": "Crear un plan de proyecto",
+ "prompt": "Crea un plan de proyecto conciso para lo que debería construir después."
+ },
+ "analyze": {
+ "title": "Analizar estos datos",
+ "prompt": "Ayúdame a analizar estos datos y destaca los patrones más importantes."
+ },
+ "brainstorm": {
+ "title": "Lluvia de ideas",
+ "prompt": "Propón algunas ideas prácticas y sus compensaciones para este problema."
+ },
+ "code": {
+ "title": "Escribir código",
+ "prompt": "Ayúdame a escribir el código para esta tarea, empezando por el cambio útil más pequeño."
+ },
+ "summarize": {
+ "title": "Resumir este documento",
+ "prompt": "Resume este documento y enumera las conclusiones clave."
+ },
+ "more": {
+ "title": "Más",
+ "prompt": "Muéstrame algunas formas útiles en las que puedes ayudar en este workspace."
+ }
+ }
},
"header": {
"toggleSidebar": "Mostrar u ocultar la barra lateral"
@@ -62,6 +89,7 @@
"placeholderThread": "Escribe tu mensaje…",
"placeholderHero": "¿Qué tienes en mente?",
"placeholderOpening": "Abriendo un nuevo chat…",
+ "placeholderStreaming": "El modelo está respondiendo…",
"inputAria": "Entrada de mensaje",
"sendHint": "Enter para enviar · Shift+Enter para nueva línea",
"send": "Enviar mensaje",
@@ -76,6 +104,51 @@
"decode_failed": "No se pudo decodificar esta imagen",
"too_large": "Imagen demasiado grande — prueba una más pequeña",
"io": "No se pudo leer este archivo"
+ },
+ "slash": {
+ "ariaLabel": "Comandos slash",
+ "label": "comandos",
+ "navigateHint": "↑↓ Navegar",
+ "selectHint": "Enter/Tab Insertar",
+ "closeHint": "Esc Cerrar",
+ "commands": {
+ "new": {
+ "title": "Nuevo chat",
+ "description": "Detiene la tarea actual e inicia una conversación nueva."
+ },
+ "stop": {
+ "title": "Detener tarea actual",
+ "description": "Cancela el turno activo del agent en este chat."
+ },
+ "restart": {
+ "title": "Reiniciar nanobot",
+ "description": "Reinicia el proceso del bot en el mismo lugar."
+ },
+ "status": {
+ "title": "Mostrar estado",
+ "description": "Muestra el estado del runtime, provider y channels."
+ },
+ "history": {
+ "title": "Mostrar historial",
+ "description": "Imprime los últimos N mensajes persistidos de la conversación."
+ },
+ "dream": {
+ "title": "Ejecutar Dream",
+ "description": "Activa manualmente la consolidación de memoria."
+ },
+ "dream_log": {
+ "title": "Mostrar registro de Dream",
+ "description": "Muestra qué cambió la última consolidación Dream."
+ },
+ "dream_restore": {
+ "title": "Restaurar memoria",
+ "description": "Revierte la memoria a una instantánea Dream anterior."
+ },
+ "help": {
+ "title": "Mostrar ayuda",
+ "description": "Lista los comandos slash disponibles."
+ }
+ }
}
},
"scrollToBottom": "Desplazarse al final"
diff --git a/webui/src/i18n/locales/fr/common.json b/webui/src/i18n/locales/fr/common.json
index ba9e759b3..d5a37c9b0 100644
--- a/webui/src/i18n/locales/fr/common.json
+++ b/webui/src/i18n/locales/fr/common.json
@@ -53,7 +53,34 @@
"thread": {
"loadingConversation": "Chargement de la conversation…",
"empty": {
- "description": "Posez des questions, poursuivez votre travail local ou démarrez un nouveau fil."
+ "description": "Posez des questions, poursuivez votre travail local ou démarrez un nouveau fil.",
+ "greeting": "Que puis-je faire pour vous ?",
+ "quickActions": {
+ "plan": {
+ "title": "Créer un plan de projet",
+ "prompt": "Créez un plan de projet concis pour ce que je devrais construire ensuite."
+ },
+ "analyze": {
+ "title": "Analyser ces données",
+ "prompt": "Aidez-moi à analyser ces données et à faire ressortir les tendances les plus importantes."
+ },
+ "brainstorm": {
+ "title": "Trouver des idées",
+ "prompt": "Proposez quelques idées pratiques et leurs compromis pour ce problème."
+ },
+ "code": {
+ "title": "Écrire du code",
+ "prompt": "Aidez-moi à écrire le code pour cette tâche, en commençant par le plus petit changement utile."
+ },
+ "summarize": {
+ "title": "Résumer ce document",
+ "prompt": "Résumez ce document et listez les points clés à retenir."
+ },
+ "more": {
+ "title": "Plus",
+ "prompt": "Montrez-moi quelques façons utiles dont vous pouvez m’aider dans cet espace de travail."
+ }
+ }
},
"header": {
"toggleSidebar": "Afficher ou masquer la barre latérale"
@@ -62,6 +89,7 @@
"placeholderThread": "Saisissez votre message…",
"placeholderHero": "Qu’avez-vous en tête ?",
"placeholderOpening": "Ouverture d’une nouvelle discussion…",
+ "placeholderStreaming": "Le modèle est en train de répondre…",
"inputAria": "Champ de message",
"sendHint": "Entrée pour envoyer · Maj+Entrée pour un retour à la ligne",
"send": "Envoyer le message",
@@ -76,6 +104,51 @@
"decode_failed": "Impossible de décoder cette image",
"too_large": "Image trop grande — essayez-en une plus petite",
"io": "Impossible de lire ce fichier"
+ },
+ "slash": {
+ "ariaLabel": "Commandes slash",
+ "label": "commandes",
+ "navigateHint": "↑↓ Naviguer",
+ "selectHint": "Entrée/Tab Insérer",
+ "closeHint": "Échap Fermer",
+ "commands": {
+ "new": {
+ "title": "Nouvelle discussion",
+ "description": "Arrêter la tâche en cours et démarrer une nouvelle conversation."
+ },
+ "stop": {
+ "title": "Arrêter la tâche en cours",
+ "description": "Annuler le tour agent actif pour cette discussion."
+ },
+ "restart": {
+ "title": "Redémarrer nanobot",
+ "description": "Redémarrer le processus du bot sur place."
+ },
+ "status": {
+ "title": "Afficher l’état",
+ "description": "Afficher l’état du runtime, du provider et des channels."
+ },
+ "history": {
+ "title": "Afficher l’historique",
+ "description": "Afficher les N derniers messages persistés de la conversation."
+ },
+ "dream": {
+ "title": "Lancer Dream",
+ "description": "Déclencher manuellement la consolidation de la mémoire."
+ },
+ "dream_log": {
+ "title": "Afficher le journal Dream",
+ "description": "Afficher ce que la dernière consolidation Dream a changé."
+ },
+ "dream_restore": {
+ "title": "Restaurer la mémoire",
+ "description": "Revenir à un instantané Dream précédent."
+ },
+ "help": {
+ "title": "Afficher l’aide",
+ "description": "Lister les commandes slash disponibles."
+ }
+ }
}
},
"scrollToBottom": "Faire défiler vers le bas"
diff --git a/webui/src/i18n/locales/id/common.json b/webui/src/i18n/locales/id/common.json
index 9775372cc..fdc5febfe 100644
--- a/webui/src/i18n/locales/id/common.json
+++ b/webui/src/i18n/locales/id/common.json
@@ -53,7 +53,34 @@
"thread": {
"loadingConversation": "Memuat percakapan…",
"empty": {
- "description": "Ajukan pertanyaan, lanjutkan pekerjaan lokal, atau mulai thread baru."
+ "description": "Ajukan pertanyaan, lanjutkan pekerjaan lokal, atau mulai thread baru.",
+ "greeting": "Apa yang bisa saya bantu?",
+ "quickActions": {
+ "plan": {
+ "title": "Buat rencana proyek",
+ "prompt": "Buat rencana proyek ringkas untuk apa yang sebaiknya saya bangun berikutnya."
+ },
+ "analyze": {
+ "title": "Analisis data ini",
+ "prompt": "Bantu saya menganalisis data ini dan soroti pola yang paling penting."
+ },
+ "brainstorm": {
+ "title": "Brainstorm ide",
+ "prompt": "Brainstorm beberapa ide praktis dan tradeoff untuk masalah ini."
+ },
+ "code": {
+ "title": "Tulis kode",
+ "prompt": "Bantu saya menulis kode untuk tugas ini, mulai dari perubahan berguna yang paling kecil."
+ },
+ "summarize": {
+ "title": "Ringkas dokumen ini",
+ "prompt": "Ringkas dokumen ini dan daftar poin-poin utamanya."
+ },
+ "more": {
+ "title": "Lainnya",
+ "prompt": "Tunjukkan beberapa cara berguna Anda dapat membantu di workspace ini."
+ }
+ }
},
"header": {
"toggleSidebar": "Tampilkan atau sembunyikan sidebar"
@@ -62,6 +89,7 @@
"placeholderThread": "Ketik pesan Anda…",
"placeholderHero": "Apa yang sedang Anda pikirkan?",
"placeholderOpening": "Membuka obrolan baru…",
+ "placeholderStreaming": "Model sedang merespons…",
"inputAria": "Input pesan",
"sendHint": "Enter untuk kirim · Shift+Enter untuk baris baru",
"send": "Kirim pesan",
@@ -76,6 +104,51 @@
"decode_failed": "Tidak dapat mendekode gambar ini",
"too_large": "Gambar terlalu besar — coba yang lebih kecil",
"io": "Tidak dapat membaca file ini"
+ },
+ "slash": {
+ "ariaLabel": "Perintah slash",
+ "label": "perintah",
+ "navigateHint": "↑↓ Pilih",
+ "selectHint": "Enter/Tab Sisipkan",
+ "closeHint": "Esc Tutup",
+ "commands": {
+ "new": {
+ "title": "Obrolan baru",
+ "description": "Hentikan tugas saat ini dan mulai percakapan baru."
+ },
+ "stop": {
+ "title": "Hentikan tugas saat ini",
+ "description": "Batalkan giliran agent yang sedang aktif di chat ini."
+ },
+ "restart": {
+ "title": "Mulai ulang nanobot",
+ "description": "Mulai ulang proses bot di tempat."
+ },
+ "status": {
+ "title": "Tampilkan status",
+ "description": "Tampilkan status runtime, provider, dan channel."
+ },
+ "history": {
+ "title": "Tampilkan riwayat",
+ "description": "Cetak N pesan percakapan tersimpan terbaru."
+ },
+ "dream": {
+ "title": "Jalankan Dream",
+ "description": "Picu konsolidasi memori secara manual."
+ },
+ "dream_log": {
+ "title": "Tampilkan log Dream",
+ "description": "Tampilkan perubahan dari konsolidasi Dream terakhir."
+ },
+ "dream_restore": {
+ "title": "Pulihkan memori",
+ "description": "Kembalikan memori ke snapshot Dream sebelumnya."
+ },
+ "help": {
+ "title": "Tampilkan bantuan",
+ "description": "Daftar perintah slash yang tersedia."
+ }
+ }
}
},
"scrollToBottom": "Gulir ke bawah"
diff --git a/webui/src/i18n/locales/ja/common.json b/webui/src/i18n/locales/ja/common.json
index 6868dec5c..0fb012146 100644
--- a/webui/src/i18n/locales/ja/common.json
+++ b/webui/src/i18n/locales/ja/common.json
@@ -53,7 +53,34 @@
"thread": {
"loadingConversation": "会話を読み込み中…",
"empty": {
- "description": "質問したり、ローカル作業を続けたり、新しいスレッドを始めたりできます。"
+ "description": "質問したり、ローカル作業を続けたり、新しいスレッドを始めたりできます。",
+ "greeting": "何をお手伝いしましょうか?",
+ "quickActions": {
+ "plan": {
+ "title": "プロジェクト計画を作成",
+ "prompt": "次に作るものについて、簡潔なプロジェクト計画を作成してください。"
+ },
+ "analyze": {
+ "title": "このデータを分析",
+ "prompt": "このデータを分析し、最も重要なパターンを指摘してください。"
+ },
+ "brainstorm": {
+ "title": "アイデアを出す",
+ "prompt": "この問題について、実用的なアイデアとトレードオフをいくつか出してください。"
+ },
+ "code": {
+ "title": "コードを書く",
+ "prompt": "このタスクのコードを書くのを手伝ってください。まず最小限の有用な変更から始めてください。"
+ },
+ "summarize": {
+ "title": "この文書を要約",
+ "prompt": "この文書を要約し、重要なポイントを列挙してください。"
+ },
+ "more": {
+ "title": "その他",
+ "prompt": "このワークスペースであなたが手伝える便利な方法をいくつか見せてください。"
+ }
+ }
},
"header": {
"toggleSidebar": "サイドバーを切り替える"
@@ -62,6 +89,7 @@
"placeholderThread": "メッセージを入力…",
"placeholderHero": "何を考えていますか?",
"placeholderOpening": "新しいチャットを開いています…",
+ "placeholderStreaming": "モデルが応答しています…",
"inputAria": "メッセージ入力欄",
"sendHint": "Enter で送信 · Shift+Enter で改行",
"send": "メッセージを送信",
@@ -76,6 +104,51 @@
"decode_failed": "この画像をデコードできません",
"too_large": "画像が大きすぎます。小さいものを選んでください",
"io": "このファイルを読み込めません"
+ },
+ "slash": {
+ "ariaLabel": "スラッシュコマンド",
+ "label": "コマンド",
+ "navigateHint": "↑↓ 選択",
+ "selectHint": "Enter/Tab 入力",
+ "closeHint": "Esc 閉じる",
+ "commands": {
+ "new": {
+ "title": "新しいチャット",
+ "description": "現在のタスクを停止して、新しい会話を開始します。"
+ },
+ "stop": {
+ "title": "現在のタスクを停止",
+ "description": "このチャットで実行中の agent ターンをキャンセルします。"
+ },
+ "restart": {
+ "title": "nanobot を再起動",
+ "description": "bot プロセスをその場で再起動します。"
+ },
+ "status": {
+ "title": "ステータスを表示",
+ "description": "ランタイム、provider、channel の状態を表示します。"
+ },
+ "history": {
+ "title": "会話履歴を表示",
+ "description": "保存済みの直近 N 件の会話メッセージを表示します。"
+ },
+ "dream": {
+ "title": "Dream を実行",
+ "description": "メモリ統合を手動で開始します。"
+ },
+ "dream_log": {
+ "title": "Dream ログを表示",
+ "description": "直近の Dream 統合で変更された内容を表示します。"
+ },
+ "dream_restore": {
+ "title": "メモリを復元",
+ "description": "以前の Dream スナップショットへメモリを戻します。"
+ },
+ "help": {
+ "title": "ヘルプを表示",
+ "description": "利用可能なスラッシュコマンドを一覧表示します。"
+ }
+ }
}
},
"scrollToBottom": "一番下へスクロール"
diff --git a/webui/src/i18n/locales/ko/common.json b/webui/src/i18n/locales/ko/common.json
index bb89af259..75ecaf147 100644
--- a/webui/src/i18n/locales/ko/common.json
+++ b/webui/src/i18n/locales/ko/common.json
@@ -53,7 +53,34 @@
"thread": {
"loadingConversation": "대화 불러오는 중…",
"empty": {
- "description": "질문을 하거나, 로컬 작업을 이어가거나, 새 스레드를 시작할 수 있습니다."
+ "description": "질문을 하거나, 로컬 작업을 이어가거나, 새 스레드를 시작할 수 있습니다.",
+ "greeting": "무엇을 도와드릴까요?",
+ "quickActions": {
+ "plan": {
+ "title": "프로젝트 계획 만들기",
+ "prompt": "다음에 만들 것에 대한 간결한 프로젝트 계획을 작성해 주세요."
+ },
+ "analyze": {
+ "title": "이 데이터 분석하기",
+ "prompt": "이 데이터를 분석하고 가장 중요한 패턴을 짚어 주세요."
+ },
+ "brainstorm": {
+ "title": "아이디어 브레인스토밍",
+ "prompt": "이 문제에 대한 실용적인 아이디어와 트레이드오프를 몇 가지 제안해 주세요."
+ },
+ "code": {
+ "title": "코드 작성하기",
+ "prompt": "이 작업을 위한 코드를 작성해 주세요. 가장 작은 유용한 변경부터 시작해 주세요."
+ },
+ "summarize": {
+ "title": "문서 요약하기",
+ "prompt": "이 문서를 요약하고 핵심 내용을 정리해 주세요."
+ },
+ "more": {
+ "title": "더 보기",
+ "prompt": "이 워크스페이스에서 도와줄 수 있는 유용한 방법을 몇 가지 보여 주세요."
+ }
+ }
},
"header": {
"toggleSidebar": "사이드바 전환"
@@ -62,6 +89,7 @@
"placeholderThread": "메시지를 입력하세요…",
"placeholderHero": "무슨 생각을 하고 있나요?",
"placeholderOpening": "새 채팅을 여는 중…",
+ "placeholderStreaming": "모델이 응답 중입니다…",
"inputAria": "메시지 입력",
"sendHint": "Enter로 전송 · Shift+Enter로 줄바꿈",
"send": "메시지 보내기",
@@ -76,6 +104,51 @@
"decode_failed": "이 이미지를 디코딩할 수 없습니다",
"too_large": "이미지가 너무 큽니다. 더 작은 걸로 시도해 주세요",
"io": "이 파일을 읽을 수 없습니다"
+ },
+ "slash": {
+ "ariaLabel": "슬래시 명령",
+ "label": "명령",
+ "navigateHint": "↑↓ 선택",
+ "selectHint": "Enter/Tab 입력",
+ "closeHint": "Esc 닫기",
+ "commands": {
+ "new": {
+ "title": "새 채팅",
+ "description": "현재 작업을 중지하고 새 대화를 시작합니다."
+ },
+ "stop": {
+ "title": "현재 작업 중지",
+ "description": "이 채팅에서 실행 중인 agent 턴을 취소합니다."
+ },
+ "restart": {
+ "title": "nanobot 재시작",
+ "description": "bot 프로세스를 제자리에서 재시작합니다."
+ },
+ "status": {
+ "title": "상태 보기",
+ "description": "런타임, provider, channel 상태를 표시합니다."
+ },
+ "history": {
+ "title": "대화 기록 보기",
+ "description": "저장된 최근 N개의 대화 메시지를 출력합니다."
+ },
+ "dream": {
+ "title": "Dream 실행",
+ "description": "메모리 정리를 수동으로 트리거합니다."
+ },
+ "dream_log": {
+ "title": "Dream 로그 보기",
+ "description": "마지막 Dream 정리에서 변경된 내용을 표시합니다."
+ },
+ "dream_restore": {
+ "title": "메모리 복원",
+ "description": "이전 Dream 스냅샷으로 메모리를 되돌립니다."
+ },
+ "help": {
+ "title": "도움말 보기",
+ "description": "사용 가능한 슬래시 명령을 나열합니다."
+ }
+ }
}
},
"scrollToBottom": "맨 아래로 스크롤"
diff --git a/webui/src/i18n/locales/vi/common.json b/webui/src/i18n/locales/vi/common.json
index f2b64e33b..5e2f713a4 100644
--- a/webui/src/i18n/locales/vi/common.json
+++ b/webui/src/i18n/locales/vi/common.json
@@ -53,7 +53,34 @@
"thread": {
"loadingConversation": "Đang tải cuộc trò chuyện…",
"empty": {
- "description": "Hãy đặt câu hỏi, tiếp tục công việc cục bộ hoặc bắt đầu một luồng mới."
+ "description": "Hãy đặt câu hỏi, tiếp tục công việc cục bộ hoặc bắt đầu một luồng mới.",
+ "greeting": "Tôi có thể giúp gì cho bạn?",
+ "quickActions": {
+ "plan": {
+ "title": "Tạo kế hoạch dự án",
+ "prompt": "Tạo một kế hoạch dự án ngắn gọn cho việc tôi nên xây dựng tiếp theo."
+ },
+ "analyze": {
+ "title": "Phân tích dữ liệu này",
+ "prompt": "Giúp tôi phân tích dữ liệu này và chỉ ra các mẫu quan trọng nhất."
+ },
+ "brainstorm": {
+ "title": "Động não ý tưởng",
+ "prompt": "Động não vài ý tưởng thực tế và các đánh đổi cho vấn đề này."
+ },
+ "code": {
+ "title": "Viết mã",
+ "prompt": "Giúp tôi viết mã cho nhiệm vụ này, bắt đầu từ thay đổi hữu ích nhỏ nhất."
+ },
+ "summarize": {
+ "title": "Tóm tắt tài liệu này",
+ "prompt": "Tóm tắt tài liệu này và liệt kê các ý chính."
+ },
+ "more": {
+ "title": "Thêm",
+ "prompt": "Cho tôi xem vài cách hữu ích mà bạn có thể giúp trong workspace này."
+ }
+ }
},
"header": {
"toggleSidebar": "Bật/tắt thanh bên"
@@ -62,6 +89,7 @@
"placeholderThread": "Nhập tin nhắn…",
"placeholderHero": "Bạn đang nghĩ gì?",
"placeholderOpening": "Đang mở cuộc trò chuyện mới…",
+ "placeholderStreaming": "Mô hình đang trả lời…",
"inputAria": "Ô nhập tin nhắn",
"sendHint": "Enter để gửi · Shift+Enter để xuống dòng",
"send": "Gửi tin nhắn",
@@ -76,6 +104,51 @@
"decode_failed": "Không thể giải mã ảnh này",
"too_large": "Ảnh quá lớn — hãy thử ảnh nhỏ hơn",
"io": "Không thể đọc tệp này"
+ },
+ "slash": {
+ "ariaLabel": "Lệnh slash",
+ "label": "lệnh",
+ "navigateHint": "↑↓ Chọn",
+ "selectHint": "Enter/Tab Chèn",
+ "closeHint": "Esc Đóng",
+ "commands": {
+ "new": {
+ "title": "Cuộc trò chuyện mới",
+ "description": "Dừng tác vụ hiện tại và bắt đầu một cuộc trò chuyện mới."
+ },
+ "stop": {
+ "title": "Dừng tác vụ hiện tại",
+ "description": "Hủy lượt agent đang chạy trong cuộc trò chuyện này."
+ },
+ "restart": {
+ "title": "Khởi động lại nanobot",
+ "description": "Khởi động lại tiến trình bot tại chỗ."
+ },
+ "status": {
+ "title": "Hiển thị trạng thái",
+ "description": "Hiển thị trạng thái runtime, provider và channel."
+ },
+ "history": {
+ "title": "Hiển thị lịch sử",
+ "description": "In N tin nhắn hội thoại đã lưu gần nhất."
+ },
+ "dream": {
+ "title": "Chạy Dream",
+ "description": "Kích hoạt thủ công quá trình hợp nhất bộ nhớ."
+ },
+ "dream_log": {
+ "title": "Hiển thị nhật ký Dream",
+ "description": "Hiển thị những gì lần hợp nhất Dream gần nhất đã thay đổi."
+ },
+ "dream_restore": {
+ "title": "Khôi phục bộ nhớ",
+ "description": "Đưa bộ nhớ về một snapshot Dream trước đó."
+ },
+ "help": {
+ "title": "Hiển thị trợ giúp",
+ "description": "Liệt kê các lệnh slash có sẵn."
+ }
+ }
}
},
"scrollToBottom": "Cuộn xuống cuối"
diff --git a/webui/src/i18n/locales/zh-CN/common.json b/webui/src/i18n/locales/zh-CN/common.json
index 349e2625c..88334f358 100644
--- a/webui/src/i18n/locales/zh-CN/common.json
+++ b/webui/src/i18n/locales/zh-CN/common.json
@@ -18,11 +18,19 @@
}
},
"sidebar": {
+ "navigation": "侧边栏导航",
+ "globalActions": "全局操作",
"collapse": "收起侧边栏",
"toggleTheme": "切换主题",
+ "home": "首页",
"newChat": "新建对话",
+ "searchAria": "搜索会话",
+ "searchPlaceholder": "搜索会话",
+ "searchResults": "搜索结果",
+ "noSearchResults": "没有匹配的会话。",
"recent": "最近对话",
"refreshSessions": "刷新会话",
+ "settings": "设置",
"language": {
"label": "语言",
"ariaLabel": "切换语言"
@@ -34,7 +42,12 @@
"noSessions": "还没有会话。",
"actions": "“{{title}}” 的会话操作",
"delete": "删除",
- "newChat": "新建对话"
+ "newChat": "新建对话",
+ "groups": {
+ "today": "今天",
+ "yesterday": "昨天",
+ "earlier": "更早"
+ }
},
"deleteConfirm": {
"title": "删除“{{title}}”?",
@@ -53,19 +66,100 @@
"thread": {
"loadingConversation": "正在加载对话…",
"empty": {
- "description": "可以提问、继续本地工作,或者开启一个新线程。"
+ "greeting": "我可以帮你做什么?",
+ "quickActions": {
+ "plan": {
+ "title": "创建项目计划",
+ "prompt": "帮我为接下来要做的事情写一份简洁的项目计划。"
+ },
+ "analyze": {
+ "title": "分析这些数据",
+ "prompt": "帮我分析这些数据,并指出最重要的模式。"
+ },
+ "brainstorm": {
+ "title": "头脑风暴想法",
+ "prompt": "围绕这个问题头脑风暴几个实用方案,并说明取舍。"
+ },
+ "code": {
+ "title": "编写代码",
+ "prompt": "帮我为这个任务写代码,先从最小可用改动开始。"
+ },
+ "summarize": {
+ "title": "总结这份文档",
+ "prompt": "帮我总结这份文档,并列出关键要点。"
+ },
+ "more": {
+ "title": "更多",
+ "prompt": "展示几个你在这个工作区里可以帮我的实用方式。"
+ }
+ }
},
"header": {
- "toggleSidebar": "切换侧边栏"
+ "toggleSidebar": "切换侧边栏",
+ "newChat": "从顶部新建对话",
+ "toggleTheme": "从顶部切换主题",
+ "settings": "打开设置"
},
"composer": {
"placeholderThread": "输入消息…",
- "placeholderHero": "你在想什么?",
+ "placeholderHero": "问任何问题...",
"placeholderOpening": "正在打开新对话…",
+ "placeholderStreaming": "模型正在回复…",
"inputAria": "消息输入框",
"sendHint": "Enter 发送 · Shift+Enter 换行",
"send": "发送消息",
"attachImage": "添加图片",
+ "tools": {
+ "search": "搜索",
+ "reason": "推理",
+ "deepResearch": "深度研究",
+ "voice": "语音输入"
+ },
+ "slash": {
+ "ariaLabel": "斜杠命令",
+ "label": "命令",
+ "navigateHint": "↑↓ 选择",
+ "selectHint": "Enter/Tab 填入",
+ "closeHint": "Esc 关闭",
+ "commands": {
+ "new": {
+ "title": "新建对话",
+ "description": "停止当前任务,并开始一个新的对话。"
+ },
+ "stop": {
+ "title": "停止当前任务",
+ "description": "取消这个对话中正在运行的 agent 回合。"
+ },
+ "restart": {
+ "title": "重启 nanobot",
+ "description": "原地重启 bot 进程。"
+ },
+ "status": {
+ "title": "查看状态",
+ "description": "显示运行时、provider 和 channel 状态。"
+ },
+ "history": {
+ "title": "查看对话历史",
+ "description": "打印最近 N 条已持久化的对话消息。"
+ },
+ "dream": {
+ "title": "运行 Dream",
+ "description": "手动触发记忆整理。"
+ },
+ "dream_log": {
+ "title": "查看 Dream 日志",
+ "description": "查看上一次 Dream 整理改变了什么。"
+ },
+ "dream_restore": {
+ "title": "恢复记忆",
+ "description": "将记忆恢复到之前的 Dream 快照。"
+ },
+ "help": {
+ "title": "查看帮助",
+ "description": "列出可用的斜杠命令。"
+ }
+ }
+ },
"encoding": "处理中…",
"remove": "移除附件",
"normalizedSizeHint": "{{orig}} → {{current}}(已自动压缩)",
@@ -85,7 +179,9 @@
"assistantTyping": "助手正在输入",
"toolSingle": "正在使用工具",
"toolMany": "已使用 {{count}} 个工具",
- "imageAttachment": "图片附件"
+ "imageAttachment": "图片附件",
+ "copyReply": "复制回复",
+ "copiedReply": "已复制回复"
},
"lightbox": {
"title": "图片预览",
diff --git a/webui/src/i18n/locales/zh-TW/common.json b/webui/src/i18n/locales/zh-TW/common.json
index b8a1e83da..5a3b7f1d6 100644
--- a/webui/src/i18n/locales/zh-TW/common.json
+++ b/webui/src/i18n/locales/zh-TW/common.json
@@ -53,7 +53,34 @@
"thread": {
"loadingConversation": "正在載入對話…",
"empty": {
- "description": "你可以提問、延續本地工作,或是開始新的執行緒。"
+ "description": "你可以提問、延續本地工作,或是開始新的執行緒。",
+ "greeting": "我可以幫你做什麼?",
+ "quickActions": {
+ "plan": {
+ "title": "建立專案計畫",
+ "prompt": "幫我為接下來要做的事情寫一份簡潔的專案計畫。"
+ },
+ "analyze": {
+ "title": "分析這些資料",
+ "prompt": "幫我分析這些資料,並指出最重要的模式。"
+ },
+ "brainstorm": {
+ "title": "腦力激盪想法",
+ "prompt": "圍繞這個問題腦力激盪幾個實用方案,並說明取捨。"
+ },
+ "code": {
+ "title": "撰寫程式碼",
+ "prompt": "幫我為這個任務撰寫程式碼,先從最小可用改動開始。"
+ },
+ "summarize": {
+ "title": "總結這份文件",
+ "prompt": "幫我總結這份文件,並列出關鍵重點。"
+ },
+ "more": {
+ "title": "更多",
+ "prompt": "展示幾個你在這個工作區裡可以幫我的實用方式。"
+ }
+ }
},
"header": {
"toggleSidebar": "切換側邊欄"
@@ -62,6 +89,7 @@
"placeholderThread": "輸入訊息…",
"placeholderHero": "你在想什麼?",
"placeholderOpening": "正在開啟新對話…",
+ "placeholderStreaming": "模型正在回覆…",
"inputAria": "訊息輸入框",
"sendHint": "Enter 送出 · Shift+Enter 換行",
"send": "送出訊息",
@@ -76,6 +104,51 @@
"decode_failed": "無法解碼這張圖片",
"too_large": "圖片太大,請換一張小一點的",
"io": "無法讀取這個檔案"
+ },
+ "slash": {
+ "ariaLabel": "斜線命令",
+ "label": "命令",
+ "navigateHint": "↑↓ 選擇",
+ "selectHint": "Enter/Tab 填入",
+ "closeHint": "Esc 關閉",
+ "commands": {
+ "new": {
+ "title": "新增對話",
+ "description": "停止目前任務,並開始新的對話。"
+ },
+ "stop": {
+ "title": "停止目前任務",
+ "description": "取消這個對話中正在執行的 agent 回合。"
+ },
+ "restart": {
+ "title": "重新啟動 nanobot",
+ "description": "原地重新啟動 bot 進程。"
+ },
+ "status": {
+ "title": "查看狀態",
+ "description": "顯示執行環境、provider 和 channel 狀態。"
+ },
+ "history": {
+ "title": "查看對話歷史",
+ "description": "列印最近 N 則已持久化的對話訊息。"
+ },
+ "dream": {
+ "title": "執行 Dream",
+ "description": "手動觸發記憶整理。"
+ },
+ "dream_log": {
+ "title": "查看 Dream 日誌",
+ "description": "查看上一次 Dream 整理變更了什麼。"
+ },
+ "dream_restore": {
+ "title": "恢復記憶",
+ "description": "將記憶恢復到之前的 Dream 快照。"
+ },
+ "help": {
+ "title": "查看說明",
+ "description": "列出可用的斜線命令。"
+ }
+ }
}
},
"scrollToBottom": "捲動到底部"
diff --git a/webui/src/lib/api.ts b/webui/src/lib/api.ts
index 56fed32c7..453297862 100644
--- a/webui/src/lib/api.ts
+++ b/webui/src/lib/api.ts
@@ -1,4 +1,4 @@
-import type { ChatSummary, SettingsPayload, SettingsUpdate } from "./types";
+import type { ChatSummary, SettingsPayload, SettingsUpdate, SlashCommand } from "./types";
export class ApiError extends Error {
status: number;
@@ -42,6 +42,7 @@ export async function listSessions(
key: string;
created_at: string | null;
updated_at: string | null;
+ title?: string;
preview?: string;
};
const body = await request<{ sessions: Row[] }>(
@@ -53,6 +54,7 @@ export async function listSessions(
...splitKey(s.key),
createdAt: s.created_at,
updatedAt: s.updated_at,
+ title: s.title ?? "",
preview: s.preview ?? "",
}));
}
@@ -112,6 +114,27 @@ export async function fetchSettings(
return request(`${base}/api/settings`, token);
}
+export async function listSlashCommands(
+ token: string,
+ base: string = "",
+): Promise {
+ type Row = {
+ command: string;
+ title: string;
+ description: string;
+ icon: string;
+ arg_hint?: string;
+ };
+ const body = await request<{ commands: Row[] }>(`${base}/api/commands`, token);
+ return body.commands.map((command) => ({
+ command: command.command,
+ title: command.title,
+ description: command.description,
+ icon: command.icon,
+ argHint: command.arg_hint ?? "",
+ }));
+}
+
export async function updateSettings(
token: string,
update: SettingsUpdate,
diff --git a/webui/src/lib/bootstrap.ts b/webui/src/lib/bootstrap.ts
index 66d2b5958..931484a87 100644
--- a/webui/src/lib/bootstrap.ts
+++ b/webui/src/lib/bootstrap.ts
@@ -1,15 +1,51 @@
import type { BootstrapResponse } from "./types";
+const SECRET_STORAGE_KEY = "nanobot-webui.bootstrap-secret";
+
+/** Read a previously saved bootstrap secret from localStorage. */
+export function loadSavedSecret(): string {
+ if (typeof window === "undefined") return "";
+ try {
+ return window.localStorage.getItem(SECRET_STORAGE_KEY) ?? "";
+ } catch {
+ return "";
+ }
+}
+
+/** Persist the bootstrap secret so page reloads don't re-prompt. */
+export function saveSecret(secret: string): void {
+ try {
+ window.localStorage.setItem(SECRET_STORAGE_KEY, secret);
+ } catch {
+ // ignore storage errors (private mode, etc.)
+ }
+}
+
+/** Clear the saved bootstrap secret (sign out). */
+export function clearSavedSecret(): void {
+ try {
+ window.localStorage.removeItem(SECRET_STORAGE_KEY);
+ } catch {
+ // ignore
+ }
+}
+
/**
* Fetch a short-lived token + the WebSocket path from the gateway's
- * ``/webui/bootstrap`` endpoint. Localhost-only on the server side.
+ * ``/webui/bootstrap`` endpoint.
*/
export async function fetchBootstrap(
baseUrl: string = "",
+ secret: string = "",
): Promise {
+ const headers: Record = {};
+ if (secret) {
+ headers["X-Nanobot-Auth"] = secret;
+ }
const res = await fetch(`${baseUrl}/webui/bootstrap`, {
method: "GET",
credentials: "same-origin",
+ headers,
});
if (!res.ok) {
throw new Error(`bootstrap failed: HTTP ${res.status}`);
diff --git a/webui/src/lib/nanobot-client.ts b/webui/src/lib/nanobot-client.ts
index f5039f93f..2162cf439 100644
--- a/webui/src/lib/nanobot-client.ts
+++ b/webui/src/lib/nanobot-client.ts
@@ -185,8 +185,8 @@ export class NanobotClient {
this.knownChats.add(chatId);
const frame: Outbound =
media && media.length > 0
- ? { type: "message", chat_id: chatId, content, media }
- : { type: "message", chat_id: chatId, content };
+ ? { type: "message", chat_id: chatId, content, media, webui: true }
+ : { type: "message", chat_id: chatId, content, webui: true };
this.queueSend(frame);
}
diff --git a/webui/src/lib/types.ts b/webui/src/lib/types.ts
index 1b857a171..cc5e7ae29 100644
--- a/webui/src/lib/types.ts
+++ b/webui/src/lib/types.ts
@@ -56,6 +56,7 @@ export interface ChatSummary {
chatId: string;
createdAt: string | null;
updatedAt: string | null;
+ title?: string;
preview: string;
}
@@ -88,6 +89,14 @@ export interface SettingsUpdate {
provider?: string;
}
+export interface SlashCommand {
+ command: string;
+ title: string;
+ description: string;
+ icon: string;
+ argHint?: string;
+}
+
export type ConnectionStatus =
| "idle"
| "connecting"
@@ -124,6 +133,8 @@ export type InboundEvent =
chat_id: string;
stream_id?: string;
}
+ | { event: "turn_end"; chat_id: string }
+ | { event: "session_updated"; chat_id: string }
| { event: "error"; chat_id?: string; detail?: string };
/** Base64-encoded image attached to an outbound ``message`` envelope.
@@ -147,4 +158,7 @@ export type Outbound =
chat_id: string;
content: string;
media?: OutboundMedia[];
+ /** Marks messages sent by the embedded WebUI, without changing the
+ * generic websocket protocol for other clients. */
+ webui?: true;
};
diff --git a/webui/src/tests/api.test.ts b/webui/src/tests/api.test.ts
index aab940d5c..aa44651f5 100644
--- a/webui/src/tests/api.test.ts
+++ b/webui/src/tests/api.test.ts
@@ -1,6 +1,12 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
-import { deleteSession, fetchSessionMessages, updateSettings } from "@/lib/api";
+import {
+ deleteSession,
+ fetchSessionMessages,
+ listSessions,
+ listSlashCommands,
+ updateSettings,
+} from "@/lib/api";
describe("webui API helpers", () => {
beforeEach(() => {
@@ -48,4 +54,61 @@ describe("webui API helpers", () => {
}),
);
});
+
+ it("maps generated session titles from the sessions list", async () => {
+ vi.mocked(fetch).mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({
+ sessions: [
+ {
+ key: "websocket:chat-1",
+ created_at: "2026-05-01T10:00:00",
+ updated_at: "2026-05-01T10:01:00",
+ title: "优化 WebUI 标题",
+ },
+ ],
+ }),
+ } as Response);
+
+ await expect(listSessions("tok")).resolves.toMatchObject([
+ {
+ key: "websocket:chat-1",
+ title: "优化 WebUI 标题",
+ preview: "",
+ },
+ ]);
+ });
+
+ it("maps slash command metadata from the commands endpoint", async () => {
+ vi.mocked(fetch).mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({
+ commands: [
+ {
+ command: "/history",
+ title: "Show conversation history",
+ description: "Print the last N messages.",
+ icon: "history",
+ arg_hint: "[n]",
+ },
+ ],
+ }),
+ } as Response);
+
+ await expect(listSlashCommands("tok")).resolves.toEqual([
+ {
+ command: "/history",
+ title: "Show conversation history",
+ description: "Print the last N messages.",
+ icon: "history",
+ argHint: "[n]",
+ },
+ ]);
+ expect(fetch).toHaveBeenCalledWith(
+ "/api/commands",
+ expect.objectContaining({
+ headers: { Authorization: "Bearer tok" },
+ }),
+ );
+ });
});
diff --git a/webui/src/tests/app-layout.test.tsx b/webui/src/tests/app-layout.test.tsx
index 77b9420dd..25248230e 100644
--- a/webui/src/tests/app-layout.test.tsx
+++ b/webui/src/tests/app-layout.test.tsx
@@ -1,4 +1,4 @@
-import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ChatSummary } from "@/lib/types";
@@ -7,6 +7,7 @@ const connectSpy = vi.fn();
const refreshSpy = vi.fn();
const createChatSpy = vi.fn().mockResolvedValue("chat-1");
const deleteChatSpy = vi.fn();
+const toggleThemeSpy = vi.fn();
let mockSessions: ChatSummary[] = [];
vi.mock("@/hooks/useSessions", async (importOriginal) => {
@@ -34,7 +35,7 @@ vi.mock("@/hooks/useSessions", async (importOriginal) => {
vi.mock("@/hooks/useTheme", () => ({
useTheme: () => ({
theme: "light" as const,
- toggle: vi.fn(),
+ toggle: toggleThemeSpy,
}),
}));
@@ -45,6 +46,9 @@ vi.mock("@/lib/bootstrap", () => ({
expires_in: 300,
}),
deriveWsUrl: vi.fn(() => "ws://test"),
+ loadSavedSecret: vi.fn(() => ""),
+ saveSecret: vi.fn(),
+ clearSavedSecret: vi.fn(),
}));
vi.mock("@/lib/nanobot-client", () => {
@@ -74,6 +78,7 @@ describe("App layout", () => {
refreshSpy.mockReset();
createChatSpy.mockClear();
deleteChatSpy.mockReset();
+ toggleThemeSpy.mockReset();
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
@@ -121,8 +126,11 @@ describe("App layout", () => {
render( );
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
+ const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
await waitFor(() =>
- expect(screen.getByRole("button", { name: /^First chat$/ })).toBeInTheDocument(),
+ expect(
+ within(sidebar).getByRole("button", { name: /^First chat$/ }),
+ ).toBeInTheDocument(),
);
fireEvent.pointerDown(screen.getByLabelText("Chat actions for First chat"), {
@@ -140,14 +148,24 @@ describe("App layout", () => {
);
await waitFor(() =>
expect(
- screen.getByRole("button", { name: /^Second chat$/ }),
+ within(sidebar).getByRole("button", { name: /^Second chat$/ }),
).toBeInTheDocument(),
);
expect(screen.queryByText('Delete “First chat”?')).not.toBeInTheDocument();
expect(document.body.style.pointerEvents).not.toBe("none");
}, 15_000);
- it("opens the Cursor-style settings view from the sidebar", async () => {
+ it("opens the Cursor-style settings view from the header", async () => {
+ mockSessions = [
+ {
+ key: "websocket:chat-a",
+ channel: "websocket",
+ chatId: "chat-a",
+ createdAt: "2026-04-16T10:00:00Z",
+ updatedAt: "2026-04-16T10:00:00Z",
+ preview: "Existing chat",
+ },
+ ];
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
@@ -180,10 +198,95 @@ describe("App layout", () => {
render( );
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
- fireEvent.click(screen.getByRole("button", { name: "Settings" }));
+ fireEvent.click(screen.getByRole("button", { name: "Open settings" }));
expect(await screen.findByRole("heading", { name: "General" })).toBeInTheDocument();
expect(screen.getByText("AI")).toBeInTheDocument();
expect(screen.getByDisplayValue("openai/gpt-4o")).toBeInTheDocument();
});
+
+ it("filters sidebar sessions through the lightweight search row", async () => {
+ mockSessions = [
+ {
+ key: "websocket:chat-alpha",
+ channel: "websocket",
+ chatId: "chat-alpha",
+ createdAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ preview: "Project planning notes",
+ },
+ {
+ key: "websocket:chat-beta",
+ channel: "websocket",
+ chatId: "chat-beta",
+ createdAt: "2026-04-15T10:00:00Z",
+ updatedAt: "2026-04-15T10:00:00Z",
+ preview: "Travel ideas",
+ },
+ ];
+
+ render( );
+
+ await waitFor(() => expect(connectSpy).toHaveBeenCalled());
+ const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
+ expect(within(sidebar).getByText("Project planning notes")).toBeInTheDocument();
+ expect(within(sidebar).getByText("Travel ideas")).toBeInTheDocument();
+
+ fireEvent.change(screen.getByRole("textbox", { name: "Search chats" }), {
+ target: { value: "travel" },
+ });
+
+ expect(within(sidebar).queryByText("Project planning notes")).not.toBeInTheDocument();
+ expect(within(sidebar).getByText("Travel ideas")).toBeInTheDocument();
+ });
+
+ it("opens a blank start page without creating an empty chat", async () => {
+ mockSessions = [
+ {
+ key: "websocket:chat-a",
+ channel: "websocket",
+ chatId: "chat-a",
+ createdAt: "2026-04-16T10:00:00Z",
+ updatedAt: "2026-04-16T10:00:00Z",
+ preview: "Existing chat",
+ },
+ ];
+
+ const matchMedia = vi.fn().mockImplementation((query: string) => ({
+ matches: query.includes("1024px"),
+ media: query,
+ onchange: null,
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ }));
+ vi.stubGlobal("matchMedia", matchMedia);
+
+ const { container } = render( );
+
+ await waitFor(() => expect(connectSpy).toHaveBeenCalled());
+
+ fireEvent.click(screen.getByRole("button", { name: "Toggle theme from header" }));
+ 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("0px"));
+
+ expect(screen.queryByRole("button", { name: "Start a new chat" })).not.toBeInTheDocument();
+ fireEvent.click(screen.getByRole("button", { name: "Toggle sidebar" }));
+ await waitFor(() => expect(desktopAside.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.queryByRole("button", { name: "Start a new chat" })).not.toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Toggle theme from header" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Open settings" })).toBeInTheDocument();
+
+ expect(within(sidebar).getByText("Existing chat")).toBeInTheDocument();
+ });
});
diff --git a/webui/src/tests/i18n.test.tsx b/webui/src/tests/i18n.test.tsx
index 66b029577..fb4496f71 100644
--- a/webui/src/tests/i18n.test.tsx
+++ b/webui/src/tests/i18n.test.tsx
@@ -4,6 +4,9 @@ import { describe, expect, it, vi } from "vitest";
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
import { ThreadComposer } from "@/components/thread/ThreadComposer";
+import { resources } from "@/i18n";
+
+const QUICK_ACTION_KEYS = ["plan", "analyze", "brainstorm", "code", "summarize", "more"];
describe("webui i18n", () => {
it("switches UI copy and document locale through the language switcher", async () => {
@@ -41,4 +44,16 @@ describe("webui i18n", () => {
expect(screen.getByLabelText("メッセージ入力欄")).toBeInTheDocument();
});
+
+ it("keeps welcome quick actions 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 QUICK_ACTION_KEYS) {
+ const action = empty.quickActions[key as keyof typeof empty.quickActions];
+ expect(action.title).toBeTruthy();
+ expect(action.prompt).toBeTruthy();
+ }
+ }
+ });
});
diff --git a/webui/src/tests/message-bubble.test.tsx b/webui/src/tests/message-bubble.test.tsx
index e8dec29ab..773c143c7 100644
--- a/webui/src/tests/message-bubble.test.tsx
+++ b/webui/src/tests/message-bubble.test.tsx
@@ -1,5 +1,5 @@
-import { fireEvent, render, screen } from "@testing-library/react";
-import { describe, expect, it } from "vitest";
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
import { MessageBubble } from "@/components/MessageBubble";
import type { UIMessage } from "@/lib/types";
@@ -19,6 +19,44 @@ describe("MessageBubble", () => {
expect(row).toHaveClass("ml-auto", "flex");
expect(pill).toHaveClass("ml-auto", "w-fit", "rounded-[18px]");
+ expect(screen.queryByRole("button", { name: "Copy reply" })).not.toBeInTheDocument();
+ });
+
+ it("copies completed assistant replies from the action row", async () => {
+ const writeText = vi.fn().mockResolvedValue(undefined);
+ Object.defineProperty(navigator, "clipboard", {
+ configurable: true,
+ value: { writeText },
+ });
+ const message: UIMessage = {
+ id: "a-copy",
+ role: "assistant",
+ content: "I can help with the next step.",
+ createdAt: Date.now(),
+ };
+
+ render( );
+
+ fireEvent.click(screen.getByRole("button", { name: "Copy reply" }));
+
+ expect(writeText).toHaveBeenCalledWith("I can help with the next step.");
+ await waitFor(() =>
+ expect(screen.getByRole("button", { name: "Copied reply" })).toBeInTheDocument(),
+ );
+ });
+
+ it("does not show copy actions for streaming placeholders", () => {
+ const message: UIMessage = {
+ id: "a-streaming",
+ role: "assistant",
+ content: "",
+ isStreaming: true,
+ createdAt: Date.now(),
+ };
+
+ render( );
+
+ expect(screen.queryByRole("button", { name: "Copy reply" })).not.toBeInTheDocument();
});
it("renders trace messages as collapsible tool groups", () => {
diff --git a/webui/src/tests/nanobot-client.test.ts b/webui/src/tests/nanobot-client.test.ts
index b95ef6804..4c7923999 100644
--- a/webui/src/tests/nanobot-client.test.ts
+++ b/webui/src/tests/nanobot-client.test.ts
@@ -116,7 +116,7 @@ describe("NanobotClient", () => {
// Attach is sent first because sendMessage adds to knownChats, which
// handleOpen re-attaches; then the queued message follows.
expect(lastSocket().sent).toContain(
- JSON.stringify({ type: "message", chat_id: "chat-x", content: "hello" }),
+ JSON.stringify({ type: "message", chat_id: "chat-x", content: "hello", webui: true }),
);
});
@@ -196,6 +196,7 @@ describe("NanobotClient", () => {
chat_id: "chat-x",
content: "look",
media: [{ data_url: "data:image/png;base64,AAAA", name: "shot.png" }],
+ webui: true,
});
});
@@ -214,6 +215,7 @@ describe("NanobotClient", () => {
type: "message",
chat_id: "chat-x",
content: "hello",
+ webui: true,
});
});
diff --git a/webui/src/tests/thread-composer.test.tsx b/webui/src/tests/thread-composer.test.tsx
index 17205fb67..9e776291a 100644
--- a/webui/src/tests/thread-composer.test.tsx
+++ b/webui/src/tests/thread-composer.test.tsx
@@ -1,7 +1,24 @@
-import { render, screen } from "@testing-library/react";
+import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { ThreadComposer } from "@/components/thread/ThreadComposer";
+import type { SlashCommand } from "@/lib/types";
+
+const COMMANDS: SlashCommand[] = [
+ {
+ command: "/stop",
+ title: "Stop current task",
+ description: "Cancel the active agent turn.",
+ icon: "square",
+ },
+ {
+ command: "/history",
+ title: "Show conversation history",
+ description: "Print the last N persisted messages.",
+ icon: "history",
+ argHint: "[n]",
+ },
+];
describe("ThreadComposer", () => {
it("renders a readonly hero model composer when provided", () => {
@@ -9,15 +26,69 @@ describe("ThreadComposer", () => {
,
);
expect(screen.getByText("claude-opus-4-5")).toBeInTheDocument();
- const input = screen.getByPlaceholderText("What's on your mind?");
+ expect(screen.queryByRole("button", { name: "Search" })).not.toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: "Reason" })).not.toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: "Deep research" })).not.toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: "Voice input" })).not.toBeInTheDocument();
+ const input = screen.getByPlaceholderText("Ask anything...");
expect(input).toBeInTheDocument();
- expect(input.className).toContain("min-h-[96px]");
- expect(input.parentElement?.className).toContain("max-w-[40rem]");
+ expect(input.className).toContain("min-h-[78px]");
+ expect(input.parentElement?.className).toContain("max-w-[58rem]");
+ });
+
+ it("keeps the thread composer compact while matching the hero style", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText("gpt-4o")).toBeInTheDocument();
+ const input = screen.getByPlaceholderText("Type your message...");
+ expect(input.className).toContain("min-h-[50px]");
+ expect(input.parentElement?.className).toContain("max-w-[49.5rem]");
+ expect(input.parentElement?.className).toContain("rounded-[22px]");
+ expect(input.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");
+ });
+
+ it("opens a slash command palette and inserts the selected command", () => {
+ const onSend = vi.fn();
+ render(
+ ,
+ );
+
+ const input = screen.getByLabelText("Message input");
+ fireEvent.change(input, { target: { value: "/" } });
+
+ expect(screen.getByRole("listbox", { name: "Slash commands" })).toBeInTheDocument();
+ expect(screen.getByRole("option", { name: /\/stop/i })).toHaveAttribute(
+ "aria-selected",
+ "true",
+ );
+
+ fireEvent.keyDown(input, { key: "ArrowDown" });
+ expect(screen.getByRole("option", { name: /\/history/i })).toHaveAttribute(
+ "aria-selected",
+ "true",
+ );
+ fireEvent.keyDown(input, { key: "Enter" });
+
+ expect(input).toHaveValue("/history ");
+ expect(onSend).not.toHaveBeenCalled();
+ expect(screen.queryByRole("listbox", { name: "Slash commands" })).not.toBeInTheDocument();
});
});
diff --git a/webui/src/tests/thread-shell.test.tsx b/webui/src/tests/thread-shell.test.tsx
index d134fcce2..3dd47f6b8 100644
--- a/webui/src/tests/thread-shell.test.tsx
+++ b/webui/src/tests/thread-shell.test.tsx
@@ -86,6 +86,26 @@ describe("ThreadShell", () => {
);
});
+ it("does not navigate away when clicking the chat title", async () => {
+ const client = makeClient();
+ const onGoHome = vi.fn();
+ render(wrap(
+ client,
+ {}}
+ onGoHome={onGoHome}
+ onNewChat={() => {}}
+ />,
+ ));
+
+ await waitFor(() => expect(screen.getByText("Important conversation")).toBeInTheDocument());
+ fireEvent.click(screen.getByText("Important conversation"));
+
+ expect(onGoHome).not.toHaveBeenCalled();
+ });
+
it("restores in-memory messages when switching away and back to a session", async () => {
const client = makeClient();
const onNewChat = vi.fn().mockResolvedValue("chat-a");
@@ -199,7 +219,67 @@ describe("ThreadShell", () => {
await waitFor(() => {
expect(screen.queryByText("delete me cleanly")).not.toBeInTheDocument();
});
- expect(screen.getByPlaceholderText("What's on your mind?")).toBeInTheDocument();
+ expect(screen.getByPlaceholderText("Ask anything...")).toBeInTheDocument();
+ });
+
+ it("creates a chat only when the blank landing sends a first message", async () => {
+ const client = makeClient();
+ const onNewChat = vi.fn();
+ const onCreateChat = vi.fn().mockResolvedValue("chat-new");
+
+ render(
+ wrap(
+ client,
+ {}}
+ onGoHome={() => {}}
+ onNewChat={onNewChat}
+ onCreateChat={onCreateChat}
+ />,
+ ),
+ );
+
+ fireEvent.change(screen.getByLabelText("Message input"), {
+ target: { value: "start for real" },
+ });
+ fireEvent.click(screen.getByRole("button", { name: "Send message" }));
+
+ await waitFor(() => expect(onCreateChat).toHaveBeenCalledTimes(1));
+ expect(onNewChat).not.toHaveBeenCalled();
+ });
+
+ it("sends quick action prompts from the empty thread landing", async () => {
+ const client = makeClient();
+ const onNewChat = vi.fn().mockResolvedValue("chat-a");
+
+ render(
+ wrap(
+ client,
+ {}}
+ onGoHome={() => {}}
+ onNewChat={onNewChat}
+ />,
+ ),
+ );
+
+ 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,
+ ),
+ );
});
it("does not leak the previous thread when opening a brand-new chat", async () => {
@@ -260,13 +340,232 @@ describe("ThreadShell", () => {
expect(screen.queryByText("old answer")).not.toBeInTheDocument();
await waitFor(() =>
- expect(screen.getByPlaceholderText("What's on your mind?")).toBeInTheDocument(),
+ expect(screen.getByPlaceholderText("Ask anything...")).toBeInTheDocument(),
);
- const input = screen.getByPlaceholderText("What's on your mind?");
- expect(input.className).toContain("min-h-[96px]");
+ const input = screen.getByPlaceholderText("Ask anything...");
+ expect(input.className).toContain("min-h-[78px]");
expect(screen.queryByText("old answer")).not.toBeInTheDocument();
});
+ it("does not cache optimistic messages under the next chat during a session switch", async () => {
+ const client = makeClient();
+ const onNewChat = vi.fn().mockResolvedValue("chat-b");
+
+ const { rerender } = render(
+ wrap(
+ client,
+ {}}
+ onGoHome={() => {}}
+ onNewChat={onNewChat}
+ />,
+ ),
+ );
+
+ fireEvent.change(screen.getByLabelText("Message input"), {
+ target: { value: "only in chat a" },
+ });
+ fireEvent.click(screen.getByRole("button", { name: "Send message" }));
+
+ await waitFor(() =>
+ expect(client.sendMessage).toHaveBeenCalledWith(
+ "chat-a",
+ "only in chat a",
+ undefined,
+ ),
+ );
+ expect(screen.getByText("only in chat a")).toBeInTheDocument();
+
+ await act(async () => {
+ rerender(
+ wrap(
+ client,
+ {}}
+ onGoHome={() => {}}
+ onNewChat={onNewChat}
+ />,
+ ),
+ );
+ });
+
+ await waitFor(() => {
+ expect(screen.queryByText("only in chat a")).not.toBeInTheDocument();
+ });
+
+ await act(async () => {
+ rerender(
+ wrap(
+ client,
+ {}}
+ onGoHome={() => {}}
+ onNewChat={onNewChat}
+ />,
+ ),
+ );
+ });
+
+ expect(screen.getByText("only in chat a")).toBeInTheDocument();
+
+ await act(async () => {
+ rerender(
+ wrap(
+ client,
+ {}}
+ onGoHome={() => {}}
+ onNewChat={onNewChat}
+ />,
+ ),
+ );
+ });
+
+ await waitFor(() => {
+ expect(screen.queryByText("only in chat a")).not.toBeInTheDocument();
+ });
+ });
+
+ it("keeps live assistant replies after visiting the blank new-chat page", async () => {
+ const client = makeClient();
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async (input: RequestInfo | URL) => {
+ const url = String(input);
+ if (url.includes("websocket%3Achat-a/messages")) {
+ return httpJson({
+ key: "websocket:chat-a",
+ created_at: null,
+ updated_at: null,
+ // Simulate a stale history response that has not persisted the
+ // just-received assistant reply yet.
+ messages: [{ role: "user", content: "hello" }],
+ });
+ }
+ return {
+ ok: false,
+ status: 404,
+ json: async () => ({}),
+ };
+ }),
+ );
+
+ const { rerender } = render(
+ wrap(
+ client,
+ {}}
+ onNewChat={() => {}}
+ />,
+ ),
+ );
+
+ await waitFor(() => expect(screen.getByText("hello")).toBeInTheDocument());
+ await act(async () => {
+ client._emitChat("chat-a", {
+ event: "message",
+ chat_id: "chat-a",
+ text: "live assistant reply",
+ });
+ });
+ expect(screen.getByText("live assistant reply")).toBeInTheDocument();
+
+ await act(async () => {
+ rerender(
+ wrap(
+ client,
+ {}}
+ onNewChat={() => {}}
+ />,
+ ),
+ );
+ });
+
+ expect(screen.queryByText("live assistant reply")).not.toBeInTheDocument();
+ expect(screen.getByText("What can I do for you?")).toBeInTheDocument();
+
+ await act(async () => {
+ rerender(
+ wrap(
+ client,
+ {}}
+ onNewChat={() => {}}
+ />,
+ ),
+ );
+ });
+
+ await waitFor(() => expect(screen.getByText("live assistant reply")).toBeInTheDocument());
+ });
+
+ it("does not open slash commands on the blank welcome page", async () => {
+ const client = makeClient();
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async (input: RequestInfo | URL) => {
+ const url = String(input);
+ if (url.endsWith("/api/commands")) {
+ return httpJson({
+ commands: [
+ {
+ command: "/stop",
+ title: "Stop current task",
+ description: "Cancel the active agent turn.",
+ icon: "square",
+ },
+ ],
+ });
+ }
+ return {
+ ok: false,
+ status: 404,
+ json: async () => ({}),
+ };
+ }),
+ );
+
+ render(
+ wrap(
+ client,
+ {}}
+ onNewChat={() => {}}
+ />,
+ ),
+ );
+
+ await waitFor(() => expect(fetch).toHaveBeenCalledWith(
+ "/api/commands",
+ expect.objectContaining({
+ headers: { Authorization: "Bearer tok" },
+ }),
+ ));
+
+ fireEvent.change(screen.getByLabelText("Message input"), {
+ target: { value: "/" },
+ });
+
+ expect(screen.queryByRole("listbox", { name: "Slash commands" })).not.toBeInTheDocument();
+ });
+
it("surfaces a dismissible banner when the stream reports message_too_big", async () => {
const client = makeClient();
const onNewChat = vi.fn().mockResolvedValue("chat-a");
@@ -287,6 +586,7 @@ describe("ThreadShell", () => {
// No banner yet: only appears once the client emits a matching error.
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
+ await act(async () => {});
await act(async () => {
client._emitError({ kind: "message_too_big" });
});
@@ -318,6 +618,7 @@ describe("ThreadShell", () => {
),
);
+ await act(async () => {});
await act(async () => {
client._emitError({ kind: "message_too_big" });
});
diff --git a/webui/src/tests/useNanobotStream.test.tsx b/webui/src/tests/useNanobotStream.test.tsx
index f5adcf176..155ec118e 100644
--- a/webui/src/tests/useNanobotStream.test.tsx
+++ b/webui/src/tests/useNanobotStream.test.tsx
@@ -6,6 +6,8 @@ import { useNanobotStream } from "@/hooks/useNanobotStream";
import type { InboundEvent } from "@/lib/types";
import { ClientProvider } from "@/providers/ClientProvider";
+const EMPTY_MESSAGES: import("@/lib/types").UIMessage[] = [];
+
function fakeClient() {
const handlers = new Map void>>();
return {
@@ -51,9 +53,27 @@ function wrap(client: ReturnType["client"]) {
}
describe("useNanobotStream", () => {
+ it("starts in streaming mode when history shows pending tool calls", () => {
+ const fake = fakeClient();
+ const initialMessages = [{
+ id: "m1",
+ role: "assistant" as const,
+ content: "Using tools",
+ createdAt: Date.now(),
+ }];
+ const { result } = renderHook(
+ () => useNanobotStream("chat-p", initialMessages, true),
+ {
+ wrapper: wrap(fake.client),
+ },
+ );
+
+ expect(result.current.isStreaming).toBe(true);
+ });
+
it("collapses consecutive tool_hint frames into one trace row", () => {
const fake = fakeClient();
- const { result } = renderHook(() => useNanobotStream("chat-t", []), {
+ const { result } = renderHook(() => useNanobotStream("chat-t", EMPTY_MESSAGES), {
wrapper: wrap(fake.client),
});
@@ -95,7 +115,7 @@ describe("useNanobotStream", () => {
it("attaches assistant media_urls to complete messages", () => {
const fake = fakeClient();
- const { result } = renderHook(() => useNanobotStream("chat-m", []), {
+ const { result } = renderHook(() => useNanobotStream("chat-m", EMPTY_MESSAGES), {
wrapper: wrap(fake.client),
});
@@ -116,7 +136,7 @@ describe("useNanobotStream", () => {
it("keeps assistant buttons on complete messages", () => {
const fake = fakeClient();
- const { result } = renderHook(() => useNanobotStream("chat-q", []), {
+ const { result } = renderHook(() => useNanobotStream("chat-q", EMPTY_MESSAGES), {
wrapper: wrap(fake.client),
});
@@ -136,4 +156,79 @@ describe("useNanobotStream", () => {
["Short answer", "Detailed answer"],
]);
});
+
+ it("keeps streaming alive across stream_end and completes on turn_end", () => {
+ const fake = fakeClient();
+ const onTurnEnd = vi.fn();
+ const { result } = renderHook(() => useNanobotStream("chat-s", EMPTY_MESSAGES, false, onTurnEnd), {
+ wrapper: wrap(fake.client),
+ });
+
+ act(() => {
+ fake.emit("chat-s", {
+ event: "delta",
+ chat_id: "chat-s",
+ text: "Hello",
+ });
+ });
+
+ expect(result.current.isStreaming).toBe(true);
+ expect(result.current.messages[0]).toMatchObject({
+ role: "assistant",
+ content: "Hello",
+ isStreaming: true,
+ });
+
+ act(() => {
+ fake.emit("chat-s", {
+ event: "stream_end",
+ chat_id: "chat-s",
+ });
+ });
+
+ expect(result.current.isStreaming).toBe(true);
+ expect(result.current.messages[0].isStreaming).toBe(true);
+
+ act(() => {
+ fake.emit("chat-s", {
+ event: "message",
+ chat_id: "chat-s",
+ text: "Hello world",
+ });
+ });
+
+ expect(result.current.isStreaming).toBe(true);
+ expect(result.current.messages.at(-1)).toMatchObject({
+ role: "assistant",
+ content: "Hello world",
+ });
+
+ act(() => {
+ fake.emit("chat-s", {
+ event: "turn_end",
+ chat_id: "chat-s",
+ });
+ });
+
+ expect(result.current.isStreaming).toBe(false);
+ expect(result.current.messages.every((message) => !message.isStreaming)).toBe(true);
+ expect(onTurnEnd).toHaveBeenCalledTimes(1);
+ });
+
+ it("refreshes session metadata when the server reports a session update", () => {
+ const fake = fakeClient();
+ const onTurnEnd = vi.fn();
+ renderHook(() => useNanobotStream("chat-title", EMPTY_MESSAGES, false, onTurnEnd), {
+ wrapper: wrap(fake.client),
+ });
+
+ act(() => {
+ fake.emit("chat-title", {
+ event: "session_updated",
+ chat_id: "chat-title",
+ });
+ });
+
+ expect(onTurnEnd).toHaveBeenCalledTimes(1);
+ });
});
diff --git a/webui/src/tests/useSessions.test.tsx b/webui/src/tests/useSessions.test.tsx
index ad4f1c1af..4805c6567 100644
--- a/webui/src/tests/useSessions.test.tsx
+++ b/webui/src/tests/useSessions.test.tsx
@@ -170,6 +170,83 @@ describe("useSessions", () => {
]);
});
+ it("flags history with trailing assistant tool calls as still pending", async () => {
+ vi.mocked(api.fetchSessionMessages).mockResolvedValue({
+ key: "websocket:chat-pending",
+ created_at: "2026-04-20T10:00:00Z",
+ updated_at: "2026-04-20T10:05:00Z",
+ messages: [
+ {
+ role: "assistant",
+ content: "Using 2 tools",
+ timestamp: "2026-04-20T10:00:01Z",
+ tool_calls: [{ id: "call-1" }],
+ },
+ ],
+ });
+
+ const { result } = renderHook(() => useSessionHistory("websocket:chat-pending"), {
+ wrapper: wrap(fakeClient()),
+ });
+
+ await waitFor(() => expect(result.current.loading).toBe(false));
+
+ expect(result.current.hasPendingToolCalls).toBe(true);
+ });
+
+ it("keeps pending when tool result rows trail assistant tool calls", async () => {
+ vi.mocked(api.fetchSessionMessages).mockResolvedValue({
+ key: "websocket:chat-pending-tool-result",
+ created_at: "2026-04-20T10:00:00Z",
+ updated_at: "2026-04-20T10:05:00Z",
+ messages: [
+ {
+ role: "assistant",
+ content: "Using 1 tool",
+ timestamp: "2026-04-20T10:00:01Z",
+ tool_calls: [{ id: "call-1" }],
+ },
+ {
+ role: "tool",
+ content: "tool output",
+ timestamp: "2026-04-20T10:00:02Z",
+ tool_call_id: "call-1",
+ },
+ ],
+ });
+
+ const { result } = renderHook(() => useSessionHistory("websocket:chat-pending-tool-result"), {
+ wrapper: wrap(fakeClient()),
+ });
+
+ await waitFor(() => expect(result.current.loading).toBe(false));
+
+ expect(result.current.hasPendingToolCalls).toBe(true);
+ });
+
+ it("does not flag history as pending once the assistant turn has no tool calls", async () => {
+ vi.mocked(api.fetchSessionMessages).mockResolvedValue({
+ key: "websocket:chat-done",
+ created_at: "2026-04-20T10:00:00Z",
+ updated_at: "2026-04-20T10:05:00Z",
+ messages: [
+ {
+ role: "assistant",
+ content: "All done",
+ timestamp: "2026-04-20T10:00:01Z",
+ },
+ ],
+ });
+
+ const { result } = renderHook(() => useSessionHistory("websocket:chat-done"), {
+ wrapper: wrap(fakeClient()),
+ });
+
+ await waitFor(() => expect(result.current.loading).toBe(false));
+
+ expect(result.current.hasPendingToolCalls).toBe(false);
+ });
+
it("keeps the session in the list when delete fails", async () => {
vi.mocked(api.listSessions).mockResolvedValue([
{