fix(chat): query expert skills via symlinks and use $ prefix for Codex
Expert skills in the chat session were derived by intersecting built-in experts with ACP availableCommands, which caused Codex experts to never appear since Codex does not advertise skills through ACP. - Add `experts_list_for_agent` backend API that checks symlink status across all global skill dirs for the given agent type - Replace availableCommands-based expert filtering with symlink-based query, making the settings page the single source of truth - Use `$` prefix for Codex expert skills while keeping `/` for slash commands and other agents' experts - Disable the expert button when no experts are linked for the agent - Invalidate per-agent expert cache after link/unlink in settings Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -682,6 +682,47 @@ pub async fn experts_list() -> Result<Vec<ExpertListItem>, ExpertsError> {
|
|||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg_attr(feature = "tauri-runtime", tauri::command)]
|
||||||
|
pub async fn experts_list_for_agent(
|
||||||
|
agent_type: AgentType,
|
||||||
|
) -> Result<Vec<ExpertListItem>, ExpertsError> {
|
||||||
|
let _ = skill_storage_spec(agent_type)
|
||||||
|
.ok_or(ExpertsError::UnsupportedAgent(agent_type))?;
|
||||||
|
|
||||||
|
let dirs = scoped_skill_dirs(agent_type, AgentSkillScope::Global, None)
|
||||||
|
.map_err(|_| ExpertsError::UnsupportedAgent(agent_type))?;
|
||||||
|
|
||||||
|
let meta_list = bundled_metadata().to_vec();
|
||||||
|
let manifest = load_manifest();
|
||||||
|
let mut out = Vec::new();
|
||||||
|
|
||||||
|
for meta in meta_list {
|
||||||
|
let central_path = expert_central_path(&meta.id);
|
||||||
|
let is_linked = dirs.iter().any(|dir| {
|
||||||
|
let candidate = dir.join(&meta.id);
|
||||||
|
classify_link(&candidate, ¢ral_path) == ExpertLinkState::LinkedToCodeg
|
||||||
|
});
|
||||||
|
if !is_linked {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let installed_centrally = central_path.exists();
|
||||||
|
let user_modified = manifest
|
||||||
|
.experts
|
||||||
|
.get(&meta.id)
|
||||||
|
.map(|e| e.pending_user_review)
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
out.push(ExpertListItem {
|
||||||
|
metadata: meta,
|
||||||
|
installed_centrally,
|
||||||
|
user_modified,
|
||||||
|
central_path: central_path.to_string_lossy().to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg_attr(feature = "tauri-runtime", tauri::command)]
|
#[cfg_attr(feature = "tauri-runtime", tauri::command)]
|
||||||
pub async fn experts_get_install_status(
|
pub async fn experts_get_install_status(
|
||||||
expert_id: String,
|
expert_id: String,
|
||||||
|
|||||||
@@ -376,6 +376,7 @@ mod tauri_app {
|
|||||||
acp_commands::acp_save_agent_skill,
|
acp_commands::acp_save_agent_skill,
|
||||||
acp_commands::acp_delete_agent_skill,
|
acp_commands::acp_delete_agent_skill,
|
||||||
experts_commands::experts_list,
|
experts_commands::experts_list,
|
||||||
|
experts_commands::experts_list_for_agent,
|
||||||
experts_commands::experts_get_install_status,
|
experts_commands::experts_get_install_status,
|
||||||
experts_commands::experts_link_to_agent,
|
experts_commands::experts_link_to_agent,
|
||||||
experts_commands::experts_unlink_from_agent,
|
experts_commands::experts_unlink_from_agent,
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ pub struct ExpertIdParams {
|
|||||||
pub expert_id: String,
|
pub expert_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct AgentTypeOnlyParams {
|
||||||
|
pub agent_type: AgentType,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct ExpertAgentParams {
|
pub struct ExpertAgentParams {
|
||||||
@@ -26,6 +32,15 @@ pub async fn experts_list() -> Result<Json<Vec<ExpertListItem>>, AppCommandError
|
|||||||
Ok(Json(result))
|
Ok(Json(result))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn experts_list_for_agent(
|
||||||
|
Json(params): Json<AgentTypeOnlyParams>,
|
||||||
|
) -> Result<Json<Vec<ExpertListItem>>, AppCommandError> {
|
||||||
|
let result = experts_commands::experts_list_for_agent(params.agent_type)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppCommandError::task_execution_failed(e.to_string()))?;
|
||||||
|
Ok(Json(result))
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn experts_get_install_status(
|
pub async fn experts_get_install_status(
|
||||||
Json(params): Json<ExpertIdParams>,
|
Json(params): Json<ExpertIdParams>,
|
||||||
) -> Result<Json<Vec<ExpertInstallStatus>>, AppCommandError> {
|
) -> Result<Json<Vec<ExpertInstallStatus>>, AppCommandError> {
|
||||||
|
|||||||
@@ -177,6 +177,7 @@ pub fn build_router(state: Arc<AppState>, token: String, static_dir: std::path::
|
|||||||
.route("/acp_delete_agent_skill", post(handlers::acp::acp_delete_agent_skill))
|
.route("/acp_delete_agent_skill", post(handlers::acp::acp_delete_agent_skill))
|
||||||
// ─── Experts ───
|
// ─── Experts ───
|
||||||
.route("/experts_list", post(handlers::experts::experts_list))
|
.route("/experts_list", post(handlers::experts::experts_list))
|
||||||
|
.route("/experts_list_for_agent", post(handlers::experts::experts_list_for_agent))
|
||||||
.route("/experts_get_install_status", post(handlers::experts::experts_get_install_status))
|
.route("/experts_get_install_status", post(handlers::experts::experts_get_install_status))
|
||||||
.route("/experts_link_to_agent", post(handlers::experts::experts_link_to_agent))
|
.route("/experts_link_to_agent", post(handlers::experts::experts_link_to_agent))
|
||||||
.route("/experts_unlink_from_agent", post(handlers::experts::experts_unlink_from_agent))
|
.route("/experts_unlink_from_agent", post(handlers::experts::experts_unlink_from_agent))
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useTranslations } from "next-intl"
|
import { useTranslations } from "next-intl"
|
||||||
import type {
|
import type {
|
||||||
|
AgentType,
|
||||||
ConnectionStatus,
|
ConnectionStatus,
|
||||||
PromptCapabilitiesInfo,
|
PromptCapabilitiesInfo,
|
||||||
PromptDraft,
|
PromptDraft,
|
||||||
@@ -29,6 +30,7 @@ interface ChatInputProps {
|
|||||||
selectedModeId?: string | null
|
selectedModeId?: string | null
|
||||||
onModeChange?: (modeId: string) => void
|
onModeChange?: (modeId: string) => void
|
||||||
onConfigOptionChange?: (configId: string, valueId: string) => void
|
onConfigOptionChange?: (configId: string, valueId: string) => void
|
||||||
|
agentType?: AgentType | null
|
||||||
availableCommands?: AvailableCommandInfo[] | null
|
availableCommands?: AvailableCommandInfo[] | null
|
||||||
attachmentTabId?: string | null
|
attachmentTabId?: string | null
|
||||||
draftStorageKey?: string | null
|
draftStorageKey?: string | null
|
||||||
@@ -62,6 +64,7 @@ export function ChatInput({
|
|||||||
selectedModeId,
|
selectedModeId,
|
||||||
onModeChange,
|
onModeChange,
|
||||||
onConfigOptionChange,
|
onConfigOptionChange,
|
||||||
|
agentType,
|
||||||
availableCommands,
|
availableCommands,
|
||||||
attachmentTabId,
|
attachmentTabId,
|
||||||
draftStorageKey,
|
draftStorageKey,
|
||||||
@@ -113,6 +116,7 @@ export function ChatInput({
|
|||||||
selectedModeId={selectedModeId}
|
selectedModeId={selectedModeId}
|
||||||
onModeChange={onModeChange}
|
onModeChange={onModeChange}
|
||||||
onConfigOptionChange={onConfigOptionChange}
|
onConfigOptionChange={onConfigOptionChange}
|
||||||
|
agentType={agentType}
|
||||||
availableCommands={availableCommands}
|
availableCommands={availableCommands}
|
||||||
attachmentTabId={attachmentTabId}
|
attachmentTabId={attachmentTabId}
|
||||||
draftStorageKey={draftStorageKey}
|
draftStorageKey={draftStorageKey}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { ReactNode } from "react"
|
import type { ReactNode } from "react"
|
||||||
import type {
|
import type {
|
||||||
|
AgentType,
|
||||||
ConnectionStatus,
|
ConnectionStatus,
|
||||||
PromptCapabilitiesInfo,
|
PromptCapabilitiesInfo,
|
||||||
PromptDraft,
|
PromptDraft,
|
||||||
@@ -38,6 +39,7 @@ interface ConversationShellProps {
|
|||||||
selectedModeId?: string | null
|
selectedModeId?: string | null
|
||||||
onModeChange?: (modeId: string) => void
|
onModeChange?: (modeId: string) => void
|
||||||
onConfigOptionChange?: (configId: string, valueId: string) => void
|
onConfigOptionChange?: (configId: string, valueId: string) => void
|
||||||
|
agentType?: AgentType | null
|
||||||
availableCommands?: AvailableCommandInfo[] | null
|
availableCommands?: AvailableCommandInfo[] | null
|
||||||
attachmentTabId?: string | null
|
attachmentTabId?: string | null
|
||||||
draftStorageKey?: string | null
|
draftStorageKey?: string | null
|
||||||
@@ -78,6 +80,7 @@ export function ConversationShell({
|
|||||||
selectedModeId,
|
selectedModeId,
|
||||||
onModeChange,
|
onModeChange,
|
||||||
onConfigOptionChange,
|
onConfigOptionChange,
|
||||||
|
agentType,
|
||||||
availableCommands,
|
availableCommands,
|
||||||
attachmentTabId,
|
attachmentTabId,
|
||||||
draftStorageKey,
|
draftStorageKey,
|
||||||
@@ -123,6 +126,7 @@ export function ConversationShell({
|
|||||||
selectedModeId={selectedModeId}
|
selectedModeId={selectedModeId}
|
||||||
onModeChange={onModeChange}
|
onModeChange={onModeChange}
|
||||||
onConfigOptionChange={onConfigOptionChange}
|
onConfigOptionChange={onConfigOptionChange}
|
||||||
|
agentType={agentType}
|
||||||
availableCommands={availableCommands}
|
availableCommands={availableCommands}
|
||||||
attachmentTabId={attachmentTabId}
|
attachmentTabId={attachmentTabId}
|
||||||
draftStorageKey={draftStorageKey}
|
draftStorageKey={draftStorageKey}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import { readFileBase64 } from "@/lib/api"
|
|||||||
import { openFileDialog } from "@/lib/platform"
|
import { openFileDialog } from "@/lib/platform"
|
||||||
import { disposeTauriListener } from "@/lib/tauri-listener"
|
import { disposeTauriListener } from "@/lib/tauri-listener"
|
||||||
import type {
|
import type {
|
||||||
|
AgentType,
|
||||||
AvailableCommandInfo,
|
AvailableCommandInfo,
|
||||||
ExpertListItem,
|
ExpertListItem,
|
||||||
PromptCapabilitiesInfo,
|
PromptCapabilitiesInfo,
|
||||||
@@ -65,6 +66,7 @@ import { FileMentionMenu } from "@/components/chat/file-mention-menu"
|
|||||||
import { DropdownRadioItemContent } from "@/components/chat/dropdown-radio-item-content"
|
import { DropdownRadioItemContent } from "@/components/chat/dropdown-radio-item-content"
|
||||||
import { useFileTree } from "@/hooks/use-file-tree"
|
import { useFileTree } from "@/hooks/use-file-tree"
|
||||||
import { useBuiltInExperts } from "@/hooks/use-built-in-experts"
|
import { useBuiltInExperts } from "@/hooks/use-built-in-experts"
|
||||||
|
import { useAgentExperts } from "@/hooks/use-agent-experts"
|
||||||
import { joinFsPath } from "@/lib/path-utils"
|
import { joinFsPath } from "@/lib/path-utils"
|
||||||
import {
|
import {
|
||||||
clearMessageInputDraft,
|
clearMessageInputDraft,
|
||||||
@@ -89,6 +91,7 @@ interface MessageInputProps {
|
|||||||
selectedModeId?: string | null
|
selectedModeId?: string | null
|
||||||
onModeChange?: (modeId: string) => void
|
onModeChange?: (modeId: string) => void
|
||||||
onConfigOptionChange?: (configId: string, valueId: string) => void
|
onConfigOptionChange?: (configId: string, valueId: string) => void
|
||||||
|
agentType?: AgentType | null
|
||||||
availableCommands?: AvailableCommandInfo[] | null
|
availableCommands?: AvailableCommandInfo[] | null
|
||||||
promptCapabilities: PromptCapabilitiesInfo
|
promptCapabilities: PromptCapabilitiesInfo
|
||||||
attachmentTabId?: string | null
|
attachmentTabId?: string | null
|
||||||
@@ -294,6 +297,7 @@ export function MessageInput({
|
|||||||
selectedModeId,
|
selectedModeId,
|
||||||
onModeChange,
|
onModeChange,
|
||||||
onConfigOptionChange,
|
onConfigOptionChange,
|
||||||
|
agentType,
|
||||||
availableCommands,
|
availableCommands,
|
||||||
promptCapabilities,
|
promptCapabilities,
|
||||||
attachmentTabId,
|
attachmentTabId,
|
||||||
@@ -315,19 +319,10 @@ export function MessageInput({
|
|||||||
() => new Set(builtInExperts.map((item) => item.metadata.id)),
|
() => new Set(builtInExperts.map((item) => item.metadata.id)),
|
||||||
[builtInExperts]
|
[builtInExperts]
|
||||||
)
|
)
|
||||||
// Derive the list of experts this specific agent session actually knows
|
// Experts linked to the current agent via symlinks in the settings page.
|
||||||
// about. The backend advertises every enabled expert via its skill
|
// This is the single source of truth — no dependency on ACP availableCommands.
|
||||||
// directory, so any expert whose id appears in `availableCommands` is
|
const availableExperts = useAgentExperts(agentType ?? null)
|
||||||
// guaranteed to be linked for the current agent. Using this intersection
|
const expertPrefix = agentType === "codex" ? "$" : "/"
|
||||||
// keeps the experts button in lockstep with what the agent will accept
|
|
||||||
// — an expert disabled in settings simply never reaches this dropdown.
|
|
||||||
const availableExperts = useMemo(() => {
|
|
||||||
if (!availableCommands || availableCommands.length === 0) return []
|
|
||||||
const agentCommandNames = new Set(availableCommands.map((cmd) => cmd.name))
|
|
||||||
return builtInExperts.filter((item) =>
|
|
||||||
agentCommandNames.has(item.metadata.id)
|
|
||||||
)
|
|
||||||
}, [availableCommands, builtInExperts])
|
|
||||||
// Stable presentation order for expert categories in the button
|
// Stable presentation order for expert categories in the button
|
||||||
// dropdown. Keep this in sync with experts-settings.tsx so both surfaces
|
// dropdown. Keep this in sync with experts-settings.tsx so both surfaces
|
||||||
// group experts the same way.
|
// group experts the same way.
|
||||||
@@ -519,6 +514,15 @@ export function MessageInput({
|
|||||||
() => (availableCommands ?? []).filter((cmd) => !expertIdSet.has(cmd.name)),
|
() => (availableCommands ?? []).filter((cmd) => !expertIdSet.has(cmd.name)),
|
||||||
[availableCommands, expertIdSet]
|
[availableCommands, expertIdSet]
|
||||||
)
|
)
|
||||||
|
// For Codex the menu triggers on both "/" (commands) and "$" (experts).
|
||||||
|
const menuTriggerRegex = useMemo(
|
||||||
|
() => (agentType === "codex" ? /^[/$](\S*)$/ : /^\/(\S*)$/),
|
||||||
|
[agentType]
|
||||||
|
)
|
||||||
|
const expertPrefixRegex = useMemo(
|
||||||
|
() => (agentType === "codex" ? /^\$(\S*)$/ : /^\/(\S*)$/),
|
||||||
|
[agentType]
|
||||||
|
)
|
||||||
const filteredSlashCommands = useMemo(() => {
|
const filteredSlashCommands = useMemo(() => {
|
||||||
if (!slashMenuOpen || slashCommands.length === 0) return []
|
if (!slashMenuOpen || slashCommands.length === 0) return []
|
||||||
const match = text.match(/^\/(\S*)$/)
|
const match = text.match(/^\/(\S*)$/)
|
||||||
@@ -530,13 +534,13 @@ export function MessageInput({
|
|||||||
}, [slashMenuOpen, slashCommands, text])
|
}, [slashMenuOpen, slashCommands, text])
|
||||||
const filteredSlashExperts = useMemo(() => {
|
const filteredSlashExperts = useMemo(() => {
|
||||||
if (!slashMenuOpen || availableExperts.length === 0) return []
|
if (!slashMenuOpen || availableExperts.length === 0) return []
|
||||||
const match = text.match(/^\/(\S*)$/)
|
const match = text.match(expertPrefixRegex)
|
||||||
if (!match) return []
|
if (!match) return []
|
||||||
const filter = match[1].toLowerCase()
|
const filter = match[1].toLowerCase()
|
||||||
return availableExperts.filter((item) =>
|
return availableExperts.filter((item) =>
|
||||||
item.metadata.id.toLowerCase().startsWith(filter)
|
item.metadata.id.toLowerCase().startsWith(filter)
|
||||||
)
|
)
|
||||||
}, [slashMenuOpen, availableExperts, text])
|
}, [slashMenuOpen, availableExperts, text, expertPrefixRegex])
|
||||||
const slashAutocompleteCount =
|
const slashAutocompleteCount =
|
||||||
filteredSlashCommands.length + filteredSlashExperts.length
|
filteredSlashCommands.length + filteredSlashExperts.length
|
||||||
|
|
||||||
@@ -916,24 +920,27 @@ export function MessageInput({
|
|||||||
|
|
||||||
const handleExpertAutocompleteSelect = useCallback(
|
const handleExpertAutocompleteSelect = useCallback(
|
||||||
(expert: ExpertListItem) => {
|
(expert: ExpertListItem) => {
|
||||||
setText(`/${expert.metadata.id} `)
|
setText(`${expertPrefix}${expert.metadata.id} `)
|
||||||
setSlashMenuOpen(false)
|
setSlashMenuOpen(false)
|
||||||
},
|
},
|
||||||
[]
|
[expertPrefix]
|
||||||
)
|
)
|
||||||
|
|
||||||
// Experts always inject `/expert-id ` at the very front of the input,
|
// Experts always inject `prefix + expert-id ` at the very front of the
|
||||||
// never at the cursor. The expert skill is a whole-turn directive that
|
// input, never at the cursor. The expert skill is a whole-turn directive
|
||||||
// the agent inspects first, so prepending keeps semantics unambiguous
|
// that the agent inspects first, so prepending keeps semantics unambiguous
|
||||||
// regardless of what the user has already typed. If another expert prefix
|
// regardless of what the user has already typed. If another expert prefix
|
||||||
// is already at the front (from a prior click), replace it instead of
|
// is already at the front (from a prior click), replace it instead of
|
||||||
// stacking — the agent only honors the first slash command, so a stacked
|
// stacking — the agent only honors the first command, so a stacked prefix
|
||||||
// prefix would silently drop the earlier choice.
|
// would silently drop the earlier choice.
|
||||||
const handleExpertPopoverSelect = useCallback(
|
const handleExpertPopoverSelect = useCallback(
|
||||||
(expert: ExpertListItem) => {
|
(expert: ExpertListItem) => {
|
||||||
const current = textRef.current
|
const current = textRef.current
|
||||||
const insertion = `/${expert.metadata.id} `
|
const insertion = `${expertPrefix}${expert.metadata.id} `
|
||||||
const existingPrefix = current.match(/^\/([A-Za-z0-9_-]+)\s/)
|
const escapedPrefix = expertPrefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
||||||
|
const existingPrefix = current.match(
|
||||||
|
new RegExp(`^${escapedPrefix}([A-Za-z0-9_-]+)\\s`)
|
||||||
|
)
|
||||||
let base = current
|
let base = current
|
||||||
if (existingPrefix && expertIdSet.has(existingPrefix[1])) {
|
if (existingPrefix && expertIdSet.has(existingPrefix[1])) {
|
||||||
base = current.slice(existingPrefix[0].length)
|
base = current.slice(existingPrefix[0].length)
|
||||||
@@ -951,7 +958,7 @@ export function MessageInput({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
[expertIdSet]
|
[expertIdSet, expertPrefix]
|
||||||
)
|
)
|
||||||
|
|
||||||
const atTriggerPosRef = useRef(atTriggerPos)
|
const atTriggerPosRef = useRef(atTriggerPos)
|
||||||
@@ -994,7 +1001,7 @@ export function MessageInput({
|
|||||||
// the menu whenever at least one of them is available.
|
// the menu whenever at least one of them is available.
|
||||||
const hasSlashSource =
|
const hasSlashSource =
|
||||||
slashCommands.length > 0 || availableExperts.length > 0
|
slashCommands.length > 0 || availableExperts.length > 0
|
||||||
if (hasSlashSource && /^\/(\S*)$/.test(value)) {
|
if (hasSlashSource && menuTriggerRegex.test(value)) {
|
||||||
setSlashSelectedIndex(0)
|
setSlashSelectedIndex(0)
|
||||||
setSlashMenuOpen(true)
|
setSlashMenuOpen(true)
|
||||||
setAtMenuOpen(false)
|
setAtMenuOpen(false)
|
||||||
@@ -1020,7 +1027,12 @@ export function MessageInput({
|
|||||||
}
|
}
|
||||||
setAtMenuOpen(false)
|
setAtMenuOpen(false)
|
||||||
},
|
},
|
||||||
[slashCommands.length, availableExperts.length, defaultPath]
|
[
|
||||||
|
slashCommands.length,
|
||||||
|
availableExperts.length,
|
||||||
|
defaultPath,
|
||||||
|
menuTriggerRegex,
|
||||||
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
const handlePickFiles = useCallback(async () => {
|
const handlePickFiles = useCallback(async () => {
|
||||||
@@ -1626,7 +1638,8 @@ export function MessageInput({
|
|||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="truncate font-medium">{name}</span>
|
<span className="truncate font-medium">{name}</span>
|
||||||
<span className="shrink-0 font-mono text-[11px] text-muted-foreground">
|
<span className="shrink-0 font-mono text-[11px] text-muted-foreground">
|
||||||
/{expert.metadata.id}
|
{expertPrefix}
|
||||||
|
{expert.metadata.id}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{description && (
|
{description && (
|
||||||
@@ -1744,7 +1757,7 @@ export function MessageInput({
|
|||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
disabled={disabled}
|
disabled={disabled || availableExperts.length === 0}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-6 w-6 shrink-0 bg-transparent"
|
className="h-6 w-6 shrink-0 bg-transparent"
|
||||||
|
|||||||
@@ -895,6 +895,7 @@ const ConversationTabView = memo(function ConversationTabView({
|
|||||||
selectedModeId={selectedModeId}
|
selectedModeId={selectedModeId}
|
||||||
onModeChange={handleModeChange}
|
onModeChange={handleModeChange}
|
||||||
onConfigOptionChange={handleSetConfigOption}
|
onConfigOptionChange={handleSetConfigOption}
|
||||||
|
agentType={selectedAgent}
|
||||||
availableCommands={connectionCommands}
|
availableCommands={connectionCommands}
|
||||||
attachmentTabId={tabId}
|
attachmentTabId={tabId}
|
||||||
draftStorageKey={draftStorageKey}
|
draftStorageKey={draftStorageKey}
|
||||||
@@ -966,6 +967,7 @@ const ConversationTabView = memo(function ConversationTabView({
|
|||||||
selectedModeId={selectedModeId}
|
selectedModeId={selectedModeId}
|
||||||
onModeChange={handleModeChange}
|
onModeChange={handleModeChange}
|
||||||
onConfigOptionChange={handleSetConfigOption}
|
onConfigOptionChange={handleSetConfigOption}
|
||||||
|
agentType={selectedAgent}
|
||||||
availableCommands={connectionCommands}
|
availableCommands={connectionCommands}
|
||||||
attachmentTabId={tabId}
|
attachmentTabId={tabId}
|
||||||
draftStorageKey={draftStorageKey}
|
draftStorageKey={draftStorageKey}
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import {
|
|||||||
expertsUnlinkFromAgent,
|
expertsUnlinkFromAgent,
|
||||||
openFolderWindow,
|
openFolderWindow,
|
||||||
} from "@/lib/api"
|
} from "@/lib/api"
|
||||||
|
import { invalidateAgentExpertsCache } from "@/hooks/use-agent-experts"
|
||||||
import type {
|
import type {
|
||||||
AcpAgentInfo,
|
AcpAgentInfo,
|
||||||
AgentType,
|
AgentType,
|
||||||
@@ -336,6 +337,7 @@ export function ExpertsSettings() {
|
|||||||
if (enable) {
|
if (enable) {
|
||||||
const next = await expertsLinkToAgent({ expertId, agentType })
|
const next = await expertsLinkToAgent({ expertId, agentType })
|
||||||
setStatuses((prev) => ({ ...prev, [agentType]: next }))
|
setStatuses((prev) => ({ ...prev, [agentType]: next }))
|
||||||
|
invalidateAgentExpertsCache(agentType)
|
||||||
toast.success(t("toasts.enabled"))
|
toast.success(t("toasts.enabled"))
|
||||||
} else {
|
} else {
|
||||||
await expertsUnlinkFromAgent({ expertId, agentType })
|
await expertsUnlinkFromAgent({ expertId, agentType })
|
||||||
@@ -346,6 +348,7 @@ export function ExpertsSettings() {
|
|||||||
map[entry.agentType] = entry
|
map[entry.agentType] = entry
|
||||||
}
|
}
|
||||||
setStatuses(map)
|
setStatuses(map)
|
||||||
|
invalidateAgentExpertsCache(agentType)
|
||||||
toast.success(t("toasts.disabled"))
|
toast.success(t("toasts.disabled"))
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
68
src/hooks/use-agent-experts.ts
Normal file
68
src/hooks/use-agent-experts.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react"
|
||||||
|
|
||||||
|
import { expertsListForAgent } from "@/lib/api"
|
||||||
|
import type { AgentType, ExpertListItem } from "@/lib/types"
|
||||||
|
|
||||||
|
const agentCache = new Map<AgentType, ExpertListItem[]>()
|
||||||
|
const inflightMap = new Map<AgentType, Promise<ExpertListItem[]>>()
|
||||||
|
|
||||||
|
const EMPTY: ExpertListItem[] = []
|
||||||
|
|
||||||
|
export function useAgentExperts(agentType: AgentType | null): ExpertListItem[] {
|
||||||
|
const cached = useMemo(
|
||||||
|
() => (agentType ? (agentCache.get(agentType) ?? null) : null),
|
||||||
|
[agentType]
|
||||||
|
)
|
||||||
|
// Track which agent type the fetched result belongs to so stale data
|
||||||
|
// from a previous agent is never returned after a switch.
|
||||||
|
const [fetched, setFetched] = useState<{
|
||||||
|
agentType: AgentType
|
||||||
|
experts: ExpertListItem[]
|
||||||
|
} | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!agentType || agentCache.has(agentType)) return
|
||||||
|
|
||||||
|
let cancelled = false
|
||||||
|
let promise = inflightMap.get(agentType)
|
||||||
|
if (!promise) {
|
||||||
|
promise = expertsListForAgent(agentType)
|
||||||
|
.then((list) => {
|
||||||
|
agentCache.set(agentType, list)
|
||||||
|
inflightMap.delete(agentType)
|
||||||
|
return list
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
inflightMap.delete(agentType)
|
||||||
|
console.warn("[useAgentExperts] failed:", err)
|
||||||
|
return EMPTY
|
||||||
|
})
|
||||||
|
inflightMap.set(agentType, promise)
|
||||||
|
}
|
||||||
|
|
||||||
|
promise.then((list) => {
|
||||||
|
if (!cancelled) setFetched({ agentType, experts: list })
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [agentType])
|
||||||
|
|
||||||
|
if (!agentType) return EMPTY
|
||||||
|
if (cached) return cached
|
||||||
|
if (fetched && fetched.agentType === agentType) return fetched.experts
|
||||||
|
return EMPTY
|
||||||
|
}
|
||||||
|
|
||||||
|
export function invalidateAgentExpertsCache(agentType?: AgentType) {
|
||||||
|
if (agentType) {
|
||||||
|
agentCache.delete(agentType)
|
||||||
|
inflightMap.delete(agentType)
|
||||||
|
} else {
|
||||||
|
agentCache.clear()
|
||||||
|
inflightMap.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -340,6 +340,12 @@ export async function expertsList(): Promise<ExpertListItem[]> {
|
|||||||
return getTransport().call("experts_list")
|
return getTransport().call("experts_list")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function expertsListForAgent(
|
||||||
|
agentType: AgentType
|
||||||
|
): Promise<ExpertListItem[]> {
|
||||||
|
return getTransport().call("experts_list_for_agent", { agentType })
|
||||||
|
}
|
||||||
|
|
||||||
export async function expertsGetInstallStatus(
|
export async function expertsGetInstallStatus(
|
||||||
expertId: string
|
expertId: string
|
||||||
): Promise<ExpertInstallStatus[]> {
|
): Promise<ExpertInstallStatus[]> {
|
||||||
|
|||||||
Reference in New Issue
Block a user