mirror of
https://github.com/different-ai/openwork
synced 2026-04-25 17:15:34 +02:00
fix: list skills across skill directories (#234)
* fix: list skills across skill directories * feat: move context panel to right rail * fix: align skill paths with opencode docs * fix: show full-screen boot loader during connect
This commit is contained in:
@@ -31,7 +31,7 @@ OpenWork is an open-source alternative to Claude Cowork.
|
||||
|
||||
## Repository Guidance
|
||||
|
||||
- Write new PRDs under `packages/app/pr/<prd-name>.md` (see `.opencode/skill/prd-conventions/SKILL.md`).
|
||||
- Write new PRDs under `packages/app/pr/<prd-name>.md` (see `.opencode/skills/prd-conventions/SKILL.md`).
|
||||
- Use MOTIVATIONS-PHILOSOPHY.md to understand the "why" of OpenWork so you can guide your decisions.
|
||||
|
||||
|
||||
@@ -90,7 +90,7 @@ Key primitives to expose:
|
||||
|
||||
When editing SolidJS UI (`packages/app/src/**/*.tsx`), consult:
|
||||
|
||||
- `.opencode/skill/solidjs-patterns/SKILL.md`
|
||||
- `.opencode/skills/solidjs-patterns/SKILL.md`
|
||||
|
||||
This captures OpenWork’s preferred reactivity + UI state patterns (avoid global `busy()` deadlocks; use scoped async state).
|
||||
|
||||
|
||||
@@ -228,7 +228,7 @@ OpenWork’s settings pages use:
|
||||
OpenWork exposes two extension surfaces:
|
||||
|
||||
1. **Skills (OpenPackage)**
|
||||
- Installed into `.opencode/skill/*`.
|
||||
- Installed into `.opencode/skills/*`.
|
||||
- OpenWork can run `opkg install` to pull packages from the registry or GitHub.
|
||||
|
||||
2. **Plugins (OpenCode)**
|
||||
|
||||
@@ -51,9 +51,9 @@ OpenWork is designed to be:
|
||||
- **Permissions**: surface permission requests and reply (allow once / always / deny).
|
||||
- **Templates**: save and re-run common workflows (stored locally).
|
||||
- **Skills manager**:
|
||||
- list installed `.opencode/skill` folders
|
||||
- list installed `.opencode/skills` folders
|
||||
- install from OpenPackage (`opkg install ...`)
|
||||
- import a local skill folder into `.opencode/skill/<skill-name>`
|
||||
- import a local skill folder into `.opencode/skills/<skill-name>`
|
||||
|
||||
|
||||
## Skill Manager
|
||||
@@ -177,7 +177,7 @@ WEBKIT_DISABLE_COMPOSITING_MODE=1 openwork
|
||||
- Review `AGENTS.md` and `MOTIVATIONS-PHILOSOPHY.md` to understand the product goals before making changes.
|
||||
- Ensure Node.js, `pnpm`, the Rust toolchain, and `opencode` are installed before working inside the repo.
|
||||
- Run `pnpm install` once per checkout, then verify your change with `pnpm typecheck` plus `pnpm test:e2e` (or the targeted subset of scripts) before opening a PR.
|
||||
- Add new PRDs to `packages/app/pr/<name>.md` following the `.opencode/skill/prd-conventions/SKILL.md` conventions described in `AGENTS.md`.
|
||||
- Add new PRDs to `packages/app/pr/<name>.md` following the `.opencode/skills/prd-conventions/SKILL.md` conventions described in `AGENTS.md`.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -153,6 +153,7 @@ export default function App() {
|
||||
const [busyLabel, setBusyLabel] = createSignal<string | null>(null);
|
||||
const [busyStartedAt, setBusyStartedAt] = createSignal<number | null>(null);
|
||||
const [error, setError] = createSignal<string | null>(null);
|
||||
const [booting, setBooting] = createSignal(true);
|
||||
const [developerMode, setDeveloperMode] = createSignal(false);
|
||||
let markReloadRequiredRef: (reason: ReloadReason) => void = () => {};
|
||||
|
||||
@@ -1632,7 +1633,7 @@ export default function App() {
|
||||
}
|
||||
}
|
||||
|
||||
void workspaceStore.bootstrapOnboarding();
|
||||
void workspaceStore.bootstrapOnboarding().finally(() => setBooting(false));
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
@@ -1936,6 +1937,7 @@ export default function App() {
|
||||
});
|
||||
|
||||
const workspaceSwitchOpen = createMemo(() => {
|
||||
if (booting()) return true;
|
||||
if (workspaceStore.connectingWorkspaceId()) return true;
|
||||
if (!busy() || !busyLabel()) return false;
|
||||
const label = busyLabel();
|
||||
@@ -1954,6 +1956,7 @@ export default function App() {
|
||||
}
|
||||
if (label === "status.loading_session") return "workspace.switching_status_loading";
|
||||
if (workspaceStore.connectingWorkspaceId()) return "workspace.switching_status_loading";
|
||||
if (booting()) return "workspace.switching_status_preparing";
|
||||
return "workspace.switching_status_preparing";
|
||||
});
|
||||
|
||||
|
||||
115
packages/app/src/app/components/session/context-panel.tsx
Normal file
115
packages/app/src/app/components/session/context-panel.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import { For, Show } from "solid-js";
|
||||
import { ChevronDown, Circle, File, Folder } from "lucide-solid";
|
||||
|
||||
export type ContextPanelProps = {
|
||||
activePlugins: string[];
|
||||
activePluginStatus: string | null;
|
||||
authorizedDirs: string[];
|
||||
workingFiles: string[];
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
};
|
||||
|
||||
const humanizePlugin = (name: string) => {
|
||||
const cleaned = name
|
||||
.replace(/^@[^/]+\//, "")
|
||||
.replace(/[-_]+/g, " ")
|
||||
.replace(/\b(opencode|plugin)\b/gi, "")
|
||||
.trim();
|
||||
return cleaned
|
||||
.split(" ")
|
||||
.filter(Boolean)
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(" ")
|
||||
.trim();
|
||||
};
|
||||
|
||||
export default function ContextPanel(props: ContextPanelProps) {
|
||||
return (
|
||||
<div class="flex flex-col h-full overflow-hidden">
|
||||
<div class="flex-1 overflow-y-auto px-4 py-4">
|
||||
<div class="rounded-2xl border border-gray-6 bg-gray-2/30" id="sidebar-context">
|
||||
<button
|
||||
class="w-full px-4 py-3 flex items-center justify-between text-sm text-gray-12 font-medium"
|
||||
onClick={props.onToggle}
|
||||
>
|
||||
<span>Context</span>
|
||||
<ChevronDown
|
||||
size={16}
|
||||
class={`transition-transform text-gray-10 ${props.expanded ? "rotate-180" : ""}`.trim()}
|
||||
/>
|
||||
</button>
|
||||
<Show when={props.expanded}>
|
||||
<div class="px-4 pb-4 pt-1 space-y-5">
|
||||
<Show when={props.activePlugins.length || props.activePluginStatus}>
|
||||
<div>
|
||||
<div class="flex items-center justify-between text-[11px] uppercase tracking-wider text-gray-9 font-semibold mb-2">
|
||||
<span>Active plugins</span>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Show
|
||||
when={props.activePlugins.length}
|
||||
fallback={
|
||||
<div class="text-xs text-gray-9">
|
||||
{props.activePluginStatus ?? "No plugins loaded."}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<For each={props.activePlugins}>
|
||||
{(plugin) => (
|
||||
<div class="flex items-center gap-2 text-xs text-gray-11">
|
||||
<Circle size={6} class="text-green-9 fill-green-9" />
|
||||
<span class="truncate">{humanizePlugin(plugin) || plugin}</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div>
|
||||
<div class="flex items-center justify-between text-[11px] uppercase tracking-wider text-gray-9 font-semibold mb-2">
|
||||
<span>Authorized folders</span>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<For each={props.authorizedDirs.slice(0, 3)}>
|
||||
{(folder) => (
|
||||
<div class="flex items-center gap-2 text-xs text-gray-11">
|
||||
<Folder size={12} class="text-gray-9" />
|
||||
<span class="truncate" title={folder}>
|
||||
{folder.split(/[/\\]/).pop()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex items-center justify-between text-[11px] uppercase tracking-wider text-gray-9 font-semibold mb-2">
|
||||
<span>Working files</span>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Show
|
||||
when={props.workingFiles.length}
|
||||
fallback={<div class="text-xs text-gray-9">None yet.</div>}
|
||||
>
|
||||
<For each={props.workingFiles}>
|
||||
{(file) => (
|
||||
<div class="flex items-center gap-2 text-xs text-gray-11">
|
||||
<File size={12} class="text-gray-9" />
|
||||
<span class="truncate">{file}</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +1,18 @@
|
||||
import { For, Show, createMemo, createSignal, onCleanup } from "solid-js";
|
||||
import type { JSX } from "solid-js";
|
||||
import type { Part } from "@opencode-ai/sdk/v2/client";
|
||||
import { Check, ChevronDown, Circle, Copy, File, FileText } from "lucide-solid";
|
||||
import { Check, ChevronDown, Circle, Copy, File } from "lucide-solid";
|
||||
|
||||
import type { ArtifactItem, MessageGroup, MessageWithParts } from "../../types";
|
||||
import type { MessageGroup, MessageWithParts } from "../../types";
|
||||
import { groupMessageParts, summarizeStep } from "../../utils";
|
||||
import Button from "../button";
|
||||
import PartView from "../part-view";
|
||||
|
||||
export type MessageListProps = {
|
||||
messages: MessageWithParts[];
|
||||
artifacts: ArtifactItem[];
|
||||
developerMode: boolean;
|
||||
showThinking: boolean;
|
||||
expandedStepIds: Set<string>;
|
||||
setExpandedStepIds: (updater: (current: Set<string>) => Set<string>) => void;
|
||||
onOpenArtifact: (artifact: ArtifactItem) => void;
|
||||
footer?: JSX.Element;
|
||||
};
|
||||
|
||||
@@ -26,7 +23,6 @@ type StepClusterBlock = {
|
||||
partsGroups: Part[][];
|
||||
messageIds: string[];
|
||||
isUser: boolean;
|
||||
artifacts: ArtifactItem[];
|
||||
};
|
||||
|
||||
type MessageBlock = {
|
||||
@@ -36,7 +32,6 @@ type MessageBlock = {
|
||||
groups: MessageGroup[];
|
||||
isUser: boolean;
|
||||
messageId: string;
|
||||
artifacts: ArtifactItem[];
|
||||
};
|
||||
|
||||
type MessageBlockItem = MessageBlock | StepClusterBlock;
|
||||
@@ -103,24 +98,8 @@ export default function MessageList(props: MessageListProps) {
|
||||
return props.developerMode;
|
||||
});
|
||||
|
||||
const artifactsByMessage = createMemo(() => {
|
||||
const map = new Map<string, ArtifactItem[]>();
|
||||
for (const artifact of props.artifacts) {
|
||||
const key = artifact.messageId?.trim();
|
||||
if (!key) continue;
|
||||
const current = map.get(key);
|
||||
if (current) {
|
||||
current.push(artifact);
|
||||
} else {
|
||||
map.set(key, [artifact]);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
const messageBlocks = createMemo<MessageBlockItem[]>(() => {
|
||||
const blocks: MessageBlockItem[] = [];
|
||||
const artifactMap = artifactsByMessage();
|
||||
|
||||
for (const message of props.messages) {
|
||||
const renderableParts = renderablePartsForMessage(message);
|
||||
@@ -130,7 +109,6 @@ export default function MessageList(props: MessageListProps) {
|
||||
const groupId = String((message.info as any).id ?? "message");
|
||||
const groups = groupMessageParts(renderableParts, groupId);
|
||||
const isUser = (message.info as any).role === "user";
|
||||
const messageArtifacts = artifactMap.get(messageId) ?? [];
|
||||
const isStepsOnly = groups.length === 1 && groups[0].kind === "steps";
|
||||
|
||||
if (isStepsOnly) {
|
||||
@@ -140,9 +118,6 @@ export default function MessageList(props: MessageListProps) {
|
||||
lastBlock.partsGroups.push(stepGroup.parts);
|
||||
lastBlock.stepIds.push(stepGroup.id);
|
||||
lastBlock.messageIds.push(messageId);
|
||||
if (messageArtifacts.length) {
|
||||
lastBlock.artifacts.push(...messageArtifacts);
|
||||
}
|
||||
} else {
|
||||
blocks.push({
|
||||
kind: "steps-cluster",
|
||||
@@ -151,7 +126,6 @@ export default function MessageList(props: MessageListProps) {
|
||||
partsGroups: [stepGroup.parts],
|
||||
messageIds: [messageId],
|
||||
isUser,
|
||||
artifacts: [...messageArtifacts],
|
||||
});
|
||||
}
|
||||
continue;
|
||||
@@ -164,7 +138,6 @@ export default function MessageList(props: MessageListProps) {
|
||||
groups,
|
||||
isUser,
|
||||
messageId,
|
||||
artifacts: messageArtifacts,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -261,36 +234,6 @@ export default function MessageList(props: MessageListProps) {
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={block.artifacts.length}>
|
||||
<div class={`mt-4 space-y-2 ${block.isUser ? "text-gray-12" : ""}`.trim()}>
|
||||
<div class="text-[11px] uppercase tracking-wide text-gray-9">Artifacts</div>
|
||||
<For each={block.artifacts}>
|
||||
{(artifact) => (
|
||||
<div
|
||||
class="rounded-2xl border border-gray-6 bg-gray-1/60 px-4 py-3 flex items-center justify-between"
|
||||
data-artifact-id={artifact.id}
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="h-9 w-9 rounded-lg bg-gray-2 flex items-center justify-center">
|
||||
<FileText size={16} class="text-gray-10" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm text-gray-12">{artifact.name}</div>
|
||||
<div class="text-xs text-gray-10">Document</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="text-xs"
|
||||
onClick={() => props.onOpenArtifact(artifact)}
|
||||
>
|
||||
Open
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -322,8 +265,8 @@ export default function MessageList(props: MessageListProps) {
|
||||
renderMarkdown={!block.isUser}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={group.kind === "steps"}>
|
||||
{() => {
|
||||
{group.kind === "steps" &&
|
||||
(() => {
|
||||
const stepGroup = group as { kind: "steps"; id: string; parts: Part[] };
|
||||
const expanded = () => isStepsExpanded(stepGroup.id);
|
||||
return (
|
||||
@@ -355,42 +298,10 @@ export default function MessageList(props: MessageListProps) {
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</Show>
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
<Show when={block.artifacts.length}>
|
||||
<div class={`mt-4 space-y-2 ${block.isUser ? "text-gray-12" : ""}`.trim()}>
|
||||
<div class="text-[11px] uppercase tracking-wide text-gray-9">Artifacts</div>
|
||||
<For each={block.artifacts}>
|
||||
{(artifact) => (
|
||||
<div
|
||||
class="rounded-2xl border border-gray-6 bg-gray-1/60 px-4 py-3 flex items-center justify-between"
|
||||
data-artifact-id={artifact.id}
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="h-9 w-9 rounded-lg bg-gray-2 flex items-center justify-center">
|
||||
<FileText size={16} class="text-gray-10" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm text-gray-12">{artifact.name}</div>
|
||||
<div class="text-xs text-gray-10">Document</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="text-xs"
|
||||
onClick={() => props.onOpenArtifact(artifact)}
|
||||
>
|
||||
Open
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="mt-2 flex justify-end opacity-0 group-hover:opacity-100 transition-opacity select-none">
|
||||
<button
|
||||
class="text-gray-9 hover:text-gray-11 p-1 rounded hover:bg-black/5 dark:hover:bg-white/10 transition-colors"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { For, Show, createMemo } from "solid-js";
|
||||
import { Check, ChevronDown, Circle, File, FileText, Folder, Plus } from "lucide-solid";
|
||||
import { Check, ChevronDown, Plus } from "lucide-solid";
|
||||
|
||||
import type { ArtifactItem, TodoItem } from "../../types";
|
||||
import type { TodoItem } from "../../types";
|
||||
|
||||
export type SidebarSectionState = {
|
||||
progress: boolean;
|
||||
@@ -11,14 +11,8 @@ export type SidebarSectionState = {
|
||||
|
||||
export type SidebarProps = {
|
||||
todos: TodoItem[];
|
||||
artifacts: ArtifactItem[];
|
||||
activePlugins: string[];
|
||||
activePluginStatus: string | null;
|
||||
authorizedDirs: string[];
|
||||
workingFiles: string[];
|
||||
expandedSections: SidebarSectionState;
|
||||
onToggleSection: (section: keyof SidebarSectionState) => void;
|
||||
onOpenArtifact: (artifact: ArtifactItem) => void;
|
||||
sessions: Array<{ id: string; title: string; slug?: string | null }>;
|
||||
selectedSessionId: string | null;
|
||||
onSelectSession: (id: string) => void;
|
||||
@@ -27,20 +21,6 @@ export type SidebarProps = {
|
||||
newTaskDisabled: boolean;
|
||||
};
|
||||
|
||||
const humanizePlugin = (name: string) => {
|
||||
const cleaned = name
|
||||
.replace(/^@[^/]+\//, "")
|
||||
.replace(/[-_]+/g, " ")
|
||||
.replace(/\b(opencode|plugin)\b/gi, "")
|
||||
.trim();
|
||||
return cleaned
|
||||
.split(" ")
|
||||
.filter(Boolean)
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(" ")
|
||||
.trim();
|
||||
};
|
||||
|
||||
export default function SessionSidebar(props: SidebarProps) {
|
||||
const realTodos = createMemo(() => props.todos.filter((todo) => todo.content.trim()));
|
||||
|
||||
@@ -112,7 +92,7 @@ export default function SessionSidebar(props: SidebarProps) {
|
||||
|
||||
<div class="space-y-4">
|
||||
<Show when={realTodos().length > 0}>
|
||||
<div class="rounded-2xl border border-gray-6 bg-gray-2/30">
|
||||
<div class="rounded-2xl border border-gray-6 bg-gray-2/30" id="sidebar-progress">
|
||||
<button
|
||||
class="w-full px-4 py-3 flex items-center justify-between text-sm text-gray-12 font-medium"
|
||||
onClick={() => props.onToggleSection("progress")}
|
||||
@@ -148,134 +128,6 @@ export default function SessionSidebar(props: SidebarProps) {
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="rounded-2xl border border-gray-6 bg-gray-2/30">
|
||||
<button
|
||||
class="w-full px-4 py-3 flex items-center justify-between text-sm text-gray-12 font-medium"
|
||||
onClick={() => props.onToggleSection("artifacts")}
|
||||
>
|
||||
<span>Artifacts</span>
|
||||
<ChevronDown
|
||||
size={16}
|
||||
class={`transition-transform text-gray-10 ${
|
||||
props.expandedSections.artifacts ? "rotate-180" : ""
|
||||
}`.trim()}
|
||||
/>
|
||||
</button>
|
||||
<Show when={props.expandedSections.artifacts}>
|
||||
<div class="px-4 pb-4 pt-1 space-y-3">
|
||||
<Show
|
||||
when={props.artifacts.length}
|
||||
fallback={<div class="text-xs text-gray-9 pl-1">No artifacts yet.</div>}
|
||||
>
|
||||
<For each={props.artifacts}>
|
||||
{(artifact) => (
|
||||
<button
|
||||
class="flex items-center gap-3 text-sm text-gray-11 hover:text-gray-12 w-full text-left group"
|
||||
onClick={() => props.onOpenArtifact(artifact)}
|
||||
>
|
||||
<div class="h-8 w-8 rounded-lg bg-gray-3 group-hover:bg-gray-4 flex items-center justify-center transition-colors shrink-0">
|
||||
<FileText size={16} class="text-gray-10 group-hover:text-gray-11" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="truncate">{artifact.name}</div>
|
||||
<Show when={artifact.path}>
|
||||
<div class="truncate text-[7px] text-gray-5" title={artifact.path}>
|
||||
{artifact.path}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-gray-6 bg-gray-2/30">
|
||||
<button
|
||||
class="w-full px-4 py-3 flex items-center justify-between text-sm text-gray-12 font-medium"
|
||||
onClick={() => props.onToggleSection("context")}
|
||||
>
|
||||
<span>Context</span>
|
||||
<ChevronDown
|
||||
size={16}
|
||||
class={`transition-transform text-gray-10 ${
|
||||
props.expandedSections.context ? "rotate-180" : ""
|
||||
}`.trim()}
|
||||
/>
|
||||
</button>
|
||||
<Show when={props.expandedSections.context}>
|
||||
<div class="px-4 pb-4 pt-1 space-y-5">
|
||||
<Show when={props.activePlugins.length || props.activePluginStatus}>
|
||||
<div>
|
||||
<div class="flex items-center justify-between text-[11px] uppercase tracking-wider text-gray-9 font-semibold mb-2">
|
||||
<span>Active plugins</span>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Show
|
||||
when={props.activePlugins.length}
|
||||
fallback={
|
||||
<div class="text-xs text-gray-9">
|
||||
{props.activePluginStatus ?? "No plugins loaded."}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<For each={props.activePlugins}>
|
||||
{(plugin) => (
|
||||
<div class="flex items-center gap-2 text-xs text-gray-11">
|
||||
<Circle size={6} class="text-green-9 fill-green-9" />
|
||||
<span class="truncate">{humanizePlugin(plugin) || plugin}</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div>
|
||||
<div class="flex items-center justify-between text-[11px] uppercase tracking-wider text-gray-9 font-semibold mb-2">
|
||||
<span>Authorized folders</span>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<For each={props.authorizedDirs.slice(0, 3)}>
|
||||
{(folder) => (
|
||||
<div class="flex items-center gap-2 text-xs text-gray-11">
|
||||
<Folder size={12} class="text-gray-9" />
|
||||
<span class="truncate" title={folder}>
|
||||
{folder.split(/[/\\]/).pop()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex items-center justify-between text-[11px] uppercase tracking-wider text-gray-9 font-semibold mb-2">
|
||||
<span>Working files</span>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Show
|
||||
when={props.workingFiles.length}
|
||||
fallback={<div class="text-xs text-gray-9">None yet.</div>}
|
||||
>
|
||||
<For each={props.workingFiles}>
|
||||
{(file) => (
|
||||
<div class="flex items-center gap-2 text-xs text-gray-11">
|
||||
<File size={12} class="text-gray-9" />
|
||||
<span class="truncate">{file}</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Show, createMemo } from "solid-js";
|
||||
import { Dynamic } from "solid-js/web";
|
||||
|
||||
import { Folder, Globe, Loader2, Zap } from "lucide-solid";
|
||||
import { Folder, Globe, Zap } from "lucide-solid";
|
||||
import { t, currentLocale } from "../../i18n";
|
||||
|
||||
import type { WorkspaceInfo } from "../lib/tauri";
|
||||
@@ -64,37 +64,83 @@ export default function WorkspaceSwitchOverlay(props: {
|
||||
|
||||
return (
|
||||
<Show when={props.open}>
|
||||
<div class="fixed inset-0 z-[60] flex items-center justify-center bg-gray-1/60 backdrop-blur-sm p-6 motion-safe:animate-in motion-safe:fade-in motion-safe:zoom-in-95 motion-safe:duration-200">
|
||||
<div class="w-full max-w-md rounded-2xl bg-gray-2 border border-gray-6 shadow-2xl p-6">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="w-12 h-12 rounded-2xl bg-gray-3 flex items-center justify-center text-gray-12">
|
||||
<Dynamic component={Icon()} size={22} />
|
||||
<div class="fixed inset-0 z-[60] overflow-hidden bg-gray-1 text-gray-12 motion-safe:animate-in motion-safe:fade-in motion-safe:duration-300">
|
||||
<div class="absolute inset-0">
|
||||
<div class="absolute inset-0 bg-[radial-gradient(circle_at_top,_var(--tw-gradient-stops))] from-gray-2 via-gray-1 to-gray-1 opacity-80" />
|
||||
<div
|
||||
class="absolute -top-24 right-[-4rem] h-72 w-72 rounded-full bg-indigo-7/20 blur-3xl motion-safe:animate-pulse motion-reduce:opacity-40"
|
||||
style={{ "animation-duration": "6s" }}
|
||||
/>
|
||||
<div
|
||||
class="absolute -bottom-28 left-[-5rem] h-80 w-80 rounded-full bg-indigo-6/15 blur-3xl motion-safe:animate-pulse motion-reduce:opacity-40"
|
||||
style={{ "animation-duration": "8s" }}
|
||||
/>
|
||||
<div class="absolute inset-x-0 bottom-0 h-48 bg-gradient-to-t from-gray-1 via-gray-1/40 to-transparent" />
|
||||
</div>
|
||||
|
||||
<div class="relative z-10 flex min-h-screen flex-col items-center justify-center px-6 py-10 text-center">
|
||||
<div class="flex flex-col items-center gap-8">
|
||||
<div class="flex items-center gap-3 text-[10px] uppercase tracking-[0.35em] text-gray-8">
|
||||
<span class="h-px w-10 bg-gray-6/60" />
|
||||
<span>OpenWork</span>
|
||||
<span class="h-px w-10 bg-gray-6/60" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0 space-y-3">
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="text-lg font-medium text-gray-12 truncate">{title()}</h3>
|
||||
<Show when={props.workspace?.workspaceType === "remote"}>
|
||||
<span class="text-[9px] uppercase tracking-wide px-1.5 py-0.5 rounded-full bg-gray-4 text-gray-11">
|
||||
{translate("dashboard.remote")}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
<p class="text-sm text-gray-11">{subtitle()}</p>
|
||||
|
||||
<div class="relative">
|
||||
<div
|
||||
class="absolute inset-0 rounded-full bg-indigo-6/20 blur-2xl motion-safe:animate-pulse motion-reduce:opacity-50"
|
||||
style={{ "animation-duration": "5s" }}
|
||||
/>
|
||||
<div
|
||||
class="absolute -inset-4 rounded-full border border-gray-6/40 motion-safe:animate-spin motion-reduce:opacity-60"
|
||||
style={{ "animation-duration": "14s" }}
|
||||
/>
|
||||
<div
|
||||
class="absolute -inset-1 rounded-full border border-gray-6/30 motion-safe:animate-spin motion-reduce:opacity-60"
|
||||
style={{ "animation-duration": "9s", "animation-direction": "reverse" }}
|
||||
/>
|
||||
<div class="relative h-24 w-24 rounded-3xl bg-gray-2/80 border border-gray-6/70 shadow-2xl flex items-center justify-center text-gray-12">
|
||||
<Dynamic component={Icon()} size={26} />
|
||||
</div>
|
||||
<div class="min-h-[1rem] flex items-center gap-2 text-xs text-gray-10">
|
||||
<Loader2
|
||||
size={14}
|
||||
class="text-gray-10 motion-safe:animate-spin motion-reduce:opacity-60"
|
||||
style={{ "animation-duration": "1.6s" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<h2 class="text-2xl font-semibold tracking-tight">{title()}</h2>
|
||||
<Show when={props.workspace?.workspaceType === "remote"}>
|
||||
<span class="text-[9px] uppercase tracking-wide px-1.5 py-0.5 rounded-full bg-gray-4 text-gray-11">
|
||||
{translate("dashboard.remote")}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
<p class="text-sm text-gray-10">{subtitle()}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<div class="flex items-center gap-2 text-sm text-gray-11">
|
||||
<span class="relative flex h-2.5 w-2.5">
|
||||
<span
|
||||
class="absolute inline-flex h-full w-full rounded-full bg-indigo-7/40 motion-safe:animate-ping motion-reduce:opacity-40"
|
||||
style={{ "animation-duration": "2.6s" }}
|
||||
/>
|
||||
<span class="relative inline-flex h-2.5 w-2.5 rounded-full bg-indigo-7/70" />
|
||||
</span>
|
||||
<span>{statusLine()}</span>
|
||||
</div>
|
||||
<div class="h-1 w-56 overflow-hidden rounded-full bg-gray-4/50">
|
||||
<div
|
||||
class="h-full w-1/2 rounded-full bg-gradient-to-r from-transparent via-indigo-6/50 to-transparent motion-safe:animate-pulse motion-reduce:opacity-70"
|
||||
style={{ "animation-duration": "2.8s" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1 text-[11px] text-gray-8 font-mono">
|
||||
<Show when={metaPrimary()}>
|
||||
<div class="text-[11px] text-gray-9 font-mono truncate">{metaPrimary()}</div>
|
||||
<div class="truncate max-w-[22rem]">{metaPrimary()}</div>
|
||||
</Show>
|
||||
<Show when={metaSecondary()}>
|
||||
<div class="text-[11px] text-gray-8 font-mono truncate">{metaSecondary()}</div>
|
||||
<div class="truncate max-w-[22rem] text-gray-7">{metaSecondary()}</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -79,8 +79,8 @@ export function createExtensionsStore(options: {
|
||||
return;
|
||||
}
|
||||
|
||||
// Host/Tauri mode: read directly from `.opencode/skill(s)` so the UI still works
|
||||
// even if the OpenCode engine is stopped or unreachable.
|
||||
// Host/Tauri mode: read directly from `.opencode/skills` or `.claude/skills`
|
||||
// so the UI still works even if the OpenCode engine is stopped or unreachable.
|
||||
if (options.mode() === "host" && isTauriRuntime()) {
|
||||
if (root !== skillsRoot) {
|
||||
skillsLoaded = false;
|
||||
@@ -432,8 +432,9 @@ export function createExtensionsStore(options: {
|
||||
|
||||
try {
|
||||
const { openPath, revealItemInDir } = await import("@tauri-apps/plugin-opener");
|
||||
const plural = await join(root, ".opencode", "skills");
|
||||
const singular = await join(root, ".opencode", "skill");
|
||||
const opencodeSkills = await join(root, ".opencode", "skills");
|
||||
const claudeSkills = await join(root, ".claude", "skills");
|
||||
const legacySkills = await join(root, ".opencode", "skill");
|
||||
|
||||
const tryOpen = async (target: string) => {
|
||||
try {
|
||||
@@ -445,9 +446,10 @@ export function createExtensionsStore(options: {
|
||||
};
|
||||
|
||||
// Prefer opening the folder. `revealItemInDir` expects a file path on macOS.
|
||||
if (await tryOpen(plural)) return;
|
||||
if (await tryOpen(singular)) return;
|
||||
await revealItemInDir(singular);
|
||||
if (await tryOpen(opencodeSkills)) return;
|
||||
if (await tryOpen(claudeSkills)) return;
|
||||
if (await tryOpen(legacySkills)) return;
|
||||
await revealItemInDir(opencodeSkills);
|
||||
} catch (e) {
|
||||
setSkillsStatus(e instanceof Error ? e.message : translate("skills.reveal_failed"));
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ This skill is a template + checklist for creating skills in a workspace.
|
||||
|
||||
## What is a skill?
|
||||
|
||||
A skill is a folder under `.opencode/skill/<skill-name>/` (or `.opencode/skills/<skill-name>/`) anchored by `SKILL.md`.
|
||||
A skill is a folder under `.opencode/skills/<skill-name>/` or `.claude/skills/<skill-name>/` anchored by `SKILL.md`.
|
||||
|
||||
## Design goals
|
||||
|
||||
@@ -22,7 +22,7 @@ A skill is a folder under `.opencode/skill/<skill-name>/` (or `.opencode/skills/
|
||||
|
||||
```
|
||||
.opencode/
|
||||
skill/
|
||||
skills/
|
||||
my-skill/
|
||||
SKILL.md
|
||||
README.md
|
||||
|
||||
@@ -24,12 +24,11 @@ import Button from "../components/button";
|
||||
import RenameSessionModal from "../components/rename-session-modal";
|
||||
import WorkspaceChip from "../components/workspace-chip";
|
||||
import ProviderAuthModal from "../components/provider-auth-modal";
|
||||
import { isTauriRuntime, isWindowsPlatform } from "../utils";
|
||||
|
||||
import MessageList from "../components/session/message-list";
|
||||
import Composer, { type CommandItem } from "../components/session/composer";
|
||||
import Composer from "../components/session/composer";
|
||||
import SessionSidebar, { type SidebarSectionState } from "../components/session/sidebar";
|
||||
import Minimap from "../components/session/minimap";
|
||||
import ContextPanel from "../components/session/context-panel";
|
||||
import FlyoutItem from "../components/flyout-item";
|
||||
|
||||
export type SessionViewProps = {
|
||||
@@ -100,7 +99,6 @@ export default function SessionView(props: SessionViewProps) {
|
||||
let messagesEndEl: HTMLDivElement | undefined;
|
||||
let chatContainerEl: HTMLDivElement | undefined;
|
||||
|
||||
const [artifactToast, setArtifactToast] = createSignal<string | null>(null);
|
||||
const [commandToast, setCommandToast] = createSignal<string | null>(null);
|
||||
const [providerAuthActionBusy, setProviderAuthActionBusy] = createSignal(false);
|
||||
const [renameModalOpen, setRenameModalOpen] = createSignal(false);
|
||||
@@ -116,7 +114,6 @@ export default function SessionView(props: SessionViewProps) {
|
||||
};
|
||||
const [flyouts, setFlyouts] = createSignal<Flyout[]>([]);
|
||||
const [prevTodoCount, setPrevTodoCount] = createSignal(0);
|
||||
const [prevArtifactCount, setPrevArtifactCount] = createSignal(0);
|
||||
const [prevFileCount, setPrevFileCount] = createSignal(0);
|
||||
const [isInitialLoad, setIsInitialLoad] = createSignal(true);
|
||||
const [runStartedAt, setRunStartedAt] = createSignal<number | null>(null);
|
||||
@@ -127,8 +124,6 @@ export default function SessionView(props: SessionViewProps) {
|
||||
partCount: 0,
|
||||
});
|
||||
const [thinkingExpanded, setThinkingExpanded] = createSignal(false);
|
||||
|
||||
const pendingArtifactRafIds = new Set<number>();
|
||||
|
||||
const lastAssistantSnapshot = createMemo(() => {
|
||||
for (let i = props.messages.length - 1; i >= 0; i -= 1) {
|
||||
@@ -405,31 +400,6 @@ export default function SessionView(props: SessionViewProps) {
|
||||
setPrevTodoCount(count);
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
const artifacts = props.artifacts;
|
||||
const count = artifacts.length;
|
||||
const prev = prevArtifactCount();
|
||||
if (count > prev && prev > 0) {
|
||||
const last = artifacts[artifacts.length - 1];
|
||||
const scheduleAttempt = (attempts: number) => {
|
||||
const rafId = requestAnimationFrame(() => {
|
||||
pendingArtifactRafIds.delete(rafId);
|
||||
const card = document.querySelector(`[data-artifact-id="${last.id}"]`);
|
||||
if (card) {
|
||||
triggerFlyout(card, "sidebar-artifacts", last.name, "file");
|
||||
return;
|
||||
}
|
||||
if (attempts > 0) {
|
||||
scheduleAttempt(attempts - 1);
|
||||
}
|
||||
});
|
||||
pendingArtifactRafIds.add(rafId);
|
||||
};
|
||||
scheduleAttempt(3);
|
||||
}
|
||||
setPrevArtifactCount(count);
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
const files = props.workingFiles;
|
||||
const count = files.length;
|
||||
@@ -441,61 +411,12 @@ export default function SessionView(props: SessionViewProps) {
|
||||
setPrevFileCount(count);
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
if (!artifactToast()) return;
|
||||
const id = window.setTimeout(() => setArtifactToast(null), 3000);
|
||||
return () => window.clearTimeout(id);
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
if (!commandToast()) return;
|
||||
const id = window.setTimeout(() => setCommandToast(null), 2400);
|
||||
return () => window.clearTimeout(id);
|
||||
});
|
||||
|
||||
const artifactActionToast = () => (isWindowsPlatform() ? "Opened in default app." : "Revealed in file manager.");
|
||||
|
||||
const resolveArtifactPath = (artifact: ArtifactItem) => {
|
||||
const rawPath = artifact.path?.trim();
|
||||
if (!rawPath) return null;
|
||||
if (/^(?:[a-zA-Z]:[\\/]|~[\\/]|\/)/.test(rawPath)) {
|
||||
return rawPath;
|
||||
}
|
||||
|
||||
const root = props.activeWorkspaceDisplay.path?.trim();
|
||||
if (!root) return rawPath;
|
||||
|
||||
const separator = root.includes("\\") ? "\\" : "/";
|
||||
const trimmedRoot = root.replace(/[\\/]+$/, "");
|
||||
const trimmedPath = rawPath.replace(/^[\\/]+/, "");
|
||||
return `${trimmedRoot}${separator}${trimmedPath}`;
|
||||
};
|
||||
|
||||
const handleOpenArtifact = async (artifact: ArtifactItem) => {
|
||||
const resolvedPath = resolveArtifactPath(artifact);
|
||||
if (!resolvedPath) {
|
||||
setArtifactToast("Artifact path missing.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isTauriRuntime()) {
|
||||
setArtifactToast("Open is only available in the desktop app.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { openPath, revealItemInDir } = await import("@tauri-apps/plugin-opener");
|
||||
if (isWindowsPlatform()) {
|
||||
await openPath(resolvedPath);
|
||||
} else {
|
||||
await revealItemInDir(resolvedPath);
|
||||
}
|
||||
setArtifactToast(artifactActionToast());
|
||||
} catch (error) {
|
||||
setArtifactToast(error instanceof Error ? error.message : "Could not open artifact.");
|
||||
}
|
||||
};
|
||||
|
||||
const selectedSessionTitle = createMemo(() => {
|
||||
const id = props.selectedSessionId;
|
||||
if (!id) return "";
|
||||
@@ -786,16 +707,10 @@ export default function SessionView(props: SessionViewProps) {
|
||||
<aside class="hidden lg:flex w-72 border-r border-gray-6 bg-gray-1 flex-col">
|
||||
<SessionSidebar
|
||||
todos={props.todos}
|
||||
artifacts={props.artifacts}
|
||||
activePlugins={props.activePlugins}
|
||||
activePluginStatus={props.activePluginStatus}
|
||||
authorizedDirs={props.authorizedDirs}
|
||||
workingFiles={props.workingFiles}
|
||||
expandedSections={props.expandedSidebarSections}
|
||||
onToggleSection={(section) => {
|
||||
props.setExpandedSidebarSections((curr) => ({...curr, [section]: !curr[section]}));
|
||||
}}
|
||||
onOpenArtifact={handleOpenArtifact}
|
||||
sessions={props.sessions}
|
||||
selectedSessionId={props.selectedSessionId}
|
||||
onSelectSession={async (id) => {
|
||||
@@ -810,21 +725,9 @@ export default function SessionView(props: SessionViewProps) {
|
||||
</aside>
|
||||
|
||||
<div
|
||||
class="flex-1 overflow-y-auto pt-6 md:pt-10 scroll-smooth relative no-scrollbar"
|
||||
class="flex-1 overflow-y-auto pt-6 md:pt-10 scroll-smooth relative"
|
||||
ref={(el) => (chatContainerEl = el)}
|
||||
>
|
||||
<style>
|
||||
{`
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
|
||||
<Show when={props.messages.length === 0}>
|
||||
<div class="text-center py-20 space-y-4">
|
||||
<div class="w-16 h-16 bg-gray-2 rounded-3xl mx-auto flex items-center justify-center border border-gray-6">
|
||||
@@ -839,12 +742,10 @@ export default function SessionView(props: SessionViewProps) {
|
||||
|
||||
<MessageList
|
||||
messages={props.messages}
|
||||
artifacts={props.artifacts}
|
||||
developerMode={props.developerMode}
|
||||
showThinking={props.showThinking}
|
||||
expandedStepIds={props.expandedStepIds}
|
||||
setExpandedStepIds={props.setExpandedStepIds}
|
||||
onOpenArtifact={handleOpenArtifact}
|
||||
footer={
|
||||
showRunIndicator() ? (
|
||||
<div class="flex justify-start pl-2">
|
||||
@@ -904,14 +805,22 @@ export default function SessionView(props: SessionViewProps) {
|
||||
|
||||
<div ref={(el) => (messagesEndEl = el)} />
|
||||
</div>
|
||||
|
||||
<Minimap containerRef={() => chatContainerEl} messages={props.messages} />
|
||||
|
||||
<Show when={artifactToast()}>
|
||||
<div class="fixed bottom-24 right-8 z-30 rounded-xl bg-gray-2 border border-gray-6 px-4 py-2 text-xs text-gray-11 shadow-lg">
|
||||
{artifactToast()}
|
||||
</div>
|
||||
</Show>
|
||||
<aside class="hidden lg:flex w-72 border-l border-gray-6 bg-gray-1 flex-col">
|
||||
<ContextPanel
|
||||
activePlugins={props.activePlugins}
|
||||
activePluginStatus={props.activePluginStatus}
|
||||
authorizedDirs={props.authorizedDirs}
|
||||
workingFiles={props.workingFiles}
|
||||
expanded={props.expandedSidebarSections.context}
|
||||
onToggle={() =>
|
||||
props.setExpandedSidebarSections((curr) => ({
|
||||
...curr,
|
||||
context: !curr.context,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<Composer
|
||||
|
||||
@@ -173,7 +173,7 @@ export default {
|
||||
"skills.host_mode_only": "Host mode only",
|
||||
"skills.install": "Install",
|
||||
"skills.installed_label": "Installed",
|
||||
"skills.install_hint": "Installs OpenPackage packages into the current workspace. Skills should land in `.opencode/skill`.",
|
||||
"skills.install_hint": "Installs OpenPackage packages into the current workspace. Skills should land in `.opencode/skills`.",
|
||||
"skills.import_local": "Import local skill",
|
||||
"skills.import_local_hint": "Copy an existing skill folder into this workspace.",
|
||||
"skills.import": "Import",
|
||||
@@ -184,7 +184,7 @@ export default {
|
||||
"skills.install_package": "Install",
|
||||
"skills.registry_notice": "Publishing to the OpenPackage registry (`opkg push`) requires authentication today. A registry search + curated list sync is planned.",
|
||||
"skills.installed": "Installed skills",
|
||||
"skills.no_skills": "No skills detected in `.opencode/skill` (or `.opencode/skills`).",
|
||||
"skills.no_skills": "No skills detected in `.opencode/skills` or `.claude/skills`.",
|
||||
"skills.desktop_required": "Skill management requires the desktop app.",
|
||||
"skills.host_only_error": "Skill management is only available in Host mode.",
|
||||
"skills.install_skill_creator": "Install skill creator",
|
||||
|
||||
@@ -174,7 +174,7 @@ export default {
|
||||
"skills.host_mode_only": "仅主机模式",
|
||||
"skills.install": "安装",
|
||||
"skills.installed_label": "已安装",
|
||||
"skills.install_hint": "将 OpenPackage 包安装到当前工作区。Skills 应放在 `.opencode/skill` 中。",
|
||||
"skills.install_hint": "将 OpenPackage 包安装到当前工作区。Skills 应放在 `.opencode/skills` 中。",
|
||||
"skills.import_local": "导入本地 skill",
|
||||
"skills.import_local_hint": "将现有 skill 文件夹复制到此工作区。",
|
||||
"skills.import": "导入",
|
||||
@@ -185,7 +185,7 @@ export default {
|
||||
"skills.install_package": "安装",
|
||||
"skills.registry_notice": "发布到 OpenPackage 注册表(`opkg push`)目前需要身份验证。计划添加注册表搜索和精选列表同步。",
|
||||
"skills.installed": "已安装的 skills",
|
||||
"skills.no_skills": "在 `.opencode/skill`(或 `.opencode/skills`)中未检测到 skills。",
|
||||
"skills.no_skills": "在 `.opencode/skills` 或 `.claude/skills` 中未检测到 skills。",
|
||||
"skills.desktop_required": "技能管理需要桌面应用。",
|
||||
"skills.host_only_error": "技能管理仅在主机模式下可用。",
|
||||
"skills.install_skill_creator": "安装 skill creator",
|
||||
|
||||
@@ -37,7 +37,7 @@ pub fn import_skill(project_dir: String, source_dir: String, overwrite: bool) ->
|
||||
|
||||
let dest = std::path::PathBuf::from(&project_dir)
|
||||
.join(".opencode")
|
||||
.join("skill")
|
||||
.join("skills")
|
||||
.join(name);
|
||||
|
||||
if dest.exists() {
|
||||
|
||||
@@ -3,16 +3,99 @@ use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::paths::{candidate_xdg_config_dirs, home_dir};
|
||||
use crate::types::ExecResult;
|
||||
|
||||
fn resolve_skill_root(project_dir: &str) -> Result<PathBuf, String> {
|
||||
fn ensure_project_skill_root(project_dir: &str) -> Result<PathBuf, String> {
|
||||
let project_dir = project_dir.trim();
|
||||
if project_dir.is_empty() {
|
||||
return Err("projectDir is required".to_string());
|
||||
}
|
||||
|
||||
let base = PathBuf::from(project_dir).join(".opencode");
|
||||
let plural = base.join("skills");
|
||||
let singular = base.join("skill");
|
||||
let root = if plural.exists() { plural } else { singular };
|
||||
fs::create_dir_all(&root)
|
||||
.map_err(|e| format!("Failed to create {}: {e}", root.display()))?;
|
||||
Ok(root)
|
||||
let legacy = base.join("skill");
|
||||
let modern = base.join("skills");
|
||||
|
||||
if legacy.is_dir() && !modern.exists() {
|
||||
fs::rename(&legacy, &modern)
|
||||
.map_err(|e| format!("Failed to move {} -> {}: {e}", legacy.display(), modern.display()))?;
|
||||
}
|
||||
|
||||
fs::create_dir_all(&modern)
|
||||
.map_err(|e| format!("Failed to create {}: {e}", modern.display()))?;
|
||||
Ok(modern)
|
||||
}
|
||||
|
||||
fn collect_project_skill_roots(project_dir: &Path) -> Vec<PathBuf> {
|
||||
let mut roots = Vec::new();
|
||||
let mut current = Some(project_dir);
|
||||
|
||||
while let Some(dir) = current {
|
||||
let opencode_root = dir.join(".opencode").join("skills");
|
||||
if opencode_root.is_dir() {
|
||||
roots.push(opencode_root);
|
||||
} else {
|
||||
let legacy_root = dir.join(".opencode").join("skill");
|
||||
if legacy_root.is_dir() {
|
||||
roots.push(legacy_root);
|
||||
}
|
||||
}
|
||||
|
||||
let claude_root = dir.join(".claude").join("skills");
|
||||
if claude_root.is_dir() {
|
||||
roots.push(claude_root);
|
||||
}
|
||||
|
||||
if dir.join(".git").exists() {
|
||||
break;
|
||||
}
|
||||
|
||||
current = dir.parent();
|
||||
}
|
||||
|
||||
roots
|
||||
}
|
||||
|
||||
fn collect_global_skill_roots() -> Vec<PathBuf> {
|
||||
let mut roots = Vec::new();
|
||||
for dir in candidate_xdg_config_dirs() {
|
||||
let opencode_root = dir.join("opencode").join("skills");
|
||||
if opencode_root.is_dir() {
|
||||
roots.push(opencode_root);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(home) = home_dir() {
|
||||
let claude_root = home.join(".claude").join("skills");
|
||||
if claude_root.is_dir() {
|
||||
roots.push(claude_root);
|
||||
}
|
||||
}
|
||||
|
||||
roots
|
||||
}
|
||||
|
||||
fn collect_skill_roots(project_dir: &str) -> Result<Vec<PathBuf>, String> {
|
||||
let project_dir = project_dir.trim();
|
||||
if project_dir.is_empty() {
|
||||
return Err("projectDir is required".to_string());
|
||||
}
|
||||
|
||||
let mut roots = Vec::new();
|
||||
let project_path = PathBuf::from(project_dir);
|
||||
roots.extend(collect_project_skill_roots(&project_path));
|
||||
roots.extend(collect_global_skill_roots());
|
||||
|
||||
let mut seen = HashSet::new();
|
||||
let mut unique = Vec::new();
|
||||
for root in roots {
|
||||
let key = root.to_string_lossy().to_string();
|
||||
if seen.insert(key) {
|
||||
unique.push(root);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(unique)
|
||||
}
|
||||
|
||||
fn validate_skill_name(name: &str) -> Result<String, String> {
|
||||
@@ -114,10 +197,12 @@ pub fn list_local_skills(project_dir: String) -> Result<Vec<LocalSkillCard>, Str
|
||||
return Err("projectDir is required".to_string());
|
||||
}
|
||||
|
||||
let skill_root = resolve_skill_root(project_dir)?;
|
||||
let skill_roots = collect_skill_roots(project_dir)?;
|
||||
let mut found: Vec<PathBuf> = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
gather_skills(&skill_root, &mut seen, &mut found)?;
|
||||
for root in skill_roots {
|
||||
gather_skills(&root, &mut seen, &mut found)?;
|
||||
}
|
||||
|
||||
let mut out = Vec::new();
|
||||
for path in found {
|
||||
@@ -154,7 +239,7 @@ pub fn install_skill_template(
|
||||
}
|
||||
|
||||
let name = validate_skill_name(&name)?;
|
||||
let skill_root = resolve_skill_root(project_dir)?;
|
||||
let skill_root = ensure_project_skill_root(project_dir)?;
|
||||
let dest = skill_root.join(&name);
|
||||
|
||||
if dest.exists() {
|
||||
@@ -192,21 +277,29 @@ pub fn uninstall_skill(project_dir: String, name: String) -> Result<ExecResult,
|
||||
}
|
||||
|
||||
let name = validate_skill_name(&name)?;
|
||||
let skill_root = resolve_skill_root(project_dir)?;
|
||||
let dest = skill_root.join(&name);
|
||||
let skill_roots = collect_skill_roots(project_dir)?;
|
||||
let mut removed = false;
|
||||
|
||||
if !dest.exists() {
|
||||
for root in skill_roots {
|
||||
let dest = root.join(&name);
|
||||
if !dest.exists() {
|
||||
continue;
|
||||
}
|
||||
|
||||
fs::remove_dir_all(&dest)
|
||||
.map_err(|e| format!("Failed to remove {}: {e}", dest.display()))?;
|
||||
removed = true;
|
||||
}
|
||||
|
||||
if !removed {
|
||||
return Ok(ExecResult {
|
||||
ok: false,
|
||||
status: 1,
|
||||
stdout: String::new(),
|
||||
stderr: format!("Skill not found at {}", dest.display()),
|
||||
stderr: "Skill not found in .opencode/skills or .claude/skills".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
fs::remove_dir_all(&dest)
|
||||
.map_err(|e| format!("Failed to remove {}: {e}", dest.display()))?;
|
||||
|
||||
Ok(ExecResult {
|
||||
ok: true,
|
||||
status: 0,
|
||||
|
||||
@@ -148,9 +148,9 @@ fn seed_templates(templates_dir: &PathBuf) -> Result<(), String> {
|
||||
pub fn ensure_workspace_files(workspace_path: &str, preset: &str) -> Result<(), String> {
|
||||
let root = PathBuf::from(workspace_path);
|
||||
|
||||
let skill_root = root.join(".opencode").join("skill");
|
||||
let skill_root = root.join(".opencode").join("skills");
|
||||
fs::create_dir_all(&skill_root)
|
||||
.map_err(|e| format!("Failed to create .opencode/skill: {e}"))?;
|
||||
.map_err(|e| format!("Failed to create .opencode/skills: {e}"))?;
|
||||
seed_workspace_guide(&skill_root)?;
|
||||
|
||||
let templates_dir = root.join(".openwork").join("templates");
|
||||
|
||||
Reference in New Issue
Block a user