Files
openchamber/packages/ui/src/stores/useSkillsStore.ts
T
Bohdan Triapitsyn f4743ea060 feat(chat): work-status panel, and MCP auth and settings fixes (#2776)
Adds a work-status panel beside the transcript. Context fill, model and
cost, todos, running subagents and the permission requests blocking
them, branch and working-tree state, MCP servers, pinned messages and
context sources were scattered across the header, the composer and the
context panel — a blocked subagent was reported nowhere at all. The
panel reads them from live channels rather than persisted history, and
becomes an overlay where the chat is too narrow to seat a column.

It is on by default, including for existing installs. Because it now
carries these readouts, the desktop header and composer drop the ones it
duplicates: todo and changed-files chips, usage and MCP tabs. VS Code
and mobile keep theirs — neither hosts the panel.

Fixes MCP authorization, which was broken from the panel, invalidated by
a directory switch through a redirect URI that encoded the working
directory, and left the desktop app in the background because browsers
will not follow a custom-protocol link without a user gesture. The
settings page no longer asks the user to understand the MCP spec before
adding a server: one field takes the command or the link, with the kind
inferred and a visible override, and client-registration fields appear
only when a server actually asks for its own credentials.

Also: skills load from the panel instead of only when the composer's
slash autocomplete opens; the header button names the current instance
rather than falling through to the word "Instance" for relay hosts.

Three new optional UI settings keys, all migrated. No change to stored
MCP server configuration.
2026-08-09 19:30:25 +03:00

740 lines
25 KiB
TypeScript

import { create } from "zustand";
import type { StoreApi, UseBoundStore } from "zustand";
import { devtools, persist } from "zustand/middleware";
import { emitConfigChange, scopeMatches, subscribeToConfigChanges } from "@/lib/configSync";
import {
startConfigUpdate,
finishConfigUpdate,
updateConfigUpdateMessage,
} from "@/lib/configUpdate";
import { createDeferredSafeJSONStorage } from "./utils/safeStorage";
import { runtimeFetch } from "@/lib/runtime-fetch";
import { runBackgroundNetworkTask } from "@/lib/background-network";
import { noteDeferredRestartFromPayload } from "@/lib/opencode/deferredRestart";
import { useProjectsStore } from "@/stores/useProjectsStore";
import { opencodeClient } from '@/lib/opencode/client';
import { filterSkillsByRuntimeFlags } from './skillVisibility';
// Prefer the active project path so Settings/Skills discovery matches the
// project selector (and Commands/Agents). Falling back only to the session
// directory misses repository-local `.agents/skills` when the client directory
// is unset or points elsewhere while an active project exists.
const getRequestDirectory = (): string | null => {
try {
const projectsStore = useProjectsStore.getState();
const activeProject = projectsStore.getActiveProject?.();
if (activeProject?.path?.trim()) {
return activeProject.path.trim();
}
const clientDir = opencodeClient.getDirectory();
if (clientDir?.trim()) {
return clientDir.trim();
}
} catch (err) {
console.warn('[SkillsStore] Error resolving config directory:', err);
}
return null;
};
export type SkillScope = 'user' | 'project';
export type SkillSource = 'opencode' | 'claude' | 'agents';
export interface SupportingFile {
name: string;
path: string;
fullPath: string;
}
interface SkillSources {
md: {
exists: boolean;
path: string | null;
dir: string | null;
fields: string[];
scope?: SkillScope | null;
source?: SkillSource | null;
supportingFiles: SupportingFile[];
// Actual content values
name?: string;
description?: string;
instructions?: string;
};
projectMd?: { exists: boolean; path: string | null };
claudeMd?: { exists: boolean; path: string | null };
userMd?: { exists: boolean; path: string | null };
userClaudeMd?: { exists: boolean; path: string | null };
userAgentsMd?: { exists: boolean; path: string | null };
}
export interface DiscoveredSkill {
name: string;
path: string;
scope: SkillScope;
source: SkillSource;
description?: string;
/** Domain folder parsed from file path, e.g. "automation-ai", "lark-ecosystem" */
group?: string;
/** Authoritative server flag: skill lives under a managed root and can be renamed in place. */
renamable?: boolean;
}
/** Parse the domain group folder from a skill file path.
* e.g. "~/.config/opencode/skills/automation-ai/ai-production/SKILL.md" → "automation-ai"
* e.g. "~/.config/opencode/skills/theme-system/SKILL.md" → undefined (flat)
*/
function parseSkillGroup(path: string): string | undefined {
const normalizedPath = path.replace(/\\/g, '/');
const idx = normalizedPath.lastIndexOf('/skills/');
if (idx === -1) return undefined;
const relative = normalizedPath.substring(idx + '/skills/'.length);
const parts = relative.split('/');
// Grouped layout: <group>/<name>/SKILL.md → parts.length >= 3
// Flat layout: <name>/SKILL.md → parts.length == 2
return parts.length >= 3 ? parts[0] : undefined;
}
// Raw skill response from API before transformation
interface RawSkillResponse {
name: string;
path: string;
scope?: SkillScope;
source?: SkillSource;
renamable?: boolean;
sources?: {
md?: {
description?: string;
};
};
}
export interface SkillConfig {
name: string;
description: string;
instructions?: string;
scope?: SkillScope;
source?: SkillSource;
targetPath?: string;
supportingFiles?: Array<{ path: string; content: string }>;
}
export interface PendingFile {
path: string;
content: string;
}
export interface SkillDraft {
name: string;
scope: SkillScope;
source?: SkillSource;
description: string;
instructions?: string;
pendingFiles?: PendingFile[];
}
interface SkillDetail {
name: string;
sources: SkillSources;
scope?: SkillScope | null;
source?: SkillSource | null;
}
interface SkillsStore {
selectedSkillName: string | null;
skills: DiscoveredSkill[];
isLoading: boolean;
skillDraft: SkillDraft | null;
setSelectedSkill: (name: string | null) => void;
setSkillDraft: (draft: SkillDraft | null) => void;
loadSkills: () => Promise<boolean>;
getSkillDetail: (name: string) => Promise<SkillDetail | null>;
createSkill: (config: SkillConfig) => Promise<boolean>;
updateSkill: (name: string, config: Partial<SkillConfig>) => Promise<boolean>;
renameSkill: (name: string, newName: string) => Promise<boolean>;
deleteSkill: (name: string) => Promise<boolean>;
getSkillByName: (name: string) => DiscoveredSkill | undefined;
// Supporting files
readSupportingFile: (skillName: string, filePath: string) => Promise<string | null>;
writeSupportingFile: (skillName: string, filePath: string, content: string) => Promise<boolean>;
deleteSupportingFile: (skillName: string, filePath: string) => Promise<boolean>;
}
declare global {
interface Window {
__zustand_skills_store__?: UseBoundStore<StoreApi<SkillsStore>>;
}
}
const CONFIG_EVENT_SOURCE = "useSkillsStore";
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const SKILLS_LOAD_CACHE_TTL_MS = 5000;
const DEFAULT_SKILLS_CACHE_KEY = '__default__';
const skillsLastLoadedAt = new Map<string, number>();
const skillsLoadInFlight = new Map<string, Promise<boolean>>();
const getSkillsCacheKey = (directory: string | null): string => {
return directory?.trim() || DEFAULT_SKILLS_CACHE_KEY;
};
export const invalidateSkillsLoadCache = (directory: string | null = getRequestDirectory()) => {
skillsLastLoadedAt.delete(getSkillsCacheKey(directory));
};
const upsertSkillLocal = (
set: (state: Partial<SkillsStore>) => void,
get: () => SkillsStore,
name: string,
config: Partial<SkillConfig>,
) => {
const existing = get().skills.find((skill) => skill.name === name);
const path = config.targetPath ?? existing?.path ?? '';
const nextSkill: DiscoveredSkill = {
...existing,
name,
path,
scope: config.scope ?? existing?.scope ?? 'user',
source: config.source ?? existing?.source ?? 'opencode',
description: config.description ?? existing?.description ?? '',
group: parseSkillGroup(path),
};
const skills = get().skills;
const nextSkills = skills.some((skill) => skill.name === name)
? skills.map((skill) => (skill.name === name ? nextSkill : skill))
: [...skills, nextSkill];
set({ skills: nextSkills });
};
const removeSkillLocal = (
set: (state: Partial<SkillsStore>) => void,
get: () => SkillsStore,
name: string,
) => {
const nextState: Partial<SkillsStore> = {
skills: get().skills.filter((skill) => skill.name !== name),
};
if (get().selectedSkillName === name) {
nextState.selectedSkillName = null;
}
set(nextState);
};
const MAX_HEALTH_WAIT_MS = 20000;
const FAST_HEALTH_POLL_INTERVAL_MS = 300;
const FAST_HEALTH_POLL_ATTEMPTS = 4;
const SLOW_HEALTH_POLL_BASE_MS = 800;
const SLOW_HEALTH_POLL_INCREMENT_MS = 200;
const SLOW_HEALTH_POLL_MAX_MS = 2000;
export const useSkillsStore = create<SkillsStore>()(
devtools(
persist(
(set, get) => ({
selectedSkillName: null,
skills: [],
isLoading: false,
skillDraft: null,
setSelectedSkill: (name: string | null) => {
set({ selectedSkillName: name });
},
setSkillDraft: (draft: SkillDraft | null) => {
set({ skillDraft: draft });
},
loadSkills: async () => {
const directory = getRequestDirectory();
const cacheKey = getSkillsCacheKey(directory);
const now = Date.now();
const loadedAt = skillsLastLoadedAt.get(cacheKey) ?? 0;
const hasCachedSkills = get().skills.length > 0;
if (hasCachedSkills && now - loadedAt < SKILLS_LOAD_CACHE_TTL_MS) {
return true;
}
const inFlight = skillsLoadInFlight.get(cacheKey);
if (inFlight) {
return inFlight;
}
const request = (async () => {
set({ isLoading: true });
const previousSkills = get().skills;
let lastError: unknown = null;
for (let attempt = 0; attempt < 3; attempt++) {
try {
const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : '';
const response = await runBackgroundNetworkTask(() => runtimeFetch(`/api/config/skills${queryParams}`, {
priority: 'low',
headers: directory ? { 'x-opencode-directory': directory } : undefined,
}));
if (!response.ok) {
throw new Error(`Failed to list skills: ${response.status}`);
}
const data = await response.json();
const rawSkills: RawSkillResponse[] = data.skills || [];
const configSkills: DiscoveredSkill[] = rawSkills.map((s) => ({
name: s.name,
path: s.path,
scope: s.scope ?? 'user',
source: s.source ?? 'opencode',
description: s.sources?.md?.description || '',
group: parseSkillGroup(s.path),
renamable: s.renamable === true,
}));
// OpenCode loads a narrower set than this scan finds, and the
// rules live in server-side env flags the browser cannot read.
// The route reports them; `filterSkillsByRuntimeFlags` mirrors
// OpenCode's discovery, including the `.agents`-wins dedup that
// matters when `.claude/skills` are symlinks back into it.
//
// Deliberately not OpenCode's own skill endpoint: measured
// against 1.18.14 it lists only global and builtin skills and
// omits the project skills the agent actually has.
const visibleSkills = filterSkillsByRuntimeFlags(
configSkills,
data.externalSkills ?? null,
);
set({ skills: visibleSkills, isLoading: false });
skillsLastLoadedAt.set(cacheKey, Date.now());
return true;
} catch (error) {
lastError = error;
const waitMs = 200 * (attempt + 1);
await new Promise((resolve) => setTimeout(resolve, waitMs));
}
}
console.error("Failed to load skills:", lastError);
set({ skills: previousSkills, isLoading: false });
return false;
})();
skillsLoadInFlight.set(cacheKey, request);
try {
return await request;
} finally {
skillsLoadInFlight.delete(cacheKey);
}
},
getSkillDetail: async (name: string) => {
try {
const directory = getRequestDirectory();
const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : '';
const response = await runtimeFetch(`/api/config/skills/${encodeURIComponent(name)}${queryParams}`, {
headers: directory ? { 'x-opencode-directory': directory } : undefined,
});
if (!response.ok) {
return null;
}
return await response.json() as SkillDetail;
} catch {
return null;
}
},
createSkill: async (config: SkillConfig) => {
try {
const skillConfig: Record<string, unknown> = {
name: config.name,
description: config.description,
};
if (config.instructions) skillConfig.instructions = config.instructions;
if (config.scope) skillConfig.scope = config.scope;
if (config.source) skillConfig.source = config.source;
if (config.supportingFiles) skillConfig.supportingFiles = config.supportingFiles;
const directory = getRequestDirectory();
const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : '';
const response = await runtimeFetch(`/api/config/skills/${encodeURIComponent(config.name)}${queryParams}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(directory ? { 'x-opencode-directory': directory } : {}),
},
body: JSON.stringify(skillConfig)
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
const message = payload?.error || 'Failed to create skill';
throw new Error(message);
}
invalidateSkillsLoadCache(directory);
if (payload?.requiresManualRestart) {
upsertSkillLocal(set, get, config.name, config);
return true;
}
if (noteDeferredRestartFromPayload(payload, 'skills', { id: config.name })) {
upsertSkillLocal(set, get, config.name, config);
emitConfigChange("skills", { source: CONFIG_EVENT_SOURCE });
return true;
}
if (payload?.requiresReload) {
startConfigUpdate("Creating skill...");
await refreshSkillsAfterOpenCodeRestart({
message: payload?.message,
delayMs: payload?.reloadDelayMs,
});
return true;
}
const loaded = await get().loadSkills();
if (loaded) {
emitConfigChange("skills", { source: CONFIG_EVENT_SOURCE });
}
return loaded;
} catch {
return false;
}
},
updateSkill: async (name: string, config: Partial<SkillConfig>) => {
try {
const skillConfig: Record<string, unknown> = {};
if (config.description !== undefined) skillConfig.description = config.description;
if (config.instructions !== undefined) skillConfig.instructions = config.instructions;
if (config.supportingFiles !== undefined) skillConfig.supportingFiles = config.supportingFiles;
if (config.targetPath !== undefined) skillConfig.targetPath = config.targetPath;
const directory = getRequestDirectory();
const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : '';
const response = await runtimeFetch(`/api/config/skills/${encodeURIComponent(name)}${queryParams}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
...(directory ? { 'x-opencode-directory': directory } : {}),
},
body: JSON.stringify(skillConfig)
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
const message = payload?.error || 'Failed to update skill';
throw new Error(message);
}
invalidateSkillsLoadCache(directory);
if (payload?.requiresManualRestart) {
upsertSkillLocal(set, get, name, config);
return true;
}
if (noteDeferredRestartFromPayload(payload, 'skills', { id: name })) {
upsertSkillLocal(set, get, name, config);
emitConfigChange("skills", { source: CONFIG_EVENT_SOURCE });
return true;
}
if (payload?.requiresReload) {
startConfigUpdate("Updating skill...");
await refreshSkillsAfterOpenCodeRestart({
message: payload?.message,
delayMs: payload?.reloadDelayMs,
});
return true;
}
const loaded = await get().loadSkills();
if (loaded) {
emitConfigChange("skills", { source: CONFIG_EVENT_SOURCE });
}
return loaded;
} catch {
return false;
}
},
renameSkill: async (name: string, newName: string) => {
startConfigUpdate("Renaming skill...");
let requiresReload = false;
try {
const directory = getRequestDirectory();
const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : '';
const response = await runtimeFetch(`/api/config/skills/${encodeURIComponent(name)}${queryParams}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
...(directory ? { 'x-opencode-directory': directory } : {}),
},
body: JSON.stringify({ renameTo: newName }),
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
const message = payload?.error || 'Failed to rename skill';
throw new Error(message);
}
const needsReload = payload?.requiresReload ?? false;
invalidateSkillsLoadCache(directory);
if (needsReload) {
requiresReload = true;
await refreshSkillsAfterOpenCodeRestart({
message: payload?.message,
delayMs: payload?.reloadDelayMs,
});
return true;
}
const loaded = await get().loadSkills();
if (loaded) {
emitConfigChange("skills", { source: CONFIG_EVENT_SOURCE });
}
return loaded;
} catch {
return false;
} finally {
if (!requiresReload) {
finishConfigUpdate();
}
}
},
deleteSkill: async (name: string) => {
try {
const directory = getRequestDirectory();
const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : '';
const response = await runtimeFetch(`/api/config/skills/${encodeURIComponent(name)}${queryParams}`, {
method: 'DELETE',
headers: directory ? { 'x-opencode-directory': directory } : undefined,
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
const message = payload?.error || 'Failed to delete skill';
throw new Error(message);
}
invalidateSkillsLoadCache(directory);
if (payload?.requiresManualRestart) {
removeSkillLocal(set, get, name);
return true;
}
if (noteDeferredRestartFromPayload(payload, 'skills', { id: name })) {
removeSkillLocal(set, get, name);
emitConfigChange("skills", { source: CONFIG_EVENT_SOURCE });
return true;
}
if (payload?.requiresReload) {
startConfigUpdate("Deleting skill...");
await refreshSkillsAfterOpenCodeRestart({
message: payload?.message,
delayMs: payload?.reloadDelayMs,
});
return true;
}
const loaded = await get().loadSkills();
if (loaded) {
emitConfigChange("skills", { source: CONFIG_EVENT_SOURCE });
}
if (get().selectedSkillName === name) {
set({ selectedSkillName: null });
}
return loaded;
} catch {
return false;
}
},
getSkillByName: (name: string) => {
const { skills } = get();
return skills.find((s) => s.name === name);
},
readSupportingFile: async (skillName: string, filePath: string) => {
try {
const directory = getRequestDirectory();
const queryParams = directory ? `&directory=${encodeURIComponent(directory)}` : '';
const response = await runtimeFetch(
`/api/config/skills/${encodeURIComponent(skillName)}/files/${encodeURIComponent(filePath)}?${queryParams.slice(1)}`,
{ headers: directory ? { 'x-opencode-directory': directory } : undefined },
);
if (!response.ok) {
return null;
}
const data = await response.json();
return data.content ?? null;
} catch {
return null;
}
},
writeSupportingFile: async (skillName: string, filePath: string, content: string) => {
try {
const directory = getRequestDirectory();
const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : '';
const response = await runtimeFetch(
`/api/config/skills/${encodeURIComponent(skillName)}/files/${encodeURIComponent(filePath)}${queryParams}`,
{
method: 'PUT',
headers: {
'Content-Type': 'application/json',
...(directory ? { 'x-opencode-directory': directory } : {}),
},
body: JSON.stringify({ content })
}
);
return response.ok;
} catch {
return false;
}
},
deleteSupportingFile: async (skillName: string, filePath: string) => {
try {
const directory = getRequestDirectory();
const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : '';
const response = await runtimeFetch(
`/api/config/skills/${encodeURIComponent(skillName)}/files/${encodeURIComponent(filePath)}${queryParams}`,
{
method: 'DELETE',
headers: directory ? { 'x-opencode-directory': directory } : undefined,
}
);
return response.ok;
} catch {
return false;
}
},
}),
{
name: "skills-store",
storage: createDeferredSafeJSONStorage(),
partialize: (state) => ({
selectedSkillName: state.selectedSkillName,
}),
},
),
{
name: "skills-store",
},
),
);
if (typeof window !== "undefined") {
window.__zustand_skills_store__ = useSkillsStore;
}
async function waitForOpenCodeConnection(delayMs?: number) {
const initialPause = typeof delayMs === "number" && delayMs > 0
? Math.min(delayMs, FAST_HEALTH_POLL_INTERVAL_MS)
: 0;
if (initialPause > 0) {
await sleep(initialPause);
}
const start = Date.now();
let attempt = 0;
let lastError: unknown = null;
while (Date.now() - start < MAX_HEALTH_WAIT_MS) {
attempt += 1;
updateConfigUpdateMessage(`Waiting for OpenCode… (attempt ${attempt})`);
try {
const isHealthy = await opencodeClient.checkHealth();
if (isHealthy) {
return;
}
lastError = new Error("OpenCode health check reported not ready");
} catch (error) {
lastError = error;
}
const elapsed = Date.now() - start;
const waitMs =
attempt <= FAST_HEALTH_POLL_ATTEMPTS && elapsed < 1200
? FAST_HEALTH_POLL_INTERVAL_MS
: Math.min(
SLOW_HEALTH_POLL_BASE_MS +
Math.max(0, attempt - FAST_HEALTH_POLL_ATTEMPTS) * SLOW_HEALTH_POLL_INCREMENT_MS,
SLOW_HEALTH_POLL_MAX_MS,
);
await sleep(waitMs);
}
throw lastError || new Error("OpenCode did not become ready in time");
}
export async function refreshSkillsAfterOpenCodeRestart(options?: { message?: string; delayMs?: number }) {
try {
updateConfigUpdateMessage(options?.message || "Refreshing skills…");
} catch {
// ignore
}
try {
await waitForOpenCodeConnection(options?.delayMs);
updateConfigUpdateMessage("Refreshing skills…");
const skillsStore = useSkillsStore.getState();
invalidateSkillsLoadCache();
const loaded = await skillsStore.loadSkills();
if (loaded) {
emitConfigChange("skills", { source: CONFIG_EVENT_SOURCE });
}
} catch (error) {
updateConfigUpdateMessage("OpenCode refresh failed. Please retry.");
await sleep(1500);
throw error;
} finally {
finishConfigUpdate();
}
}
// Subscribe to config changes from other stores
let unsubscribeSkillsConfigChanges: (() => void) | null = null;
if (!unsubscribeSkillsConfigChanges) {
unsubscribeSkillsConfigChanges = subscribeToConfigChanges((event) => {
if (event.source === CONFIG_EVENT_SOURCE) {
return;
}
if (scopeMatches(event, "skills")) {
const { loadSkills } = useSkillsStore.getState();
void loadSkills();
}
});
}