mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-04 08:28:36 +00:00
feat(webui): add persistent Quick Chat
This commit is contained in:
parent
6a1a45d07a
commit
47d83af0b6
@ -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<ShellRoute | null>(null);
|
||||
if (!initialRouteRef.current) initialRouteRef.current = readShellRoute();
|
||||
const [activeKey, setActiveKey] = useState<string | null>(
|
||||
@ -1114,8 +1132,10 @@ function Shell({
|
||||
|
||||
const activeSession = useMemo<ChatSummary | null>(() => {
|
||||
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<WorkspaceScopePayload | null>(() => {
|
||||
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({
|
||||
<SessionSearchDialog
|
||||
open
|
||||
onOpenChange={setSessionSearchOpen}
|
||||
sessions={sessions}
|
||||
sessions={regularSessions}
|
||||
activeKey={activeKey}
|
||||
loading={loading}
|
||||
titleOverrides={sidebarState.title_overrides}
|
||||
@ -2090,7 +2138,7 @@ function Shell({
|
||||
onToggleSidebar={toggleSidebar}
|
||||
onNewChat={onNewChat}
|
||||
onCreateChat={onCreateChat}
|
||||
onForkChat={onForkChat}
|
||||
onForkChat={quickChatActive ? undefined : onForkChat}
|
||||
onTurnEnd={onTurnEnd}
|
||||
theme={theme}
|
||||
onToggleTheme={toggle}
|
||||
@ -2099,13 +2147,20 @@ function Shell({
|
||||
hideHeader={false}
|
||||
workspaceScope={activeWorkspaceScope}
|
||||
workspaceDefaultScope={workspaces?.default_scope ?? null}
|
||||
workspaceControls={workspaces?.controls ?? null}
|
||||
workspaceControls={
|
||||
quickChatActive ? null : (workspaces?.controls ?? null)
|
||||
}
|
||||
workspaceScopeDisabled={activeChatRunning}
|
||||
workspaceError={workspaceError}
|
||||
onWorkspaceScopeChange={applyWorkspaceScope}
|
||||
settingsSnapshot={settingsSnapshot}
|
||||
onOpenModelSettings={onOpenModelSettings}
|
||||
skills={skills}
|
||||
allowConversationReset={!quickChatActive}
|
||||
showSessionInfo={!quickChatActive}
|
||||
emptyStateGreeting={
|
||||
quickChatActive ? t("quickChat.greeting") : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{view !== "chat" && (
|
||||
|
||||
@ -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",
|
||||
)}
|
||||
>
|
||||
<SidebarActionButton
|
||||
collapsed={collapsed}
|
||||
label={t("sidebar.quickChat")}
|
||||
onClick={props.onOpenQuickChat}
|
||||
active={props.quickChatActive}
|
||||
icon={<MessageCircle className="h-4 w-4" />}
|
||||
/>
|
||||
<SidebarActionButton
|
||||
collapsed={collapsed}
|
||||
label={t("sidebar.newChat")}
|
||||
|
||||
@ -315,6 +315,9 @@ interface ThreadShellProps {
|
||||
settingsSnapshot?: SettingsPayload | null;
|
||||
onOpenModelSettings?: () => 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<string | null>(null);
|
||||
const [booting, setBooting] = useState(false);
|
||||
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
|
||||
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({
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex w-full flex-col items-center text-center animate-in fade-in-0 slide-in-from-bottom-2 duration-500">
|
||||
<HeroGreeting text={t(heroGreetingKey)} />
|
||||
<HeroGreeting text={emptyStateGreeting ?? t(heroGreetingKey)} />
|
||||
</div>
|
||||
);
|
||||
const sessionInfoAction = historyKey ? (
|
||||
const sessionInfoAction = historyKey && showSessionInfo ? (
|
||||
<SessionInfoPopover sessionKey={historyKey} token={token} title={title} />
|
||||
) : 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}
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -43,6 +43,7 @@
|
||||
"sidebar": {
|
||||
"navigation": "サイドバーのナビゲーション",
|
||||
"collapse": "サイドバーを閉じる",
|
||||
"quickChat": "クイックチャット",
|
||||
"newChat": "新しいトピック",
|
||||
"searchAria": "検索",
|
||||
"searchPlaceholder": "検索",
|
||||
@ -60,6 +61,9 @@
|
||||
"title": "スキル"
|
||||
}
|
||||
},
|
||||
"quickChat": {
|
||||
"greeting": "何について話しますか?"
|
||||
},
|
||||
"settings": {
|
||||
"backToChat": "チャットに戻る",
|
||||
"sidebar": {
|
||||
|
||||
@ -43,6 +43,7 @@
|
||||
"sidebar": {
|
||||
"navigation": "사이드바 탐색",
|
||||
"collapse": "사이드바 접기",
|
||||
"quickChat": "빠른 채팅",
|
||||
"newChat": "새 주제",
|
||||
"searchAria": "검색",
|
||||
"searchPlaceholder": "검색",
|
||||
@ -60,6 +61,9 @@
|
||||
"title": "스킬"
|
||||
}
|
||||
},
|
||||
"quickChat": {
|
||||
"greeting": "무슨 이야기를 나눠볼까요?"
|
||||
},
|
||||
"settings": {
|
||||
"backToChat": "채팅으로 돌아가기",
|
||||
"sidebar": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -43,6 +43,7 @@
|
||||
"sidebar": {
|
||||
"navigation": "侧边栏导航",
|
||||
"collapse": "收起侧边栏",
|
||||
"quickChat": "随便聊聊",
|
||||
"newChat": "新建话题",
|
||||
"searchAria": "搜索",
|
||||
"searchPlaceholder": "搜索",
|
||||
@ -60,6 +61,9 @@
|
||||
"title": "技能"
|
||||
}
|
||||
},
|
||||
"quickChat": {
|
||||
"greeting": "想聊点什么?"
|
||||
},
|
||||
"settings": {
|
||||
"backToChat": "返回聊天",
|
||||
"sidebar": {
|
||||
|
||||
@ -43,6 +43,7 @@
|
||||
"sidebar": {
|
||||
"navigation": "側邊欄導覽",
|
||||
"collapse": "收合側邊欄",
|
||||
"quickChat": "輕鬆聊聊",
|
||||
"newChat": "新增話題",
|
||||
"searchAria": "搜尋",
|
||||
"searchPlaceholder": "搜尋",
|
||||
@ -60,6 +61,9 @@
|
||||
"title": "技能"
|
||||
}
|
||||
},
|
||||
"quickChat": {
|
||||
"greeting": "想聊點什麼?"
|
||||
},
|
||||
"settings": {
|
||||
"backToChat": "返回聊天",
|
||||
"sidebar": {
|
||||
|
||||
22
webui/src/lib/quick-chat.ts
Normal file
22
webui/src/lib/quick-chat.ts
Normal file
@ -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,
|
||||
};
|
||||
}
|
||||
@ -349,6 +349,86 @@ describe("App layout", () => {
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("opens a single fixed Quick Chat without provisioning a new session", async () => {
|
||||
render(<App />);
|
||||
|
||||
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(<App />);
|
||||
|
||||
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(<App />);
|
||||
|
||||
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");
|
||||
|
||||
37
webui/src/tests/quick-chat.test.ts
Normal file
37
webui/src/tests/quick-chat.test.ts
Normal file
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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,
|
||||
<ThreadShell
|
||||
session={session("quick-chat")}
|
||||
title="Quick Chat"
|
||||
onToggleSidebar={() => {}}
|
||||
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");
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user