feat(webui): add temporary chat mode

This commit is contained in:
Xubin Ren
2026-08-08 23:20:59 +08:00
parent 113e8d67ad
commit c9a6145878
41 changed files with 1500 additions and 119 deletions
+94 -8
View File
@@ -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}