fix(control-ui): create sessions for typed /new

Route typed Control UI `/new` through the dashboard session create-and-switch flow used by the New Chat button.

Keep typed `/reset` as the explicit in-place gateway reset path, and document the Control UI slash-command boundary.

Fixes #69599.
This commit is contained in:
Val Alexander
2026-05-02 04:02:34 -05:00
committed by GitHub
parent 6669827135
commit 37aebf612b
6 changed files with 90 additions and 54 deletions

View File

@@ -71,6 +71,7 @@ Docs: https://docs.openclaw.ai
- CLI/message: skip gateway-stop hooks for read-only `message read` and bound stop-hook shutdown for other message actions, so one-shot Discord reads cannot hang behind plugin lifecycle cleanup.
- Plugins/web-provider: cache repeated bundled web search and web fetch provider registry loads by default while preserving explicit cache opt-outs. Supersedes #75992. Thanks @DmitryPogodaev.
- Agents/sandbox: preserve existing workspace file modes when sandbox edits atomically replace files, so 0644 files do not collapse to 0600 after Write/Edit/apply_patch. Fixes #44077. Thanks @patosullivan.
- Control UI/WebChat: route typed `/new` through the New Chat dashboard-session creation flow instead of `chat.send`, while keeping `/reset` as the explicit current-session reset. Fixes #69599. Thanks @WolvenRA.
- Agents/models: keep legacy CLI runtime model refs such as `claude-cli/*` in the configured allowlist after canonical runtime migration, so cron `payload.model` overrides keep working. Fixes #75753. Thanks @RyanSandoval.
- Codex/app-server: restart the shared Codex app-server client once when it closes during startup thread resume, preserving the existing thread binding instead of retrying `thread/start` on a closed client. Thanks @vincentkoc.
- Gateway/watch: keep colored subsystem log prefixes in the managed tmux pane even when the parent shell exports `NO_COLOR`, while preserving explicit `FORCE_COLOR=0` opt-out. Thanks @vincentkoc.
@@ -655,7 +656,7 @@ Docs: https://docs.openclaw.ai
- 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.
- 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.
- Control UI/WebChat: create a fresh dashboard session from the New Chat button instead of resetting the current transcript with `/new`, 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.

View File

@@ -125,6 +125,7 @@ Current source-of-truth:
<AccordionGroup>
<Accordion title="Sessions and runs">
- `/new [model]` starts a new session; `/reset` is the reset alias.
- Control UI intercepts typed `/new` to create and switch to a fresh dashboard session; typed `/reset` still runs the Gateway's in-place reset.
- `/reset soft [message]` keeps the current transcript, drops reused CLI backend session ids, and reruns startup/system-prompt loading in-place.
- `/compact [instructions]` compacts the session context. See [Compaction](/concepts/compaction).
- `/stop` aborts the current run.

View File

@@ -157,6 +157,7 @@ Imported themes are stored only in the current browser profile. They are not wri
- During an active send and the final history refresh, the chat view keeps local optimistic user/assistant messages visible if `chat.history` briefly returns an older snapshot; the canonical transcript replaces those local messages once the Gateway history catches up.
- `chat.inject` appends an assistant note to the session transcript and broadcasts a `chat` event for UI-only updates (no agent run, no channel delivery).
- The chat header model and thinking pickers patch the active session immediately through `sessions.patch`; they are persistent session overrides, not one-turn-only send options.
- Typing `/new` in the Control UI creates and switches to the same fresh dashboard session as New Chat. Typing `/reset` keeps the Gateway's explicit in-place reset for the current session.
- The chat model picker requests the Gateway's configured model view. If `agents.defaults.models` is present, that allowlist drives the picker. Otherwise the picker shows explicit `models.providers.*.models` entries plus providers with usable auth. The full catalog stays available through the debug `models.list` RPC with `view: "all"`.
- When fresh Gateway session usage reports show high context pressure, the chat composer area shows a context notice and, at recommended compaction levels, a compact button that runs the normal session compaction path. Stale token snapshots are hidden until the Gateway reports fresh usage again.

