feat: refine onboarding, skills, and MCP alpha UX (#118)

This commit is contained in:
ben
2026-01-19 19:56:38 -08:00
committed by GitHub
parent e78d4210b6
commit edaf73b609
11 changed files with 149 additions and 231 deletions

View File

@@ -109,31 +109,24 @@ fn seed_templates(templates_dir: &PathBuf) -> Result<(), String> {
let defaults = vec![
WorkspaceTemplate {
id: "tmpl_understand_workspace".to_string(),
title: "Understand this workspace".to_string(),
description: "Explains local vs global tools".to_string(),
prompt: "Explain how this workspace is configured and what tools are available locally. Be concise and actionable.".to_string(),
id: "tmpl_interact_with_files".to_string(),
title: "Learn to interact with files".to_string(),
description: "Safe, practical file workflows".to_string(),
prompt: "Show me how to interact with files in this workspace. Include safe examples for reading, summarizing, and editing.".to_string(),
created_at: now_ms(),
},
WorkspaceTemplate {
id: "tmpl_create_skill".to_string(),
title: "Create a new skill".to_string(),
description: "Guide to adding capabilities".to_string(),
prompt: "I want to create a new skill for this workspace. Guide me through it.".to_string(),
id: "tmpl_learn_skills".to_string(),
title: "Learn about skills".to_string(),
description: "How skills work and how to create your own".to_string(),
prompt: "Explain what skills are, how to use them, and how to create a new skill for this workspace.".to_string(),
created_at: now_ms(),
},
WorkspaceTemplate {
id: "tmpl_run_scheduled_task".to_string(),
title: "Run a scheduled task".to_string(),
description: "Demo of the scheduler plugin".to_string(),
prompt: "Show me how to schedule a task to run every morning.".to_string(),
created_at: now_ms(),
},
WorkspaceTemplate {
id: "tmpl_task_to_template".to_string(),
title: "Turn task into template".to_string(),
description: "Save workflow for later".to_string(),
prompt: "Help me turn the last task into a reusable template.".to_string(),
id: "tmpl_learn_plugins".to_string(),
title: "Learn about plugins".to_string(),
description: "What plugins are and how to install them".to_string(),
prompt: "Explain what plugins are and how to install them in this workspace.".to_string(),
created_at: now_ms(),
},
];

View File

@@ -1659,7 +1659,7 @@ export default function App() {
openTemplateModal,
runTemplate,
deleteTemplate,
refreshSkills: () => refreshSkills().catch(() => undefined),
refreshSkills: (options?: { force?: boolean }) => refreshSkills(options).catch(() => undefined),
refreshPlugins: (scopeOverride?: PluginScope) =>
refreshPlugins(scopeOverride).catch(() => undefined),
skills: skills(),

View File

@@ -13,45 +13,10 @@ export const DEFAULT_MODEL: ModelRef = {
export const CURATED_PACKAGES: CuratedPackage[] = [
{
name: "OpenPackage Essentials",
source: "essentials",
description: "Starter rules, commands, and skills from the OpenPackage registry.",
tags: ["registry", "starter"],
installable: true,
},
{
name: "Claude Code Plugins",
source: "github:anthropics/claude-code",
description: "Official Claude Code plugin pack from GitHub.",
tags: ["github", "claude"],
installable: true,
},
{
name: "Claude Code Commit Commands",
source: "github:anthropics/claude-code#subdirectory=plugins/commit-commands",
description: "Commit message helper commands (Claude Code plugin).",
tags: ["github", "workflow"],
installable: true,
},
{
name: "Awesome OpenPackage",
source: "git:https://github.com/enulus/awesome-openpackage.git",
description: "Community collection of OpenPackage examples and templates.",
tags: ["community"],
installable: true,
},
{
name: "Notion CRM Skill",
source: "github:different-ai/openwork-skills#subdirectory=manage-crm-notion",
description: "Set up a Notion CRM with pipelines, contacts, and follow-ups.",
tags: ["notion", "crm", "demo"],
installable: true,
},
{
name: "Awesome Claude Skills",
source: "https://github.com/ComposioHQ/awesome-claude-skills",
description: "Curated list of Claude skills and prompts (not an OpenPackage yet).",
tags: ["community", "list"],
name: "Notion CRM Enrichment Skills",
source: "https://github.com/different-ai/notion-crm-enrichment/tree/main/.claude/skills",
description: "Enrich Notion CRM data with ready-made skills.",
tags: ["notion", "crm", "skills"],
installable: false,
},
];

View File

@@ -39,12 +39,7 @@ export function createExtensionsStore(options: {
const [openPackageSource, setOpenPackageSource] = createSignal("");
const [packageSearch, setPackageSearch] = createSignal("");
const skillDocFallbacks: Record<string, string> = {
"workspace-guide": "Workspace guide that introduces OpenWork and suggests first steps.",
"manage-crm-notion": "Set up a Notion CRM with pipelines, contacts, and follow-ups.",
};
const failedSkillDocs = new Set<string>();
const skillDocKey = (root: string, name: string) => `${root}::${name}`;
const formatSkillPath = (location: string) => location.replace(/[/\\]SKILL\.md$/i, "");
const [pluginScope, setPluginScope] = createSignal<PluginScope>("project");
const [pluginConfig, setPluginConfig] = createSignal<OpencodeConfigFile | null>(null);
@@ -61,6 +56,8 @@ export function createExtensionsStore(options: {
let refreshPluginsInFlight = false;
let refreshSkillsAborted = false;
let refreshPluginsAborted = false;
let skillsLoaded = false;
let skillsRoot = "";
const isPluginInstalledByName = (pluginName: string, aliases: string[] = []) =>
isPluginInstalled(pluginList(), pluginName, aliases);
@@ -69,11 +66,29 @@ export function createExtensionsStore(options: {
loadPluginsFromConfigHelpers(config, setPluginList, (message) => setPluginStatus(message));
};
async function refreshSkills() {
async function refreshSkills(optionsOverride?: { force?: boolean }) {
const c = options.client();
if (!c) return;
if (!c) {
setSkills([]);
setSkillsStatus("Connect to a host to load skills.");
return;
}
const root = options.activeWorkspaceRoot().trim();
if (!root) {
setSkills([]);
setSkillsStatus("Pick a workspace folder first.");
return;
}
if (root !== skillsRoot) {
skillsLoaded = false;
}
if (!optionsOverride?.force && skillsLoaded) {
return;
}
// Skip if already in flight
if (refreshSkillsInFlight) {
return;
}
@@ -86,65 +101,40 @@ export function createExtensionsStore(options: {
if (refreshSkillsAborted) return;
const nodes = unwrap(
await c.file.list({ directory: options.activeWorkspaceRoot().trim(), path: ".opencode/skill" }),
);
if (refreshSkillsAborted) return;
const dirs = nodes.filter((n) => n.type === "directory" && !n.ignored);
const next: SkillCard[] = [];
const root = options.activeWorkspaceRoot().trim();
for (const dir of dirs) {
if (refreshSkillsAborted) return;
let description: string | undefined;
const fallback = skillDocFallbacks[dir.name];
const docKey = skillDocKey(root, dir.name);
if (fallback && failedSkillDocs.has(docKey)) {
description = fallback;
next.push({ name: dir.name, path: dir.path, description });
continue;
}
try {
const skillDoc = unwrap(
await c.file.read({
directory: root,
path: `.opencode/skill/${dir.name}/SKILL.md`,
}),
);
if (skillDoc.type === "text") {
const lines = skillDoc.content.split("\n");
const first = lines
.map((l) => l.trim())
.filter((l) => l && !l.startsWith("#"))
.slice(0, 2)
.join(" ");
if (first) {
description = first;
}
}
} catch {
if (fallback) {
failedSkillDocs.add(docKey);
description = fallback;
}
}
next.push({ name: dir.name, path: dir.path, description });
const rawClient = c as unknown as { _client?: { get: (input: { url: string }) => Promise<any> } };
if (!rawClient._client) {
throw new Error("OpenCode client unavailable.");
}
const result = await rawClient._client.get({ url: "/skill" });
if (result?.data === undefined) {
const err = result?.error;
const message =
err instanceof Error ? err.message : typeof err === "string" ? err : "Failed to load skills";
throw new Error(message);
}
const data = result.data as Array<{
name: string;
description: string;
location: string;
}>;
if (refreshSkillsAborted) return;
const next: SkillCard[] = Array.isArray(data)
? data.map((entry) => ({
name: entry.name,
description: entry.description,
path: formatSkillPath(entry.location),
}))
: [];
setSkills(next);
if (!next.length) {
setSkillsStatus("No skills found in .opencode/skill");
setSkillsStatus("No skills found yet.");
}
skillsLoaded = true;
skillsRoot = root;
} catch (e) {
if (refreshSkillsAborted) return;
setSkills([]);
@@ -329,7 +319,7 @@ export function createExtensionsStore(options: {
}
}
await refreshSkills();
await refreshSkills({ force: true });
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
options.setError(addOpencodeCacheHint(message));
@@ -382,7 +372,7 @@ export function createExtensionsStore(options: {
options.markReloadRequired("skills");
}
await refreshSkills();
await refreshSkills({ force: true });
} catch (e) {
const message = e instanceof Error ? e.message : "Unknown error";
options.setError(addOpencodeCacheHint(message));

View File

@@ -26,7 +26,7 @@ export function createSystemState(options: {
sessions: Accessor<Session[]>;
sessionStatusById: Accessor<Record<string, string>>;
refreshPlugins: (scopeOverride?: PluginScope) => Promise<void>;
refreshSkills: () => Promise<void>;
refreshSkills: (options?: { force?: boolean }) => Promise<void>;
setProviders: (value: Provider[]) => void;
setProviderDefaults: (value: Record<string, string>) => void;
setProviderConnectedIds: (value: string[]) => void;
@@ -229,7 +229,7 @@ export function createSystemState(options: {
}
await options.refreshPlugins("project").catch(() => undefined);
await options.refreshSkills().catch(() => undefined);
await options.refreshSkills({ force: true }).catch(() => undefined);
if (options.notion) {
let nextStatus = options.notion.status();

View File

@@ -64,7 +64,7 @@ export function createWorkspaceStore(options: {
setSessionStatusById: (value: Record<string, string>) => void;
defaultModel: () => any;
modelVariant: () => string | null;
refreshSkills: () => Promise<void>;
refreshSkills: (options?: { force?: boolean }) => Promise<void>;
refreshPlugins: () => Promise<void>;
engineSource: () => "path" | "sidecar";
setEngineSource: (value: "path" | "sidecar") => void;
@@ -238,60 +238,7 @@ export function createWorkspaceStore(options: {
options.setPendingPermissions([]);
options.setSessionStatusById({});
try {
if (isTauriRuntime() && activeWorkspaceRoot().trim()) {
const wsRoot = activeWorkspaceRoot().trim();
const storedKey = `openwork.welcomeSessionCreated:${wsRoot}`;
let already = false;
try {
already = window.localStorage.getItem(storedKey) === "1";
} catch {
// ignore
}
if (!already) {
const session = unwrap(
await nextClient.session.create({ directory: wsRoot, title: "Welcome to OpenWork" }),
);
await nextClient.session.promptAsync({
directory: wsRoot,
sessionID: session.id,
model: options.defaultModel(),
variant: options.modelVariant() ?? undefined,
parts: [
{
type: "text",
text:
"Give a short, welcoming overview of this workspace and how to use OpenWork. If a workspace guide skill is available, use it. Avoid CLI language or raw file paths. End with two friendly next actions to try inside OpenWork.",
},
],
});
try {
window.localStorage.setItem(storedKey, "1");
} catch {
// ignore
}
await options.loadSessions(activeWorkspaceRoot().trim()).catch(() => undefined);
if (session?.id) {
try {
await options.selectSession(session.id);
options.setView("session");
options.setTab("sessions");
} catch {
// ignore selection failure
}
}
}
}
} catch {
// ignore onboarding session failures
}
options.refreshSkills().catch(() => undefined);
options.refreshSkills({ force: true }).catch(() => undefined);
if (!options.selectedSessionId()) {
options.setView("dashboard");
options.setTab("home");

View File

@@ -1,6 +1,6 @@
import { For, Show, createSignal } from "solid-js";
import { CheckCircle2, FolderPlus, X } from "lucide-solid";
import { CheckCircle2, FolderPlus, Loader2, X } from "lucide-solid";
import Button from "./Button";
@@ -12,22 +12,18 @@ export default function CreateWorkspaceModal(props: {
}) {
const [preset, setPreset] = createSignal<"starter" | "automation" | "minimal">("starter");
const [selectedFolder, setSelectedFolder] = createSignal<string | null>(null);
const [pickingFolder, setPickingFolder] = createSignal(false);
const options = () => [
{
id: "starter" as const,
name: "Starter",
desc: "Pre-configured with Scheduler & starter templates. Best for general use.",
},
{
id: "automation" as const,
name: "Automation",
desc: "Optimized for scheduled/background work.",
name: "Starter workspace",
desc: "Preconfigured to show you how to use plugins, templates, and skills.",
},
{
id: "minimal" as const,
name: "Minimal",
desc: "Empty project. Adds only core config.",
name: "Empty workspace",
desc: "Start with a blank folder and add what you need.",
},
];
@@ -45,9 +41,15 @@ export default function CreateWorkspaceModal(props: {
};
const handlePickFolder = async () => {
const next = await props.onPickFolder();
if (next) {
setSelectedFolder(next);
if (pickingFolder()) return;
setPickingFolder(true);
try {
const next = await props.onPickFolder();
if (next) {
setSelectedFolder(next);
}
} finally {
setPickingFolder(false);
}
};
@@ -77,7 +79,10 @@ export default function CreateWorkspaceModal(props: {
<button
type="button"
onClick={handlePickFolder}
class="w-full border border-dashed border-zinc-700 bg-zinc-900/50 rounded-xl p-4 text-left transition hover:border-zinc-500"
disabled={pickingFolder()}
class={`w-full border border-dashed border-zinc-700 bg-zinc-900/50 rounded-xl p-4 text-left transition ${
pickingFolder() ? "opacity-70 cursor-wait" : "hover:border-zinc-500"
}`.trim()}
>
<div class="flex items-center gap-3 text-zinc-200">
<FolderPlus size={20} class="text-zinc-400" />
@@ -85,7 +90,15 @@ export default function CreateWorkspaceModal(props: {
<div class="text-sm font-medium text-zinc-100 truncate">{folderLabel()}</div>
<div class="text-xs text-zinc-500 font-mono truncate mt-1">{folderSubLabel()}</div>
</div>
<span class="text-xs text-zinc-500">Change</span>
<Show
when={pickingFolder()}
fallback={<span class="text-xs text-zinc-500">Change</span>}
>
<span class="flex items-center gap-2 text-xs text-zinc-500">
<Loader2 size={12} class="animate-spin" />
Opening...
</span>
</Show>
</div>
</button>
</div>

View File

@@ -29,7 +29,6 @@ import {
Plus,
Settings,
Server,
Smartphone,
} from "lucide-solid";
export type DashboardViewProps = {
@@ -81,7 +80,7 @@ export type DashboardViewProps = {
resetTemplateDraft?: (scope?: "workspace" | "global") => void;
runTemplate: (template: WorkspaceTemplate) => void;
deleteTemplate: (templateId: string) => void;
refreshSkills: () => void;
refreshSkills: (options?: { force?: boolean }) => void;
refreshPlugins: (scopeOverride?: PluginScope) => void;
refreshMcpServers: () => void;
skills: SkillCard[];
@@ -284,7 +283,7 @@ export default function DashboardView(props: DashboardViewProps) {
});
});
const navItem = (t: DashboardTab, label: string, icon: any) => {
const navItem = (t: DashboardTab, label: any, icon: any) => {
const active = () => props.tab === t;
return (
<button
@@ -318,7 +317,16 @@ export default function DashboardView(props: DashboardViewProps) {
{navItem("templates", "Templates", <FileText size={18} />)}
{navItem("skills", "Skills", <Package size={18} />)}
{navItem("plugins", "Plugins", <Cpu size={18} />)}
{navItem("mcp", "MCPs", <Server size={18} />)}
{navItem(
"mcp",
<span class="inline-flex items-center gap-2">
MCPs
<span class="text-[10px] uppercase tracking-wide px-2 py-0.5 rounded-full bg-amber-500/20 text-amber-200">
Alpha
</span>
</span>,
<Server size={18} />,
)}
{navItem("settings", "Settings", <Settings size={18} />)}
</nav>
</div>
@@ -326,12 +334,12 @@ export default function DashboardView(props: DashboardViewProps) {
<div class="space-y-4">
<div class="px-3 py-3 rounded-xl bg-zinc-900/50 border border-zinc-800">
<div class="flex items-center gap-2 text-xs font-medium text-zinc-400 mb-2">
{props.mode === "host" ? (
<Cpu size={12} />
) : (
<Smartphone size={12} />
)}
{props.mode === "host" ? "Local Engine" : "Client Mode"}
Connection
<Show when={props.developerMode}>
<span class="text-zinc-600">
{props.mode === "host" ? "Local Engine" : "Client Mode"}
</span>
</Show>
</div>
<div class="flex items-center gap-2">
<div
@@ -342,16 +350,18 @@ export default function DashboardView(props: DashboardViewProps) {
}`}
/>
<span
class={`text-sm font-mono ${
class={`text-sm font-medium ${
props.clientConnected ? "text-emerald-500" : "text-zinc-500"
}`}
>
{props.clientConnected ? "Connected" : "Disconnected"}
{props.clientConnected ? "Connected" : "Not connected"}
</span>
</div>
<div class="mt-2 text-[11px] text-zinc-600 font-mono truncate">
{props.baseUrl}
</div>
<Show when={props.developerMode}>
<div class="mt-2 text-[11px] text-zinc-600 font-mono truncate">
{props.baseUrl}
</div>
</Show>
</div>
<Show when={props.mode === "host"}>

View File

@@ -72,7 +72,7 @@ const statusLabel = (status: "connected" | "needs_auth" | "needs_client_registra
export default function McpView(props: McpViewProps) {
const [advancedOpen, setAdvancedOpen] = createSignal(false);
const [showDangerousContent, setShowDangerousContent] = createSignal(false);
const [showDangerousContent, setShowDangerousContent] = createSignal(true);
const selectedEntry = createMemo(() =>
props.mcpServers.find((entry) => entry.name === props.selectedMcp) ?? null,
@@ -108,9 +108,9 @@ export default function McpView(props: McpViewProps) {
<section class="space-y-6">
<div class="space-y-4">
<div class="space-y-1">
<h2 class="text-lg font-semibold text-white">Model Context Protocol</h2>
<h2 class="text-lg font-semibold text-white">MCP (Alpha)</h2>
<p class="text-sm text-zinc-400">
MCP servers allow you to easily connect your favorite service and login using your own credentials.
MCP servers let you connect services with your own credentials.
</p>
</div>
@@ -119,7 +119,7 @@ export default function McpView(props: McpViewProps) {
<TriangleAlert size={20} class="text-amber-400 shrink-0 mt-0.5" />
<div class="space-y-3">
<div class="text-sm font-medium text-amber-200">
hey we're currently building this and are chatting with opencode to figure out a bug before this can be pushed live.
MCP is in alpha while we harden OAuth with OpenCode.
</div>
<div class="flex flex-col gap-2">
<a
@@ -132,7 +132,7 @@ export default function McpView(props: McpViewProps) {
View issue #9510 on GitHub
</a>
<p class="text-xs text-zinc-400 leading-relaxed">
if you want to fix it or have a look at it feel free to submit a pr and show video for proof the oauth flows works
If you want to help, open a PR and include a short video showing the OAuth flow works end to end.
</p>
</div>
</div>
@@ -147,7 +147,7 @@ export default function McpView(props: McpViewProps) {
<Show when={showDangerousContent()} fallback={<ChevronRight size={14} class="group-hover:translate-x-0.5 transition-transform" />}>
<ChevronDown size={14} />
</Show>
dangerously use
{showDangerousContent() ? "Hide advanced settings" : "Show advanced settings"}
</button>
</div>
@@ -159,7 +159,7 @@ export default function McpView(props: McpViewProps) {
<div>
<div class="text-sm font-medium text-white">MCPs</div>
<div class="text-xs text-zinc-500">
Connect Model Context Protocol servers to expand what OpenWork can do.
Connect MCP servers to expand what OpenWork can do.
</div>
</div>
<div class="text-xs text-zinc-500 text-right">

View File

@@ -99,7 +99,7 @@ export default function OnboardingView(props: OnboardingViewProps) {
<div class="space-y-2">
<div class="text-sm font-medium text-white">Starter Workspace</div>
<div class="text-xs text-zinc-500">
OpenWork will create a ready-to-run folder and get everything set up for you.
A ready-to-run workspace with starter templates and plugins.
</div>
<div class={`text-xs ${props.developerMode ? "text-zinc-600 font-mono" : "text-zinc-500"} break-all`}>
{props.developerMode ? props.activeWorkspacePath || "(initializing...)" : "A starter workspace will be created for you."}
@@ -115,11 +115,11 @@ export default function OnboardingView(props: OnboardingViewProps) {
</div>
<div class="flex items-center gap-3 text-sm text-zinc-300">
<div class="w-2 h-2 rounded-full bg-emerald-500" />
Starter templates ("Understand this workspace", etc.)
Starter templates for files, skills, and plugins
</div>
<div class="flex items-center gap-3 text-sm text-zinc-300">
<div class="w-2 h-2 rounded-full bg-emerald-500" />
Add more folders when prompted
This workspace is preconfigured to show you how to use plugins, templates, and skills
</div>
</div>
</div>

View File

@@ -9,7 +9,7 @@ import { Package, Upload } from "lucide-solid";
export type SkillsViewProps = {
busy: boolean;
mode: "host" | "client" | null;
refreshSkills: () => void;
refreshSkills: (options?: { force?: boolean }) => void;
skills: SkillCard[];
skillsStatus: string | null;
openPackageSource: string;
@@ -27,7 +27,7 @@ export default function SkillsView(props: SkillsViewProps) {
<section class="space-y-6">
<div class="flex items-center justify-between">
<h3 class="text-sm font-medium text-zinc-400 uppercase tracking-wider">Skills</h3>
<Button variant="secondary" onClick={props.refreshSkills} disabled={props.busy}>
<Button variant="secondary" onClick={() => props.refreshSkills({ force: true })} disabled={props.busy}>
Refresh
</Button>
</div>
@@ -87,21 +87,21 @@ export default function SkillsView(props: SkillsViewProps) {
<div class="rounded-2xl border border-emerald-500/20 bg-emerald-500/10 p-4">
<div class="flex items-start justify-between gap-4">
<div>
<div class="text-sm font-medium text-emerald-100">Manage CRM in Notion</div>
<div class="text-xs text-emerald-200/80 mt-1">Set up pipelines, contacts, and follow-ups in minutes.</div>
<div class="text-sm font-medium text-emerald-100">Notion CRM Enrichment Skills</div>
<div class="text-xs text-emerald-200/80 mt-1">Add enrichment workflows for contacts, pipelines, and follow-ups.</div>
</div>
<Button
variant="secondary"
onClick={() => props.useCuratedPackage({
name: "Notion CRM Skill",
source: "github:different-ai/openwork-skills#subdirectory=manage-crm-notion",
description: "Set up a Notion CRM with pipelines, contacts, and follow-ups.",
tags: ["notion", "crm", "demo"],
installable: true,
name: "Notion CRM Enrichment Skills",
source: "https://github.com/different-ai/notion-crm-enrichment/tree/main/.claude/skills",
description: "Enrich Notion CRM data with ready-made skills.",
tags: ["notion", "crm", "skills"],
installable: false,
})}
disabled={props.busy || props.mode !== "host" || !isTauriRuntime()}
disabled={props.busy}
>
Install
View
</Button>
</div>
</div>
@@ -169,7 +169,7 @@ export default function SkillsView(props: SkillsViewProps) {
when={props.skills.length}
fallback={
<div class="bg-zinc-900/30 border border-zinc-800/50 rounded-2xl p-6 text-sm text-zinc-500">
No skills detected in `.opencode/skill`.
No skills detected yet.
</div>
}
>