From 0f7d2b28a54c5f87ff87e2c86d8ee538ce60f049 Mon Sep 17 00:00:00 2001 From: youngbeom Date: Tue, 9 Jun 2026 18:17:17 +0800 Subject: [PATCH] feat(web): optimize slash command picker --- .../workspace/useConversationController.ts | 151 +++++++++++++++--- web/app/src/models/slashCommands.ts | 1 + .../ConversationPane/ConversationPane.css | 84 ++++++++-- .../ConversationPane/ConversationPane.tsx | 18 ++- web/app/src/shared/i18n/messages.ts | 6 + .../hooks/useConversationController.test.ts | 18 ++- 6 files changed, 227 insertions(+), 51 deletions(-) diff --git a/web/app/src/hooks/workspace/useConversationController.ts b/web/app/src/hooks/workspace/useConversationController.ts index 30eceeed..4f64a62b 100644 --- a/web/app/src/hooks/workspace/useConversationController.ts +++ b/web/app/src/hooks/workspace/useConversationController.ts @@ -10,7 +10,7 @@ import { sendMessageRequest, startThreadRequest, } from "@/api/im"; -import { fetchAgentWorkspace } from "@/api/agents"; +import { fetchAgentWorkspace, fetchAgentWorkspaceFile } from "@/api/agents"; import { agentMatchesUser, appendMessageToData, @@ -56,6 +56,14 @@ import type { IMMessage, IMServerEvent, IMUser, ThreadView } from "@/models/conv import type { SlashPickerCandidate } from "@/models/slashCommands"; import type { UseConversationControllerArgs } from "./types"; +type SlashSkillOption = { + description?: string; + name: string; +}; + +const slashSkillOptionsCache = new Map(); +const slashSkillOptionsRequests = new Map>(); + type ComposerMentionState = { endOffset: number; query: string; @@ -121,7 +129,7 @@ export function useConversationController({ const [threadError, setThreadError] = useState(""); const [composerMentionState, setComposerMentionState] = useState(null); const [mentionIndex, setMentionIndex] = useState(0); - const [skillNames, setSkillNames] = useState([]); + const [skillOptions, setSkillOptions] = useState([]); const [slashIndex, setSlashIndex] = useState(0); const [slashPickerLoading, setSlashPickerLoading] = useState(false); const [slashPickerDismissed, setSlashPickerDismissed] = useState(false); @@ -277,9 +285,9 @@ export function useConversationController({ buildSlashPickerState({ draftText, enabled: slashPickerEnabled, - skillNames, + skillOptions, }), - [draftText, slashPickerEnabled, skillNames], + [draftText, slashPickerEnabled, skillOptions], ); const slashPickerQuery = slashPickerState.query; const slashPickerActive = slashPickerState.active; @@ -298,10 +306,10 @@ export function useConversationController({ buildSlashPickerState({ draftText: activeThreadDraft, enabled: threadSlashPickerEnabled, - skillNames, + skillOptions, disabled: threadSlashPickerDismissed, }), - [activeThreadDraft, threadSlashPickerEnabled, threadSlashPickerDismissed, skillNames], + [activeThreadDraft, threadSlashPickerEnabled, threadSlashPickerDismissed, skillOptions], ); const threadSlashPickerQuery = threadSlashPickerState.query; const threadSlashPickerActive = threadSlashPickerState.active; @@ -344,7 +352,7 @@ export function useConversationController({ }, [draftText]); useEffect(() => { - setSkillNames([]); + setSkillOptions([]); setSlashIndex(0); setSlashPickerDismissed(false); }, [activeConversationId]); @@ -356,30 +364,45 @@ export function useConversationController({ useEffect(() => { setSlashIndex(0); - }, [slashPickerQuery, skillNames]); + }, [slashPickerQuery, skillOptions]); useEffect(() => { setThreadSlashIndex(0); - }, [threadSlashPickerQuery, skillNames]); + }, [threadSlashPickerQuery, skillOptions]); useEffect(() => { - if (!isAnySlashPickerNeeded || !activeConversationAgentId) { - setSkillNames([]); + if (!activeConversationAgentId) { + setSkillOptions([]); setSlashPickerLoading(false); return; } + + const cached = slashSkillOptionsCache.get(activeConversationAgentId); + if (cached) { + setSkillOptions(cached); + setSlashPickerLoading(false); + return; + } + let cancelled = false; - setSlashPickerLoading(true); - fetchAgentWorkspace(activeConversationAgentId, "skills") - .then((workspace) => { + setSkillOptions([]); + setSlashPickerLoading(false); + loadSlashSkillOptions(activeConversationAgentId, (skills) => { + if (cancelled) { + return; + } + setSkillOptions(skills); + setSlashPickerLoading(false); + }) + .then((skills) => { if (cancelled) { return; } - setSkillNames(skillNamesFromWorkspace(workspace.entries || [])); + setSkillOptions(skills); }) .catch(() => { if (!cancelled) { - setSkillNames([]); + setSkillOptions([]); } }) .finally(() => { @@ -390,7 +413,15 @@ export function useConversationController({ return () => { cancelled = true; }; - }, [activeConversationAgentId, isAnySlashPickerNeeded]); + }, [activeConversationAgentId]); + + useEffect(() => { + if (!isAnySlashPickerNeeded || !activeConversationAgentId || skillOptions.length > 0) { + setSlashPickerLoading(false); + return; + } + setSlashPickerLoading(slashSkillOptionsRequests.has(activeConversationAgentId)); + }, [activeConversationAgentId, isAnySlashPickerNeeded, skillOptions.length]); useEffect(() => { if (!managerProfileIncomplete) { @@ -1130,7 +1161,9 @@ export function useConversationController({ }; } -function skillNamesFromWorkspace(entries: readonly { name?: string; path?: string; type?: string }[]): string[] { +function skillOptionsFromWorkspace( + entries: readonly { name?: string; path?: string; type?: string }[], +): SlashSkillOption[] { const skillDirs = new Set(); const dirs = new Set(); entries.forEach((entry) => { @@ -1148,9 +1181,68 @@ function skillNamesFromWorkspace(entries: readonly { name?: string; path?: strin }); return [...dirs] .filter((path) => skillDirs.has(path)) - .map((path) => path.split("/").pop() || "") - .filter(Boolean) - .sort((left, right) => left.localeCompare(right)); + .map((path) => ({ name: path.split("/").pop() || "" })) + .filter((skill) => Boolean(skill.name)) + .sort((left, right) => left.name.localeCompare(right.name)); +} + +function loadSlashSkillOptions( + agentID: string, + onInitial: (skills: SlashSkillOption[]) => void, +): Promise { + const cached = slashSkillOptionsCache.get(agentID); + if (cached) { + return Promise.resolve(cached); + } + const pending = slashSkillOptionsRequests.get(agentID); + if (pending) { + return pending; + } + + const request = fetchAgentWorkspace(agentID, "skills") + .then(async (workspace) => { + const skills = skillOptionsFromWorkspace(workspace.entries || []); + slashSkillOptionsCache.set(agentID, skills); + onInitial(skills); + + const enriched = await Promise.all( + skills.map(async (skill) => { + try { + const file = await fetchAgentWorkspaceFile(agentID, `skills/${skill.name}/SKILL.md`); + return { + ...skill, + description: skillDescriptionFromMarkdown(file.content || "") || skill.description, + }; + } catch { + return skill; + } + }), + ); + slashSkillOptionsCache.set(agentID, enriched); + return enriched; + }) + .finally(() => { + slashSkillOptionsRequests.delete(agentID); + }); + + slashSkillOptionsRequests.set(agentID, request); + return request; +} + +export function skillDescriptionFromMarkdown(content: string): string { + const frontmatterMatch = String(content || "").match(/^---\n([\s\S]*?)\n---/); + if (!frontmatterMatch) { + return ""; + } + const descriptionLine = frontmatterMatch[1].split(/\r?\n/).find((line) => line.trim().startsWith("description:")); + if (!descriptionLine) { + return ""; + } + const description = descriptionLine + .replace(/^\s*description:\s*/, "") + .trim() + .replace(/^['"]|['"]$/g, ""); + return description.slice(0, 220); } const builtinSlashCommandNames = ["new"]; @@ -1159,7 +1251,7 @@ type SlashPickerStateInput = { draftText: string; disabled?: boolean; enabled: boolean; - skillNames: string[]; + skillOptions: SlashSkillOption[]; }; type SlashPickerState = { @@ -1185,14 +1277,21 @@ export function buildSlashPickerState(input: SlashPickerStateInput): SlashPicker candidates: [ ...builtinSlashCommandNames .filter((name) => fuzzySkillMatch(name, query ?? "")) - .map((name) => ({ name, type: "command" as const })), - ...input.skillNames - .filter((name) => !builtinSlashCommandNames.includes(name) && fuzzySkillMatch(name, query ?? "")) - .map((name) => ({ name, type: "skill" as const })), + .map((name) => ({ description: slashCommandDescription(name), name, type: "command" as const })), + ...input.skillOptions + .filter((skill) => !builtinSlashCommandNames.includes(skill.name) && fuzzySkillMatch(skill.name, query ?? "")) + .map((skill) => ({ description: skill.description, name: skill.name, type: "skill" as const })), ], }; } +function slashCommandDescription(name: string): string { + if (name === "new") { + return "Start a new conversation"; + } + return ""; +} + export function slashPickerQueryForDraft(draftText: string): string | null { const trimmed = draftText.trimStart(); if (!trimmed.startsWith("/")) { diff --git a/web/app/src/models/slashCommands.ts b/web/app/src/models/slashCommands.ts index 759e3ae1..b5e3f49b 100644 --- a/web/app/src/models/slashCommands.ts +++ b/web/app/src/models/slashCommands.ts @@ -7,6 +7,7 @@ export type SlashCommandPayload = { export type SlashPickerCandidateType = "command" | "skill"; export type SlashPickerCandidate = { + description?: string; name: string; type: SlashPickerCandidateType; }; diff --git a/web/app/src/pages/ConversationPage/components/ConversationPane/ConversationPane.css b/web/app/src/pages/ConversationPage/components/ConversationPane/ConversationPane.css index 007fc8b5..02c9bd13 100644 --- a/web/app/src/pages/ConversationPage/components/ConversationPane/ConversationPane.css +++ b/web/app/src/pages/ConversationPage/components/ConversationPane/ConversationPane.css @@ -1516,29 +1516,83 @@ font-size: 13px; } +.slash-picker { + left: 12px; + right: 12px; + width: auto; + max-height: min(352px, calc(100dvh - 220px)); + padding: 8px; + border-color: rgba(148, 163, 184, 0.18); + border-radius: 18px; + background: color-mix(in oklch, var(--portal-white) 96%, transparent); + box-shadow: 0 18px 42px rgba(15, 23, 42, 0.12); +} + +.slash-picker .slash-option { + display: grid; + grid-template-columns: 28px minmax(0, 1fr) auto; + gap: 12px; + min-height: 44px; + padding: 6px 10px; + border-radius: 14px; +} + +.slash-picker .slash-option:hover, +.slash-picker .slash-option.active { + background: rgba(15, 23, 42, 0.055); +} + .slash-option-mark { display: inline-flex; align-items: center; justify-content: center; - width: 32px; - height: 32px; - flex: 0 0 32px; - border-radius: 8px; - background: var(--primary-soft); - color: var(--primary-strong); - font-weight: 700; - font-size: 12px; - letter-spacing: 0.02em; + width: 24px; + height: 24px; + color: color-mix(in oklch, var(--text) 72%, var(--muted)); +} + +.slash-option-copy { + min-width: 0; + display: flex; + align-items: baseline; + gap: 12px; } -.command-option .slash-option-mark { - background: var(--warning-soft, #fff3cd); - color: var(--warning-strong, #8a4b00); +.slash-picker .message-author { + flex: 0 0 auto; + color: var(--text); + font-size: 15px; + font-weight: 560; + line-height: 24px; } -.skill-slash-option .slash-option-mark { - background: var(--primary-soft); - color: var(--primary-strong); +.slash-option-description { + min-width: 0; + overflow: hidden; + color: color-mix(in oklch, var(--muted) 86%, var(--text)); + font-size: 14px; + line-height: 24px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.slash-option-kind { + align-self: center; + color: color-mix(in oklch, var(--muted) 76%, transparent); + font-size: 13px; + line-height: 20px; + white-space: nowrap; +} + +:root[data-theme="dark"] .slash-picker { + border-color: rgba(148, 163, 184, 0.2); + background: color-mix(in oklch, var(--panel-strong) 92%, transparent); + box-shadow: 0 18px 42px rgba(0, 0, 0, 0.32); +} + +:root[data-theme="dark"] .slash-picker .slash-option:hover, +:root[data-theme="dark"] .slash-picker .slash-option.active { + background: rgba(255, 255, 255, 0.08); } /* Preserve the message display design after the latest main refinements. */ diff --git a/web/app/src/pages/ConversationPage/components/ConversationPane/ConversationPane.tsx b/web/app/src/pages/ConversationPage/components/ConversationPane/ConversationPane.tsx index f855a3fc..8cf2b0bc 100644 --- a/web/app/src/pages/ConversationPage/components/ConversationPane/ConversationPane.tsx +++ b/web/app/src/pages/ConversationPage/components/ConversationPane/ConversationPane.tsx @@ -1,6 +1,6 @@ import { Fragment, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import type { Dispatch, KeyboardEvent as ReactKeyboardEvent, RefObject, SetStateAction } from "react"; -import { Logs, RefreshCw, X } from "lucide-react"; +import { BoxIcon, TerminalIcon, Logs, RefreshCw, X } from "lucide-react"; import { fetchAgentLogsRequest } from "@/api/agents"; import { errorMessage } from "@/api/client"; import { CLIProxyAuthControl } from "@/components/business/ProfileControls"; @@ -817,7 +817,7 @@ function SlashPicker({ return (
- {loading ?
{t("agentWorkspaceLoading")}
: null} + {loading ?
{t("slashPickerLoading")}
: null} {!loading && candidates.length === 0 ?
{t("slashPickerEmpty")}
: null} {candidates.map((candidate, index) => ( ))}
diff --git a/web/app/src/shared/i18n/messages.ts b/web/app/src/shared/i18n/messages.ts index 5ecbd72b..0d81c866 100644 --- a/web/app/src/shared/i18n/messages.ts +++ b/web/app/src/shared/i18n/messages.ts @@ -111,6 +111,9 @@ export const messages = { agentWorkspaceBinary: "该文件是二进制文件,暂不支持预览。", agentWorkspaceEmptyFile: "该文件为空。", slashPickerEmpty: "没有匹配的 slash command", + slashPickerLoading: "正在加载技能...", + slashPickerCommandKind: "命令", + slashPickerSkillKind: "技能", refreshLogs: "刷新日志", sendFailed: "消息发送失败,请重试。", roomCreatedToast: "房间已创建", @@ -669,6 +672,9 @@ export const messages = { workspacePreviewViewMode: "View", workspacePreviewTruncated: "truncated", slashPickerEmpty: "No matching slash commands", + slashPickerLoading: "Loading skills...", + slashPickerCommandKind: "Command", + slashPickerSkillKind: "Skill", refreshLogs: "Refresh logs", sendFailed: "Failed to send the message. Please retry.", roomCreatedToast: "Room created", diff --git a/web/app/tests/hooks/useConversationController.test.ts b/web/app/tests/hooks/useConversationController.test.ts index f6b676e3..ec42b709 100644 --- a/web/app/tests/hooks/useConversationController.test.ts +++ b/web/app/tests/hooks/useConversationController.test.ts @@ -3,6 +3,7 @@ import { act, renderHook, waitFor } from "@testing-library/react"; import { buildSlashPickerState, normalizeSlashShorthandForPayload, + skillDescriptionFromMarkdown, slashSkillCommandText, slashCommandInputText, slashPickerQueryForDraft, @@ -93,19 +94,26 @@ describe("useConversationController slash skill helpers", () => { buildSlashPickerState({ draftText: "/", enabled: true, - skillNames: ["skill-creator", "new"], + skillOptions: [{ name: "skill-creator", description: "Create useful skills" }, { name: "new" }], }).candidates, ).toEqual([ - { name: "new", type: "command" }, - { name: "skill-creator", type: "skill" }, + { description: "Start a new conversation", name: "new", type: "command" }, + { description: "Create useful skills", name: "skill-creator", type: "skill" }, ]); expect( buildSlashPickerState({ draftText: "/ne", enabled: true, - skillNames: ["skill-creator"], + skillOptions: [{ name: "skill-creator" }], }).candidates, - ).toEqual([{ name: "new", type: "command" }]); + ).toEqual([{ description: "Start a new conversation", name: "new", type: "command" }]); + }); + + it("extracts optional skill descriptions from SKILL.md frontmatter", () => { + expect( + skillDescriptionFromMarkdown('---\nname: browser\ndescription: "Control the in-app browser"\n---\n# Browser'), + ).toBe("Control the in-app browser"); + expect(skillDescriptionFromMarkdown("---\nname: empty\n---\n# Empty")).toBe(""); }); it("renders selected skills as canonical slash-command XML", () => {