View File

@@ -469,9 +469,72 @@ describe("handleSendChat", () => {
expect(host.refreshSessionsAfterChat.size).toBe(0);
});
it("sends button-triggered /new resets after confirmation", async () => {
it("runs the fresh-session action for confirmed /new overrides", async () => {
const confirm = vi.fn(() => true);
vi.stubGlobal("confirm", confirm);
const request = vi.fn(async (method: string) => {
throw new Error(`Unexpected request: ${method}`);
});
const onSlashAction = vi.fn();
const host = makeHost({
client: { request } as unknown as ChatHost["client"],
chatMessage: "restore me",
sessionKey: "agent:main",
onSlashAction,
});
await handleSendChat(host, "/new", { confirmReset: true, restoreDraft: true });
expect(confirm).toHaveBeenCalledTimes(1);
expect(request).not.toHaveBeenCalled();
expect(onSlashAction).toHaveBeenCalledWith("new-session");
expect(host.chatMessage).toBe("restore me");
expect(host.refreshSessionsAfterChat.size).toBe(0);
});
it("routes typed /new through the fresh-session action without confirmation", async () => {
const confirm = vi.fn(() => false);
vi.stubGlobal("confirm", confirm);
const request = vi.fn(async (method: string) => {
throw new Error(`Unexpected request: ${method}`);
});
const onSlashAction = vi.fn();
const host = makeHost({
client: { request } as unknown as ChatHost["client"],
chatMessage: "/new",
sessionKey: "agent:main",
onSlashAction,
});
await handleSendChat(host);
expect(confirm).not.toHaveBeenCalled();
expect(request).not.toHaveBeenCalled();
expect(onSlashAction).toHaveBeenCalledWith("new-session");
expect(host.chatMessage).toBe("");
});
it("does not queue typed /new behind an active run", async () => {
const onSlashAction = vi.fn();
const host = makeHost({
chatMessage: "/new",
chatRunId: "run-main",
chatStream: "Working...",
onSlashAction,
});
await handleSendChat(host);
expect(onSlashAction).toHaveBeenCalledWith("new-session");
expect(host.chatQueue).toEqual([]);
expect(host.chatRunId).toBe("run-main");
expect(host.chatStream).toBe("Working...");
expect(host.chatMessage).toBe("");
});
it("preserves typed /reset command dispatch without confirmation", async () => {
const confirm = vi.fn(() => false);
vi.stubGlobal("confirm", confirm);
const request = vi.fn(async (method: string) => {
if (method === "chat.send") {
return { status: "started" };
@@ -480,57 +543,23 @@ describe("handleSendChat", () => {
});
const host = makeHost({
client: { request } as unknown as ChatHost["client"],
chatMessage: "restore me",
chatMessage: "/reset",
sessionKey: "agent:main",
});
await handleSendChat(host, "/new", { confirmReset: true, restoreDraft: true });
await handleSendChat(host);
expect(confirm).toHaveBeenCalledTimes(1);
expect(confirm).not.toHaveBeenCalled();
expect(request).toHaveBeenCalledWith(
"chat.send",
expect.objectContaining({
sessionKey: "agent:main",
message: "/new",
deliver: false,
idempotencyKey: expect.any(String),
message: "/reset",
}),
);
expect(host.chatMessage).toBe("restore me");
expect(host.refreshSessionsAfterChat).toContain(host.chatRunId);
expect(host.chatMessage).toBe("");
});
it.each(["/new", "/reset"])(
"preserves typed %s command dispatch without confirmation",
async (command) => {
const confirm = vi.fn(() => false);
vi.stubGlobal("confirm", confirm);
const request = vi.fn(async (method: string) => {
if (method === "chat.send") {
return { status: "started" };
}
throw new Error(`Unexpected request: ${method}`);
});
const host = makeHost({
client: { request } as unknown as ChatHost["client"],
chatMessage: command,
sessionKey: "agent:main",
});
await handleSendChat(host);
expect(confirm).not.toHaveBeenCalled();
expect(request).toHaveBeenCalledWith(
"chat.send",
expect.objectContaining({
sessionKey: "agent:main",
message: command,
}),
);
expect(host.chatMessage).toBe("");
},
);
it("keeps slash-command model changes in sync with the chat header cache", async () => {
vi.stubGlobal(
"fetch",

View File

@@ -69,7 +69,7 @@ export type ChatHost = ChatInputHistoryState & {
pendingAbort?: { runId: string; sessionKey: string } | null;
chatSubmitGuards?: Map<string, Promise<void>>;
/** Callback for slash-command side effects that need app-level access. */
onSlashAction?: (action: string) => void;
onSlashAction?: (action: string) => void | Promise<void>;
};
export type ChatSendOptions = {
@@ -527,7 +527,7 @@ export async function handleSendChat(
}
function shouldQueueLocalSlashCommand(name: string): boolean {
return !["stop", "focus", "export-session", "steer", "redirect"].includes(name);
return !["stop", "focus", "export-session", "steer", "redirect", "new"].includes(name);
}
// ── Slash Command Dispatch ──
@@ -543,11 +543,11 @@ async function dispatchSlashCommand(
await handleAbortChat(host);
return;
case "new":
await sendChatMessageNow(host, "/new", {
refreshSessions: true,
previousDraft: sendOpts?.previousDraft,
restoreDraft: sendOpts?.restoreDraft,
});
if (!host.onSlashAction) {
host.lastError = "New Chat is unavailable.";
return;
}
await host.onSlashAction("new-session");
return;
case "reset":
await sendChatMessageNow(host, "/reset", {
@@ -560,10 +560,10 @@ async function dispatchSlashCommand(
await clearChatHistory(host);
return;
case "focus":
host.onSlashAction?.("toggle-focus");
await host.onSlashAction?.("toggle-focus");
return;
case "export-session":
host.onSlashAction?.("export");
await host.onSlashAction?.("export");
return;
}
@@ -596,7 +596,7 @@ async function dispatchSlashCommand(
...host.chatModelOverrides,
[targetSessionKey]: result.sessionPatch.modelOverride ?? null,
};
host.onSlashAction?.("refresh-tools-effective");
await host.onSlashAction?.("refresh-tools-effective");
}
if (result.action === "refresh") {

View File

@@ -34,6 +34,7 @@ import {
handleFirstUpdated,
handleUpdated,
} from "./app-lifecycle.ts";
import { createChatSession as createChatSessionInternal } from "./app-render.helpers.ts";
import { renderApp } from "./app-render.ts";
import {
exportLogs as exportLogsInternal,
@@ -225,7 +226,7 @@ export class OpenClawApp extends LitElement {
private chatMobileControlsTrigger: HTMLElement | null = null;
@state() navDrawerOpen = false;
onSlashAction?: (action: string) => void;
onSlashAction?: (action: string) => void | Promise<void>;
chatLocalInputHistoryBySession: Record<string, Array<{ text: string; ts: number }>> = {};
chatInputHistorySessionKey: string | null = null;
chatInputHistoryItems: string[] | null = null;
@@ -605,8 +606,11 @@ export class OpenClawApp extends LitElement {
connectedCallback() {
super.connectedCallback();
this.onSlashAction = (action: string) => {
this.onSlashAction = async (action: string) => {
switch (action) {
case "new-session":
await createChatSessionInternal(this as unknown as AppViewState);
break;
case "toggle-focus":
this.applySettings({
...this.settings,
@@ -617,7 +621,7 @@ export class OpenClawApp extends LitElement {
exportChatMarkdown(this.chatMessages, this.assistantName);
break;
case "refresh-tools-effective": {
void refreshVisibleToolsEffectiveForCurrentSessionInternal(this);
await refreshVisibleToolsEffectiveForCurrentSessionInternal(this);
break;
}
}