fix(webui): complete temporary chat mode

This commit is contained in:
chengyongru
2026-08-07 17:07:58 +08:00
parent 324a61dff1
commit 36253685bd
51 changed files with 1285 additions and 945 deletions
+154 -69
View File
@@ -65,7 +65,7 @@ import { projectNameFromPath, scopeWithAccessMode } from "@/lib/workspace";
import {
createTemporaryChatSession,
isTemporaryChatId,
TEMPORARY_CHAT_ROUTE_KEY,
temporaryChatIdFromSessionKey,
} from "@/lib/temporary-chat";
type BootState =
@@ -102,7 +102,6 @@ type ShellRoute = {
activeKey: string | null;
settingsSection: SettingsSectionKey;
};
const loadSettingsView = () => import("@/components/settings/SettingsView");
const SettingsView = lazy(async () => {
const module = await loadSettingsView();
@@ -232,12 +231,20 @@ function readShellRoute(): ShellRoute {
if (path === "/skills") {
return { view: "skills", activeKey, settingsSection: "skills" };
}
if (path === "/temporary") {
return {
view: "chat",
activeKey: TEMPORARY_CHAT_ROUTE_KEY,
settingsSection: "overview",
};
if (path.startsWith("/temporary/")) {
const encoded = path.slice("/temporary/".length);
try {
const chatId = decodeURIComponent(encoded).trim();
return isTemporaryChatId(chatId)
? {
view: "chat",
activeKey: `websocket:${chatId}`,
settingsSection: "overview",
}
: defaultShellRoute();
} catch {
return defaultShellRoute();
}
}
if (path.startsWith("/chat/")) {
const encoded = path.slice("/chat/".length);
@@ -255,7 +262,8 @@ function readShellRoute(): ShellRoute {
function shellRouteHash(route: ShellRoute): string {
if (route.view === "chat") {
if (route.activeKey === TEMPORARY_CHAT_ROUTE_KEY) return "#/temporary";
const temporaryChatId = temporaryChatIdFromSessionKey(route.activeKey);
if (temporaryChatId) return `#/temporary/${encodeURIComponent(temporaryChatId)}`;
return route.activeKey
? `#/chat/${encodeURIComponent(route.activeKey)}`
: "#/new";
@@ -974,7 +982,8 @@ function Shell({
initialRouteRef.current.activeKey,
);
const [view, setView] = useState<ShellView>(initialRouteRef.current.view);
const [temporarySession, setTemporarySession] = useState<ChatSummary | null>(null);
const [temporarySessions, setTemporarySessions] = useState<Record<string, ChatSummary>>({});
const [temporaryChatEnabled, setTemporaryChatEnabled] = useState(false);
const [settingsInitialSection, setSettingsInitialSection] =
useState<SettingsSectionKey>(initialRouteRef.current.settingsSection);
const [hostSidebarOpen, setHostSidebarOpen] =
@@ -1019,13 +1028,25 @@ function Shell({
const runningChatIdsRef = useRef<Set<string>>(new Set());
const activeChatIdRef = useRef<string | null>(null);
const pendingCreatedSessionKeyRef = useRef<string | null>(null);
const temporarySessionsRef = useRef<Record<string, ChatSummary>>({});
const hostSidebarPreviewCloseTimerRef = useRef<number | null>(null);
const effectiveRuntimeSurface =
settingsSnapshot?.surface ?? settingsSnapshot?.runtime_surface ?? runtimeSurface;
const showHostChrome = effectiveRuntimeSurface === "native";
const showMainSidebar = view !== "settings";
const temporaryChatActive = view === "chat" && activeKey === TEMPORARY_CHAT_ROUTE_KEY;
const temporaryChatId = temporarySession?.chatId ?? null;
const temporaryChatId = temporaryChatIdFromSessionKey(activeKey);
const temporaryChatActive = view === "chat" && temporaryChatId !== null;
const temporaryChatRequested = temporaryChatActive || temporaryChatEnabled;
const temporarySessionList = useMemo(
() => Object.values(temporarySessions).sort((a, b) => (
Date.parse(b.createdAt ?? "") - Date.parse(a.createdAt ?? "")
)),
[temporarySessions],
);
const temporaryChatIds = useMemo(
() => temporarySessionList.map((session) => session.chatId),
[temporarySessionList],
);
const navigate = useCallback(
(route: ShellRoute, options?: { replace?: boolean }) => {
@@ -1053,15 +1074,19 @@ function Shell({
}, []);
useEffect(() => {
if (temporaryChatActive && !temporarySession) {
setTemporarySession(createTemporaryChatSession());
}
}, [temporaryChatActive, temporarySession]);
temporarySessionsRef.current = temporarySessions;
}, [temporarySessions]);
useEffect(() => {
if (!temporaryChatId) return;
return () => client.discardTemporaryChat(temporaryChatId);
}, [client, temporaryChatId]);
if (view === "chat" && !activeKey) return;
setTemporaryChatEnabled(false);
}, [activeKey, view]);
useEffect(() => () => {
for (const session of Object.values(temporarySessionsRef.current)) {
client.discardTemporaryChat(session.chatId);
}
}, [client]);
useEffect(() => {
let cancelled = false;
@@ -1148,9 +1173,11 @@ function Shell({
const activeSession = useMemo<ChatSummary | null>(() => {
if (!activeKey) return null;
if (activeKey === TEMPORARY_CHAT_ROUTE_KEY) return temporarySession;
if (temporaryChatIdFromSessionKey(activeKey)) {
return temporarySessions[activeKey] ?? null;
}
return sessions.find((s) => s.key === activeKey) ?? null;
}, [sessions, activeKey, temporarySession]);
}, [sessions, activeKey, temporarySessions]);
const runningChatIdList = useMemo(() => Array.from(runningChatIds), [runningChatIds]);
const updatedChatIdList = useMemo(() => Array.from(updatedChatIds), [updatedChatIds]);
const activeChatId = activeSession?.chatId ?? null;
@@ -1165,8 +1192,7 @@ function Shell({
});
}, [activeChatId]);
const activeWorkspaceScope = useMemo<WorkspaceScopePayload | null>(() => {
if (temporaryChatActive) {
if (temporarySession?.workspaceScope) return temporarySession.workspaceScope;
if (temporaryChatRequested) {
return workspaces?.default_scope
? normalizeWorkspaceScope(scopeWithAccessMode(workspaces.default_scope, "restricted"))
: null;
@@ -1182,8 +1208,7 @@ function Shell({
activeChatId,
activeSession?.workspaceScope,
draftWorkspaceScope,
temporaryChatActive,
temporarySession?.workspaceScope,
temporaryChatRequested,
workspaceOverrides,
workspaces?.default_scope,
]);
@@ -1223,11 +1248,13 @@ function Shell({
if (pendingCreatedKey && sessions.some((session) => session.key === pendingCreatedKey)) {
pendingCreatedSessionKeyRef.current = null;
}
if (
!activeKey ||
activeKey === TEMPORARY_CHAT_ROUTE_KEY ||
sessions.some((session) => session.key === activeKey)
) return;
if (!activeKey) return;
if (temporaryChatIdFromSessionKey(activeKey)) {
if (temporarySessions[activeKey]) return;
navigate(defaultShellRoute(), { replace: true });
return;
}
if (sessions.some((session) => session.key === activeKey)) return;
// WebKit can commit the route before useSessions' optimistic insert.
// Keep that just-created destination valid until the session list catches up.
if (pendingCreatedKey === activeKey) return;
@@ -1241,7 +1268,7 @@ function Shell({
},
{ replace: true },
);
}, [activeKey, loading, navigate, sessions]);
}, [activeKey, loading, navigate, sessions, temporarySessions]);
useEffect(() => {
return client.onSessionUpdate((chatId, scope, workspaceScope) => {
@@ -1401,7 +1428,13 @@ function Shell({
setWorkspaceError(null);
if (activeChatId) {
if (temporaryChatActive) {
setTemporarySession((current) => current ? { ...current, workspaceScope: next } : current);
setTemporarySessions((current) => {
if (!activeKey || !current[activeKey]) return current;
return {
...current,
[activeKey]: { ...current[activeKey], workspaceScope: next },
};
});
} else if (!activeChatRunning) {
client.setWorkspaceScope(activeChatId, next);
}
@@ -1409,7 +1442,7 @@ function Shell({
}
setDraftWorkspaceScope(next);
},
[activeChatId, activeChatRunning, client, temporaryChatActive],
[activeChatId, activeChatRunning, activeKey, client, temporaryChatActive],
);
const onCreateChat = useCallback(async (workspaceScope?: WorkspaceScopePayload | null) => {
@@ -1440,6 +1473,38 @@ function Shell({
}
}, [activeWorkspaceScope, createChat, navigate, t]);
const onCreateTemporaryChat = useCallback(
async (
workspaceScope?: WorkspaceScopePayload | null,
initialMessage?: string,
) => {
const session = createTemporaryChatSession();
const restrictedScope = workspaceScope
? normalizeWorkspaceScope(scopeWithAccessMode(workspaceScope, "restricted"))
: null;
const nextSession: ChatSummary = {
...session,
preview: initialMessage ?? "",
...(restrictedScope ? { workspaceScope: restrictedScope } : {}),
};
setTemporarySessions((current) => ({
...current,
[nextSession.key]: nextSession,
}));
setTemporaryChatEnabled(false);
setWorkspaceError(null);
setSessionSearchOpen(false);
navigate({
view: "chat",
activeKey: nextSession.key,
settingsSection: "overview",
});
setMobileSidebarOpen(false);
return nextSession.chatId;
},
[navigate],
);
const onForkChat = useCallback(async (
sourceChatId: string,
beforeUserIndex: number,
@@ -1469,30 +1534,19 @@ function Shell({
const onNewChat = useCallback(() => {
navigate(defaultShellRoute());
setTemporaryChatEnabled(false);
setDraftWorkspaceScope(null);
setWorkspaceError(null);
setSessionSearchOpen(false);
setMobileSidebarOpen(false);
}, [navigate]);
const onOpenTemporaryChat = useCallback(() => {
if (temporaryChatActive) return;
if (!temporarySession) setTemporarySession(createTemporaryChatSession());
const onTemporaryChatEnabledChange = useCallback((enabled: boolean) => {
if (view !== "chat" || activeKey) return;
setTemporaryChatEnabled(enabled);
setDraftWorkspaceScope(null);
setWorkspaceError(null);
setSessionSearchOpen(false);
navigate({
view: "chat",
activeKey: TEMPORARY_CHAT_ROUTE_KEY,
settingsSection: "overview",
});
setMobileSidebarOpen(false);
}, [navigate, temporaryChatActive, temporarySession]);
const onClearTemporaryChat = useCallback(() => {
if (!temporaryChatActive) return;
setTemporarySession(createTemporaryChatSession());
setWorkspaceError(null);
}, [temporaryChatActive]);
}, [activeKey, view]);
const onNewChatInProject = useCallback(
(projectPath: string, projectName: string) => {
@@ -1502,6 +1556,7 @@ function Shell({
onNewChat();
return;
}
setTemporaryChatEnabled(false);
navigate(defaultShellRoute());
setDraftWorkspaceScope(normalizeWorkspaceScope({
project_path: trimmed,
@@ -1517,7 +1572,8 @@ function Shell({
const onSelectChat = useCallback(
(key: string) => {
const selected = sessions.find((session) => session.key === key);
const selected = temporarySessionsRef.current[key]
?? sessions.find((session) => session.key === key);
const selectedChatId = selected?.chatId;
if (selectedChatId) {
setUpdatedChatIds((current) => {
@@ -1539,6 +1595,26 @@ function Shell({
[navigate, sessions],
);
const onCloseTemporaryChat = useCallback((key: string) => {
const session = temporarySessionsRef.current[key];
if (!session) return;
const remaining = temporarySessionList.filter((item) => item.key !== key);
const nextSessions = Object.fromEntries(remaining.map((item) => [item.key, item]));
temporarySessionsRef.current = nextSessions;
setTemporarySessions(nextSessions);
client.discardTemporaryChat(session.chatId);
if (activeKey === key) {
if (remaining.length === 0) setDraftWorkspaceScope(null);
setWorkspaceError(null);
navigate({
view: "chat",
activeKey: remaining[0]?.key ?? null,
settingsSection: "overview",
}, { replace: true });
}
setMobileSidebarOpen(false);
}, [activeKey, client, navigate, temporarySessionList]);
const onTogglePin = useCallback(
(key: string) => {
void updateSidebarState((current) => {
@@ -1837,16 +1913,20 @@ function Shell({
useEffect(() => {
let wasOpen = client.status === "open";
return client.onStatus((status) => {
if (!temporaryChatId) return;
if (status === "open") {
wasOpen = true;
return;
}
if (!wasOpen) return;
setTemporarySession(null);
if (temporaryChatActive) navigate(defaultShellRoute(), { replace: true });
wasOpen = false;
if (Object.keys(temporarySessionsRef.current).length === 0) return;
temporarySessionsRef.current = {};
setTemporarySessions({});
if (temporaryChatIdFromSessionKey(readShellRoute().activeKey)) {
navigate(defaultShellRoute(), { replace: true });
}
});
}, [client, navigate, temporaryChatActive, temporaryChatId]);
}, [client, navigate]);
useEffect(() => {
return client.onStatus((status) => {
@@ -2009,13 +2089,13 @@ function Shell({
const sidebarProps = {
sessions,
temporarySessions: temporarySessionList,
activeKey: view === "chat" ? activeKey : null,
loading,
newChatActive: view === "chat" && activeKey === null,
temporaryChatActive,
onNewChat,
onOpenTemporaryChat,
onSelect: onSelectChat,
onCloseTemporaryChat,
onRequestDelete,
onTogglePin,
onRequestRename,
@@ -2201,12 +2281,15 @@ function Shell({
session={activeSession}
sessions={sessions}
title={headerTitle}
temporary={temporaryChatActive}
onClearTemporaryChat={onClearTemporaryChat}
workspaceConnected={!!temporarySession?.workspaceScope}
temporary={temporaryChatRequested}
temporaryChatIds={temporaryChatIds}
temporaryChatEnabled={temporaryChatEnabled}
onTemporaryChatEnabledChange={
!activeKey ? onTemporaryChatEnabledChange : undefined
}
onToggleSidebar={toggleSidebar}
onNewChat={onNewChat}
onCreateChat={onCreateChat}
onCreateChat={temporaryChatEnabled ? onCreateTemporaryChat : onCreateChat}
onForkChat={temporaryChatActive ? undefined : onForkChat}
onTurnEnd={onTurnEnd}
theme={theme}
@@ -2286,14 +2369,16 @@ function Shell({
</Suspense>
) : null}
{restartToast ? (
<div
role="status"
className={cn(
floatingSurfaceElevationClassName,
"fixed left-1/2 top-[calc(0.75rem+env(safe-area-inset-top))] z-50 max-w-[calc(100vw-1rem)] -translate-x-1/2 rounded-full px-4 py-2 text-sm font-medium",
)}
>
{restartToast}
<div className="fixed left-1/2 top-[calc(0.75rem+env(safe-area-inset-top))] z-50 flex w-[min(32rem,calc(100vw-1rem))] -translate-x-1/2 flex-col items-center gap-2">
<div
role="status"
className={cn(
floatingSurfaceElevationClassName,
"max-w-full rounded-full px-4 py-2 text-sm font-medium",
)}
>
{restartToast}
</div>
</div>
) : null}
<PairingCodePopup
+113 -2
View File
@@ -4,17 +4,20 @@ import {
useMemo,
useRef,
useState,
type RefObject,
} from "react";
import {
Archive,
ArchiveRestore,
Folder,
MessageCircleDashed,
MoreHorizontal,
Pencil,
Pin,
PinOff,
Plus,
Trash2,
X,
} from "lucide-react";
import { useTranslation } from "react-i18next";
@@ -50,8 +53,10 @@ const ACTION_MENU_CONTENT_CLASS = "w-[8.5rem] min-w-[8.5rem]";
interface ChatListProps {
sessions: ChatSummary[];
temporarySessions?: ChatSummary[];
activeKey: string | null;
onSelect: (key: string) => void;
onCloseTemporaryChat?: (key: string) => void;
onRequestDelete: (key: string, label: string) => void;
onTogglePin: (key: string) => void;
onRequestRename: (key: string, label: string) => void;
@@ -81,8 +86,10 @@ interface ChatListProps {
export const ChatList = memo(function ChatList({
sessions,
temporarySessions = [],
activeKey,
onSelect,
onCloseTemporaryChat,
onRequestDelete,
onTogglePin,
onRequestRename,
@@ -188,7 +195,7 @@ export const ChatList = memo(function ChatList({
setVisibleLimit(INITIAL_VISIBLE_SESSIONS);
}, [showArchived, sort]);
if (loading && sessions.length === 0) {
if (loading && sessions.length === 0 && temporarySessions.length === 0) {
return (
<div className="px-3 py-6 text-[12px] text-muted-foreground">
{t("chat.loading")}
@@ -196,7 +203,7 @@ export const ChatList = memo(function ChatList({
);
}
if (sessions.length === 0) {
if (sessions.length === 0 && temporarySessions.length === 0) {
return (
<div className="px-3 py-6 text-[12px] leading-5 text-muted-foreground/80">
{emptyLabel ?? t("chat.noSessions")}
@@ -237,6 +244,17 @@ export const ChatList = memo(function ChatList({
data-chat-list-content
className="relative min-w-0 space-y-3 px-2 py-1.5"
>
{temporarySessions.length > 0 ? (
<TemporaryChatSection
sessions={temporarySessions}
activeKey={activeKey}
activeRowRef={activeRowRef}
running={running}
onSelect={onSelect}
onClose={onCloseTemporaryChat}
actionMenuPortalContainer={actionMenuPortalContainer}
/>
) : null}
{limitedGroups.map((group, index) => {
const foldableChatsGroup = isFoldableChatsGroup(group);
const foldedChatsGroup = isFoldedChatsGroup(group, collapsedGroups);
@@ -497,6 +515,99 @@ export const ChatList = memo(function ChatList({
);
});
function TemporaryChatSection({
sessions,
activeKey,
activeRowRef,
running,
onSelect,
onClose,
actionMenuPortalContainer,
}: {
sessions: ChatSummary[];
activeKey: string | null;
activeRowRef: RefObject<HTMLDivElement>;
running: ReadonlySet<string>;
onSelect: (key: string) => void;
onClose?: (key: string) => void;
actionMenuPortalContainer?: HTMLElement | null;
}) {
const { t } = useTranslation();
return (
<section aria-label={t("temporaryChat.sectionTitle")} className="relative z-[1]">
<ChatsGroupHeader label={t("temporaryChat.sectionTitle")} />
<ul className="space-y-0.5">
{sessions.map((session) => {
const active = session.key === activeKey;
const title = deriveTitle(session.preview, t("temporaryChat.title"));
return (
<li key={session.key} className="min-w-0">
<div
ref={active ? activeRowRef : undefined}
data-temporary-chat-row={session.key}
className={cn(
"group flex min-h-8 min-w-0 max-w-full items-center gap-2 rounded-xl px-2 text-[13px]",
SIDEBAR_SELECTION_ITEM_CLASS,
active
? "text-sidebar-accent-foreground"
: "text-sidebar-foreground/82 hover:bg-sidebar-foreground/[0.035] hover:text-sidebar-foreground dark:hover:bg-white/[0.05]",
)}
>
<button
type="button"
onClick={() => onSelect(session.key)}
aria-current={active ? "page" : undefined}
title={title}
className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden py-1.5 text-left"
>
<MessageCircleDashed
className="h-3.5 w-3.5 shrink-0 text-[hsl(var(--temporary-foreground))]"
aria-hidden
/>
<span className="min-w-0 flex-1 truncate font-medium leading-5">
{title}
</span>
</button>
<SessionActivityIndicator state={running.has(session.chatId) ? "running" : null} />
{onClose ? (
<DropdownMenu modal={false}>
<DropdownMenuTrigger
className={cn(
"inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-muted-foreground/75 opacity-40 transition-opacity",
"hover:bg-sidebar-accent hover:text-sidebar-foreground group-hover:opacity-100",
"focus-visible:opacity-100",
active && "opacity-100",
)}
aria-label={t("chat.actions", { title })}
>
<MoreHorizontal className="h-3.5 w-3.5" />
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className={ACTION_MENU_CONTENT_CLASS}
portalContainer={actionMenuPortalContainer}
onCloseAutoFocus={(event) => event.preventDefault()}
>
<DropdownMenuItem
tone="destructive"
onSelect={() => onClose(session.key)}
>
<X className="h-4 w-4 shrink-0" />
{t("temporaryChat.closeAction")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null}
</div>
</li>
);
})}
</ul>
</section>
);
}
function ProjectGroupHeader({
label,
path,
+8 -1
View File
@@ -52,6 +52,8 @@ import type {
interface MessageBubbleProps {
message: UIMessage;
/** Give temporary-chat user turns the dashed private-mode treatment. */
temporary?: boolean;
/** When false, hide this message's copy button. Default true. */
showCopyAction?: boolean;
cliApps?: CliAppInfo[];
@@ -258,6 +260,7 @@ function UserDeliveryStatus({
/** Render user turns as compact bubbles and assistant turns as document-like prose. */
export function MessageBubble({
message,
temporary = false,
showCopyAction = true,
cliApps = [],
mcpPresets = [],
@@ -326,9 +329,13 @@ export function MessageBubble({
) : null}
{hasText ? (
<p
data-temporary-message={temporary ? "true" : undefined}
className={cn(
"ml-auto w-fit max-w-full min-w-0 rounded-[18px] bg-secondary/70 px-4 py-2",
"ml-auto w-fit max-w-full min-w-0 rounded-[18px] px-4 py-2",
"text-left text-[16px]/[1.75] whitespace-pre-wrap [overflow-wrap:anywhere]",
temporary
? "border border-dashed border-muted-foreground/40 bg-transparent"
: "bg-secondary/70",
)}
>
{messageText}
+6 -15
View File
@@ -8,7 +8,6 @@ import {
Archive,
Brain,
CalendarClock,
MessageCircleDashed,
Menu,
Search,
Settings,
@@ -32,13 +31,13 @@ import { cn } from "@/lib/utils";
interface SidebarProps {
sessions: ChatSummary[];
temporarySessions?: ChatSummary[];
activeKey: string | null;
loading: boolean;
newChatActive: boolean;
temporaryChatActive: boolean;
onNewChat: () => void;
onOpenTemporaryChat: () => void;
onSelect: (key: string) => void;
onCloseTemporaryChat?: (key: string) => void;
onRequestDelete: (key: string, label: string) => void;
onTogglePin: (key: string) => void;
onRequestRename: (key: string, label: string) => void;
@@ -98,10 +97,8 @@ export function Sidebar(props: SidebarProps) {
const toggleLabel = t("thread.header.toggleSidebar");
const newChatShortcut = newChatShortcutLabel();
const activeActionRef = useRef<HTMLButtonElement>(null);
const activeActionId = props.temporaryChatActive
? "temporary-chat"
: props.newChatActive
? "new-chat"
const activeActionId = props.newChatActive
? "new-chat"
: props.activeUtility
? `utility:${props.activeUtility}`
: null;
@@ -175,14 +172,6 @@ export function Sidebar(props: SidebarProps) {
shortcut={newChatShortcut}
ariaKeyShortcuts="Meta+Shift+O Control+Shift+O"
/>
<SidebarActionButton
collapsed={collapsed}
label={t("temporaryChat.title")}
onClick={props.onOpenTemporaryChat}
active={props.temporaryChatActive}
selectionRef={activeActionRef}
icon={<MessageCircleDashed className="h-4 w-4" />}
/>
<SidebarActionButton
collapsed={collapsed}
label={t("sidebar.searchAria")}
@@ -234,10 +223,12 @@ export function Sidebar(props: SidebarProps) {
{!collapsed && (
<ChatList
sessions={props.sessions}
temporarySessions={props.temporarySessions}
activeKey={props.activeKey}
loading={props.loading}
emptyLabel={t("chat.noSessions")}
onSelect={props.onSelect}
onCloseTemporaryChat={props.onCloseTemporaryChat}
onRequestDelete={props.onRequestDelete}
onTogglePin={props.onTogglePin}
onRequestRename={props.onRequestRename}
+30 -30
View File
@@ -7,6 +7,7 @@ import {
useState,
type CSSProperties,
type KeyboardEvent as ReactKeyboardEvent,
type Ref,
} from "react";
import { MarkdownText, preloadMarkdownText } from "@/components/MarkdownText";
@@ -200,14 +201,14 @@ interface ThreadComposerProps {
sessions?: ChatSummary[];
skills?: SkillSummary[];
onStop?: () => void;
surfaceRef?: Ref<HTMLDivElement>;
onTranscribeAudio?: (dataUrl: string, options?: { durationMs?: number }) => Promise<string>;
/** Unix seconds from server; turn elapsed timer above input while set. */
runStartedAt?: number | null;
/** Sustained objective for this chat (WebSocket ``goal_state``). */
goalState?: GoalStateWsPayload;
workspaceScope?: WorkspaceScopePayload | null;
compactWorkspaceControls?: boolean;
workspaceConnected?: boolean;
workspaceControlsHidden?: boolean;
workspaceDefaultScope?: WorkspaceScopePayload | null;
workspaceControls?: WorkspacesPayload["controls"] | null;
workspaceScopeDisabled?: boolean;
@@ -956,12 +957,12 @@ export function ThreadComposer({
sessions = [],
skills = [],
onStop,
surfaceRef,
onTranscribeAudio,
runStartedAt = null,
goalState,
workspaceScope = null,
compactWorkspaceControls = false,
workspaceConnected = false,
workspaceControlsHidden = false,
workspaceDefaultScope = null,
workspaceControls = null,
workspaceScopeDisabled = false,
@@ -1017,7 +1018,7 @@ export function ThreadComposer({
&& !!workspaceDefaultScope
&& !!onWorkspaceScopeChange
&& workspaceControls?.can_change_project !== false;
const showProjectPicker = projectPickerAvailable && !compactWorkspaceControls;
const showProjectPicker = projectPickerAvailable && !workspaceControlsHidden;
useEffect(() => {
secondEnterPromptIdRef.current = null;
@@ -2249,6 +2250,7 @@ export function ThreadComposer({
/>
) : null}
<div
ref={surfaceRef}
className={cn(
"thread-composer-surface group/composer relative mx-auto flex w-full flex-col overflow-visible transition-all duration-200",
isHero
@@ -2394,7 +2396,7 @@ export function ThreadComposer({
) : null}
<div
className={cn(
"thread-composer-footer flex flex-nowrap items-center",
"thread-composer-footer flex flex-nowrap items-center motion-safe:transition-[padding-bottom] motion-safe:[transition-duration:220ms] motion-safe:ease-in-out",
isHero
? cn(
"gap-x-1.5 px-3 sm:px-4",
@@ -2433,19 +2435,6 @@ export function ThreadComposer({
>
<Plus className={cn(isHero ? "h-[18px] w-[18px]" : "h-4 w-4")} />
</Button>
{compactWorkspaceControls && projectPickerAvailable ? (
<WorkspaceProjectPicker
compact
connected={workspaceConnected}
isHero={isHero}
disabled={disabled || workspaceScopeDisabled}
scope={workspaceScope}
defaultScope={workspaceDefaultScope}
controls={workspaceControls}
error={workspaceError}
onChange={onWorkspaceScopeChange}
/>
) : null}
{voiceRecorder.isRecording ? (
<VoiceRecordingMeter
ariaLabel={voiceRecordingStatusLabel}
@@ -2454,7 +2443,7 @@ export function ThreadComposer({
isHero={isHero}
levels={voiceRecorder.levels}
/>
) : workspaceScope && (!compactWorkspaceControls || workspaceConnected) ? (
) : workspaceScope && !workspaceControlsHidden ? (
<WorkspaceAccessMenu
scope={workspaceScope}
disabled={disabled || workspaceScopeDisabled}
@@ -2565,16 +2554,27 @@ export function ThreadComposer({
</Button>
</div>
</div>
{showProjectPicker ? (
<WorkspaceProjectPicker
isHero={isHero}
disabled={disabled || workspaceScopeDisabled}
scope={workspaceScope}
defaultScope={workspaceDefaultScope}
controls={workspaceControls}
error={workspaceError}
onChange={onWorkspaceScopeChange}
/>
{projectPickerAvailable ? (
<div
className="composer-workspace-drawer"
data-composer-workspace-drawer=""
data-state={showProjectPicker ? "open" : "closed"}
aria-hidden={showProjectPicker ? undefined : true}
>
<div className="composer-workspace-drawer-clip">
<div className="composer-workspace-drawer-content">
<WorkspaceProjectPicker
isHero={isHero}
disabled={disabled || workspaceScopeDisabled || !showProjectPicker}
scope={workspaceScope}
defaultScope={workspaceDefaultScope}
controls={workspaceControls}
error={workspaceError}
onChange={onWorkspaceScopeChange}
/>
</div>
</div>
</div>
) : null}
</div>
</form>
+55 -3
View File
@@ -1,8 +1,14 @@
import { Menu, Moon, Sun } from "lucide-react";
import type { ReactNode } from "react";
import { Menu, MessageCircleDashed, Moon, Sun } from "lucide-react";
import { type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
interface ThreadHeaderProps {
@@ -16,6 +22,9 @@ interface ThreadHeaderProps {
minimal?: boolean;
promptNavigatorAction?: ReactNode;
sessionInfoAction?: ReactNode;
temporaryChatEnabled?: boolean;
temporaryChatDisabled?: boolean;
onTemporaryChatEnabledChange?: (enabled: boolean) => void;
}
export function ThreadHeader({
@@ -29,13 +38,17 @@ export function ThreadHeader({
minimal = false,
promptNavigatorAction,
sessionInfoAction,
temporaryChatEnabled = false,
temporaryChatDisabled = false,
onTemporaryChatEnabledChange,
}: ThreadHeaderProps) {
const { t } = useTranslation();
return (
<div
data-testid="thread-header"
className={cn(
"relative z-10 flex items-center justify-between gap-3 px-3 py-2",
"relative z-30 flex items-center justify-between gap-3 px-3 py-2",
minimal && "h-11",
!minimal && hostChromeTitleInset && "lg:pl-[128px]",
)}
@@ -63,6 +76,45 @@ export function ThreadHeader({
<div className="ml-auto flex shrink-0 items-center gap-1">
{sessionInfoAction}
{promptNavigatorAction}
{onTemporaryChatEnabledChange ? (
<TooltipProvider delayDuration={700} skipDelayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
disabled={temporaryChatDisabled}
aria-label={t("temporaryChat.title")}
aria-pressed={temporaryChatEnabled}
onClick={() => onTemporaryChatEnabledChange(!temporaryChatEnabled)}
className={cn(
"host-no-drag h-8 w-8 shrink-0 rounded-full bg-transparent text-muted-foreground shadow-none transition-none hover:text-foreground",
temporaryChatEnabled ? "hover:bg-transparent" : "hover:bg-accent/45",
)}
>
<MessageCircleDashed
data-testid="temporary-chat-icon"
className={cn(
"h-4 w-4 motion-safe:transition-colors",
temporaryChatEnabled
? "text-[var(--temporary-control-active)] motion-safe:duration-150"
: "text-current motion-safe:duration-75",
)}
aria-hidden
/>
</Button>
</TooltipTrigger>
<TooltipContent
side="bottom"
align="end"
className="max-w-72 rounded-xl border border-border/70 bg-popover px-3 py-2 text-[12px]/[1.4] text-popover-foreground shadow-[0_8px_24px_rgba(15,23,42,0.13)] dark:border-white/10"
>
{t("temporaryChat.description")}
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : null}
{!hideThemeButton ? (
<ThemeButton
theme={theme}
@@ -8,6 +8,7 @@ import type { CliAppInfo, McpPresetInfo, SlashCommand, UIMessage } from "@/lib/t
interface ThreadMessagesProps {
messages: UIMessage[];
temporary?: boolean;
/** When true, agent turn still in flight — keeps activity timeline expanded. */
isStreaming?: boolean;
hiddenUserMessageCount?: number;
@@ -50,6 +51,7 @@ export function assistantForkFlags(units: DisplayUnit[]): boolean[] {
export function ThreadMessages({
messages,
temporary = false,
isStreaming = false,
hiddenUserMessageCount = 0,
cliApps = [],
@@ -125,6 +127,7 @@ export function ThreadMessages({
forkIndex={forkIndex}
showForkBoundary={index === forkBoundaryAfterUnitIndex}
forkBoundaryLabel={t("thread.forkedFromHistory")}
temporary={temporary}
cliApps={cliApps}
mcpPresets={mcpPresets}
slashCommands={slashCommands}
@@ -147,6 +150,7 @@ interface ThreadDisplayUnitProps {
forkIndex?: number;
showForkBoundary: boolean;
forkBoundaryLabel: string;
temporary: boolean;
cliApps: CliAppInfo[];
mcpPresets: McpPresetInfo[];
slashCommands: SlashCommand[];
@@ -164,6 +168,7 @@ const ThreadDisplayUnit = memo(function ThreadDisplayUnit({
forkIndex,
showForkBoundary,
forkBoundaryLabel,
temporary,
cliApps,
mcpPresets,
slashCommands,
@@ -200,6 +205,7 @@ const ThreadDisplayUnit = memo(function ThreadDisplayUnit({
) : (
<MessageBubble
message={unit.message}
temporary={temporary}
cliApps={cliApps}
mcpPresets={mcpPresets}
slashCommands={slashCommands}
@@ -227,6 +233,7 @@ function threadDisplayUnitPropsEqual(
&& previous.forkIndex === next.forkIndex
&& previous.showForkBoundary === next.showForkBoundary
&& previous.forkBoundaryLabel === next.forkBoundaryLabel
&& previous.temporary === next.temporary
&& previous.cliApps === next.cliApps
&& previous.mcpPresets === next.mcpPresets
&& previous.slashCommands === next.slashCommands
+33 -44
View File
@@ -1,11 +1,9 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import type { PointerEvent as ReactPointerEvent } from "react";
import { RotateCcw } from "lucide-react";
import { useTranslation } from "react-i18next";
import { FilePreviewAvailabilityProvider } from "@/components/FilePreviewAvailabilityContext";
import { FilePreviewPanel } from "@/components/FilePreviewPanel";
import { Button } from "@/components/ui/button";
import { PromptNavigator } from "@/components/thread/PromptNavigator";
import { SessionInfoPopover } from "@/components/thread/SessionInfoPopover";
import { ThreadComposer } from "@/components/thread/ThreadComposer";
@@ -35,6 +33,7 @@ import {
} from "@/lib/mcp-preset-events";
import type { CanonicalRunSnapshot, StreamError } from "@/lib/nanobot-client";
import { inferProviderFromModelName, providerDisplayLabel } from "@/lib/provider-brand";
import { isTemporaryChatId } from "@/lib/temporary-chat";
import type {
ChatSummary,
SettingsPayload,
@@ -298,11 +297,16 @@ interface ThreadShellProps {
sessions?: ChatSummary[];
title: string;
temporary?: boolean;
onClearTemporaryChat?: () => void;
temporaryChatIds?: readonly string[];
temporaryChatEnabled?: boolean;
onTemporaryChatEnabledChange?: (enabled: boolean) => void;
onToggleSidebar: () => void;
onGoHome?: () => void;
onNewChat?: () => void;
onCreateChat?: (workspaceScope?: WorkspaceScopePayload | null) => Promise<string | null>;
onCreateChat?: (
workspaceScope?: WorkspaceScopePayload | null,
initialMessage?: string,
) => Promise<string | null>;
onForkChat?: (sourceChatId: string, beforeUserIndex: number) => Promise<string | null>;
onTurnEnd?: () => void;
theme?: "light" | "dark";
@@ -312,7 +316,6 @@ interface ThreadShellProps {
hideThemeButton?: boolean;
hideHeader?: boolean;
workspaceScope?: WorkspaceScopePayload | null;
workspaceConnected?: boolean;
workspaceDefaultScope?: WorkspaceScopePayload | null;
workspaceControls?: WorkspacesPayload["controls"] | null;
workspaceScopeDisabled?: boolean;
@@ -482,7 +485,7 @@ function HeroGreeting({ text }: { text: string }) {
<h1
ref={headingRef}
data-testid="hero-greeting"
className="whitespace-nowrap text-[34px] font-normal leading-[1.08] tracking-normal text-foreground sm:text-[48px] sm:leading-tight"
className="select-none whitespace-nowrap text-[34px] font-normal leading-[1.08] tracking-normal text-foreground sm:text-[48px] sm:leading-tight"
>
{text}
</h1>
@@ -586,7 +589,9 @@ export function ThreadShell({
sessions = [],
title,
temporary = false,
onClearTemporaryChat,
temporaryChatIds = [],
temporaryChatEnabled = false,
onTemporaryChatEnabledChange,
onToggleSidebar,
onCreateChat,
onForkChat,
@@ -598,7 +603,6 @@ export function ThreadShell({
hideThemeButton = false,
hideHeader = false,
workspaceScope = null,
workspaceConnected = false,
workspaceDefaultScope = null,
workspaceControls = null,
workspaceScopeDisabled = false,
@@ -665,6 +669,7 @@ export function ThreadShell({
const [quotedContext, setQuotedContext] = useState<string | null>(null);
const [composerFocusSignal, setComposerFocusSignal] = useState(0);
const shellRef = useRef<HTMLElement | null>(null);
const composerSurfaceRef = useRef<HTMLDivElement | null>(null);
const filePreviewWidthRef = useRef(FILE_PREVIEW_DEFAULT_WIDTH);
const filePreviewCloseTimerRef = useRef<number | null>(null);
const pendingFirstRef = useRef<PendingFirstMessage | null>(null);
@@ -672,7 +677,6 @@ export function ThreadShell({
const viewportRef = useRef<ThreadViewportHandle | null>(null);
const activeViewportTurnByChatIdRef = useRef<Map<string, string>>(new Map());
const messageCacheRef = useRef<Map<string, UIMessage[]>>(new Map());
const temporaryChatIdRef = useRef<string | null>(null);
/** Last chatId we associated with the in-memory thread (for cache-on-switch). */
const prevChatIdForCacheRef = useRef<string | null>(null);
/** Skip one message-cache write right after chatId changes (messages may not match yet). */
@@ -687,6 +691,8 @@ export function ThreadShell({
const sessionKeyByChatIdRef = useRef<Map<string, string>>(new Map());
const currentUiMessagesRef = useRef<UIMessage[] | null>(null);
const uiRevisionRef = useRef(0);
const showTemporaryChatControl =
!hideHeader && !session && !loading && !!onTemporaryChatEnabledChange;
const initial = useMemo(() => {
if (!chatId) return historical;
@@ -746,13 +752,14 @@ export function ThreadShell({
}, [historyKey]);
useEffect(() => {
if (!temporary || !chatId) return;
const previous = temporaryChatIdRef.current;
temporaryChatIdRef.current = chatId;
if (!previous || previous === chatId) return;
messageCacheRef.current.delete(previous);
activeViewportTurnByChatIdRef.current.delete(previous);
}, [chatId, temporary]);
const retained = new Set(temporaryChatIds);
for (const cachedChatId of messageCacheRef.current.keys()) {
if (isTemporaryChatId(cachedChatId) && !retained.has(cachedChatId)) {
messageCacheRef.current.delete(cachedChatId);
activeViewportTurnByChatIdRef.current.delete(cachedChatId);
}
}
}, [temporaryChatIds]);
const handleQuoteSelection = useCallback((text: string) => {
setQuotedContext(text);
@@ -1256,7 +1263,7 @@ export function ThreadShell({
setBooting(true);
pendingFirstRef.current = { content, images, options: withWorkspaceScope(options) };
setPendingFirstTargetChatId(null);
const newId = await onCreateChat?.(workspaceScope);
const newId = await onCreateChat?.(workspaceScope, content);
if (!newId) {
pendingFirstRef.current = null;
setPendingFirstTargetChatId(null);
@@ -1422,8 +1429,7 @@ export function ThreadShell({
runStartedAt={currentRunStartedAt}
goalState={currentGoalState}
workspaceScope={workspaceScope}
compactWorkspaceControls={temporary}
workspaceConnected={workspaceConnected}
workspaceControlsHidden={temporary}
workspaceDefaultScope={workspaceDefaultScope}
workspaceControls={workspaceControls}
workspaceScopeDisabled={workspaceScopeDisabled}
@@ -1462,12 +1468,12 @@ export function ThreadShell({
mcpPresets={mcpPresets}
sessions={mentionSessions}
skills={skills}
surfaceRef={composerSurfaceRef}
runStartedAt={currentRunStartedAt}
onTranscribeAudio={transcribeAudio}
goalState={currentGoalState}
workspaceScope={workspaceScope}
compactWorkspaceControls={temporary}
workspaceConnected={workspaceConnected}
workspaceControlsHidden={temporary}
workspaceDefaultScope={workspaceDefaultScope}
workspaceControls={workspaceControls}
workspaceScopeDisabled={workspaceScopeDisabled}
@@ -1491,29 +1497,6 @@ export function ThreadShell({
);
const sessionInfoAction = historyKey ? (
<SessionInfoPopover sessionKey={historyKey} token={token} title={title} />
) : temporary ? (
<div className="flex items-center gap-1">
<span
className="rounded-full border border-border/70 bg-muted/35 px-2 py-1 text-[11px] text-muted-foreground"
title={t("temporaryChat.description")}
>
{t("temporaryChat.notSaved")}
</span>
{onClearTemporaryChat ? (
<Button
type="button"
variant="ghost"
size="icon"
disabled={turnActive}
aria-label={t("temporaryChat.clear")}
title={t("temporaryChat.clear")}
onClick={onClearTemporaryChat}
className="h-8 w-8 rounded-full text-muted-foreground/85 hover:bg-accent/40 hover:text-foreground"
>
<RotateCcw className="h-3.5 w-3.5" />
</Button>
) : null}
</div>
) : undefined;
const promptNavigatorAction = historyKey ? (
<PromptNavigator
@@ -1537,6 +1520,11 @@ export function ThreadShell({
minimal={!session && !loading}
promptNavigatorAction={promptNavigatorAction}
sessionInfoAction={sessionInfoAction}
temporaryChatEnabled={temporaryChatEnabled}
temporaryChatDisabled={booting || turnActive}
onTemporaryChatEnabledChange={
showTemporaryChatControl ? onTemporaryChatEnabledChange : undefined
}
/>
) : null}
<FilePreviewAvailabilityProvider
@@ -1545,6 +1533,7 @@ export function ThreadShell({
<ThreadViewport
ref={viewportRef}
messages={displayMessages}
temporary={temporary}
isStreaming={turnActive}
emptyState={emptyState}
composer={composer}
@@ -35,6 +35,7 @@ export interface ThreadViewportHandle {
interface ThreadViewportProps {
messages: UIMessage[];
temporary?: boolean;
isStreaming: boolean;
composer: ReactNode;
emptyState?: ReactNode;
@@ -157,6 +158,7 @@ function readSoftKeyboardInsetBottom(container: HTMLElement | null): number {
export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportProps>(function ThreadViewport({
messages,
temporary = false,
isStreaming,
composer,
emptyState,
@@ -682,6 +684,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
<div ref={messageContentRef} className="mx-auto w-full max-w-[49.5rem]">
<ThreadMessages
messages={visibleMessages}
temporary={temporary}
isStreaming={isStreaming}
hiddenUserMessageCount={hiddenUserMessageCount}
cliApps={cliApps}
@@ -78,8 +78,12 @@ export function WorkspaceProjectPicker({
}, [currentProjectScope?.project_path, open]);
useEffect(() => {
if (error && visible) setOpen(true);
}, [error, visible]);
if (disabled) setOpen(false);
}, [disabled]);
useEffect(() => {
if (error && visible && !disabled) setOpen(true);
}, [disabled, error, visible]);
const applyProjectPath = useCallback(
(projectPath: string, projectName?: string) => {
+43
View File
@@ -33,6 +33,10 @@
--input: 40 8% 90.5%;
--ring: 0 0% 3.9%;
--inline-token-highlight: #ef8e30;
--temporary-control-active: #ef8e30;
--temporary-accent: 24 95% 53%;
--temporary-foreground: 17 88% 32%;
--temporary-border: 17 88% 40%;
--radius: 0.4375rem;
--sidebar: 40 8% 96.8%;
--sidebar-foreground: 0 0% 3.9%;
@@ -67,6 +71,10 @@
--input: var(--border);
--ring: 0 0% 83.1%;
--inline-token-highlight: #ef8e30;
--temporary-control-active: #ef8e30;
--temporary-accent: 24 95% 53%;
--temporary-foreground: 32 98% 73%;
--temporary-border: 27 96% 61%;
--sidebar: var(--card);
--sidebar-foreground: 0 0% 98%;
--sidebar-accent: var(--background);
@@ -435,6 +443,41 @@
opacity: 1;
transform: translateY(0);
}
.composer-workspace-drawer {
--composer-workspace-drawer-duration: 220ms;
display: grid;
grid-template-rows: 0fr;
opacity: 0;
pointer-events: none;
}
.composer-workspace-drawer[data-state="open"] {
--composer-workspace-drawer-duration: 240ms;
grid-template-rows: 1fr;
opacity: 1;
pointer-events: auto;
}
.composer-workspace-drawer-clip {
min-height: 0;
overflow: hidden;
}
@media (prefers-reduced-motion: no-preference) {
.composer-workspace-drawer {
transition:
grid-template-rows var(--composer-workspace-drawer-duration)
cubic-bezier(0.4, 0, 0.2, 1),
opacity var(--composer-workspace-drawer-duration) ease-in-out;
}
.composer-workspace-drawer-content {
transform: translateY(-6px);
transition: transform var(--composer-workspace-drawer-duration)
cubic-bezier(0.4, 0, 0.2, 1);
}
.composer-workspace-drawer[data-state="open"] .composer-workspace-drawer-content {
transform: translateY(0);
}
}
@keyframes run-pulse-dot {
0%,
100% {
+2
View File
@@ -1461,6 +1461,8 @@ export function useNanobotStream(
return prev.map((m) => (m.isStreaming ? { ...m, isStreaming: false } : m));
});
suppressStreamUntilTurnEndRef.current = false;
setRunStartedAt(null);
client.finishRunLocally(chatId);
client.sendMessage(chatId, "/stop");
}, [chatId, clearActivitySegment, client, flushPendingStreamEvents]);
+4 -2
View File
@@ -51,9 +51,11 @@
},
"temporaryChat": {
"title": "Temporary chat",
"description": "Not saved to history or memory. Requests still go to your model provider, and tool actions may leave changes.",
"description": "Not saved to history or memory. Reloading, closing, or losing the connection ends these chats. Requests still go to your model provider, and tool actions may leave changes.",
"notSaved": "Not saved",
"clear": "Clear temporary chat"
"clear": "Clear temporary chat",
"sectionTitle": "Temporary chats",
"closeAction": "Close temporary chat"
},
"sidebar": {
"navigation": "Sidebar navigation",
+4 -2
View File
@@ -51,9 +51,11 @@
},
"temporaryChat": {
"title": "Chat temporal",
"description": "No se guarda en el historial ni en la memoria. Las solicitudes siguen llegando al proveedor del modelo y las herramientas pueden dejar cambios.",
"description": "No se guarda en el historial ni en la memoria. Recargar, cerrar o perder la conexión finaliza estos chats. Las solicitudes siguen llegando al proveedor del modelo y las herramientas pueden dejar cambios.",
"notSaved": "No se guarda",
"clear": "Borrar chat temporal"
"clear": "Borrar chat temporal",
"sectionTitle": "Chats temporales",
"closeAction": "Cerrar chat temporal"
},
"sidebar": {
"navigation": "Navegación de la barra lateral",
+4 -2
View File
@@ -51,9 +51,11 @@
},
"temporaryChat": {
"title": "Discussion temporaire",
"description": "Elle nest enregistrée ni dans lhistorique ni dans la mémoire. Les requêtes sont tout de même envoyées au fournisseur du modèle et les outils peuvent laisser des modifications.",
"description": "Elle nest enregistrée ni dans lhistorique ni dans la mémoire. Recharger, fermer ou perdre la connexion met fin à ces discussions. Les requêtes sont tout de même envoyées au fournisseur du modèle et les outils peuvent laisser des modifications.",
"notSaved": "Non enregistrée",
"clear": "Effacer la discussion temporaire"
"clear": "Effacer la discussion temporaire",
"sectionTitle": "Discussions temporaires",
"closeAction": "Fermer la discussion temporaire"
},
"sidebar": {
"navigation": "Navigation de la barre latérale",
+4 -2
View File
@@ -51,9 +51,11 @@
},
"temporaryChat": {
"title": "Obrolan sementara",
"description": "Tidak disimpan ke riwayat atau memori. Permintaan tetap dikirim ke penyedia model dan tindakan alat dapat meninggalkan perubahan.",
"description": "Tidak disimpan ke riwayat atau memori. Memuat ulang, menutup, atau kehilangan koneksi akan mengakhiri obrolan ini. Permintaan tetap dikirim ke penyedia model dan tindakan alat dapat meninggalkan perubahan.",
"notSaved": "Tidak disimpan",
"clear": "Hapus obrolan sementara"
"clear": "Hapus obrolan sementara",
"sectionTitle": "Obrolan sementara",
"closeAction": "Tutup obrolan sementara"
},
"sidebar": {
"navigation": "Navigasi bilah samping",
+4 -2
View File
@@ -51,9 +51,11 @@
},
"temporaryChat": {
"title": "一時チャット",
"description": "履歴やメモリには保存されません。リクエストは引き続きモデルプロバイダーに送信され、ツール操作による変更は残る場合があります。",
"description": "履歴やメモリには保存されません。再読み込み、ページを閉じる操作、接続切断で一時チャットは終了します。リクエストは引き続きモデルプロバイダーに送信され、ツール操作による変更は残る場合があります。",
"notSaved": "保存されません",
"clear": "一時チャットを消去"
"clear": "一時チャットを消去",
"sectionTitle": "一時チャット",
"closeAction": "一時チャットを閉じる"
},
"sidebar": {
"navigation": "サイドバーのナビゲーション",
+4 -2
View File
@@ -51,9 +51,11 @@
},
"temporaryChat": {
"title": "임시 채팅",
"description": "기록이나 메모리에 저장되지 않습니다. 요청은 계속 모델 제공업체로 전송되며 도구 작업의 변경 사항은 남을 수 있습니다.",
"description": "기록이나 메모리에 저장되지 않습니다. 새로고침, 페이지 닫기 또는 연결 끊김 시 임시 채팅이 종료됩니다. 요청은 계속 모델 제공업체로 전송되며 도구 작업의 변경 사항은 남을 수 있습니다.",
"notSaved": "저장 안 함",
"clear": "임시 채팅 지우기"
"clear": "임시 채팅 지우기",
"sectionTitle": "임시 채팅",
"closeAction": "임시 채팅 닫기"
},
"sidebar": {
"navigation": "사이드바 탐색",
+4 -2
View File
@@ -51,9 +51,11 @@
},
"temporaryChat": {
"title": "Chat temporário",
"description": "Não é salvo no histórico nem na memória. As solicitações ainda são enviadas ao provedor do modelo, e as ações das ferramentas podem deixar alterações.",
"description": "Não é salvo no histórico nem na memória. Recarregar, fechar ou perder a conexão encerra estes chats. As solicitações ainda são enviadas ao provedor do modelo, e as ações das ferramentas podem deixar alterações.",
"notSaved": "Não salvo",
"clear": "Limpar chat temporário"
"clear": "Limpar chat temporário",
"sectionTitle": "Chats temporários",
"closeAction": "Fechar chat temporário"
},
"sidebar": {
"navigation": "Navegação da barra lateral",
+4 -2
View File
@@ -51,9 +51,11 @@
},
"temporaryChat": {
"title": "Trò chuyện tạm thời",
"description": "Không được lưu vào lịch sử hoặc bộ nhớ. Yêu cầu vẫn được gửi đến nhà cung cấp mô hình và thao tác công cụ có thể để lại thay đổi.",
"description": "Không được lưu vào lịch sử hoặc bộ nhớ. Tải lại, đóng trang hoặc mất kết nối sẽ kết thúc các cuộc trò chuyện này. Yêu cầu vẫn được gửi đến nhà cung cấp mô hình và thao tác công cụ có thể để lại thay đổi.",
"notSaved": "Không lưu",
"clear": "Xóa trò chuyện tạm thời"
"clear": "Xóa trò chuyện tạm thời",
"sectionTitle": "Trò chuyện tạm thời",
"closeAction": "Đóng trò chuyện tạm thời"
},
"sidebar": {
"navigation": "Điều hướng thanh bên",
+4 -2
View File
@@ -51,9 +51,11 @@
},
"temporaryChat": {
"title": "临时聊天",
"description": "不会保存到历史记录或记忆。请求仍会发送给模型提供商,工具操作也可能留下更改。",
"description": "不会保存到历史记录或记忆。刷新、关闭页面或连接中断后,临时聊天会结束。请求仍会发送给模型提供商,工具操作也可能留下更改。",
"notSaved": "不保存",
"clear": "清空临时聊天"
"clear": "清空临时聊天",
"sectionTitle": "临时聊天",
"closeAction": "关闭临时聊天"
},
"sidebar": {
"navigation": "侧边栏导航",
+4 -2
View File
@@ -51,9 +51,11 @@
},
"temporaryChat": {
"title": "臨時聊天",
"description": "不會儲存至歷史記錄或記憶。請求仍會傳送給模型供應商,工具操作也可能留下變更。",
"description": "不會儲存至歷史記錄或記憶。重新載入、關閉頁面或連線中斷後,臨時聊天會結束。請求仍會傳送給模型供應商,工具操作也可能留下變更。",
"notSaved": "不儲存",
"clear": "清空臨時聊天"
"clear": "清空臨時聊天",
"sectionTitle": "臨時聊天",
"closeAction": "關閉臨時聊天"
},
"sidebar": {
"navigation": "側邊欄導覽",
+18 -6
View File
@@ -174,8 +174,8 @@ export class NanobotClient {
private static readonly PENDING_INBOUND_MAX = 2000;
// chat_ids we've attached to since connect; re-attached after reconnects
private knownChats = new Set<string>();
/** Temporary chat is connection-owned and intentionally not reattached. */
private temporaryChatId: string | null = null;
/** Temporary chats are connection-owned and intentionally not reattached. */
private temporaryChatIds = new Set<string>();
/** Wall-clock run strip: updated from ``goal_status`` even with no ``onChat`` subscriber. */
private runStartedAtByChatId = new Map<string, number>();
/** Per-turn clocks let a rejected newer turn fall back without borrowing its timer. */
@@ -285,6 +285,16 @@ export class NanobotClient {
return v === undefined ? null : v;
}
/** Clear the optimistic run state immediately after the user stops a turn. */
finishRunLocally(chatId: string): void {
const unsettled = [...(this.unsettledRunTurnIdsByChatId.get(chatId) ?? [])];
for (const turnId of unsettled) this.settleRunTurn(chatId, turnId);
this.latestRunTurnIdByChatId.delete(chatId);
if (this.runStartedAtByChatId.delete(chatId)) {
this.emitRunStatus(chatId, null);
}
}
/** Refresh transport policy after bootstrap token renewal. */
updateMaxFrameBytes(maxFrameBytes?: number): void {
this.maxFrameBytes = this.normalizeMaxFrameBytes(maxFrameBytes);
@@ -806,7 +816,7 @@ export class NanobotClient {
attach(chatId: string): void {
if (isTemporaryChatId(chatId)) {
this.temporaryChatId = chatId;
this.temporaryChatIds.add(chatId);
return;
}
this.knownChats.add(chatId);
@@ -831,7 +841,7 @@ export class NanobotClient {
},
): void {
const temporary = isTemporaryChatId(chatId);
if (temporary) this.temporaryChatId = chatId;
if (temporary) this.temporaryChatIds.add(chatId);
if (!temporary) this.knownChats.add(chatId);
const frame: Outbound = {
type: "message",
@@ -1261,11 +1271,13 @@ export class NanobotClient {
}
private clearTemporaryChats(): void {
if (this.temporaryChatId) this.forgetTemporaryChat(this.temporaryChatId);
for (const chatId of [...this.temporaryChatIds]) {
this.forgetTemporaryChat(chatId);
}
}
private forgetTemporaryChat(chatId: string): void {
if (this.temporaryChatId === chatId) this.temporaryChatId = null;
this.temporaryChatIds.delete(chatId);
this.knownChats.delete(chatId);
this.chatHandlers.delete(chatId);
this.pendingInboundByChat.delete(chatId);
+8 -2
View File
@@ -1,17 +1,23 @@
import type { ChatSummary } from "./types";
export const TEMPORARY_CHAT_ID_PREFIX = "temporary-";
export const TEMPORARY_CHAT_ROUTE_KEY = "__temporary_chat__";
const WEBSOCKET_SESSION_KEY_PREFIX = "websocket:";
export function isTemporaryChatId(value: string): boolean {
return value.startsWith(TEMPORARY_CHAT_ID_PREFIX);
}
export function temporaryChatIdFromSessionKey(value: string | null): string | null {
if (!value?.startsWith(WEBSOCKET_SESSION_KEY_PREFIX)) return null;
const chatId = value.slice(WEBSOCKET_SESSION_KEY_PREFIX.length);
return isTemporaryChatId(chatId) ? chatId : null;
}
export function createTemporaryChatSession(): ChatSummary {
const chatId = `${TEMPORARY_CHAT_ID_PREFIX}${crypto.randomUUID()}`;
const now = new Date().toISOString();
return {
key: `websocket:${chatId}`,
key: `${WEBSOCKET_SESSION_KEY_PREFIX}${chatId}`,
channel: "websocket",
chatId,
createdAt: now,
+210 -34
View File
@@ -3,7 +3,12 @@ import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "@/i18n";
import type { ChatSummary, SessionAutomationJob } from "@/lib/types";
import type {
ChatSummary,
ConnectionStatus,
SessionAutomationJob,
WorkspaceScopePayload,
} from "@/lib/types";
const connectSpy = vi.fn();
const refreshSpy = vi.fn();
@@ -15,8 +20,14 @@ const updateUrlSpy = vi.fn();
const attachSpy = vi.fn();
const setSidebarStateSpy = vi.fn();
const discardTemporaryChatSpy = vi.fn();
const sendMessageSpy = vi.fn();
const statusHandlers = new Set<(status: ConnectionStatus) => void>();
const runStatusHandlers = new Set<(chatId: string, startedAt: number | null) => void>();
const sessionUpdateHandlers = new Set<(chatId: string, scope?: string) => void>();
const sessionUpdateHandlers = new Set<(
chatId: string,
scope?: string,
workspaceScope?: WorkspaceScopePayload,
) => void>();
let mockSessions: ChatSummary[] = [];
const HERO_GREETING_PATTERN =
/What should we work on\?|Where should we start\?|What are we building today\?|What should we tackle together\?/;
@@ -198,16 +209,24 @@ vi.mock("@/lib/bootstrap", () => ({
clearSavedSecret: vi.fn(),
}));
vi.mock("@/lib/nanobot-client", () => {
vi.mock("@/lib/nanobot-client", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/nanobot-client")>();
class MockClient {
status = "idle" as const;
defaultChatId: string | null = null;
connect = connectSpy;
onStatus = () => () => {};
onStatus = (handler: (status: ConnectionStatus) => void) => {
statusHandlers.add(handler);
return () => statusHandlers.delete(handler);
};
onRuntimeModelUpdate = () => () => {};
onError = () => () => {};
onChat = () => () => {};
onSessionUpdate = (handler: (chatId: string, scope?: string) => void) => {
onSessionUpdate = (handler: (
chatId: string,
scope?: string,
workspaceScope?: WorkspaceScopePayload,
) => void) => {
sessionUpdateHandlers.add(handler);
return () => sessionUpdateHandlers.delete(handler);
};
@@ -217,7 +236,7 @@ vi.mock("@/lib/nanobot-client", () => {
};
getRunStartedAt = () => null;
getGoalState = () => undefined;
sendMessage = vi.fn();
sendMessage = sendMessageSpy;
newChat = vi.fn();
attach = attachSpy;
setSidebarState = setSidebarStateSpy;
@@ -227,7 +246,7 @@ vi.mock("@/lib/nanobot-client", () => {
updateMaxFrameBytes = vi.fn();
}
return { NanobotClient: MockClient };
return { ...actual, NanobotClient: MockClient };
});
import {
@@ -251,6 +270,8 @@ describe("App layout", () => {
attachSpy.mockReset();
setSidebarStateSpy.mockReset();
discardTemporaryChatSpy.mockReset();
sendMessageSpy.mockReset();
statusHandlers.clear();
runStatusHandlers.clear();
sessionUpdateHandlers.clear();
window.history.replaceState(null, "", "/");
@@ -387,54 +408,190 @@ describe("App layout", () => {
);
});
it("keeps a temporary chat while navigating and discards it on unmount", async () => {
it("creates a new temporary chat from the hero each time", async () => {
const { unmount } = render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
const temporaryButton = within(sidebar).getByRole("button", { name: "Temporary chat" });
expect(within(sidebar).queryByRole("button", { name: "Temporary chat" })).not.toBeInTheDocument();
const firstToggle = screen.getByRole("button", { name: "Temporary chat" });
expect(firstToggle).toHaveAttribute("aria-pressed", "false");
fireEvent.click(firstToggle);
expect(firstToggle).toHaveAttribute("aria-pressed", "true");
expect(window.location.hash).toBe("");
fireEvent.click(temporaryButton);
fireEvent.change(screen.getByLabelText("Message input"), {
target: { value: "first private message" },
});
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
expect(temporaryButton).toHaveAttribute("aria-current", "page");
expect(within(sidebar).getByTestId("actions-selection-highlight")).toHaveAttribute(
"data-active-id",
"temporary-chat",
);
expect(window.location.hash).toBe("#/temporary");
await waitFor(() => expect(window.location.hash).toMatch(/^#\/temporary\/temporary-/));
const firstHash = window.location.hash;
expect(firstHash).toMatch(/^#\/temporary\/temporary-/);
expect(screen.queryByRole("button", { name: "Temporary chat" })).not.toBeInTheDocument();
expect(createChatSpy).not.toHaveBeenCalled();
fireEvent.click(within(sidebar).getByRole("button", { name: "New topic" }));
expect(discardTemporaryChatSpy).not.toHaveBeenCalled();
const secondToggle = screen.getByRole("button", { name: "Temporary chat" });
expect(secondToggle).toHaveAttribute("aria-pressed", "false");
fireEvent.click(temporaryButton);
expect(window.location.hash).toBe("#/temporary");
expect(temporaryButton).toHaveAttribute("aria-current", "page");
fireEvent.click(secondToggle);
fireEvent.change(screen.getByLabelText("Message input"), {
target: { value: "second private message" },
});
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
await waitFor(() => expect(window.location.hash).toMatch(/^#\/temporary\/temporary-/));
const secondHash = window.location.hash;
expect(secondHash).toMatch(/^#\/temporary\/temporary-/);
expect(secondHash).not.toBe(firstHash);
expect(screen.queryByRole("button", { name: "Temporary chat" })).not.toBeInTheDocument();
expect(discardTemporaryChatSpy).not.toHaveBeenCalled();
expect(within(sidebar).getByText("Temporary chats")).toBeInTheDocument();
expect(within(sidebar).getByRole("button", {
name: "first private message",
})).toBeInTheDocument();
expect(within(sidebar).getByRole("button", {
name: "second private message",
})).toBeInTheDocument();
fireEvent.click(within(sidebar).getByRole("button", {
name: "first private message",
}));
await waitFor(() => expect(window.location.hash).toBe(firstHash));
expect(screen.getByText("Temporary chat")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Temporary chat" })).not.toBeInTheDocument();
fireEvent.pointerDown(within(sidebar).getByRole("button", {
name: "Topic actions for first private message",
}), { button: 0 });
fireEvent.click(await screen.findByRole("menuitem", { name: "Close temporary chat" }));
await waitFor(() => expect(window.location.hash).toBe(secondHash));
expect(within(sidebar).queryByRole("button", {
name: "first private message",
})).not.toBeInTheDocument();
expect(within(sidebar).getByRole("button", {
name: "second private message",
})).toBeInTheDocument();
expect(discardTemporaryChatSpy).toHaveBeenCalledTimes(1);
unmount();
await waitFor(() => expect(discardTemporaryChatSpy).toHaveBeenCalledOnce());
expect(discardTemporaryChatSpy.mock.calls[0][0]).toMatch(/^temporary-/);
await waitFor(() => expect(discardTemporaryChatSpy).toHaveBeenCalledTimes(2));
const discardedChatIds = discardTemporaryChatSpy.mock.calls.map(([chatId]) => chatId);
expect(new Set(discardedChatIds).size).toBe(2);
expect(discardedChatIds).toEqual([
expect.stringMatching(/^temporary-/),
expect.stringMatching(/^temporary-/),
]);
});
it("clears a temporary chat explicitly without leaving it", async () => {
it("shows the temporary-chat control only on the new-topic hero", async () => {
mockSessions = [{
key: "websocket:existing-chat",
channel: "websocket",
chatId: "existing-chat",
createdAt: "2026-08-06T10:00:00Z",
updatedAt: "2026-08-06T10:00:00Z",
preview: "Existing topic",
}];
render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
fireEvent.click(within(sidebar).getByRole("button", { name: "Temporary chat" }));
const heroHeader = screen.getByTestId("thread-header");
const heroTemporaryToggle = within(heroHeader).getByRole("button", {
name: "Temporary chat",
});
const themeToggle = within(heroHeader).getByRole("button", {
name: "Toggle theme from header",
});
expect(within(sidebar).queryByRole("button", { name: "Temporary chat" })).not.toBeInTheDocument();
expect(within(screen.getByTestId("thread-composer-motion")).queryByRole("button", {
name: "Temporary chat",
})).not.toBeInTheDocument();
expect(heroTemporaryToggle.compareDocumentPosition(themeToggle)
& Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
fireEvent.click(await screen.findByRole("button", { name: "Clear temporary chat" }));
fireEvent.click(within(sidebar).getByText("Existing topic"));
expect(window.location.hash).toBe("#/chat/websocket%3Aexisting-chat");
expect(screen.queryByRole("button", { name: "Temporary chat" })).not.toBeInTheDocument();
await waitFor(() => expect(discardTemporaryChatSpy).toHaveBeenCalledOnce());
expect(window.location.hash).toBe("#/temporary");
expect(within(sidebar).getByRole("button", { name: "Temporary chat" })).toHaveAttribute(
"aria-current",
"page",
fireEvent.click(within(sidebar).getByRole("button", { name: "New topic" }));
const temporaryToggle = screen.getByRole("button", { name: "Temporary chat" });
expect(temporaryToggle).toHaveClass("h-8", "w-8", "rounded-full");
expect(within(temporaryToggle).queryByText("Temporary chat")).not.toBeInTheDocument();
fireEvent.click(temporaryToggle);
expect(temporaryToggle).toHaveAttribute("aria-pressed", "true");
expect(temporaryToggle).toHaveClass("bg-transparent", "shadow-none", "hover:bg-transparent");
expect(within(temporaryToggle).getByTestId("temporary-chat-icon")).toHaveClass(
"motion-safe:duration-150",
"text-[var(--temporary-control-active)]",
);
expect(screen.queryByRole("tooltip")).not.toBeInTheDocument();
expect(screen.queryByTestId("temporary-chat-outline")).not.toBeInTheDocument();
fireEvent.click(temporaryToggle);
expect(temporaryToggle).toHaveAttribute("aria-pressed", "false");
expect(within(temporaryToggle).getByTestId("temporary-chat-icon")).toHaveClass(
"motion-safe:duration-75",
"text-current",
);
fireEvent.click(temporaryToggle);
expect(window.location.hash).toBe("#/new");
expect(temporaryToggle).toHaveAttribute("aria-pressed", "true");
fireEvent.change(screen.getByLabelText("Message input"), {
target: { value: "start temporary chat" },
});
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
await waitFor(() => expect(window.location.hash).toMatch(/^#\/temporary\/temporary-/));
expect(screen.queryByText("Not saved")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Clear temporary chat" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Temporary chat" })).not.toBeInTheDocument();
});
it("starts temporary chat with restricted on-demand workspace controls", async () => {
it("allows leaving a page with temporary chats without blocking", async () => {
render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
fireEvent.click(screen.getByRole("button", { name: "Temporary chat" }));
fireEvent.change(screen.getByLabelText("Message input"), {
target: { value: "do not lose this" },
});
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
await waitFor(() => expect(window.location.hash).toMatch(/^#\/temporary\/temporary-/));
const beforeUnload = new Event("beforeunload", { cancelable: true });
act(() => window.dispatchEvent(beforeUnload));
expect(beforeUnload.defaultPrevented).toBe(false);
});
it("ends temporary chats quietly after a connection interruption", async () => {
render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
act(() => {
statusHandlers.forEach((handler) => handler("open"));
});
fireEvent.click(screen.getByRole("button", { name: "Temporary chat" }));
fireEvent.change(screen.getByLabelText("Message input"), {
target: { value: "connection-sensitive message" },
});
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
await waitFor(() => expect(window.location.hash).toMatch(/^#\/temporary\/temporary-/));
act(() => {
statusHandlers.forEach((handler) => handler("reconnecting"));
});
await waitFor(() => expect(window.location.hash).toBe("#/new"));
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
expect(screen.queryByText("connection-sensitive message")).not.toBeInTheDocument();
});
it("uses the restricted default scope without offering project selection", async () => {
mockFetchRoutes({
"/api/settings": baseSettingsPayload(),
"/api/workspaces": {
schema_version: 1,
default_access_mode: "full",
@@ -450,11 +607,30 @@ describe("App layout", () => {
render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
fireEvent.click(within(sidebar).getByRole("button", { name: "Temporary chat" }));
expect(await screen.findByRole("button", { name: "Choose project" })).toBeInTheDocument();
act(() => {
sessionUpdateHandlers.forEach((handler) => handler("selected-chat", "metadata", {
project_path: "/tmp/selected-project",
project_name: "selected-project",
access_mode: "full",
restrict_to_workspace: false,
}));
});
fireEvent.click(screen.getByRole("button", { name: "Temporary chat" }));
expect(screen.queryByRole("button", { name: "Choose project" })).not.toBeInTheDocument();
expect(screen.queryByText("Full Access")).not.toBeInTheDocument();
fireEvent.change(screen.getByLabelText("Message input"), {
target: { value: "temporary project check" },
});
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
await waitFor(() => expect(sendMessageSpy).toHaveBeenCalled());
const options = sendMessageSpy.mock.calls.at(-1)?.[3];
expect(options?.workspaceScope).toMatchObject({
project_path: "/tmp/workspace",
access_mode: "restricted",
restrict_to_workspace: true,
});
});
it("restores the Settings route after a restart fallback hash", async () => {
+35
View File
@@ -152,6 +152,41 @@ describe("ChatList", () => {
expect(text.indexOf("Charlie")).toBeLessThan(text.indexOf("Alpha"));
});
it("shows temporary chats separately and lets the user reopen or close them", async () => {
const temporarySession = session({
key: "temporary:temporary-one",
chatId: "temporary-one",
preview: "Private planning",
});
const onSelect = vi.fn();
const onClose = vi.fn();
render(
<ChatList
sessions={[]}
temporarySessions={[temporarySession]}
activeKey={null}
onSelect={onSelect}
onCloseTemporaryChat={onClose}
onRequestDelete={vi.fn()}
onTogglePin={vi.fn()}
onRequestRename={vi.fn()}
onToggleArchive={vi.fn()}
/>,
);
const section = screen.getByRole("region", { name: "Temporary chats" });
fireEvent.click(within(section).getByRole("button", { name: "Private planning" }));
expect(onSelect).toHaveBeenCalledWith("temporary:temporary-one");
fireEvent.pointerDown(
within(section).getByRole("button", { name: "Topic actions for Private planning" }),
{ button: 0 },
);
fireEvent.click(await screen.findByRole("menuitem", { name: "Close temporary chat" }));
expect(onClose).toHaveBeenCalledWith("temporary:temporary-one");
});
it("orders chats by latest session activity by default", () => {
const sessions = [
session({
+19
View File
@@ -113,6 +113,25 @@ describe("MessageBubble", () => {
expect(screen.queryByRole("button", { name: "Fork" })).not.toBeInTheDocument();
});
it("outlines temporary-chat user messages with a short dashed border", () => {
const message: UIMessage = {
id: "u-temporary",
role: "user",
content: "private question",
createdAt: Date.now(),
};
const { rerender } = render(<MessageBubble message={message} temporary />);
const bubble = screen.getByText("private question");
expect(bubble).toHaveAttribute("data-temporary-message", "true");
expect(bubble).toHaveClass("border-dashed", "border-muted-foreground/40", "bg-transparent");
rerender(<MessageBubble message={message} />);
expect(bubble).not.toHaveClass("border-dashed");
expect(bubble).toHaveClass("bg-secondary/70");
});
it("does not replay an entrance animation when persisted messages mount", () => {
const messages: UIMessage[] = [
{
+42 -2
View File
@@ -101,22 +101,37 @@ describe("NanobotClient", () => {
});
});
it("forgets temporary chats when the socket drops", async () => {
it("forgets every temporary chat when the socket drops", async () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: true,
maxBackoffMs: 1,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
const firstHandler = vi.fn();
const secondHandler = vi.fn();
client.connect();
lastSocket().fakeOpen();
client.onChat("temporary-drop", vi.fn());
client.onChat("temporary-drop-a", firstHandler);
client.onChat("temporary-drop-b", secondHandler);
lastSocket().close();
await vi.advanceTimersByTimeAsync(1);
lastSocket().fakeOpen();
lastSocket().fakeMessage({
event: "message",
chat_id: "temporary-drop-a",
text: "stale first chat",
});
lastSocket().fakeMessage({
event: "message",
chat_id: "temporary-drop-b",
text: "stale second chat",
});
expect(lastSocket().sent).toEqual([]);
expect(firstHandler).not.toHaveBeenCalled();
expect(secondHandler).not.toHaveBeenCalled();
});
it("routes events to the matching chat handler", () => {
@@ -262,6 +277,31 @@ describe("NanobotClient", () => {
expect(client.getRunStartedAt("chat-strip")).toBeNull();
});
it("clears the local run strip immediately when a stop is requested", () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
const handler = vi.fn();
client.onRunStatus(handler);
client.connect();
lastSocket().fakeOpen();
lastSocket().fakeMessage({
event: "goal_status",
chat_id: "chat-stop",
status: "running",
started_at: 12_345,
turn_id: "turn-stop",
});
client.finishRunLocally("chat-stop");
expect(client.getRunStartedAt("chat-stop")).toBeNull();
expect(client.hasUnsettledRun("chat-stop")).toBe(false);
expect(handler).toHaveBeenLastCalledWith("chat-stop", null);
});
it("clears stale run strip when reconnecting after a dropped socket", async () => {
const client = new NanobotClient({
url: "ws://test",
+30 -33
View File
@@ -1070,56 +1070,53 @@ describe("ThreadComposer", () => {
}));
});
it("keeps temporary-chat workspace controls on demand", async () => {
const user = userEvent.setup();
const onWorkspaceScopeChange = vi.fn();
it("slides project controls closed without offering a compact replacement", () => {
const defaultScope = {
project_path: "/Users/test/.nanobot/workspace",
project_name: "workspace",
access_mode: "restricted" as const,
restrict_to_workspace: true,
access_mode: "full" as const,
restrict_to_workspace: false,
};
const { rerender } = render(
const composer = (workspaceControlsHidden: boolean) => (
<ThreadComposer
onSend={vi.fn()}
placeholder="Ask anything..."
variant="hero"
compactWorkspaceControls
workspaceControlsHidden={workspaceControlsHidden}
workspaceScope={defaultScope}
workspaceDefaultScope={defaultScope}
workspaceControls={{ can_change_project: true, can_use_full_access: true }}
onWorkspaceScopeChange={onWorkspaceScopeChange}
/>,
onWorkspaceScopeChange={vi.fn()}
/>
);
const { container, rerender } = render(composer(false));
const drawer = container.querySelector("[data-composer-workspace-drawer]");
expect(drawer).toHaveAttribute("data-state", "open");
expect(drawer).not.toHaveAttribute("aria-hidden");
expect(container.querySelector("[data-composer-workspace-compact]")).not.toBeInTheDocument();
rerender(composer(true));
expect(container.querySelector("[data-composer-workspace-drawer]")).toBe(drawer);
expect(drawer).toHaveAttribute("data-state", "closed");
expect(drawer).toHaveAttribute("aria-hidden", "true");
expect(within(drawer as HTMLElement).getByRole("button", {
hidden: true,
name: "Choose project",
})).toBeDisabled();
expect(screen.queryByRole("button", { name: "Choose project" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", {
name: "Workspace access mode: Default Permission",
name: "Workspace access mode: Full Access",
})).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Choose project" }));
const input = await screen.findByLabelText("Paste path");
fireEvent.change(input, { target: { value: "relative/project" } });
fireEvent.click(screen.getByRole("button", { name: "Use Path" }));
expect(screen.getByRole("alert")).toHaveTextContent(
"Enter an absolute folder path on this machine.",
);
rerender(
<ThreadComposer
onSend={vi.fn()}
placeholder="Ask anything..."
variant="hero"
compactWorkspaceControls
workspaceConnected
workspaceScope={defaultScope}
workspaceDefaultScope={defaultScope}
workspaceControls={{ can_change_project: true, can_use_full_access: true }}
onWorkspaceScopeChange={onWorkspaceScopeChange}
/>,
);
rerender(composer(false));
expect(screen.getByRole("button", {
name: "Workspace access mode: Default Permission",
})).toBeInTheDocument();
expect(container.querySelector("[data-composer-workspace-drawer]")).toBe(drawer);
expect(drawer).toHaveAttribute("data-state", "open");
expect(within(drawer as HTMLElement).getByRole("button", {
name: "Choose project",
})).toBeEnabled();
});
it("uses the native folder picker for project selection on native host", async () => {
+17 -6
View File
@@ -107,6 +107,10 @@ function makeClient() {
};
},
getRunStartedAt: (chatId: string) => runStartedAtByChatId.get(chatId) ?? null,
finishRunLocally: vi.fn((chatId: string) => {
runStartedAtByChatId.delete(chatId);
latestRunTurnIdByChatId.delete(chatId);
}),
hasUnsettledRun: () => false,
getRunGeneration: (chatId: string) => runGenerationByChatId.get(chatId) ?? 0,
canReconcileCanonicalCompletion,
@@ -850,16 +854,22 @@ describe("ThreadShell", () => {
it("keeps temporary messages across navigation and drops them after clear", async () => {
const client = makeClient();
const view = (chatId: string, temporary: boolean) => wrap(
const view = (
chatId: string,
temporary: boolean,
temporaryChatIds: readonly string[],
) => wrap(
client,
<ThreadShell
session={session(chatId)}
title={temporary ? "Temporary chat" : "Regular chat"}
temporary={temporary}
temporaryChatIds={temporaryChatIds}
onToggleSidebar={() => {}}
/>,
);
const { rerender } = render(view("temporary-live", true));
const retainedTemporaryChats = ["temporary-live"];
const { rerender } = render(view("temporary-live", true, retainedTemporaryChats));
fireEvent.change(screen.getByLabelText("Message input"), {
target: { value: "keep this only in memory" },
@@ -871,14 +881,14 @@ describe("ThreadShell", () => {
"keep this only in memory",
));
rerender(view("regular", false));
rerender(view("regular", false, retainedTemporaryChats));
await waitFor(() => {
expect(screen.queryByText("keep this only in memory")).not.toBeInTheDocument();
});
rerender(view("temporary-live", true));
rerender(view("temporary-live", true, retainedTemporaryChats));
expect(screen.getByText("keep this only in memory")).toBeInTheDocument();
rerender(view("temporary-cleared", true));
rerender(view("temporary-cleared", true, ["temporary-cleared"]));
await waitFor(() => {
expect(screen.queryByText("keep this only in memory")).not.toBeInTheDocument();
});
@@ -980,6 +990,7 @@ describe("ThreadShell", () => {
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
await waitFor(() => expect(onCreateChat).toHaveBeenCalledTimes(1));
expect(onCreateChat).toHaveBeenCalledWith(null, "start for real");
expect(onNewChat).not.toHaveBeenCalled();
});
@@ -1260,7 +1271,7 @@ describe("ThreadShell", () => {
const greeting = screen.getByRole("heading", { level: 1, name: HERO_GREETING_PATTERN });
expect(greeting).toHaveAttribute("data-testid", "hero-greeting");
expect(greeting).toHaveClass("whitespace-nowrap");
expect(greeting).toHaveClass("select-none", "whitespace-nowrap");
expect(screen.getByPlaceholderText("Ask anything...")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Write code" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Create a project plan" })).not.toBeInTheDocument();
@@ -76,6 +76,7 @@ function fakeClient() {
return () => set!.delete(h);
},
sendMessage: vi.fn(),
finishRunLocally: vi.fn(),
newChat: vi.fn(),
forkChat: vi.fn(),
attach: vi.fn(),
@@ -2247,6 +2248,7 @@ describe("useNanobotStream", () => {
});
expect(fake.client.sendMessage).toHaveBeenLastCalledWith("chat-stop", "/stop");
expect(fake.client.finishRunLocally).toHaveBeenCalledWith("chat-stop");
expect(result.current.isStreaming).toBe(false);
expect(result.current.messages).toHaveLength(1);
expect(result.current.messages[0].content).toBe("long task");