Skip to content

Commit 609743c

Browse files
committed
fix(webchat): create dashboard sessions from New Chat
1 parent e2295b3 commit 609743c

6 files changed

Lines changed: 295 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai
1212

1313
### Fixes
1414

15+
- 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 and preserving in-progress composer state when creation cannot safely switch sessions. Carries forward #52042 and #52746. Thanks @bobashopcashier.
1516
- 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.
1617
- fix(plugins): restrict bundled plugin dir resolution to trusted package roots. (#73275) Thanks @pgondhi987.
1718
- 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: 128 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,53 @@ 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+
sessionsResult: {
134+
ts: 0,
135+
path: "",
136+
count: 1,
137+
defaults: { modelProvider: "openai", model: "gpt-5", contextTokens: null },
138+
sessions: [row({ key: "agent:ops:main" })],
139+
},
140+
settings,
141+
applySettings(next: typeof settings) {
142+
state.settings = next;
143+
},
144+
loadAssistantIdentity: vi.fn(),
145+
resetToolStream: vi.fn(),
146+
resetChatScroll: vi.fn(),
147+
resetChatInputHistoryNavigation: vi.fn(),
148+
...overrides,
149+
} as unknown as AppViewState;
150+
return state;
151+
}
152+
93153
/* ================================================================
94154
* parseSessionKey – low-level key → type / fallback mapping
95155
* ================================================================ */
@@ -493,6 +553,73 @@ describe("resolveSessionOptionGroups", () => {
493553
});
494554
});
495555

