Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
151 changes: 125 additions & 26 deletions web/app/src/hooks/workspace/useConversationController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
sendMessageRequest,
startThreadRequest,
} from "@/api/im";
import { fetchAgentWorkspace } from "@/api/agents";
import { fetchAgentWorkspace, fetchAgentWorkspaceFile } from "@/api/agents";
import {
agentMatchesUser,
appendMessageToData,
Expand Down Expand Up @@ -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<string, SlashSkillOption[]>();
const slashSkillOptionsRequests = new Map<string, Promise<SlashSkillOption[]>>();

type ComposerMentionState = {
endOffset: number;
query: string;
Expand Down Expand Up @@ -121,7 +129,7 @@ export function useConversationController({
const [threadError, setThreadError] = useState("");
const [composerMentionState, setComposerMentionState] = useState<ComposerMentionState | null>(null);
const [mentionIndex, setMentionIndex] = useState(0);
const [skillNames, setSkillNames] = useState<string[]>([]);
const [skillOptions, setSkillOptions] = useState<SlashSkillOption[]>([]);
const [slashIndex, setSlashIndex] = useState(0);
const [slashPickerLoading, setSlashPickerLoading] = useState(false);
const [slashPickerDismissed, setSlashPickerDismissed] = useState(false);
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -344,7 +352,7 @@ export function useConversationController({
}, [draftText]);

useEffect(() => {
setSkillNames([]);
setSkillOptions([]);
setSlashIndex(0);
setSlashPickerDismissed(false);
}, [activeConversationId]);
Expand All @@ -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(() => {
Expand All @@ -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) {
Expand Down Expand Up @@ -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<string>();
const dirs = new Set<string>();
entries.forEach((entry) => {
Expand All @@ -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<SlashSkillOption[]> {
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"];
Expand All @@ -1159,7 +1251,7 @@ type SlashPickerStateInput = {
draftText: string;
disabled?: boolean;
enabled: boolean;
skillNames: string[];
skillOptions: SlashSkillOption[];
};

type SlashPickerState = {
Expand All @@ -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("/")) {
Expand Down
1 change: 1 addition & 0 deletions web/app/src/models/slashCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export type SlashCommandPayload = {
export type SlashPickerCandidateType = "command" | "skill";

export type SlashPickerCandidate = {
description?: string;
name: string;
type: SlashPickerCandidateType;
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
Loading
Loading