mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-24 19:35:28 -06:00
fix(ui): isolate new-session composer lifecycle
This commit is contained in:
@@ -4286,7 +4286,6 @@ ui/src/pages/model-providers/model-providers-page.ts 1
|
||||
ui/src/pages/model-providers/view.ts 3
|
||||
ui/src/pages/model-setup/provider-picker.ts 3
|
||||
ui/src/pages/model-setup/view.ts 2
|
||||
ui/src/pages/new-session/composer.ts 1
|
||||
ui/src/pages/new-session/detail-chip.ts 4
|
||||
ui/src/pages/new-session/discovery.ts 4
|
||||
ui/src/pages/new-session/draft-place-browser.ts 4
|
||||
|
||||
@@ -128,7 +128,7 @@ suite.define(() => {
|
||||
];
|
||||
const gateway = await installMockGateway(page, {
|
||||
methodResponses: {
|
||||
"commands.list": { commands },
|
||||
"chat.metadata": { commands, models: [] },
|
||||
},
|
||||
});
|
||||
await page.goto(`${suite.server.baseUrl}new`);
|
||||
@@ -138,7 +138,6 @@ suite.define(() => {
|
||||
await composer.fill("/");
|
||||
const slashPicker = page.getByRole("listbox", { name: "Slash commands" });
|
||||
await slashPicker.waitFor({ state: "visible" });
|
||||
await expect.poll(() => gateway.getRequests("commands.list")).toHaveLength(1);
|
||||
await expect
|
||||
.poll(() => slashPicker.getByRole("option", { name: /\/status/u }).count())
|
||||
.toBe(1);
|
||||
|
||||
@@ -517,14 +517,15 @@ function getSlashCommandRelevance(command: SlashCommandDef, filter: string): num
|
||||
export function getSlashCommandCompletions(
|
||||
filter: string,
|
||||
options?: { showAll?: boolean },
|
||||
catalog: readonly SlashCommandDef[] = SLASH_COMMANDS,
|
||||
): SlashCommandDef[] {
|
||||
const lower = normalizeLowercaseStringOrEmpty(filter);
|
||||
const showAll = options?.showAll ?? false;
|
||||
let commands = lower
|
||||
? SLASH_COMMANDS.filter(
|
||||
? catalog.filter(
|
||||
(command) => getSlashCommandRelevance(command, lower) < NON_MATCHING_COMMAND_RANK,
|
||||
)
|
||||
: SLASH_COMMANDS;
|
||||
: catalog;
|
||||
|
||||
// When no filter text and not explicitly showing all, hide "power" tier commands
|
||||
if (!lower && !showAll) {
|
||||
@@ -556,12 +557,14 @@ export function getSkillDisplayName(command: SlashCommandDef): string {
|
||||
return command.skillDisplayName?.trim() || command.name;
|
||||
}
|
||||
|
||||
export function getSkillCommandCompletions(filter: string): SlashCommandDef[] {
|
||||
export function getSkillCommandCompletions(
|
||||
filter: string,
|
||||
catalog: readonly SlashCommandDef[] = SLASH_COMMANDS,
|
||||
): SlashCommandDef[] {
|
||||
const lower = normalizeLowercaseStringOrEmpty(filter);
|
||||
const normalized = lower.replace(/[\s_]+/gu, "-");
|
||||
return SLASH_COMMANDS.filter(
|
||||
(command) => command.source === "skill" && command.skillModelVisible === true,
|
||||
)
|
||||
return catalog
|
||||
.filter((command) => command.source === "skill" && command.skillModelVisible === true)
|
||||
.filter((command) => {
|
||||
const displayName = normalizeLowercaseStringOrEmpty(getSkillDisplayName(command));
|
||||
const displayLookup = displayName.replace(/[\s_]+/gu, "-");
|
||||
|
||||
@@ -260,16 +260,32 @@ function loadRemoteSlashCommands(
|
||||
return inFlight;
|
||||
}
|
||||
|
||||
export function activateSlashCommands(params: {
|
||||
export function readSlashCommandCatalog(params: {
|
||||
client: GatewayBrowserClient | null;
|
||||
agentId?: string | null;
|
||||
}): void {
|
||||
}): readonly SlashCommandDef[] {
|
||||
if (!params.client) {
|
||||
return buildFallbackSlashCommands();
|
||||
}
|
||||
const agentId = params.agentId?.trim();
|
||||
const cached = params.client
|
||||
? getRemoteSlashCommandCache(params.client).get(remoteSlashCommandCacheKey(agentId))
|
||||
: undefined;
|
||||
refreshSeq += 1;
|
||||
replaceSlashCommands(cached?.commands ?? buildFallbackSlashCommands());
|
||||
const metadata = peekChatMetadata(params.client, agentId);
|
||||
if (Array.isArray(metadata?.commands)) {
|
||||
return buildSlashCommandsFromEntries(getRemoteCommandEntries(metadata));
|
||||
}
|
||||
return (
|
||||
getRemoteSlashCommandCache(params.client).get(remoteSlashCommandCacheKey(agentId))?.commands ??
|
||||
buildFallbackSlashCommands()
|
||||
);
|
||||
}
|
||||
|
||||
export async function loadSlashCommandCatalog(params: {
|
||||
client: GatewayBrowserClient | null;
|
||||
agentId?: string | null;
|
||||
}): Promise<readonly SlashCommandDef[]> {
|
||||
if (!params.client) {
|
||||
return buildFallbackSlashCommands();
|
||||
}
|
||||
return await loadRemoteSlashCommands(params.client, params.agentId?.trim());
|
||||
}
|
||||
|
||||
export function applyRemoteSlashCommandsResult(params: {
|
||||
|
||||
@@ -4,8 +4,11 @@ import { i18n } from "../../i18n/index.ts";
|
||||
import { renderChatComposer, resetChatComposerState } from "./components/chat-composer.ts";
|
||||
|
||||
type ComposerProps = Parameters<typeof renderChatComposer>[0];
|
||||
type SessionComposerProps = Extract<ComposerProps, { style?: "session" }>;
|
||||
|
||||
export function createComposerProps(overrides: Partial<ComposerProps> = {}): ComposerProps {
|
||||
export function createComposerProps(
|
||||
overrides: Partial<SessionComposerProps> = {},
|
||||
): SessionComposerProps {
|
||||
return {
|
||||
paneId: crypto.randomUUID(),
|
||||
sessionKey: "main",
|
||||
@@ -27,7 +30,7 @@ export function createComposerProps(overrides: Partial<ComposerProps> = {}): Com
|
||||
};
|
||||
}
|
||||
|
||||
export function renderComposerFixture(overrides: Partial<ComposerProps> = {}) {
|
||||
export function renderComposerFixture(overrides: Partial<SessionComposerProps> = {}) {
|
||||
const container = document.createElement("div");
|
||||
const props = createComposerProps(overrides);
|
||||
render(renderChatComposer(props), container);
|
||||
|
||||
@@ -11,13 +11,15 @@ export function syncChatComposerCompletionOwner(params: {
|
||||
}): void {
|
||||
const { draftKey, props, requestUpdate, state } = params;
|
||||
const previousDraftKey = state.completionDraftKey;
|
||||
const ownerChanged = previousDraftKey !== draftKey;
|
||||
const gatewayClient = props.gatewayClient ?? null;
|
||||
const ownerChanged =
|
||||
previousDraftKey !== draftKey || state.completionGatewayClient !== gatewayClient;
|
||||
state.completionDraftKey = draftKey;
|
||||
state.completionGatewayClient = gatewayClient;
|
||||
if (!ownerChanged) {
|
||||
return;
|
||||
}
|
||||
|
||||
props.onCompletionOwnerChange?.();
|
||||
if (previousDraftKey === null) {
|
||||
return;
|
||||
}
|
||||
@@ -25,7 +27,10 @@ export function syncChatComposerCompletionOwner(params: {
|
||||
resetSkillMenuState(state);
|
||||
queueMicrotask(() => {
|
||||
const currentState = getChatComposerState(props.paneId);
|
||||
if (currentState.completionDraftKey !== draftKey) {
|
||||
if (
|
||||
currentState.completionDraftKey !== draftKey ||
|
||||
currentState.completionGatewayClient !== gatewayClient
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const currentDraft = props.getDraft?.() ?? props.draft;
|
||||
|
||||
@@ -141,7 +141,7 @@ export function updateSkillMenu(
|
||||
state.skillCommandRefreshTargetStart = target.start;
|
||||
requestSkillCommandRefresh(props, requestUpdate, getCurrentValue, getCurrentCaret);
|
||||
}
|
||||
const items = getSkillCommandCompletions(target.query).filter(
|
||||
const items = getSkillCommandCompletions(target.query, props.getCommandCatalog?.()).filter(
|
||||
(command) => props.commandFilter?.(command) ?? true,
|
||||
);
|
||||
state.skillMenuTarget = target;
|
||||
|
||||
@@ -90,7 +90,8 @@ export function updateSlashMenu(
|
||||
closeSlashMenuIfNeeded(state, requestUpdate);
|
||||
return;
|
||||
}
|
||||
const cmd = SLASH_COMMANDS.find(
|
||||
const commandCatalog = props.getCommandCatalog?.() ?? SLASH_COMMANDS;
|
||||
const cmd = commandCatalog.find(
|
||||
(entry) => entry.name === cmdName && (props.commandFilter?.(entry) ?? true),
|
||||
);
|
||||
if (cmd?.argOptions?.length) {
|
||||
@@ -117,9 +118,11 @@ export function updateSlashMenu(
|
||||
if (!opts.skipSlashIntent) {
|
||||
requestSlashCommandRefresh(value, props, requestUpdate, getCurrentValue);
|
||||
}
|
||||
const items = getSlashCommandCompletions(match[1] ?? "", { showAll: true }).filter(
|
||||
(command) => props.commandFilter?.(command) ?? true,
|
||||
);
|
||||
const items = getSlashCommandCompletions(
|
||||
match[1] ?? "",
|
||||
{ showAll: true },
|
||||
props.getCommandCatalog?.() ?? SLASH_COMMANDS,
|
||||
).filter((command) => props.commandFilter?.(command) ?? true);
|
||||
state.slashMenuItems = items;
|
||||
state.slashMenuOpen = items.length > 0;
|
||||
state.slashMenuIndex = 0;
|
||||
|
||||
@@ -49,6 +49,7 @@ function createChatComposerState(): ChatComposerState {
|
||||
dictation: null,
|
||||
dictationDraftKey: null,
|
||||
completionDraftKey: null,
|
||||
completionGatewayClient: null,
|
||||
dictationSelection: null,
|
||||
};
|
||||
}
|
||||
@@ -158,6 +159,13 @@ export function releaseMicrophoneDeviceWatch(state: ChatComposerState) {
|
||||
state.microphoneDeviceWatch = null;
|
||||
}
|
||||
|
||||
function invalidatePendingCompletionRefreshes(state: ChatComposerState): void {
|
||||
state.slashCommandRefreshGeneration += 1;
|
||||
state.slashCommandRefreshPending = false;
|
||||
state.skillCommandRefreshGeneration += 1;
|
||||
state.skillCommandRefreshPending = false;
|
||||
}
|
||||
|
||||
export function resetChatComposerState(paneId?: string) {
|
||||
if (paneId) {
|
||||
// Goal elapsed timers are keyed by element and cleaned up when their
|
||||
@@ -165,6 +173,7 @@ export function resetChatComposerState(paneId?: string) {
|
||||
const paneState = composerStates.get(paneId);
|
||||
paneState?.dictation?.dispose();
|
||||
if (paneState) {
|
||||
invalidatePendingCompletionRefreshes(paneState);
|
||||
if (paneState.composerInput) {
|
||||
disconnectComposerPopoverAnchorObserver(paneState.composerInput);
|
||||
}
|
||||
@@ -178,6 +187,7 @@ export function resetChatComposerState(paneId?: string) {
|
||||
}
|
||||
for (const state of composerStates.values()) {
|
||||
state.dictation?.dispose();
|
||||
invalidatePendingCompletionRefreshes(state);
|
||||
if (state.composerInput) {
|
||||
disconnectComposerPopoverAnchorObserver(state.composerInput);
|
||||
}
|
||||
|
||||
@@ -62,9 +62,7 @@ type ChatComposerDisabledBannerContent = {
|
||||
export type ChatComposerDisabledBanner = ChatComposerDisabledBannerContent &
|
||||
({ kind: "above-composer" } | { kind: "composer-replacement" });
|
||||
|
||||
export type ChatComposerProps = ChatAttachmentControlsProps & {
|
||||
/** Lightweight first-turn rendering omits controls that require an existing session. */
|
||||
style?: "session" | "new-session";
|
||||
type ChatComposerCommonProps = ChatAttachmentControlsProps & {
|
||||
shellClass?: string;
|
||||
textareaClass?: string;
|
||||
placeholder?: string;
|
||||
@@ -125,8 +123,8 @@ export type ChatComposerProps = ChatAttachmentControlsProps & {
|
||||
typingActors?: readonly { id: string; label: string }[];
|
||||
onTypingChange?: (typing: boolean) => void;
|
||||
composerControls?: TemplateResult | typeof nothing;
|
||||
getCommandCatalog?: () => readonly SlashCommandDef[];
|
||||
onDraftChange: (next: string) => void;
|
||||
onCompletionOwnerChange?: () => void;
|
||||
onHistoryKeydown?: (input: ChatInputHistoryKeyInput) => ChatInputHistoryKeyResult;
|
||||
onSlashIntent?: () => void | Promise<void>;
|
||||
/** Route-owned Enter semantics that run before ordinary message submission. */
|
||||
@@ -139,7 +137,6 @@ export type ChatComposerProps = ChatAttachmentControlsProps & {
|
||||
onDismissRealtimeTalkError?: () => void;
|
||||
onDictationError?: (message: string) => void;
|
||||
onAbort?: () => void;
|
||||
onQueueRemove?: (id: string) => void;
|
||||
onQueueRetry?: (id: string) => void;
|
||||
onQueueSteer?: (id: string) => void;
|
||||
onQueueMove?: (id: string, toIndex: number) => void;
|
||||
@@ -151,6 +148,20 @@ export type ChatComposerProps = ChatAttachmentControlsProps & {
|
||||
onGatewayQuestionSkip?: (id: string) => void | Promise<void>;
|
||||
};
|
||||
|
||||
type ChatComposerModeProps =
|
||||
| {
|
||||
/** Existing-session rendering includes queue, run, reply, and context controls. */
|
||||
style?: "session";
|
||||
onQueueRemove: (id: string) => void;
|
||||
}
|
||||
| {
|
||||
/** Lightweight first-turn rendering omits controls that require an existing session. */
|
||||
style: "new-session";
|
||||
onQueueRemove?: never;
|
||||
};
|
||||
|
||||
export type ChatComposerProps = ChatComposerCommonProps & ChatComposerModeProps;
|
||||
|
||||
type PendingClearedSubmittedDraft = {
|
||||
key: string;
|
||||
value: string;
|
||||
@@ -210,5 +221,6 @@ export type ChatComposerState = {
|
||||
dictation: ComposerDictationController | null;
|
||||
dictationDraftKey: string | null;
|
||||
completionDraftKey: string | null;
|
||||
completionGatewayClient: GatewayBrowserClient | null;
|
||||
dictationSelection: { start: number; end: number } | null;
|
||||
};
|
||||
|
||||
@@ -170,7 +170,7 @@ export function renderChatComposerView(context: ChatComposerViewContext) {
|
||||
onQueueMove: props.onQueueMove,
|
||||
onQueueEdit: props.queuedEdit?.onEdit,
|
||||
editingId: props.queuedEdit?.editingId ?? null,
|
||||
onQueueRemove: props.onQueueRemove ?? (() => undefined),
|
||||
onQueueRemove: props.onQueueRemove,
|
||||
})
|
||||
: nothing}
|
||||
${isSessionStyle && props.runError
|
||||
|
||||
@@ -5,7 +5,12 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CommandsListResult } from "../../../../packages/gateway-protocol/src/index.js";
|
||||
import type { GatewayBrowserClient } from "../../api/gateway.ts";
|
||||
import type { ApplicationContext } from "../../app/context.ts";
|
||||
import { buildFallbackSlashCommands, replaceSlashCommands } from "../../lib/chat/commands.ts";
|
||||
import { rememberChatMetadata } from "../../lib/chat/chat-metadata-store.ts";
|
||||
import {
|
||||
buildFallbackSlashCommands,
|
||||
replaceSlashCommands,
|
||||
SLASH_COMMANDS,
|
||||
} from "../../lib/chat/commands.ts";
|
||||
import { waitForFast } from "../../test-helpers/wait-for.ts";
|
||||
import { adjustTextareaHeight } from "../chat/components/chat-composer-dom.ts";
|
||||
import { resetChatComposerState } from "../chat/components/chat-composer.ts";
|
||||
@@ -92,12 +97,29 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("new-session composer prompt authoring", () => {
|
||||
it("shares slash and skill completion while omitting existing-session actions", () => {
|
||||
it("shares slash and skill completion while omitting existing-session actions", async () => {
|
||||
const container = document.createElement("div");
|
||||
const attachmentDraft = new NewSessionAttachmentDraft(() => draw());
|
||||
const onSubmit = vi.fn();
|
||||
let message = "";
|
||||
attachmentDrafts.push(attachmentDraft);
|
||||
const client = { request: vi.fn() } as unknown as GatewayBrowserClient;
|
||||
rememberChatMetadata(client, "main", {
|
||||
commands: [
|
||||
{
|
||||
name: "prose",
|
||||
textAliases: ["/prose"],
|
||||
description: "Draft polished prose.",
|
||||
source: "skill",
|
||||
scope: "text",
|
||||
acceptsArgs: true,
|
||||
skillModelVisible: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
const context = {
|
||||
gateway: { snapshot: { client, phase: "connected" } },
|
||||
} as unknown as ApplicationContext;
|
||||
|
||||
const draw = () => {
|
||||
render(
|
||||
@@ -106,7 +128,7 @@ describe("new-session composer prompt authoring", () => {
|
||||
getCurrentAgentId: () => "main",
|
||||
attachmentDraft,
|
||||
canSubmit: true,
|
||||
context: undefined,
|
||||
context,
|
||||
isCatalogTarget: true,
|
||||
message,
|
||||
modelControl: new NewSessionModelControl(draw),
|
||||
@@ -132,17 +154,6 @@ describe("new-session composer prompt authoring", () => {
|
||||
textarea.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText" }));
|
||||
};
|
||||
draw();
|
||||
replaceSlashCommands([
|
||||
...buildFallbackSlashCommands(),
|
||||
{
|
||||
key: "prose",
|
||||
name: "prose",
|
||||
description: "Draft polished prose.",
|
||||
source: "skill",
|
||||
skillModelVisible: true,
|
||||
},
|
||||
]);
|
||||
|
||||
const shell = container.querySelector<HTMLElement>(".agent-chat__composer-shell");
|
||||
expect(shell?.dataset.composerStyle).toBe("new-session");
|
||||
expect(container.querySelector(".agent-chat__composer-status-stack")).toBeNull();
|
||||
@@ -161,7 +172,9 @@ describe("new-session composer prompt authoring", () => {
|
||||
input("Polish this with $pro");
|
||||
const skillMenu = container.querySelector<HTMLElement>("[role='listbox']");
|
||||
expect(skillMenu?.getAttribute("aria-label")).toBe("Skill references");
|
||||
expect(skillMenu?.querySelector(".slash-menu-name")?.textContent).toBe("prose");
|
||||
await waitForFast(() =>
|
||||
expect(skillMenu?.querySelector(".slash-menu-name")?.textContent).toBe("prose"),
|
||||
);
|
||||
|
||||
const textarea = container.querySelector<HTMLTextAreaElement>("textarea");
|
||||
textarea?.dispatchEvent(
|
||||
@@ -277,9 +290,118 @@ describe("new-session composer prompt authoring", () => {
|
||||
expect(names).not.toContain("/agent-a-only");
|
||||
expect(names).toContain("/agent-b-only");
|
||||
});
|
||||
expect(SLASH_COMMANDS.map((entry) => entry.name)).toContain("previous-agent-only");
|
||||
expect(SLASH_COMMANDS.map((entry) => entry.name)).not.toContain("agent-a-only");
|
||||
expect(SLASH_COMMANDS.map((entry) => entry.name)).not.toContain("agent-b-only");
|
||||
expect(message).toBe("/");
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ kind: "slash", draft: "/", resetBeforeSwitch: false },
|
||||
{ kind: "skill", draft: "Use $", resetBeforeSwitch: false },
|
||||
{ kind: "slash", draft: "/", resetBeforeSwitch: true },
|
||||
{ kind: "skill", draft: "Use $", resetBeforeSwitch: true },
|
||||
] as const)(
|
||||
"fences delayed $kind completion across a Gateway client replacement (reset=$resetBeforeSwitch)",
|
||||
async ({ kind, draft, resetBeforeSwitch }) => {
|
||||
const remoteCommand = (owner: "a" | "b") =>
|
||||
kind === "skill"
|
||||
? {
|
||||
name: `${owner}-skill`,
|
||||
textAliases: [`/${owner}-skill`],
|
||||
description: `Only available from client ${owner.toUpperCase()}.`,
|
||||
source: "skill" as const,
|
||||
scope: "text" as const,
|
||||
acceptsArgs: true,
|
||||
skillModelVisible: true,
|
||||
}
|
||||
: {
|
||||
name: `${owner}-only`,
|
||||
textAliases: [`/${owner}-only`],
|
||||
description: `Only available from client ${owner.toUpperCase()}.`,
|
||||
source: "plugin" as const,
|
||||
scope: "text" as const,
|
||||
acceptsArgs: false,
|
||||
};
|
||||
let resolveClientA: (value: CommandsListResult) => void = () => undefined;
|
||||
const clientAResult = new Promise<CommandsListResult>((resolve) => {
|
||||
resolveClientA = resolve;
|
||||
});
|
||||
const requestA = vi.fn(async () => await clientAResult);
|
||||
const requestB = vi.fn(async () => ({ commands: [remoteCommand("b")] }));
|
||||
const clientA = { request: requestA } as unknown as GatewayBrowserClient;
|
||||
const clientB = { request: requestB } as unknown as GatewayBrowserClient;
|
||||
let activeClient = clientA;
|
||||
const container = document.createElement("div");
|
||||
const attachmentDraft = new NewSessionAttachmentDraft(() => draw());
|
||||
attachmentDrafts.push(attachmentDraft);
|
||||
let message = "";
|
||||
|
||||
const draw = () => {
|
||||
const context = {
|
||||
gateway: { snapshot: { client: activeClient, phase: "connected" } },
|
||||
} as unknown as ApplicationContext;
|
||||
render(
|
||||
renderNewSessionDraftComposer({
|
||||
agentId: "main",
|
||||
getCurrentAgentId: () => "main",
|
||||
attachmentDraft,
|
||||
canSubmit: true,
|
||||
context,
|
||||
isCatalogTarget: true,
|
||||
message,
|
||||
modelControl: new NewSessionModelControl(draw),
|
||||
requiresModifier: false,
|
||||
submitting: false,
|
||||
onInput: (next) => {
|
||||
message = next;
|
||||
draw();
|
||||
},
|
||||
onRequestUpdate: draw,
|
||||
onSubmit: () => undefined,
|
||||
}),
|
||||
container,
|
||||
);
|
||||
};
|
||||
const input = () => {
|
||||
const textarea = container.querySelector<HTMLTextAreaElement>("textarea");
|
||||
if (!textarea) {
|
||||
throw new Error("Expected composer textarea");
|
||||
}
|
||||
textarea.value = draft;
|
||||
textarea.setSelectionRange(draft.length, draft.length);
|
||||
textarea.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText" }));
|
||||
};
|
||||
const menuNames = () =>
|
||||
Array.from(container.querySelectorAll<HTMLElement>(".slash-menu-name")).map((entry) =>
|
||||
entry.textContent?.trim(),
|
||||
);
|
||||
|
||||
draw();
|
||||
input();
|
||||
await waitForFast(() => expect(requestA).toHaveBeenCalledOnce());
|
||||
|
||||
if (resetBeforeSwitch) {
|
||||
resetChatComposerState("new-session");
|
||||
}
|
||||
activeClient = clientB;
|
||||
draw();
|
||||
input();
|
||||
await waitForFast(() => expect(requestB).toHaveBeenCalledOnce());
|
||||
await waitForFast(() =>
|
||||
expect(menuNames()).toContain(kind === "skill" ? "b-skill" : "/b-only"),
|
||||
);
|
||||
|
||||
resolveClientA({ commands: [remoteCommand("a")] });
|
||||
await clientAResult;
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(menuNames()).not.toContain(kind === "skill" ? "a-skill" : "/a-only");
|
||||
expect(menuNames()).toContain(kind === "skill" ? "b-skill" : "/b-only");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("new-session composer keyboard submission", () => {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { t } from "../../i18n/index.ts";
|
||||
import "../../components/tooltip.ts";
|
||||
import type { ChatAttachment } from "../../lib/chat/chat-types.ts";
|
||||
import { formatUiError } from "../../lib/format-error.ts";
|
||||
import { activateSlashCommands, refreshSlashCommands } from "../chat/chat-commands.ts";
|
||||
import { loadSlashCommandCatalog, readSlashCommandCatalog } from "../chat/chat-commands.ts";
|
||||
import { renderChatAttachmentMenu } from "../chat/components/chat-attachments.ts";
|
||||
import { renderChatComposer } from "../chat/components/chat-composer.ts";
|
||||
import type { NewSessionAttachmentDraft } from "./attachment-draft.ts";
|
||||
@@ -188,6 +188,7 @@ function renderNewSessionComposer(options: NewSessionComposerOptions) {
|
||||
paneId: "new-session",
|
||||
sessionKey: "new-session",
|
||||
currentAgentId: options.agentId,
|
||||
gatewayClient: client,
|
||||
connected: context ? context.gateway.snapshot.phase === "connected" : true,
|
||||
canSend: canCompose,
|
||||
canSubmit: options.canSubmit,
|
||||
@@ -209,26 +210,18 @@ function renderNewSessionComposer(options: NewSessionComposerOptions) {
|
||||
onPendingReadsChange: options.onPendingReadsChange,
|
||||
onOpenImage: options.onOpenImage,
|
||||
composerControls,
|
||||
getCommandCatalog: () =>
|
||||
readSlashCommandCatalog({ client, agentId: options.getCurrentAgentId() }),
|
||||
getDraft: () => options.message,
|
||||
onDraftChange: options.onInput,
|
||||
onRequestUpdate: options.onRequestUpdate,
|
||||
onCompletionOwnerChange: () =>
|
||||
activateSlashCommands({ client, agentId: options.getCurrentAgentId() }),
|
||||
onSlashIntent: client
|
||||
? () => {
|
||||
const agentId = options.agentId;
|
||||
return refreshSlashCommands({
|
||||
const agentId = options.getCurrentAgentId();
|
||||
return loadSlashCommandCatalog({
|
||||
client,
|
||||
agentId,
|
||||
shouldApply: () => {
|
||||
const snapshot = options.context?.gateway.snapshot;
|
||||
return (
|
||||
snapshot?.phase === "connected" &&
|
||||
snapshot.client === client &&
|
||||
options.getCurrentAgentId() === agentId
|
||||
);
|
||||
},
|
||||
});
|
||||
}).then(() => undefined);
|
||||
}
|
||||
: undefined,
|
||||
onSubmitShortcut: () => {
|
||||
|
||||
@@ -19,6 +19,7 @@ import { isTerminalAvailable } from "../../lib/terminal-availability.ts";
|
||||
import { createManagedWorktree } from "../../lib/worktrees/create-worktree.ts";
|
||||
import { buildChatApiAttachments, restoreChatApiAttachments } from "../chat/attachment-api.ts";
|
||||
import { requiresChatModelSetup } from "../chat/chat-model-setup.ts";
|
||||
import { resetChatComposerState } from "../chat/components/chat-composer-state.ts";
|
||||
import { prepareInitialUserMessageHandoff } from "../chat/initial-turn-handoff.ts";
|
||||
import { NewSessionAttachmentDraft } from "./attachment-draft.ts";
|
||||
import * as catalog from "./catalog-target.ts";
|
||||
@@ -302,6 +303,7 @@ export class DraftSubmissionFlow {
|
||||
}
|
||||
|
||||
resetDraft() {
|
||||
resetChatComposerState("new-session");
|
||||
const preservePendingCloud = Boolean(this.pendingCloud.sessionKey);
|
||||
this.blockedSubmitGate = null;
|
||||
this.invalidate();
|
||||
@@ -667,6 +669,7 @@ export class DraftSubmissionFlow {
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
resetChatComposerState("new-session");
|
||||
this.attachmentDraft.reset({ release: true });
|
||||
}
|
||||
|
||||
|
||||
@@ -123,7 +123,7 @@ describe("new session draft route ownership", () => {
|
||||
expect(page.querySelector('[role="listbox"][aria-label="Slash commands"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("retires the open command menu through the actual agent selector", async () => {
|
||||
it("keeps active-chat commands out through the actual agent selector", async () => {
|
||||
const page = document.createElement("openclaw-new-session-page") as NewSessionElement;
|
||||
page.place.agents = () =>
|
||||
[
|
||||
@@ -141,8 +141,13 @@ describe("new session draft route ownership", () => {
|
||||
source: "plugin",
|
||||
},
|
||||
]);
|
||||
await enterMessage(page, "/agent-a");
|
||||
expect(page.querySelector(".slash-menu-name")?.textContent?.trim()).toBe("/agent-a-only");
|
||||
await enterMessage(page, "/");
|
||||
expect(page.querySelector('[role="listbox"][aria-label="Slash commands"]')).not.toBeNull();
|
||||
expect(
|
||||
Array.from(page.querySelectorAll<HTMLElement>(".slash-menu-name")).map((entry) =>
|
||||
entry.textContent?.trim(),
|
||||
),
|
||||
).not.toContain("/agent-a-only");
|
||||
|
||||
const selector = page.querySelector<HTMLElement & { onSelect: (agentId: string) => void }>(
|
||||
"openclaw-agent-select",
|
||||
|
||||
@@ -18,7 +18,6 @@ import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts";
|
||||
import { SubscriptionsController } from "../../lit/subscriptions-controller.ts";
|
||||
import "../../styles/chat.css";
|
||||
import "../../styles/new-session.css";
|
||||
import { resetChatComposerState } from "../chat/components/chat-composer.ts";
|
||||
import { renderChatImageLightbox } from "../chat/components/chat-image-lightbox.ts";
|
||||
import { renderWelcomeState } from "../chat/components/chat-welcome.ts";
|
||||
import * as catalog from "./catalog-target.ts";
|
||||
@@ -237,7 +236,6 @@ class NewSessionPage extends OpenClawLightDomElement {
|
||||
this.browser.disconnect();
|
||||
this.submission.disconnect();
|
||||
this.closeConnectMachine();
|
||||
resetChatComposerState("new-session");
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
@@ -310,7 +308,6 @@ class NewSessionPage extends OpenClawLightDomElement {
|
||||
}
|
||||
|
||||
private resetDraft() {
|
||||
resetChatComposerState("new-session");
|
||||
this.place.resetDraft();
|
||||
this.submission.resetDraft();
|
||||
this.messageOwnerKey = catalog.routeKey(this.data);
|
||||
@@ -324,13 +321,9 @@ class NewSessionPage extends OpenClawLightDomElement {
|
||||
});
|
||||
}
|
||||
|
||||
private setMessage(message: string, ownerKey = catalog.routeKey(this.data)) {
|
||||
this.submission.setMessage(message);
|
||||
this.messageOwnerKey = ownerKey;
|
||||
}
|
||||
|
||||
private setMessageFromUser(message: string) {
|
||||
this.setMessage(message, catalog.routeKeyFromSearch(window.location.search));
|
||||
this.submission.setMessage(message);
|
||||
this.messageOwnerKey = catalog.routeKeyFromSearch(window.location.search);
|
||||
}
|
||||
|
||||
private renderAgentSelect() {
|
||||
|
||||
Reference in New Issue
Block a user