556+
describe("createChatSession", () => {
557+
it("creates a dashboard session, switches to it, and preserves the current composer", async () => {
558+
const state = createChatSessionState();
559+
createSessionAndRefreshMock.mockResolvedValue("agent:ops:dashboard:new-chat");
560+
refreshChatAvatarMock.mockResolvedValue(undefined);
561+
refreshSlashCommandsMock.mockResolvedValue(undefined);
562+
loadChatHistoryMock.mockResolvedValue(undefined);
563+
loadSessionsMock.mockResolvedValue(undefined);
564+
565+
await createChatSession(state);
566+
567+
expect(createSessionAndRefreshMock).toHaveBeenCalledWith(
568+
state,
569+
{
570+
agentId: "ops",
571+
parentSessionKey: "agent:ops:main",
572+
},
573+
{
574+
activeMinutes: 0,
575+
limit: 0,
576+
includeGlobal: true,
577+
includeUnknown: true,
578+
},
579+
);
580+
expect(state.sessionKey).toBe("agent:ops:dashboard:new-chat");
581+
expect(state.settings.sessionKey).toBe("agent:ops:dashboard:new-chat");
582+
expect(state.chatMessage).toBe("draft prompt");
583+
expect(state.chatAttachments).toEqual([
584+
{ id: "att-1", mimeType: "image/png", dataUrl: "data:image/png;base64,AAA" },
585+
]);
586+
expect(state.chatMessages).toEqual([]);
587+
expect(loadChatHistoryMock).toHaveBeenCalledWith(state);
588+
});
589+
590+
it("ignores a stale create response after the active session changes", async () => {
591+
const state = createChatSessionState();
592+
createSessionAndRefreshMock.mockImplementation(async () => {
593+
state.sessionKey = "agent:ops:other";
594+
return "agent:ops:dashboard:new-chat";
595+
});
596+
597+
await createChatSession(state);
598+
599+
expect(state.sessionKey).toBe("agent:ops:other");
600+
expect(state.chatMessage).toBe("draft prompt");
601+
expect(state.chatMessages).toEqual([{ role: "assistant", content: "old" }]);
602+
expect(loadChatHistoryMock).not.toHaveBeenCalled();
603+
});
604+
605+
it("does not create or switch while a run is active", async () => {
606+
const state = createChatSessionState({
607+
chatRunId: "run-1",
608+
chatQueue: [{ id: "queued-1", text: "follow up", createdAt: 1 }],
609+
});
610+
611+
await createChatSession(state);
612+
613+
expect(createSessionAndRefreshMock).not.toHaveBeenCalled();
614+
expect(state.sessionKey).toBe("agent:ops:main");
615+
expect(state.chatMessage).toBe("draft prompt");
616+
expect(state.chatQueue).toEqual([{ id: "queued-1", text: "follow up", createdAt: 1 }]);
617+
expect(state.lastError).toBe(
618+
"Start a new session after the active run or queued messages finish.",
619+
);
620+
});
621+
});
622+
496623
describe("switchChatSession", () => {
497624
it("refreshes the chat avatar after clearing session-scoped state", async () => {
498625
const settings = createSettings();

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

Lines changed: 59 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";
@@ -98,6 +98,16 @@ function resetChatStateForSessionSwitch(state: AppViewState, sessionKey: string)
9898
});
9999
}
100100

101+
function canSwitchToNewChatSession(state: AppViewState): boolean {
102+
return (
103+
!state.chatLoading &&
104+
!state.chatSending &&
105+
!state.chatRunId &&
106+
state.chatStream === null &&
107+
state.chatQueue.length === 0
108+
);
109+
}
110+
101111
export function renderTab(state: AppViewState, tab: Tab, opts?: { collapsed?: boolean }) {
102112
const href = pathForTab(tab, state.basePath);
103113
const isActive = state.tab === tab;
@@ -547,6 +557,53 @@ export function switchChatSession(state: AppViewState, nextSessionKey: string) {
547557
void refreshSessionOptions(state);
548558
}
549559

560+
export async function createChatSession(state: AppViewState) {
561+
if (!state.client || !state.connected) {
562+
return;
563+
}
564+
if (!canSwitchToNewChatSession(state)) {
565+
state.lastError = "Start a new session after the active run or queued messages finish.";
566+
return;
567+
}
568+
569+
state.lastError = null;
570+
const previousSessionKey = state.sessionKey;
571+
const preservedDraft = state.chatMessage;
572+
const preservedAttachments = state.chatAttachments;
573+
const parentSessionKey = state.sessionsResult?.sessions.some(
574+
(row) => row.key === previousSessionKey,
575+
)
576+
? previousSessionKey
577+
: undefined;
578+
const nextSessionKey = await createSessionAndRefresh(
579+
state as unknown as Parameters<typeof createSessionAndRefresh>[0],
580+
{
581+
agentId: resolveAgentIdFromSessionKey(previousSessionKey),
582+
parentSessionKey,
583+
},
584+
{
585+
activeMinutes: 0,
586+
limit: 0,
587+
includeGlobal: true,
588+
includeUnknown: true,
589+
},
590+
);
591+
if (
592+
!nextSessionKey ||
593+
state.sessionKey !== previousSessionKey ||
594+
!canSwitchToNewChatSession(state)
595+
) {
596+
if (!nextSessionKey && state.sessionsError) {
597+
state.lastError = state.sessionsError;
598+
}
599+
return;
600+
}
601+
602+
switchChatSession(state, nextSessionKey);
603+
state.chatMessage = preservedDraft;
604+
state.chatAttachments = preservedAttachments;
605+
}
606+
550607
async function refreshSessionOptions(state: AppViewState) {
551608
await loadSessions(state as unknown as Parameters<typeof loadSessions>[0], {
552609
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";
@@ -2357,8 +2358,7 @@ export function renderApp(state: AppViewState) {
23572358
onDismissSideResult: () => {
23582359
state.chatSideResult = null;
23592360
},
2360-
onNewSession: () =>
2361-
state.handleSendChat("/new", { confirmReset: true, restoreDraft: true }),
2361+
onNewSession: () => void createChatSession(state),
23622362
onClearHistory: async () => {
23632363
if (!state.client || !state.connected) {
23642364
return;

ui/src/ui/controllers/sessions.test.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { afterEach, describe, expect, it, vi } from "vitest";
22
import {
33
applySessionsChangedEvent,
4+
createSessionAndRefresh,
45
deleteSessionsAndRefresh,
56
loadSessions,
67
subscribeSessions,
@@ -53,6 +54,72 @@ describe("subscribeSessions", () => {
5354
});
5455
});
5556

57+
describe("createSessionAndRefresh", () => {
58+
it("creates a dashboard session and refreshes the session list", async () => {
59+
const request = vi.fn(async (method: string) => {
60+
if (method === "sessions.create") {
61+
return { key: "agent:main:dashboard:abc" };
62+
}
63+
if (method === "sessions.list") {
64+
return {
65+
ts: 2,
66+
path: "(multiple)",
67+
count: 1,
68+
defaults: {},
69+
sessions: [{ key: "agent:main:dashboard:abc", kind: "direct", updatedAt: 2 }],
70+
};
71+
}
72+
throw new Error(`unexpected method: ${method}`);
73+
});
74+
const state = createState(request);
75+
76+
const key = await createSessionAndRefresh(
77+
state,
78+
{ agentId: "main", parentSessionKey: "agent:main:main" },
79+
{ activeMinutes: 0, limit: 0, includeGlobal: true, includeUnknown: true },
80+
);
81+
82+
expect(key).toBe("agent:main:dashboard:abc");
83+
expect(request).toHaveBeenNthCalledWith(1, "sessions.create", {
84+
agentId: "main",
85+
parentSessionKey: "agent:main:main",
86+
});
87+
expect(request).toHaveBeenNthCalledWith(2, "sessions.list", {
88+
includeGlobal: true,
89+
includeUnknown: true,
90+
});
91+
expect(state.sessionsResult?.sessions[0]?.key).toBe("agent:main:dashboard:abc");
92+
expect(state.sessionsLoading).toBe(false);
93+
});
94+
95+
it("keeps the current state when create does not return a key", async () => {
96+
const request = vi.fn(async (method: string) => {
97+
if (method === "sessions.create") {
98+
return {};
99+
}
100+
throw new Error(`unexpected method: ${method}`);
101+
});
102+
const state = createState(request);
103+
104+
const key = await createSessionAndRefresh(state);
105+
106+
expect(key).toBeNull();
107+
expect(state.sessionsError).toBe("Error: sessions.create returned no key");
108+
expect(state.sessionsLoading).toBe(false);
109+
expect(request).toHaveBeenCalledTimes(1);
110+
});
111+
112+
it("does not start a create mutation while sessions are loading", async () => {
113+
const request = vi.fn(async () => ({ key: "agent:main:dashboard:abc" }));
114+
const state = createState(request, { sessionsLoading: true });
115+
116+
const key = await createSessionAndRefresh(state);
117+
118+
expect(key).toBeNull();
119+
expect(request).not.toHaveBeenCalled();
120+
});
121+
});
122+
56123
describe("deleteSessionsAndRefresh", () => {
57124
it("deletes multiple sessions and refreshes", async () => {
58125
const request = vi.fn(async (method: string) => {

0 commit comments

Comments
 (0)