From ea246d6e170d615d3ec421341fe596138187cdfd Mon Sep 17 00:00:00 2001 From: Arvin Xu Date: Tue, 9 Jun 2026 10:58:55 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(agent):=20list=20project=20ski?= =?UTF-8?q?lls=20over=20device=20RPC=20in=20the=20sidebar=20(#15566)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨ feat(agent): list project skills over device RPC in the sidebar The right-sidebar 技能 (project skills) tab only read skills over local Electron IPC, so in device mode (working dir on a bound remote device, or the web client) the list was always empty — unlike the Files / Review tabs which already branch on `deviceId`. Add a `listProjectSkills` device RPC mirroring `getProjectFileIndex`: - types: `DeviceProjectSkillItem` / `DeviceListProjectSkillsResult` - `deviceGateway.listProjectSkills` via the generic `invokeRpc` relay - TRPC `device.listProjectSkills` + `GatewayConnectionCtr` dispatch to `WorkspaceCtr.listProjectSkills` - renderer chokepoint `projectSkillService` branches on `deviceId` - `useProjectSkills(dir, deviceId?)`; remote mode lists but doesn't open previews (parity with the Files tab) - thread `remoteDeviceId` through `SkillsGroup` No device-gateway repo change needed — the RPC relay is method-agnostic. Co-Authored-By: Claude Opus 4.8 * ✨ feat(agent): list project skills over device RPC for homogeneous agents too Thread `deviceId` through the homogeneous resources path (`AgentDocumentsGroup` → `ProjectLevelSkills`) so a device-bound homogeneous agent's 技能 tab populates over RPC, matching the heterogeneous `SkillsGroup`. `useProjectSkills` already accepts `deviceId`; this just wires it in and OR-s `deviceId` into the `showProjectSkills` gate. (The large AgentDocumentsGroup diff is prettier re-indentation from wrapping the outer memo() once the param list crossed the print width.) Co-Authored-By: Claude Opus 4.8 * 🐛 fix(agent): resolve per-device cwd in ResourcesSection so device-mode skills load ResourcesSection computed its working directory with the legacy `topicCwd || agentCwd` selector, which misses `workingDirByDevice[deviceId]` and `device.defaultCwd`. For a device-bound agent the cwd lives in that per-device map, so it resolved to `undefined` — the project-skills SWR key was null and the fetch never fired even though `deviceId` was set (the 技能 tab showed "暂无可用技能"). Switch to `useEffectiveWorkingDirectory`, the same resolver the runtime bar / WorkingSidebar use. Fixes both the hetero SkillsGroup and the homogeneous AgentDocumentsGroup paths. Co-Authored-By: Claude Opus 4.8 * 💄 feat(agent): show loading state for project skills while switching path On a working-directory switch the project-skills SWR key changes, so items go empty while the new scan is in flight. The homogeneous skills panel was flashing the empty placeholder instead of a loader. Surface `useProjectSkills().isLoading` and render NeuralNetworkLoading when project skills are the only source and still loading. (The hetero SkillsGroup already shows it via SkillSection's isLoading.) Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- .../main/controllers/GatewayConnectionCtr.ts | 5 + packages/types/src/device.ts | 28 ++ src/features/SkillsList/useProjectSkills.ts | 27 +- .../ResourcesSection/AgentDocumentsGroup.tsx | 388 +++++++++--------- .../ResourcesSection/ProjectLevelSkills.tsx | 68 +-- .../ResourcesSection/SkillsGroup.tsx | 9 +- .../WorkingSidebar/ResourcesSection/index.tsx | 28 +- .../Conversation/WorkingSidebar/index.tsx | 2 +- src/server/routers/lambda/device.ts | 16 + .../deviceGateway/__tests__/index.test.ts | 114 +++++ src/server/services/deviceGateway/index.ts | 36 ++ src/services/projectSkill.ts | 27 ++ 12 files changed, 512 insertions(+), 236 deletions(-) create mode 100644 src/services/projectSkill.ts diff --git a/apps/desktop/src/main/controllers/GatewayConnectionCtr.ts b/apps/desktop/src/main/controllers/GatewayConnectionCtr.ts index 9d82130977..137b484299 100644 --- a/apps/desktop/src/main/controllers/GatewayConnectionCtr.ts +++ b/apps/desktop/src/main/controllers/GatewayConnectionCtr.ts @@ -16,6 +16,7 @@ import type { InitWorkspaceParams, KillCommandParams, ListLocalFileParams, + ListProjectSkillsParams, LocalReadFileParams, LocalReadFilesParams, LocalSearchFilesParams, @@ -407,6 +408,10 @@ export default class GatewayConnectionCtr extends ControllerModule { return this.localFileCtr.getProjectFileIndex(params as { scope?: string }); } + case 'listProjectSkills': { + return this.workspaceCtr.listProjectSkills(params as ListProjectSkillsParams); + } + case 'getGitBranchDiff': { return this.gitCtr.getGitBranchDiff(params as { baseRef?: string; path: string }); } diff --git a/packages/types/src/device.ts b/packages/types/src/device.ts index a3a5fb5f71..280398acba 100644 --- a/packages/types/src/device.ts +++ b/packages/types/src/device.ts @@ -303,3 +303,31 @@ export interface DeviceProjectFileIndexResult { source: 'git' | 'glob'; totalCount: number; } + +/** + * A single project skill (`.agents/skills` / `.claude/skills`) discovered on a + * remote device, returned by the `listProjectSkills` device RPC. Mirrors the + * desktop `ProjectSkillItem` (`@lobechat/electron-client-ipc`). + */ +export interface DeviceProjectSkillItem { + description?: string; + fileCount: number; + files: string[]; + name: string; + /** Absolute path to the SKILL.md file on the device. */ + path: string; + /** Directory containing the SKILL.md. */ + skillDir: string; + source: '.agents/skills' | '.claude/skills'; +} + +/** + * Project skills listing for a directory on a remote device, returned by the + * `listProjectSkills` device RPC. Powers the Resources tab's skills group in + * device mode. Mirrors the desktop `ListProjectSkillsResult`. + */ +export interface DeviceListProjectSkillsResult { + root: string; + skills: DeviceProjectSkillItem[]; + source: DeviceProjectSkillItem['source'] | null; +} diff --git a/src/features/SkillsList/useProjectSkills.ts b/src/features/SkillsList/useProjectSkills.ts index 9191c9ae99..f6f7196067 100644 --- a/src/features/SkillsList/useProjectSkills.ts +++ b/src/features/SkillsList/useProjectSkills.ts @@ -3,7 +3,7 @@ import path from 'pathe'; import { useMemo } from 'react'; import { useClientDataSWR } from '@/libs/swr'; -import { localFileService } from '@/services/electron/localFileService'; +import { projectSkillService } from '@/services/projectSkill'; import { useChatStore } from '@/store/chat'; import type { SkillListItem } from './SkillsList'; @@ -21,15 +21,24 @@ export interface UseProjectSkillsResult { * `.agents/skills/` / `.claude/skills/` in `workingDirectory`. Powers both * the hetero `SkillsGroup` and the homogeneous `ProjectLevelSkills` section. * - * Pass `undefined` to keep the hook inert (no fetch fires) — useful when the - * caller hasn't decided whether to render the section yet. + * `deviceId` picks the transport: when set, the scan runs on that remote device + * via the `device.listProjectSkills` RPC; otherwise it goes through local + * Electron IPC. Like the Files tab, remote mode lists skills but does not open + * previews (the device's filesystem isn't reachable by the local viewer). + * + * Pass `undefined` workingDirectory to keep the hook inert (no fetch fires) — + * useful when the caller hasn't decided whether to render the section yet. */ -export const useProjectSkills = (workingDirectory: string | undefined): UseProjectSkillsResult => { +export const useProjectSkills = ( + workingDirectory: string | undefined, + deviceId?: string, +): UseProjectSkillsResult => { const openLocalFile = useChatStore((s) => s.openLocalFile); + const isRemote = !!deviceId; - const { data, isLoading } = useClientDataSWR( - workingDirectory ? ['project-skills', workingDirectory] : null, - () => localFileService.listProjectSkills({ scope: workingDirectory! }), + const { data, isLoading } = useClientDataSWR( + workingDirectory ? ['project-skills', deviceId ?? 'local', workingDirectory] : null, + () => projectSkillService.listProjectSkills({ deviceId, scope: workingDirectory! }), { revalidateOnFocus: false, shouldRetryOnError: false }, ); @@ -58,6 +67,9 @@ export const useProjectSkills = (workingDirectory: string | undefined): UseProje }, [data?.skills]); const onOpenFile = (item: SkillListItem, relativePath: string) => { + // A remote device has no filesystem the local viewer can open (matches the + // Files tab); device mode lists skills but does not preview them. + if (isRemote) return; const skill = skillByDir.get(item.id); if (!skill) return; openLocalFile({ @@ -67,6 +79,7 @@ export const useProjectSkills = (workingDirectory: string | undefined): UseProje }; const onOpenSkill = (item: SkillListItem) => { + if (isRemote) return; const skill = skillByDir.get(item.id); if (!skill) return; openLocalFile({ filePath: skill.path, workingDirectory: previewRoot }); diff --git a/src/routes/(main)/agent/features/Conversation/WorkingSidebar/ResourcesSection/AgentDocumentsGroup.tsx b/src/routes/(main)/agent/features/Conversation/WorkingSidebar/ResourcesSection/AgentDocumentsGroup.tsx index 959907696b..94ce70f239 100644 --- a/src/routes/(main)/agent/features/Conversation/WorkingSidebar/ResourcesSection/AgentDocumentsGroup.tsx +++ b/src/routes/(main)/agent/features/Conversation/WorkingSidebar/ResourcesSection/AgentDocumentsGroup.tsx @@ -252,214 +252,234 @@ const buildSkillBundleViews = (data: AgentDocumentListItem[]): SkillBundleView[] }; interface AgentDocumentsGroupProps { + /** Bound remote device id (device mode); skills are then scanned over RPC. */ + deviceId?: string; style?: CSSProperties; workingDirectory?: string; } -const AgentDocumentsGroup = memo(({ style, workingDirectory }) => { - const { t } = useTranslation('chat'); - const agentId = useAgentStore((s) => s.activeAgentId); - const isLocalEnabled = useAgentStore((s) => - agentId ? chatConfigByIdSelectors.isLocalSystemEnabledById(agentId)(s) : false, - ); - const openDocument = useChatStore((s) => s.openDocument); - const [filter, setFilter] = useState('skills'); - - const showProjectSkills = isLocalEnabled && !!workingDirectory; - - // Mirror what each child component reads so the parent can decide the - // section layout (flat when a single source has items, sectioned otherwise). - // Both hooks are SWR-deduped against their respective child fetches. - const userSkillItems = useUserSkills(); - const { items: projectSkillItems } = useProjectSkills( - showProjectSkills ? workingDirectory : undefined, - ); - - const { - data = [], - error, - isLoading, - mutate, - } = useClientDataSWR(agentId ? agentDocumentSWRKeys.documentsList(agentId) : null, () => - agentDocumentService.getDocuments({ agentId: agentId! }), - ); - - const webData = useMemo( - () => data.filter((doc) => doc.category === AGENT_DOCUMENT_WEB_CATEGORY), - [data], - ); - - const documentsData = useMemo( - () => data.filter((doc) => doc.category === AGENT_DOCUMENT_CATEGORY), - [data], - ); - - const skillBundleViews = useMemo(() => buildSkillBundleViews(data), [data]); - - const skillItems = useMemo( - () => - skillBundleViews.map(({ bundle, files }) => ({ - description: bundle.description ?? undefined, - fileCount: files.length, - files, - id: bundle.documentId, - name: bundle.title || bundle.filename || '', - })), - [skillBundleViews], - ); - - if (!agentId) return null; - - if (isLoading) { - return ( -
- -
+const AgentDocumentsGroup = memo( + ({ deviceId, style, workingDirectory }) => { + const { t } = useTranslation('chat'); + const agentId = useAgentStore((s) => s.activeAgentId); + const isLocalEnabled = useAgentStore((s) => + agentId ? chatConfigByIdSelectors.isLocalSystemEnabledById(agentId)(s) : false, ); - } + const openDocument = useChatStore((s) => s.openDocument); + const [filter, setFilter] = useState('skills'); - if (error) { - return ( -
- {t('workingPanel.resources.error')} -
+ // Local desktop reads skills over IPC; a bound device reads over RPC. + const showProjectSkills = (isLocalEnabled || !!deviceId) && !!workingDirectory; + + // Mirror what each child component reads so the parent can decide the + // section layout (flat when a single source has items, sectioned otherwise). + // Both hooks are SWR-deduped against their respective child fetches. + const userSkillItems = useUserSkills(); + const { items: projectSkillItems, isLoading: isProjectSkillsLoading } = useProjectSkills( + showProjectSkills ? workingDirectory : undefined, + deviceId, ); - } - const renderAgentSkillsList = () => ( - { - const view = skillBundleViews.find((v) => v.bundle.documentId === item.id); - const docId = view?.pathToDocumentId.get(relativePath); - if (!docId) return; - const row = data.find((d) => d.documentId === docId); - openDocument(docId, row?.id); - }} - onOpenSkill={(item) => { - // Open the SKILL.md (skills/index child) when present; fall back to - // the bundle itself (orphan bundles surface for recovery). - const view = skillBundleViews.find((v) => v.bundle.documentId === item.id); - const indexChild = data.find((doc) => doc.parentId === item.id && doc.isSkillIndex); - const targetDocId = indexChild?.documentId ?? view?.bundle.documentId ?? item.id; - const targetRow = data.find((d) => d.documentId === targetDocId); - openDocument(targetDocId, targetRow?.id); - }} - onSkillDragStart={(item, event) => { - // The runtime resolves these via the `agent-skills:` - // identifier (built from the shared const helper so the prefix stays - // in lockstep with the server-side resolver). Display label keeps - // the human-readable title. - const view = skillBundleViews.find((v) => v.bundle.documentId === item.id); - const filename = view?.bundle.filename; - if (!filename) return; - startSkillDrag(event, { - category: 'agentSkill', - label: item.name, - type: buildAgentSkillIdentifier(filename), - }); - }} - /> - ); + const { + data = [], + error, + isLoading, + mutate, + } = useClientDataSWR(agentId ? agentDocumentSWRKeys.documentsList(agentId) : null, () => + agentDocumentService.getDocuments({ agentId: agentId! }), + ); - const renderSkills = () => { - // Sections render in fixed order — agent → project → user — and each one - // hides itself when it has nothing to show. When exactly one source has - // items we drop the group header and render the list flat (no redundant - // "User skills 1" label above a single row). When everything is empty we - // fall back to a single placeholder. - const hasAgent = skillItems.length > 0; - const hasProject = showProjectSkills && projectSkillItems.length > 0; - const hasUser = userSkillItems.length > 0; - const activeCount = (hasAgent ? 1 : 0) + (hasProject ? 1 : 0) + (hasUser ? 1 : 0); + const webData = useMemo( + () => data.filter((doc) => doc.category === AGENT_DOCUMENT_WEB_CATEGORY), + [data], + ); - if (activeCount === 0) { + const documentsData = useMemo( + () => data.filter((doc) => doc.category === AGENT_DOCUMENT_CATEGORY), + [data], + ); + + const skillBundleViews = useMemo(() => buildSkillBundleViews(data), [data]); + + const skillItems = useMemo( + () => + skillBundleViews.map(({ bundle, files }) => ({ + description: bundle.description ?? undefined, + fileCount: files.length, + files, + id: bundle.documentId, + name: bundle.title || bundle.filename || '', + })), + [skillBundleViews], + ); + + if (!agentId) return null; + + if (isLoading) { return ( -
- +
+
); } - const flat = activeCount === 1; + if (error) { + return ( +
+ {t('workingPanel.resources.error')} +
+ ); + } - return ( - - {hasAgent && - (flat ? ( - renderAgentSkillsList() - ) : ( - - {renderAgentSkillsList()} - - ))} - {hasProject && ( - - )} - {hasUser && } - - ); - }; - - const renderDocuments = () => ( - // Always render the tree for the Documents tab even when empty, so the - // toolbar (new folder / new doc) stays reachable. - - ( + { + const view = skillBundleViews.find((v) => v.bundle.documentId === item.id); + const docId = view?.pathToDocumentId.get(relativePath); + if (!docId) return; + const row = data.find((d) => d.documentId === docId); + openDocument(docId, row?.id); + }} + onOpenSkill={(item) => { + // Open the SKILL.md (skills/index child) when present; fall back to + // the bundle itself (orphan bundles surface for recovery). + const view = skillBundleViews.find((v) => v.bundle.documentId === item.id); + const indexChild = data.find((doc) => doc.parentId === item.id && doc.isSkillIndex); + const targetDocId = indexChild?.documentId ?? view?.bundle.documentId ?? item.id; + const targetRow = data.find((d) => d.documentId === targetDocId); + openDocument(targetDocId, targetRow?.id); + }} + onSkillDragStart={(item, event) => { + // The runtime resolves these via the `agent-skills:` + // identifier (built from the shared const helper so the prefix stays + // in lockstep with the server-side resolver). Display label keeps + // the human-readable title. + const view = skillBundleViews.find((v) => v.bundle.documentId === item.id); + const filename = view?.bundle.filename; + if (!filename) return; + startSkillDrag(event, { + category: 'agentSkill', + label: item.name, + type: buildAgentSkillIdentifier(filename), + }); + }} /> - - ); + ); + + const renderSkills = () => { + // Sections render in fixed order — agent → project → user — and each one + // hides itself when it has nothing to show. When exactly one source has + // items we drop the group header and render the list flat (no redundant + // "User skills 1" label above a single row). When everything is empty we + // fall back to a single placeholder. + const hasAgent = skillItems.length > 0; + const hasProject = showProjectSkills && projectSkillItems.length > 0; + const hasUser = userSkillItems.length > 0; + const activeCount = (hasAgent ? 1 : 0) + (hasProject ? 1 : 0) + (hasUser ? 1 : 0); + + if (activeCount === 0) { + // Project skills refetch on a working-directory switch (new SWR key → + // empty items while in flight). Show the loader instead of flashing the + // empty placeholder when there's nothing else to render yet. + if (showProjectSkills && isProjectSkillsLoading) { + return ( +
+ +
+ ); + } + return ( +
+ +
+ ); + } + + const flat = activeCount === 1; - const renderWeb = () => { - if (webData.length === 0) { return ( -
- -
+ + {hasAgent && + (flat ? ( + renderAgentSkillsList() + ) : ( + + {renderAgentSkillsList()} + + ))} + {hasProject && ( + + )} + {hasUser && } + ); - } - return ( - - {webData.map((doc) => ( - - ))} + }; + + const renderDocuments = () => ( + // Always render the tree for the Documents tab even when empty, so the + // toolbar (new folder / new doc) stays reachable. + + ); - }; - return ( - - - {FILTER_OPTIONS.map((option) => { - const active = filter === option.value; - return ( -
setFilter(option.value)} - > - {t(option.labelKey)} -
- ); - })} + const renderWeb = () => { + if (webData.length === 0) { + return ( +
+ +
+ ); + } + return ( + + {webData.map((doc) => ( + + ))} + + ); + }; + + return ( + + + {FILTER_OPTIONS.map((option) => { + const active = filter === option.value; + return ( +
setFilter(option.value)} + > + {t(option.labelKey)} +
+ ); + })} +
+ {filter === 'skills' && renderSkills()} + {filter === 'documents' && renderDocuments()} + {filter === 'web' && renderWeb()}
- {filter === 'skills' && renderSkills()} - {filter === 'documents' && renderDocuments()} - {filter === 'web' && renderWeb()} -
- ); -}); + ); + }, +); AgentDocumentsGroup.displayName = 'AgentDocumentsGroup'; diff --git a/src/routes/(main)/agent/features/Conversation/WorkingSidebar/ResourcesSection/ProjectLevelSkills.tsx b/src/routes/(main)/agent/features/Conversation/WorkingSidebar/ResourcesSection/ProjectLevelSkills.tsx index aac6672638..ded068c366 100644 --- a/src/routes/(main)/agent/features/Conversation/WorkingSidebar/ResourcesSection/ProjectLevelSkills.tsx +++ b/src/routes/(main)/agent/features/Conversation/WorkingSidebar/ResourcesSection/ProjectLevelSkills.tsx @@ -5,6 +5,8 @@ import { startSkillDrag } from '@/features/ChatInput/InputEditor/ActionTag/skill import { SkillSection, SkillsList, useProjectSkills } from '@/features/SkillsList'; interface ProjectLevelSkillsProps { + /** Bound remote device id; when set, skills are scanned over RPC. */ + deviceId?: string; /** * Skip the `SkillSection` wrapper (no header row). Set when the parent has * collapsed to a single visible source and wants the list rendered flat. @@ -13,42 +15,44 @@ interface ProjectLevelSkillsProps { workingDirectory: string; } -const ProjectLevelSkills = memo(({ hideHeader, workingDirectory }) => { - const { t } = useTranslation('chat'); - const { items, onOpenFile, onOpenSkill } = useProjectSkills(workingDirectory); +const ProjectLevelSkills = memo( + ({ deviceId, hideHeader, workingDirectory }) => { + const { t } = useTranslation('chat'); + const { items, onOpenFile, onOpenSkill } = useProjectSkills(workingDirectory, deviceId); - if (items.length === 0) return null; + if (items.length === 0) return null; - const list = ( - { - // Project skills are resolved by the underlying CLI agent itself, so - // we serialize them as a literal `/skill-name` (projectSkill chip). - startSkillDrag(event, { - category: 'projectSkill', - label: item.name, - type: item.name, - }); - }} - /> - ); + const list = ( + { + // Project skills are resolved by the underlying CLI agent itself, so + // we serialize them as a literal `/skill-name` (projectSkill chip). + startSkillDrag(event, { + category: 'projectSkill', + label: item.name, + type: item.name, + }); + }} + /> + ); - if (hideHeader) return list; + if (hideHeader) return list; - return ( - - {list} - - ); -}); + return ( + + {list} + + ); + }, +); ProjectLevelSkills.displayName = 'ProjectLevelSkills'; diff --git a/src/routes/(main)/agent/features/Conversation/WorkingSidebar/ResourcesSection/SkillsGroup.tsx b/src/routes/(main)/agent/features/Conversation/WorkingSidebar/ResourcesSection/SkillsGroup.tsx index 903fc9433a..7b2a3451af 100644 --- a/src/routes/(main)/agent/features/Conversation/WorkingSidebar/ResourcesSection/SkillsGroup.tsx +++ b/src/routes/(main)/agent/features/Conversation/WorkingSidebar/ResourcesSection/SkillsGroup.tsx @@ -6,14 +6,19 @@ import { startSkillDrag } from '@/features/ChatInput/InputEditor/ActionTag/skill import { SkillSection, SkillsList, useProjectSkills } from '@/features/SkillsList'; interface SkillsGroupProps { + /** Bound remote device id; when set, skills are scanned over RPC. */ + deviceId?: string; workingDirectory: string; } -const SkillsGroup = memo(({ workingDirectory }) => { +const SkillsGroup = memo(({ deviceId, workingDirectory }) => { const { t } = useTranslation('chat'); - const enabled = isDesktop && !!workingDirectory; + // Local desktop reads over IPC; a bound device reads over RPC. Either path + // makes the skills list reachable even when this client isn't the desktop. + const enabled = (isDesktop || !!deviceId) && !!workingDirectory; const { isLoading, items, onOpenFile, onOpenSkill } = useProjectSkills( enabled ? workingDirectory : undefined, + deviceId, ); if (!enabled) return null; diff --git a/src/routes/(main)/agent/features/Conversation/WorkingSidebar/ResourcesSection/index.tsx b/src/routes/(main)/agent/features/Conversation/WorkingSidebar/ResourcesSection/index.tsx index 847a648d20..3772221175 100644 --- a/src/routes/(main)/agent/features/Conversation/WorkingSidebar/ResourcesSection/index.tsx +++ b/src/routes/(main)/agent/features/Conversation/WorkingSidebar/ResourcesSection/index.tsx @@ -1,22 +1,27 @@ import { Flexbox } from '@lobehub/ui'; import { memo } from 'react'; +import { useEffectiveWorkingDirectory } from '@/hooks/useEffectiveWorkingDirectory'; import { useAgentStore } from '@/store/agent'; -import { agentByIdSelectors, agentSelectors } from '@/store/agent/selectors'; -import { useChatStore } from '@/store/chat'; -import { topicSelectors } from '@/store/chat/selectors'; +import { agentSelectors } from '@/store/agent/selectors'; import AgentDocumentsGroup from './AgentDocumentsGroup'; import SkillsGroup from './SkillsGroup'; -const ResourcesSection = memo(() => { +interface ResourcesSectionProps { + /** Bound remote device id (device mode); skills are then scanned over RPC. */ + deviceId?: string; +} + +const ResourcesSection = memo(({ deviceId }) => { const isHetero = useAgentStore(agentSelectors.isCurrentAgentHeterogeneous); const activeAgentId = useAgentStore((s) => s.activeAgentId); - const agentWorkingDirectory = useAgentStore((s) => - activeAgentId ? agentByIdSelectors.getAgentWorkingDirectoryById(activeAgentId)(s) : undefined, - ); - const topicWorkingDirectory = useChatStore(topicSelectors.currentTopicWorkingDirectory); - const workingDirectory = topicWorkingDirectory || agentWorkingDirectory; + // Resolve the cwd the same way the runtime bar / WorkingSidebar do + // (`useEffectiveWorkingDirectory`). The old `topicCwd || agentCwd` pattern + // missed `workingDirByDevice[deviceId]` / `device.defaultCwd`, so a + // device-bound agent resolved to `undefined` here and the skills fetch never + // fired even though `deviceId` was set. + const workingDirectory = useEffectiveWorkingDirectory(activeAgentId); return ( { paddingInline={'8px 12px'} style={{ minHeight: 0 }} > - {isHetero && workingDirectory && } + {isHetero && workingDirectory && ( + + )} {!isHetero && ( diff --git a/src/routes/(main)/agent/features/Conversation/WorkingSidebar/index.tsx b/src/routes/(main)/agent/features/Conversation/WorkingSidebar/index.tsx index b3422025bc..ea914f394d 100644 --- a/src/routes/(main)/agent/features/Conversation/WorkingSidebar/index.tsx +++ b/src/routes/(main)/agent/features/Conversation/WorkingSidebar/index.tsx @@ -200,7 +200,7 @@ const AgentWorkingSidebar = memo(() => { width={'100%'} > - +
diff --git a/src/server/routers/lambda/device.ts b/src/server/routers/lambda/device.ts index 4374a78792..1741d2091a 100644 --- a/src/server/routers/lambda/device.ts +++ b/src/server/routers/lambda/device.ts @@ -270,6 +270,22 @@ export const deviceRouter = router({ return result ?? null; }), + /** + * Project skills (`.agents/skills` / `.claude/skills`) for a directory on a + * remote device, via the device's `listProjectSkills` RPC. Powers the + * Resources tab's skills group in device mode. Returns `null` when offline. + */ + listProjectSkills: deviceProcedure + .input(z.object({ deviceId: z.string(), scope: z.string() })) + .query(async ({ ctx, input }) => { + const result = await deviceGateway.listProjectSkills({ + deviceId: input.deviceId, + scope: input.scope, + userId: ctx.userId, + }); + return result ?? null; + }), + /** * Revert a single file in a directory on a remote device, via the device's * `revertGitFile` RPC. diff --git a/src/server/services/deviceGateway/__tests__/index.test.ts b/src/server/services/deviceGateway/__tests__/index.test.ts index 96d3c2a9c4..b48ed93d95 100644 --- a/src/server/services/deviceGateway/__tests__/index.test.ts +++ b/src/server/services/deviceGateway/__tests__/index.test.ts @@ -560,6 +560,120 @@ describe('DeviceGateway', () => { }); }); + describe('listProjectSkills', () => { + const configure = () => { + mockEnv.DEVICE_GATEWAY_URL = 'https://gateway.example.com'; + mockEnv.DEVICE_GATEWAY_SERVICE_TOKEN = 'token'; + }; + + it('should return undefined when not configured', async () => { + const proxy = new DeviceGateway(); + const result = await proxy.listProjectSkills({ + deviceId: 'dev-1', + scope: '/proj', + userId: 'user-1', + }); + expect(result).toBeUndefined(); + expect(mockClient.invokeRpc).not.toHaveBeenCalled(); + }); + + it('passes the device result through and invokes the rpc with scope', async () => { + configure(); + const data = { + root: '/proj', + skills: [ + { + description: 'spa', + fileCount: 3, + files: ['SKILL.md'], + name: 'spa-routes', + path: '/proj/.agents/skills/spa-routes/SKILL.md', + skillDir: '/proj/.agents/skills/spa-routes', + source: '.agents/skills', + }, + ], + source: '.agents/skills', + }; + mockClient.invokeRpc.mockResolvedValue({ data, success: true }); + + const proxy = new DeviceGateway(); + const result = await proxy.listProjectSkills({ + deviceId: 'dev-1', + scope: '/proj', + userId: 'user-1', + }); + + expect(result).toEqual(data); + expect(mockClient.invokeRpc).toHaveBeenCalledWith( + { deviceId: 'dev-1', timeout: 30_000, userId: 'user-1' }, + { method: 'listProjectSkills', params: { scope: '/proj' } }, + ); + }); + + it('returns undefined when the rpc reports failure', async () => { + configure(); + mockClient.invokeRpc.mockResolvedValue({ error: 'offline', success: false }); + + const proxy = new DeviceGateway(); + const result = await proxy.listProjectSkills({ + deviceId: 'dev-1', + scope: '/proj', + userId: 'user-1', + }); + + expect(result).toBeUndefined(); + }); + + it('returns undefined when the rpc succeeds without data', async () => { + configure(); + mockClient.invokeRpc.mockResolvedValue({ success: true }); + + const proxy = new DeviceGateway(); + const result = await proxy.listProjectSkills({ + deviceId: 'dev-1', + scope: '/proj', + userId: 'user-1', + }); + + expect(result).toBeUndefined(); + }); + + it('returns undefined on exception', async () => { + configure(); + mockClient.invokeRpc.mockRejectedValue(new Error('timeout')); + + const proxy = new DeviceGateway(); + const result = await proxy.listProjectSkills({ + deviceId: 'dev-1', + scope: '/proj', + userId: 'user-1', + }); + + expect(result).toBeUndefined(); + }); + + it('forwards a custom timeout', async () => { + configure(); + mockClient.invokeRpc.mockResolvedValue({ + data: { root: '/proj', skills: [], source: null }, + success: true, + }); + + const proxy = new DeviceGateway(); + await proxy.listProjectSkills({ + deviceId: 'dev-1', + scope: '/proj', + timeout: 60_000, + userId: 'user-1', + }); + + expect(mockClient.invokeRpc).toHaveBeenCalledWith( + { deviceId: 'dev-1', timeout: 60_000, userId: 'user-1' }, + { method: 'listProjectSkills', params: { scope: '/proj' } }, + ); + }); + }); + describe('getClient (lazy initialization)', () => { it('should return null when URL is missing', async () => { mockEnv.DEVICE_GATEWAY_SERVICE_TOKEN = 'token'; diff --git a/src/server/services/deviceGateway/index.ts b/src/server/services/deviceGateway/index.ts index 9033219e5c..a6944c4fa0 100644 --- a/src/server/services/deviceGateway/index.ts +++ b/src/server/services/deviceGateway/index.ts @@ -21,6 +21,7 @@ import type { DeviceGitWorkingTreeFiles, DeviceGitWorkingTreePatches, DeviceGitWorkingTreeStatus, + DeviceListProjectSkillsResult, DeviceProjectFileIndexResult, ProjectSkillMeta, WorkspaceInitResult, @@ -465,6 +466,41 @@ export class DeviceGateway { } } + /** + * Project skills (`.agents/skills` / `.claude/skills`) for a directory on a + * remote device via the `listProjectSkills` device RPC — the Resources tab's + * skills group in device mode. Mirrors `getProjectFileIndex`; returns + * `undefined` when the gateway is unconfigured, the device is offline, or the + * call fails so the UI degrades to "no skills". + */ + async listProjectSkills(params: { + deviceId: string; + scope: string; + timeout?: number; + userId: string; + }): Promise { + const { userId, deviceId, scope, timeout = 30_000 } = params; + const client = this.getClient(); + if (!client) return undefined; + + try { + const result = await client.invokeRpc( + { deviceId, timeout, userId }, + { method: 'listProjectSkills', params: { scope } }, + ); + + if (!result.success || !result.data) { + log('listProjectSkills: failed for deviceId=%s — %s', deviceId, result.error); + return undefined; + } + + return result.data; + } catch (error) { + log('listProjectSkills: error for deviceId=%s — %O', deviceId, error); + return undefined; + } + } + /** * List the remote branches (`refs/remotes/origin/*`) of a directory on a * remote device via the `listGitRemoteBranches` device RPC, so the web/remote diff --git a/src/services/projectSkill.ts b/src/services/projectSkill.ts new file mode 100644 index 0000000000..a215b28b62 --- /dev/null +++ b/src/services/projectSkill.ts @@ -0,0 +1,27 @@ +import type { ListProjectSkillsResult } from '@lobechat/electron-client-ipc'; + +import { lambdaClient } from '@/libs/trpc/client'; +import { localFileService } from '@/services/electron/localFileService'; + +/** + * Project skills chokepoint. Picks the transport per call from `deviceId`: a + * remote / web target goes through the `device.listProjectSkills` RPC; the local + * desktop talks to Electron over IPC. UI / store only see this service — the + * electron-vs-lambda decision never leaks up. (Parallels `projectFileService`.) + */ +class ProjectSkillService { + /** List `.agents/skills` / `.claude/skills` for a working directory. */ + async listProjectSkills({ + deviceId, + scope, + }: { + deviceId?: string; + scope: string; + }): Promise { + return deviceId + ? ((await lambdaClient.device.listProjectSkills.query({ deviceId, scope })) ?? undefined) + : localFileService.listProjectSkills({ scope }); + } +} + +export const projectSkillService = new ProjectSkillService();