mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-06 17:38:35 +00:00
feat(webui): add dollar skill shortcuts
Add a WebUI-only $<skill> completion shortcut without changing slash command behavior. Keep slash autocomplete command-only and allow dollar skill shortcuts anywhere in the composer. Co-authored-by: Alan Chen <zc2610@nyu.edu>
This commit is contained in:
parent
a6d5e4f3b5
commit
2ec4044217
@ -1586,6 +1586,7 @@ function Shell({
|
|||||||
onWorkspaceScopeChange={applyWorkspaceScope}
|
onWorkspaceScopeChange={applyWorkspaceScope}
|
||||||
settingsSnapshot={settingsSnapshot}
|
settingsSnapshot={settingsSnapshot}
|
||||||
onOpenModelSettings={onOpenModelSettings}
|
onOpenModelSettings={onOpenModelSettings}
|
||||||
|
skills={skills}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{view !== "chat" && (
|
{view !== "chat" && (
|
||||||
|
|||||||
@ -74,6 +74,7 @@ import type {
|
|||||||
OutboundCliAppMention,
|
OutboundCliAppMention,
|
||||||
OutboundMcpPresetMention,
|
OutboundMcpPresetMention,
|
||||||
SlashCommand,
|
SlashCommand,
|
||||||
|
SkillSummary,
|
||||||
WorkspaceScopePayload,
|
WorkspaceScopePayload,
|
||||||
WorkspacesPayload,
|
WorkspacesPayload,
|
||||||
} from "@/lib/types";
|
} from "@/lib/types";
|
||||||
@ -159,6 +160,7 @@ interface ThreadComposerProps {
|
|||||||
slashCommands?: SlashCommand[];
|
slashCommands?: SlashCommand[];
|
||||||
cliApps?: CliAppInfo[];
|
cliApps?: CliAppInfo[];
|
||||||
mcpPresets?: McpPresetInfo[];
|
mcpPresets?: McpPresetInfo[];
|
||||||
|
skills?: SkillSummary[];
|
||||||
onStop?: () => void;
|
onStop?: () => void;
|
||||||
onTranscribeAudio?: (dataUrl: string, options?: { durationMs?: number }) => Promise<string>;
|
onTranscribeAudio?: (dataUrl: string, options?: { durationMs?: number }) => Promise<string>;
|
||||||
/** Unix seconds from server; turn elapsed timer above input while set. */
|
/** Unix seconds from server; turn elapsed timer above input while set. */
|
||||||
@ -772,6 +774,7 @@ export function ThreadComposer({
|
|||||||
slashCommands = [],
|
slashCommands = [],
|
||||||
cliApps = [],
|
cliApps = [],
|
||||||
mcpPresets = [],
|
mcpPresets = [],
|
||||||
|
skills = [],
|
||||||
onStop,
|
onStop,
|
||||||
onTranscribeAudio,
|
onTranscribeAudio,
|
||||||
runStartedAt = null,
|
runStartedAt = null,
|
||||||
@ -909,6 +912,19 @@ export function ThreadComposer({
|
|||||||
return commandToken.toLowerCase();
|
return commandToken.toLowerCase();
|
||||||
}, [disabled, slashMenuDismissed, value]);
|
}, [disabled, slashMenuDismissed, value]);
|
||||||
|
|
||||||
|
const skillQuery = useMemo(() => {
|
||||||
|
if (disabled || slashMenuDismissed) return null;
|
||||||
|
const caret = Math.min(Math.max(cursorPosition, 0), value.length);
|
||||||
|
const beforeCaret = value.slice(0, caret);
|
||||||
|
const match = /\$([A-Za-z0-9_-]*)$/i.exec(beforeCaret);
|
||||||
|
if (!match) return null;
|
||||||
|
return {
|
||||||
|
end: caret,
|
||||||
|
start: match.index,
|
||||||
|
text: match[1].toLowerCase(),
|
||||||
|
};
|
||||||
|
}, [cursorPosition, disabled, slashMenuDismissed, value]);
|
||||||
|
|
||||||
const visibleSlashCommands = useMemo(() => {
|
const visibleSlashCommands = useMemo(() => {
|
||||||
const baseCommands = slashCommands.filter((command) => command.command !== "/stop");
|
const baseCommands = slashCommands.filter((command) => command.command !== "/stop");
|
||||||
if (!(isStreaming && onStop)) return baseCommands;
|
if (!(isStreaming && onStop)) return baseCommands;
|
||||||
@ -925,6 +941,31 @@ export function ThreadComposer({
|
|||||||
}, [isStreaming, onStop, slashCommands]);
|
}, [isStreaming, onStop, slashCommands]);
|
||||||
|
|
||||||
const filteredSlashCommands = useMemo<SlashPaletteCommand[]>(() => {
|
const filteredSlashCommands = useMemo<SlashPaletteCommand[]>(() => {
|
||||||
|
if (skillQuery !== null) {
|
||||||
|
const query = skillQuery.text;
|
||||||
|
return skills
|
||||||
|
.filter((skill) => skill.available)
|
||||||
|
.filter((skill) => {
|
||||||
|
const haystack = [
|
||||||
|
skill.name,
|
||||||
|
skill.description,
|
||||||
|
].join(" ").toLowerCase();
|
||||||
|
return haystack.includes(query);
|
||||||
|
})
|
||||||
|
.map((skill) => {
|
||||||
|
const command = `$${skill.name}`;
|
||||||
|
const description = skill.description || skill.name;
|
||||||
|
return {
|
||||||
|
command,
|
||||||
|
title: skill.name,
|
||||||
|
description,
|
||||||
|
detail: description,
|
||||||
|
icon: "brain",
|
||||||
|
recent: recentSlashCommands.includes(command),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.slice(0, 8);
|
||||||
|
}
|
||||||
if (slashQuery === null) return [];
|
if (slashQuery === null) return [];
|
||||||
const withDetails = visibleSlashCommands
|
const withDetails = visibleSlashCommands
|
||||||
.filter((command) => {
|
.filter((command) => {
|
||||||
@ -989,7 +1030,7 @@ export function ThreadComposer({
|
|||||||
|
|
||||||
return withDetails
|
return withDetails
|
||||||
.slice(0, 8);
|
.slice(0, 8);
|
||||||
}, [goalState?.active, isStreaming, modelLabel, recentSlashCommands, slashQuery, t, visibleSlashCommands]);
|
}, [goalState?.active, isStreaming, modelLabel, recentSlashCommands, skills, skillQuery, slashQuery, t, visibleSlashCommands]);
|
||||||
|
|
||||||
const showSlashMenu = filteredSlashCommands.length > 0;
|
const showSlashMenu = filteredSlashCommands.length > 0;
|
||||||
const cliAppMention = useMemo<CliAppMentionQuery | null>(() => {
|
const cliAppMention = useMemo<CliAppMentionQuery | null>(() => {
|
||||||
@ -1232,7 +1273,7 @@ export function ThreadComposer({
|
|||||||
}, [onTranscribeAudio, voiceRecorder.beginShortcutHold, voiceRecorder.endShortcutHold]);
|
}, [onTranscribeAudio, voiceRecorder.beginShortcutHold, voiceRecorder.endShortcutHold]);
|
||||||
|
|
||||||
const chooseSlashCommand = useCallback(
|
const chooseSlashCommand = useCallback(
|
||||||
(command: SlashCommand) => {
|
(command: SlashPaletteCommand) => {
|
||||||
if (command.command === "/stop" && isStreaming && onStop) {
|
if (command.command === "/stop" && isStreaming && onStop) {
|
||||||
onStop();
|
onStop();
|
||||||
setValue("");
|
setValue("");
|
||||||
@ -1250,13 +1291,28 @@ export function ThreadComposer({
|
|||||||
setRecentSlashCommands(nextRecents);
|
setRecentSlashCommands(nextRecents);
|
||||||
storeSlashRecents(nextRecents);
|
storeSlashRecents(nextRecents);
|
||||||
|
|
||||||
setValue(command.argHint ? `${command.command} ` : command.command);
|
if (skillQuery !== null) {
|
||||||
|
const suffix = value.slice(skillQuery.end);
|
||||||
|
const inserted = `${command.command}${suffix.startsWith(" ") ? "" : " "}`;
|
||||||
|
const next = `${value.slice(0, skillQuery.start)}${inserted}${suffix}`;
|
||||||
|
const nextCursor = skillQuery.start + inserted.length;
|
||||||
|
setValue(next);
|
||||||
|
setCursorPosition(nextCursor);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const el = textareaRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
el.focus();
|
||||||
|
el.setSelectionRange(nextCursor, nextCursor);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setValue(command.argHint ? `${command.command} ` : command.command);
|
||||||
|
}
|
||||||
setSlashMenuDismissed(true);
|
setSlashMenuDismissed(true);
|
||||||
setCliAppMenuDismissed(false);
|
setCliAppMenuDismissed(false);
|
||||||
setInlineError(null);
|
setInlineError(null);
|
||||||
resizeTextarea();
|
resizeTextarea();
|
||||||
},
|
},
|
||||||
[isStreaming, onStop, recentSlashCommands, resizeTextarea],
|
[isStreaming, onStop, recentSlashCommands, resizeTextarea, skillQuery, value],
|
||||||
);
|
);
|
||||||
|
|
||||||
const chooseMentionCandidate = useCallback(
|
const chooseMentionCandidate = useCallback(
|
||||||
|
|||||||
@ -32,6 +32,7 @@ import type {
|
|||||||
ChatSummary,
|
ChatSummary,
|
||||||
SettingsPayload,
|
SettingsPayload,
|
||||||
SlashCommand,
|
SlashCommand,
|
||||||
|
SkillSummary,
|
||||||
UIMessage,
|
UIMessage,
|
||||||
WorkspaceScopePayload,
|
WorkspaceScopePayload,
|
||||||
WorkspacesPayload,
|
WorkspacesPayload,
|
||||||
@ -142,6 +143,7 @@ interface ThreadShellProps {
|
|||||||
onWorkspaceScopeChange?: (scope: WorkspaceScopePayload) => void;
|
onWorkspaceScopeChange?: (scope: WorkspaceScopePayload) => void;
|
||||||
settingsSnapshot?: SettingsPayload | null;
|
settingsSnapshot?: SettingsPayload | null;
|
||||||
onOpenModelSettings?: () => void;
|
onOpenModelSettings?: () => void;
|
||||||
|
skills?: SkillSummary[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function toModelBadgeLabel(modelName: string | null): string | null {
|
function toModelBadgeLabel(modelName: string | null): string | null {
|
||||||
@ -292,6 +294,7 @@ export function ThreadShell({
|
|||||||
onWorkspaceScopeChange,
|
onWorkspaceScopeChange,
|
||||||
settingsSnapshot = null,
|
settingsSnapshot = null,
|
||||||
onOpenModelSettings,
|
onOpenModelSettings,
|
||||||
|
skills = [],
|
||||||
}: ThreadShellProps) {
|
}: ThreadShellProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const chatId = session?.chatId ?? null;
|
const chatId = session?.chatId ?? null;
|
||||||
@ -726,6 +729,7 @@ export function ThreadShell({
|
|||||||
slashCommands={slashCommands}
|
slashCommands={slashCommands}
|
||||||
cliApps={cliApps}
|
cliApps={cliApps}
|
||||||
mcpPresets={mcpPresets}
|
mcpPresets={mcpPresets}
|
||||||
|
skills={skills}
|
||||||
onStop={stop}
|
onStop={stop}
|
||||||
onTranscribeAudio={transcribeAudio}
|
onTranscribeAudio={transcribeAudio}
|
||||||
runStartedAt={runStartedAt}
|
runStartedAt={runStartedAt}
|
||||||
@ -758,6 +762,7 @@ export function ThreadShell({
|
|||||||
slashCommands={slashCommands}
|
slashCommands={slashCommands}
|
||||||
cliApps={cliApps}
|
cliApps={cliApps}
|
||||||
mcpPresets={mcpPresets}
|
mcpPresets={mcpPresets}
|
||||||
|
skills={skills}
|
||||||
runStartedAt={runStartedAt}
|
runStartedAt={runStartedAt}
|
||||||
onTranscribeAudio={transcribeAudio}
|
onTranscribeAudio={transcribeAudio}
|
||||||
goalState={goalState}
|
goalState={goalState}
|
||||||
|
|||||||
@ -120,6 +120,7 @@ const MCP_PRESETS: McpPresetInfo[] = [
|
|||||||
connection_summary: "",
|
connection_summary: "",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const ORIGINAL_INNER_HEIGHT = window.innerHeight;
|
const ORIGINAL_INNER_HEIGHT = window.innerHeight;
|
||||||
const ORIGINAL_MEDIA_DEVICES = navigator.mediaDevices;
|
const ORIGINAL_MEDIA_DEVICES = navigator.mediaDevices;
|
||||||
|
|
||||||
@ -1114,6 +1115,36 @@ describe("ThreadComposer", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("opens skills only from a $ reference anywhere", () => {
|
||||||
|
render(
|
||||||
|
<ThreadComposer
|
||||||
|
onSend={vi.fn()}
|
||||||
|
placeholder="Type your message..."
|
||||||
|
skills={[{
|
||||||
|
name: "github",
|
||||||
|
description: "Work with pull requests and issues",
|
||||||
|
source: "builtin",
|
||||||
|
available: true,
|
||||||
|
}]}
|
||||||
|
slashCommands={COMMANDS}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = screen.getByLabelText("Message input");
|
||||||
|
fireEvent.change(input, { target: { value: "/git", selectionStart: 4 } });
|
||||||
|
expect(screen.queryByRole("listbox", { name: "Slash commands" })).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.change(input, { target: { value: "please use $git", selectionStart: 15 } });
|
||||||
|
|
||||||
|
const palette = screen.getByRole("listbox", { name: "Slash commands" });
|
||||||
|
expect(within(palette).getByRole("option", { name: /github/i })).toHaveTextContent("$github");
|
||||||
|
expect(within(palette).queryByText("/model")).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.keyDown(input, { key: "Tab" });
|
||||||
|
|
||||||
|
expect(input).toHaveValue("please use $github ");
|
||||||
|
});
|
||||||
|
|
||||||
it("shows right-side source badges so users can distinguish CLI apps from MCP servers", () => {
|
it("shows right-side source badges so users can distinguish CLI apps from MCP servers", () => {
|
||||||
render(
|
render(
|
||||||
<ThreadComposer
|
<ThreadComposer
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user