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:
ben
2026-01-24 08:35:49 -08:00
committed by GitHub
parent 6f5dfdb70d
commit 33294d8363
22 changed files with 350 additions and 419 deletions

View File

@@ -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 OpenWorks preferred reactivity + UI state patterns (avoid global `busy()` deadlocks; use scoped async state).

View File

@@ -228,7 +228,7 @@ OpenWorks 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)**

View File

@@ -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

View File

@@ -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";
});

View 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>
);
}

View File

@@ -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"

View File

@@ -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>

View File

@@ -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>

View File

@@ -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"));
}

View File

@@ -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

View File

@@ -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

View File

@@ -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",

View File

@@ -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",

View File

@@ -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() {

View File

@@ -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,

View File

@@ -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");