From 4db414463d0aa2e93d633def2d66858b29bc99d3 Mon Sep 17 00:00:00 2001 From: Jesse Merhi <79823012+jesse-merhi@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:24:40 +1000 Subject: [PATCH] fix(ui): isolate new-session composer lifecycle --- config/assertion-safety-baseline.txt | 1 - ...ession-page.prompt-attachments.e2e.test.ts | 3 +- ui/src/lib/chat/commands.ts | 15 +- ui/src/pages/chat/chat-commands.ts | 30 +++- .../pages/chat/chat-composer.test-support.ts | 7 +- .../chat-composer-completion-owner.ts | 11 +- .../components/chat-composer-skill-menu.ts | 2 +- .../components/chat-composer-slash-menu.ts | 11 +- .../chat/components/chat-composer-state.ts | 10 ++ .../chat/components/chat-composer-types.ts | 22 ++- .../chat/components/chat-composer-view.ts | 2 +- ui/src/pages/new-session/composer.test.ts | 152 ++++++++++++++++-- ui/src/pages/new-session/composer.ts | 21 +-- .../new-session/draft-submission-flow.ts | 3 + .../new-session/new-session-page.test.ts | 11 +- ui/src/pages/new-session/new-session-page.ts | 11 +- 16 files changed, 239 insertions(+), 73 deletions(-) diff --git a/config/assertion-safety-baseline.txt b/config/assertion-safety-baseline.txt index a37469632a5d..118ef5d95849 100644 --- a/config/assertion-safety-baseline.txt +++ b/config/assertion-safety-baseline.txt @@ -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 diff --git a/ui/src/e2e/new-session-page.prompt-attachments.e2e.test.ts b/ui/src/e2e/new-session-page.prompt-attachments.e2e.test.ts index 4c48422a93c6..497218e011b5 100644 --- a/ui/src/e2e/new-session-page.prompt-attachments.e2e.test.ts +++ b/ui/src/e2e/new-session-page.prompt-attachments.e2e.test.ts @@ -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); diff --git a/ui/src/lib/chat/commands.ts b/ui/src/lib/chat/commands.ts index 12f83935a36d..335338a2169c 100644 --- a/ui/src/lib/chat/commands.ts +++ b/ui/src/lib/chat/commands.ts @@ -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, "-"); diff --git a/ui/src/pages/chat/chat-commands.ts b/ui/src/pages/chat/chat-commands.ts index 19db2948f4ec..e72835932da1 100644 --- a/ui/src/pages/chat/chat-commands.ts +++ b/ui/src/pages/chat/chat-commands.ts @@ -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 { + if (!params.client) { + return buildFallbackSlashCommands(); + } + return await loadRemoteSlashCommands(params.client, params.agentId?.trim()); } export function applyRemoteSlashCommandsResult(params: { diff --git a/ui/src/pages/chat/chat-composer.test-support.ts b/ui/src/pages/chat/chat-composer.test-support.ts index af2a9d06eff7..ee9959954b57 100644 --- a/ui/src/pages/chat/chat-composer.test-support.ts +++ b/ui/src/pages/chat/chat-composer.test-support.ts @@ -4,8 +4,11 @@ import { i18n } from "../../i18n/index.ts"; import { renderChatComposer, resetChatComposerState } from "./components/chat-composer.ts"; type ComposerProps = Parameters[0]; +type SessionComposerProps = Extract; -export function createComposerProps(overrides: Partial = {}): ComposerProps { +export function createComposerProps( + overrides: Partial = {}, +): SessionComposerProps { return { paneId: crypto.randomUUID(), sessionKey: "main", @@ -27,7 +30,7 @@ export function createComposerProps(overrides: Partial = {}): Com }; } -export function renderComposerFixture(overrides: Partial = {}) { +export function renderComposerFixture(overrides: Partial = {}) { const container = document.createElement("div"); const props = createComposerProps(overrides); render(renderChatComposer(props), container); diff --git a/ui/src/pages/chat/components/chat-composer-completion-owner.ts b/ui/src/pages/chat/components/chat-composer-completion-owner.ts index e28898338645..7454adf6c254 100644 --- a/ui/src/pages/chat/components/chat-composer-completion-owner.ts +++ b/ui/src/pages/chat/components/chat-composer-completion-owner.ts @@ -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; diff --git a/ui/src/pages/chat/components/chat-composer-skill-menu.ts b/ui/src/pages/chat/components/chat-composer-skill-menu.ts index 8cb5fe01d9af..4e38235cf0cc 100644 --- a/ui/src/pages/chat/components/chat-composer-skill-menu.ts +++ b/ui/src/pages/chat/components/chat-composer-skill-menu.ts @@ -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; diff --git a/ui/src/pages/chat/components/chat-composer-slash-menu.ts b/ui/src/pages/chat/components/chat-composer-slash-menu.ts index 0c062a3157ec..84cee5dbb61d 100644 --- a/ui/src/pages/chat/components/chat-composer-slash-menu.ts +++ b/ui/src/pages/chat/components/chat-composer-slash-menu.ts @@ -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; diff --git a/ui/src/pages/chat/components/chat-composer-state.ts b/ui/src/pages/chat/components/chat-composer-state.ts index 0db5e36e25dd..ff4bf7125c3a 100644 --- a/ui/src/pages/chat/components/chat-composer-state.ts +++ b/ui/src/pages/chat/components/chat-composer-state.ts @@ -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); } diff --git a/ui/src/pages/chat/components/chat-composer-types.ts b/ui/src/pages/chat/components/chat-composer-types.ts index 9e642a4f0b07..7c35af19033d 100644 --- a/ui/src/pages/chat/components/chat-composer-types.ts +++ b/ui/src/pages/chat/components/chat-composer-types.ts @@ -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; /** 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; }; +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; }; diff --git a/ui/src/pages/chat/components/chat-composer-view.ts b/ui/src/pages/chat/components/chat-composer-view.ts index d6c1f5ffeed5..ba8874abdf59 100644 --- a/ui/src/pages/chat/components/chat-composer-view.ts +++ b/ui/src/pages/chat/components/chat-composer-view.ts @@ -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 diff --git a/ui/src/pages/new-session/composer.test.ts b/ui/src/pages/new-session/composer.test.ts index a6b62e594268..99493323cdc7 100644 --- a/ui/src/pages/new-session/composer.test.ts +++ b/ui/src/pages/new-session/composer.test.ts @@ -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(".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("[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("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((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("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(".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", () => { diff --git a/ui/src/pages/new-session/composer.ts b/ui/src/pages/new-session/composer.ts index 9ee677daabdb..f628c5123f2b 100644 --- a/ui/src/pages/new-session/composer.ts +++ b/ui/src/pages/new-session/composer.ts @@ -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: () => { diff --git a/ui/src/pages/new-session/draft-submission-flow.ts b/ui/src/pages/new-session/draft-submission-flow.ts index 1328160c97f0..b602fb181146 100644 --- a/ui/src/pages/new-session/draft-submission-flow.ts +++ b/ui/src/pages/new-session/draft-submission-flow.ts @@ -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 }); } diff --git a/ui/src/pages/new-session/new-session-page.test.ts b/ui/src/pages/new-session/new-session-page.test.ts index a11e68dba630..3a0e3524f369 100644 --- a/ui/src/pages/new-session/new-session-page.test.ts +++ b/ui/src/pages/new-session/new-session-page.test.ts @@ -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(".slash-menu-name")).map((entry) => + entry.textContent?.trim(), + ), + ).not.toContain("/agent-a-only"); const selector = page.querySelector void }>( "openclaw-agent-select", diff --git a/ui/src/pages/new-session/new-session-page.ts b/ui/src/pages/new-session/new-session-page.ts index 402e5befe44d..423986461997 100644 --- a/ui/src/pages/new-session/new-session-page.ts +++ b/ui/src/pages/new-session/new-session-page.ts @@ -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() {