Skip to content

Commit 2e50f16

Browse files
fix(webchat): create dashboard sessions from New Chat (#73725)
Summary: - The PR rewires Control UI/WebChat New Chat to create and switch to a dashboard session through `sessions.create`, adds guarded UI/session helper logic and regression tests, and updates the changelog. ClawSweeper fixups: - Included follow-up commit: fix(webchat): create dashboard sessions from New Chat Validation: - ClawSweeper review passed for head 983c634. - Required merge gates passed before the squash merge. Prepared head SHA: 983c634 Review: #73725 (comment) Co-authored-by: vincentkoc <[email protected]> Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
1 parent 7df025f commit 2e50f16

6 files changed

Lines changed: 384 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -423,6 +423,7 @@ Docs: https://docs.openclaw.ai
423423
- Outbound/security: strip known internal runtime scaffolding such as `<system-reminder>` and `<previous_response>` 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.
424424
- 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.
425425
- 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.
426+
- 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.
426427
- 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.
427428
- fix(plugins): restrict bundled plugin dir resolution to trusted package roots. (#73275) Thanks @pgondhi987.
428429
- fix(security): prevent workspace PATH injection via service env and trash helpers. (#73264) Thanks @pgondhi987.

ui/src/ui/app-render.helpers.node.test.ts

Lines changed: 202 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,18 @@
11
// @vitest-environment node
2-
import { describe, expect, it, vi } from "vitest";
2+
import { beforeEach, describe, expect, it, vi } from "vitest";
33
const {
44
refreshChatMock,
55
refreshChatAvatarMock,
66
refreshSlashCommandsMock,
77
loadChatHistoryMock,
8+
createSessionAndRefreshMock,
89
loadSessionsMock,
910
} = vi.hoisted(() => ({
1011
refreshChatMock: vi.fn(),
1112
refreshChatAvatarMock: vi.fn(),
1213
refreshSlashCommandsMock: vi.fn(),
1314
loadChatHistoryMock: vi.fn(),
15+
createSessionAndRefreshMock: vi.fn(),
1416
loadSessionsMock: vi.fn(),
1517
}));
1618

@@ -28,10 +30,12 @@ vi.mock("./controllers/chat.ts", () => ({
2830
}));
2931

3032
vi.mock("./controllers/sessions.ts", () => ({
33+
createSessionAndRefresh: createSessionAndRefreshMock,
3134
loadSessions: loadSessionsMock,
3235
}));
3336

3437
import {
38+
createChatSession,
3539
isCronSessionKey,
3640
parseSessionKey,
3741
resolveAssistantAttachmentAuthToken,
@@ -44,6 +48,15 @@ import type { SessionsListResult } from "./types.ts";
4448

4549
type SessionRow = SessionsListResult["sessions"][number];
4650

51+
beforeEach(() => {
52+
refreshChatMock.mockReset();
53+
refreshChatAvatarMock.mockReset();
54+
refreshSlashCommandsMock.mockReset();
55+
loadChatHistoryMock.mockReset();
56+
createSessionAndRefreshMock.mockReset();
57+
loadSessionsMock.mockReset();
58+
});
59+
4760
function row(overrides: Partial<SessionRow> & { key: string }): SessionRow {
4861
return { kind: "direct", updatedAt: 0, ...overrides };
4962
}
@@ -90,6 +103,55 @@ function createSettings(): AppViewState["settings"] {
90103
};
91104
}
92105

106+
function createChatSessionState(overrides: Partial<AppViewState> = {}) {
107+
const settings = createSettings();
108+
const state = {
109+
sessionKey: "agent:ops:main",
110+
chatMessage: "draft prompt",
111+
chatAttachments: [{ id: "att-1", mimeType: "image/png", dataUrl: "data:image/png;base64,AAA" }],
112+
chatMessages: [{ role: "assistant", content: "old" }],
113+
chatToolMessages: [{ id: "tool-1" }],
114+
chatStreamSegments: [],
115+
chatThinkingLevel: null,
116+
chatStream: null,
117+
chatSideResult: null,
118+
lastError: null,
119+
compactionStatus: null,
120+
fallbackStatus: null,
121+
chatAvatarUrl: null,
122+
chatAvatarSource: null,
123+
chatAvatarStatus: null,
124+
chatAvatarReason: null,
125+
chatQueue: [],
126+
chatRunId: null,
127+
chatSending: false,
128+
chatLoading: false,
129+
chatSideResultTerminalRuns: new Set<string>(),
130+
chatStreamStartedAt: null,
131+
connected: true,
132+
client: { request: vi.fn() },
133+
sessionsLoading: false,
134+
sessionsError: null,
135+
sessionsResult: {
136+
ts: 0,
137+
path: "",
138+
count: 1,
139+
defaults: { modelProvider: "openai", model: "gpt-5", contextTokens: null },
140+
sessions: [row({ key: "agent:ops:main" })],
141+
},
142+
settings,
143+
applySettings(next: typeof settings) {
144+
state.settings = next;
145+
},
146+
loadAssistantIdentity: vi.fn(),
147+
resetToolStream: vi.fn(),
148+
resetChatScroll: vi.fn(),
149+
resetChatInputHistoryNavigation: vi.fn(),
150+
...overrides,
151+
} as unknown as AppViewState;
152+
return state;
153+
}
154+
93155
/* ================================================================
94156
* parseSessionKey – low-level key → type / fallback mapping
95157
* ================================================================ */
@@ -493,6 +555,145 @@ describe("resolveSessionOptionGroups", () => {
493555
});
494556
});
495557

558+
describe("createChatSession", () => {
559+
it("creates a dashboard session, switches to it, and preserves the current composer", async () => {
560+
const state = createChatSessionState();
561+
createSessionAndRefreshMock.mockResolvedValue("agent:ops:dashboard:new-chat");
562+
refreshChatAvatarMock.mockResolvedValue(undefined);
563+
refreshSlashCommandsMock.mockResolvedValue(undefined);
564+
loadChatHistoryMock.mockResolvedValue(undefined);
565+
loadSessionsMock.mockResolvedValue(undefined);
566+
567+
await createChatSession(state);
568+
569+
expect(createSessionAndRefreshMock).toHaveBeenCalledWith(
570+
state,
571+
{
572+
agentId: "ops",
573+
parentSessionKey: "agent:ops:main",
574+
},
575+
{
576+
activeMinutes: 0,
577+
limit: 0,
578+
includeGlobal: true,
579+
includeUnknown: true,
580+
},
581+
);
582+
expect(state.sessionKey).toBe("agent:ops:dashboard:new-chat");
583+
expect(state.settings.sessionKey).toBe("agent:ops:dashboard:new-chat");
584+
expect(state.chatMessage).toBe("draft prompt");
585+
expect(state.chatAttachments).toEqual([
586+
{ id: "att-1", mimeType: "image/png", dataUrl: "data:image/png;base64,AAA" },
587+
]);
588+
expect(state.chatMessages).toEqual([]);
589+
expect(loadChatHistoryMock).toHaveBeenCalledWith(state);
590+
});
591+
592+
it("preserves draft and attachment edits made while session creation is in flight", async () => {
593+
const state = createChatSessionState();
594+
const updatedAttachments = [
595+
{ id: "att-2", mimeType: "image/png", dataUrl: "data:image/png;base64,BBB" },
596+
];
597+
createSessionAndRefreshMock.mockImplementation(async () => {
598+
state.chatMessage = "updated draft";
599+
state.chatAttachments = updatedAttachments;
600+
return "agent:ops:dashboard:new-chat";
601+
});
602+
refreshChatAvatarMock.mockResolvedValue(undefined);
603+
refreshSlashCommandsMock.mockResolvedValue(undefined);
604+
loadChatHistoryMock.mockResolvedValue(undefined);
605+
loadSessionsMock.mockResolvedValue(undefined);
606+
607+
await createChatSession(state);
608+
609+
expect(state.sessionKey).toBe("agent:ops:dashboard:new-chat");
610+
expect(state.chatMessage).toBe("updated draft");
611+
expect(state.chatAttachments).toBe(updatedAttachments);
612+
expect(loadChatHistoryMock).toHaveBeenCalledWith(state);
613+
});
614+
615+
it("ignores a stale create response after the active session changes", async () => {
616+
const state = createChatSessionState();
617+
createSessionAndRefreshMock.mockImplementation(async () => {
618+
state.sessionKey = "agent:ops:other";
619+
return "agent:ops:dashboard:new-chat";
620+
});
621+
622+
await createChatSession(state);
623+
624+
expect(state.sessionKey).toBe("agent:ops:other");
625+
expect(state.chatMessage).toBe("draft prompt");
626+
expect(state.chatMessages).toEqual([{ role: "assistant", content: "old" }]);
627+
expect(loadChatHistoryMock).not.toHaveBeenCalled();
628+
});
629+
630+
it("does not create or switch while a run is active", async () => {
631+
const state = createChatSessionState({
632+
chatRunId: "run-1",
633+
chatQueue: [{ id: "queued-1", text: "follow up", createdAt: 1 }],
634+
});
635+
636+
await createChatSession(state);
637+
638+
expect(createSessionAndRefreshMock).not.toHaveBeenCalled();
639+
expect(state.sessionKey).toBe("agent:ops:main");
640+
expect(state.chatMessage).toBe("draft prompt");
641+
expect(state.chatQueue).toEqual([{ id: "queued-1", text: "follow up", createdAt: 1 }]);
642+
expect(state.lastError).toBe(
643+
"Start a new session after the active run or queued messages finish.",
644+
);
645+
});
646+
647+
it("shows feedback instead of clearing errors when session loading blocks creation", async () => {
648+
const state = createChatSessionState({
649+
sessionsLoading: true,
650+
lastError: "previous error",
651+
});
652+
653+
await createChatSession(state);
654+
655+
expect(createSessionAndRefreshMock).not.toHaveBeenCalled();
656+
expect(state.sessionKey).toBe("agent:ops:main");
657+
expect(state.chatMessage).toBe("draft prompt");
658+
expect(state.lastError).toBe(
659+
"Session list is still refreshing. Try New Chat again in a moment.",
660+
);
661+
});
662+
663+
it("shows creation failure feedback when creation is skipped without a session error", async () => {
664+
const state = createChatSessionState({ lastError: "previous error" });
665+
createSessionAndRefreshMock.mockResolvedValue(null);
666+
667+
await createChatSession(state);
668+
669+
expect(createSessionAndRefreshMock).toHaveBeenCalledTimes(1);
670+
expect(state.sessionKey).toBe("agent:ops:main");
671+
expect(state.chatMessage).toBe("draft prompt");
672+
expect(state.sessionsError).toBeNull();
673+
expect(state.lastError).toBe("New Chat could not create a new session. Try again in a moment.");
674+
expect(loadChatHistoryMock).not.toHaveBeenCalled();
675+
});
676+
677+
it("keeps refresh feedback when a queued session refresh skips creation", async () => {
678+
const state = createChatSessionState({ lastError: "previous error" });
679+
createSessionAndRefreshMock.mockImplementation(async () => {
680+
state.sessionsLoading = true;
681+
return null;
682+
});
683+
684+
await createChatSession(state);
685+
686+
expect(createSessionAndRefreshMock).toHaveBeenCalledTimes(1);
687+
expect(state.sessionKey).toBe("agent:ops:main");
688+
expect(state.chatMessage).toBe("draft prompt");
689+
expect(state.sessionsError).toBeNull();
690+
expect(state.lastError).toBe(
691+
"Session list is still refreshing. Try New Chat again in a moment.",
692+
);
693+
expect(loadChatHistoryMock).not.toHaveBeenCalled();
694+
});
695+
});
696+
496697
describe("switchChatSession", () => {
497698
it("refreshes the chat avatar after clearing session-scoped state", async () => {
498699
const settings = createSettings();

ui/src/ui/app-render.helpers.ts

Lines changed: 74 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,10 @@ import {
1414
import { refreshSlashCommands } from "./chat/slash-commands.ts";
1515
import { resolveControlUiAuthToken } from "./control-ui-auth.ts";
1616
import { ChatState, loadChatHistory } from "./controllers/chat.ts";
17-
import { loadSessions } from "./controllers/sessions.ts";
17+
import { createSessionAndRefresh, loadSessions } from "./controllers/sessions.ts";
1818
import { icons } from "./icons.ts";
1919
import { iconForTab, pathForTab, titleForTab, type Tab } from "./navigation.ts";
20-
import { parseAgentSessionKey } from "./session-key.ts";
20+
import { parseAgentSessionKey, resolveAgentIdFromSessionKey } from "./session-key.ts";
2121
import { normalizeOptionalString } from "./string-coerce.ts";
2222
import type { ThemeMode } from "./theme.ts";
2323
import type { SessionsListResult } from "./types.ts";
@@ -118,6 +118,23 @@ function resetChatStateForSessionSwitch(state: AppViewState, sessionKey: string)
118118
});
119119
}
120120

121+
function canSwitchToNewChatSession(state: AppViewState): boolean {
122+
return (
123+
!state.chatLoading &&
124+
!state.chatSending &&
125+
!state.chatRunId &&
126+
state.chatStream === null &&
127+
state.chatQueue.length === 0
128+
);
129+
}
130+
131+
const NEW_CHAT_ACTIVE_RUN_MESSAGE =
132+
"Start a new session after the active run or queued messages finish.";
133+
const NEW_CHAT_SESSIONS_LOADING_MESSAGE =
134+
"Session list is still refreshing. Try New Chat again in a moment.";
135+
const NEW_CHAT_CREATE_FAILED_MESSAGE =
136+
"New Chat could not create a new session. Try again in a moment.";
137+
121138
export function renderTab(state: AppViewState, tab: Tab, opts?: { collapsed?: boolean }) {
122139
const href = pathForTab(tab, state.basePath);
123140
const isActive = state.tab === tab;
@@ -587,6 +604,61 @@ export function switchChatSession(state: AppViewState, nextSessionKey: string) {
587604
void refreshSessionOptions(state);
588605
}
589606

607+
export async function createChatSession(state: AppViewState) {
608+
if (!state.client || !state.connected) {
609+
return;
610+
}
611+
if (!canSwitchToNewChatSession(state)) {
612+
state.lastError = NEW_CHAT_ACTIVE_RUN_MESSAGE;
613+
return;
614+
}
615+
if (state.sessionsLoading) {
616+
state.lastError = NEW_CHAT_SESSIONS_LOADING_MESSAGE;
617+
return;
618+
}
619+
620+
state.lastError = null;
621+
const previousSessionKey = state.sessionKey;
622+
const parentSessionKey = state.sessionsResult?.sessions.some(
623+
(row) => row.key === previousSessionKey,
624+
)
625+
? previousSessionKey
626+
: undefined;
627+
const nextSessionKey = await createSessionAndRefresh(
628+
state as unknown as Parameters<typeof createSessionAndRefresh>[0],
629+
{
630+
agentId: resolveAgentIdFromSessionKey(previousSessionKey),
631+
parentSessionKey,
632+
},
633+
{
634+
activeMinutes: 0,
635+
limit: 0,
636+
includeGlobal: true,
637+
includeUnknown: true,
638+
},
639+
);
640+
if (
641+
!nextSessionKey ||
642+
state.sessionKey !== previousSessionKey ||
643+
!canSwitchToNewChatSession(state)
644+
) {
645+
if (!nextSessionKey) {
646+
state.lastError =
647+
state.sessionsError ??
648+
(state.sessionsLoading
649+
? NEW_CHAT_SESSIONS_LOADING_MESSAGE
650+
: NEW_CHAT_CREATE_FAILED_MESSAGE);
651+
}
652+
return;
653+
}
654+
655+
const preservedDraft = state.chatMessage;
656+
const preservedAttachments = state.chatAttachments;
657+
switchChatSession(state, nextSessionKey);
658+
state.chatMessage = preservedDraft;
659+
state.chatAttachments = preservedAttachments;
660+
}
661+
590662
async function refreshSessionOptions(state: AppViewState) {
591663
await loadSessions(state as unknown as Parameters<typeof loadSessions>[0], {
592664
activeMinutes: 0,

ui/src/ui/app-render.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
resolveAssistantAttachmentAuthToken,
1313
renderSidebarConnectionStatus,
1414
renderTopbarThemeModeToggle,
15+
createChatSession,
1516
switchChatSession,
1617
} from "./app-render.helpers.ts";
1718
import { warnQueryToken } from "./app-settings.ts";
@@ -2373,8 +2374,7 @@ export function renderApp(state: AppViewState) {
23732374
onDismissSideResult: () => {
23742375
state.chatSideResult = null;
23752376
},
2376-
onNewSession: () =>
2377-
state.handleSendChat("/new", { confirmReset: true, restoreDraft: true }),
2377+
onNewSession: () => void createChatSession(state),
23782378
onClearHistory: async () => {
23792379
if (!state.client || !state.connected) {
23802380
return;

0 commit comments

Comments
 (0)