Skip to content

Commit b3c9469

Browse files
authored
perf(control-ui): lazy load slash commands
Avoid clean Control UI startup command discovery, then hydrate slash commands only on explicit slash or palette intent. Proof: local focused unit tests, mocked Gateway E2E, Testbox check:changed, autoreview clean, and GitHub CI clean.
1 parent f05e987 commit b3c9469

8 files changed

Lines changed: 131 additions & 15 deletions

File tree

ui/src/ui/app-chat.test.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1076,7 +1076,7 @@ describe("refreshChat", () => {
10761076
}
10771077
});
10781078

1079-
it("uses startup metadata without scheduling a chat.metadata follow-up", async () => {
1079+
it("uses startup metadata without scheduling command or metadata follow-ups", async () => {
10801080
const { resetSlashCommandsForTest } = await import("./chat/slash-commands.ts");
10811081
resetSlashCommandsForTest();
10821082
const previousFetch = globalThis.fetch;
@@ -1110,11 +1110,7 @@ describe("refreshChat", () => {
11101110
});
11111111
expect(request).not.toHaveBeenCalledWith("chat.metadata", expect.anything());
11121112
expect(request).not.toHaveBeenCalledWith("models.list", expect.anything());
1113-
expect(request).toHaveBeenCalledWith("commands.list", {
1114-
agentId: "main",
1115-
includeArgs: true,
1116-
scope: "text",
1117-
});
1113+
expect(request).not.toHaveBeenCalledWith("commands.list", expect.anything());
11181114
expect(host.chatModelCatalog).toEqual([
11191115
{ id: "gpt-fast", name: "GPT Fast", provider: "openai" },
11201116
]);

ui/src/ui/app-chat.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2060,10 +2060,9 @@ export async function refreshChat(
20602060
.then((metadataApplied) => {
20612061
const metadataRefresh =
20622062
opts?.startup === true && (metadataApplied.commands || metadataApplied.models)
2063-
? Promise.allSettled([
2064-
...(metadataApplied.models ? [] : [refreshChatModels(host)]),
2065-
...(metadataApplied.commands ? [] : [refreshChatCommands(host)]),
2066-
])
2063+
? metadataApplied.models
2064+
? Promise.allSettled([])
2065+
: Promise.allSettled([refreshChatModels(host)])
20672066
: Promise.allSettled([refreshChatMetadata(host)]);
20682067
return Promise.allSettled([refreshChatAvatar(host), metadataRefresh]);
20692068
})
@@ -2102,7 +2101,7 @@ async function refreshChatModels(host: ChatHost) {
21022101
}
21032102
}
21042103

2105-
async function refreshChatCommands(host: ChatHost) {
2104+
export async function refreshChatCommands(host: ChatHost) {
21062105
await refreshSlashCommands({
21072106
client: host.client,
21082107
agentId: resolveAgentIdForSession(host),

ui/src/ui/app-render.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
createChatSessionsLoadOverrides,
99
hasAbortableSessionRun,
1010
refreshChat,
11+
refreshChatCommands,
1112
scopedAgentListParamsForSession,
1213
scopedAgentParamsForSession,
1314
} from "./app-chat.ts";
@@ -2209,6 +2210,9 @@ export function renderApp(state: AppViewState) {
22092210
open: state.paletteOpen,
22102211
query: state.paletteQuery,
22112212
activeIndex: state.paletteActiveIndex,
2213+
onOpen: () => {
2214+
void refreshChatCommands(state).finally(requestHostUpdate);
2215+
},
22122216
onToggle: () => {
22132217
state.paletteOpen = !state.paletteOpen;
22142218
},
@@ -3547,6 +3551,7 @@ export function renderApp(state: AppViewState) {
35473551
onDraftChange: (next) => state.handleChatDraftChange(next),
35483552
onRequestUpdate: requestHostUpdate,
35493553
onHistoryKeydown: (input) => state.handleChatInputHistoryKey(input),
3554+
onSlashIntent: () => refreshChatCommands(state).finally(requestHostUpdate),
35503555
attachments: state.chatAttachments,
35513556
onAttachmentsChange: (next) => (state.chatAttachments = next),
35523557
onSend: () => void state.handleSendChat(),

ui/src/ui/e2e/chat-flow.e2e.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -488,9 +488,11 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => {
488488
await page.getByText("First token visible.").waitFor({ timeout: 10_000 });
489489
expect(await gateway.getRequests("chat.metadata")).toHaveLength(0);
490490
expect(await gateway.getRequests("models.list")).toHaveLength(0);
491-
await gateway.waitForRequest("commands.list");
491+
expect(await gateway.getRequests("commands.list")).toHaveLength(0);
492492
await gateway.emitChatFinal({ runId, text: "History race stayed visible." });
493493
await page.getByText("History race stayed visible.").waitFor({ timeout: 10_000 });
494+
await page.locator(".agent-chat__composer-combobox textarea").fill("/");
495+
await gateway.waitForRequest("commands.list");
494496
expect(await gateway.getRequests("agents.list")).toHaveLength(0);
495497
} finally {
496498
await context.close();

ui/src/ui/views/chat.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -579,6 +579,16 @@ function renderChatView(overrides: Partial<Parameters<typeof renderChat>[0]> = {
579579
return container;
580580
}
581581

582+
function createDeferred<T>() {
583+
let resolve!: (value: T | PromiseLike<T>) => void;
584+
let reject!: (error?: unknown) => void;
585+
const promise = new Promise<T>((res, rej) => {
586+
resolve = res;
587+
reject = rej;
588+
});
589+
return { promise, resolve, reject };
590+
}
591+
582592
describe("chat compaction divider", () => {
583593
it("renders checkpoint recovery copy and action", () => {
584594
const onOpenSessionCheckpoints = vi.fn();
@@ -1440,6 +1450,55 @@ describe("chat slash menu accessibility", () => {
14401450
expect(onSend).toHaveBeenCalledTimes(1);
14411451
});
14421452

1453+
it("requests slash command hydration only after slash intent", () => {
1454+
const onSlashIntent = vi.fn(async () => undefined);
1455+
const container = renderChatView({ onSlashIntent });
1456+
1457+
inputDraft(container, "plain first message");
1458+
1459+
expect(onSlashIntent).not.toHaveBeenCalled();
1460+
1461+
inputDraft(container, "/");
1462+
1463+
expect(onSlashIntent).toHaveBeenCalledTimes(1);
1464+
});
1465+
1466+
it("does not reopen slash suggestions when command hydration finishes after plain typing", async () => {
1467+
let draft = "";
1468+
const hydration = createDeferred<void>();
1469+
const onSlashIntent = vi.fn(() => hydration.promise);
1470+
const onDraftChange = vi.fn((next: string) => {
1471+
draft = next;
1472+
});
1473+
const container = document.createElement("div");
1474+
const renderCurrent = () => {
1475+
render(
1476+
renderChat(
1477+
createChatProps({
1478+
draft,
1479+
getDraft: () => draft,
1480+
onDraftChange,
1481+
onRequestUpdate: renderCurrent,
1482+
onSlashIntent,
1483+
}),
1484+
),
1485+
container,
1486+
);
1487+
};
1488+
renderCurrent();
1489+
1490+
inputDraft(container, "/");
1491+
expect(container.querySelector(".slash-menu")).not.toBeNull();
1492+
1493+
inputDraft(container, "plain first message");
1494+
expect(container.querySelector(".slash-menu")).toBeNull();
1495+
hydration.resolve();
1496+
await hydration.promise;
1497+
await Promise.resolve();
1498+
1499+
expect(container.querySelector(".slash-menu")).toBeNull();
1500+
});
1501+
14431502
it("clears the visible local draft immediately when send clears the host draft", () => {
14441503
let draft = "";
14451504
const container = document.createElement("div");

ui/src/ui/views/chat.ts

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,7 @@ export type ChatProps = {
153153
onDraftChange: (next: string) => void;
154154
onRequestUpdate?: () => void;
155155
onHistoryKeydown?: (input: ChatInputHistoryKeyInput) => ChatInputHistoryKeyResult;
156+
onSlashIntent?: () => void | Promise<void>;
156157
onSend: () => void;
157158
onCompact?: () => void | Promise<void>;
158159
onOpenSessionCheckpoints?: () => void | Promise<void>;
@@ -454,6 +455,7 @@ interface ChatEphemeralState {
454455
slashMenuCommand: SlashCommandDef | null;
455456
slashMenuArgItems: string[];
456457
slashMenuExpanded: boolean;
458+
slashCommandRefreshPending: boolean;
457459
searchOpen: boolean;
458460
searchQuery: string;
459461
pinnedExpanded: boolean;
@@ -479,6 +481,7 @@ function createChatEphemeralState(): ChatEphemeralState {
479481
slashMenuCommand: null,
480482
slashMenuArgItems: [],
481483
slashMenuExpanded: false,
484+
slashCommandRefreshPending: false,
482485
searchOpen: false,
483486
searchQuery: "",
484487
pinnedExpanded: false,
@@ -1106,10 +1109,44 @@ function closeSlashMenuIfNeeded(requestUpdate: () => void): void {
11061109
requestUpdate();
11071110
}
11081111

1109-
function updateSlashMenu(value: string, requestUpdate: () => void): void {
1112+
function requestSlashCommandRefresh(
1113+
value: string,
1114+
props: ChatProps,
1115+
requestUpdate: () => void,
1116+
getCurrentValue?: () => string,
1117+
): void {
1118+
if (!props.onSlashIntent || vs.slashCommandRefreshPending) {
1119+
return;
1120+
}
1121+
const refresh = props.onSlashIntent();
1122+
if (!refresh || typeof refresh.then !== "function") {
1123+
return;
1124+
}
1125+
vs.slashCommandRefreshPending = true;
1126+
void Promise.resolve(refresh).finally(() => {
1127+
vs.slashCommandRefreshPending = false;
1128+
const nextValue = getCurrentValue?.() ?? props.getDraft?.() ?? value;
1129+
if (!nextValue.startsWith("/")) {
1130+
closeSlashMenuIfNeeded(requestUpdate);
1131+
return;
1132+
}
1133+
updateSlashMenu(nextValue, requestUpdate, props, { skipSlashIntent: true });
1134+
});
1135+
}
1136+
1137+
function updateSlashMenu(
1138+
value: string,
1139+
requestUpdate: () => void,
1140+
props: ChatProps,
1141+
opts: { skipSlashIntent?: boolean } = {},
1142+
getCurrentValue?: () => string,
1143+
): void {
11101144
// Arg mode: /command <partial-arg>
11111145
const argMatch = value.match(/^\/(\S+)\s(.*)$/);
11121146
if (argMatch) {
1147+
if (!opts.skipSlashIntent) {
1148+
requestSlashCommandRefresh(value, props, requestUpdate, getCurrentValue);
1149+
}
11131150
const cmdName = argMatch[1].toLowerCase();
11141151
const argFilter = argMatch[2].toLowerCase();
11151152
const cmd = SLASH_COMMANDS.find((c) => c.name === cmdName);
@@ -1135,6 +1172,9 @@ function updateSlashMenu(value: string, requestUpdate: () => void): void {
11351172
// Command mode: /partial-command
11361173
const match = value.match(/^\/(\S*)$/);
11371174
if (match) {
1175+
if (!opts.skipSlashIntent) {
1176+
requestSlashCommandRefresh(value, props, requestUpdate, getCurrentValue);
1177+
}
11381178
const items = getSlashCommandCompletions(match[1], { showAll: vs.slashMenuExpanded });
11391179
vs.slashMenuItems = items;
11401180
vs.slashMenuOpen = items.length > 0;
@@ -1512,7 +1552,7 @@ function renderSlashMenu(
15121552
e.preventDefault();
15131553
e.stopPropagation();
15141554
vs.slashMenuExpanded = true;
1515-
updateSlashMenu(draft, requestUpdate);
1555+
updateSlashMenu(draft, requestUpdate, props);
15161556
}}
15171557
>
15181558
Show ${hiddenCount} more command${hiddenCount !== 1 ? "s" : ""}
@@ -1941,7 +1981,7 @@ export function renderChat(props: ChatProps) {
19411981
if (hostDraftNeeded || target.value.startsWith("/") || hasVisibleSlashMenuState()) {
19421982
commitComposerDraft(props, target.value);
19431983
}
1944-
updateSlashMenu(target.value, requestUpdate);
1984+
updateSlashMenu(target.value, requestUpdate, props, {}, () => target.value);
19451985
};
19461986
const handleBlur = (e: FocusEvent) => {
19471987
const target = e.target as HTMLTextAreaElement;

ui/src/ui/views/command-palette.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,17 @@ describe("command palette", () => {
130130
expect(prose?.label).toBe("/prose");
131131
});
132132

133+
it("requests slash command hydration when the palette opens", async () => {
134+
const onOpen = vi.fn();
135+
136+
await renderPalette({ onOpen });
137+
expect(onOpen).toHaveBeenCalledTimes(1);
138+
139+
render(renderCommandPalette(createProps({ onOpen, query: "overview" })), container);
140+
await nextFrame();
141+
expect(onOpen).toHaveBeenCalledTimes(1);
142+
});
143+
133144
it("matches localized base item labels and descriptions", async () => {
134145
await i18n.setLocale("zh-CN");
135146

ui/src/ui/views/command-palette.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ export type CommandPaletteProps = {
101101
open: boolean;
102102
query: string;
103103
activeIndex: number;
104+
onOpen?: () => void | Promise<void>;
104105
onToggle: () => void;
105106
onQueryChange: (query: string) => void;
106107
onActiveIndexChange: (index: number) => void;
@@ -137,6 +138,7 @@ function groupItems(items: PaletteItem[]): Array<[string, PaletteItem[]]> {
137138

138139
let previouslyFocused: Element | null = null;
139140
let activeDialog: HTMLDialogElement | null = null;
141+
let activeProps: CommandPaletteProps | null = null;
140142

141143
const FOCUSABLE_SELECTOR = [
142144
"a[href]",
@@ -288,6 +290,7 @@ function syncDialog(el: Element | undefined) {
288290
if (activeDialog !== el) {
289291
saveFocus();
290292
activeDialog = el;
293+
void activeProps?.onOpen?.();
291294
}
292295
if (el.open) {
293296
return;
@@ -319,6 +322,7 @@ export function renderCommandPalette(props: CommandPaletteProps) {
319322
if (!props.open) {
320323
return nothing;
321324
}
325+
activeProps = props;
322326

323327
const items = filteredItems(props.query);
324328
const grouped = groupItems(items);

0 commit comments

Comments
 (0)