diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a10b3ade53..ba5eedf459e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -423,6 +423,7 @@ Docs: https://docs.openclaw.ai - Outbound/security: strip known internal runtime scaffolding such as `` and `` at the final channel delivery boundary and keep Discord output on targeted tag stripping, so degraded harness replies cannot leak those tags to users. Fixes #73595. Thanks @gabrielexito-stack and @martingarramon. - Security/Telegram: load Telegram security adapters in read-only audit/doctor, audit malformed Telegram DM `allowFrom` entries even when groups are disabled, and keep allowlist DM audits from counting stale pairing-store senders, so public/shared-DM risk checks stay accurate. Refs #73698. Thanks @xace1825. - Plugins: remove hidden manifest, provider-owner, bootstrap, and channel metadata caches so plugin installs, manifest edits, and bundled-root changes are visible on the next metadata read while keeping runtime/module loader caches for actual plugin code. Thanks @shakkernerd. +- Control UI/WebChat: create a fresh dashboard session from the New Chat button instead of resetting the current transcript with `/new`, while keeping explicit `/new` reset behavior, preserving in-progress composer edits during delayed session creation or when creation cannot safely switch sessions, and showing clear retry feedback when creation is blocked, refreshing, or returns no new session. Carries forward #52042 and #52746. Thanks @bobashopcashier and @vincentkoc. - CLI/plugins: use plugin metadata snapshots for install slot selection and add opt-in plugin lifecycle timing traces, so plugin install avoids runtime-loading the plugin registry for metadata-only decisions. Thanks @shakkernerd. - fix(plugins): restrict bundled plugin dir resolution to trusted package roots. (#73275) Thanks @pgondhi987. - fix(security): prevent workspace PATH injection via service env and trash helpers. (#73264) Thanks @pgondhi987. diff --git a/ui/src/ui/app-render.helpers.node.test.ts b/ui/src/ui/app-render.helpers.node.test.ts index fc12e50a997..3771efb71d9 100644 --- a/ui/src/ui/app-render.helpers.node.test.ts +++ b/ui/src/ui/app-render.helpers.node.test.ts @@ -1,16 +1,18 @@ // @vitest-environment node -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; const { refreshChatMock, refreshChatAvatarMock, refreshSlashCommandsMock, loadChatHistoryMock, + createSessionAndRefreshMock, loadSessionsMock, } = vi.hoisted(() => ({ refreshChatMock: vi.fn(), refreshChatAvatarMock: vi.fn(), refreshSlashCommandsMock: vi.fn(), loadChatHistoryMock: vi.fn(), + createSessionAndRefreshMock: vi.fn(), loadSessionsMock: vi.fn(), })); @@ -28,10 +30,12 @@ vi.mock("./controllers/chat.ts", () => ({ })); vi.mock("./controllers/sessions.ts", () => ({ + createSessionAndRefresh: createSessionAndRefreshMock, loadSessions: loadSessionsMock, })); import { + createChatSession, isCronSessionKey, parseSessionKey, resolveAssistantAttachmentAuthToken, @@ -44,6 +48,15 @@ import type { SessionsListResult } from "./types.ts"; type SessionRow = SessionsListResult["sessions"][number]; +beforeEach(() => { + refreshChatMock.mockReset(); + refreshChatAvatarMock.mockReset(); + refreshSlashCommandsMock.mockReset(); + loadChatHistoryMock.mockReset(); + createSessionAndRefreshMock.mockReset(); + loadSessionsMock.mockReset(); +}); + function row(overrides: Partial & { key: string }): SessionRow { return { kind: "direct", updatedAt: 0, ...overrides }; } @@ -90,6 +103,55 @@ function createSettings(): AppViewState["settings"] { }; } +function createChatSessionState(overrides: Partial = {}) { + const settings = createSettings(); + const state = { + sessionKey: "agent:ops:main", + chatMessage: "draft prompt", + chatAttachments: [{ id: "att-1", mimeType: "image/png", dataUrl: "data:image/png;base64,AAA" }], + chatMessages: [{ role: "assistant", content: "old" }], + chatToolMessages: [{ id: "tool-1" }], + chatStreamSegments: [], + chatThinkingLevel: null, + chatStream: null, + chatSideResult: null, + lastError: null, + compactionStatus: null, + fallbackStatus: null, + chatAvatarUrl: null, + chatAvatarSource: null, + chatAvatarStatus: null, + chatAvatarReason: null, + chatQueue: [], + chatRunId: null, + chatSending: false, + chatLoading: false, + chatSideResultTerminalRuns: new Set(), + chatStreamStartedAt: null, + connected: true, + client: { request: vi.fn() }, + sessionsLoading: false, + sessionsError: null, + sessionsResult: { + ts: 0, + path: "", + count: 1, + defaults: { modelProvider: "openai", model: "gpt-5", contextTokens: null }, + sessions: [row({ key: "agent:ops:main" })], + }, + settings, + applySettings(next: typeof settings) { + state.settings = next; + }, + loadAssistantIdentity: vi.fn(), + resetToolStream: vi.fn(), + resetChatScroll: vi.fn(), + resetChatInputHistoryNavigation: vi.fn(), + ...overrides, + } as unknown as AppViewState; + return state; +} + /* ================================================================ * parseSessionKey – low-level key → type / fallback mapping * ================================================================ */ @@ -493,6 +555,145 @@ describe("resolveSessionOptionGroups", () => { }); }); +describe("createChatSession", () => { + it("creates a dashboard session, switches to it, and preserves the current composer", async () => { + const state = createChatSessionState(); + createSessionAndRefreshMock.mockResolvedValue("agent:ops:dashboard:new-chat"); + refreshChatAvatarMock.mockResolvedValue(undefined); + refreshSlashCommandsMock.mockResolvedValue(undefined); + loadChatHistoryMock.mockResolvedValue(undefined); + loadSessionsMock.mockResolvedValue(undefined); + + await createChatSession(state); + + expect(createSessionAndRefreshMock).toHaveBeenCalledWith( + state, + { + agentId: "ops", + parentSessionKey: "agent:ops:main", + }, + { + activeMinutes: 0, + limit: 0, + includeGlobal: true, + includeUnknown: true, + }, + ); + expect(state.sessionKey).toBe("agent:ops:dashboard:new-chat"); + expect(state.settings.sessionKey).toBe("agent:ops:dashboard:new-chat"); + expect(state.chatMessage).toBe("draft prompt"); + expect(state.chatAttachments).toEqual([ + { id: "att-1", mimeType: "image/png", dataUrl: "data:image/png;base64,AAA" }, + ]); + expect(state.chatMessages).toEqual([]); + expect(loadChatHistoryMock).toHaveBeenCalledWith(state); + }); + + it("preserves draft and attachment edits made while session creation is in flight", async () => { + const state = createChatSessionState(); + const updatedAttachments = [ + { id: "att-2", mimeType: "image/png", dataUrl: "data:image/png;base64,BBB" }, + ]; + createSessionAndRefreshMock.mockImplementation(async () => { + state.chatMessage = "updated draft"; + state.chatAttachments = updatedAttachments; + return "agent:ops:dashboard:new-chat"; + }); + refreshChatAvatarMock.mockResolvedValue(undefined); + refreshSlashCommandsMock.mockResolvedValue(undefined); + loadChatHistoryMock.mockResolvedValue(undefined); + loadSessionsMock.mockResolvedValue(undefined); + + await createChatSession(state); + + expect(state.sessionKey).toBe("agent:ops:dashboard:new-chat"); + expect(state.chatMessage).toBe("updated draft"); + expect(state.chatAttachments).toBe(updatedAttachments); + expect(loadChatHistoryMock).toHaveBeenCalledWith(state); + }); + + it("ignores a stale create response after the active session changes", async () => { + const state = createChatSessionState(); + createSessionAndRefreshMock.mockImplementation(async () => { + state.sessionKey = "agent:ops:other"; + return "agent:ops:dashboard:new-chat"; + }); + + await createChatSession(state); + + expect(state.sessionKey).toBe("agent:ops:other"); + expect(state.chatMessage).toBe("draft prompt"); + expect(state.chatMessages).toEqual([{ role: "assistant", content: "old" }]); + expect(loadChatHistoryMock).not.toHaveBeenCalled(); + }); + + it("does not create or switch while a run is active", async () => { + const state = createChatSessionState({ + chatRunId: "run-1", + chatQueue: [{ id: "queued-1", text: "follow up", createdAt: 1 }], + }); + + await createChatSession(state); + + expect(createSessionAndRefreshMock).not.toHaveBeenCalled(); + expect(state.sessionKey).toBe("agent:ops:main"); + expect(state.chatMessage).toBe("draft prompt"); + expect(state.chatQueue).toEqual([{ id: "queued-1", text: "follow up", createdAt: 1 }]); + expect(state.lastError).toBe( + "Start a new session after the active run or queued messages finish.", + ); + }); + + it("shows feedback instead of clearing errors when session loading blocks creation", async () => { + const state = createChatSessionState({ + sessionsLoading: true, + lastError: "previous error", + }); + + await createChatSession(state); + + expect(createSessionAndRefreshMock).not.toHaveBeenCalled(); + expect(state.sessionKey).toBe("agent:ops:main"); + expect(state.chatMessage).toBe("draft prompt"); + expect(state.lastError).toBe( + "Session list is still refreshing. Try New Chat again in a moment.", + ); + }); + + it("shows creation failure feedback when creation is skipped without a session error", async () => { + const state = createChatSessionState({ lastError: "previous error" }); + createSessionAndRefreshMock.mockResolvedValue(null); + + await createChatSession(state); + + expect(createSessionAndRefreshMock).toHaveBeenCalledTimes(1); + expect(state.sessionKey).toBe("agent:ops:main"); + expect(state.chatMessage).toBe("draft prompt"); + expect(state.sessionsError).toBeNull(); + expect(state.lastError).toBe("New Chat could not create a new session. Try again in a moment."); + expect(loadChatHistoryMock).not.toHaveBeenCalled(); + }); + + it("keeps refresh feedback when a queued session refresh skips creation", async () => { + const state = createChatSessionState({ lastError: "previous error" }); + createSessionAndRefreshMock.mockImplementation(async () => { + state.sessionsLoading = true; + return null; + }); + + await createChatSession(state); + + expect(createSessionAndRefreshMock).toHaveBeenCalledTimes(1); + expect(state.sessionKey).toBe("agent:ops:main"); + expect(state.chatMessage).toBe("draft prompt"); + expect(state.sessionsError).toBeNull(); + expect(state.lastError).toBe( + "Session list is still refreshing. Try New Chat again in a moment.", + ); + expect(loadChatHistoryMock).not.toHaveBeenCalled(); + }); +}); + describe("switchChatSession", () => { it("refreshes the chat avatar after clearing session-scoped state", async () => { const settings = createSettings(); diff --git a/ui/src/ui/app-render.helpers.ts b/ui/src/ui/app-render.helpers.ts index 8faa31b053b..f40824a2678 100644 --- a/ui/src/ui/app-render.helpers.ts +++ b/ui/src/ui/app-render.helpers.ts @@ -14,10 +14,10 @@ import { import { refreshSlashCommands } from "./chat/slash-commands.ts"; import { resolveControlUiAuthToken } from "./control-ui-auth.ts"; import { ChatState, loadChatHistory } from "./controllers/chat.ts"; -import { loadSessions } from "./controllers/sessions.ts"; +import { createSessionAndRefresh, loadSessions } from "./controllers/sessions.ts"; import { icons } from "./icons.ts"; import { iconForTab, pathForTab, titleForTab, type Tab } from "./navigation.ts"; -import { parseAgentSessionKey } from "./session-key.ts"; +import { parseAgentSessionKey, resolveAgentIdFromSessionKey } from "./session-key.ts"; import { normalizeOptionalString } from "./string-coerce.ts"; import type { ThemeMode } from "./theme.ts"; import type { SessionsListResult } from "./types.ts"; @@ -118,6 +118,23 @@ function resetChatStateForSessionSwitch(state: AppViewState, sessionKey: string) }); } +function canSwitchToNewChatSession(state: AppViewState): boolean { + return ( + !state.chatLoading && + !state.chatSending && + !state.chatRunId && + state.chatStream === null && + state.chatQueue.length === 0 + ); +} + +const NEW_CHAT_ACTIVE_RUN_MESSAGE = + "Start a new session after the active run or queued messages finish."; +const NEW_CHAT_SESSIONS_LOADING_MESSAGE = + "Session list is still refreshing. Try New Chat again in a moment."; +const NEW_CHAT_CREATE_FAILED_MESSAGE = + "New Chat could not create a new session. Try again in a moment."; + export function renderTab(state: AppViewState, tab: Tab, opts?: { collapsed?: boolean }) { const href = pathForTab(tab, state.basePath); const isActive = state.tab === tab; @@ -587,6 +604,61 @@ export function switchChatSession(state: AppViewState, nextSessionKey: string) { void refreshSessionOptions(state); } +export async function createChatSession(state: AppViewState) { + if (!state.client || !state.connected) { + return; + } + if (!canSwitchToNewChatSession(state)) { + state.lastError = NEW_CHAT_ACTIVE_RUN_MESSAGE; + return; + } + if (state.sessionsLoading) { + state.lastError = NEW_CHAT_SESSIONS_LOADING_MESSAGE; + return; + } + + state.lastError = null; + const previousSessionKey = state.sessionKey; + const parentSessionKey = state.sessionsResult?.sessions.some( + (row) => row.key === previousSessionKey, + ) + ? previousSessionKey + : undefined; + const nextSessionKey = await createSessionAndRefresh( + state as unknown as Parameters[0], + { + agentId: resolveAgentIdFromSessionKey(previousSessionKey), + parentSessionKey, + }, + { + activeMinutes: 0, + limit: 0, + includeGlobal: true, + includeUnknown: true, + }, + ); + if ( + !nextSessionKey || + state.sessionKey !== previousSessionKey || + !canSwitchToNewChatSession(state) + ) { + if (!nextSessionKey) { + state.lastError = + state.sessionsError ?? + (state.sessionsLoading + ? NEW_CHAT_SESSIONS_LOADING_MESSAGE + : NEW_CHAT_CREATE_FAILED_MESSAGE); + } + return; + } + + const preservedDraft = state.chatMessage; + const preservedAttachments = state.chatAttachments; + switchChatSession(state, nextSessionKey); + state.chatMessage = preservedDraft; + state.chatAttachments = preservedAttachments; +} + async function refreshSessionOptions(state: AppViewState) { await loadSessions(state as unknown as Parameters[0], { activeMinutes: 0, diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 810e4e36aa8..16c12d094bc 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -12,6 +12,7 @@ import { resolveAssistantAttachmentAuthToken, renderSidebarConnectionStatus, renderTopbarThemeModeToggle, + createChatSession, switchChatSession, } from "./app-render.helpers.ts"; import { warnQueryToken } from "./app-settings.ts"; @@ -2373,8 +2374,7 @@ export function renderApp(state: AppViewState) { onDismissSideResult: () => { state.chatSideResult = null; }, - onNewSession: () => - state.handleSendChat("/new", { confirmReset: true, restoreDraft: true }), + onNewSession: () => void createChatSession(state), onClearHistory: async () => { if (!state.client || !state.connected) { return; diff --git a/ui/src/ui/controllers/sessions.test.ts b/ui/src/ui/controllers/sessions.test.ts index 1d1412c4ac4..d39aa0739f0 100644 --- a/ui/src/ui/controllers/sessions.test.ts +++ b/ui/src/ui/controllers/sessions.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { applySessionsChangedEvent, + createSessionAndRefresh, deleteSessionsAndRefresh, loadSessions, subscribeSessions, @@ -53,6 +54,72 @@ describe("subscribeSessions", () => { }); }); +describe("createSessionAndRefresh", () => { + it("creates a dashboard session and refreshes the session list", async () => { + const request = vi.fn(async (method: string) => { + if (method === "sessions.create") { + return { key: "agent:main:dashboard:abc" }; + } + if (method === "sessions.list") { + return { + ts: 2, + path: "(multiple)", + count: 1, + defaults: {}, + sessions: [{ key: "agent:main:dashboard:abc", kind: "direct", updatedAt: 2 }], + }; + } + throw new Error(`unexpected method: ${method}`); + }); + const state = createState(request); + + const key = await createSessionAndRefresh( + state, + { agentId: "main", parentSessionKey: "agent:main:main" }, + { activeMinutes: 0, limit: 0, includeGlobal: true, includeUnknown: true }, + ); + + expect(key).toBe("agent:main:dashboard:abc"); + expect(request).toHaveBeenNthCalledWith(1, "sessions.create", { + agentId: "main", + parentSessionKey: "agent:main:main", + }); + expect(request).toHaveBeenNthCalledWith(2, "sessions.list", { + includeGlobal: true, + includeUnknown: true, + }); + expect(state.sessionsResult?.sessions[0]?.key).toBe("agent:main:dashboard:abc"); + expect(state.sessionsLoading).toBe(false); + }); + + it("keeps the current state when create does not return a key", async () => { + const request = vi.fn(async (method: string) => { + if (method === "sessions.create") { + return {}; + } + throw new Error(`unexpected method: ${method}`); + }); + const state = createState(request); + + const key = await createSessionAndRefresh(state); + + expect(key).toBeNull(); + expect(state.sessionsError).toBe("Error: sessions.create returned no key"); + expect(state.sessionsLoading).toBe(false); + expect(request).toHaveBeenCalledTimes(1); + }); + + it("does not start a create mutation while sessions are loading", async () => { + const request = vi.fn(async () => ({ key: "agent:main:dashboard:abc" })); + const state = createState(request, { sessionsLoading: true }); + + const key = await createSessionAndRefresh(state); + + expect(key).toBeNull(); + expect(request).not.toHaveBeenCalled(); + }); +}); + describe("deleteSessionsAndRefresh", () => { it("deletes multiple sessions and refreshes", async () => { const request = vi.fn(async (method: string) => { diff --git a/ui/src/ui/controllers/sessions.ts b/ui/src/ui/controllers/sessions.ts index 233ba651c2b..4b46b8af559 100644 --- a/ui/src/ui/controllers/sessions.ts +++ b/ui/src/ui/controllers/sessions.ts @@ -37,6 +37,17 @@ type LoadSessionsOverrides = { includeUnknown?: boolean; }; +type CreateSessionParams = { + agentId?: string; + label?: string; + model?: string; + parentSessionKey?: string; +}; + +type CreateSessionResult = { + key?: string; +}; + type SessionsLoadControl = { loading: boolean; pending: { overrides?: LoadSessionsOverrides } | null; @@ -422,6 +433,33 @@ export async function patchSession( } } +export async function createSessionAndRefresh( + state: SessionsState, + params: CreateSessionParams = {}, + refreshOverrides?: LoadSessionsOverrides, +): Promise { + if (!state.client || !state.connected || state.sessionsLoading) { + return null; + } + const client = state.client; + let createdKey: string | null = null; + try { + await withSessionsLoading(state, async () => { + const result = await client.request("sessions.create", params); + const key = typeof result?.key === "string" ? result.key.trim() : ""; + if (!key) { + throw new Error("sessions.create returned no key"); + } + createdKey = key; + await loadSessions(state, refreshOverrides); + }); + } catch (err) { + state.sessionsError = String(err); + return null; + } + return createdKey; +} + export async function deleteSessionsAndRefresh( state: SessionsState, keys: string[],