feat(agent): list project skills over device RPC in the sidebar (#15566)

*  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 <noreply@anthropic.com>

*  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 <noreply@anthropic.com>

* 🐛 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 <noreply@anthropic.com>

* 💄 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Arvin Xu
2026-06-09 10:58:55 +08:00
committed by GitHub
parent f5458e1ad9
commit ea246d6e17
12 changed files with 512 additions and 236 deletions
@@ -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 });
}
+28
View File
@@ -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;
}
+20 -7
View File
@@ -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<ListProjectSkillsResult>(
workingDirectory ? ['project-skills', workingDirectory] : null,
() => localFileService.listProjectSkills({ scope: workingDirectory! }),
const { data, isLoading } = useClientDataSWR<ListProjectSkillsResult | undefined>(
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 });
@@ -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<AgentDocumentsGroupProps>(({ 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<ResourceFilter>('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<SkillListItem[]>(
() =>
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 (
<Center flex={1} paddingBlock={24}>
<NeuralNetworkLoading size={32} />
</Center>
const AgentDocumentsGroup = memo<AgentDocumentsGroupProps>(
({ 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<ResourceFilter>('skills');
if (error) {
return (
<Center flex={1} paddingBlock={24}>
<Text type={'danger'}>{t('workingPanel.resources.error')}</Text>
</Center>
// 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 = () => (
<SkillsList
items={skillItems}
onOpenFile={(item, relativePath) => {
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:<filename>`
// 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<SkillListItem[]>(
() =>
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 (
<Center flex={1} gap={8} paddingBlock={24}>
<Empty description={t('workingPanel.skills.empty')} icon={SkillsIcon} />
<Center flex={1} paddingBlock={24}>
<NeuralNetworkLoading size={32} />
</Center>
);
}
const flat = activeCount === 1;
if (error) {
return (
<Center flex={1} paddingBlock={24}>
<Text type={'danger'}>{t('workingPanel.resources.error')}</Text>
</Center>
);
}
return (
<Flexbox gap={16} style={{ paddingBottom: 16 }}>
{hasAgent &&
(flat ? (
renderAgentSkillsList()
) : (
<SkillSection
sectionHeader={{
count: skillItems.length,
title: t('workingPanel.skills.section.agent'),
}}
>
{renderAgentSkillsList()}
</SkillSection>
))}
{hasProject && (
<ProjectLevelSkills hideHeader={flat} workingDirectory={workingDirectory!} />
)}
{hasUser && <UserLevelSkills hideHeader={flat} />}
</Flexbox>
);
};
const renderDocuments = () => (
// Always render the tree for the Documents tab even when empty, so the
// toolbar (new folder / new doc) stays reachable.
<Flexbox flex={1} style={{ minHeight: 0 }}>
<DocumentExplorerTree
agentId={agentId}
data={documentsData}
mutate={mutate}
style={{ height: '100%' }}
const renderAgentSkillsList = () => (
<SkillsList
items={skillItems}
onOpenFile={(item, relativePath) => {
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:<filename>`
// 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),
});
}}
/>
</Flexbox>
);
);
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 (
<Center flex={1} paddingBlock={24}>
<NeuralNetworkLoading size={32} />
</Center>
);
}
return (
<Center flex={1} gap={8} paddingBlock={24}>
<Empty description={t('workingPanel.skills.empty')} icon={SkillsIcon} />
</Center>
);
}
const flat = activeCount === 1;
const renderWeb = () => {
if (webData.length === 0) {
return (
<Center flex={1} gap={8} paddingBlock={24}>
<Empty description={t('workingPanel.resources.empty')} icon={GlobeIcon} />
</Center>
<Flexbox gap={16} style={{ paddingBottom: 16 }}>
{hasAgent &&
(flat ? (
renderAgentSkillsList()
) : (
<SkillSection
sectionHeader={{
count: skillItems.length,
title: t('workingPanel.skills.section.agent'),
}}
>
{renderAgentSkillsList()}
</SkillSection>
))}
{hasProject && (
<ProjectLevelSkills
deviceId={deviceId}
hideHeader={flat}
workingDirectory={workingDirectory!}
/>
)}
{hasUser && <UserLevelSkills hideHeader={flat} />}
</Flexbox>
);
}
return (
<Flexbox gap={8}>
{webData.map((doc) => (
<DocumentItem agentId={agentId} document={doc} key={doc.id} mutate={mutate} />
))}
};
const renderDocuments = () => (
// Always render the tree for the Documents tab even when empty, so the
// toolbar (new folder / new doc) stays reachable.
<Flexbox flex={1} style={{ minHeight: 0 }}>
<DocumentExplorerTree
agentId={agentId}
data={documentsData}
mutate={mutate}
style={{ height: '100%' }}
/>
</Flexbox>
);
};
return (
<Flexbox gap={12} style={style}>
<Flexbox horizontal gap={4} role={'tablist'}>
{FILTER_OPTIONS.map((option) => {
const active = filter === option.value;
return (
<div
aria-selected={active}
className={cx(styles.pillTab, active && styles.pillActive)}
key={option.value}
role={'tab'}
onClick={() => setFilter(option.value)}
>
{t(option.labelKey)}
</div>
);
})}
const renderWeb = () => {
if (webData.length === 0) {
return (
<Center flex={1} gap={8} paddingBlock={24}>
<Empty description={t('workingPanel.resources.empty')} icon={GlobeIcon} />
</Center>
);
}
return (
<Flexbox gap={8}>
{webData.map((doc) => (
<DocumentItem agentId={agentId} document={doc} key={doc.id} mutate={mutate} />
))}
</Flexbox>
);
};
return (
<Flexbox gap={12} style={style}>
<Flexbox horizontal gap={4} role={'tablist'}>
{FILTER_OPTIONS.map((option) => {
const active = filter === option.value;
return (
<div
aria-selected={active}
className={cx(styles.pillTab, active && styles.pillActive)}
key={option.value}
role={'tab'}
onClick={() => setFilter(option.value)}
>
{t(option.labelKey)}
</div>
);
})}
</Flexbox>
{filter === 'skills' && renderSkills()}
{filter === 'documents' && renderDocuments()}
{filter === 'web' && renderWeb()}
</Flexbox>
{filter === 'skills' && renderSkills()}
{filter === 'documents' && renderDocuments()}
{filter === 'web' && renderWeb()}
</Flexbox>
);
});
);
},
);
AgentDocumentsGroup.displayName = 'AgentDocumentsGroup';
@@ -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<ProjectLevelSkillsProps>(({ hideHeader, workingDirectory }) => {
const { t } = useTranslation('chat');
const { items, onOpenFile, onOpenSkill } = useProjectSkills(workingDirectory);
const ProjectLevelSkills = memo<ProjectLevelSkillsProps>(
({ 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 = (
<SkillsList
items={items}
onOpenFile={onOpenFile}
onOpenSkill={onOpenSkill}
onSkillDragStart={(item, event) => {
// 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 = (
<SkillsList
items={items}
onOpenFile={onOpenFile}
onOpenSkill={onOpenSkill}
onSkillDragStart={(item, event) => {
// 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 (
<SkillSection
sectionHeader={{
count: items.length,
title: t('workingPanel.skills.section.project'),
}}
>
{list}
</SkillSection>
);
});
return (
<SkillSection
sectionHeader={{
count: items.length,
title: t('workingPanel.skills.section.project'),
}}
>
{list}
</SkillSection>
);
},
);
ProjectLevelSkills.displayName = 'ProjectLevelSkills';
@@ -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<SkillsGroupProps>(({ workingDirectory }) => {
const SkillsGroup = memo<SkillsGroupProps>(({ 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;
@@ -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<ResourcesSectionProps>(({ 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 (
<Flexbox
@@ -27,9 +32,12 @@ const ResourcesSection = memo(() => {
paddingInline={'8px 12px'}
style={{ minHeight: 0 }}
>
{isHetero && workingDirectory && <SkillsGroup workingDirectory={workingDirectory} />}
{isHetero && workingDirectory && (
<SkillsGroup deviceId={deviceId} workingDirectory={workingDirectory} />
)}
{!isHetero && (
<AgentDocumentsGroup
deviceId={deviceId}
style={{ flex: 1, minHeight: 0 }}
workingDirectory={workingDirectory}
/>
@@ -200,7 +200,7 @@ const AgentWorkingSidebar = memo(() => {
width={'100%'}
>
<ProgressSection />
<ResourcesSection />
<ResourcesSection deviceId={remoteDeviceId} />
</Flexbox>
</Flexbox>
</Flexbox>
+16
View File
@@ -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.
@@ -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';
@@ -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<DeviceListProjectSkillsResult | undefined> {
const { userId, deviceId, scope, timeout = 30_000 } = params;
const client = this.getClient();
if (!client) return undefined;
try {
const result = await client.invokeRpc<DeviceListProjectSkillsResult>(
{ 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
+27
View File
@@ -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<ListProjectSkillsResult | undefined> {
return deviceId
? ((await lambdaClient.device.listProjectSkills.query({ deviceId, scope })) ?? undefined)
: localFileService.listProjectSkills({ scope });
}
}
export const projectSkillService = new ProjectSkillService();