the last piece of docs/plugin-capabilities.md. skills, affordances and notification catalogues already loaded from each installed and enabled plugin's own directory; tasks did not, so the transcribe plugin shipped a task nothing could see — right-clicking an .m4a offered no Run Task at all. it turned out to be one function rather than several. every path that runs a task resolves it through getTaskByDirName and then reads implementation off the record, so the sources go in there and in listTasks and a plugin's task is runnable everywhere at once, identity unchanged: still a directory name, still what pipeline_jobs stores. the record carries `plugin` and that one field does the rest. updateTask and deleteTask throw on it, DELETE /api/tasks/:name answers 409 naming the plugin, and the capability screen shows whose it is instead of an edit and a delete that cannot work. without the delete guard it was quietly a no-op: rm -rf on a path in the owner's store that does not exist, then ok: true, and the task still in the menu. owner's store first, so a plugin cannot take over a name already in use. getTaskByDirName now refuses a name containing a separator. it always joined a caller-supplied string onto a path — from a websocket message and a job row — and searching several roots is the moment that stops being one directory nobody could escape. capabilityRootsFrom is gone; it was dead. capabilitySources replaces it and carries the provenance, with capabilityRoots the thin map over it that the loaders that only want paths still use. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
642 lines
25 KiB
TypeScript
642 lines
25 KiB
TypeScript
import type { FormEvent } from 'react';
|
|
import { useState, useEffect, useRef } from 'react';
|
|
import { Link, useNavigate, useParams, useSearchParams } from 'react-router';
|
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import ReactMarkdown from 'react-markdown';
|
|
import remarkGfm from 'remark-gfm';
|
|
import rehypeRaw from 'rehype-raw';
|
|
import { toast } from 'sonner';
|
|
import { ArrowLeft, Pencil, Plus, Check, X, Trash2, Search, ChevronRight } from 'lucide-react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
|
import { useClient } from 'hooks/useClient';
|
|
import { Card } from '@/components/Card';
|
|
import { useChat, EmbeddableChat } from 'officerdev';
|
|
import { useTranslation } from '@/lib/i18n';
|
|
type CapabilitySummary = {
|
|
dirName: string;
|
|
name: string;
|
|
description: string;
|
|
};
|
|
|
|
export type CapabilityDetail = CapabilitySummary & {
|
|
body: string;
|
|
rawFrontmatter: string;
|
|
filePath: string;
|
|
chatSessionId: string | null;
|
|
/** Only tasks carry this today: the plugin that ships it, or null/absent when it is the owner's own. */
|
|
plugin?: string | null;
|
|
};
|
|
|
|
type CapabilityListProps = {
|
|
kind: string;
|
|
endpoint: string;
|
|
queryKey: string;
|
|
/** Route root for this capability — `/tasks`, `/skills`, `/processes`. Rows link to `<basePath>/<dirName>`. */
|
|
basePath: string;
|
|
selected: string | null;
|
|
onCreate: (dirName: string) => void;
|
|
search?: string;
|
|
showCreate?: boolean;
|
|
onShowCreateChange?: (value: boolean) => void;
|
|
};
|
|
|
|
type CapabilityPageProps = {
|
|
kind: string;
|
|
endpoint: string;
|
|
queryKey: string;
|
|
/** Route root — given explicitly rather than reused from `endpoint`, which only happens to match. */
|
|
basePath: string;
|
|
};
|
|
|
|
type CapabilityChatProps = {
|
|
kind: string;
|
|
endpoint: string;
|
|
dirName: string;
|
|
filePath: string;
|
|
resourceDir: string;
|
|
chatSessionId: string | null;
|
|
isNew?: boolean;
|
|
description?: string;
|
|
onResponseEnd?: () => void;
|
|
};
|
|
|
|
const buildTaskCreationPrefix = (filePath: string, resourceDir: string) =>
|
|
`<frontmatter>
|
|
input file: ${filePath}
|
|
TASK.md: ${filePath}
|
|
dir: ${resourceDir}
|
|
|
|
Be aware of any extra files alongside the same dir as the task file we're handling, for possible extra context. You can also, if pertinent, create scripts or other files that will help you in the future.
|
|
</frontmatter>
|
|
|
|
<task-creation-guide>
|
|
You are helping create a new task. Gather requirements through a short conversation BEFORE writing the TASK.md. Ask questions one at a time (or a small related group), wait for the answer, then move on.
|
|
|
|
Conversation flow:
|
|
1. First, ask what the task should do — its purpose and high-level steps.
|
|
2. Based on the answer, ask about triggers: should it appear in the file browser context menu for specific file types? For directories? Or only be runnable from the Automation page?
|
|
3. Then ask if it needs user inputs (parameters) when running, and if so what kind (text, number, yes/no toggle, dropdown).
|
|
4. If anything is still unclear, ask a follow-up. Otherwise, write the TASK.md.
|
|
|
|
Rules:
|
|
- Never ask all questions at once. Keep it conversational.
|
|
- Each message should have at most 1-2 questions on the same topic.
|
|
- Summarize what you understood before writing the file so the user can confirm.
|
|
|
|
## TASK.md Format
|
|
|
|
\`\`\`yaml
|
|
---
|
|
name: Task Name
|
|
description: Short description of what the task does.
|
|
version: 1
|
|
author: pastilhas
|
|
tags:
|
|
- tag1
|
|
- tag2
|
|
skills:
|
|
- skill-name # optional — skills the agent can use
|
|
tools:
|
|
- tool_name # optional — tools the agent can call
|
|
trigger: # optional — when omitted, only runnable from Automation page
|
|
- type: file
|
|
extensions:
|
|
- mp3
|
|
- flac
|
|
- type: directory
|
|
inputs: # optional — parameters the user fills in before running
|
|
- name: param_name
|
|
description: What this parameter is for.
|
|
type: string # string (default) | number | boolean | select
|
|
required: true
|
|
default: some value
|
|
# select example:
|
|
- name: country
|
|
description: Country to use.
|
|
type: select
|
|
default: US
|
|
options:
|
|
- value: US
|
|
label: United States
|
|
- value: PT
|
|
label: Portugal
|
|
# number example:
|
|
- name: limit
|
|
type: number
|
|
default: 20
|
|
min: 1
|
|
max: 100
|
|
# boolean example:
|
|
- name: download
|
|
type: boolean
|
|
default: false
|
|
---
|
|
|
|
(Markdown body with detailed instructions for the agent executing the task)
|
|
\`\`\`
|
|
|
|
## Trigger rules
|
|
- \`type: file\` + \`extensions\` → appears in file browser context menu for those file types
|
|
- \`type: directory\` → appears on right-click directories
|
|
- Both can coexist in the same task
|
|
- No triggers → task is only runnable from the Automation page
|
|
|
|
## Notes
|
|
- Tasks run on the host as the server owner
|
|
- The markdown body after the frontmatter should contain step-by-step instructions for the agent
|
|
</task-creation-guide>`;
|
|
|
|
const buildSkillCreationPrefix = (filePath: string, resourceDir: string) =>
|
|
`<frontmatter>
|
|
input file: ${filePath}
|
|
SKILL.md: ${filePath}
|
|
dir: ${resourceDir}
|
|
|
|
Be aware of any extra files alongside the same dir as the skill file we're handling, for possible extra context. You can also, if pertinent, create scripts or other files that will help you in the future.
|
|
</frontmatter>
|
|
|
|
<skill-creation-guide>
|
|
You are helping create a new skill. A skill is a reference document (knowledge base) that the agent can consult when performing tasks. Gather requirements through a short conversation BEFORE writing the SKILL.md. Ask questions one at a time (or a small related group), wait for the answer, then move on.
|
|
|
|
Conversation flow:
|
|
1. First, ask what technology, API, or domain this skill covers — what should the agent know about?
|
|
2. Ask what key information should be included: API reference, code examples, common patterns, gotchas?
|
|
3. If it's for a specific library or tool, ask for the version and any project-specific conventions.
|
|
4. Summarize what you understood before writing the file so the user can confirm.
|
|
|
|
Rules:
|
|
- Never ask all questions at once. Keep it conversational.
|
|
- Each message should have at most 1-2 questions on the same topic.
|
|
- Summarize what you understood before writing the file so the user can confirm.
|
|
|
|
## SKILL.md Format
|
|
|
|
\`\`\`yaml
|
|
---
|
|
name: skill-name
|
|
description: When to use this skill — a sentence describing the domain and trigger conditions.
|
|
---
|
|
|
|
(Comprehensive reference documentation in markdown — API docs, code examples, recipes, best practices)
|
|
\`\`\`
|
|
|
|
## Notes
|
|
- The frontmatter only needs \`name\` and \`description\`
|
|
- The description should tell the agent WHEN to consult this skill (e.g. "Use when the user wants to process images with sharp")
|
|
- The markdown body is the actual knowledge — be thorough, include code examples and common recipes
|
|
- Skills are referenced by name in TASK.md \`skills:\` fields
|
|
</skill-creation-guide>`;
|
|
|
|
const buildToolCreationPrefix = (filePath: string, resourceDir: string) =>
|
|
`<frontmatter>
|
|
input file: ${filePath}
|
|
TOOL.md: ${filePath}
|
|
dir: ${resourceDir}
|
|
|
|
Be aware of any extra files alongside the same dir as the tool file we're handling, for possible extra context. You can also, if pertinent, create scripts or other files that will help you in the future.
|
|
</frontmatter>
|
|
|
|
<tool-creation-guide>
|
|
You are helping create a new tool. A tool is an executable function the agent can call. Gather requirements through a short conversation BEFORE writing the TOOL.md. Ask questions one at a time (or a small related group), wait for the answer, then move on.
|
|
|
|
Conversation flow:
|
|
1. First, ask what the tool should do — what action does it perform?
|
|
2. Ask what inputs (parameters) it needs and their types.
|
|
3. Ask what language it should be implemented in (TypeScript, Bash, or Python) and whether it needs any external APIs or services.
|
|
4. If anything is still unclear, ask a follow-up. Otherwise, write the TOOL.md.
|
|
|
|
Rules:
|
|
- Never ask all questions at once. Keep it conversational.
|
|
- Each message should have at most 1-2 questions on the same topic.
|
|
- Summarize what you understood before writing the file so the user can confirm.
|
|
|
|
## TOOL.md Format
|
|
|
|
\`\`\`yaml
|
|
---
|
|
name: tool_name
|
|
label: Tool Display Name
|
|
description: What the tool does and when to use it.
|
|
language: typescript # typescript | bash | python
|
|
inputs:
|
|
param_name:
|
|
type: string # string | number | boolean | enum | object
|
|
description: What this parameter is for.
|
|
optional_param:
|
|
type: string
|
|
description: An optional parameter.
|
|
optional: true
|
|
secret_param:
|
|
type: string
|
|
description: A sensitive parameter (e.g. API key).
|
|
optional: true
|
|
sensitive: true
|
|
choice_param:
|
|
type: enum
|
|
description: A parameter with fixed options.
|
|
values:
|
|
- option_a
|
|
- option_b
|
|
---
|
|
|
|
(Markdown body with documentation: usage notes, output format, error handling, examples)
|
|
\`\`\`
|
|
|
|
## Input types
|
|
- \`string\` — free text (default)
|
|
- \`number\` — numeric value
|
|
- \`boolean\` — true/false
|
|
- \`enum\` — fixed set of values (list under \`values:\`)
|
|
- \`object\` — JSON object
|
|
|
|
## Notes
|
|
- Tools run on the host as the server owner
|
|
- The \`name\` field uses snake_case (this is the function name the agent calls)
|
|
- The \`label\` field is the human-readable display name
|
|
- Mark parameters as \`optional: true\` when they have sensible defaults
|
|
- Mark credentials/keys as \`sensitive: true\` so they aren't logged
|
|
- Tools are referenced by name in TASK.md \`tools:\` fields
|
|
</tool-creation-guide>`;
|
|
|
|
const buildCreationPrefix = (kind: string, filePath: string, resourceDir: string) => {
|
|
switch (kind) {
|
|
case 'task':
|
|
return buildTaskCreationPrefix(filePath, resourceDir);
|
|
case 'skill':
|
|
return buildSkillCreationPrefix(filePath, resourceDir);
|
|
case 'tool':
|
|
return buildToolCreationPrefix(filePath, resourceDir);
|
|
default:
|
|
return null;
|
|
}
|
|
};
|
|
|
|
export const CapabilityChat = ({
|
|
kind,
|
|
filePath,
|
|
resourceDir,
|
|
isNew,
|
|
description,
|
|
onResponseEnd,
|
|
}: CapabilityChatProps) => {
|
|
const { t } = useTranslation();
|
|
const seedFile = `${kind.toUpperCase()}.md`;
|
|
const genericPrefix = `<frontmatter>\ninput file: ${filePath}\n${seedFile}: ${filePath}\ndir: ${resourceDir}\n\nBe aware of any extra files alongside the same dir as the ${kind} file we're handling, for possible extra context. You can also, if pertinent, create scripts or other files that will help you in the future.\n</frontmatter>`;
|
|
const promptFrontmatter = isNew ? (buildCreationPrefix(kind, filePath, resourceDir) ?? genericPrefix) : genericPrefix;
|
|
/*
|
|
The composer's PRE-FILLED text, which the reader sees and edits — so it is translated. The hidden
|
|
`promptFrontmatter` above is not: that one instructs the agent and carries file paths, and is
|
|
machine-facing rather than something anybody reads.
|
|
*/
|
|
const defaultInput = isNew
|
|
? (description ?? t('capability.createPrompt', { kind: kind.toLowerCase() }))
|
|
: t('capability.improvePrompt', { kind: kind.toLowerCase() });
|
|
|
|
const chat = useChat(undefined, undefined, { replaceUrl: false });
|
|
|
|
const onResponseEndRef = useRef(onResponseEnd);
|
|
onResponseEndRef.current = onResponseEnd;
|
|
|
|
const wasGenerating = useRef(false);
|
|
useEffect(() => {
|
|
if (wasGenerating.current && !chat.isGenerating) {
|
|
onResponseEndRef.current?.();
|
|
}
|
|
wasGenerating.current = chat.isGenerating;
|
|
}, [chat.isGenerating]);
|
|
|
|
return <EmbeddableChat chat={chat} defaultInput={defaultInput} promptPrefix={promptFrontmatter} className="h-full" />;
|
|
};
|
|
|
|
export const FrontmatterBlock = ({ yaml }: { yaml: string }) => {
|
|
const { t } = useTranslation();
|
|
const [open, setOpen] = useState(false);
|
|
|
|
return (
|
|
<div className="mb-4 rounded border border-duck-dark/10 bg-duck-dark/3 text-sm">
|
|
<button
|
|
onClick={() => setOpen((o) => !o)}
|
|
className="flex w-full items-center gap-1.5 px-3 py-1.5 text-duck-dark/50 hover:text-duck-dark/70 cursor-pointer transition-colors"
|
|
>
|
|
<ChevronRight className={`h-3.5 w-3.5 transition-transform ${open ? 'rotate-90' : ''}`} />
|
|
<span className="text-xs font-medium">{t('capability.frontmatter')}</span>
|
|
</button>
|
|
{open && (
|
|
<pre className="px-4 pb-3 text-xs text-duck-dark/60 whitespace-pre-wrap font-mono leading-relaxed">{yaml}</pre>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export const CapabilityList = ({
|
|
kind,
|
|
endpoint,
|
|
queryKey,
|
|
basePath,
|
|
selected,
|
|
onCreate,
|
|
search: externalSearch,
|
|
showCreate,
|
|
onShowCreateChange,
|
|
}: CapabilityListProps) => {
|
|
const { t, tUi } = useTranslation();
|
|
const client = useClient();
|
|
const qc = useQueryClient();
|
|
const [internalCreating, setInternalCreating] = useState(false);
|
|
const creating = showCreate ?? internalCreating;
|
|
const setCreating = onShowCreateChange ?? setInternalCreating;
|
|
const [newName, setNewName] = useState('');
|
|
const [internalSearch, setInternalSearch] = useState('');
|
|
const search = externalSearch ?? internalSearch;
|
|
const newNameRef = useRef<HTMLInputElement | null>(null);
|
|
|
|
const { data: items = [] } = useQuery<CapabilitySummary[]>({
|
|
queryKey: [queryKey],
|
|
queryFn: () => client.get<CapabilitySummary[]>(endpoint),
|
|
});
|
|
|
|
const handleCreate = async (ev?: FormEvent) => {
|
|
ev?.preventDefault();
|
|
const name = newName.trim();
|
|
if (!name) return;
|
|
try {
|
|
const res = await client.post<{ name: string; dirName: string }>(endpoint, { name });
|
|
await qc.invalidateQueries({ queryKey: [queryKey] });
|
|
setCreating(false);
|
|
setNewName('');
|
|
onCreate(res.dirName);
|
|
} catch {
|
|
toast.error(t('capability.createFailed', { kind: kind.toLowerCase() }));
|
|
}
|
|
};
|
|
|
|
const filtered = items.filter(
|
|
(item) =>
|
|
!search ||
|
|
item.name.toLowerCase().includes(search.toLowerCase()) ||
|
|
item.description?.toLowerCase().includes(search.toLowerCase()),
|
|
);
|
|
|
|
return (
|
|
<>
|
|
<div className="shrink-0 px-4 py-2 border-b border-duck-dark/10 bg-background/60 flex items-center justify-between">
|
|
<span className="text-sm font-medium text-duck-dark/70">{kind}s</span>
|
|
{!creating && (
|
|
<button
|
|
onClick={() => {
|
|
setCreating(true);
|
|
setTimeout(() => newNameRef.current?.focus(), 0);
|
|
}}
|
|
className="p-1 rounded hover:bg-duck-dark/10 cursor-pointer transition-colors"
|
|
>
|
|
<Plus className="h-4 w-4 text-duck-dark/50" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
{creating && (
|
|
<form
|
|
onSubmit={handleCreate}
|
|
className="shrink-0 px-3 py-2 border-b border-duck-dark/10 bg-duck-teal/5 flex items-center gap-1.5"
|
|
>
|
|
<input
|
|
ref={newNameRef}
|
|
value={newName}
|
|
onChange={(ev) => setNewName(ev.target.value)}
|
|
onKeyDown={(ev) => {
|
|
if (ev.key === 'Escape') {
|
|
setCreating(false);
|
|
setNewName('');
|
|
}
|
|
}}
|
|
placeholder={`${kind} name...`}
|
|
className="flex-1 min-w-0 rounded border border-duck-dark/20 bg-background px-2 py-1 text-base md:text-sm text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30"
|
|
/>
|
|
<button
|
|
type="submit"
|
|
disabled={!newName.trim()}
|
|
className="p-1 rounded hover:bg-duck-dark/10 cursor-pointer transition-colors disabled:opacity-30"
|
|
>
|
|
<Check className="h-3.5 w-3.5 text-duck-teal" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setCreating(false);
|
|
setNewName('');
|
|
}}
|
|
className="p-1 rounded hover:bg-duck-dark/10 cursor-pointer transition-colors"
|
|
>
|
|
<X className="h-3.5 w-3.5 text-duck-dark/50" />
|
|
</button>
|
|
</form>
|
|
)}
|
|
{externalSearch === undefined && (
|
|
<div className="shrink-0 px-3 py-2 border-b border-duck-dark/10">
|
|
<div className="relative">
|
|
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-duck-dark/30" />
|
|
<input
|
|
value={internalSearch}
|
|
onChange={(ev) => setInternalSearch(ev.target.value)}
|
|
placeholder={t('capability.searchPlaceholder', { kind: `${kind.toLowerCase()}s` })}
|
|
className="w-full rounded border border-duck-dark/15 bg-background/80 pl-7 pr-2 py-1 text-base md:text-sm text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30"
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
<div className="overflow-y-auto flex-1">
|
|
{filtered.map((item) => (
|
|
<Link
|
|
key={item.dirName}
|
|
to={`${basePath}/${encodeURIComponent(item.dirName)}`}
|
|
className={`block w-full text-left px-4 py-3 border-b border-duck-dark/5 cursor-pointer transition-colors ${
|
|
selected === item.dirName ? 'bg-duck-teal/10' : 'hover:bg-duck-dark/5'
|
|
}`}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm font-medium text-duck-dark truncate">{item.name}</span>
|
|
</div>
|
|
{item.description && <p className="text-xs text-duck-dark/50 mt-1 line-clamp-2">{item.description}</p>}
|
|
</Link>
|
|
))}
|
|
{items.length === 0 && (
|
|
<p className="text-sm text-duck-dark/40 px-4 py-6 text-center">No {kind.toLowerCase()}s found</p>
|
|
)}
|
|
{items.length > 0 && filtered.length === 0 && (
|
|
<p className="text-sm text-duck-dark/40 px-4 py-6 text-center">{t('capability.noMatches')}</p>
|
|
)}
|
|
</div>
|
|
</>
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Backs /tasks, /skills and /processes. Which capability is open is `<basePath>/:dirName` — a route pair
|
|
* with no redirect guard, because the bare route (the list with nothing open) is a real state and an
|
|
* unknown dirName gets the empty detail pane rather than a rewritten address.
|
|
*
|
|
* The two per-item modes ride along as search params instead of local state: a row is a <Link> now, so it
|
|
* cannot imperatively reset the panes it changes, and deriving them means navigating to another item drops
|
|
* them for free — no reset effect racing the navigation. `edit` also makes the editor pane linkable.
|
|
*/
|
|
export const CapabilityPage = ({ kind, endpoint, queryKey, basePath }: CapabilityPageProps) => {
|
|
const { t, tUi } = useTranslation();
|
|
const client = useClient();
|
|
const qc = useQueryClient();
|
|
const navigate = useNavigate();
|
|
const selected = useParams<{ dirName: string }>().dirName ?? null;
|
|
const [params, setParams] = useSearchParams();
|
|
const editing = params.get('edit') === '1';
|
|
const isNew = params.get('new') === '1';
|
|
const [deleteConfirm, setDeleteConfirm] = useState(false);
|
|
|
|
const setEditing = (on: boolean) => setParams(on ? { edit: '1' } : {}, { replace: true });
|
|
|
|
const { data: detail } = useQuery<CapabilityDetail>({
|
|
queryKey: [queryKey, selected],
|
|
queryFn: () => client.get<CapabilityDetail>(`${endpoint}/${selected}`),
|
|
enabled: !!selected,
|
|
});
|
|
|
|
// A brand-new capability is empty, so it opens straight into the chat that fills it in.
|
|
const handleCreate = (dirName: string) => navigate(`${basePath}/${encodeURIComponent(dirName)}?edit=1&new=1`);
|
|
|
|
const handleDelete = async () => {
|
|
if (!selected) return;
|
|
try {
|
|
await client.delete(`${endpoint}/${selected}`);
|
|
setDeleteConfirm(false);
|
|
navigate(basePath, { replace: true });
|
|
await qc.invalidateQueries({ queryKey: [queryKey] });
|
|
} catch {
|
|
toast.error(t('capability.deleteFailed', { kind: kind.toLowerCase() }));
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<div className="flex h-full p-2 md:p-4 gap-2 md:gap-4">
|
|
{/* Left panel — list */}
|
|
<Card
|
|
className={`md:w-72 shrink-0 overflow-hidden flex flex-col ${selected ? 'hidden md:flex' : 'flex-1 md:flex-none'}`}
|
|
>
|
|
<CapabilityList
|
|
kind={kind}
|
|
endpoint={endpoint}
|
|
queryKey={queryKey}
|
|
basePath={basePath}
|
|
selected={selected}
|
|
onCreate={handleCreate}
|
|
/>
|
|
</Card>
|
|
|
|
{/* Right panel — detail + chat. On mobile the two panes swap on `selected`; going back is the bare route. */}
|
|
<div className={`flex-1 flex flex-col gap-4 min-h-0 ${selected ? 'flex' : 'hidden md:flex'}`}>
|
|
<Card className={`flex-1 overflow-hidden flex flex-col min-h-0 ${editing ? 'hidden md:flex' : ''}`}>
|
|
<div className="shrink-0 px-4 py-2 border-b border-duck-dark/10 bg-background/60 flex items-center gap-2">
|
|
<Link to={basePath} className="md:hidden p-1 -ml-1 rounded hover:bg-duck-dark/10 cursor-pointer">
|
|
<ArrowLeft className="h-4 w-4 text-duck-dark/60" />
|
|
</Link>
|
|
<span className="text-sm font-medium text-duck-dark/70 flex-1">
|
|
{detail?.name ?? t('capability.selectOne', { kind: kind.toLowerCase() })}
|
|
</span>
|
|
{/*
|
|
A plugin's capability is read where it lives, in a git checkout that `plugins:update`
|
|
pulls. So there is nothing to offer here: an edit would be overwritten by the next update,
|
|
and the delete refuses server-side (409). Say whose it is instead of showing two controls
|
|
that cannot work.
|
|
*/}
|
|
{detail?.plugin ? (
|
|
<span className="text-xs text-duck-dark/40">
|
|
{t('capability.fromPlugin', { plugin: detail.plugin })}
|
|
</span>
|
|
) : detail ? (
|
|
<>
|
|
<button
|
|
onClick={() => setEditing(!editing)}
|
|
className={`p-1 rounded hover:bg-duck-dark/10 cursor-pointer transition-colors ${editing ? 'bg-duck-teal/10' : ''}`}
|
|
>
|
|
<Pencil className={`h-3.5 w-3.5 ${editing ? 'text-duck-teal' : 'text-duck-dark/50'}`} />
|
|
</button>
|
|
<button
|
|
onClick={() => setDeleteConfirm(true)}
|
|
className="p-1 rounded hover:bg-red-50 cursor-pointer transition-colors"
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5 text-duck-dark/50 hover:text-red-500" />
|
|
</button>
|
|
</>
|
|
) : null}
|
|
</div>
|
|
<div className="overflow-y-auto flex-1 p-6">
|
|
{detail?.rawFrontmatter && <FrontmatterBlock yaml={detail.rawFrontmatter} />}
|
|
{detail?.body ? (
|
|
<article className="skill-md">
|
|
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>
|
|
{detail.body}
|
|
</ReactMarkdown>
|
|
</article>
|
|
) : detail ? (
|
|
<p className="text-sm text-duck-dark/40 text-center mt-12">{t('capability.emptyFile')}</p>
|
|
) : (
|
|
<p className="text-sm text-duck-dark/40 text-center mt-12">
|
|
{t('capability.selectToView', { kind: kind.toLowerCase() })}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</Card>
|
|
|
|
{editing && detail?.filePath && selected && (
|
|
<Card className="flex-1 overflow-hidden flex flex-col min-h-0">
|
|
<div className="shrink-0 px-4 py-1.5 border-b border-duck-dark/10 bg-background/60 flex items-center gap-2">
|
|
<button
|
|
onClick={() => setEditing(false)}
|
|
className="md:hidden p-1 -ml-1 rounded hover:bg-duck-dark/10 cursor-pointer"
|
|
>
|
|
<ArrowLeft className="h-4 w-4 text-duck-dark/60" />
|
|
</button>
|
|
<span className="text-xs font-medium text-duck-dark/50 flex-1">{detail?.name ?? 'Chat'}</span>
|
|
<button
|
|
onClick={() => setEditing(false)}
|
|
className="p-1 rounded hover:bg-duck-dark/10 cursor-pointer transition-colors"
|
|
>
|
|
<X className="h-3.5 w-3.5 text-duck-dark/50" />
|
|
</button>
|
|
</div>
|
|
<CapabilityChat
|
|
kind={kind.toLowerCase()}
|
|
key={detail.filePath}
|
|
endpoint={endpoint}
|
|
dirName={selected}
|
|
filePath={detail.filePath}
|
|
resourceDir={detail.filePath.replace(/\/[^/]+$/, '')}
|
|
chatSessionId={detail.chatSessionId}
|
|
isNew={isNew}
|
|
onResponseEnd={() => qc.invalidateQueries({ queryKey: [queryKey, selected] })}
|
|
/>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<Dialog open={deleteConfirm} onOpenChange={setDeleteConfirm}>
|
|
<DialogContent className="sm:max-w-md z-[700]">
|
|
<DialogHeader>
|
|
<DialogTitle>Delete {kind}</DialogTitle>
|
|
<DialogDescription>
|
|
Are you sure you want to delete "{detail?.name}"? This action cannot be undone.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="flex justify-end gap-2 mt-2">
|
|
<Button variant="outline" onClick={() => setDeleteConfirm(false)} className="cursor-pointer">
|
|
{tUi('button.cancel')}
|
|
</Button>
|
|
<Button variant="destructive" onClick={handleDelete} className="cursor-pointer">
|
|
{t('capability.delete')}
|
|
</Button>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
);
|
|
};
|