mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-07 21:08:34 +03:00
feat(webui): add temporary chat mode
This commit is contained in:
+94
-8
@@ -61,7 +61,12 @@ import {
|
||||
createRuntimeHost,
|
||||
toRuntimeSurface,
|
||||
} from "@/lib/runtime";
|
||||
import { projectNameFromPath } from "@/lib/workspace";
|
||||
import { projectNameFromPath, scopeWithAccessMode } from "@/lib/workspace";
|
||||
import {
|
||||
createTemporaryChatSession,
|
||||
isTemporaryChatId,
|
||||
TEMPORARY_CHAT_ROUTE_KEY,
|
||||
} from "@/lib/temporary-chat";
|
||||
|
||||
type BootState =
|
||||
| { status: "loading" }
|
||||
@@ -227,6 +232,13 @@ 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("/chat/")) {
|
||||
const encoded = path.slice("/chat/".length);
|
||||
try {
|
||||
@@ -243,6 +255,7 @@ function readShellRoute(): ShellRoute {
|
||||
|
||||
function shellRouteHash(route: ShellRoute): string {
|
||||
if (route.view === "chat") {
|
||||
if (route.activeKey === TEMPORARY_CHAT_ROUTE_KEY) return "#/temporary";
|
||||
return route.activeKey
|
||||
? `#/chat/${encodeURIComponent(route.activeKey)}`
|
||||
: "#/new";
|
||||
@@ -961,6 +974,7 @@ function Shell({
|
||||
initialRouteRef.current.activeKey,
|
||||
);
|
||||
const [view, setView] = useState<ShellView>(initialRouteRef.current.view);
|
||||
const [temporarySession, setTemporarySession] = useState<ChatSummary | null>(null);
|
||||
const [settingsInitialSection, setSettingsInitialSection] =
|
||||
useState<SettingsSectionKey>(initialRouteRef.current.settingsSection);
|
||||
const [hostSidebarOpen, setHostSidebarOpen] =
|
||||
@@ -1010,6 +1024,8 @@ function Shell({
|
||||
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 navigate = useCallback(
|
||||
(route: ShellRoute, options?: { replace?: boolean }) => {
|
||||
@@ -1036,6 +1052,17 @@ function Shell({
|
||||
return () => window.removeEventListener("hashchange", applyRoute);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (temporaryChatActive && !temporarySession) {
|
||||
setTemporarySession(createTemporaryChatSession());
|
||||
}
|
||||
}, [temporaryChatActive, temporarySession]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!temporaryChatId) return;
|
||||
return () => client.discardTemporaryChat(temporaryChatId);
|
||||
}, [client, temporaryChatId]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetchSettings(getToken())
|
||||
@@ -1121,8 +1148,9 @@ function Shell({
|
||||
|
||||
const activeSession = useMemo<ChatSummary | null>(() => {
|
||||
if (!activeKey) return null;
|
||||
if (activeKey === TEMPORARY_CHAT_ROUTE_KEY) return temporarySession;
|
||||
return sessions.find((s) => s.key === activeKey) ?? null;
|
||||
}, [sessions, activeKey]);
|
||||
}, [sessions, activeKey, temporarySession]);
|
||||
const runningChatIdList = useMemo(() => Array.from(runningChatIds), [runningChatIds]);
|
||||
const updatedChatIdList = useMemo(() => Array.from(updatedChatIds), [updatedChatIds]);
|
||||
const activeChatId = activeSession?.chatId ?? null;
|
||||
@@ -1137,6 +1165,12 @@ function Shell({
|
||||
});
|
||||
}, [activeChatId]);
|
||||
const activeWorkspaceScope = useMemo<WorkspaceScopePayload | null>(() => {
|
||||
if (temporaryChatActive) {
|
||||
if (temporarySession?.workspaceScope) return temporarySession.workspaceScope;
|
||||
return workspaces?.default_scope
|
||||
? normalizeWorkspaceScope(scopeWithAccessMode(workspaces.default_scope, "restricted"))
|
||||
: null;
|
||||
}
|
||||
if (activeChatId && workspaceOverrides[activeChatId]) {
|
||||
return workspaceOverrides[activeChatId];
|
||||
}
|
||||
@@ -1148,6 +1182,8 @@ function Shell({
|
||||
activeChatId,
|
||||
activeSession?.workspaceScope,
|
||||
draftWorkspaceScope,
|
||||
temporaryChatActive,
|
||||
temporarySession?.workspaceScope,
|
||||
workspaceOverrides,
|
||||
workspaces?.default_scope,
|
||||
]);
|
||||
@@ -1187,7 +1223,11 @@ function Shell({
|
||||
if (pendingCreatedKey && sessions.some((session) => session.key === pendingCreatedKey)) {
|
||||
pendingCreatedSessionKeyRef.current = null;
|
||||
}
|
||||
if (!activeKey || sessions.some((session) => session.key === activeKey)) return;
|
||||
if (
|
||||
!activeKey ||
|
||||
activeKey === TEMPORARY_CHAT_ROUTE_KEY ||
|
||||
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;
|
||||
@@ -1360,14 +1400,16 @@ function Shell({
|
||||
const next = normalizeWorkspaceScope(scope);
|
||||
setWorkspaceError(null);
|
||||
if (activeChatId) {
|
||||
if (!activeChatRunning) {
|
||||
if (temporaryChatActive) {
|
||||
setTemporarySession((current) => current ? { ...current, workspaceScope: next } : current);
|
||||
} else if (!activeChatRunning) {
|
||||
client.setWorkspaceScope(activeChatId, next);
|
||||
}
|
||||
return;
|
||||
}
|
||||
setDraftWorkspaceScope(next);
|
||||
},
|
||||
[activeChatId, activeChatRunning, client],
|
||||
[activeChatId, activeChatRunning, client, temporaryChatActive],
|
||||
);
|
||||
|
||||
const onCreateChat = useCallback(async (workspaceScope?: WorkspaceScopePayload | null) => {
|
||||
@@ -1433,6 +1475,25 @@ function Shell({
|
||||
setMobileSidebarOpen(false);
|
||||
}, [navigate]);
|
||||
|
||||
const onOpenTemporaryChat = useCallback(() => {
|
||||
if (temporaryChatActive) return;
|
||||
if (!temporarySession) setTemporarySession(createTemporaryChatSession());
|
||||
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]);
|
||||
|
||||
const onNewChatInProject = useCallback(
|
||||
(projectPath: string, projectName: string) => {
|
||||
const base = workspaces?.default_scope ?? activeWorkspaceScope;
|
||||
@@ -1760,6 +1821,7 @@ function Shell({
|
||||
nextRunning.delete(chatId);
|
||||
runningChatIdsRef.current = nextRunning;
|
||||
setRunningChatIds(nextRunning);
|
||||
if (isTemporaryChatId(chatId)) return;
|
||||
setUpdatedChatIds((current) => {
|
||||
const next = new Set(current);
|
||||
if (activeChatIdRef.current === chatId) {
|
||||
@@ -1772,6 +1834,20 @@ function Shell({
|
||||
});
|
||||
}, [client]);
|
||||
|
||||
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 });
|
||||
});
|
||||
}, [client, navigate, temporaryChatActive, temporaryChatId]);
|
||||
|
||||
useEffect(() => {
|
||||
return client.onStatus((status) => {
|
||||
const startedAt = (() => {
|
||||
@@ -1800,7 +1876,10 @@ function Shell({
|
||||
});
|
||||
}, [client, t]);
|
||||
|
||||
const onTurnEnd = useDeferredTitleRefresh(activeSession, refresh);
|
||||
const onTurnEnd = useDeferredTitleRefresh(
|
||||
temporaryChatActive ? null : activeSession,
|
||||
refresh,
|
||||
);
|
||||
|
||||
const onConfirmDelete = useCallback(async () => {
|
||||
if (!pendingDelete) return;
|
||||
@@ -1890,7 +1969,9 @@ function Shell({
|
||||
});
|
||||
}, []);
|
||||
|
||||
const headerTitle = activeSession
|
||||
const headerTitle = temporaryChatActive
|
||||
? t("temporaryChat.title")
|
||||
: activeSession
|
||||
? sidebarState.title_overrides[activeSession.key] ||
|
||||
activeSession.title ||
|
||||
deriveTitle(activeSession.preview, t("chat.newChat"))
|
||||
@@ -1931,7 +2012,9 @@ function Shell({
|
||||
activeKey: view === "chat" ? activeKey : null,
|
||||
loading,
|
||||
newChatActive: view === "chat" && activeKey === null,
|
||||
temporaryChatActive,
|
||||
onNewChat,
|
||||
onOpenTemporaryChat,
|
||||
onSelect: onSelectChat,
|
||||
onRequestDelete,
|
||||
onTogglePin,
|
||||
@@ -2118,10 +2201,13 @@ function Shell({
|
||||
session={activeSession}
|
||||
sessions={sessions}
|
||||
title={headerTitle}
|
||||
temporary={temporaryChatActive}
|
||||
onClearTemporaryChat={onClearTemporaryChat}
|
||||
workspaceConnected={!!temporarySession?.workspaceScope}
|
||||
onToggleSidebar={toggleSidebar}
|
||||
onNewChat={onNewChat}
|
||||
onCreateChat={onCreateChat}
|
||||
onForkChat={onForkChat}
|
||||
onForkChat={temporaryChatActive ? undefined : onForkChat}
|
||||
onTurnEnd={onTurnEnd}
|
||||
theme={theme}
|
||||
onToggleTheme={toggle}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Archive,
|
||||
Brain,
|
||||
CalendarClock,
|
||||
MessageCircleDashed,
|
||||
Menu,
|
||||
Search,
|
||||
Settings,
|
||||
@@ -34,7 +35,9 @@ interface SidebarProps {
|
||||
activeKey: string | null;
|
||||
loading: boolean;
|
||||
newChatActive: boolean;
|
||||
temporaryChatActive: boolean;
|
||||
onNewChat: () => void;
|
||||
onOpenTemporaryChat: () => void;
|
||||
onSelect: (key: string) => void;
|
||||
onRequestDelete: (key: string, label: string) => void;
|
||||
onTogglePin: (key: string) => void;
|
||||
@@ -95,8 +98,10 @@ export function Sidebar(props: SidebarProps) {
|
||||
const toggleLabel = t("thread.header.toggleSidebar");
|
||||
const newChatShortcut = newChatShortcutLabel();
|
||||
const activeActionRef = useRef<HTMLButtonElement>(null);
|
||||
const activeActionId = props.newChatActive
|
||||
? "new-chat"
|
||||
const activeActionId = props.temporaryChatActive
|
||||
? "temporary-chat"
|
||||
: props.newChatActive
|
||||
? "new-chat"
|
||||
: props.activeUtility
|
||||
? `utility:${props.activeUtility}`
|
||||
: null;
|
||||
@@ -170,6 +175,14 @@ 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")}
|
||||
|
||||
@@ -84,6 +84,7 @@ import { useMediaQuery } from "@/hooks/useMediaQuery";
|
||||
import type { SendAttachment, SendOptions } from "@/hooks/useNanobotStream";
|
||||
import { usePageVisibility } from "@/hooks/usePageVisibility";
|
||||
import { useVoiceRecorder, type VoiceRecorderErrorKey } from "@/hooks/useVoiceRecorder";
|
||||
import { isTemporaryChatId } from "@/lib/temporary-chat";
|
||||
import type {
|
||||
CliAppInfo,
|
||||
ChatSummary,
|
||||
@@ -205,6 +206,8 @@ interface ThreadComposerProps {
|
||||
/** Sustained objective for this chat (WebSocket ``goal_state``). */
|
||||
goalState?: GoalStateWsPayload;
|
||||
workspaceScope?: WorkspaceScopePayload | null;
|
||||
compactWorkspaceControls?: boolean;
|
||||
workspaceConnected?: boolean;
|
||||
workspaceDefaultScope?: WorkspaceScopePayload | null;
|
||||
workspaceControls?: WorkspacesPayload["controls"] | null;
|
||||
workspaceScopeDisabled?: boolean;
|
||||
@@ -440,7 +443,9 @@ function storeSlashRecents(commands: string[]): void {
|
||||
|
||||
function queuedPromptsStorageKey(key?: string | null): string | null {
|
||||
const clean = key?.trim();
|
||||
return clean ? `${QUEUED_PROMPTS_STORAGE_PREFIX}${clean}` : null;
|
||||
return clean && !isTemporaryChatId(clean)
|
||||
? `${QUEUED_PROMPTS_STORAGE_PREFIX}${clean}`
|
||||
: null;
|
||||
}
|
||||
|
||||
function normalizeQueuedSessionMentions(value: unknown): SessionMention[] {
|
||||
@@ -955,6 +960,8 @@ export function ThreadComposer({
|
||||
runStartedAt = null,
|
||||
goalState,
|
||||
workspaceScope = null,
|
||||
compactWorkspaceControls = false,
|
||||
workspaceConnected = false,
|
||||
workspaceDefaultScope = null,
|
||||
workspaceControls = null,
|
||||
workspaceScopeDisabled = false,
|
||||
@@ -1005,17 +1012,18 @@ export function ThreadComposer({
|
||||
() => queuedPromptsStorageKey(pendingQueueKey),
|
||||
[pendingQueueKey],
|
||||
);
|
||||
const showProjectPicker =
|
||||
const projectPickerAvailable =
|
||||
isHero
|
||||
&& !!workspaceDefaultScope
|
||||
&& !!onWorkspaceScopeChange
|
||||
&& workspaceControls?.can_change_project !== false;
|
||||
const showProjectPicker = projectPickerAvailable && !compactWorkspaceControls;
|
||||
|
||||
useEffect(() => {
|
||||
secondEnterPromptIdRef.current = null;
|
||||
skipQueuedPromptPersistRef.current = true;
|
||||
setQueuedPrompts(queuedPromptStorageKey ? readQueuedPrompts(queuedPromptStorageKey) : []);
|
||||
}, [queuedPromptStorageKey]);
|
||||
}, [pendingQueueKey, queuedPromptStorageKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!queuedPromptStorageKey) return;
|
||||
@@ -2425,6 +2433,19 @@ 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}
|
||||
@@ -2433,7 +2454,7 @@ export function ThreadComposer({
|
||||
isHero={isHero}
|
||||
levels={voiceRecorder.levels}
|
||||
/>
|
||||
) : workspaceScope ? (
|
||||
) : workspaceScope && (!compactWorkspaceControls || workspaceConnected) ? (
|
||||
<WorkspaceAccessMenu
|
||||
scope={workspaceScope}
|
||||
disabled={disabled || workspaceScopeDisabled}
|
||||
@@ -2544,15 +2565,17 @@ export function ThreadComposer({
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<WorkspaceProjectPicker
|
||||
isHero={isHero}
|
||||
disabled={disabled || workspaceScopeDisabled}
|
||||
scope={workspaceScope}
|
||||
defaultScope={workspaceDefaultScope}
|
||||
controls={workspaceControls}
|
||||
error={workspaceError}
|
||||
onChange={onWorkspaceScopeChange}
|
||||
/>
|
||||
{showProjectPicker ? (
|
||||
<WorkspaceProjectPicker
|
||||
isHero={isHero}
|
||||
disabled={disabled || workspaceScopeDisabled}
|
||||
scope={workspaceScope}
|
||||
defaultScope={workspaceDefaultScope}
|
||||
controls={workspaceControls}
|
||||
error={workspaceError}
|
||||
onChange={onWorkspaceScopeChange}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
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";
|
||||
@@ -295,6 +297,8 @@ interface ThreadShellProps {
|
||||
session: ChatSummary | null;
|
||||
sessions?: ChatSummary[];
|
||||
title: string;
|
||||
temporary?: boolean;
|
||||
onClearTemporaryChat?: () => void;
|
||||
onToggleSidebar: () => void;
|
||||
onGoHome?: () => void;
|
||||
onNewChat?: () => void;
|
||||
@@ -308,6 +312,7 @@ interface ThreadShellProps {
|
||||
hideThemeButton?: boolean;
|
||||
hideHeader?: boolean;
|
||||
workspaceScope?: WorkspaceScopePayload | null;
|
||||
workspaceConnected?: boolean;
|
||||
workspaceDefaultScope?: WorkspaceScopePayload | null;
|
||||
workspaceControls?: WorkspacesPayload["controls"] | null;
|
||||
workspaceScopeDisabled?: boolean;
|
||||
@@ -580,6 +585,8 @@ export function ThreadShell({
|
||||
session,
|
||||
sessions = [],
|
||||
title,
|
||||
temporary = false,
|
||||
onClearTemporaryChat,
|
||||
onToggleSidebar,
|
||||
onCreateChat,
|
||||
onForkChat,
|
||||
@@ -591,6 +598,7 @@ export function ThreadShell({
|
||||
hideThemeButton = false,
|
||||
hideHeader = false,
|
||||
workspaceScope = null,
|
||||
workspaceConnected = false,
|
||||
workspaceDefaultScope = null,
|
||||
workspaceControls = null,
|
||||
workspaceScopeDisabled = false,
|
||||
@@ -602,7 +610,7 @@ export function ThreadShell({
|
||||
}: ThreadShellProps) {
|
||||
const { t } = useTranslation();
|
||||
const chatId = session?.chatId ?? null;
|
||||
const historyKey = session?.key ?? null;
|
||||
const historyKey = temporary ? null : session?.key ?? null;
|
||||
const mentionSessions = useMemo(
|
||||
() => sessions.filter((candidate) => (
|
||||
candidate.key !== historyKey
|
||||
@@ -664,6 +672,7 @@ 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). */
|
||||
@@ -736,6 +745,15 @@ export function ThreadShell({
|
||||
setSubmittedViewportTurnId(null);
|
||||
}, [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 handleQuoteSelection = useCallback((text: string) => {
|
||||
setQuotedContext(text);
|
||||
setComposerFocusSignal((value) => value + 1);
|
||||
@@ -838,6 +856,12 @@ export function ThreadShell({
|
||||
() => modelPresetOptionsFromSettings(settings),
|
||||
[settings],
|
||||
);
|
||||
const availableSlashCommands = useMemo(
|
||||
() => temporary
|
||||
? slashCommands.filter(({ command }) => command === "/model" || command === "/stop")
|
||||
: slashCommands,
|
||||
[slashCommands, temporary],
|
||||
);
|
||||
const modelBadge = useMemo(
|
||||
() => toModelBadgeInfo(modelName, settings, activeModelPreset),
|
||||
[activeModelPreset, modelName, settings],
|
||||
@@ -898,7 +922,7 @@ export function ThreadShell({
|
||||
}, [chatId, client]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!chatId || loading) return;
|
||||
if (!historyKey || !chatId || loading) return;
|
||||
const cached = messageCacheRef.current.get(chatId);
|
||||
const pendingCanonicalHydrate = pendingCanonicalHydrateRef.current.get(chatId);
|
||||
const hasNewCanonicalHistory = (
|
||||
@@ -1028,10 +1052,11 @@ export function ThreadShell({
|
||||
historyLineage,
|
||||
historyActiveTurnId,
|
||||
hasPendingToolCalls,
|
||||
historyKey,
|
||||
]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!chatId) return;
|
||||
if (!historyKey || !chatId) return;
|
||||
const commit = pendingCanonicalCommitRef.current.get(chatId);
|
||||
if (!commit) return;
|
||||
if (
|
||||
@@ -1069,17 +1094,17 @@ export function ThreadShell({
|
||||
pendingCanonicalCommitRef.current.delete(chatId);
|
||||
committedHistoryLineageRef.current.set(chatId, historyLineage);
|
||||
completedCanonicalHydrateVersionRef.current.set(chatId, historyVersion);
|
||||
}, [chatId, client, historyLineage, historyVersion, messages, setMessages]);
|
||||
}, [chatId, client, historyKey, historyLineage, historyVersion, messages, setMessages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!chatId || hasPendingToolCalls) return;
|
||||
if (!historyKey || !chatId || hasPendingToolCalls) return;
|
||||
if (completedCanonicalHydrateVersionRef.current.get(chatId) !== historyVersion) return;
|
||||
completedCanonicalHydrateVersionRef.current.delete(chatId);
|
||||
reconcileTurnComplete();
|
||||
}, [chatId, hasPendingToolCalls, historyVersion, messages, reconcileTurnComplete]);
|
||||
}, [chatId, hasPendingToolCalls, historyKey, historyVersion, messages, reconcileTurnComplete]);
|
||||
|
||||
const refreshCanonicalHistory = useCallback(() => {
|
||||
if (!chatId) return;
|
||||
if (!historyKey || !chatId) return;
|
||||
pendingCanonicalHydrateRef.current.set(chatId, {
|
||||
historyLineage,
|
||||
historyVersion,
|
||||
@@ -1089,10 +1114,10 @@ export function ThreadShell({
|
||||
uiRevision: uiRevisionRef.current,
|
||||
});
|
||||
refreshHistory();
|
||||
}, [chatId, client, historyLineage, historyVersion, refreshHistory]);
|
||||
}, [chatId, client, historyKey, historyLineage, historyVersion, refreshHistory]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!chatId) return;
|
||||
if (!historyKey || !chatId) return;
|
||||
return client.onSessionUpdate((updatedChatId, scope) => {
|
||||
if (updatedChatId !== chatId) return;
|
||||
if (scope === "metadata") return;
|
||||
@@ -1101,7 +1126,7 @@ export function ThreadShell({
|
||||
// so keep an active programmatic follow alive across canonical hydration.
|
||||
refreshCanonicalHistory();
|
||||
});
|
||||
}, [chatId, client, refreshCanonicalHistory]);
|
||||
}, [chatId, client, historyKey, refreshCanonicalHistory]);
|
||||
|
||||
const wasPageHiddenRef = useRef(document.visibilityState === "hidden");
|
||||
useEffect(() => {
|
||||
@@ -1112,7 +1137,7 @@ export function ThreadShell({
|
||||
}
|
||||
if (!wasPageHiddenRef.current) return;
|
||||
wasPageHiddenRef.current = false;
|
||||
if (!chatId || client.status !== "open" || loading) return;
|
||||
if (!historyKey || !chatId || client.status !== "open" || loading) return;
|
||||
if (
|
||||
!turnActive
|
||||
&& !hasPendingToolCalls
|
||||
@@ -1129,6 +1154,7 @@ export function ThreadShell({
|
||||
chatId,
|
||||
client,
|
||||
hasPendingToolCalls,
|
||||
historyKey,
|
||||
historyError,
|
||||
loading,
|
||||
refreshCanonicalHistory,
|
||||
@@ -1386,7 +1412,7 @@ export function ThreadShell({
|
||||
fallbackModelName={fallbackModelName}
|
||||
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
|
||||
variant={showHeroComposer ? "hero" : "thread"}
|
||||
slashCommands={slashCommands}
|
||||
slashCommands={availableSlashCommands}
|
||||
cliApps={cliApps}
|
||||
mcpPresets={mcpPresets}
|
||||
sessions={mentionSessions}
|
||||
@@ -1396,6 +1422,8 @@ export function ThreadShell({
|
||||
runStartedAt={currentRunStartedAt}
|
||||
goalState={currentGoalState}
|
||||
workspaceScope={workspaceScope}
|
||||
compactWorkspaceControls={temporary}
|
||||
workspaceConnected={workspaceConnected}
|
||||
workspaceDefaultScope={workspaceDefaultScope}
|
||||
workspaceControls={workspaceControls}
|
||||
workspaceScopeDisabled={workspaceScopeDisabled}
|
||||
@@ -1429,7 +1457,7 @@ export function ThreadShell({
|
||||
fallbackModelName={fallbackModelName}
|
||||
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
|
||||
variant="hero"
|
||||
slashCommands={slashCommands}
|
||||
slashCommands={availableSlashCommands}
|
||||
cliApps={cliApps}
|
||||
mcpPresets={mcpPresets}
|
||||
sessions={mentionSessions}
|
||||
@@ -1438,6 +1466,8 @@ export function ThreadShell({
|
||||
onTranscribeAudio={transcribeAudio}
|
||||
goalState={currentGoalState}
|
||||
workspaceScope={workspaceScope}
|
||||
compactWorkspaceControls={temporary}
|
||||
workspaceConnected={workspaceConnected}
|
||||
workspaceDefaultScope={workspaceDefaultScope}
|
||||
workspaceControls={workspaceControls}
|
||||
workspaceScopeDisabled={workspaceScopeDisabled}
|
||||
@@ -1461,6 +1491,29 @@ 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
|
||||
@@ -1502,7 +1555,7 @@ export function ThreadShell({
|
||||
showScrollToBottomButton={!!session}
|
||||
cliApps={cliApps}
|
||||
mcpPresets={mcpPresets}
|
||||
slashCommands={slashCommands}
|
||||
slashCommands={availableSlashCommands}
|
||||
forkBoundaryMessageCount={forkBoundaryMessageCount}
|
||||
hasMoreBefore={hasMoreBefore}
|
||||
loadingOlder={loadingOlder}
|
||||
|
||||
@@ -36,6 +36,8 @@ import {
|
||||
|
||||
export function WorkspaceProjectPicker({
|
||||
isHero,
|
||||
compact = false,
|
||||
connected = false,
|
||||
disabled,
|
||||
scope,
|
||||
defaultScope,
|
||||
@@ -44,6 +46,8 @@ export function WorkspaceProjectPicker({
|
||||
onChange,
|
||||
}: {
|
||||
isHero: boolean;
|
||||
compact?: boolean;
|
||||
connected?: boolean;
|
||||
disabled?: boolean;
|
||||
scope: WorkspaceScopePayload | null;
|
||||
defaultScope: WorkspaceScopePayload | null;
|
||||
@@ -115,7 +119,11 @@ export function WorkspaceProjectPicker({
|
||||
|
||||
if (nativeProjectPicker) {
|
||||
return (
|
||||
<div className="flex min-w-0 items-center rounded-b-[28px] bg-muted/45 px-3 py-1.5 dark:bg-white/[0.045] sm:px-4">
|
||||
<div className={cn(
|
||||
compact
|
||||
? "inline-flex"
|
||||
: "flex min-w-0 items-center rounded-b-[28px] bg-muted/45 px-3 py-1.5 dark:bg-white/[0.045] sm:px-4",
|
||||
)}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled || pickingFolder}
|
||||
@@ -123,16 +131,18 @@ export function WorkspaceProjectPicker({
|
||||
title={currentProjectScope?.project_path}
|
||||
onClick={() => void pickNativeFolder()}
|
||||
className={cn(
|
||||
"inline-flex h-7 max-w-full items-center gap-2 rounded-full px-2.5 sm:max-w-[18rem]",
|
||||
"text-[12px] font-medium text-muted-foreground/90 transition-colors",
|
||||
"hover:bg-background/70 hover:text-foreground disabled:pointer-events-none disabled:opacity-55",
|
||||
currentProjectScope && "text-foreground/82",
|
||||
compact
|
||||
? "thread-composer-action touch-target inline-flex h-8 w-8 items-center justify-center rounded-full border border-transparent"
|
||||
: "inline-flex h-7 max-w-full items-center gap-2 rounded-full px-2.5 sm:max-w-[18rem]",
|
||||
"text-[12px] font-medium text-muted-foreground/90 transition-colors hover:text-foreground disabled:pointer-events-none disabled:opacity-55",
|
||||
compact ? "hover:bg-muted/65" : "hover:bg-background/70",
|
||||
(connected || currentProjectScope) && "text-primary",
|
||||
)}
|
||||
>
|
||||
<Folder className={cn("h-3.5 w-3.5 shrink-0", currentProjectScope && "text-primary")} />
|
||||
<span className="truncate">{projectLabel}</span>
|
||||
<Folder className={cn("shrink-0", compact ? "h-4 w-4" : "h-3.5 w-3.5")} />
|
||||
<span className={compact ? "sr-only" : "truncate"}>{projectLabel}</span>
|
||||
</button>
|
||||
{pathError || error ? (
|
||||
{!compact && (pathError || error) ? (
|
||||
<span role="alert" className="ml-2 min-w-0 truncate text-[11.5px] font-medium text-destructive">
|
||||
{pathError ?? error}
|
||||
</span>
|
||||
@@ -142,7 +152,11 @@ export function WorkspaceProjectPicker({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 items-center rounded-b-[28px] bg-muted/45 px-3 py-1.5 dark:bg-white/[0.045] sm:px-4">
|
||||
<div className={cn(
|
||||
compact
|
||||
? "inline-flex"
|
||||
: "flex min-w-0 items-center rounded-b-[28px] bg-muted/45 px-3 py-1.5 dark:bg-white/[0.045] sm:px-4",
|
||||
)}>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
@@ -150,15 +164,19 @@ export function WorkspaceProjectPicker({
|
||||
disabled={disabled}
|
||||
aria-label={t("thread.composer.workspace.projectAria")}
|
||||
className={cn(
|
||||
"inline-flex h-7 max-w-full items-center gap-2 rounded-full px-2.5 sm:max-w-[18rem]",
|
||||
"text-[12px] font-medium text-muted-foreground/90 transition-colors",
|
||||
"hover:bg-background/70 hover:text-foreground disabled:pointer-events-none disabled:opacity-55",
|
||||
currentProjectScope && "text-foreground/82",
|
||||
compact
|
||||
? "thread-composer-action touch-target inline-flex h-8 w-8 items-center justify-center rounded-full border border-transparent"
|
||||
: "inline-flex h-7 max-w-full items-center gap-2 rounded-full px-2.5 sm:max-w-[18rem]",
|
||||
"text-[12px] font-medium text-muted-foreground/90 transition-colors hover:text-foreground disabled:pointer-events-none disabled:opacity-55",
|
||||
compact ? "hover:bg-muted/65" : "hover:bg-background/70",
|
||||
(connected || currentProjectScope) && "text-primary",
|
||||
)}
|
||||
>
|
||||
<Folder className={cn("h-3.5 w-3.5 shrink-0", currentProjectScope && "text-primary")} />
|
||||
<span className="truncate">{projectLabel}</span>
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<Folder className={cn("shrink-0", compact ? "h-4 w-4" : "h-3.5 w-3.5")} />
|
||||
<span className={compact ? "sr-only" : "truncate"}>{projectLabel}</span>
|
||||
{!compact ? (
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
) : null}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
|
||||
@@ -49,6 +49,12 @@
|
||||
"noMatch": "No pending request matches this code."
|
||||
}
|
||||
},
|
||||
"temporaryChat": {
|
||||
"title": "Temporary chat",
|
||||
"description": "Not saved to history or memory. Requests still go to your model provider, and tool actions may leave changes.",
|
||||
"notSaved": "Not saved",
|
||||
"clear": "Clear temporary chat"
|
||||
},
|
||||
"sidebar": {
|
||||
"navigation": "Sidebar navigation",
|
||||
"collapse": "Collapse sidebar",
|
||||
|
||||
@@ -49,6 +49,12 @@
|
||||
"noMatch": "No hay ninguna solicitud pendiente que coincida con este código."
|
||||
}
|
||||
},
|
||||
"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.",
|
||||
"notSaved": "No se guarda",
|
||||
"clear": "Borrar chat temporal"
|
||||
},
|
||||
"sidebar": {
|
||||
"navigation": "Navegación de la barra lateral",
|
||||
"collapse": "Contraer barra lateral",
|
||||
|
||||
@@ -49,6 +49,12 @@
|
||||
"noMatch": "Aucune demande en attente ne correspond à ce code."
|
||||
}
|
||||
},
|
||||
"temporaryChat": {
|
||||
"title": "Discussion temporaire",
|
||||
"description": "Elle n’est enregistrée ni dans l’historique 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.",
|
||||
"notSaved": "Non enregistrée",
|
||||
"clear": "Effacer la discussion temporaire"
|
||||
},
|
||||
"sidebar": {
|
||||
"navigation": "Navigation de la barre latérale",
|
||||
"collapse": "Réduire la barre latérale",
|
||||
|
||||
@@ -49,6 +49,12 @@
|
||||
"noMatch": "Tidak ada permintaan tertunda yang cocok dengan kode ini."
|
||||
}
|
||||
},
|
||||
"temporaryChat": {
|
||||
"title": "Obrolan sementara",
|
||||
"description": "Tidak disimpan ke riwayat atau memori. Permintaan tetap dikirim ke penyedia model dan tindakan alat dapat meninggalkan perubahan.",
|
||||
"notSaved": "Tidak disimpan",
|
||||
"clear": "Hapus obrolan sementara"
|
||||
},
|
||||
"sidebar": {
|
||||
"navigation": "Navigasi bilah samping",
|
||||
"collapse": "Ciutkan sidebar",
|
||||
|
||||
@@ -49,6 +49,12 @@
|
||||
"noMatch": "このコードに一致する保留中のリクエストはありません。"
|
||||
}
|
||||
},
|
||||
"temporaryChat": {
|
||||
"title": "一時チャット",
|
||||
"description": "履歴やメモリには保存されません。リクエストは引き続きモデルプロバイダーに送信され、ツール操作による変更は残る場合があります。",
|
||||
"notSaved": "保存されません",
|
||||
"clear": "一時チャットを消去"
|
||||
},
|
||||
"sidebar": {
|
||||
"navigation": "サイドバーのナビゲーション",
|
||||
"collapse": "サイドバーを閉じる",
|
||||
|
||||
@@ -49,6 +49,12 @@
|
||||
"noMatch": "이 코드와 일치하는 대기 중인 요청이 없습니다."
|
||||
}
|
||||
},
|
||||
"temporaryChat": {
|
||||
"title": "임시 채팅",
|
||||
"description": "기록이나 메모리에 저장되지 않습니다. 요청은 계속 모델 제공업체로 전송되며 도구 작업의 변경 사항은 남을 수 있습니다.",
|
||||
"notSaved": "저장 안 함",
|
||||
"clear": "임시 채팅 지우기"
|
||||
},
|
||||
"sidebar": {
|
||||
"navigation": "사이드바 탐색",
|
||||
"collapse": "사이드바 접기",
|
||||
|
||||
@@ -49,6 +49,12 @@
|
||||
"noMatch": "Nenhuma solicitação pendente corresponde a este código."
|
||||
}
|
||||
},
|
||||
"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.",
|
||||
"notSaved": "Não salvo",
|
||||
"clear": "Limpar chat temporário"
|
||||
},
|
||||
"sidebar": {
|
||||
"navigation": "Navegação da barra lateral",
|
||||
"collapse": "Recolher barra lateral",
|
||||
|
||||
@@ -49,6 +49,12 @@
|
||||
"noMatch": "Không có yêu cầu đang chờ nào khớp với mã này."
|
||||
}
|
||||
},
|
||||
"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.",
|
||||
"notSaved": "Không lưu",
|
||||
"clear": "Xóa trò chuyện tạm thời"
|
||||
},
|
||||
"sidebar": {
|
||||
"navigation": "Điều hướng thanh bên",
|
||||
"collapse": "Thu gọn thanh bên",
|
||||
|
||||
@@ -49,6 +49,12 @@
|
||||
"noMatch": "没有待处理请求与此配对码匹配。"
|
||||
}
|
||||
},
|
||||
"temporaryChat": {
|
||||
"title": "临时聊天",
|
||||
"description": "不会保存到历史记录或记忆。请求仍会发送给模型提供商,工具操作也可能留下更改。",
|
||||
"notSaved": "不保存",
|
||||
"clear": "清空临时聊天"
|
||||
},
|
||||
"sidebar": {
|
||||
"navigation": "侧边栏导航",
|
||||
"collapse": "收起侧边栏",
|
||||
|
||||
@@ -49,6 +49,12 @@
|
||||
"noMatch": "沒有待處理請求符合此配對碼。"
|
||||
}
|
||||
},
|
||||
"temporaryChat": {
|
||||
"title": "臨時聊天",
|
||||
"description": "不會儲存至歷史記錄或記憶。請求仍會傳送給模型供應商,工具操作也可能留下變更。",
|
||||
"notSaved": "不儲存",
|
||||
"clear": "清空臨時聊天"
|
||||
},
|
||||
"sidebar": {
|
||||
"navigation": "側邊欄導覽",
|
||||
"collapse": "收合側邊欄",
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
WorkspaceScopePayload,
|
||||
} from "./types";
|
||||
import { createHostWebSocket } from "./runtime";
|
||||
import { isTemporaryChatId } from "./temporary-chat";
|
||||
|
||||
/** WebSocket readyState constants, referenced by value to stay portable
|
||||
* across runtimes that don't expose a global ``WebSocket`` (tests, SSR). */
|
||||
@@ -173,6 +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;
|
||||
/** 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. */
|
||||
@@ -725,9 +728,18 @@ export class NanobotClient {
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
this.clearTemporaryChats();
|
||||
this.setStatus("closed");
|
||||
}
|
||||
|
||||
discardTemporaryChat(chatId: string): void {
|
||||
if (!isTemporaryChatId(chatId)) return;
|
||||
if (this.socket?.readyState === WS_OPEN) {
|
||||
this.rawSend({ type: "discard_temporary_chat", chat_id: chatId });
|
||||
}
|
||||
this.forgetTemporaryChat(chatId);
|
||||
}
|
||||
|
||||
/** Ask the server to provision a new chat_id; resolves with the assigned id. */
|
||||
newChat(timeoutMs: number = 5_000, workspaceScope?: WorkspaceScopePayload | null): Promise<string> {
|
||||
if (this.pendingNewChat) {
|
||||
@@ -793,6 +805,10 @@ export class NanobotClient {
|
||||
}
|
||||
|
||||
attach(chatId: string): void {
|
||||
if (isTemporaryChatId(chatId)) {
|
||||
this.temporaryChatId = chatId;
|
||||
return;
|
||||
}
|
||||
this.knownChats.add(chatId);
|
||||
if (this.socket?.readyState === WS_OPEN) {
|
||||
this.queueSend({ type: "attach", chat_id: chatId });
|
||||
@@ -814,7 +830,9 @@ export class NanobotClient {
|
||||
startsNewRun?: boolean;
|
||||
},
|
||||
): void {
|
||||
this.knownChats.add(chatId);
|
||||
const temporary = isTemporaryChatId(chatId);
|
||||
if (temporary) this.temporaryChatId = chatId;
|
||||
if (!temporary) this.knownChats.add(chatId);
|
||||
const frame: Outbound = {
|
||||
type: "message",
|
||||
chat_id: chatId,
|
||||
@@ -863,6 +881,7 @@ export class NanobotClient {
|
||||
}
|
||||
|
||||
setWorkspaceScope(chatId: string, workspaceScope: WorkspaceScopePayload): void {
|
||||
if (isTemporaryChatId(chatId)) return;
|
||||
this.knownChats.add(chatId);
|
||||
this.queueSend({
|
||||
type: "set_workspace_scope",
|
||||
@@ -1094,6 +1113,7 @@ export class NanobotClient {
|
||||
|
||||
private handleClose(event?: { code?: number }): void {
|
||||
this.socket = null;
|
||||
this.clearTemporaryChats();
|
||||
if (this.pendingNewChat) {
|
||||
clearTimeout(this.pendingNewChat.timer);
|
||||
this.pendingNewChat.reject(new Error("socket closed"));
|
||||
@@ -1240,6 +1260,38 @@ export class NanobotClient {
|
||||
}
|
||||
}
|
||||
|
||||
private clearTemporaryChats(): void {
|
||||
if (this.temporaryChatId) this.forgetTemporaryChat(this.temporaryChatId);
|
||||
}
|
||||
|
||||
private forgetTemporaryChat(chatId: string): void {
|
||||
if (this.temporaryChatId === chatId) this.temporaryChatId = null;
|
||||
this.knownChats.delete(chatId);
|
||||
this.chatHandlers.delete(chatId);
|
||||
this.pendingInboundByChat.delete(chatId);
|
||||
const wasRunning = this.runStartedAtByChatId.delete(chatId);
|
||||
this.runGenerationByChatId.delete(chatId);
|
||||
this.latestRunTurnIdByChatId.delete(chatId);
|
||||
this.unsettledRunTurnIdsByChatId.delete(chatId);
|
||||
this.canonicalCompletedTurnIdsByChatId.delete(chatId);
|
||||
this.goalStateByChatId.delete(chatId);
|
||||
for (const key of [...this.runStartedAtByTurnKey.keys()]) {
|
||||
if (key.startsWith(`${chatId}\u0000`)) this.runStartedAtByTurnKey.delete(key);
|
||||
}
|
||||
for (const [key, pending] of [...this.pendingMessageSends]) {
|
||||
if (pending.chatId !== chatId) continue;
|
||||
this.pendingMessageSends.delete(key);
|
||||
this.socketPendingMessageSendKeys.delete(key);
|
||||
}
|
||||
this.sendQueue = this.sendQueue.filter((frame) => (
|
||||
!("chat_id" in frame) || frame.chat_id !== chatId
|
||||
));
|
||||
if (this.lastSocketMessageSendKey?.startsWith(`${chatId}\u0000`)) {
|
||||
this.lastSocketMessageSendKey = null;
|
||||
}
|
||||
if (wasRunning) this.emitRunStatus(chatId, null);
|
||||
}
|
||||
|
||||
private frameFitsTransport(frame: Outbound): boolean {
|
||||
if (this.maxFrameBytes === undefined) return true;
|
||||
return new TextEncoder().encode(JSON.stringify(frame)).byteLength <= this.maxFrameBytes;
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { ChatSummary } from "./types";
|
||||
|
||||
export const TEMPORARY_CHAT_ID_PREFIX = "temporary-";
|
||||
export const TEMPORARY_CHAT_ROUTE_KEY = "__temporary_chat__";
|
||||
|
||||
export function isTemporaryChatId(value: string): boolean {
|
||||
return value.startsWith(TEMPORARY_CHAT_ID_PREFIX);
|
||||
}
|
||||
|
||||
export function createTemporaryChatSession(): ChatSummary {
|
||||
const chatId = `${TEMPORARY_CHAT_ID_PREFIX}${crypto.randomUUID()}`;
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
key: `websocket:${chatId}`,
|
||||
channel: "websocket",
|
||||
chatId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
preview: "",
|
||||
};
|
||||
}
|
||||
@@ -1341,6 +1341,7 @@ export type Outbound =
|
||||
| { type: "fork_chat"; source_chat_id: string; before_user_index: number; title?: string }
|
||||
| { type: "attach"; chat_id: string }
|
||||
| { type: "set_sidebar_state"; state: SidebarStatePayload }
|
||||
| { type: "discard_temporary_chat"; chat_id: string }
|
||||
| { type: "set_workspace_scope"; chat_id: string; workspace_scope: WorkspaceScopePayload }
|
||||
| { type: "transcribe_audio"; request_id: string; data_url: string; duration_ms?: number }
|
||||
| {
|
||||
|
||||
@@ -14,6 +14,7 @@ const toggleThemeSpy = vi.fn();
|
||||
const updateUrlSpy = vi.fn();
|
||||
const attachSpy = vi.fn();
|
||||
const setSidebarStateSpy = vi.fn();
|
||||
const discardTemporaryChatSpy = vi.fn();
|
||||
const runStatusHandlers = new Set<(chatId: string, startedAt: number | null) => void>();
|
||||
const sessionUpdateHandlers = new Set<(chatId: string, scope?: string) => void>();
|
||||
let mockSessions: ChatSummary[] = [];
|
||||
@@ -220,6 +221,7 @@ vi.mock("@/lib/nanobot-client", () => {
|
||||
newChat = vi.fn();
|
||||
attach = attachSpy;
|
||||
setSidebarState = setSidebarStateSpy;
|
||||
discardTemporaryChat = discardTemporaryChatSpy;
|
||||
close = vi.fn();
|
||||
updateUrl = updateUrlSpy;
|
||||
updateMaxFrameBytes = vi.fn();
|
||||
@@ -248,6 +250,7 @@ describe("App layout", () => {
|
||||
toggleThemeSpy.mockReset();
|
||||
attachSpy.mockReset();
|
||||
setSidebarStateSpy.mockReset();
|
||||
discardTemporaryChatSpy.mockReset();
|
||||
runStatusHandlers.clear();
|
||||
sessionUpdateHandlers.clear();
|
||||
window.history.replaceState(null, "", "/");
|
||||
@@ -384,6 +387,76 @@ describe("App layout", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a temporary chat while navigating and discards it on unmount", 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" });
|
||||
|
||||
fireEvent.click(temporaryButton);
|
||||
|
||||
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");
|
||||
|
||||
fireEvent.click(within(sidebar).getByRole("button", { name: "New topic" }));
|
||||
expect(discardTemporaryChatSpy).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(temporaryButton);
|
||||
expect(window.location.hash).toBe("#/temporary");
|
||||
expect(temporaryButton).toHaveAttribute("aria-current", "page");
|
||||
|
||||
unmount();
|
||||
await waitFor(() => expect(discardTemporaryChatSpy).toHaveBeenCalledOnce());
|
||||
expect(discardTemporaryChatSpy.mock.calls[0][0]).toMatch(/^temporary-/);
|
||||
});
|
||||
|
||||
it("clears a temporary chat explicitly without leaving it", async () => {
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
|
||||
fireEvent.click(within(sidebar).getByRole("button", { name: "Temporary chat" }));
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Clear temporary chat" }));
|
||||
|
||||
await waitFor(() => expect(discardTemporaryChatSpy).toHaveBeenCalledOnce());
|
||||
expect(window.location.hash).toBe("#/temporary");
|
||||
expect(within(sidebar).getByRole("button", { name: "Temporary chat" })).toHaveAttribute(
|
||||
"aria-current",
|
||||
"page",
|
||||
);
|
||||
});
|
||||
|
||||
it("starts temporary chat with restricted on-demand workspace controls", async () => {
|
||||
mockFetchRoutes({
|
||||
"/api/settings": baseSettingsPayload(),
|
||||
"/api/workspaces": {
|
||||
schema_version: 1,
|
||||
default_access_mode: "full",
|
||||
default_scope: {
|
||||
project_path: "/tmp/workspace",
|
||||
project_name: "workspace",
|
||||
access_mode: "full",
|
||||
restrict_to_workspace: false,
|
||||
},
|
||||
controls: { can_change_project: true, can_use_full_access: true },
|
||||
},
|
||||
});
|
||||
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();
|
||||
expect(screen.queryByText("Full Access")).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");
|
||||
|
||||
@@ -71,6 +71,54 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("NanobotClient", () => {
|
||||
it("keeps temporary chats out of attachment and reconnect state", () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
const chatId = "temporary-test";
|
||||
client.connect();
|
||||
client.onChat(chatId, vi.fn());
|
||||
client.sendMessage(chatId, "hello", undefined, { turnId: "turn-1" });
|
||||
|
||||
lastSocket().fakeOpen();
|
||||
|
||||
expect(lastSocket().sent.map((raw) => JSON.parse(raw))).toEqual([
|
||||
{
|
||||
type: "message",
|
||||
chat_id: chatId,
|
||||
content: "hello",
|
||||
turn_id: "turn-1",
|
||||
webui: true,
|
||||
},
|
||||
]);
|
||||
|
||||
client.discardTemporaryChat(chatId);
|
||||
expect(JSON.parse(lastSocket().sent.at(-1) as string)).toEqual({
|
||||
type: "discard_temporary_chat",
|
||||
chat_id: chatId,
|
||||
});
|
||||
});
|
||||
|
||||
it("forgets temporary chats 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,
|
||||
});
|
||||
client.connect();
|
||||
lastSocket().fakeOpen();
|
||||
client.onChat("temporary-drop", vi.fn());
|
||||
lastSocket().close();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
lastSocket().fakeOpen();
|
||||
|
||||
expect(lastSocket().sent).toEqual([]);
|
||||
});
|
||||
|
||||
it("routes events to the matching chat handler", () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
|
||||
@@ -1070,6 +1070,58 @@ describe("ThreadComposer", () => {
|
||||
}));
|
||||
});
|
||||
|
||||
it("keeps temporary-chat workspace controls on demand", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onWorkspaceScopeChange = vi.fn();
|
||||
const defaultScope = {
|
||||
project_path: "/Users/test/.nanobot/workspace",
|
||||
project_name: "workspace",
|
||||
access_mode: "restricted" as const,
|
||||
restrict_to_workspace: true,
|
||||
};
|
||||
const { rerender } = render(
|
||||
<ThreadComposer
|
||||
onSend={vi.fn()}
|
||||
placeholder="Ask anything..."
|
||||
variant="hero"
|
||||
compactWorkspaceControls
|
||||
workspaceScope={defaultScope}
|
||||
workspaceDefaultScope={defaultScope}
|
||||
workspaceControls={{ can_change_project: true, can_use_full_access: true }}
|
||||
onWorkspaceScopeChange={onWorkspaceScopeChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole("button", {
|
||||
name: "Workspace access mode: Default Permission",
|
||||
})).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}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", {
|
||||
name: "Workspace access mode: Default Permission",
|
||||
})).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("uses the native folder picker for project selection on native host", async () => {
|
||||
const onWorkspaceScopeChange = vi.fn();
|
||||
const pickFolder = vi.fn().mockResolvedValue("/Users/test/native-project");
|
||||
@@ -2888,4 +2940,48 @@ describe("ThreadComposer", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps temporary chat guidance in memory only", async () => {
|
||||
const onSend = vi.fn();
|
||||
const view = render(
|
||||
<ThreadComposer
|
||||
onSend={onSend}
|
||||
onStop={vi.fn()}
|
||||
isStreaming
|
||||
pendingQueueKey="temporary-private"
|
||||
placeholder="Type your message..."
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText("Message input");
|
||||
fireEvent.change(input, { target: { value: "do not persist this" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
|
||||
expect(await screen.findByText("do not persist this")).toBeInTheDocument();
|
||||
expect(
|
||||
window.localStorage.getItem(
|
||||
"nanobot.webui.composerQueuedGuidance.v1:temporary-private",
|
||||
),
|
||||
).toBeNull();
|
||||
|
||||
view.unmount();
|
||||
render(
|
||||
<ThreadComposer
|
||||
onSend={onSend}
|
||||
onStop={vi.fn()}
|
||||
isStreaming
|
||||
pendingQueueKey="temporary-private"
|
||||
placeholder="Type your message..."
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("do not persist this")).not.toBeInTheDocument();
|
||||
});
|
||||
expect(
|
||||
window.localStorage.getItem(
|
||||
"nanobot.webui.composerQueuedGuidance.v1:temporary-private",
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -848,6 +848,42 @@ describe("ThreadShell", () => {
|
||||
expect(screen.getByText("persist me across tabs")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps temporary messages across navigation and drops them after clear", async () => {
|
||||
const client = makeClient();
|
||||
const view = (chatId: string, temporary: boolean) => wrap(
|
||||
client,
|
||||
<ThreadShell
|
||||
session={session(chatId)}
|
||||
title={temporary ? "Temporary chat" : "Regular chat"}
|
||||
temporary={temporary}
|
||||
onToggleSidebar={() => {}}
|
||||
/>,
|
||||
);
|
||||
const { rerender } = render(view("temporary-live", true));
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Message input"), {
|
||||
target: { value: "keep this only in memory" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
||||
await waitFor(() => expectSendMessageWithTurn(
|
||||
client,
|
||||
"temporary-live",
|
||||
"keep this only in memory",
|
||||
));
|
||||
|
||||
rerender(view("regular", false));
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("keep this only in memory")).not.toBeInTheDocument();
|
||||
});
|
||||
rerender(view("temporary-live", true));
|
||||
expect(screen.getByText("keep this only in memory")).toBeInTheDocument();
|
||||
|
||||
rerender(view("temporary-cleared", true));
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("keep this only in memory")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("highlights sent skill references without skill metadata", async () => {
|
||||
const client = makeClient();
|
||||
render(wrap(
|
||||
|
||||
Reference in New Issue
Block a user