feat: add image generation tool and WebUI mode

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Xubin Ren
2026-05-08 20:06:23 +08:00
committed by Xubin Ren
co-authored by Cursor
parent 3a2f47d720
commit e936ed48bd
45 changed files with 2979 additions and 89 deletions
+51 -1
View File
@@ -17,7 +17,7 @@ import {
saveSecret,
} from "@/lib/bootstrap";
import { NanobotClient } from "@/lib/nanobot-client";
import { ClientProvider } from "@/providers/ClientProvider";
import { ClientProvider, useClient } from "@/providers/ClientProvider";
import type { ChatSummary } from "@/lib/types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -34,6 +34,7 @@ type BootState =
};
const SIDEBAR_STORAGE_KEY = "nanobot-webui.sidebar";
const RESTART_STARTED_KEY = "nanobot-webui.restartStartedAt";
const SIDEBAR_WIDTH = 272;
type ShellView = "chat" | "settings";
@@ -237,6 +238,7 @@ export default function App() {
function Shell({ onModelNameChange, onLogout }: { onModelNameChange: (modelName: string | null) => void; onLogout: () => void }) {
const { t, i18n } = useTranslation();
const { client } = useClient();
const { theme, toggle } = useTheme();
const { sessions, loading, refresh, createChat, deleteChat } = useSessions();
const [activeKey, setActiveKey] = useState<string | null>(null);
@@ -249,6 +251,8 @@ function Shell({ onModelNameChange, onLogout }: { onModelNameChange: (modelName:
label: string;
} | null>(null);
const lastSessionsLen = useRef(0);
const restartSawDisconnectRef = useRef(false);
const [restartToast, setRestartToast] = useState<string | null>(null);
useEffect(() => {
try {
@@ -326,6 +330,43 @@ function Shell({ onModelNameChange, onLogout }: { onModelNameChange: (modelName:
setMobileSidebarOpen(false);
}, []);
const onRestart = useCallback(() => {
const chatId = activeSession?.chatId ?? client.defaultChatId;
if (!chatId) return;
restartSawDisconnectRef.current = false;
try {
window.localStorage.setItem(RESTART_STARTED_KEY, String(Date.now()));
} catch {
// ignore storage errors
}
client.sendMessage(chatId, "/restart");
}, [activeSession?.chatId, client]);
useEffect(() => {
return client.onStatus((status) => {
let startedAt = 0;
try {
startedAt = Number(window.localStorage.getItem(RESTART_STARTED_KEY) ?? "0");
} catch {
startedAt = 0;
}
if (!startedAt) return;
if (status !== "open") {
restartSawDisconnectRef.current = true;
return;
}
const elapsedMs = Date.now() - startedAt;
if (!restartSawDisconnectRef.current && elapsedMs < 1500) return;
try {
window.localStorage.removeItem(RESTART_STARTED_KEY);
} catch {
// ignore storage errors
}
setRestartToast(t("app.restart.completed", { seconds: (elapsedMs / 1000).toFixed(1) }));
window.setTimeout(() => setRestartToast(null), 3_500);
});
}, [client, t]);
const onTurnEnd = useCallback(() => {
void refresh();
}, [refresh]);
@@ -414,6 +455,7 @@ function Shell({ onModelNameChange, onLogout }: { onModelNameChange: (modelName:
onBackToChat={() => setView("chat")}
onModelNameChange={onModelNameChange}
onLogout={onLogout}
onRestart={onRestart}
/>
) : (
<ThreadShell
@@ -437,6 +479,14 @@ function Shell({ onModelNameChange, onLogout }: { onModelNameChange: (modelName:
onCancel={() => setPendingDelete(null)}
onConfirm={onConfirmDelete}
/>
{restartToast ? (
<div
role="status"
className="fixed left-1/2 top-4 z-50 -translate-x-1/2 rounded-full border border-border/70 bg-popover px-4 py-2 text-sm font-medium text-popover-foreground shadow-lg"
>
{restartToast}
</div>
) : null}
</div>
);
}
+14 -3
View File
@@ -142,7 +142,9 @@ function MessageMedia({
align === "right" ? "justify-end" : "justify-start",
)}
>
{images.length > 0 ? <UserImages images={images} align={align} /> : null}
{images.length > 0 ? (
<UserImages images={images} align={align} size={align === "left" ? "large" : "compact"} />
) : null}
{nonImages.map((item, i) => (
<MediaCell key={`${item.url ?? item.name ?? item.kind}-${i}`} media={item} />
))}
@@ -208,9 +210,11 @@ function MediaCell({ media }: { media: UIMediaAttachment }) {
function UserImages({
images,
align = "right",
size = "compact",
}: {
images: UIImage[];
align?: "left" | "right";
size?: "compact" | "large";
}) {
const { t } = useTranslation();
// Only real-URL images can open in the lightbox; historical-replay
@@ -230,6 +234,7 @@ function UserImages({
<div
className={cn(
"flex flex-wrap items-end gap-2",
size === "large" && "gap-3",
align === "right" ? "ml-auto justify-end" : "mr-auto justify-start",
)}
>
@@ -237,6 +242,7 @@ function UserImages({
<UserImageCell
key={`${img.url ?? "placeholder"}-${i}`}
image={img}
size={size}
placeholderLabel={t("message.imageAttachment")}
openLabel={t("lightbox.open")}
onOpen={
@@ -261,18 +267,23 @@ function UserImages({
function UserImageCell({
image,
size,
placeholderLabel,
openLabel,
onOpen,
}: {
image: UIImage;
size: "compact" | "large";
placeholderLabel: string;
openLabel: string;
onOpen?: () => void;
}) {
const hasUrl = typeof image.url === "string" && image.url.length > 0;
const tileClasses = cn(
"relative h-24 w-24 overflow-hidden rounded-[14px] border border-border/60 bg-muted/40",
"relative overflow-hidden border border-border/60 bg-muted/40",
size === "large"
? "h-56 w-[min(100%,22rem)] rounded-[18px] sm:h-72 sm:w-[26rem]"
: "h-24 w-24 rounded-[14px]",
"shadow-[0_6px_18px_-14px_rgba(0,0,0,0.45)]",
);
@@ -296,7 +307,7 @@ function UserImageCell({
loading="lazy"
decoding="async"
draggable={false}
className="h-full w-full object-cover"
className={cn("h-full w-full", size === "large" ? "object-contain" : "object-cover")}
/>
</button>
);
@@ -16,12 +16,14 @@ interface SettingsViewProps {
onBackToChat: () => void;
onModelNameChange: (modelName: string | null) => void;
onLogout?: () => void;
onRestart?: () => void;
}
export function SettingsView({
onBackToChat,
onModelNameChange,
onLogout,
onRestart,
}: SettingsViewProps) {
const { token } = useClient();
const [settings, setSettings] = useState<SettingsPayload | null>(null);
@@ -119,6 +121,7 @@ export function SettingsView({
saving={saving}
onSave={save}
onLogout={onLogout}
onRestart={onRestart}
/>
) : null}
</main>
@@ -134,6 +137,7 @@ function SettingsSection({
saving,
onSave,
onLogout,
onRestart,
}: {
form: {
model: string;
@@ -148,6 +152,7 @@ function SettingsSection({
saving: boolean;
onSave: () => void;
onLogout?: () => void;
onRestart?: () => void;
}) {
const { t } = useTranslation();
return (
@@ -200,6 +205,19 @@ function SettingsSection({
</SettingsGroup>
</section>
{onRestart && (
<section>
<h2 className="mb-2 px-2 text-xs font-medium text-muted-foreground">{t("app.system.section")}</h2>
<SettingsGroup>
<SettingsRow title={t("app.system.restartHint")}>
<Button size="sm" variant="outline" onClick={onRestart}>
{t("app.system.restart")}
</Button>
</SettingsRow>
</SettingsGroup>
</section>
)}
{onLogout && (
<section>
<h2 className="mb-2 px-2 text-xs font-medium text-muted-foreground">{t("app.account.section")}</h2>
+210 -15
View File
@@ -10,6 +10,8 @@ import {
Activity,
ArrowUp,
BookOpen,
Check,
ChevronDown,
CircleHelp,
History,
ImageIcon,
@@ -33,7 +35,7 @@ import {
MAX_IMAGES_PER_MESSAGE,
} from "@/hooks/useAttachedImages";
import { useClipboardAndDrop } from "@/hooks/useClipboardAndDrop";
import type { SendImage } from "@/hooks/useNanobotStream";
import type { SendImage, SendOptions } from "@/hooks/useNanobotStream";
import type { SlashCommand } from "@/lib/types";
import { cn } from "@/lib/utils";
@@ -48,13 +50,16 @@ function formatBytes(n: number): string {
}
interface ThreadComposerProps {
onSend: (content: string, images?: SendImage[]) => void;
onSend: (content: string, images?: SendImage[], options?: SendOptions) => void;
disabled?: boolean;
placeholder?: string;
isStreaming?: boolean;
modelLabel?: string | null;
variant?: "thread" | "hero";
slashCommands?: SlashCommand[];
imageMode?: boolean;
onImageModeChange?: (enabled: boolean) => void;
onStop?: () => void;
}
const COMMAND_ICONS: Record<string, LucideIcon> = {
@@ -69,10 +74,28 @@ const COMMAND_ICONS: Record<string, LucideIcon> = {
"undo-2": Undo2,
};
type ImageAspectRatio = "auto" | "1:1" | "3:4" | "9:16" | "4:3" | "16:9";
const IMAGE_ASPECT_RATIOS: ImageAspectRatio[] = ["auto", "1:1", "3:4", "9:16", "4:3", "16:9"];
function slashCommandI18nKey(command: string): string {
return command.replace(/^\//, "").replace(/-/g, "_");
}
function scrollNearestOverflowParent(target: EventTarget | null, deltaY: number) {
if (!(target instanceof Element) || deltaY === 0) return;
let el: HTMLElement | null = target.parentElement;
while (el) {
const style = window.getComputedStyle(el);
const canScroll = /(auto|scroll)/.test(style.overflowY) && el.scrollHeight > el.clientHeight;
if (canScroll) {
el.scrollTop += deltaY;
return;
}
el = el.parentElement;
}
}
export function ThreadComposer({
onSend,
disabled,
@@ -81,19 +104,38 @@ export function ThreadComposer({
modelLabel = null,
variant = "thread",
slashCommands = [],
imageMode: controlledImageMode,
onImageModeChange,
onStop,
}: ThreadComposerProps) {
const { t } = useTranslation();
const [value, setValue] = useState("");
const [inlineError, setInlineError] = useState<string | null>(null);
const [slashMenuDismissed, setSlashMenuDismissed] = useState(false);
const [selectedCommandIndex, setSelectedCommandIndex] = useState(0);
const [uncontrolledImageMode, setUncontrolledImageMode] = useState(false);
const [imageAspectRatio, setImageAspectRatio] = useState<ImageAspectRatio>("auto");
const [aspectMenuOpen, setAspectMenuOpen] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const aspectControlRef = useRef<HTMLDivElement>(null);
const chipRefs = useRef(new Map<string, HTMLButtonElement>());
const isHero = variant === "hero";
const imageMode = controlledImageMode ?? uncontrolledImageMode;
const setImageMode = useCallback(
(enabled: boolean) => {
if (controlledImageMode === undefined) {
setUncontrolledImageMode(enabled);
}
onImageModeChange?.(enabled);
},
[controlledImageMode, onImageModeChange],
);
const resolvedPlaceholder = isStreaming
? t("thread.composer.placeholderStreaming")
: placeholder ?? t("thread.composer.placeholderThread");
: imageMode
? t("thread.composer.imageMode.placeholder")
: placeholder ?? t("thread.composer.placeholderThread");
const { images, enqueue, remove, clear, encoding, full } =
useAttachedImages();
@@ -190,6 +232,38 @@ export function ThreadComposer({
}
}, [filteredSlashCommands.length, selectedCommandIndex]);
useEffect(() => {
if (!aspectMenuOpen) return;
const closeOnPointerDown = (event: PointerEvent) => {
const target = event.target;
if (target instanceof Node && aspectControlRef.current?.contains(target)) return;
setAspectMenuOpen(false);
};
const closeOnKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setAspectMenuOpen(false);
textareaRef.current?.focus();
}
};
const closeOnScroll = () => setAspectMenuOpen(false);
const closeOnWheel = (event: WheelEvent) => {
setAspectMenuOpen(false);
scrollNearestOverflowParent(event.target, event.deltaY);
};
document.addEventListener("pointerdown", closeOnPointerDown, true);
document.addEventListener("keydown", closeOnKeyDown);
document.addEventListener("scroll", closeOnScroll, true);
document.addEventListener("wheel", closeOnWheel, { capture: true, passive: true });
return () => {
document.removeEventListener("pointerdown", closeOnPointerDown, true);
document.removeEventListener("keydown", closeOnKeyDown);
document.removeEventListener("scroll", closeOnScroll, true);
document.removeEventListener("wheel", closeOnWheel, true);
};
}, [aspectMenuOpen]);
const resizeTextarea = useCallback(() => {
requestAnimationFrame(() => {
const el = textareaRef.current;
@@ -227,7 +301,15 @@ export function ThreadComposer({
preview: { url: img.dataUrl, name: img.file.name },
}))
: undefined;
onSend(trimmed, payload);
const options: SendOptions | undefined = imageMode
? {
imageGeneration: {
enabled: true,
aspect_ratio: imageAspectRatio === "auto" ? null : imageAspectRatio,
},
}
: undefined;
onSend(trimmed, payload, options);
setValue("");
setInlineError(null);
// Bubble owns the data URL copy; safe to revoke every staged blob
@@ -235,7 +317,7 @@ export function ThreadComposer({
clear();
setSlashMenuDismissed(false);
resizeTextarea();
}, [canSend, clear, onSend, readyImages, resizeTextarea, value]);
}, [canSend, clear, imageAspectRatio, imageMode, onSend, readyImages, resizeTextarea, value]);
const onKeyDown = (e: ReactKeyboardEvent<HTMLTextAreaElement>) => {
if (showSlashMenu) {
@@ -312,6 +394,7 @@ export function ThreadComposer({
);
const attachButtonDisabled = disabled || full;
const showStopButton = isStreaming && !!onStop;
return (
<form
@@ -336,7 +419,7 @@ export function ThreadComposer({
) : null}
<div
className={cn(
"relative mx-auto flex w-full flex-col overflow-hidden transition-all duration-200",
"relative mx-auto flex w-full flex-col overflow-visible transition-all duration-200",
isHero
? "max-w-[58rem] rounded-[28px] border border-black/[0.035] bg-card shadow-[0_20px_55px_rgba(15,23,42,0.08)] dark:border-white/[0.06] dark:shadow-[0_24px_55px_rgba(0,0,0,0.34)]"
: "max-w-[49.5rem] rounded-[22px] border border-black/[0.035] bg-card shadow-[0_12px_30px_rgba(15,23,42,0.07)] dark:border-white/[0.06] dark:shadow-[0_16px_34px_rgba(0,0,0,0.28)]",
@@ -439,6 +522,59 @@ export function ThreadComposer({
>
<Plus className={cn(isHero ? "h-5 w-5" : "h-4 w-4")} />
</Button>
<div ref={aspectControlRef} className="relative flex items-center gap-1">
<Button
type="button"
variant="ghost"
disabled={disabled}
aria-pressed={imageMode}
aria-label={t("thread.composer.imageMode.toggle")}
onClick={() => {
setImageMode(!imageMode);
setAspectMenuOpen(false);
textareaRef.current?.focus();
}}
className={cn(
"rounded-full border border-border/55 px-2.5 font-medium shadow-[0_2px_8px_rgba(15,23,42,0.04)]",
isHero ? "h-9 text-[12px]" : "h-7.5 text-[10.5px]",
imageMode
? "border-primary/30 bg-primary/10 text-primary hover:bg-primary/12"
: "bg-card text-muted-foreground hover:bg-card hover:text-foreground",
)}
>
<ImageIcon className={cn("mr-1.5", isHero ? "h-4 w-4" : "h-3.5 w-3.5")} />
{t("thread.composer.imageMode.label")}
</Button>
{imageMode ? (
<Button
type="button"
variant="ghost"
disabled={disabled}
aria-haspopup="listbox"
aria-expanded={aspectMenuOpen}
aria-label={t("thread.composer.imageMode.aspectAria")}
onClick={() => setAspectMenuOpen((open) => !open)}
className={cn(
"rounded-full border border-border/55 bg-card px-2.5 font-medium text-foreground/80 shadow-[0_2px_8px_rgba(15,23,42,0.04)] hover:bg-card",
isHero ? "h-9 text-[12px]" : "h-7.5 text-[10.5px]",
)}
>
<span>{t(`thread.composer.imageMode.aspect.${imageAspectRatio.replace(":", "_")}`)}</span>
<ChevronDown className={cn("ml-1.5", isHero ? "h-3.5 w-3.5" : "h-3 w-3")} />
</Button>
) : null}
{imageMode && aspectMenuOpen ? (
<ImageAspectMenu
selected={imageAspectRatio}
isHero={isHero}
onSelect={(ratio) => {
setImageAspectRatio(ratio);
setAspectMenuOpen(false);
textareaRef.current?.focus();
}}
/>
) : null}
</div>
{modelLabel ? (
<span
title={modelLabel}
@@ -465,19 +601,25 @@ export function ThreadComposer({
</div>
<span className={cn(isHero ? "hidden" : "sm:hidden")} aria-hidden />
<Button
type="submit"
type={showStopButton ? "button" : "submit"}
size="icon"
disabled={!canSend}
aria-label={t("thread.composer.send")}
disabled={showStopButton ? disabled : !canSend}
aria-label={showStopButton ? t("thread.composer.stop") : t("thread.composer.send")}
onClick={showStopButton ? onStop : undefined}
className={cn(
isHero
? "h-9 w-9 rounded-full border border-foreground bg-foreground text-background shadow-[0_4px_12px_rgba(15,23,42,0.20)] hover:bg-foreground/90 disabled:border-foreground/35 disabled:bg-foreground/35 disabled:text-background/80"
: "rounded-full border border-foreground bg-foreground text-background shadow-[0_3px_10px_rgba(15,23,42,0.18)] transition-transform hover:bg-foreground/90 disabled:border-foreground/35 disabled:bg-foreground/35 disabled:text-background/80",
"rounded-full transition-transform",
showStopButton
? "border border-border/70 bg-card text-foreground/85 shadow-[0_3px_10px_rgba(15,23,42,0.08)] hover:bg-muted/65 hover:text-foreground disabled:text-muted-foreground/50"
: isHero
? "border border-foreground bg-foreground text-background shadow-[0_4px_12px_rgba(15,23,42,0.20)] hover:bg-foreground/90 disabled:border-foreground/35 disabled:bg-foreground/35 disabled:text-background/80"
: "border border-foreground bg-foreground text-background shadow-[0_3px_10px_rgba(15,23,42,0.18)] hover:bg-foreground/90 disabled:border-foreground/35 disabled:bg-foreground/35 disabled:text-background/80",
isHero ? "" : "h-7.5 w-7.5",
canSend && "hover:scale-[1.03] active:scale-95",
(canSend || showStopButton) && "hover:scale-[1.03] active:scale-95",
)}
>
{isStreaming ? (
{showStopButton ? (
<Square className={cn("fill-current stroke-current", isHero ? "h-3 w-3" : "h-2.5 w-2.5")} />
) : isStreaming ? (
<Loader2 className={cn(isHero ? "h-4.5 w-4.5" : "h-4 w-4", "animate-spin")} />
) : (
<ArrowUp className={cn(isHero ? "h-4.5 w-4.5" : "h-4 w-4")} />
@@ -497,6 +639,59 @@ interface SlashCommandPaletteProps {
onChoose: (command: SlashCommand) => void;
}
function ImageAspectMenu({
selected,
isHero,
onSelect,
}: {
selected: ImageAspectRatio;
isHero: boolean;
onSelect: (ratio: ImageAspectRatio) => void;
}) {
const { t } = useTranslation();
return (
<div
role="listbox"
aria-label={t("thread.composer.imageMode.aspectAria")}
className={cn(
"absolute left-0 z-30 w-44 overflow-hidden rounded-[16px] border",
isHero ? "top-full mt-2" : "bottom-full mb-2",
"border-border/65 bg-popover p-1.5 text-popover-foreground shadow-[0_16px_45px_rgba(15,23,42,0.16)]",
"dark:border-white/10 dark:shadow-[0_18px_45px_rgba(0,0,0,0.42)]",
isHero ? "text-[12px]" : "text-[11.5px]",
)}
>
<div className="px-2 pb-1 pt-1 font-medium text-muted-foreground/70">
{t("thread.composer.imageMode.aspectLabel")}
</div>
{IMAGE_ASPECT_RATIOS.map((ratio) => {
const label = t(`thread.composer.imageMode.aspect.${ratio.replace(":", "_")}`);
return (
<button
key={ratio}
type="button"
role="option"
aria-selected={selected === ratio}
onMouseDown={(e) => {
e.preventDefault();
onSelect(ratio);
}}
className={cn(
"flex w-full items-center justify-between rounded-[11px] px-2.5 py-2 text-left transition-colors",
selected === ratio
? "bg-primary/10 text-foreground"
: "text-foreground/86 hover:bg-accent/55",
)}
>
<span>{label}</span>
{selected === ratio ? <Check className="h-3.5 w-3.5 text-primary" /> : null}
</button>
);
})}
</div>
);
}
function SlashCommandPalette({
commands,
selectedIndex,
@@ -511,7 +706,7 @@ function SlashCommandPalette({
aria-label={t("thread.composer.slash.ariaLabel")}
className={cn(
"absolute bottom-full left-1/2 z-30 mb-2 max-h-[22rem] w-[calc(100%-0.5rem)] -translate-x-1/2 overflow-hidden rounded-[18px] border",
"border-border/65 bg-popover/98 p-1.5 text-popover-foreground shadow-[0_18px_55px_rgba(15,23,42,0.18)] backdrop-blur",
"border-border/65 bg-popover p-1.5 text-popover-foreground shadow-[0_18px_55px_rgba(15,23,42,0.18)]",
"dark:border-white/10 dark:shadow-[0_22px_55px_rgba(0,0,0,0.45)]",
isHero ? "max-w-[58rem]" : "max-w-[49.5rem]",
)}
+50 -23
View File
@@ -4,9 +4,12 @@ import {
BookOpen,
ChevronRight,
Code2,
ImageIcon,
LayoutGrid,
Lightbulb,
MoreHorizontal,
Palette,
Sparkles,
} from "lucide-react";
import { useTranslation } from "react-i18next";
@@ -15,7 +18,7 @@ import { ThreadComposer } from "@/components/thread/ThreadComposer";
import { ThreadHeader } from "@/components/thread/ThreadHeader";
import { StreamErrorNotice } from "@/components/thread/StreamErrorNotice";
import { ThreadViewport } from "@/components/thread/ThreadViewport";
import { useNanobotStream } from "@/hooks/useNanobotStream";
import { useNanobotStream, type SendImage, type SendOptions } from "@/hooks/useNanobotStream";
import { useSessionHistory } from "@/hooks/useSessions";
import { listSlashCommands } from "@/lib/api";
import type { ChatSummary, SlashCommand, UIMessage } from "@/lib/types";
@@ -52,6 +55,21 @@ const QUICK_ACTION_KEYS = [
{ key: "more", icon: MoreHorizontal, tone: "text-muted-foreground/65" },
] as const;
const IMAGE_QUICK_ACTION_KEYS = [
{ key: "icon", icon: ImageIcon, tone: "text-[#4f9de8]" },
{ key: "sticker", icon: Sparkles, tone: "text-[#f25b8f]" },
{ key: "poster", icon: Palette, tone: "text-[#eba45d]" },
{ key: "product", icon: LayoutGrid, tone: "text-[#53c59d]" },
{ key: "portrait", icon: ImageIcon, tone: "text-[#a877e7]" },
{ key: "edit", icon: MoreHorizontal, tone: "text-muted-foreground/65" },
] as const;
interface PendingFirstMessage {
content: string;
images?: SendImage[];
options?: SendOptions;
}
export function ThreadShell({
session,
title,
@@ -67,10 +85,11 @@ export function ThreadShell({
const chatId = session?.chatId ?? null;
const historyKey = session?.key ?? null;
const { messages: historical, loading, hasPendingToolCalls } = useSessionHistory(historyKey);
const { client, modelName, token } = useClient();
const { modelName, token } = useClient();
const [booting, setBooting] = useState(false);
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
const pendingFirstRef = useRef<string | null>(null);
const [heroImageMode, setHeroImageMode] = useState(false);
const pendingFirstRef = useRef<PendingFirstMessage | null>(null);
const messageCacheRef = useRef<Map<string, UIMessage[]>>(new Map());
const lastCachedChatIdRef = useRef<string | null>(null);
@@ -82,6 +101,7 @@ export function ThreadShell({
messages,
isStreaming,
send,
stop,
setMessages,
streamError,
dismissStreamError,
@@ -109,7 +129,11 @@ export function ThreadShell({
// When the user switches away and back, keep the local in-memory thread
// state (including not-yet-persisted messages) instead of replacing it with
// whatever the history endpoint currently knows about.
setMessages(cached && cached.length > 0 ? cached : historical);
setMessages((prev) => {
if (cached && cached.length > 0) return cached;
if (historical.length === 0 && prev.length > 0) return prev;
return historical;
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [loading, chatId, historical]);
@@ -142,18 +166,9 @@ export function ThreadShell({
const pending = pendingFirstRef.current;
if (!pending) return;
pendingFirstRef.current = null;
client.sendMessage(chatId, pending);
setMessages((prev) => [
...prev,
{
id: crypto.randomUUID(),
role: "user",
content: pending,
createdAt: Date.now(),
},
]);
send(pending.content, pending.images, pending.options);
setBooting(false);
}, [chatId, client, setMessages]);
}, [chatId, send]);
useEffect(() => {
let cancelled = false;
@@ -171,10 +186,10 @@ export function ThreadShell({
}, [token]);
const handleWelcomeSend = useCallback(
async (content: string) => {
async (content: string, images?: SendImage[], options?: SendOptions) => {
if (booting) return;
setBooting(true);
pendingFirstRef.current = content;
pendingFirstRef.current = { content, images, options };
const newId = await onCreateChat?.();
if (!newId) {
pendingFirstRef.current = null;
@@ -186,20 +201,27 @@ export function ThreadShell({
const handleQuickAction = useCallback(
(prompt: string) => {
const options: SendOptions | undefined = heroImageMode
? { imageGeneration: { enabled: true, aspect_ratio: null } }
: undefined;
if (session) {
send(prompt);
send(prompt, undefined, options);
return;
}
void handleWelcomeSend(prompt);
void handleWelcomeSend(prompt, undefined, options);
},
[handleWelcomeSend, send, session],
[handleWelcomeSend, heroImageMode, send, session],
);
const quickActionItems = heroImageMode ? IMAGE_QUICK_ACTION_KEYS : QUICK_ACTION_KEYS;
const quickActionPrefix = heroImageMode
? "thread.empty.imageQuickActions"
: "thread.empty.quickActions";
const quickActions = (
<div className="mx-auto grid w-full max-w-[58rem] grid-cols-2 gap-3 pt-4 sm:grid-cols-3 lg:grid-cols-6 lg:gap-4">
{QUICK_ACTION_KEYS.map(({ key, icon: Icon, tone }) => {
const title = t(`thread.empty.quickActions.${key}.title`);
const prompt = t(`thread.empty.quickActions.${key}.prompt`);
{quickActionItems.map(({ key, icon: Icon, tone }) => {
const title = t(`${quickActionPrefix}.${key}.title`);
const prompt = t(`${quickActionPrefix}.${key}.prompt`);
return (
<button
key={key}
@@ -247,6 +269,9 @@ export function ThreadShell({
modelLabel={toModelBadgeLabel(modelName)}
variant={showHeroComposer ? "hero" : "thread"}
slashCommands={slashCommands}
imageMode={showHeroComposer ? heroImageMode : undefined}
onImageModeChange={showHeroComposer ? setHeroImageMode : undefined}
onStop={stop}
/>
) : (
<ThreadComposer
@@ -260,6 +285,8 @@ export function ThreadShell({
}
modelLabel={toModelBadgeLabel(modelName)}
variant="hero"
imageMode={heroImageMode}
onImageModeChange={setHeroImageMode}
/>
)}
{showHeroComposer ? quickActions : null}
+65 -29
View File
@@ -5,6 +5,7 @@ import { toMediaAttachment } from "@/lib/media";
import type { StreamError } from "@/lib/nanobot-client";
import type {
InboundEvent,
OutboundImageGeneration,
OutboundMedia,
UIImage,
UIMessage,
@@ -34,6 +35,10 @@ export interface SendImage {
preview: UIImage;
}
export interface SendOptions {
imageGeneration?: OutboundImageGeneration;
}
export function useNanobotStream(
chatId: string | null,
initialMessages: UIMessage[] = [],
@@ -42,7 +47,8 @@ export function useNanobotStream(
): {
messages: UIMessage[];
isStreaming: boolean;
send: (content: string, images?: SendImage[]) => void;
send: (content: string, images?: SendImage[], options?: SendOptions) => void;
stop: () => void;
setMessages: React.Dispatch<React.SetStateAction<UIMessage[]>>;
/** Latest transport-level fault raised since the last ``dismissStreamError``.
* ``null`` when there is nothing to show. */
@@ -62,6 +68,7 @@ export function useNanobotStream(
const [isStreaming, setIsStreaming] = useState(initialStreaming || hasPendingToolCalls);
const [streamError, setStreamError] = useState<StreamError | null>(null);
const buffer = useRef<StreamBuffer | null>(null);
const suppressStreamUntilTurnEndRef = useRef(false);
/** Timer that defers ``isStreaming = false`` after ``stream_end``.
*
* When the model finishes a text segment and calls a tool, the server
@@ -77,31 +84,29 @@ 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);
// 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]);
// Reset local state when switching chats. Do not reset on every
// ``initialMessages`` update: a brand-new chat can receive an empty/404
// history response after the optimistic first message has already rendered.
useEffect(() => {
setMessages(initialMessages);
setIsStreaming(
(initialMessages.length > 0
? initialMessages[initialMessages.length - 1].kind === "trace"
: false) || hasPendingToolCalls,
);
setStreamError(null);
buffer.current = null;
suppressStreamUntilTurnEndRef.current = false;
if (streamEndTimerRef.current !== null) {
clearTimeout(streamEndTimerRef.current);
streamEndTimerRef.current = null;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [chatId]);
useEffect(() => {
if (hasPendingToolCalls) setIsStreaming(true);
}, [hasPendingToolCalls]);
useEffect(() => {
if (!chatId) return;
@@ -116,6 +121,7 @@ export function useNanobotStream(
}
if (ev.event === "delta") {
if (suppressStreamUntilTurnEndRef.current) return;
const id = buffer.current?.messageId ?? crypto.randomUUID();
if (!buffer.current) {
buffer.current = { messageId: id, parts: [] };
@@ -141,6 +147,10 @@ export function useNanobotStream(
}
if (ev.event === "stream_end") {
if (suppressStreamUntilTurnEndRef.current) {
buffer.current = null;
return;
}
// 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``.
@@ -160,6 +170,7 @@ export function useNanobotStream(
setMessages((prev) =>
prev.map((m) => (m.isStreaming ? { ...m, isStreaming: false } : m)),
);
suppressStreamUntilTurnEndRef.current = false;
onTurnEnd?.();
return;
}
@@ -170,6 +181,12 @@ export function useNanobotStream(
}
if (ev.event === "message") {
if (
suppressStreamUntilTurnEndRef.current &&
(ev.kind === "tool_hint" || ev.kind === "progress")
) {
return;
}
// Intermediate agent breadcrumbs (tool-call hints, raw progress).
// Attach them to the last trace row if it was the last emitted item
// so a sequence of calls collapses into one compact trace group.
@@ -203,6 +220,7 @@ export function useNanobotStream(
const media = ev.media_urls?.length
? ev.media_urls.map((m) => toMediaAttachment(m))
: ev.media?.map((url) => toMediaAttachment({ url }));
const hasMedia = !!media && media.length > 0;
// A complete (non-streamed) assistant message. If a stream was in
// flight, drop the placeholder so we don't render the text twice.
@@ -221,10 +239,13 @@ export function useNanobotStream(
content,
createdAt: Date.now(),
...(ev.buttons && ev.buttons.length > 0 ? { buttons: ev.buttons } : {}),
...(media && media.length > 0 ? { media } : {}),
...(hasMedia ? { media } : {}),
},
];
});
if (hasMedia) {
suppressStreamUntilTurnEndRef.current = true;
}
return;
}
// ``attached`` / ``error`` frames aren't actionable here; the client
@@ -243,7 +264,7 @@ export function useNanobotStream(
}, [chatId, client, onTurnEnd]);
const send = useCallback(
(content: string, images?: SendImage[]) => {
(content: string, images?: SendImage[], options?: SendOptions) => {
if (!chatId) return;
const hasImages = !!images && images.length > 0;
// Text is optional when images are attached — the agent will still see
@@ -265,15 +286,30 @@ export function useNanobotStream(
// 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);
if (options) {
client.sendMessage(chatId, content, wireMedia, options);
} else {
client.sendMessage(chatId, content, wireMedia);
}
},
[chatId, client],
);
const stop = useCallback(() => {
if (!chatId) return;
setIsStreaming(false);
setMessages((prev) =>
prev.map((m) => (m.isStreaming ? { ...m, isStreaming: false } : m)),
);
suppressStreamUntilTurnEndRef.current = false;
client.sendMessage(chatId, "/stop");
}, [chatId, client]);
return {
messages,
isStreaming,
send,
stop,
setMessages,
streamError,
dismissStreamError,
+50
View File
@@ -21,6 +21,14 @@
"logoutHint": "Disconnect this browser from the gateway.",
"logout": "Sign out"
},
"system": {
"section": "System",
"restartHint": "Restart nanobot to apply runtime changes.",
"restart": "Restart nanobot"
},
"restart": {
"completed": "Restart completed in {{seconds}}s."
},
"documentTitle": {
"base": "nanobot",
"chat": "{{title}} · nanobot"
@@ -104,6 +112,32 @@
"title": "More",
"prompt": "Show me a few useful ways you can help in this workspace."
}
},
"imageQuickActions": {
"icon": {
"title": "Design an app icon",
"prompt": "Generate a clean 1:1 app icon for nanobot: friendly robot, simple vector style, soft blue and white palette, no text."
},
"sticker": {
"title": "Make a sticker",
"prompt": "Generate a cute sticker-style image of a tiny robot assistant, transparent-looking background, expressive and playful."
},
"poster": {
"title": "Create a poster",
"prompt": "Generate a polished poster concept for a personal AI assistant, modern composition, strong visual hierarchy, suitable for a landing page."
},
"product": {
"title": "Product mockup",
"prompt": "Generate a clean product mockup image for a conversational AI web app, minimal interface, premium lighting, realistic device frame."
},
"portrait": {
"title": "Stylized portrait",
"prompt": "Generate a stylized portrait of a friendly AI companion, soft lighting, detailed but approachable, modern illustration style."
},
"edit": {
"title": "Edit an image",
"prompt": "Help me edit an image. Ask me to upload or reference the image first, then generate the edited result."
}
}
},
"header": {
@@ -120,7 +154,23 @@
"inputAria": "Message input",
"sendHint": "Enter to send · Shift+Enter for newline",
"send": "Send message",
"stop": "Stop response",
"attachImage": "Attach image",
"imageMode": {
"label": "Image Generation",
"toggle": "Toggle image generation mode",
"placeholder": "Describe or edit an image…",
"aspectAria": "Image aspect ratio",
"aspectLabel": "Image aspect",
"aspect": {
"auto": "Auto",
"1_1": "Square 1:1",
"3_4": "Portrait 3:4",
"9_16": "Story 9:16",
"4_3": "Landscape 4:3",
"16_9": "Wide 16:9"
}
},
"tools": {
"search": "Search",
"reason": "Reason",
+50
View File
@@ -9,6 +9,14 @@
"title": "No se pudo conectar con nanobot",
"gatewayHint": "Asegúrate de que la gateway esté en ejecución (`nanobot gateway`) y de que esta página esté abierta en la misma máquina."
},
"system": {
"section": "Sistema",
"restartHint": "Reinicia nanobot para aplicar los cambios de ejecución.",
"restart": "Reiniciar nanobot"
},
"restart": {
"completed": "Reinicio completado en {{seconds}} s."
},
"documentTitle": {
"base": "nanobot",
"chat": "{{title}} · nanobot"
@@ -80,6 +88,32 @@
"title": "Más",
"prompt": "Muéstrame algunas formas útiles en las que puedes ayudar en este workspace."
}
},
"imageQuickActions": {
"icon": {
"title": "Diseñar un icono de app",
"prompt": "Genera un icono de app 1:1 limpio para nanobot: robot amigable, estilo vectorial simple, paleta suave azul y blanca, sin texto."
},
"sticker": {
"title": "Crear un sticker",
"prompt": "Genera una imagen estilo sticker de un pequeño asistente robot, con fondo de apariencia transparente, expresivo y divertido."
},
"poster": {
"title": "Crear un póster",
"prompt": "Genera un concepto de póster pulido para un asistente personal de IA, composición moderna, jerarquía visual fuerte, apto para una landing page."
},
"product": {
"title": "Mockup de producto",
"prompt": "Genera una imagen limpia de mockup de producto para una app web de IA conversacional, interfaz mínima, iluminación premium, marco de dispositivo realista."
},
"portrait": {
"title": "Retrato estilizado",
"prompt": "Genera un retrato estilizado de un compañero de IA amigable, luz suave, detallado pero cercano, estilo de ilustración moderna."
},
"edit": {
"title": "Editar una imagen",
"prompt": "Ayúdame a editar una imagen. Primero pídeme que suba o indique la imagen, y luego genera el resultado editado."
}
}
},
"header": {
@@ -93,7 +127,23 @@
"inputAria": "Entrada de mensaje",
"sendHint": "Enter para enviar · Shift+Enter para nueva línea",
"send": "Enviar mensaje",
"stop": "Detener respuesta",
"attachImage": "Adjuntar imagen",
"imageMode": {
"label": "Generar imagen",
"toggle": "Activar o desactivar modo de generación de imágenes",
"placeholder": "Describe o edita una imagen…",
"aspectAria": "Relación de aspecto de imagen",
"aspectLabel": "Formato de imagen",
"aspect": {
"auto": "Auto",
"1_1": "Cuadrado 1:1",
"3_4": "Vertical 3:4",
"9_16": "Historia 9:16",
"4_3": "Horizontal 4:3",
"16_9": "Panorámico 16:9"
}
},
"encoding": "Procesando…",
"remove": "Quitar adjunto",
"normalizedSizeHint": "{{orig}} → {{current}} (auto)",
+50
View File
@@ -9,6 +9,14 @@
"title": "Impossible de joindre nanobot",
"gatewayHint": "Assurez-vous que la gateway est en cours dexécution (`nanobot gateway`) et que cette page est ouverte sur la même machine."
},
"system": {
"section": "Système",
"restartHint": "Redémarrez nanobot pour appliquer les changements dexécution.",
"restart": "Redémarrer nanobot"
},
"restart": {
"completed": "Redémarrage terminé en {{seconds}} s."
},
"documentTitle": {
"base": "nanobot",
"chat": "{{title}} · nanobot"
@@ -80,6 +88,32 @@
"title": "Plus",
"prompt": "Montrez-moi quelques façons utiles dont vous pouvez maider dans cet espace de travail."
}
},
"imageQuickActions": {
"icon": {
"title": "Créer une icône dapp",
"prompt": "Générez une icône dapplication 1:1 propre pour nanobot : robot sympathique, style vectoriel simple, palette douce bleu et blanc, sans texte."
},
"sticker": {
"title": "Créer un sticker",
"prompt": "Générez une image façon sticker dun petit assistant robot, avec un fond dapparence transparente, expressive et ludique."
},
"poster": {
"title": "Créer une affiche",
"prompt": "Générez un concept daffiche soigné pour un assistant IA personnel, composition moderne, hiérarchie visuelle forte, adapté à une landing page."
},
"product": {
"title": "Maquette produit",
"prompt": "Générez une maquette produit propre pour une application web dIA conversationnelle, interface minimale, éclairage premium, cadre dappareil réaliste."
},
"portrait": {
"title": "Portrait stylisé",
"prompt": "Générez un portrait stylisé dun compagnon IA sympathique, lumière douce, détaillé mais accessible, style illustration moderne."
},
"edit": {
"title": "Modifier une image",
"prompt": "Aidez-moi à modifier une image. Demandez-moi dabord de téléverser ou dindiquer limage, puis générez le résultat modifié."
}
}
},
"header": {
@@ -93,7 +127,23 @@
"inputAria": "Champ de message",
"sendHint": "Entrée pour envoyer · Maj+Entrée pour un retour à la ligne",
"send": "Envoyer le message",
"stop": "Arrêter la réponse",
"attachImage": "Joindre une image",
"imageMode": {
"label": "Génération dimage",
"toggle": "Activer ou désactiver le mode génération dimage",
"placeholder": "Décrire ou modifier une image…",
"aspectAria": "Format de limage",
"aspectLabel": "Format de limage",
"aspect": {
"auto": "Auto",
"1_1": "Carré 1:1",
"3_4": "Portrait 3:4",
"9_16": "Story 9:16",
"4_3": "Paysage 4:3",
"16_9": "Large 16:9"
}
},
"encoding": "Traitement…",
"remove": "Retirer la pièce jointe",
"normalizedSizeHint": "{{orig}} → {{current}} (auto)",
+50
View File
@@ -9,6 +9,14 @@
"title": "Tidak dapat menjangkau nanobot",
"gatewayHint": "Pastikan gateway sedang berjalan (`nanobot gateway`) dan halaman ini dibuka pada mesin yang sama."
},
"system": {
"section": "Sistem",
"restartHint": "Mulai ulang nanobot untuk menerapkan perubahan runtime.",
"restart": "Mulai ulang nanobot"
},
"restart": {
"completed": "Mulai ulang selesai dalam {{seconds}} dtk."
},
"documentTitle": {
"base": "nanobot",
"chat": "{{title}} · nanobot"
@@ -80,6 +88,32 @@
"title": "Lainnya",
"prompt": "Tunjukkan beberapa cara berguna Anda dapat membantu di workspace ini."
}
},
"imageQuickActions": {
"icon": {
"title": "Desain ikon aplikasi",
"prompt": "Buat ikon aplikasi 1:1 yang bersih untuk nanobot: robot ramah, gaya vektor sederhana, palet biru dan putih lembut, tanpa teks."
},
"sticker": {
"title": "Buat stiker",
"prompt": "Buat gambar gaya stiker yang lucu dari asisten robot kecil, latar terlihat transparan, ekspresif dan menyenangkan."
},
"poster": {
"title": "Buat poster",
"prompt": "Buat konsep poster yang rapi untuk asisten AI pribadi, komposisi modern, hierarki visual kuat, cocok untuk landing page."
},
"product": {
"title": "Mockup produk",
"prompt": "Buat gambar mockup produk yang bersih untuk aplikasi web AI percakapan, antarmuka minimal, pencahayaan premium, bingkai perangkat realistis."
},
"portrait": {
"title": "Potret bergaya",
"prompt": "Buat potret bergaya dari pendamping AI yang ramah, pencahayaan lembut, detail tetapi tetap mudah didekati, gaya ilustrasi modern."
},
"edit": {
"title": "Edit gambar",
"prompt": "Bantu saya mengedit gambar. Minta saya mengunggah atau menyebutkan gambar terlebih dahulu, lalu buat hasil editnya."
}
}
},
"header": {
@@ -93,7 +127,23 @@
"inputAria": "Input pesan",
"sendHint": "Enter untuk kirim · Shift+Enter untuk baris baru",
"send": "Kirim pesan",
"stop": "Hentikan respons",
"attachImage": "Lampirkan gambar",
"imageMode": {
"label": "Buat gambar",
"toggle": "Alihkan mode pembuatan gambar",
"placeholder": "Deskripsikan atau edit gambar…",
"aspectAria": "Rasio aspek gambar",
"aspectLabel": "Rasio gambar",
"aspect": {
"auto": "Otomatis",
"1_1": "Persegi 1:1",
"3_4": "Potret 3:4",
"9_16": "Story 9:16",
"4_3": "Lanskap 4:3",
"16_9": "Lebar 16:9"
}
},
"encoding": "Memproses…",
"remove": "Hapus lampiran",
"normalizedSizeHint": "{{orig}} → {{current}} (auto)",
+50
View File
@@ -9,6 +9,14 @@
"title": "nanobot に接続できませんでした",
"gatewayHint": "gateway`nanobot gateway`)が起動しており、このページが同じマシン上で開かれていることを確認してください。"
},
"system": {
"section": "システム",
"restartHint": "実行時の変更を適用するには nanobot を再起動します。",
"restart": "nanobot を再起動"
},
"restart": {
"completed": "{{seconds}} 秒で再起動が完了しました。"
},
"documentTitle": {
"base": "nanobot",
"chat": "{{title}} · nanobot"
@@ -80,6 +88,32 @@
"title": "その他",
"prompt": "このワークスペースであなたが手伝える便利な方法をいくつか見せてください。"
}
},
"imageQuickActions": {
"icon": {
"title": "アプリアイコンを作る",
"prompt": "nanobot のクリーンな 1:1 アプリアイコンを生成してください。親しみやすいロボット、シンプルなベクター風、柔らかい青と白の配色、文字なし。"
},
"sticker": {
"title": "ステッカーを作る",
"prompt": "小さなロボットアシスタントのかわいいステッカー風画像を生成してください。透明風の背景で、表情豊かで遊び心のある雰囲気。"
},
"poster": {
"title": "ポスターを作る",
"prompt": "個人向け AI アシスタントの洗練されたポスター案を生成してください。モダンな構図、強い視覚階層、ランディングページ向け。"
},
"product": {
"title": "製品モックアップ",
"prompt": "会話型 AI Web アプリのクリーンな製品モックアップ画像を生成してください。ミニマルな UI、上質なライティング、リアルなデバイスフレーム。"
},
"portrait": {
"title": "スタイル付きポートレート",
"prompt": "親しみやすい AI コンパニオンのスタイル付きポートレートを生成してください。柔らかい光、細部は豊かで近づきやすい、モダンなイラスト風。"
},
"edit": {
"title": "画像を編集",
"prompt": "画像編集を手伝ってください。まず編集する画像のアップロードまたは指定を求め、その後に編集後の結果を生成してください。"
}
}
},
"header": {
@@ -93,7 +127,23 @@
"inputAria": "メッセージ入力欄",
"sendHint": "Enter で送信 · Shift+Enter で改行",
"send": "メッセージを送信",
"stop": "応答を停止",
"attachImage": "画像を添付",
"imageMode": {
"label": "画像生成",
"toggle": "画像生成モードを切り替え",
"placeholder": "画像を説明または編集…",
"aspectAria": "画像のアスペクト比",
"aspectLabel": "画像の比率",
"aspect": {
"auto": "自動",
"1_1": "正方形 1:1",
"3_4": "縦長 3:4",
"9_16": "ストーリー 9:16",
"4_3": "横長 4:3",
"16_9": "ワイド 16:9"
}
},
"encoding": "処理中…",
"remove": "添付を削除",
"normalizedSizeHint": "{{orig}} → {{current}}(自動圧縮)",
+50
View File
@@ -9,6 +9,14 @@
"title": "nanobot에 연결할 수 없습니다",
"gatewayHint": "gateway(`nanobot gateway`)가 실행 중인지, 그리고 이 페이지가 같은 머신에서 열려 있는지 확인하세요."
},
"system": {
"section": "시스템",
"restartHint": "런타임 변경 사항을 적용하려면 nanobot을 다시 시작하세요.",
"restart": "nanobot 다시 시작"
},
"restart": {
"completed": "{{seconds}}초 만에 다시 시작되었습니다."
},
"documentTitle": {
"base": "nanobot",
"chat": "{{title}} · nanobot"
@@ -80,6 +88,32 @@
"title": "더 보기",
"prompt": "이 워크스페이스에서 도와줄 수 있는 유용한 방법을 몇 가지 보여 주세요."
}
},
"imageQuickActions": {
"icon": {
"title": "앱 아이콘 디자인",
"prompt": "nanobot을 위한 깔끔한 1:1 앱 아이콘을 생성해 주세요. 친근한 로봇, 단순한 벡터 스타일, 부드러운 파란색과 흰색 팔레트, 텍스트 없음."
},
"sticker": {
"title": "스티커 만들기",
"prompt": "작은 로봇 도우미의 귀여운 스티커 스타일 이미지를 생성해 주세요. 투명해 보이는 배경, 표정이 풍부하고 장난스러운 느낌."
},
"poster": {
"title": "포스터 만들기",
"prompt": "개인 AI 도우미를 위한 세련된 포스터 콘셉트를 생성해 주세요. 현대적인 구성, 강한 시각적 계층, 랜딩 페이지에 어울리는 스타일."
},
"product": {
"title": "제품 목업",
"prompt": "대화형 AI 웹 앱을 위한 깔끔한 제품 목업 이미지를 생성해 주세요. 미니멀한 인터페이스, 고급스러운 조명, 현실적인 기기 프레임."
},
"portrait": {
"title": "스타일화된 초상화",
"prompt": "친근한 AI 동반자의 스타일화된 초상화를 생성해 주세요. 부드러운 조명, 세밀하지만 다가가기 쉬운 분위기, 현대적인 일러스트 스타일."
},
"edit": {
"title": "이미지 편집",
"prompt": "이미지 편집을 도와주세요. 먼저 편집할 이미지를 업로드하거나 지정하게 한 뒤, 편집된 결과를 생성해 주세요."
}
}
},
"header": {
@@ -93,7 +127,23 @@
"inputAria": "메시지 입력",
"sendHint": "Enter로 전송 · Shift+Enter로 줄바꿈",
"send": "메시지 보내기",
"stop": "응답 중지",
"attachImage": "이미지 첨부",
"imageMode": {
"label": "이미지 생성",
"toggle": "이미지 생성 모드 전환",
"placeholder": "이미지를 설명하거나 편집하세요…",
"aspectAria": "이미지 화면 비율",
"aspectLabel": "이미지 비율",
"aspect": {
"auto": "자동",
"1_1": "정사각형 1:1",
"3_4": "세로 3:4",
"9_16": "스토리 9:16",
"4_3": "가로 4:3",
"16_9": "와이드 16:9"
}
},
"encoding": "처리 중…",
"remove": "첨부 제거",
"normalizedSizeHint": "{{orig}} → {{current}} (자동 압축)",
+50
View File
@@ -9,6 +9,14 @@
"title": "Không thể kết nối tới nanobot",
"gatewayHint": "Hãy chắc chắn gateway đang chạy (`nanobot gateway`) và trang này được mở trên cùng máy."
},
"system": {
"section": "Hệ thống",
"restartHint": "Khởi động lại nanobot để áp dụng thay đổi runtime.",
"restart": "Khởi động lại nanobot"
},
"restart": {
"completed": "Khởi động lại hoàn tất sau {{seconds}} giây."
},
"documentTitle": {
"base": "nanobot",
"chat": "{{title}} · nanobot"
@@ -80,6 +88,32 @@
"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."
}
},
"imageQuickActions": {
"icon": {
"title": "Thiết kế biểu tượng app",
"prompt": "Tạo một biểu tượng ứng dụng 1:1 gọn gàng cho nanobot: robot thân thiện, phong cách vector đơn giản, bảng màu xanh trắng dịu, không có chữ."
},
"sticker": {
"title": "Tạo sticker",
"prompt": "Tạo một hình kiểu sticker dễ thương của trợ lý robot nhỏ, nền trông như trong suốt, biểu cảm và vui nhộn."
},
"poster": {
"title": "Tạo poster",
"prompt": "Tạo một ý tưởng poster chỉn chu cho trợ lý AI cá nhân, bố cục hiện đại, phân cấp thị giác rõ, phù hợp cho landing page."
},
"product": {
"title": "Mockup sản phẩm",
"prompt": "Tạo một hình mockup sản phẩm gọn gàng cho ứng dụng web AI hội thoại, giao diện tối giản, ánh sáng cao cấp, khung thiết bị chân thực."
},
"portrait": {
"title": "Chân dung cách điệu",
"prompt": "Tạo chân dung cách điệu của một người bạn đồng hành AI thân thiện, ánh sáng mềm, chi tiết nhưng dễ gần, phong cách minh họa hiện đại."
},
"edit": {
"title": "Chỉnh sửa ảnh",
"prompt": "Giúp tôi chỉnh sửa một ảnh. Trước tiên hãy yêu cầu tôi tải lên hoặc chỉ định ảnh, rồi tạo kết quả đã chỉnh sửa."
}
}
},
"header": {
@@ -93,7 +127,23 @@
"inputAria": "Ô nhập tin nhắn",
"sendHint": "Enter để gửi · Shift+Enter để xuống dòng",
"send": "Gửi tin nhắn",
"stop": "Dừng phản hồi",
"attachImage": "Đính kèm ảnh",
"imageMode": {
"label": "Tạo ảnh",
"toggle": "Bật/tắt chế độ tạo ảnh",
"placeholder": "Mô tả hoặc chỉnh sửa ảnh…",
"aspectAria": "Tỷ lệ khung hình ảnh",
"aspectLabel": "Tỷ lệ ảnh",
"aspect": {
"auto": "Tự động",
"1_1": "Vuông 1:1",
"3_4": "Dọc 3:4",
"9_16": "Story 9:16",
"4_3": "Ngang 4:3",
"16_9": "Rộng 16:9"
}
},
"encoding": "Đang xử lý…",
"remove": "Xóa tệp đính kèm",
"normalizedSizeHint": "{{orig}} → {{current}} (tự động)",
+50
View File
@@ -9,6 +9,14 @@
"title": "无法连接到 nanobot",
"gatewayHint": "请确认 gateway 已启动(`nanobot gateway`),并且当前页面与 gateway 运行在同一台机器上。"
},
"system": {
"section": "系统",
"restartHint": "重启 nanobot 以应用运行时更改。",
"restart": "重启 nanobot"
},
"restart": {
"completed": "重启已完成,用时 {{seconds}} 秒。"
},
"documentTitle": {
"base": "nanobot",
"chat": "{{title}} · nanobot"
@@ -92,6 +100,32 @@
"title": "更多",
"prompt": "展示几个你在这个工作区里可以帮我的实用方式。"
}
},
"imageQuickActions": {
"icon": {
"title": "设计应用图标",
"prompt": "生成一个干净的 1:1 nanobot 应用图标:友好的机器人,简洁矢量风格,蓝白柔和配色,不要文字。"
},
"sticker": {
"title": "制作贴纸",
"prompt": "生成一张可爱的贴纸风小机器人助手图片,背景像透明贴纸,表情活泼有趣。"
},
"poster": {
"title": "创建海报",
"prompt": "生成一张个人 AI 助手的精致海报概念图,现代构图,视觉层级清晰,适合落地页展示。"
},
"product": {
"title": "产品样机",
"prompt": "生成一张对话式 AI Web 应用的干净产品样机图,极简界面,高级光影,真实设备边框。"
},
"portrait": {
"title": "风格化头像",
"prompt": "生成一个友好的 AI 伙伴风格化头像,柔和光线,细节丰富但亲切,现代插画风格。"
},
"edit": {
"title": "编辑图片",
"prompt": "帮我编辑一张图片。先让我上传或指定要编辑的图片,然后生成编辑后的结果。"
}
}
},
"header": {
@@ -108,7 +142,23 @@
"inputAria": "消息输入框",
"sendHint": "Enter 发送 · Shift+Enter 换行",
"send": "发送消息",
"stop": "停止响应",
"attachImage": "添加图片",
"imageMode": {
"label": "图片生成",
"toggle": "切换图片生成模式",
"placeholder": "描述或编辑图片…",
"aspectAria": "图片画幅",
"aspectLabel": "图片画幅",
"aspect": {
"auto": "自动",
"1_1": "方形 1:1",
"3_4": "竖版 3:4",
"9_16": "故事版 9:16",
"4_3": "横版 4:3",
"16_9": "宽屏 16:9"
}
},
"tools": {
"search": "搜索",
"reason": "推理",
+50
View File
@@ -9,6 +9,14 @@
"title": "無法連線到 nanobot",
"gatewayHint": "請確認 gateway 已啟動(`nanobot gateway`),並且目前頁面與 gateway 在同一台機器上開啟。"
},
"system": {
"section": "系統",
"restartHint": "重新啟動 nanobot 以套用執行階段變更。",
"restart": "重新啟動 nanobot"
},
"restart": {
"completed": "重新啟動已完成,耗時 {{seconds}} 秒。"
},
"documentTitle": {
"base": "nanobot",
"chat": "{{title}} · nanobot"
@@ -80,6 +88,32 @@
"title": "更多",
"prompt": "展示幾個你在這個工作區裡可以幫我的實用方式。"
}
},
"imageQuickActions": {
"icon": {
"title": "設計應用程式圖示",
"prompt": "生成一個乾淨的 1:1 nanobot 應用程式圖示:友善的機器人、簡潔向量風格、柔和藍白配色,不要文字。"
},
"sticker": {
"title": "製作貼圖",
"prompt": "生成一張可愛貼圖風格的小型機器人助理圖片,背景像透明貼紙,表情活潑有趣。"
},
"poster": {
"title": "建立海報",
"prompt": "生成一張個人 AI 助理的精緻海報概念圖,現代構圖、清楚的視覺層級,適合登陸頁展示。"
},
"product": {
"title": "產品樣機",
"prompt": "生成一張對話式 AI Web 應用的乾淨產品樣機圖,極簡介面、高級光影、真實裝置邊框。"
},
"portrait": {
"title": "風格化頭像",
"prompt": "生成一個友善 AI 夥伴的風格化頭像,柔和光線、細節豐富但親切,現代插畫風格。"
},
"edit": {
"title": "編輯圖片",
"prompt": "幫我編輯一張圖片。先請我上傳或指定要編輯的圖片,然後生成編輯後的結果。"
}
}
},
"header": {
@@ -93,7 +127,23 @@
"inputAria": "訊息輸入框",
"sendHint": "Enter 送出 · Shift+Enter 換行",
"send": "送出訊息",
"stop": "停止回覆",
"attachImage": "附加圖片",
"imageMode": {
"label": "圖片生成",
"toggle": "切換圖片生成模式",
"placeholder": "描述或編輯圖片…",
"aspectAria": "圖片畫幅",
"aspectLabel": "圖片畫幅",
"aspect": {
"auto": "自動",
"1_1": "方形 1:1",
"3_4": "直式 3:4",
"9_16": "故事版 9:16",
"4_3": "橫式 4:3",
"16_9": "寬螢幕 16:9"
}
},
"encoding": "處理中…",
"remove": "移除附件",
"normalizedSizeHint": "{{orig}} → {{current}}(已自動壓縮)",
+9 -7
View File
@@ -126,13 +126,15 @@ export async function listSlashCommands(
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 ?? "",
}));
return body.commands
.filter((command) => !["/stop", "/restart"].includes(command.command))
.map((command) => ({
command: command.command,
title: command.title,
description: command.description,
icon: command.icon,
argHint: command.arg_hint ?? "",
}));
}
export async function updateSettings(
+15 -5
View File
@@ -2,6 +2,7 @@ import type {
ConnectionStatus,
InboundEvent,
Outbound,
OutboundImageGeneration,
OutboundMedia,
} from "./types";
@@ -181,12 +182,21 @@ export class NanobotClient {
}
}
sendMessage(chatId: string, content: string, media?: OutboundMedia[]): void {
sendMessage(
chatId: string,
content: string,
media?: OutboundMedia[],
options?: { imageGeneration?: OutboundImageGeneration },
): void {
this.knownChats.add(chatId);
const frame: Outbound =
media && media.length > 0
? { type: "message", chat_id: chatId, content, media, webui: true }
: { type: "message", chat_id: chatId, content, webui: true };
const frame: Outbound = {
type: "message",
chat_id: chatId,
content,
...(media && media.length > 0 ? { media } : {}),
...(options?.imageGeneration ? { image_generation: options.imageGeneration } : {}),
webui: true,
};
this.queueSend(frame);
}
+6
View File
@@ -150,6 +150,11 @@ export interface OutboundMedia {
name?: string;
}
export interface OutboundImageGeneration {
enabled: true;
aspect_ratio?: string | null;
}
export type Outbound =
| { type: "new_chat" }
| { type: "attach"; chat_id: string }
@@ -158,6 +163,7 @@ export type Outbound =
chat_id: string;
content: string;
media?: OutboundMedia[];
image_generation?: OutboundImageGeneration;
/** Marks messages sent by the embedded WebUI, without changing the
* generic websocket protocol for other clients. */
webui?: true;
+12
View File
@@ -84,6 +84,18 @@ describe("webui API helpers", () => {
ok: true,
json: async () => ({
commands: [
{
command: "/stop",
title: "Stop current task",
description: "Cancel the active task.",
icon: "square",
},
{
command: "/restart",
title: "Restart nanobot",
description: "Restart the bot process.",
icon: "rotate-cw",
},
{
command: "/history",
title: "Show conversation history",
+6
View File
@@ -7,6 +7,7 @@ import { ThreadComposer } from "@/components/thread/ThreadComposer";
import { resources } from "@/i18n";
const QUICK_ACTION_KEYS = ["plan", "analyze", "brainstorm", "code", "summarize", "more"];
const IMAGE_QUICK_ACTION_KEYS = ["icon", "sticker", "poster", "product", "portrait", "edit"];
describe("webui i18n", () => {
it("switches UI copy and document locale through the language switcher", async () => {
@@ -54,6 +55,11 @@ describe("webui i18n", () => {
expect(action.title).toBeTruthy();
expect(action.prompt).toBeTruthy();
}
for (const key of IMAGE_QUICK_ACTION_KEYS) {
const action = empty.imageQuickActions[key as keyof typeof empty.imageQuickActions];
expect(action.title).toBeTruthy();
expect(action.prompt).toBeTruthy();
}
}
});
});
+22
View File
@@ -102,4 +102,26 @@ describe("MessageBubble", () => {
expect(video).toHaveAttribute("src", "/api/media/sig/payload");
expect(container.querySelector("video[controls]")).toBeInTheDocument();
});
it("renders assistant image media as a larger generated result", () => {
const message: UIMessage = {
id: "a-image",
role: "assistant",
content: "done",
createdAt: Date.now(),
media: [
{
kind: "image",
url: "/api/media/sig/image",
name: "generated.png",
},
],
};
const { container } = render(<MessageBubble message={message} />);
const imageButton = screen.getByRole("button", { name: /view image/i });
expect(imageButton).toHaveClass("h-56", "sm:h-72");
expect(container.querySelector("img")).toHaveClass("object-contain");
});
});
+27
View File
@@ -120,6 +120,33 @@ describe("NanobotClient", () => {
);
});
it("includes image generation options in outbound messages", () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
client.connect();
lastSocket().fakeOpen();
client.sendMessage(
"chat-img",
"draw a banner",
undefined,
{ imageGeneration: { enabled: true, aspect_ratio: "16:9" } },
);
expect(lastSocket().sent).toContain(
JSON.stringify({
type: "message",
chat_id: "chat-img",
content: "draw a banner",
image_generation: { enabled: true, aspect_ratio: "16:9" },
webui: true,
}),
);
});
it("re-attaches known chats after a reconnect", async () => {
const client = new NanobotClient({
url: "ws://test",
+112
View File
@@ -91,4 +91,116 @@ describe("ThreadComposer", () => {
expect(onSend).not.toHaveBeenCalled();
expect(screen.queryByRole("listbox", { name: "Slash commands" })).not.toBeInTheDocument();
});
it("sends image generation mode with automatic aspect ratio", () => {
const onSend = vi.fn();
render(
<ThreadComposer
onSend={onSend}
placeholder="Type your message..."
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Toggle image generation mode" }));
expect(screen.getByPlaceholderText("Describe or edit an image…")).toBeInTheDocument();
const input = screen.getByLabelText("Message input");
fireEvent.change(input, { target: { value: "Draw a friendly robot" } });
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
expect(onSend).toHaveBeenCalledWith(
"Draw a friendly robot",
undefined,
{ imageGeneration: { enabled: true, aspect_ratio: null } },
);
});
it("shows a stop button while streaming", () => {
const onStop = vi.fn();
render(
<ThreadComposer
onSend={vi.fn()}
onStop={onStop}
isStreaming
placeholder="Type your message..."
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Stop response" }));
expect(onStop).toHaveBeenCalledTimes(1);
expect(screen.queryByRole("button", { name: "Send message" })).not.toBeInTheDocument();
});
it("lets users select a concrete image aspect ratio", () => {
const onSend = vi.fn();
render(
<ThreadComposer
onSend={onSend}
placeholder="Type your message..."
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Toggle image generation mode" }));
fireEvent.click(screen.getByRole("button", { name: "Image aspect ratio" }));
expect(screen.getByRole("listbox", { name: "Image aspect ratio" }).className).toContain(
"bottom-full",
);
fireEvent.mouseDown(screen.getByRole("option", { name: "Wide 16:9" }));
const input = screen.getByLabelText("Message input");
fireEvent.change(input, { target: { value: "Draw a banner" } });
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
expect(onSend).toHaveBeenCalledWith(
"Draw a banner",
undefined,
{ imageGeneration: { enabled: true, aspect_ratio: "16:9" } },
);
});
it("opens the hero image aspect menu downward", () => {
render(
<ThreadComposer
onSend={vi.fn()}
placeholder="Ask anything..."
variant="hero"
imageMode
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Image aspect ratio" }));
expect(screen.getByRole("listbox", { name: "Image aspect ratio" }).className).toContain(
"top-full",
);
});
it("dismisses the image aspect menu on outside click, escape, and wheel", () => {
render(
<div>
<button type="button">outside</button>
<ThreadComposer
onSend={vi.fn()}
placeholder="Type your message..."
imageMode
/>
</div>,
);
const aspectButton = screen.getByRole("button", { name: "Image aspect ratio" });
fireEvent.click(aspectButton);
expect(screen.getByRole("listbox", { name: "Image aspect ratio" })).toBeInTheDocument();
fireEvent.pointerDown(screen.getByRole("button", { name: "outside" }));
expect(screen.queryByRole("listbox", { name: "Image aspect ratio" })).not.toBeInTheDocument();
fireEvent.click(aspectButton);
fireEvent.keyDown(document, { key: "Escape" });
expect(screen.queryByRole("listbox", { name: "Image aspect ratio" })).not.toBeInTheDocument();
fireEvent.click(aspectButton);
fireEvent.wheel(screen.getByRole("listbox", { name: "Image aspect ratio" }), { deltaY: 120 });
expect(screen.queryByRole("listbox", { name: "Image aspect ratio" })).not.toBeInTheDocument();
});
});
+82
View File
@@ -250,6 +250,64 @@ describe("ThreadShell", () => {
expect(onNewChat).not.toHaveBeenCalled();
});
it("keeps the first landing message when new chat history is still empty", async () => {
const client = makeClient();
const onCreateChat = vi.fn().mockResolvedValue("chat-new");
vi.stubGlobal(
"fetch",
vi.fn(async () => ({
ok: false,
status: 404,
json: async () => ({}),
})),
);
const { rerender } = render(
wrap(
client,
<ThreadShell
session={null}
title="nanobot"
onToggleSidebar={() => {}}
onCreateChat={onCreateChat}
/>,
),
);
fireEvent.change(screen.getByLabelText("Message input"), {
target: { value: "first message should stay" },
});
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
await waitFor(() => expect(onCreateChat).toHaveBeenCalledTimes(1));
await act(async () => {
rerender(
wrap(
client,
<ThreadShell
session={session("chat-new")}
title="Chat chat-new"
onToggleSidebar={() => {}}
onCreateChat={onCreateChat}
/>,
),
);
});
await waitFor(() =>
expect(client.sendMessage).toHaveBeenCalledWith(
"chat-new",
"first message should stay",
undefined,
),
);
await waitFor(() =>
expect(screen.getByText("first message should stay")).toBeInTheDocument(),
);
expect(screen.queryByText("What can I do for you?")).not.toBeInTheDocument();
});
it("sends quick action prompts from the empty thread landing", async () => {
const client = makeClient();
const onNewChat = vi.fn().mockResolvedValue("chat-a");
@@ -566,6 +624,30 @@ describe("ThreadShell", () => {
expect(screen.queryByRole("listbox", { name: "Slash commands" })).not.toBeInTheDocument();
});
it("switches welcome quick actions when image mode is enabled", async () => {
const client = makeClient();
render(
wrap(
client,
<ThreadShell
session={null}
title="nanobot"
onToggleSidebar={() => {}}
onNewChat={() => {}}
/>,
),
);
await act(async () => {});
expect(screen.getByText("Write code")).toBeInTheDocument();
expect(screen.queryByText("Design an app icon")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Toggle image generation mode" }));
expect(screen.getByText("Design an app icon")).toBeInTheDocument();
expect(screen.queryByText("Write code")).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");
+83
View File
@@ -134,6 +134,89 @@ describe("useNanobotStream", () => {
]);
});
it("suppresses redundant stream confirmation after assistant media", () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-img-result", EMPTY_MESSAGES), {
wrapper: wrap(fake.client),
});
act(() => {
fake.emit("chat-img-result", {
event: "message",
chat_id: "chat-img-result",
text: "image ready",
media_urls: [{ url: "/api/media/sig/image", name: "generated.png" }],
});
fake.emit("chat-img-result", {
event: "message",
chat_id: "chat-img-result",
text: "message()",
kind: "tool_hint",
});
fake.emit("chat-img-result", {
event: "delta",
chat_id: "chat-img-result",
text: "发送成功",
});
fake.emit("chat-img-result", {
event: "stream_end",
chat_id: "chat-img-result",
});
fake.emit("chat-img-result", {
event: "turn_end",
chat_id: "chat-img-result",
});
});
expect(result.current.messages).toHaveLength(1);
expect(result.current.messages[0].content).toBe("image ready");
expect(result.current.messages[0].media).toHaveLength(1);
});
it("passes image generation options to the websocket client", () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-img", EMPTY_MESSAGES), {
wrapper: wrap(fake.client),
});
act(() => {
result.current.send(
"draw a square icon",
undefined,
{ imageGeneration: { enabled: true, aspect_ratio: "1:1" } },
);
});
expect(fake.client.sendMessage).toHaveBeenCalledWith(
"chat-img",
"draw a square icon",
undefined,
{ imageGeneration: { enabled: true, aspect_ratio: "1:1" } },
);
});
it("stops the active turn without adding a user slash command bubble", () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-stop", EMPTY_MESSAGES), {
wrapper: wrap(fake.client),
});
act(() => {
result.current.send("long task");
});
expect(result.current.messages).toHaveLength(1);
expect(result.current.isStreaming).toBe(true);
act(() => {
result.current.stop();
});
expect(fake.client.sendMessage).toHaveBeenLastCalledWith("chat-stop", "/stop");
expect(result.current.isStreaming).toBe(false);
expect(result.current.messages).toHaveLength(1);
expect(result.current.messages[0].content).toBe("long task");
});
it("keeps assistant buttons on complete messages", () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-q", EMPTY_MESSAGES), {