Compare commits

...
Author SHA1 Message Date
Xubin Ren 3d14bcaf72 refactor(webui): reuse sidebar selection highlight 2026-07-31 00:59:35 +08:00
Xubin Ren 47d83af0b6 feat(webui): add persistent Quick Chat 2026-07-31 00:53:10 +08:00
21 changed files with 570 additions and 124 deletions
+66 -10
View File
@@ -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,12 @@ function Shell({
}, [activeSession, headerTitle, i18n.resolvedLanguage, t, view]);
const sidebarProps = {
sessions,
activeKey,
sessions: regularSessions,
activeKey: view === "chat" ? activeKey : null,
loading,
quickChatActive: view === "chat" && quickChatActive,
newChatActive: view === "chat" && activeKey === null,
onOpenQuickChat,
onNewChat,
onSelect: onSelectChat,
onRequestDelete,
@@ -2065,7 +2114,7 @@ function Shell({
<SessionSearchDialog
open
onOpenChange={setSessionSearchOpen}
sessions={sessions}
sessions={regularSessions}
activeKey={activeKey}
loading={loading}
titleOverrides={sidebarState.title_overrides}
@@ -2090,7 +2139,7 @@ function Shell({
onToggleSidebar={toggleSidebar}
onNewChat={onNewChat}
onCreateChat={onCreateChat}
onForkChat={onForkChat}
onForkChat={quickChatActive ? undefined : onForkChat}
onTurnEnd={onTurnEnd}
theme={theme}
onToggleTheme={toggle}
@@ -2099,13 +2148,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" && (
+12 -85
View File
@@ -1,7 +1,6 @@
import {
memo,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
@@ -25,6 +24,10 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
SIDEBAR_SELECTION_ITEM_CLASS,
SidebarSelectionHighlight,
} from "@/components/SidebarSelectionHighlight";
import { deriveTitle, relativeTime, visibleSessionPreview } from "@/lib/format";
import {
COLLAPSED_CHATS_VISIBLE_COUNT,
@@ -106,9 +109,6 @@ export const ChatList = memo(function ChatList({
const [visibleLimit, setVisibleLimit] = useState(INITIAL_VISIBLE_SESSIONS);
const listContentRef = useRef<HTMLDivElement>(null);
const activeRowRef = useRef<HTMLDivElement>(null);
const activeHighlightRef = useRef<HTMLDivElement>(null);
const activeHighlightSurfaceRef = useRef<HTMLDivElement>(null);
const highlightVisibleRef = useRef(false);
const labels = useMemo<ChatGroupLabels>(() => ({
pinned: t("chat.groups.pinned"),
all: t("chat.groups.all"),
@@ -163,74 +163,6 @@ export const ChatList = memo(function ChatList({
setVisibleLimit(INITIAL_VISIBLE_SESSIONS);
}, [showArchived, sort]);
useLayoutEffect(() => {
let resetTransitionFrame: number | null = null;
const updateHighlight = () => {
const content = listContentRef.current;
const row = activeRowRef.current;
const highlight = activeHighlightRef.current;
const surface = activeHighlightSurfaceRef.current;
if (!highlight || !surface) return;
if (!content || !row) {
surface.style.opacity = "0";
surface.style.transform = "scale(0.97)";
highlightVisibleRef.current = false;
return;
}
const shouldFloatIn = !highlightVisibleRef.current;
if (shouldFloatIn) {
highlight.style.transitionProperty = "none";
}
const contentRect = content.getBoundingClientRect();
const rowRect = row.getBoundingClientRect();
highlight.style.width = `${rowRect.width}px`;
highlight.style.height = `${rowRect.height}px`;
highlight.style.transform = `translate3d(${rowRect.left - contentRect.left}px, ${
rowRect.top - contentRect.top
}px, 0)`;
if (shouldFloatIn) {
void highlight.offsetWidth;
}
surface.style.opacity = "1";
surface.style.transform = "scale(1)";
highlightVisibleRef.current = true;
if (shouldFloatIn) {
resetTransitionFrame = window.requestAnimationFrame(() => {
highlight.style.removeProperty("transition-property");
resetTransitionFrame = null;
});
}
};
updateHighlight();
const resizeObserver =
typeof ResizeObserver === "undefined"
? null
: new ResizeObserver(updateHighlight);
if (resizeObserver) {
if (listContentRef.current) resizeObserver.observe(listContentRef.current);
if (activeRowRef.current) resizeObserver.observe(activeRowRef.current);
}
window.addEventListener("resize", updateHighlight);
return () => {
if (resetTransitionFrame !== null) {
window.cancelAnimationFrame(resetTransitionFrame);
}
activeHighlightRef.current?.style.removeProperty("transition-property");
resizeObserver?.disconnect();
window.removeEventListener("resize", updateHighlight);
};
}, [activeKey, density, limitedGroups, showPreviews, showTimestamps]);
if (loading && sessions.length === 0) {
return (
<div className="px-3 py-6 text-[12px] text-muted-foreground">
@@ -333,7 +265,8 @@ export const ChatList = memo(function ChatList({
ref={active ? activeRowRef : undefined}
data-chat-row={s.key}
className={cn(
"group flex min-w-0 max-w-full items-center gap-2 rounded-xl px-2 text-[13px] transition-colors",
"group flex min-w-0 max-w-full items-center gap-2 rounded-xl px-2 text-[13px]",
SIDEBAR_SELECTION_ITEM_CLASS,
compact ? "min-h-7" : "min-h-8",
active
? "text-sidebar-accent-foreground"
@@ -475,18 +408,12 @@ export const ChatList = memo(function ChatList({
</button>
</div>
) : null}
<div
ref={activeHighlightRef}
data-testid="active-chat-highlight"
aria-hidden="true"
className="pointer-events-none absolute left-0 top-0 z-0 !mt-0 transition-[transform,width,height] duration-300 ease-out will-change-transform motion-reduce:transition-none"
>
<div
ref={activeHighlightSurfaceRef}
data-testid="active-chat-highlight-surface"
className="h-full w-full scale-[0.97] rounded-xl bg-sidebar-foreground/[0.055] opacity-0 transition-[opacity,transform] duration-200 ease-out motion-reduce:transition-none dark:bg-white/[0.07]"
/>
</div>
<SidebarSelectionHighlight
containerRef={listContentRef}
targetRef={activeRowRef}
activeId={activeKey}
scope="sessions"
/>
</div>
</div>
);
+54 -7
View File
@@ -1,8 +1,14 @@
import { useState, type ReactNode } from "react";
import {
type ReactNode,
type RefObject,
useRef,
useState,
} from "react";
import {
Archive,
Brain,
CalendarClock,
MessageCircle,
Menu,
Search,
Settings,
@@ -13,6 +19,10 @@ import { useTranslation } from "react-i18next";
import { ChatList } from "@/components/ChatList";
import { ConnectionBadge } from "@/components/ConnectionBadge";
import {
SIDEBAR_SELECTION_ACTION_ITEM_CLASS,
SidebarSelectionHighlight,
} from "@/components/SidebarSelectionHighlight";
import { Button } from "@/components/ui/button";
import type {
ChatSummary,
@@ -24,6 +34,9 @@ interface SidebarProps {
sessions: ChatSummary[];
activeKey: string | null;
loading: boolean;
quickChatActive: boolean;
newChatActive: boolean;
onOpenQuickChat: () => void;
onNewChat: () => void;
onSelect: (key: string) => void;
onRequestDelete: (key: string, label: string) => void;
@@ -82,6 +95,15 @@ export function Sidebar(props: SidebarProps) {
const collapsed = Boolean(props.collapsed);
const toggleLabel = t("thread.header.toggleSidebar");
const newChatShortcut = newChatShortcutLabel();
const actionListRef = useRef<HTMLDivElement>(null);
const activeActionRef = useRef<HTMLButtonElement>(null);
const activeActionId = props.quickChatActive
? "quick-chat"
: props.newChatActive
? "new-chat"
: props.activeUtility
? `utility:${props.activeUtility}`
: null;
return (
<nav
@@ -134,15 +156,26 @@ export function Sidebar(props: SidebarProps) {
</div>
<div
ref={actionListRef}
className={cn(
"space-y-1.5 px-2 pb-2",
"relative space-y-1.5 px-2 pb-2",
collapsed && "flex w-14 flex-col items-center px-0",
)}
>
<SidebarActionButton
collapsed={collapsed}
label={t("sidebar.quickChat")}
onClick={props.onOpenQuickChat}
active={props.quickChatActive}
selectionRef={activeActionRef}
icon={<MessageCircle className="h-4 w-4" />}
/>
<SidebarActionButton
collapsed={collapsed}
label={t("sidebar.newChat")}
onClick={props.onNewChat}
active={props.newChatActive}
selectionRef={activeActionRef}
icon={<SquarePen className="h-4 w-4" />}
shortcut={newChatShortcut}
ariaKeyShortcuts="Meta+Shift+O Control+Shift+O"
@@ -159,6 +192,7 @@ export function Sidebar(props: SidebarProps) {
onClick={props.onOpenApps}
onIntent={props.onSettingsIntent}
active={props.activeUtility === "apps"}
selectionRef={activeActionRef}
icon={<Blocks className="h-4 w-4" />}
/>
<SidebarActionButton
@@ -167,6 +201,7 @@ export function Sidebar(props: SidebarProps) {
onClick={props.onOpenSkills}
onIntent={props.onSettingsIntent}
active={props.activeUtility === "skills"}
selectionRef={activeActionRef}
icon={<Brain className="h-4 w-4" />}
/>
<SidebarActionButton
@@ -175,6 +210,7 @@ export function Sidebar(props: SidebarProps) {
onClick={props.onOpenAutomations}
onIntent={props.onSettingsIntent}
active={props.activeUtility === "automations"}
selectionRef={activeActionRef}
icon={<CalendarClock className="h-4 w-4" />}
/>
{props.archivedCount ? (
@@ -185,6 +221,12 @@ export function Sidebar(props: SidebarProps) {
icon={<Archive className="h-4 w-4" />}
/>
) : null}
<SidebarSelectionHighlight
containerRef={actionListRef}
targetRef={activeActionRef}
activeId={activeActionId}
scope="actions"
/>
</div>
<div
className={cn(
@@ -255,6 +297,7 @@ function SidebarActionButton({
shortcut,
ariaKeyShortcuts,
onIntent,
selectionRef,
}: {
collapsed: boolean;
label: string;
@@ -265,13 +308,15 @@ function SidebarActionButton({
shortcut?: string;
ariaKeyShortcuts?: string;
onIntent?: () => void;
selectionRef?: RefObject<HTMLButtonElement>;
}) {
const title = shortcut ? `${label} (${shortcut})` : collapsed ? label : undefined;
return (
<Button
ref={active ? selectionRef : undefined}
type="button"
variant="ghost"
variant={null}
aria-label={label}
aria-current={active ? "page" : undefined}
aria-keyshortcuts={ariaKeyShortcuts}
@@ -280,12 +325,14 @@ function SidebarActionButton({
onFocus={onIntent}
onPointerEnter={onIntent}
className={cn(
"touch-target group h-8 min-w-0 gap-2 overflow-hidden rounded-full font-medium text-sidebar-foreground/85 hover:bg-sidebar-accent/75 hover:text-sidebar-foreground",
"transition-[width,padding,border-radius,color,background-color] duration-300 ease-out",
"touch-target group h-8 min-w-0 gap-2 overflow-hidden rounded-xl font-medium",
SIDEBAR_SELECTION_ACTION_ITEM_CLASS,
collapsed
? "w-9 justify-center gap-0 rounded-xl px-0"
? "w-9 justify-center gap-0 px-0"
: "w-full justify-start gap-2 px-3 text-[12.5px]",
active && "bg-sidebar-accent text-sidebar-foreground shadow-[inset_0_0_0_1px_hsl(var(--sidebar-border)/0.55)]",
active
? "text-sidebar-accent-foreground"
: "text-sidebar-foreground/85 hover:bg-sidebar-foreground/[0.035] hover:text-sidebar-foreground dark:hover:bg-white/[0.05]",
className,
)}
>
@@ -0,0 +1,90 @@
import {
type RefObject,
useLayoutEffect,
useRef,
} from "react";
interface SidebarSelectionHighlightProps {
containerRef: RefObject<HTMLElement>;
targetRef: RefObject<HTMLElement>;
activeId: string | null;
scope: string;
}
export const SIDEBAR_SELECTION_ITEM_CLASS =
"relative z-[1] transition-[color] duration-150 ease-out motion-reduce:transition-none";
export const SIDEBAR_SELECTION_ACTION_ITEM_CLASS =
"relative z-[1] transition-[width,padding,color] [transition-duration:300ms,300ms,150ms] ease-out motion-reduce:transition-none";
export function SidebarSelectionHighlight({
containerRef,
targetRef,
activeId,
scope,
}: SidebarSelectionHighlightProps) {
const highlightRef = useRef<HTMLDivElement>(null);
const positionedRef = useRef(false);
useLayoutEffect(() => {
const highlight = highlightRef.current;
const container = containerRef.current;
const target = targetRef.current;
let restoreTransitionFrame: number | null = null;
const position = () => {
if (!highlight) return;
if (!activeId || !container || !target) {
highlight.style.opacity = "0";
positionedRef.current = false;
return;
}
const firstPosition = !positionedRef.current;
if (firstPosition) highlight.style.transitionProperty = "none";
const containerRect = container.getBoundingClientRect();
const targetRect = target.getBoundingClientRect();
highlight.style.width = `${targetRect.width}px`;
highlight.style.height = `${targetRect.height}px`;
highlight.style.transform = `translate3d(${targetRect.left - containerRect.left}px, ${
targetRect.top - containerRect.top
}px, 0)`;
highlight.style.opacity = "1";
positionedRef.current = true;
if (firstPosition) {
restoreTransitionFrame = window.requestAnimationFrame(() => {
highlight.style.removeProperty("transition-property");
restoreTransitionFrame = null;
});
}
};
position();
const resizeObserver =
typeof ResizeObserver === "undefined" ? null : new ResizeObserver(position);
if (container) resizeObserver?.observe(container);
if (target) resizeObserver?.observe(target);
window.addEventListener("resize", position);
return () => {
if (restoreTransitionFrame !== null) {
window.cancelAnimationFrame(restoreTransitionFrame);
}
highlight?.style.removeProperty("transition-property");
resizeObserver?.disconnect();
window.removeEventListener("resize", position);
};
});
return (
<div
ref={highlightRef}
data-testid={`${scope}-selection-highlight`}
data-active-id={activeId ?? undefined}
aria-hidden="true"
className="pointer-events-none absolute left-0 top-0 z-0 !mt-0 rounded-xl bg-sidebar-foreground/[0.055] opacity-0 transition-[transform,width,height] duration-300 ease-out will-change-transform motion-reduce:transition-none dark:bg-white/[0.07]"
/>
);
}
+17 -3
View File
@@ -65,6 +65,10 @@ import { useTranslation } from "react-i18next";
import { channelUiPresentation } from "@/channel-plugins/registry";
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
import {
SIDEBAR_SELECTION_ITEM_CLASS,
SidebarSelectionHighlight,
} from "@/components/SidebarSelectionHighlight";
import { SkillsCatalogSettings } from "@/components/settings/SkillsCatalogSettings";
import { TokenUsageHeatmap } from "@/components/settings/TokenUsageHeatmap";
import { ToggleButton } from "@/components/settings/ToggleButton";
@@ -2497,6 +2501,8 @@ function SettingsSidebar({
hostChromeInset?: boolean;
}) {
const { t } = useTranslation();
const desktopNavRef = useRef<HTMLDivElement>(null);
const activeNavItemRef = useRef<HTMLButtonElement>(null);
const activeItem = SETTINGS_NAV_ITEMS.find((item) => item.key === activeSection)
?? SETTINGS_NAV_ITEMS[0];
const ActiveIcon = activeItem.icon;
@@ -2569,19 +2575,21 @@ function SettingsSidebar({
</DropdownMenuContent>
</DropdownMenu>
<div className="hidden space-y-1 lg:block">
<div ref={desktopNavRef} className="relative hidden space-y-1 lg:block">
{SETTINGS_NAV_ITEMS.map(({ key, icon: Icon, fallback }) => {
const active = key === activeSection;
return (
<button
ref={active ? activeNavItemRef : undefined}
key={key}
type="button"
aria-current={active ? "page" : undefined}
onClick={() => onSelectSection(key)}
className={cn(
"touch-target flex h-9 w-full items-center gap-2 rounded-[10px] px-2.5 text-left text-[13px] font-medium transition-colors",
"touch-target flex h-9 w-full items-center gap-2 rounded-xl px-2.5 text-left text-[13px] font-medium",
SIDEBAR_SELECTION_ITEM_CLASS,
active
? "bg-sidebar-accent text-foreground"
? "text-sidebar-accent-foreground"
: "text-muted-foreground/78 hover:bg-muted/45 hover:text-foreground",
)}
>
@@ -2592,6 +2600,12 @@ function SettingsSidebar({
</button>
);
})}
<SidebarSelectionHighlight
containerRef={desktopNavRef}
targetRef={activeNavItemRef}
activeId={activeSection}
scope="settings"
/>
</div>
</nav>
+17 -5
View File
@@ -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}
+4
View File
@@ -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": {
+4
View File
@@ -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": {
+4
View File
@@ -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": {
+4
View File
@@ -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": {
+4
View File
@@ -43,6 +43,7 @@
"sidebar": {
"navigation": "サイドバーのナビゲーション",
"collapse": "サイドバーを閉じる",
"quickChat": "クイックチャット",
"newChat": "新しいトピック",
"searchAria": "検索",
"searchPlaceholder": "検索",
@@ -60,6 +61,9 @@
"title": "スキル"
}
},
"quickChat": {
"greeting": "何について話しますか?"
},
"settings": {
"backToChat": "チャットに戻る",
"sidebar": {
+4
View File
@@ -43,6 +43,7 @@
"sidebar": {
"navigation": "사이드바 탐색",
"collapse": "사이드바 접기",
"quickChat": "빠른 채팅",
"newChat": "새 주제",
"searchAria": "검색",
"searchPlaceholder": "검색",
@@ -60,6 +61,9 @@
"title": "스킬"
}
},
"quickChat": {
"greeting": "무슨 이야기를 나눠볼까요?"
},
"settings": {
"backToChat": "채팅으로 돌아가기",
"sidebar": {
+4
View File
@@ -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": {
+4
View File
@@ -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": {
+4
View File
@@ -43,6 +43,7 @@
"sidebar": {
"navigation": "侧边栏导航",
"collapse": "收起侧边栏",
"quickChat": "随便聊聊",
"newChat": "新建话题",
"searchAria": "搜索",
"searchPlaceholder": "搜索",
@@ -60,6 +61,9 @@
"title": "技能"
}
},
"quickChat": {
"greeting": "想聊点什么?"
},
"settings": {
"backToChat": "返回聊天",
"sidebar": {
+4
View File
@@ -43,6 +43,7 @@
"sidebar": {
"navigation": "側邊欄導覽",
"collapse": "收合側邊欄",
"quickChat": "輕鬆聊聊",
"newChat": "新增話題",
"searchAria": "搜尋",
"searchPlaceholder": "搜尋",
@@ -60,6 +61,9 @@
"title": "技能"
}
},
"quickChat": {
"greeting": "想聊點什麼?"
},
"settings": {
"backToChat": "返回聊天",
"sidebar": {
+22
View 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,
};
}
+137 -2
View File
@@ -349,6 +349,107 @@ 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",
});
const newTopicButton = within(sidebar).getByRole("button", {
name: "New topic",
});
const actionHighlight = within(sidebar).getByTestId(
"actions-selection-highlight",
);
fireEvent.click(quickChatButton);
expect(window.location.hash).toBe("#/quick-chat");
expect(quickChatButton).toHaveAttribute("aria-current", "page");
expect(newTopicButton).not.toHaveAttribute("aria-current");
expect(quickChatButton).not.toHaveClass("bg-sidebar-accent");
expect(quickChatButton).toHaveClass("transition-[width,padding,color]");
expect(actionHighlight).toHaveAttribute("data-active-id", "quick-chat");
expect(
within(sidebar).queryByTestId("actions-selection-highlight-surface"),
).not.toBeInTheDocument();
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();
fireEvent.click(newTopicButton);
expect(window.location.hash).toBe("#/new");
expect(newTopicButton).toHaveAttribute("aria-current", "page");
expect(quickChatButton).not.toHaveAttribute("aria-current");
expect(actionHighlight).toHaveAttribute("data-active-id", "new-chat");
expect(within(sidebar).queryAllByRole("button", { current: "page" })).toHaveLength(1);
});
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");
@@ -2128,16 +2229,41 @@ describe("App layout", () => {
expect(window.location.hash).toBe("#/settings");
const settingsNav = screen.getByRole("navigation", { name: "Settings sections" });
fireEvent.click(within(settingsNav).getByRole("button", { name: "Models" }));
const overviewButton = within(settingsNav).getByRole("button", {
name: "Overview",
exact: true,
});
const modelsButton = within(settingsNav).getByRole("button", {
name: "Models",
exact: true,
});
const settingsHighlight = within(settingsNav).getByTestId(
"settings-selection-highlight",
);
expect(overviewButton).toHaveAttribute("aria-current", "page");
expect(overviewButton).not.toHaveClass("bg-sidebar-accent");
expect(overviewButton).toHaveClass("transition-[color]");
expect(settingsHighlight).toHaveAttribute("data-active-id", "overview");
fireEvent.click(modelsButton);
expect(await screen.findByText("Model presets")).toBeInTheDocument();
expect(screen.queryByRole("heading", { name: "Models" })).not.toBeInTheDocument();
expect(window.location.hash).toBe("#/settings?section=models");
expect(modelsButton).toHaveAttribute("aria-current", "page");
expect(settingsHighlight).toHaveAttribute("data-active-id", "models");
fireEvent.click(within(settingsNav).getByRole("button", { name: "Voice" }));
const voiceButton = within(settingsNav).getByRole("button", {
name: "Voice",
exact: true,
});
fireEvent.click(voiceButton);
expect(await screen.findByRole("heading", { name: "Voice input" })).toBeInTheDocument();
expect(window.location.hash).toBe("#/settings?section=voice");
expect(voiceButton).toHaveAttribute("aria-current", "page");
expect(settingsHighlight).toHaveAttribute("data-active-id", "voice");
});
it("transitions between Apps and Skills without replacing the sidebar", async () => {
@@ -2163,6 +2289,11 @@ describe("App layout", () => {
"aria-current",
"page",
);
expect(within(sidebar).getByTestId("actions-selection-highlight")).toHaveAttribute(
"data-active-id",
"utility:apps",
);
expect(within(sidebar).queryAllByRole("button", { current: "page" })).toHaveLength(1);
expect(screen.getByTestId("settings-section-transition")).toHaveAttribute(
"data-settings-section",
"apps",
@@ -2190,6 +2321,10 @@ describe("App layout", () => {
"aria-current",
"page",
);
expect(within(sidebar).getByTestId("actions-selection-highlight")).toHaveAttribute(
"data-active-id",
"utility:skills",
);
expect(document.title).toBe("Skills · nanobot");
});
+10 -12
View File
@@ -220,7 +220,7 @@ describe("ChatList", () => {
expect(within(chatsSection).queryByText("Project chat")).not.toBeInTheDocument();
});
it("floats a borderless highlight in, then slides it between selected topics", () => {
it("positions one background highlight, then slides it between selected topics", () => {
let revealFrame: FrameRequestCallback | null = null;
vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => {
revealFrame = callback;
@@ -259,14 +259,15 @@ describe("ChatList", () => {
/>,
);
const highlight = screen.getByTestId("active-chat-highlight");
const surface = screen.getByTestId("active-chat-highlight-surface");
expect(surface).toHaveClass(
const highlight = screen.getByTestId("sessions-selection-highlight");
expect(highlight).toHaveClass(
"bg-sidebar-foreground/[0.055]",
"transition-[opacity,transform]",
"transition-[transform,width,height]",
"motion-reduce:transition-none",
);
expect(surface).toHaveStyle("opacity: 0; transform: scale(0.97)");
expect(highlight).toHaveStyle("opacity: 0");
expect(screen.queryByTestId("sessions-selection-highlight-surface"))
.not.toBeInTheDocument();
rerender(
<ChatList
@@ -277,18 +278,15 @@ describe("ChatList", () => {
const activeButton = screen.getByTitle("Active topic");
expect(activeButton).toHaveAttribute("aria-current", "page");
expect(activeButton.parentElement).toHaveClass("transition-[color]");
expect(activeButton.parentElement).not.toHaveClass("transition-colors");
expect(activeButton.parentElement).not.toHaveClass(
"bg-sidebar-accent",
"shadow-[inset_0_0_0_1px_hsl(var(--sidebar-border)/0.55)]",
);
expect(highlight).toHaveClass(
"transition-[transform,width,height]",
"motion-reduce:transition-none",
);
expect(highlight).toHaveStyle(
"width: 284px; height: 32px; transform: translate3d(8px, 12px, 0); transition-property: none",
"width: 284px; height: 32px; transform: translate3d(8px, 12px, 0); opacity: 1; transition-property: none",
);
expect(surface).toHaveStyle("opacity: 1; transform: scale(1)");
revealFrame?.(0);
expect(highlight.style.transitionProperty).toBe("");
+37
View 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",
});
});
});
+68
View File
@@ -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");