From 47d83af0b6aa6cc3300847c4e3ff2b57e88bcd06 Mon Sep 17 00:00:00 2001 From: Xubin Ren <52506698+Re-bin@users.noreply.github.com> Date: Thu, 30 Jul 2026 18:33:03 +0800 Subject: [PATCH] feat(webui): add persistent Quick Chat --- webui/src/App.tsx | 73 ++++++++++++++++--- webui/src/components/Sidebar.tsx | 10 +++ webui/src/components/thread/ThreadShell.tsx | 22 ++++-- webui/src/i18n/locales/en/common.json | 4 ++ webui/src/i18n/locales/es/common.json | 4 ++ webui/src/i18n/locales/fr/common.json | 4 ++ webui/src/i18n/locales/id/common.json | 4 ++ webui/src/i18n/locales/ja/common.json | 4 ++ webui/src/i18n/locales/ko/common.json | 4 ++ webui/src/i18n/locales/pt-BR/common.json | 4 ++ webui/src/i18n/locales/vi/common.json | 4 ++ webui/src/i18n/locales/zh-CN/common.json | 4 ++ webui/src/i18n/locales/zh-TW/common.json | 4 ++ webui/src/lib/quick-chat.ts | 22 ++++++ webui/src/tests/app-layout.test.tsx | 80 +++++++++++++++++++++ webui/src/tests/quick-chat.test.ts | 37 ++++++++++ webui/src/tests/thread-shell.test.tsx | 68 ++++++++++++++++++ 17 files changed, 338 insertions(+), 14 deletions(-) create mode 100644 webui/src/lib/quick-chat.ts create mode 100644 webui/src/tests/quick-chat.test.ts diff --git a/webui/src/App.tsx b/webui/src/App.tsx index d2b7da990..0b209f347 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -37,6 +37,12 @@ import { import { displayTitle } from "@/lib/chat-groups"; import { deriveTitle } from "@/lib/format"; import { NanobotClient } from "@/lib/nanobot-client"; +import { + isQuickChatKey, + QUICK_CHAT_ID, + QUICK_CHAT_KEY, + quickChatSession, +} from "@/lib/quick-chat"; import { ClientProvider, useClient } from "@/providers/ClientProvider"; import type { BootstrapResponse, @@ -225,6 +231,9 @@ function readShellRoute(): ShellRoute { if (path === "/skills") { return { view: "skills", activeKey, settingsSection: "skills" }; } + if (path === "/quick-chat") { + return { view: "chat", activeKey: QUICK_CHAT_KEY, settingsSection: "overview" }; + } if (path.startsWith("/chat/")) { const encoded = path.slice("/chat/".length); try { @@ -241,6 +250,7 @@ function readShellRoute(): ShellRoute { function shellRouteHash(route: ShellRoute): string { if (route.view === "chat") { + if (isQuickChatKey(route.activeKey)) return "#/quick-chat"; return route.activeKey ? `#/chat/${encodeURIComponent(route.activeKey)}` : "#/new"; @@ -947,8 +957,16 @@ function Shell({ deleteChat, getSessionAutomations, } = useSessions(); + const regularSessions = useMemo( + () => sessions.filter((session) => !isQuickChatKey(session.key)), + [sessions], + ); + const quickSession = useMemo( + () => quickChatSession(sessions.find((session) => isQuickChatKey(session.key))), + [sessions], + ); const { state: sidebarState, update: updateSidebarState } = - useSidebarState(sessions, !loading); + useSidebarState(regularSessions, !loading); const initialRouteRef = useRef(null); if (!initialRouteRef.current) initialRouteRef.current = readShellRoute(); const [activeKey, setActiveKey] = useState( @@ -1114,8 +1132,10 @@ function Shell({ const activeSession = useMemo(() => { if (!activeKey) return null; + if (isQuickChatKey(activeKey)) return quickSession; return sessions.find((s) => s.key === activeKey) ?? null; - }, [sessions, activeKey]); + }, [sessions, activeKey, quickSession]); + const quickChatActive = isQuickChatKey(activeKey); const runningChatIdList = useMemo(() => Array.from(runningChatIds), [runningChatIds]); const updatedChatIdList = useMemo(() => Array.from(updatedChatIds), [updatedChatIds]); const activeChatId = activeSession?.chatId ?? null; @@ -1130,6 +1150,9 @@ function Shell({ }); }, [activeChatId]); const activeWorkspaceScope = useMemo(() => { + if (quickChatActive) { + return workspaces?.default_scope ?? null; + } if (activeChatId && workspaceOverrides[activeChatId]) { return workspaceOverrides[activeChatId]; } @@ -1141,6 +1164,7 @@ function Shell({ activeChatId, activeSession?.workspaceScope, draftWorkspaceScope, + quickChatActive, workspaceOverrides, workspaces?.default_scope, ]); @@ -1161,7 +1185,10 @@ function Shell({ useEffect(() => { if (loading) return; - const knownChatIds = new Set(sessions.map((session) => session.chatId)); + const knownChatIds = new Set([ + QUICK_CHAT_ID, + ...sessions.map((session) => session.chatId), + ]); setUpdatedChatIds((current) => { const next = new Set( Array.from(current).filter((chatId) => knownChatIds.has(chatId)), @@ -1176,6 +1203,7 @@ function Shell({ useEffect(() => { if (loading || !activeKey) return; + if (isQuickChatKey(activeKey)) return; if (sessions.some((session) => session.key === activeKey)) return; const currentRoute = readShellRoute(); navigate( @@ -1417,6 +1445,18 @@ function Shell({ setMobileSidebarOpen(false); }, [navigate]); + const onOpenQuickChat = useCallback(() => { + setDraftWorkspaceScope(null); + setWorkspaceError(null); + setSessionSearchOpen(false); + navigate({ + view: "chat", + activeKey: QUICK_CHAT_KEY, + settingsSection: "overview", + }); + setMobileSidebarOpen(false); + }, [navigate]); + const onNewChatInProject = useCallback( (projectPath: string, projectName: string) => { const base = workspaces?.default_scope ?? activeWorkspaceScope; @@ -1682,6 +1722,7 @@ function Shell({ setMobileSidebarOpen(false); const nextKey = (() => { if (!activeKey) return null; + if (isQuickChatKey(activeKey)) return activeKey; if (sessions.some((session) => session.key === activeKey)) return activeKey; return sessions[0]?.key ?? null; })(); @@ -1773,7 +1814,10 @@ function Shell({ }); }, [client, t]); - const onTurnEnd = useDeferredTitleRefresh(activeSession, refresh); + const onTurnEnd = useDeferredTitleRefresh( + quickChatActive ? null : activeSession, + refresh, + ); const onConfirmDelete = useCallback(async () => { if (!pendingDelete) return; @@ -1863,7 +1907,9 @@ function Shell({ }); }, []); - const headerTitle = activeSession + const headerTitle = quickChatActive + ? t("sidebar.quickChat") + : activeSession ? sidebarState.title_overrides[activeSession.key] || activeSession.title || deriveTitle(activeSession.preview, t("chat.newChat")) @@ -1900,9 +1946,11 @@ function Shell({ }, [activeSession, headerTitle, i18n.resolvedLanguage, t, view]); const sidebarProps = { - sessions, + sessions: regularSessions, activeKey, loading, + quickChatActive, + onOpenQuickChat, onNewChat, onSelect: onSelectChat, onRequestDelete, @@ -2065,7 +2113,7 @@ function Shell({ {view !== "chat" && ( diff --git a/webui/src/components/Sidebar.tsx b/webui/src/components/Sidebar.tsx index 95ae6f304..309257c48 100644 --- a/webui/src/components/Sidebar.tsx +++ b/webui/src/components/Sidebar.tsx @@ -3,6 +3,7 @@ import { Archive, Brain, CalendarClock, + MessageCircle, Menu, Search, Settings, @@ -24,6 +25,8 @@ interface SidebarProps { sessions: ChatSummary[]; activeKey: string | null; loading: boolean; + quickChatActive: boolean; + onOpenQuickChat: () => void; onNewChat: () => void; onSelect: (key: string) => void; onRequestDelete: (key: string, label: string) => void; @@ -139,6 +142,13 @@ export function Sidebar(props: SidebarProps) { collapsed && "flex w-14 flex-col items-center px-0", )} > + } + /> void; skills?: SkillSummary[]; + allowConversationReset?: boolean; + showSessionInfo?: boolean; + emptyStateGreeting?: string; } function toModelBadgeLabel(modelName: string | null): string | null { @@ -597,6 +600,9 @@ export function ThreadShell({ settingsSnapshot = null, onOpenModelSettings, skills = [], + allowConversationReset = true, + showSessionInfo = true, + emptyStateGreeting, }: ThreadShellProps) { const { t } = useTranslation(); const chatId = session?.chatId ?? null; @@ -622,6 +628,12 @@ export function ThreadShell({ const [fallbackModelName, setFallbackModelName] = useState(null); const [booting, setBooting] = useState(false); const [slashCommands, setSlashCommands] = useState([]); + const availableSlashCommands = useMemo( + () => allowConversationReset + ? slashCommands + : slashCommands.filter((command) => command.command !== "/new"), + [allowConversationReset, slashCommands], + ); const cliApps = useInstalledSettingItems({ getToken, eventName: CLI_APPS_CHANGED_EVENT, @@ -1374,7 +1386,7 @@ export function ThreadShell({ fallbackModelName={fallbackModelName} onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined} variant={showHeroComposer ? "hero" : "thread"} - slashCommands={slashCommands} + slashCommands={availableSlashCommands} cliApps={cliApps} mcpPresets={mcpPresets} skills={skills} @@ -1416,7 +1428,7 @@ export function ThreadShell({ fallbackModelName={fallbackModelName} onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined} variant="hero" - slashCommands={slashCommands} + slashCommands={availableSlashCommands} cliApps={cliApps} mcpPresets={mcpPresets} skills={skills} @@ -1442,10 +1454,10 @@ export function ThreadShell({ ) : (
- +
); - const sessionInfoAction = historyKey ? ( + const sessionInfoAction = historyKey && showSessionInfo ? ( ) : undefined; const promptNavigatorAction = historyKey ? ( @@ -1488,7 +1500,7 @@ export function ThreadShell({ showScrollToBottomButton={!!session} cliApps={cliApps} mcpPresets={mcpPresets} - slashCommands={slashCommands} + slashCommands={availableSlashCommands} forkBoundaryMessageCount={forkBoundaryMessageCount} hasMoreBefore={hasMoreBefore} loadingOlder={loadingOlder} diff --git a/webui/src/i18n/locales/en/common.json b/webui/src/i18n/locales/en/common.json index 04b69b848..9456ee36b 100644 --- a/webui/src/i18n/locales/en/common.json +++ b/webui/src/i18n/locales/en/common.json @@ -43,6 +43,7 @@ "sidebar": { "navigation": "Sidebar navigation", "collapse": "Collapse sidebar", + "quickChat": "Quick Chat", "newChat": "New topic", "searchAria": "Search", "searchPlaceholder": "Search", @@ -60,6 +61,9 @@ "title": "Skills" } }, + "quickChat": { + "greeting": "What's on your mind?" + }, "settings": { "backToChat": "Back to chat", "sidebar": { diff --git a/webui/src/i18n/locales/es/common.json b/webui/src/i18n/locales/es/common.json index 649e4a9b0..e171b7052 100644 --- a/webui/src/i18n/locales/es/common.json +++ b/webui/src/i18n/locales/es/common.json @@ -43,6 +43,7 @@ "sidebar": { "navigation": "Navegación de la barra lateral", "collapse": "Contraer barra lateral", + "quickChat": "Chat rápido", "newChat": "Nuevo tema", "searchAria": "Buscar", "searchPlaceholder": "Buscar", @@ -60,6 +61,9 @@ "title": "Habilidades" } }, + "quickChat": { + "greeting": "¿Qué tienes en mente?" + }, "settings": { "backToChat": "Volver al chat", "sidebar": { diff --git a/webui/src/i18n/locales/fr/common.json b/webui/src/i18n/locales/fr/common.json index 87741f901..cd5abb3d5 100644 --- a/webui/src/i18n/locales/fr/common.json +++ b/webui/src/i18n/locales/fr/common.json @@ -43,6 +43,7 @@ "sidebar": { "navigation": "Navigation de la barre latérale", "collapse": "Réduire la barre latérale", + "quickChat": "Discussion rapide", "newChat": "Nouveau sujet", "searchAria": "Rechercher", "searchPlaceholder": "Rechercher", @@ -60,6 +61,9 @@ "title": "Compétences" } }, + "quickChat": { + "greeting": "De quoi avez-vous envie de parler ?" + }, "settings": { "backToChat": "Retour au chat", "sidebar": { diff --git a/webui/src/i18n/locales/id/common.json b/webui/src/i18n/locales/id/common.json index 177f730e2..f95d54dda 100644 --- a/webui/src/i18n/locales/id/common.json +++ b/webui/src/i18n/locales/id/common.json @@ -43,6 +43,7 @@ "sidebar": { "navigation": "Navigasi bilah samping", "collapse": "Ciutkan sidebar", + "quickChat": "Obrolan cepat", "newChat": "Topik baru", "searchAria": "Cari", "searchPlaceholder": "Cari", @@ -60,6 +61,9 @@ "title": "Skill" } }, + "quickChat": { + "greeting": "Apa yang sedang kamu pikirkan?" + }, "settings": { "backToChat": "Kembali ke chat", "sidebar": { diff --git a/webui/src/i18n/locales/ja/common.json b/webui/src/i18n/locales/ja/common.json index 6e18a8834..836de45f9 100644 --- a/webui/src/i18n/locales/ja/common.json +++ b/webui/src/i18n/locales/ja/common.json @@ -43,6 +43,7 @@ "sidebar": { "navigation": "サイドバーのナビゲーション", "collapse": "サイドバーを閉じる", + "quickChat": "クイックチャット", "newChat": "新しいトピック", "searchAria": "検索", "searchPlaceholder": "検索", @@ -60,6 +61,9 @@ "title": "スキル" } }, + "quickChat": { + "greeting": "何について話しますか?" + }, "settings": { "backToChat": "チャットに戻る", "sidebar": { diff --git a/webui/src/i18n/locales/ko/common.json b/webui/src/i18n/locales/ko/common.json index 06b5f5e85..6737f8ddb 100644 --- a/webui/src/i18n/locales/ko/common.json +++ b/webui/src/i18n/locales/ko/common.json @@ -43,6 +43,7 @@ "sidebar": { "navigation": "사이드바 탐색", "collapse": "사이드바 접기", + "quickChat": "빠른 채팅", "newChat": "새 주제", "searchAria": "검색", "searchPlaceholder": "검색", @@ -60,6 +61,9 @@ "title": "스킬" } }, + "quickChat": { + "greeting": "무슨 이야기를 나눠볼까요?" + }, "settings": { "backToChat": "채팅으로 돌아가기", "sidebar": { diff --git a/webui/src/i18n/locales/pt-BR/common.json b/webui/src/i18n/locales/pt-BR/common.json index 0af8a54a3..5b7a09222 100644 --- a/webui/src/i18n/locales/pt-BR/common.json +++ b/webui/src/i18n/locales/pt-BR/common.json @@ -43,6 +43,7 @@ "sidebar": { "navigation": "Navegação da barra lateral", "collapse": "Recolher barra lateral", + "quickChat": "Chat rápido", "newChat": "Novo tópico", "searchAria": "Buscar", "searchPlaceholder": "Buscar", @@ -60,6 +61,9 @@ "title": "Skills" } }, + "quickChat": { + "greeting": "O que você está pensando?" + }, "settings": { "backToChat": "Voltar para a conversa", "sidebar": { diff --git a/webui/src/i18n/locales/vi/common.json b/webui/src/i18n/locales/vi/common.json index 5eb73f8d3..c7f4f735f 100644 --- a/webui/src/i18n/locales/vi/common.json +++ b/webui/src/i18n/locales/vi/common.json @@ -43,6 +43,7 @@ "sidebar": { "navigation": "Điều hướng thanh bên", "collapse": "Thu gọn thanh bên", + "quickChat": "Trò chuyện nhanh", "newChat": "Chủ đề mới", "searchAria": "Tìm kiếm", "searchPlaceholder": "Tìm kiếm", @@ -60,6 +61,9 @@ "title": "Kỹ năng" } }, + "quickChat": { + "greeting": "Bạn đang nghĩ gì?" + }, "settings": { "backToChat": "Quay lại chat", "sidebar": { diff --git a/webui/src/i18n/locales/zh-CN/common.json b/webui/src/i18n/locales/zh-CN/common.json index 48ff11e18..18108ab5c 100644 --- a/webui/src/i18n/locales/zh-CN/common.json +++ b/webui/src/i18n/locales/zh-CN/common.json @@ -43,6 +43,7 @@ "sidebar": { "navigation": "侧边栏导航", "collapse": "收起侧边栏", + "quickChat": "随便聊聊", "newChat": "新建话题", "searchAria": "搜索", "searchPlaceholder": "搜索", @@ -60,6 +61,9 @@ "title": "技能" } }, + "quickChat": { + "greeting": "想聊点什么?" + }, "settings": { "backToChat": "返回聊天", "sidebar": { diff --git a/webui/src/i18n/locales/zh-TW/common.json b/webui/src/i18n/locales/zh-TW/common.json index 4c3c689a7..b36a3e64f 100644 --- a/webui/src/i18n/locales/zh-TW/common.json +++ b/webui/src/i18n/locales/zh-TW/common.json @@ -43,6 +43,7 @@ "sidebar": { "navigation": "側邊欄導覽", "collapse": "收合側邊欄", + "quickChat": "輕鬆聊聊", "newChat": "新增話題", "searchAria": "搜尋", "searchPlaceholder": "搜尋", @@ -60,6 +61,9 @@ "title": "技能" } }, + "quickChat": { + "greeting": "想聊點什麼?" + }, "settings": { "backToChat": "返回聊天", "sidebar": { diff --git a/webui/src/lib/quick-chat.ts b/webui/src/lib/quick-chat.ts new file mode 100644 index 000000000..cdd522f81 --- /dev/null +++ b/webui/src/lib/quick-chat.ts @@ -0,0 +1,22 @@ +import type { ChatSummary } from "@/lib/types"; + +export const QUICK_CHAT_ID = "quick-chat"; +export const QUICK_CHAT_KEY = `websocket:${QUICK_CHAT_ID}`; + +export function isQuickChatKey(key: string | null): boolean { + return key === QUICK_CHAT_KEY; +} + +export function quickChatSession(persisted?: ChatSummary): ChatSummary { + return { + key: QUICK_CHAT_KEY, + channel: "websocket", + chatId: QUICK_CHAT_ID, + createdAt: persisted?.createdAt ?? null, + updatedAt: persisted?.updatedAt ?? null, + preview: persisted?.preview ?? "", + modelPreset: persisted?.modelPreset ?? null, + runStartedAt: persisted?.runStartedAt ?? null, + workspaceScope: persisted?.workspaceScope ?? null, + }; +} diff --git a/webui/src/tests/app-layout.test.tsx b/webui/src/tests/app-layout.test.tsx index 41f56e7c0..0782f8112 100644 --- a/webui/src/tests/app-layout.test.tsx +++ b/webui/src/tests/app-layout.test.tsx @@ -349,6 +349,86 @@ describe("App layout", () => { ).toBeTruthy(); }); + it("opens a single fixed Quick Chat without provisioning a new session", async () => { + render(); + + await waitFor(() => expect(connectSpy).toHaveBeenCalled()); + const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" }); + const quickChatButton = within(sidebar).getByRole("button", { + name: "Quick Chat", + }); + + fireEvent.click(quickChatButton); + + expect(window.location.hash).toBe("#/quick-chat"); + expect(quickChatButton).toHaveAttribute("aria-current", "page"); + await waitFor(() => + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining( + "/api/sessions/websocket%3Aquick-chat/webui-thread", + ), + expect.anything(), + ), + ); + expect(createChatSpy).not.toHaveBeenCalled(); + expect(document.title).toBe("Quick Chat · nanobot"); + expect(screen.getByText("What's on your mind?")).toBeInTheDocument(); + }); + + it("restores Quick Chat before it has a persisted session", async () => { + window.history.replaceState(null, "", "/#/quick-chat"); + + render(); + + await waitFor(() => expect(connectSpy).toHaveBeenCalled()); + expect(window.location.hash).toBe("#/quick-chat"); + await waitFor(() => + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining( + "/api/sessions/websocket%3Aquick-chat/webui-thread", + ), + expect.anything(), + ), + ); + expect( + within(screen.getByRole("navigation", { name: "Sidebar navigation" })) + .getByRole("button", { name: "Quick Chat" }), + ).toHaveAttribute("aria-current", "page"); + }); + + it("keeps persisted Quick Chat out of the topic list and topic search", async () => { + mockSessions = [ + { + key: "websocket:quick-chat", + channel: "websocket", + chatId: "quick-chat", + createdAt: "2026-07-30T08:00:00Z", + updatedAt: "2026-07-30T08:05:00Z", + preview: "A private casual message", + }, + { + key: "websocket:project-chat", + channel: "websocket", + chatId: "project-chat", + createdAt: "2026-07-30T08:00:00Z", + updatedAt: "2026-07-30T08:05:00Z", + preview: "Project roadmap", + }, + ]; + + render(); + + await waitFor(() => expect(connectSpy).toHaveBeenCalled()); + const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" }); + expect(within(sidebar).getByText("Project roadmap")).toBeInTheDocument(); + expect(within(sidebar).queryByText("A private casual message")).not.toBeInTheDocument(); + + fireEvent.click(within(sidebar).getByRole("button", { name: "Search" })); + const dialog = await screen.findByRole("dialog", { name: "Search" }); + expect(within(dialog).getByText("Project roadmap")).toBeInTheDocument(); + expect(within(dialog).queryByText("A private casual message")).not.toBeInTheDocument(); + }); + it("restores the Settings route after a restart fallback hash", async () => { localStorage.setItem("nanobot-webui.restartStartedAt", String(Date.now())); localStorage.setItem("nanobot-webui.restartRoute", "#/settings?section=channels"); diff --git a/webui/src/tests/quick-chat.test.ts b/webui/src/tests/quick-chat.test.ts new file mode 100644 index 000000000..56a671e43 --- /dev/null +++ b/webui/src/tests/quick-chat.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; + +import { + isQuickChatKey, + QUICK_CHAT_ID, + QUICK_CHAT_KEY, + quickChatSession, +} from "@/lib/quick-chat"; + +describe("Quick Chat identity", () => { + it("uses one stable websocket session", () => { + expect(QUICK_CHAT_ID).toBe("quick-chat"); + expect(QUICK_CHAT_KEY).toBe("websocket:quick-chat"); + expect(isQuickChatKey(QUICK_CHAT_KEY)).toBe(true); + expect(isQuickChatKey("websocket:another-chat")).toBe(false); + }); + + it("keeps persisted metadata behind the fixed identity", () => { + expect(quickChatSession({ + key: "websocket:quick-chat", + channel: "websocket", + chatId: "quick-chat", + createdAt: "2026-07-30T08:00:00Z", + updatedAt: "2026-07-30T08:05:00Z", + preview: "hello", + modelPreset: "fast", + })).toMatchObject({ + key: QUICK_CHAT_KEY, + channel: "websocket", + chatId: QUICK_CHAT_ID, + createdAt: "2026-07-30T08:00:00Z", + updatedAt: "2026-07-30T08:05:00Z", + preview: "hello", + modelPreset: "fast", + }); + }); +}); diff --git a/webui/src/tests/thread-shell.test.tsx b/webui/src/tests/thread-shell.test.tsx index 3961d3a95..4dd27f869 100644 --- a/webui/src/tests/thread-shell.test.tsx +++ b/webui/src/tests/thread-shell.test.tsx @@ -3369,6 +3369,74 @@ describe("ThreadShell", () => { expect(screen.getByRole("option", { name: /\/history/i })).toBeInTheDocument(); }); + it("removes session-management affordances from a fixed conversation", 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: "/new", + title: "New chat", + description: "Reset this chat and start a fresh conversation.", + icon: "square-pen", + lifecycle: "finalize_active_turn", + accepts_args: false, + }, + { + command: "/history", + title: "Show conversation history", + description: "Print the last N persisted messages.", + icon: "history", + arg_hint: "[n]", + lifecycle: "side_channel", + accepts_args: true, + }, + ], + }); + } + return { + ok: false, + status: 404, + json: async () => ({}), + }; + }), + ); + + render( + wrap( + client, + {}} + allowConversationReset={false} + showSessionInfo={false} + />, + ), + ); + + await waitFor(() => expect(fetch).toHaveBeenCalledWith( + "/api/commands", + expect.objectContaining({ + headers: { Authorization: "Bearer tok" }, + }), + )); + + fireEvent.change(screen.getByLabelText("Message input"), { + target: { value: "/" }, + }); + + expect(screen.getByRole("option", { name: /\/history/i })).toBeInTheDocument(); + expect(screen.queryByRole("option", { name: /\/new/i })).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Session details" }), + ).not.toBeInTheDocument(); + }); + it("does not bring back welcome cards when image mode is enabled", async () => { const client = makeClient(); const settings = modelSettings("deepseek-v4-pro", "deepseek");