From f9748391a9168541214143ae51395b5de99e0ee9 Mon Sep 17 00:00:00 2001 From: Gemini AI Date: Wed, 24 Dec 2025 14:23:51 +0400 Subject: [PATCH] Add skills catalog and sidebar tooling --- packages/server/src/api-types.ts | 35 ++ packages/server/src/server/http-server.ts | 2 + packages/server/src/server/routes/skills.ts | 141 +++++++ .../server/src/server/routes/workspaces.ts | 63 ++++ .../components/instance/instance-shell2.tsx | 222 +++++++++--- .../ui/src/components/instance/sidebar.tsx | 296 ++++++++++++++- packages/ui/src/lib/api-client.ts | 13 + packages/ui/src/stores/instance-config.tsx | 8 +- packages/ui/src/stores/session-actions.ts | 53 ++- packages/ui/src/stores/session-api.ts | 343 ++++++++++++++++-- packages/ui/src/stores/session-state.ts | 25 +- packages/ui/src/stores/skills.ts | 75 ++++ packages/ui/src/types/session.ts | 8 + 13 files changed, 1178 insertions(+), 106 deletions(-) create mode 100644 packages/server/src/server/routes/skills.ts create mode 100644 packages/ui/src/stores/skills.ts diff --git a/packages/server/src/api-types.ts b/packages/server/src/api-types.ts index 27d98c2..f4370f5 100644 --- a/packages/server/src/api-types.ts +++ b/packages/server/src/api-types.ts @@ -22,6 +22,26 @@ export interface SessionTasks { [sessionId: string]: Task[] } +export interface SkillSelection { + id: string + name: string + description?: string +} + +export interface SkillDescriptor { + id: string + name: string + description?: string +} + +export interface SkillDetail extends SkillDescriptor { + content: string +} + +export interface SkillCatalogResponse { + skills: SkillDescriptor[] +} + /** * Canonical HTTP/SSE contract for the CLI server. * These types are consumed by both the CLI implementation and any UI clients. @@ -120,10 +140,25 @@ export interface WorkspaceFileResponse { export type WorkspaceFileSearchResponse = FileSystemEntry[] +export interface WorkspaceGitStatusEntry { + path: string + status: string +} + +export interface WorkspaceGitStatus { + isRepo: boolean + branch: string | null + ahead: number + behind: number + changes: WorkspaceGitStatusEntry[] + error?: string +} + export interface InstanceData { messageHistory: string[] agentModelSelections: AgentModelSelection sessionTasks?: SessionTasks // Multi-task chat support: tasks per session + sessionSkills?: Record // Selected skills per session } export type InstanceStreamStatus = "connecting" | "connected" | "error" | "disconnected" diff --git a/packages/server/src/server/http-server.ts b/packages/server/src/server/http-server.ts index 06ffc27..8a5da48 100644 --- a/packages/server/src/server/http-server.ts +++ b/packages/server/src/server/http-server.ts @@ -22,6 +22,7 @@ import { registerOllamaRoutes } from "./routes/ollama" import { registerQwenRoutes } from "./routes/qwen" import { registerZAIRoutes } from "./routes/zai" import { registerOpenCodeZenRoutes } from "./routes/opencode-zen" +import { registerSkillsRoutes } from "./routes/skills" import { ServerMeta } from "../api-types" import { InstanceStore } from "../storage/instance-store" @@ -118,6 +119,7 @@ export function createHttpServer(deps: HttpServerDeps) { registerQwenRoutes(app, { logger: deps.logger }) registerZAIRoutes(app, { logger: deps.logger }) registerOpenCodeZenRoutes(app, { logger: deps.logger }) + await registerSkillsRoutes(app) registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger }) diff --git a/packages/server/src/server/routes/skills.ts b/packages/server/src/server/routes/skills.ts new file mode 100644 index 0000000..989964c --- /dev/null +++ b/packages/server/src/server/routes/skills.ts @@ -0,0 +1,141 @@ +import { FastifyInstance } from "fastify" +import { z } from "zod" +import type { SkillCatalogResponse, SkillDetail, SkillDescriptor } from "../../api-types" + +const SKILLS_OWNER = "anthropics" +const SKILLS_REPO = "skills" +const SKILLS_BRANCH = "main" +const SKILLS_ROOT = "skills" +const CATALOG_TTL_MS = 30 * 60 * 1000 +const DETAIL_TTL_MS = 30 * 60 * 1000 + +type CachedCatalog = { skills: SkillDescriptor[]; fetchedAt: number } +type CachedDetail = { detail: SkillDetail; fetchedAt: number } + +let catalogCache: CachedCatalog | null = null +const detailCache = new Map() + +interface RepoEntry { + name: string + path: string + type: "file" | "dir" +} + +function parseFrontmatter(markdown: string): { attributes: Record; body: string } { + if (!markdown.startsWith("---")) { + return { attributes: {}, body: markdown.trim() } + } + const end = markdown.indexOf("\n---", 3) + if (end === -1) { + return { attributes: {}, body: markdown.trim() } + } + const frontmatter = markdown.slice(3, end).trim() + const body = markdown.slice(end + 4).trimStart() + const attributes: Record = {} + for (const line of frontmatter.split(/\r?\n/)) { + const match = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/) + if (!match) continue + const key = match[1].trim() + const value = match[2]?.trim() ?? "" + attributes[key] = value + } + return { attributes, body } +} + +async function fetchJson(url: string): Promise { + const response = await fetch(url, { + headers: { "User-Agent": "NomadArch-Skills" }, + }) + if (!response.ok) { + const message = await response.text().catch(() => "") + throw new Error(message || `Request failed (${response.status})`) + } + return (await response.json()) as T +} + +async function fetchText(url: string): Promise { + const response = await fetch(url, { + headers: { "User-Agent": "NomadArch-Skills" }, + }) + if (!response.ok) { + const message = await response.text().catch(() => "") + throw new Error(message || `Request failed (${response.status})`) + } + return await response.text() +} + +async function fetchCatalog(): Promise { + const url = `https://api.github.com/repos/${SKILLS_OWNER}/${SKILLS_REPO}/contents/${SKILLS_ROOT}?ref=${SKILLS_BRANCH}` + const entries = await fetchJson(url) + const directories = entries.filter((entry) => entry.type === "dir") + const results: SkillDescriptor[] = [] + + for (const dir of directories) { + try { + const skill = await fetchSkillDetail(dir.name) + results.push({ id: skill.id, name: skill.name, description: skill.description }) + } catch { + results.push({ id: dir.name, name: dir.name, description: "" }) + } + } + + return results +} + +async function fetchSkillDetail(id: string): Promise { + const markdownUrl = `https://raw.githubusercontent.com/${SKILLS_OWNER}/${SKILLS_REPO}/${SKILLS_BRANCH}/${SKILLS_ROOT}/${id}/SKILL.md` + const markdown = await fetchText(markdownUrl) + const parsed = parseFrontmatter(markdown) + const name = parsed.attributes.name || id + const description = parsed.attributes.description || "" + return { + id, + name, + description, + content: parsed.body.trim(), + } +} + +async function getCatalogCached(): Promise { + const now = Date.now() + if (catalogCache && now - catalogCache.fetchedAt < CATALOG_TTL_MS) { + return catalogCache.skills + } + const skills = await fetchCatalog() + catalogCache = { skills, fetchedAt: now } + return skills +} + +async function getDetailCached(id: string): Promise { + const now = Date.now() + const cached = detailCache.get(id) + if (cached && now - cached.fetchedAt < DETAIL_TTL_MS) { + return cached.detail + } + const detail = await fetchSkillDetail(id) + detailCache.set(id, { detail, fetchedAt: now }) + return detail +} + +export async function registerSkillsRoutes(app: FastifyInstance) { + app.get("/api/skills/catalog", async (): Promise => { + const skills = await getCatalogCached() + return { skills } + }) + + app.get<{ Querystring: { id?: string } }>("/api/skills/detail", async (request, reply): Promise => { + const query = z.object({ id: z.string().min(1) }).parse(request.query ?? {}) + try { + return await getDetailCached(query.id) + } catch (error) { + request.log.error({ err: error, skillId: query.id }, "Failed to fetch skill detail") + reply.code(502) + return { + id: query.id, + name: query.id, + description: "", + content: "Unable to load skill content.", + } + } + }) +} diff --git a/packages/server/src/server/routes/workspaces.ts b/packages/server/src/server/routes/workspaces.ts index 1541475..dce5fd8 100644 --- a/packages/server/src/server/routes/workspaces.ts +++ b/packages/server/src/server/routes/workspaces.ts @@ -1,4 +1,5 @@ import { FastifyInstance, FastifyReply } from "fastify" +import { spawnSync } from "child_process" import { z } from "zod" import { WorkspaceManager } from "../../workspaces/manager" @@ -100,6 +101,68 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) { return handleWorkspaceError(error, reply) } }) + + app.get<{ Params: { id: string } }>("/api/workspaces/:id/git/status", async (request, reply) => { + const workspace = deps.workspaceManager.get(request.params.id) + if (!workspace) { + reply.code(404) + return { error: "Workspace not found" } + } + + const result = spawnSync("git", ["-C", workspace.path, "status", "--porcelain=v1", "-b"], { encoding: "utf8" }) + if (result.error) { + return { + isRepo: false, + branch: null, + ahead: 0, + behind: 0, + changes: [], + error: result.error.message, + } + } + + if (result.status !== 0) { + const stderr = (result.stderr || "").toLowerCase() + if (stderr.includes("not a git repository")) { + return { isRepo: false, branch: null, ahead: 0, behind: 0, changes: [] } + } + reply.code(400) + return { + isRepo: false, + branch: null, + ahead: 0, + behind: 0, + changes: [], + error: result.stderr || "Unable to read git status", + } + } + + const lines = (result.stdout || "").split(/\r?\n/).filter((line) => line.trim().length > 0) + let branch: string | null = null + let ahead = 0 + let behind = 0 + const changes: Array<{ path: string; status: string }> = [] + + for (const line of lines) { + if (line.startsWith("##")) { + const header = line.replace(/^##\s*/, "") + const [branchPart, trackingPart] = header.split("...") + branch = branchPart?.trim() || null + const tracking = trackingPart || "" + const aheadMatch = tracking.match(/ahead\s+(\d+)/) + const behindMatch = tracking.match(/behind\s+(\d+)/) + ahead = aheadMatch ? Number(aheadMatch[1]) : 0 + behind = behindMatch ? Number(behindMatch[1]) : 0 + continue + } + + const status = line.slice(0, 2).trim() || line.slice(0, 2) + const path = line.slice(3).trim() + changes.push({ path, status }) + } + + return { isRepo: true, branch, ahead, behind, changes } + }) } diff --git a/packages/ui/src/components/instance/instance-shell2.tsx b/packages/ui/src/components/instance/instance-shell2.tsx index f11aaa7..68d54b6 100644 --- a/packages/ui/src/components/instance/instance-shell2.tsx +++ b/packages/ui/src/components/instance/instance-shell2.tsx @@ -33,6 +33,7 @@ import { activeSessionId as activeSessionMap, getSessionFamily, getSessionInfo, + sessions, setActiveSession, executeCustomCommand, runShellCommand, @@ -60,10 +61,11 @@ import SessionView from "../session/session-view" import { Sidebar, type FileNode } from "./sidebar" import { Editor } from "./editor" import { serverApi } from "../../lib/api-client" -import { Sparkles, Layout as LayoutIcon, Terminal as TerminalIcon, Search, Loader2, Zap, Shield } from "lucide-solid" +import { Sparkles, Layout as LayoutIcon, Terminal as TerminalIcon, Search, Loader2, Zap, Shield, Settings } from "lucide-solid" import { formatTokenTotal } from "../../lib/formatters" import { sseManager } from "../../lib/sse-manager" import { getLogger } from "../../lib/logger" +import AdvancedSettingsModal from "../advanced-settings-modal" import { getSoloState, toggleAutonomous, @@ -138,12 +140,19 @@ const InstanceShell2: Component = (props) => { const [rightDrawerContentEl, setRightDrawerContentEl] = createSignal(null) const [leftToggleButtonEl, setLeftToggleButtonEl] = createSignal(null) const [rightToggleButtonEl, setRightToggleButtonEl] = createSignal(null) - const [activeResizeSide, setActiveResizeSide] = createSignal<"left" | "right" | null>(null) + const [activeResizeSide, setActiveResizeSide] = createSignal<"left" | "right" | "chat" | "terminal" | null>(null) const [resizeStartX, setResizeStartX] = createSignal(0) const [resizeStartWidth, setResizeStartWidth] = createSignal(0) + const [resizeStartY, setResizeStartY] = createSignal(0) + const [resizeStartHeight, setResizeStartHeight] = createSignal(0) + const [chatPanelWidth, setChatPanelWidth] = createSignal(600) + const [terminalPanelHeight, setTerminalPanelHeight] = createSignal(200) + const [terminalOpen, setTerminalOpen] = createSignal(false) const [rightPanelExpandedItems, setRightPanelExpandedItems] = createSignal(["lsp", "mcp", "plan"]) const [currentFile, setCurrentFile] = createSignal(null) const [isSoloOpen, setIsSoloOpen] = createSignal(true) + const [showAdvancedSettings, setShowAdvancedSettings] = createSignal(false) + const [selectedBinary, setSelectedBinary] = createSignal("opencode") // Handler to load file content when selected const handleFileSelect = async (file: FileNode) => { @@ -264,6 +273,17 @@ const InstanceShell2: Component = (props) => { onCleanup(() => window.removeEventListener(SESSION_SIDEBAR_EVENT, handler)) }) + onMount(() => { + if (typeof window === "undefined") return + const handler = (event: Event) => { + const detail = (event as CustomEvent<{ instanceId: string; sessionId: string }>).detail + if (!detail || detail.instanceId !== props.instance.id) return + setShowAdvancedSettings(true) + } + window.addEventListener("open-advanced-settings", handler) + onCleanup(() => window.removeEventListener("open-advanced-settings", handler)) + }) + createEffect(() => { if (typeof window === "undefined") return window.localStorage.setItem(LEFT_DRAWER_STORAGE_KEY, sessionSidebarWidth().toString()) @@ -297,7 +317,8 @@ const InstanceShell2: Component = (props) => { const activeSessionForInstance = createMemo(() => { const sessionId = activeSessionIdForInstance() if (!sessionId || sessionId === "info") return null - return activeSessions().get(sessionId) ?? null + const instanceSessions = sessions().get(props.instance.id) + return instanceSessions?.get(sessionId) ?? null }) const activeSessionUsage = createMemo(() => { @@ -626,14 +647,35 @@ const InstanceShell2: Component = (props) => { scheduleDrawerMeasure() } - const handleDrawerPointerMove = (clientX: number) => { + const applyPanelSize = (type: "chat" | "terminal", size: number) => { + if (type === "chat") { + setChatPanelWidth(size) + } else { + setTerminalPanelHeight(size) + } + } + + const handlePointerMove = (clientX: number, clientY: number) => { const side = activeResizeSide() if (!side) return - const startWidth = resizeStartWidth() - const clamp = side === "left" ? clampWidth : clampRightWidth - const delta = side === "left" ? clientX - resizeStartX() : resizeStartX() - clientX - const nextWidth = clamp(startWidth + delta) - applyDrawerWidth(side, nextWidth) + + if (side === "left" || side === "right") { + const startWidth = resizeStartWidth() + const clamp = side === "left" ? clampWidth : clampRightWidth + const delta = side === "left" ? clientX - resizeStartX() : resizeStartX() - clientX + const nextWidth = clamp(startWidth + delta) + applyDrawerWidth(side, nextWidth) + } else if (side === "chat") { + const startWidth = resizeStartWidth() + const delta = resizeStartX() - clientX // Dragging left increases width + const nextWidth = Math.max(300, Math.min(window.innerWidth - 300, startWidth + delta)) + applyPanelSize("chat", nextWidth) + } else if (side === "terminal") { + const startHeight = resizeStartHeight() + const delta = resizeStartY() - clientY // Dragging up increases height + const nextHeight = Math.max(100, Math.min(window.innerHeight - 200, startHeight + delta)) + applyPanelSize("terminal", nextHeight) + } } function stopDrawerResize() { @@ -646,7 +688,7 @@ const InstanceShell2: Component = (props) => { function drawerMouseMove(event: MouseEvent) { event.preventDefault() - handleDrawerPointerMove(event.clientX) + handlePointerMove(event.clientX, event.clientY) } function drawerMouseUp() { @@ -657,33 +699,39 @@ const InstanceShell2: Component = (props) => { const touch = event.touches[0] if (!touch) return event.preventDefault() - handleDrawerPointerMove(touch.clientX) + handlePointerMove(touch.clientX, touch.clientY) } function drawerTouchEnd() { stopDrawerResize() } - const startDrawerResize = (side: "left" | "right", clientX: number) => { + const startResize = (side: "left" | "right" | "chat" | "terminal", clientX: number, clientY: number) => { setActiveResizeSide(side) setResizeStartX(clientX) - setResizeStartWidth(side === "left" ? sessionSidebarWidth() : rightDrawerWidth()) + setResizeStartY(clientY) + + if (side === "left") setResizeStartWidth(sessionSidebarWidth()) + else if (side === "right") setResizeStartWidth(rightDrawerWidth()) + else if (side === "chat") setResizeStartWidth(chatPanelWidth()) + else if (side === "terminal") setResizeStartHeight(terminalPanelHeight()) + document.addEventListener("mousemove", drawerMouseMove) document.addEventListener("mouseup", drawerMouseUp) document.addEventListener("touchmove", drawerTouchMove, { passive: false }) document.addEventListener("touchend", drawerTouchEnd) } - const handleDrawerResizeMouseDown = (side: "left" | "right") => (event: MouseEvent) => { + const handleResizeMouseDown = (side: "left" | "right" | "chat" | "terminal") => (event: MouseEvent) => { event.preventDefault() - startDrawerResize(side, event.clientX) + startResize(side, event.clientX, event.clientY) } const handleDrawerResizeTouchStart = (side: "left" | "right") => (event: TouchEvent) => { const touch = event.touches[0] if (!touch) return event.preventDefault() - startDrawerResize(side, touch.clientX) + startResize(side, touch.clientX, touch.clientY) } onCleanup(() => { @@ -850,6 +898,10 @@ const InstanceShell2: Component = (props) => { sessions={Array.from(activeSessions().values())} activeSessionId={activeSessionIdForInstance() || undefined} onSessionSelect={handleSessionSelect} + onOpenCommandPalette={handleCommandPaletteClick} + onToggleTerminal={() => setTerminalOpen((current) => !current)} + isTerminalOpen={terminalOpen()} + onOpenAdvancedSettings={() => setShowAdvancedSettings(true)} /> ) @@ -858,7 +910,7 @@ const InstanceShell2: Component = (props) => { if (sessionId && sessionId !== "info") { return ( -
+
) @@ -990,7 +1042,7 @@ const InstanceShell2: Component = (props) => { > {renderRightPanel()} @@ -1350,6 +1452,12 @@ const InstanceShell2: Component = (props) => { commands={instancePaletteCommands()} onExecute={props.onExecuteCommand} /> + setShowAdvancedSettings(false)} + selectedBinary={selectedBinary()} + onBinaryChange={(binary) => setSelectedBinary(binary)} + /> ) } diff --git a/packages/ui/src/components/instance/sidebar.tsx b/packages/ui/src/components/instance/sidebar.tsx index abf8efb..f899a43 100644 --- a/packages/ui/src/components/instance/sidebar.tsx +++ b/packages/ui/src/components/instance/sidebar.tsx @@ -1,10 +1,12 @@ -import { Component, createSignal, For, Show, createEffect } from "solid-js" +import { Component, createSignal, For, Show, createEffect, createMemo } from "solid-js" import { Files, Search, GitBranch, Play, Settings, + Plug, + Sparkles, ChevronRight, ChevronDown, Folder, @@ -15,6 +17,9 @@ import { Image as ImageIcon, } from "lucide-solid" import { serverApi } from "../../lib/api-client" +import InstanceServiceStatus from "../instance-service-status" +import { catalog, catalogLoading, catalogError, loadCatalog } from "../../stores/skills" +import { getSessionSkills, setSessionSkills } from "../../stores/session-state" export interface FileNode { name: string @@ -32,6 +37,10 @@ interface SidebarProps { sessions: any[] // Existing sessions to display in one of the tabs activeSessionId?: string onSessionSelect: (id: string) => void + onOpenCommandPalette?: () => void + onToggleTerminal?: () => void + isTerminalOpen?: boolean + onOpenAdvancedSettings?: () => void } const getFileIcon = (fileName: string) => { @@ -119,6 +128,19 @@ const FileTree: Component<{ export const Sidebar: Component = (props) => { const [activeTab, setActiveTab] = createSignal("files") const [rootFiles, setRootFiles] = createSignal([]) + const [searchQuery, setSearchQuery] = createSignal("") + const [searchResults, setSearchResults] = createSignal([]) + const [searchLoading, setSearchLoading] = createSignal(false) + const [gitStatus, setGitStatus] = createSignal<{ + isRepo: boolean + branch: string | null + ahead: number + behind: number + changes: Array<{ path: string; status: string }> + error?: string + } | null>(null) + const [gitLoading, setGitLoading] = createSignal(false) + const [skillsFilter, setSkillsFilter] = createSignal("") createEffect(async () => { if (props.instanceId) { @@ -135,6 +157,89 @@ export const Sidebar: Component = (props) => { } }) + createEffect(() => { + if (activeTab() === "skills") { + loadCatalog() + } + }) + + const filteredSkills = createMemo(() => { + const term = skillsFilter().trim().toLowerCase() + if (!term) return catalog() + return catalog().filter((skill) => { + const name = skill.name?.toLowerCase() ?? "" + const description = skill.description?.toLowerCase() ?? "" + return name.includes(term) || description.includes(term) || skill.id.toLowerCase().includes(term) + }) + }) + + const selectedSkills = createMemo(() => { + if (!props.activeSessionId) return [] + return getSessionSkills(props.instanceId, props.activeSessionId) + }) + + const toggleSkillSelection = (skillId: string) => { + if (!props.activeSessionId) return + const current = selectedSkills() + const exists = current.some((skill) => skill.id === skillId) + const next = exists + ? current.filter((skill) => skill.id !== skillId) + : (() => { + const found = catalog().find((skill) => skill.id === skillId) + if (!found) return current + return [...current, { id: found.id, name: found.name, description: found.description }] + })() + setSessionSkills(props.instanceId, props.activeSessionId, next) + } + + const handleSearch = async () => { + const query = searchQuery().trim() + if (!query) { + setSearchResults([]) + return + } + setSearchLoading(true) + try { + const results = await serverApi.searchWorkspaceFiles(props.instanceId, query, { limit: 50, type: "all" }) + setSearchResults( + results.map((entry) => ({ + name: entry.name, + type: entry.type, + path: entry.path, + })), + ) + } catch (error) { + console.error("Failed to search files", error) + } finally { + setSearchLoading(false) + } + } + + const refreshGitStatus = async () => { + setGitLoading(true) + try { + const status = await serverApi.fetchWorkspaceGitStatus(props.instanceId) + setGitStatus(status) + } catch (error) { + setGitStatus({ + isRepo: false, + branch: null, + ahead: 0, + behind: 0, + changes: [], + error: error instanceof Error ? error.message : "Unable to load git status", + }) + } finally { + setGitLoading(false) + } + } + + createEffect(() => { + if (activeTab() === "git") { + refreshGitStatus() + } + }) + return (
= (props) => { { id: "search", icon: Search }, { id: "git", icon: GitBranch }, { id: "debug", icon: Play }, + { id: "mcp", icon: Plug }, + { id: "skills", icon: Sparkles }, + { id: "settings", icon: Settings }, ]} > {(item) => ( @@ -162,11 +270,6 @@ export const Sidebar: Component = (props) => { )} -
- -
{/* Side Pane */} @@ -196,6 +299,187 @@ export const Sidebar: Component = (props) => {
+ +
+
+ setSearchQuery(event.currentTarget.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + handleSearch() + } + }} + placeholder="Search files..." + class="flex-1 rounded-md bg-white/5 border border-white/10 px-3 py-2 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/60" + /> + +
+ +
Searching...
+
+ 0}> +
No results found.
+
+
+ + {(result) => ( +
props.onFileSelect(result)} + class="flex items-center gap-2 px-3 py-2 text-xs text-zinc-300 rounded-md hover:bg-white/5 cursor-pointer" + > + {result.type === "directory" ? "DIR" : "FILE"} + {result.path} +
+ )} +
+
+
+
+ +
+
+ Repository Status + +
+ +
Loading git status...
+
+ + {(status) => ( +
+ +
+ {status().error ? `Git unavailable: ${status().error}` : "No git repository detected."} +
+
+ +
+
+ {status().branch || "Detached"} + + {status().ahead ? `↑${status().ahead}` : ""} + {status().behind ? ` ↓${status().behind}` : ""} + +
+
+ {status().changes.length} change{status().changes.length === 1 ? "" : "s"} +
+
+
+ + {(change) => ( +
+ {change.status} + {change.path} +
+ )} +
+
+
+
+ )} +
+
+
+ +
+
Tools
+ + +
+
+ +
+
MCP Servers
+ +
+
+ +
+
+ Skills + + {selectedSkills().length} selected + +
+ +
Select a session to assign skills.
+
+ setSkillsFilter(event.currentTarget.value)} + placeholder="Filter skills..." + class="rounded-md bg-white/5 border border-white/10 px-3 py-2 text-xs text-zinc-200 focus:outline-none focus:border-blue-500/60" + /> + +
Loading skills...
+
+ + {(error) =>
{error()}
} +
+
+ + {(skill) => { + const isSelected = () => selectedSkills().some((item) => item.id === skill.id) + return ( + + ) + }} + +
+
+
+ +
+
Settings
+ + +
+
diff --git a/packages/ui/src/lib/api-client.ts b/packages/ui/src/lib/api-client.ts index d4509cb..5952f0d 100644 --- a/packages/ui/src/lib/api-client.ts +++ b/packages/ui/src/lib/api-client.ts @@ -8,6 +8,8 @@ import type { FileSystemListResponse, InstanceData, ServerMeta, + SkillCatalogResponse, + SkillDetail, WorkspaceCreateRequest, WorkspaceDescriptor, WorkspaceFileResponse, @@ -16,6 +18,7 @@ import type { WorkspaceLogEntry, WorkspaceEventPayload, WorkspaceEventType, + WorkspaceGitStatus, } from "../../../server/src/api-types" import { getLogger } from "./logger" @@ -152,6 +155,9 @@ export const serverApi = { `/api/workspaces/${encodeURIComponent(id)}/files/content?${params.toString()}`, ) }, + fetchWorkspaceGitStatus(id: string): Promise { + return request(`/api/workspaces/${encodeURIComponent(id)}/git/status`) + }, fetchConfig(): Promise { return request("/api/config/app") @@ -228,6 +234,13 @@ export const serverApi = { } return source }, + fetchSkillsCatalog(): Promise { + return request("/api/skills/catalog") + }, + fetchSkillDetail(id: string): Promise { + const params = new URLSearchParams({ id }) + return request(`/api/skills/detail?${params.toString()}`) + }, } export type { WorkspaceDescriptor, WorkspaceLogEntry, WorkspaceEventPayload, WorkspaceEventType } diff --git a/packages/ui/src/stores/instance-config.tsx b/packages/ui/src/stores/instance-config.tsx index b87cc00..869371b 100644 --- a/packages/ui/src/stores/instance-config.tsx +++ b/packages/ui/src/stores/instance-config.tsx @@ -5,7 +5,12 @@ import { getLogger } from "../lib/logger" const log = getLogger("api") -const DEFAULT_INSTANCE_DATA: InstanceData = { messageHistory: [], agentModelSelections: {}, sessionTasks: {} } +const DEFAULT_INSTANCE_DATA: InstanceData = { + messageHistory: [], + agentModelSelections: {}, + sessionTasks: {}, + sessionSkills: {}, +} const [instanceDataMap, setInstanceDataMap] = createSignal>(new Map()) const loadPromises = new Map>() @@ -18,6 +23,7 @@ function cloneInstanceData(data?: InstanceData | null): InstanceData { messageHistory: Array.isArray(source.messageHistory) ? [...source.messageHistory] : [], agentModelSelections: { ...(source.agentModelSelections ?? {}) }, sessionTasks: { ...(source.sessionTasks ?? {}) }, + sessionSkills: { ...(source.sessionSkills ?? {}) }, } } diff --git a/packages/ui/src/stores/session-actions.ts b/packages/ui/src/stores/session-actions.ts index d1dd63b..ebd10de 100644 --- a/packages/ui/src/stores/session-actions.ts +++ b/packages/ui/src/stores/session-actions.ts @@ -14,6 +14,7 @@ import { createSession, loadMessages } from "./session-api" import { showToastNotification } from "../lib/notifications" import { showConfirmDialog } from "./alerts" import { QwenOAuthManager } from "../lib/integrations/qwen-oauth" +import { loadSkillDetails } from "./skills" const log = getLogger("actions") @@ -255,6 +256,46 @@ function buildLanguageSystemInstruction(prompt: string): string | undefined { return "Respond in English unless the user explicitly requests another language." } +function clampText(value: string, maxChars: number): string { + if (value.length <= maxChars) return value + return `${value.slice(0, Math.max(0, maxChars - 3))}...` +} + +async function buildSkillsSystemInstruction(instanceId: string, sessionId: string): Promise { + const session = sessions().get(instanceId)?.get(sessionId) + const selected = session?.skills ?? [] + if (selected.length === 0) return undefined + + const details = await loadSkillDetails(selected.map((skill) => skill.id)) + if (details.length === 0) return undefined + + const sections: string[] = [] + for (const detail of details) { + const header = detail.name ? `# Skill: ${detail.name}` : `# Skill: ${detail.id}` + const content = detail.content ? clampText(detail.content.trim(), 4000) : "" + sections.push(`${header}\n${content}`.trim()) + } + + const payload = sections.join("\n\n") + if (!payload) return undefined + return `You have access to the following skills. Follow their instructions when relevant.\n\n${payload}` +} + +async function mergeSystemInstructions( + instanceId: string, + sessionId: string, + prompt: string, +): Promise { + const [languageSystem, skillsSystem] = await Promise.all([ + Promise.resolve(buildLanguageSystemInstruction(prompt)), + buildSkillsSystemInstruction(instanceId, sessionId), + ]) + if (languageSystem && skillsSystem) { + return `${languageSystem}\n\n${skillsSystem}` + } + return languageSystem || skillsSystem +} + function extractPlainTextFromParts(parts: Array<{ type?: string; text?: unknown; filename?: string }>): string { const segments: string[] = [] for (const part of parts) { @@ -821,7 +862,7 @@ async function sendMessage( }) const providerId = effectiveModel.providerId - const languageSystem = buildLanguageSystemInstruction(prompt) + const systemMessage = await mergeSystemInstructions(instanceId, sessionId, prompt) if (providerId === "ollama-cloud" || providerId === "qwen-oauth" || providerId === "opencode-zen" || providerId === "zai") { const store = messageStoreBus.getOrCreate(instanceId) const now = Date.now() @@ -861,7 +902,7 @@ async function sendMessage( sessionId, providerId, effectiveModel.modelId, - languageSystem, + systemMessage, messageId, assistantMessageId, assistantPartId, @@ -872,7 +913,7 @@ async function sendMessage( sessionId, providerId, effectiveModel.modelId, - languageSystem, + systemMessage, messageId, assistantMessageId, assistantPartId, @@ -883,7 +924,7 @@ async function sendMessage( sessionId, providerId, effectiveModel.modelId, - languageSystem, + systemMessage, messageId, assistantMessageId, assistantPartId, @@ -921,7 +962,7 @@ async function sendMessage( sessionId, providerId, effectiveModel.modelId, - languageSystem, + systemMessage, token.access_token, token.resource_url, messageId, @@ -989,7 +1030,7 @@ async function sendMessage( modelID: effectiveModel.modelId, }, }), - ...(languageSystem && { system: languageSystem }), + ...(systemMessage && { system: systemMessage }), } log.info("sendMessage", { diff --git a/packages/ui/src/stores/session-api.ts b/packages/ui/src/stores/session-api.ts index 1edbf43..4f6a18f 100644 --- a/packages/ui/src/stores/session-api.ts +++ b/packages/ui/src/stores/session-api.ts @@ -1,8 +1,8 @@ -import type { Session } from "../types/session" +import type { Session, Provider, Model } from "../types/session" import type { Message } from "../types/message" import { instances } from "./instances" -import { preferences, setAgentModelPreference } from "./preferences" +import { preferences, setAgentModelPreference, getAgentModelPreference } from "./preferences" import { setSessionCompactionState } from "./session-compaction" import { activeSessionId, @@ -32,9 +32,247 @@ import { seedSessionMessagesV2 } from "./message-v2/bridge" import { messageStoreBus } from "./message-v2/bus" import { clearCacheForSession } from "../lib/global-cache" import { getLogger } from "../lib/logger" +import { showToastNotification } from "../lib/notifications" const log = getLogger("api") +type ProviderMap = Map + +async function fetchJson(url: string): Promise { + try { + const response = await fetch(url) + if (!response.ok) return null + return (await response.json()) as T + } catch (error) { + log.warn("Failed to fetch provider data", { url, error }) + return null + } +} + +function mergeProviders(base: Provider[], extras: Provider[]): Provider[] { + if (extras.length === 0) return base + const map: ProviderMap = new Map(base.map((provider) => [provider.id, { ...provider }])) + + for (const extra of extras) { + const existing = map.get(extra.id) + if (!existing) { + map.set(extra.id, extra) + continue + } + + const modelMap = new Map(existing.models.map((model) => [model.id, model])) + for (const model of extra.models) { + if (!modelMap.has(model.id)) { + modelMap.set(model.id, model) + } + } + existing.models = Array.from(modelMap.values()) + } + + return Array.from(map.values()) +} + +const OLLAMA_TOAST_COOLDOWN_MS = 30000 +let lastOllamaToastAt = 0 +let lastOllamaToastKey = "" + +function showOllamaToastOnce(key: string, payload: Parameters[0]) { + const now = Date.now() + if (lastOllamaToastKey === key && now - lastOllamaToastAt < OLLAMA_TOAST_COOLDOWN_MS) { + return + } + lastOllamaToastKey = key + lastOllamaToastAt = now + showToastNotification(payload) +} + +async function fetchOllamaCloudProvider(): Promise { + try { + const config = await fetchJson<{ config?: { enabled?: boolean } }>("/api/ollama/config") + if (config && config.config?.enabled === false) { + return null + } + + const response = await fetch("/api/ollama/models") + if (!response.ok) { + const errorText = await response.text().catch(() => "") + showOllamaToastOnce(`ollama-unavailable-${response.status}`, { + title: "Ollama Cloud unavailable", + message: errorText + ? `Unable to load Ollama Cloud models (${response.status}). ${errorText}` + : "Unable to load Ollama Cloud models. Check that the integration is enabled and the API key is valid.", + variant: "warning", + duration: 8000, + }) + return null + } + const data = (await response.json()) as { models?: Array<{ name?: string } | string> } + const models = Array.isArray(data?.models) ? data.models : [] + const modelNames = models + .map((model) => (typeof model === "string" ? model : model?.name)) + .filter((name): name is string => Boolean(name)) + if (modelNames.length === 0) { + showOllamaToastOnce("ollama-empty", { + title: "Ollama Cloud models unavailable", + message: "Ollama Cloud returned no models. Check your API key and endpoint.", + variant: "warning", + duration: 8000, + }) + return null + } + + return { + id: "ollama-cloud", + name: "Ollama Cloud", + models: modelNames.map((name) => ({ + id: name, + name, + providerId: "ollama-cloud", + })), + } + } catch (error) { + log.warn("Failed to fetch Ollama Cloud models", { error }) + showOllamaToastOnce("ollama-fetch-error", { + title: "Ollama Cloud unavailable", + message: "Unable to load Ollama Cloud models. Check that the integration is enabled and the API key is valid.", + variant: "warning", + duration: 8000, + }) + return null + } +} + +function getStoredQwenToken(): + | { access_token: string; expires_in: number; created_at: number } + | null { + if (typeof window === "undefined") return null + try { + const raw = window.localStorage.getItem("qwen_oauth_token") + if (!raw) return null + return JSON.parse(raw) + } catch { + return null + } +} + +function isQwenTokenValid(token: { expires_in: number; created_at: number } | null): boolean { + if (!token) return false + const createdAt = token.created_at > 1e12 ? Math.floor(token.created_at / 1000) : token.created_at + const expiresAt = (createdAt + token.expires_in) * 1000 - 300000 + return Date.now() < expiresAt +} + +async function fetchQwenOAuthProvider(): Promise { + const token = getStoredQwenToken() + if (!isQwenTokenValid(token)) return null + + // Use actual Qwen model IDs that work with the DashScope API + const qwenModels: Model[] = [ + { + id: "qwen-coder-plus-latest", + name: "Qwen Coder Plus (OAuth)", + providerId: "qwen-oauth", + limit: { context: 131072, output: 16384 }, + }, + { + id: "qwen-turbo-latest", + name: "Qwen Turbo (OAuth)", + providerId: "qwen-oauth", + limit: { context: 131072, output: 8192 }, + }, + { + id: "qwen-plus-latest", + name: "Qwen Plus (OAuth)", + providerId: "qwen-oauth", + limit: { context: 131072, output: 8192 }, + }, + { + id: "qwen-max-latest", + name: "Qwen Max (OAuth)", + providerId: "qwen-oauth", + limit: { context: 32768, output: 8192 }, + }, + ] + + return { + id: "qwen-oauth", + name: "Qwen OAuth", + models: qwenModels, + defaultModelId: "qwen-coder-plus-latest", + } +} + +async function fetchOpenCodeZenProvider(): Promise { + const data = await fetchJson<{ models?: Array<{ id: string; name: string; limit?: Model["limit"]; cost?: Model["cost"] }> }>( + "/api/opencode-zen/models", + ) + const models = Array.isArray(data?.models) ? data?.models ?? [] : [] + if (models.length === 0) return null + + return { + id: "opencode-zen", + name: "OpenCode Zen", + models: models.map((model) => ({ + id: model.id, + name: model.name, + providerId: "opencode-zen", + limit: model.limit, + cost: model.cost, + })), + } +} + +async function fetchZAIProvider(): Promise { + try { + const config = await fetchJson<{ config?: { enabled?: boolean } }>("/api/zai/config") + if (!config?.config?.enabled) return null + + const data = await fetchJson<{ models?: Array<{ name: string; provider: string }> }>("/api/zai/models") + const models = Array.isArray(data?.models) ? data.models : [] + if (models.length === 0) return null + + return { + id: "zai", + name: "Z.AI Coding Plan", + models: models.map((model) => ({ + id: model.name, + name: model.name.toUpperCase(), + providerId: "zai", + limit: { context: 131072, output: 8192 }, + })), + defaultModelId: "glm-4.7", + } + } catch (error) { + log.warn("Failed to fetch Z.AI models", { error }) + return null + } +} + +async function fetchExtraProviders(): Promise { + const [ollama, zen, qwen, zai] = await Promise.all([ + fetchOllamaCloudProvider(), + fetchOpenCodeZenProvider(), + fetchQwenOAuthProvider(), + fetchZAIProvider(), + ]) + return [ollama, zen, qwen, zai].filter((provider): provider is Provider => Boolean(provider)) +} + +function removeDuplicateProviders(base: Provider[], extras: Provider[]): Provider[] { + const extraModelIds = new Set(extras.flatMap((provider) => provider.models.map((model) => model.id))) + if (!extras.some((provider) => provider.id === "opencode-zen")) { + return base + } + + return base.filter((provider) => { + if (provider.id === "opencode-zen") return false + if (provider.id === "opencode" && provider.models.every((model) => extraModelIds.has(model.id))) { + return false + } + return true + }) +} + interface SessionForkResponse { id: string title?: string @@ -84,30 +322,38 @@ async function fetchSessions(instanceId: string): Promise { await ensureInstanceConfigLoaded(instanceId) const instanceData = getInstanceConfig(instanceId) const sessionTasks = instanceData.sessionTasks || {} + const sessionSkills = instanceData.sessionSkills || {} for (const apiSession of response.data) { const existingSession = existingSessions?.get(apiSession.id) + const existingModel = existingSession?.model ?? { providerId: "", modelId: "" } + const hasUserSelectedModel = existingModel.providerId && existingModel.modelId + const apiModel = (apiSession as any).model?.providerID && (apiSession as any).model?.modelID + ? { providerId: (apiSession as any).model.providerID, modelId: (apiSession as any).model.modelID } + : { providerId: "", modelId: "" } + sessionMap.set(apiSession.id, { id: apiSession.id, instanceId, title: apiSession.title || "Untitled", parentId: apiSession.parentID || null, - agent: existingSession?.agent ?? "", - model: existingSession?.model ?? { providerId: "", modelId: "" }, + agent: existingSession?.agent ?? (apiSession as any).agent ?? "", + model: hasUserSelectedModel ? existingModel : apiModel, version: apiSession.version, time: { ...apiSession.time, }, revert: apiSession.revert ? { - messageID: apiSession.revert.messageID, - partID: apiSession.revert.partID, - snapshot: apiSession.revert.snapshot, - diff: apiSession.revert.diff, - } + messageID: apiSession.revert.messageID, + partID: apiSession.revert.partID, + snapshot: apiSession.revert.snapshot, + diff: apiSession.revert.diff, + } : undefined, tasks: sessionTasks[apiSession.id] || [], + skills: sessionSkills[apiSession.id] || [], }) } @@ -153,7 +399,11 @@ async function fetchSessions(instanceId: string): Promise { } } -async function createSession(instanceId: string, agent?: string): Promise { +async function createSession( + instanceId: string, + agent?: string, + options?: { skipAutoCleanup?: boolean }, +): Promise { const instance = instances().get(instanceId) if (!instance || !instance.client) { throw new Error("Instance not ready") @@ -163,10 +413,12 @@ async function createSession(instanceId: string, agent?: string): Promise a.mode !== "subagent") const selectedAgent = agent || (nonSubagents.length > 0 ? nonSubagents[0].name : "") + const agentModelPreference = await getAgentModelPreference(instanceId, selectedAgent) const defaultModel = await getDefaultModel(instanceId, selectedAgent) + const sessionModel = agentModelPreference || defaultModel - if (selectedAgent && isModelValid(instanceId, defaultModel)) { - await setAgentModelPreference(instanceId, selectedAgent, defaultModel) + if (selectedAgent && isModelValid(instanceId, sessionModel) && !agentModelPreference) { + await setAgentModelPreference(instanceId, selectedAgent, sessionModel) } setLoading((prev) => { @@ -189,18 +441,19 @@ async function createSession(instanceId: string, agent?: string): Promise { mode: agent.mode, model: agent.model?.modelID ? { - providerId: agent.model.providerID || "", - modelId: agent.model.modelID, - } + providerId: agent.model.providerID || "", + modelId: agent.model.modelID, + } : undefined, })) @@ -477,9 +737,15 @@ async function fetchProviders(instanceId: string): Promise { })), })) + const filteredBaseProviders = providerList.filter((provider) => provider.id !== "zai") + + const extraProviders = await fetchExtraProviders() + const baseProviders = removeDuplicateProviders(filteredBaseProviders, extraProviders) + const mergedProviders = mergeProviders(baseProviders, extraProviders) + setProviders((prev) => { const next = new Map(prev) - next.set(instanceId, providerList) + next.set(instanceId, mergedProviders) return next }) } catch (error) { @@ -588,10 +854,13 @@ async function loadMessages(instanceId: string, sessionId: string, force = false if (nextInstanceSessions) { const existingSession = nextInstanceSessions.get(sessionId) if (existingSession) { + const currentModel = existingSession.model + const hasUserSelectedModel = currentModel.providerId && currentModel.modelId + const updatedSession = { ...existingSession, agent: agentName || existingSession.agent, - model: providerID && modelID ? { providerId: providerID, modelId: modelID } : existingSession.model, + model: hasUserSelectedModel ? currentModel : (providerID && modelID ? { providerId: providerID, modelId: modelID } : currentModel), } const updatedInstanceSessions = new Map(nextInstanceSessions) updatedInstanceSessions.set(sessionId, updatedSession) @@ -611,8 +880,12 @@ async function loadMessages(instanceId: string, sessionId: string, force = false const sessionForV2 = sessions().get(instanceId)?.get(sessionId) ?? { id: sessionId, - title: session?.title, + instanceId, parentId: session?.parentId ?? null, + agent: "", + model: { providerId: "", modelId: "" }, + version: "0", + time: { created: Date.now(), updated: Date.now() }, revert: session?.revert, } seedSessionMessagesV2(instanceId, sessionForV2, messages, messagesInfo) diff --git a/packages/ui/src/stores/session-state.ts b/packages/ui/src/stores/session-state.ts index 9fbcb84..5c743b2 100644 --- a/packages/ui/src/stores/session-state.ts +++ b/packages/ui/src/stores/session-state.ts @@ -1,6 +1,6 @@ import { createSignal } from "solid-js" -import type { Session, Agent, Provider } from "../types/session" +import type { Session, Agent, Provider, SkillSelection } from "../types/session" import { deleteSession, loadMessages } from "./session-api" import { showToastNotification } from "../lib/notifications" import { messageStoreBus } from "./message-v2/bus" @@ -164,14 +164,19 @@ async function persistSessionTasks(instanceId: string) { if (!instanceSessions) return const sessionTasks: Record = {} + const sessionSkills: Record = {} for (const [sessionId, session] of instanceSessions) { if (session.tasks && session.tasks.length > 0) { sessionTasks[sessionId] = session.tasks } + if (session.skills && session.skills.length > 0) { + sessionSkills[sessionId] = session.skills + } } await updateInstanceConfig(instanceId, (draft) => { draft.sessionTasks = sessionTasks + draft.sessionSkills = sessionSkills }) } catch (error) { log.error("Failed to persist session tasks", error) @@ -264,6 +269,17 @@ function getSessionFamily(instanceId: string, parentId: string): Session[] { return [parent, ...children] } +function getSessionSkills(instanceId: string, sessionId: string): SkillSelection[] { + const session = sessions().get(instanceId)?.get(sessionId) + return session?.skills ?? [] +} + +function setSessionSkills(instanceId: string, sessionId: string, skills: SkillSelection[]): void { + withSession(instanceId, sessionId, (session) => { + session.skills = skills + }) +} + function isSessionBusy(instanceId: string, sessionId: string): boolean { const instanceSessions = sessions().get(instanceId) if (!instanceSessions) return false @@ -283,8 +299,13 @@ async function isBlankSession(session: Session, instanceId: string, fetchIfNeede const created = session.time?.created || 0 const updated = session.time?.updated || 0 const hasChildren = getChildSessions(instanceId, session.id).length > 0 + const hasTasks = Boolean(session.tasks && session.tasks.length > 0) const isFreshSession = created === updated && !hasChildren + if (hasTasks) { + return false + } + // Common short-circuit: fresh sessions without children if (!fetchIfNeeded) { return isFreshSession @@ -423,4 +444,6 @@ export { getSessionInfo, isBlankSession, cleanupBlankSessions, + getSessionSkills, + setSessionSkills, } diff --git a/packages/ui/src/stores/skills.ts b/packages/ui/src/stores/skills.ts new file mode 100644 index 0000000..9286930 --- /dev/null +++ b/packages/ui/src/stores/skills.ts @@ -0,0 +1,75 @@ +import { createSignal } from "solid-js" +import type { SkillCatalogResponse, SkillDescriptor, SkillDetail } from "../../../server/src/api-types" +import { serverApi } from "../lib/api-client" +import { getLogger } from "../lib/logger" + +const log = getLogger("skills") + +const [catalog, setCatalog] = createSignal([]) +const [catalogLoading, setCatalogLoading] = createSignal(false) +const [catalogError, setCatalogError] = createSignal(null) + +const detailCache = new Map() +const detailPromises = new Map>() + +async function loadCatalog(): Promise { + if (catalog().length > 0) return catalog() + if (catalogLoading()) return catalog() + setCatalogLoading(true) + setCatalogError(null) + + try { + const response: SkillCatalogResponse = await serverApi.fetchSkillsCatalog() + const skills = Array.isArray(response.skills) ? response.skills : [] + setCatalog(skills) + return skills + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to load skills" + setCatalogError(message) + log.warn("Failed to load skills catalog", error) + return [] + } finally { + setCatalogLoading(false) + } +} + +async function loadSkillDetail(id: string): Promise { + if (!id) return null + if (detailCache.has(id)) return detailCache.get(id) || null + const pending = detailPromises.get(id) + if (pending) return pending + + const promise = serverApi + .fetchSkillDetail(id) + .then((detail) => { + detailCache.set(id, detail) + return detail + }) + .catch((error) => { + log.warn("Failed to load skill detail", { id, error }) + return null + }) + .finally(() => { + detailPromises.delete(id) + }) + + detailPromises.set(id, promise as Promise) + return promise +} + +async function loadSkillDetails(ids: string[]): Promise { + const uniqueIds = Array.from(new Set(ids.filter(Boolean))) + if (uniqueIds.length === 0) return [] + + const results = await Promise.all(uniqueIds.map((id) => loadSkillDetail(id))) + return results.filter((detail): detail is SkillDetail => Boolean(detail)) +} + +export { + catalog, + catalogLoading, + catalogError, + loadCatalog, + loadSkillDetail, + loadSkillDetails, +} diff --git a/packages/ui/src/types/session.ts b/packages/ui/src/types/session.ts index 790edba..6a9725d 100644 --- a/packages/ui/src/types/session.ts +++ b/packages/ui/src/types/session.ts @@ -24,6 +24,13 @@ export interface Task { timestamp: number messageIds?: string[] // IDs of messages associated with this task taskSessionId?: string // Backend session ID for this task + archived?: boolean +} + +export interface SkillSelection { + id: string + name: string + description?: string } // Our client-specific Session interface extending SDK Session @@ -40,6 +47,7 @@ export interface Session pendingPermission?: boolean // Indicates if session is waiting on user permission tasks?: Task[] // Multi-task chat support activeTaskId?: string // Track the currently active task for message isolation + skills?: SkillSelection[] // Selected skills for this session } // Adapter function to convert SDK Session to client Session