diff --git a/README.md b/README.md index 2961d9aa..7e3b1276 100644 --- a/README.md +++ b/README.md @@ -312,7 +312,7 @@ Production-tested best practices loaded into every session. Core rules cover wor
Collaboration -- `team-vault.md` — Team Vault asset sharing via sx +- `team-vault.md` — Team asset sharing via sx (Teams dashboard)
diff --git a/console/src/services/worker-service.ts b/console/src/services/worker-service.ts index 6fb7c083..06e548ef 100644 --- a/console/src/services/worker-service.ts +++ b/console/src/services/worker-service.ts @@ -75,7 +75,7 @@ import { NotificationRoutes } from "./worker/http/routes/NotificationRoutes.js"; import { WorktreeRoutes } from "./worker/http/routes/WorktreeRoutes.js"; import { UsageRoutes } from "./worker/http/routes/UsageRoutes.js"; import { LicenseRoutes } from "./worker/http/routes/LicenseRoutes.js"; -import { VaultRoutes } from "./worker/http/routes/VaultRoutes.js"; +import { TeamsRoutes } from "./worker/http/routes/TeamsRoutes.js"; import { VexorRoutes } from "./worker/http/routes/VexorRoutes.js"; import { SettingsRoutes } from "./worker/http/routes/SettingsRoutes.js"; import { MetricsService } from "./worker/MetricsService.js"; @@ -311,7 +311,7 @@ export class WorkerService { this.server.registerRoutes(new UsageRoutes()); this.server.registerRoutes(new LicenseRoutes()); - this.server.registerRoutes(new VaultRoutes()); + this.server.registerRoutes(new TeamsRoutes()); this.server.registerRoutes(new SettingsRoutes()); startRetentionScheduler(this.dbManager); diff --git a/console/src/services/worker/http/routes/SettingsRoutes.ts b/console/src/services/worker/http/routes/SettingsRoutes.ts index 509d3d3d..e998dc7a 100644 --- a/console/src/services/worker/http/routes/SettingsRoutes.ts +++ b/console/src/services/worker/http/routes/SettingsRoutes.ts @@ -31,7 +31,6 @@ export const DEFAULT_SETTINGS: ModelSettings = { "spec-plan": "opus", "spec-implement": "sonnet", "spec-verify": "sonnet", - vault: "sonnet", sync: "sonnet", learn: "sonnet", }, diff --git a/console/src/services/worker/http/routes/TeamsRoutes.ts b/console/src/services/worker/http/routes/TeamsRoutes.ts new file mode 100644 index 00000000..227e62d0 --- /dev/null +++ b/console/src/services/worker/http/routes/TeamsRoutes.ts @@ -0,0 +1,572 @@ +/** + * TeamsRoutes + * + * API endpoints for sx team asset status and management. + * Invokes the sx CLI via Bun.spawn with timeout and caching. + */ + +import path from "path"; +import { readdirSync, existsSync } from "fs"; +import express, { type Request, type Response } from "express"; +import { BaseRouteHandler } from "../BaseRouteHandler.js"; +import { logger } from "../../../../utils/logger.js"; + +export interface TeamsAsset { + name: string; + version: string; + type: string; + clients: string[]; + status: string; + scope: string; +} + +export interface TeamsCatalogItem { + name: string; + type: string; + latestVersion: string; + versionsCount: number; + updatedAt: string; +} + +export interface TeamsStatusResponse { + installed: boolean; + version: string | null; + configured: boolean; + repoUrl: string | null; + profile: string | null; + assets: TeamsAsset[]; + catalog: TeamsCatalogItem[]; + isInstalling: boolean; +} + +interface TeamsDetailResponse { + name: string; + type: string; + metadata: { + description: string | null; + authors: string[]; + keywords: string[]; + }; + versions: Array<{ + version: string; + createdAt: string | null; + filesCount: number; + }>; +} + +const NAME_REGEX = /^[a-zA-Z0-9-]+$/; +const STATUS_TIMEOUT_MS = 15_000; +const INSTALL_TIMEOUT_MS = 60_000; +const PUSH_TIMEOUT_MS = 30_000; +const INIT_TIMEOUT_MS = 30_000; +const REMOVE_TIMEOUT_MS = 15_000; + +const STATUS_CACHE_TTL_MS = 30_000; +const DETAIL_CACHE_TTL_MS = 60_000; + +export class TeamsRoutes extends BaseRouteHandler { + private statusCache: { data: TeamsStatusResponse; timestamp: number } | null = + null; + private detailCache: Map< + string, + { data: TeamsDetailResponse; timestamp: number } + > = new Map(); + private _isInstalling = false; + + setupRoutes(app: express.Application): void { + app.get("/api/teams/status", this.handleStatus.bind(this)); + app.post("/api/teams/install", this.handleInstall.bind(this)); + app.get("/api/teams/detail/:name", this.handleDetail.bind(this)); + app.post("/api/teams/push", this.handlePush.bind(this)); + app.post("/api/teams/remove", this.handleRemove.bind(this)); + app.post("/api/teams/init", this.handleInit.bind(this)); + app.get("/api/teams/discover", this.handleDiscover.bind(this)); + app.post("/api/teams/update-asset", this.handleUpdateAsset.bind(this)); + } + + private handleStatus = this.wrapHandler( + async (_req: Request, res: Response): Promise => { + if ( + this.statusCache && + Date.now() - this.statusCache.timestamp < STATUS_CACHE_TTL_MS + ) { + res.json({ + ...this.statusCache.data, + isInstalling: this._isInstalling, + }); + return; + } + + const sxPath = this.resolveSxBinary(); + if (!sxPath) { + res.json(this.emptyStatus()); + return; + } + + try { + const [configOutput, catalogOutput] = await Promise.all([ + this.runSxCommand([sxPath, "config", "--json"], STATUS_TIMEOUT_MS), + this.runSxCommand( + [sxPath, "vault", "list", "--json"], + STATUS_TIMEOUT_MS, + ).catch(() => "[]"), + ]); + + const config = JSON.parse(configOutput); + const catalog: TeamsCatalogItem[] = JSON.parse(catalogOutput).map( + (item: any) => ({ + name: item.name, + type: item.type, + latestVersion: item.latestVersion, + versionsCount: item.versionsCount, + updatedAt: item.updatedAt, + }), + ); + + const assets: TeamsAsset[] = []; + for (const scopeGroup of config.assets || []) { + const scope = scopeGroup.scope || "Global"; + for (const asset of scopeGroup.assets || []) { + assets.push({ + name: asset.name, + version: asset.version, + type: asset.type, + clients: asset.clients || [], + status: asset.status || "unknown", + scope, + }); + } + } + + const status: TeamsStatusResponse = { + installed: true, + version: config.version?.version || null, + configured: !!config.config?.repositoryUrl, + repoUrl: config.config?.repositoryUrl || null, + profile: config.config?.profile || null, + assets, + catalog, + isInstalling: this._isInstalling, + }; + + this.statusCache = { data: status, timestamp: Date.now() }; + res.json(status); + } catch (error) { + logger.error("HTTP", "Teams status failed", {}, error as Error); + res.json(this.emptyStatus()); + } + }, + ); + + private handleInstall = this.wrapHandler( + async (_req: Request, res: Response): Promise => { + if (this._isInstalling) { + res.status(409).json({ error: "Installation already in progress" }); + return; + } + + const sxPath = this.resolveSxBinary(); + if (!sxPath) { + res.status(500).json({ error: "sx CLI not found" }); + return; + } + + const projectRoot = process.env.CLAUDE_PROJECT_ROOT || process.cwd(); + + this._isInstalling = true; + this.statusCache = null; + res.json({ started: true }); + + try { + await this.runSxCommand( + [sxPath, "install", "--repair", "--target", projectRoot], + INSTALL_TIMEOUT_MS, + ); + logger.info("HTTP", "Teams install --repair completed"); + } catch (error) { + logger.error("HTTP", "Teams install failed", {}, error as Error); + } finally { + this._isInstalling = false; + this.statusCache = null; + this.detailCache.clear(); + } + }, + ); + + private handleDetail = this.wrapHandler( + async (req: Request, res: Response): Promise => { + const name = req.params.name; + + if (!name || !NAME_REGEX.test(name)) { + res.status(400).json({ + error: + "Invalid asset name: only alphanumeric characters and hyphens allowed", + }); + return; + } + + const cached = this.detailCache.get(name); + if (cached && Date.now() - cached.timestamp < DETAIL_CACHE_TTL_MS) { + res.json(cached.data); + return; + } + + const sxPath = this.resolveSxBinary(); + if (!sxPath) { + res.status(500).json({ error: "sx CLI not found" }); + return; + } + + try { + const output = await this.runSxCommand( + [sxPath, "vault", "show", name, "--json"], + STATUS_TIMEOUT_MS, + ); + const data = JSON.parse(output); + + if (!data.name || !data.type) { + logger.error("HTTP", "Unexpected sx vault show output", { + name, + raw: output.slice(0, 500), + }); + res.status(502).json({ error: "Unexpected sx response format" }); + return; + } + + const detail = { + name: data.name, + type: data.type, + metadata: { + description: data.metadata?.description ?? null, + authors: data.metadata?.authors ?? [], + keywords: data.metadata?.keywords ?? [], + }, + versions: (data.versions ?? []).map((v: any) => ({ + version: v.version, + createdAt: v.createdAt ?? null, + filesCount: v.filesCount ?? 0, + })), + }; + + this.detailCache.set(name, { data: detail, timestamp: Date.now() }); + res.json(detail); + } catch (error) { + const message = (error as Error).message || ""; + if (message.includes("exited with code")) { + res.status(404).json({ error: `Asset '${name}' not found` }); + } else { + logger.error("HTTP", "Teams detail failed", { name }, error as Error); + res.status(502).json({ error: "Unexpected sx response format" }); + } + } + }, + ); + + private handlePush = this.wrapHandler( + async (req: Request, res: Response): Promise => { + const { source, type, name, scope, scopeUrl } = req.body; + + if (!source || !type || !name) { + res.status(400).json({ error: "source, type, and name are required" }); + return; + } + + if (!NAME_REGEX.test(name)) { + res.status(400).json({ + error: + "Invalid asset name: only alphanumeric characters and hyphens allowed", + }); + return; + } + + const projectRoot = process.env.CLAUDE_PROJECT_ROOT || process.cwd(); + const resolvedSource = path.resolve(projectRoot, source); + if ( + resolvedSource !== projectRoot && + !resolvedSource.startsWith(projectRoot + path.sep) + ) { + res.status(400).json({ error: "Source path must be within project" }); + return; + } + + const sxPath = this.resolveSxBinary(); + if (!sxPath) { + res.status(500).json({ error: "sx CLI not found" }); + return; + } + + const args = [ + sxPath, + "add", + resolvedSource, + "--type", + type, + "--name", + name, + "--yes", + ]; + + if (scope === "global") { + args.push("--scope-global"); + } else if (scopeUrl) { + args.push("--scope-repo", scopeUrl); + } + + try { + await this.runSxCommand(args, PUSH_TIMEOUT_MS); + this.statusCache = null; + this.detailCache.clear(); + res.json({ success: true, error: null }); + } catch (error) { + const message = (error as Error).message || "Push failed"; + logger.error("HTTP", "Teams push failed", { name }, error as Error); + res.json({ success: false, error: message }); + } + }, + ); + + private handleRemove = this.wrapHandler( + async (req: Request, res: Response): Promise => { + const { name } = req.body; + + if (!name || !NAME_REGEX.test(name)) { + res.status(400).json({ + error: + "Invalid asset name: only alphanumeric characters and hyphens allowed", + }); + return; + } + + const sxPath = this.resolveSxBinary(); + if (!sxPath) { + res.status(500).json({ error: "sx CLI not found" }); + return; + } + + try { + await this.runSxCommand( + [sxPath, "remove", name, "--yes"], + REMOVE_TIMEOUT_MS, + ); + this.statusCache = null; + this.detailCache.clear(); + res.json({ success: true, error: null }); + } catch (error) { + const message = (error as Error).message || "Remove failed"; + logger.error("HTTP", "Teams remove failed", { name }, error as Error); + res.json({ success: false, error: message }); + } + }, + ); + + private handleInit = this.wrapHandler( + async (req: Request, res: Response): Promise => { + const { type, repoUrl } = req.body; + + if (!type || !repoUrl) { + res.status(400).json({ error: "type and repoUrl are required" }); + return; + } + + const sxPath = this.resolveSxBinary(); + if (!sxPath) { + res.status(500).json({ error: "sx CLI not found" }); + return; + } + + try { + await this.runSxCommand( + [ + sxPath, + "init", + "--type", + type, + "--repo-url", + repoUrl, + "--clients", + "claude-code", + ], + INIT_TIMEOUT_MS, + ); + this.statusCache = null; + res.json({ success: true, error: null }); + } catch (error) { + const message = (error as Error).message || "Init failed"; + logger.error("HTTP", "Teams init failed", {}, error as Error); + res.json({ success: false, error: message }); + } + }, + ); + + private handleDiscover = this.wrapHandler( + async (_req: Request, res: Response): Promise => { + const projectRoot = process.env.CLAUDE_PROJECT_ROOT || process.cwd(); + const claudeDir = path.join(projectRoot, ".claude"); + + const discovered: { name: string; type: string; path: string }[] = []; + + const typeMap: Record = { + skills: "skill", + rules: "rule", + commands: "command", + }; + + for (const [dir, type] of Object.entries(typeMap)) { + const fullPath = path.join(claudeDir, dir); + if (!existsSync(fullPath)) continue; + + try { + const entries = readdirSync(fullPath, { withFileTypes: true }); + for (const entry of entries) { + const assetName = entry.isDirectory() + ? entry.name + : entry.name.replace(/\.md$/, ""); + if (!assetName || assetName.startsWith(".")) continue; + discovered.push({ + name: assetName, + type, + path: path.join(".claude", dir, entry.name), + }); + } + } catch { + // Directory not readable + } + } + + // Get repo URL + let repoUrl: string | null = null; + try { + const proc = Bun.spawn(["git", "remote", "get-url", "origin"], { + cwd: projectRoot, + stdout: "pipe", + stderr: "pipe", + }); + const stdout = await new Response(proc.stdout).text(); + const exitCode = await proc.exited; + if (exitCode === 0 && stdout.trim()) { + repoUrl = stdout.trim(); + } + } catch { + // Not a git repo or git not available + } + + res.json({ assets: discovered, repoUrl }); + }, + ); + + private handleUpdateAsset = this.wrapHandler( + async (req: Request, res: Response): Promise => { + const { name, currentVersion, scope, scopeUrl } = req.body; + if (!name || !NAME_REGEX.test(name)) { + res.status(400).json({ error: "Invalid asset name" }); + return; + } + const sxPath = this.resolveSxBinary(); + if (!sxPath) { + res.status(500).json({ error: "sx CLI not found" }); + return; + } + try { + // Remove old version from lock file + if (currentVersion) { + await this.runSxCommand( + [ + sxPath, + "remove", + name, + "--version", + String(currentVersion), + "--yes", + ], + REMOVE_TIMEOUT_MS, + ); + } + // Re-add with scope (picks up latest version) + const addArgs = [sxPath, "add", name, "--yes"]; + if (scope === "global") { + addArgs.push("--scope-global"); + } else if (scopeUrl) { + addArgs.push("--scope-repo", scopeUrl); + } else { + addArgs.push("--scope-global"); + } + await this.runSxCommand(addArgs, PUSH_TIMEOUT_MS); + // Install the updated version + const projectRoot = process.env.CLAUDE_PROJECT_ROOT || process.cwd(); + await this.runSxCommand( + [sxPath, "install", "--repair", "--target", projectRoot], + INSTALL_TIMEOUT_MS, + ); + this.statusCache = null; + this.detailCache.clear(); + res.json({ success: true, error: null }); + } catch (error) { + const message = (error as Error).message || "Update failed"; + logger.error( + "HTTP", + "Teams update-asset failed", + { name }, + error as Error, + ); + res.json({ success: false, error: message }); + } + }, + ); + + private emptyStatus(): TeamsStatusResponse { + return { + installed: false, + version: null, + configured: false, + repoUrl: null, + profile: null, + assets: [], + catalog: [], + isInstalling: this._isInstalling, + }; + } + + private resolveSxBinary(): string | null { + const found = Bun.which("sx"); + return found || null; + } + + private async runSxCommand( + args: string[], + timeoutMs: number, + ): Promise { + const proc = Bun.spawn(args, { + stdout: "pipe", + stderr: "pipe", + }); + + const timeoutId = setTimeout(() => { + try { + proc.kill("SIGTERM"); + setTimeout(() => { + try { + proc.kill("SIGKILL"); + } catch {} + }, 1000); + } catch {} + }, timeoutMs); + + try { + const [stdout, stderr] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]); + const exitCode = await proc.exited; + + if (exitCode !== 0) { + throw new Error( + `sx exited with code ${exitCode}: ${stderr.slice(0, 200)}`, + ); + } + + return stdout; + } finally { + clearTimeout(timeoutId); + } + } +} diff --git a/console/src/services/worker/http/routes/VaultRoutes.ts b/console/src/services/worker/http/routes/VaultRoutes.ts deleted file mode 100644 index 6e74425a..00000000 --- a/console/src/services/worker/http/routes/VaultRoutes.ts +++ /dev/null @@ -1,267 +0,0 @@ -/** - * VaultRoutes - * - * API endpoints for sx Vault status and management. - * Invokes the sx CLI via Bun.spawn with timeout and caching. - */ - -import express, { type Request, type Response } from "express"; -import { BaseRouteHandler } from "../BaseRouteHandler.js"; -import { logger } from "../../../../utils/logger.js"; - -export interface VaultAsset { - name: string; - version: string; - type: string; - clients: string[]; - status: string; - scope: string; -} - -export interface VaultCatalogItem { - name: string; - type: string; - latestVersion: string; - versionsCount: number; - updatedAt: string; -} - -export interface VaultStatus { - installed: boolean; - version: string | null; - configured: boolean; - vaultUrl: string | null; - profile: string | null; - assets: VaultAsset[]; - catalog: VaultCatalogItem[]; - isInstalling: boolean; -} - -interface VaultDetailResponse { - name: string; - type: string; - metadata: { - description: string | null; - authors: string[]; - keywords: string[]; - }; - versions: Array<{ - version: string; - createdAt: string | null; - filesCount: number; - }>; -} - -const STATUS_TIMEOUT_MS = 15_000; -const INSTALL_TIMEOUT_MS = 60_000; -const STATUS_CACHE_TTL_MS = 30_000; -const DETAIL_CACHE_TTL_MS = 60_000; - -export class VaultRoutes extends BaseRouteHandler { - private statusCache: { data: VaultStatus; timestamp: number } | null = null; - private detailCache: Map = new Map(); - private _isInstalling = false; - - setupRoutes(app: express.Application): void { - app.get("/api/vault/status", this.handleStatus.bind(this)); - app.post("/api/vault/install", this.handleInstall.bind(this)); - app.get("/api/vault/detail/:name", this.handleDetail.bind(this)); - } - - private handleStatus = this.wrapHandler(async (_req: Request, res: Response): Promise => { - if (this.statusCache && Date.now() - this.statusCache.timestamp < STATUS_CACHE_TTL_MS) { - res.json({ ...this.statusCache.data, isInstalling: this._isInstalling }); - return; - } - - const sxPath = this.resolveSxBinary(); - if (!sxPath) { - res.json(this.emptyStatus()); - return; - } - - try { - const [configOutput, catalogOutput] = await Promise.all([ - this.runSxCommand([sxPath, "config", "--json"], STATUS_TIMEOUT_MS), - this.runSxCommand([sxPath, "vault", "list", "--json"], STATUS_TIMEOUT_MS).catch(() => "[]"), - ]); - - const config = JSON.parse(configOutput); - const catalog: VaultCatalogItem[] = JSON.parse(catalogOutput).map((item: any) => ({ - name: item.name, - type: item.type, - latestVersion: item.latestVersion, - versionsCount: item.versionsCount, - updatedAt: item.updatedAt, - })); - - const assets: VaultAsset[] = []; - for (const scopeGroup of config.assets || []) { - const scope = scopeGroup.scope || "Global"; - for (const asset of scopeGroup.assets || []) { - assets.push({ - name: asset.name, - version: asset.version, - type: asset.type, - clients: asset.clients || [], - status: asset.status || "unknown", - scope, - }); - } - } - - const status: VaultStatus = { - installed: true, - version: config.version?.version || null, - configured: !!config.config?.repositoryUrl, - vaultUrl: config.config?.repositoryUrl || null, - profile: config.config?.profile || null, - assets, - catalog, - isInstalling: this._isInstalling, - }; - - this.statusCache = { data: status, timestamp: Date.now() }; - res.json(status); - } catch (error) { - logger.error("HTTP", "Vault status failed", {}, error as Error); - res.json(this.emptyStatus()); - } - }); - - private handleInstall = this.wrapHandler(async (_req: Request, res: Response): Promise => { - if (this._isInstalling) { - res.status(409).json({ error: "Installation already in progress" }); - return; - } - - const sxPath = this.resolveSxBinary(); - if (!sxPath) { - res.status(500).json({ error: "sx CLI not found" }); - return; - } - - const projectRoot = process.env.CLAUDE_PROJECT_ROOT || process.cwd(); - - this._isInstalling = true; - this.statusCache = null; - res.json({ started: true }); - - try { - await this.runSxCommand([sxPath, "install", "--repair", "--target", projectRoot], INSTALL_TIMEOUT_MS); - logger.info("HTTP", "Vault install --repair completed"); - } catch (error) { - logger.error("HTTP", "Vault install failed", {}, error as Error); - } finally { - this._isInstalling = false; - this.statusCache = null; - this.detailCache.clear(); - } - }); - - private handleDetail = this.wrapHandler(async (req: Request, res: Response): Promise => { - const name = req.params.name; - - if (!name || !/^[a-zA-Z0-9-]+$/.test(name)) { - res.status(400).json({ error: "Invalid asset name: only alphanumeric characters and hyphens allowed" }); - return; - } - - const cached = this.detailCache.get(name); - if (cached && Date.now() - cached.timestamp < DETAIL_CACHE_TTL_MS) { - res.json(cached.data); - return; - } - - const sxPath = this.resolveSxBinary(); - if (!sxPath) { - res.status(500).json({ error: "sx CLI not found" }); - return; - } - - try { - const output = await this.runSxCommand([sxPath, "vault", "show", name, "--json"], STATUS_TIMEOUT_MS); - const data = JSON.parse(output); - - if (!data.name || !data.type) { - logger.error("HTTP", "Unexpected sx vault show output", { name, raw: output.slice(0, 500) }); - res.status(502).json({ error: "Unexpected sx response format" }); - return; - } - - const detail = { - name: data.name, - type: data.type, - metadata: { - description: data.metadata?.description ?? null, - authors: data.metadata?.authors ?? [], - keywords: data.metadata?.keywords ?? [], - }, - versions: (data.versions ?? []).map((v: any) => ({ - version: v.version, - createdAt: v.createdAt ?? null, - filesCount: v.filesCount ?? 0, - })), - }; - - this.detailCache.set(name, { data: detail, timestamp: Date.now() }); - res.json(detail); - } catch (error) { - const message = (error as Error).message || ""; - if (message.includes("exited with code")) { - res.status(404).json({ error: `Asset '${name}' not found` }); - } else { - logger.error("HTTP", "Vault detail failed", { name }, error as Error); - res.status(502).json({ error: "Unexpected sx response format" }); - } - } - }); - - private emptyStatus(): VaultStatus { - return { - installed: false, - version: null, - configured: false, - vaultUrl: null, - profile: null, - assets: [], - catalog: [], - isInstalling: this._isInstalling, - }; - } - - private resolveSxBinary(): string | null { - const found = Bun.which("sx"); - return found || null; - } - - private async runSxCommand(args: string[], timeoutMs: number): Promise { - const proc = Bun.spawn(args, { - stdout: "pipe", - stderr: "pipe", - }); - - const timeoutId = setTimeout(() => { - try { - proc.kill("SIGTERM"); - setTimeout(() => { try { proc.kill("SIGKILL"); } catch {} }, 1000); - } catch {} - }, timeoutMs); - - try { - const [stdout, stderr] = await Promise.all([ - new Response(proc.stdout).text(), - new Response(proc.stderr).text(), - ]); - const exitCode = await proc.exited; - - if (exitCode !== 0) { - throw new Error(`sx exited with code ${exitCode}: ${stderr.slice(0, 200)}`); - } - - return stdout; - } finally { - clearTimeout(timeoutId); - } - } -} diff --git a/console/src/ui/viewer/App.tsx b/console/src/ui/viewer/App.tsx index 7eb4045a..ee1b2c1a 100644 --- a/console/src/ui/viewer/App.tsx +++ b/console/src/ui/viewer/App.tsx @@ -8,7 +8,7 @@ import { SettingsView, SpecView, UsageView, - VaultView, + TeamsView, } from "./views"; import { LogsDrawer } from "./components/LogsModal"; import { CommandPalette } from "./components/CommandPalette"; @@ -26,7 +26,7 @@ const routes = [ { path: "/memories/:type", component: MemoriesView }, { path: "/sessions", component: SessionsView }, { path: "/usage", component: UsageView }, - { path: "/vault", component: VaultView }, + { path: "/teams", component: TeamsView }, { path: "/settings", component: SettingsView }, ]; diff --git a/console/src/ui/viewer/components/CommandPalette.tsx b/console/src/ui/viewer/components/CommandPalette.tsx index 81bee770..288a55ac 100644 --- a/console/src/ui/viewer/components/CommandPalette.tsx +++ b/console/src/ui/viewer/components/CommandPalette.tsx @@ -1,6 +1,6 @@ -import React, { useState, useEffect, useRef, useMemo } from 'react'; -import { Icon } from './ui'; -import { SHORTCUTS, getShortcutDisplay } from '../constants/shortcuts'; +import React, { useState, useEffect, useRef, useMemo } from "react"; +import { Icon } from "./ui"; +import { SHORTCUTS, getShortcutDisplay } from "../constants/shortcuts"; interface CommandPaletteProps { open: boolean; @@ -10,7 +10,7 @@ interface CommandPaletteProps { onToggleSidebar: () => void; } -type CommandCategory = 'navigation' | 'action' | 'theme'; +type CommandCategory = "navigation" | "action" | "theme"; interface InternalCommand { id: string; @@ -28,7 +28,7 @@ export function CommandPalette({ onToggleTheme, onToggleSidebar, }: CommandPaletteProps) { - const [query, setQuery] = useState(''); + const [query, setQuery] = useState(""); const [selectedIndex, setSelectedIndex] = useState(0); const inputRef = useRef(null); const listRef = useRef(null); @@ -36,51 +36,51 @@ export function CommandPalette({ const commands = useMemo(() => { return [ { - id: 'nav-dashboard', - label: 'Go to Dashboard', - shortcut: 'G D', - category: 'navigation', - icon: 'lucide:layout-dashboard', - action: () => onNavigate('/'), + id: "nav-dashboard", + label: "Go to Dashboard", + shortcut: "G D", + category: "navigation", + icon: "lucide:layout-dashboard", + action: () => onNavigate("/"), }, { - id: 'nav-memories', - label: 'Go to Memories', - shortcut: 'G M', - category: 'navigation', - icon: 'lucide:brain', - action: () => onNavigate('/memories'), + id: "nav-memories", + label: "Go to Memories", + shortcut: "G M", + category: "navigation", + icon: "lucide:brain", + action: () => onNavigate("/memories"), }, { - id: 'nav-usage', - label: 'Go to Usage', - shortcut: 'G U', - category: 'navigation', - icon: 'lucide:bar-chart-3', - action: () => onNavigate('/usage'), + id: "nav-usage", + label: "Go to Usage", + shortcut: "G U", + category: "navigation", + icon: "lucide:bar-chart-3", + action: () => onNavigate("/usage"), }, { - id: 'nav-vault', - label: 'Go to Vault', - shortcut: 'G V', - category: 'navigation', - icon: 'lucide:archive', - action: () => onNavigate('/vault'), + id: "nav-teams", + label: "Go to Teams", + shortcut: "G V", + category: "navigation", + icon: "lucide:users", + action: () => onNavigate("/teams"), }, { - id: 'action-theme', - label: 'Toggle Theme', + id: "action-theme", + label: "Toggle Theme", shortcut: getShortcutDisplay(SHORTCUTS.TOGGLE_THEME), - category: 'action', - icon: 'lucide:sun-moon', + category: "action", + icon: "lucide:sun-moon", action: onToggleTheme, }, { - id: 'action-sidebar', - label: 'Toggle Sidebar', + id: "action-sidebar", + label: "Toggle Sidebar", shortcut: getShortcutDisplay(SHORTCUTS.TOGGLE_SIDEBAR), - category: 'action', - icon: 'lucide:panel-left', + category: "action", + icon: "lucide:panel-left", action: onToggleSidebar, }, ]; @@ -92,7 +92,7 @@ export function CommandPalette({ return commands.filter( (cmd) => cmd.label.toLowerCase().includes(lowerQuery) || - cmd.category.toLowerCase().includes(lowerQuery) + cmd.category.toLowerCase().includes(lowerQuery), ); }, [commands, query]); @@ -102,7 +102,7 @@ export function CommandPalette({ useEffect(() => { if (open) { - setQuery(''); + setQuery(""); setSelectedIndex(0); setTimeout(() => inputRef.current?.focus(), 50); } @@ -111,7 +111,7 @@ export function CommandPalette({ useEffect(() => { if (!listRef.current) return; const selected = listRef.current.querySelector('[data-selected="true"]'); - selected?.scrollIntoView({ block: 'nearest' }); + selected?.scrollIntoView({ block: "nearest" }); }, [selectedIndex]); const executeCommand = (cmd: InternalCommand) => { @@ -121,21 +121,23 @@ export function CommandPalette({ const handleKeyDown = (e: React.KeyboardEvent) => { switch (e.key) { - case 'ArrowDown': + case "ArrowDown": e.preventDefault(); setSelectedIndex((i) => (i + 1) % filteredCommands.length); break; - case 'ArrowUp': + case "ArrowUp": e.preventDefault(); - setSelectedIndex((i) => (i - 1 + filteredCommands.length) % filteredCommands.length); + setSelectedIndex( + (i) => (i - 1 + filteredCommands.length) % filteredCommands.length, + ); break; - case 'Enter': + case "Enter": e.preventDefault(); if (filteredCommands[selectedIndex]) { executeCommand(filteredCommands[selectedIndex]); } break; - case 'Escape': + case "Escape": e.preventDefault(); onClose(); break; @@ -150,13 +152,13 @@ export function CommandPalette({ acc[cmd.category].push(cmd); return acc; }, - {} as Record + {} as Record, ); const categoryLabels: Record = { - navigation: 'Navigation', - action: 'Actions', - theme: 'Theme', + navigation: "Navigation", + action: "Actions", + theme: "Theme", }; let flatIndex = 0; @@ -166,7 +168,11 @@ export function CommandPalette({
{/* Search input */}
- + {filteredCommands.length === 0 ? ( -
No commands found
+
+ No commands found +
) : ( Object.entries(groupedCommands).map(([category, cmds]) => (
@@ -198,7 +206,9 @@ export function CommandPalette({ key={cmd.id} data-selected={isSelected} className={`w-full flex items-center gap-3 px-3 py-2 rounded-lg text-left transition-colors ${ - isSelected ? 'bg-primary text-primary-content' : 'hover:bg-base-200' + isSelected + ? "bg-primary text-primary-content" + : "hover:bg-base-200" }`} onClick={() => executeCommand(cmd)} onMouseEnter={() => setSelectedIndex(currentIndex)} @@ -206,12 +216,16 @@ export function CommandPalette({ {cmd.label} {cmd.shortcut && ( {cmd.shortcut} diff --git a/console/src/ui/viewer/components/TeamGate.tsx b/console/src/ui/viewer/components/TeamGate.tsx new file mode 100644 index 00000000..6531c93c --- /dev/null +++ b/console/src/ui/viewer/components/TeamGate.tsx @@ -0,0 +1,48 @@ +import { Icon } from "./ui"; + +interface TeamGateProps { + tier: string | null; + featureName: string; + children?: React.ReactNode; +} + +export function TeamGate({ tier, featureName, children }: TeamGateProps) { + if (tier === "team") { + return <>{children}; + } + + return ( +
+
+ {children} +
+
+
+
+
+ +
+

Team Plan Required

+

+ {featureName} is available on the Team plan. Upgrade to share + assets, configure repositories, and collaborate with your team. +

+ + + Upgrade to Team + +
+
+
+
+ ); +} diff --git a/console/src/ui/viewer/constants/shortcuts.ts b/console/src/ui/viewer/constants/shortcuts.ts index fe51c5ff..cac56755 100644 --- a/console/src/ui/viewer/constants/shortcuts.ts +++ b/console/src/ui/viewer/constants/shortcuts.ts @@ -5,7 +5,7 @@ export interface Shortcut { key: string; - modifiers?: ('ctrl' | 'meta' | 'shift' | 'alt')[]; + modifiers?: ("ctrl" | "meta" | "shift" | "alt")[]; description: string; action: string; } @@ -18,64 +18,80 @@ export interface SequenceShortcut { export const SHORTCUTS: Record = { COMMAND_PALETTE: { - key: 'k', - modifiers: ['ctrl', 'meta'], - description: 'Open command palette', - action: 'openCommandPalette', + key: "k", + modifiers: ["ctrl", "meta"], + description: "Open command palette", + action: "openCommandPalette", }, SEARCH: { - key: '/', - modifiers: ['ctrl', 'meta'], - description: 'Focus search', - action: 'focusSearch', + key: "/", + modifiers: ["ctrl", "meta"], + description: "Focus search", + action: "focusSearch", }, ESCAPE: { - key: 'Escape', - description: 'Close modal/palette', - action: 'escape', + key: "Escape", + description: "Close modal/palette", + action: "escape", }, TOGGLE_THEME: { - key: 't', - modifiers: ['ctrl', 'meta'], - description: 'Toggle theme', - action: 'toggleTheme', + key: "t", + modifiers: ["ctrl", "meta"], + description: "Toggle theme", + action: "toggleTheme", }, TOGGLE_SIDEBAR: { - key: 'b', - modifiers: ['ctrl', 'meta'], - description: 'Toggle sidebar', - action: 'toggleSidebar', + key: "b", + modifiers: ["ctrl", "meta"], + description: "Toggle sidebar", + action: "toggleSidebar", }, } as const; export const SEQUENCE_SHORTCUTS: SequenceShortcut[] = [ - { sequence: ['g', 'd'], description: 'Go to Dashboard', action: 'navigate:/' }, - { sequence: ['g', 'm'], description: 'Go to Memories', action: 'navigate:/memories' }, - { sequence: ['g', 'v'], description: 'Go to Vault', action: 'navigate:/vault' }, + { + sequence: ["g", "d"], + description: "Go to Dashboard", + action: "navigate:/", + }, + { + sequence: ["g", "m"], + description: "Go to Memories", + action: "navigate:/memories", + }, + { + sequence: ["g", "v"], + description: "Go to Teams", + action: "navigate:/teams", + }, ]; export interface Command { id: string; label: string; shortcut?: string; - category: 'navigation' | 'action' | 'theme' | 'project'; + category: "navigation" | "action" | "theme" | "project"; action: () => void; } export function getShortcutDisplay(shortcut: Shortcut): string { - const isMac = typeof navigator !== 'undefined' && navigator.platform.includes('Mac'); + const isMac = + typeof navigator !== "undefined" && navigator.platform.includes("Mac"); const parts: string[] = []; - if (shortcut.modifiers?.includes('ctrl') || shortcut.modifiers?.includes('meta')) { - parts.push(isMac ? '⌘' : 'Ctrl'); + if ( + shortcut.modifiers?.includes("ctrl") || + shortcut.modifiers?.includes("meta") + ) { + parts.push(isMac ? "⌘" : "Ctrl"); } - if (shortcut.modifiers?.includes('shift')) { - parts.push(isMac ? '⇧' : 'Shift'); + if (shortcut.modifiers?.includes("shift")) { + parts.push(isMac ? "⇧" : "Shift"); } - if (shortcut.modifiers?.includes('alt')) { - parts.push(isMac ? '⌥' : 'Alt'); + if (shortcut.modifiers?.includes("alt")) { + parts.push(isMac ? "⌥" : "Alt"); } parts.push(shortcut.key.toUpperCase()); - return parts.join(isMac ? '' : '+'); + return parts.join(isMac ? "" : "+"); } diff --git a/console/src/ui/viewer/hooks/useSettings.ts b/console/src/ui/viewer/hooks/useSettings.ts index eac5174b..3643b6f9 100644 --- a/console/src/ui/viewer/hooks/useSettings.ts +++ b/console/src/ui/viewer/hooks/useSettings.ts @@ -24,7 +24,6 @@ export const DEFAULT_SETTINGS: ModelSettings = { "spec-plan": "opus", "spec-implement": "sonnet", "spec-verify": "sonnet", - vault: "sonnet", sync: "sonnet", learn: "sonnet", }, diff --git a/console/src/ui/viewer/hooks/useStats.ts b/console/src/ui/viewer/hooks/useStats.ts index d3897f0b..fd88c723 100644 --- a/console/src/ui/viewer/hooks/useStats.ts +++ b/console/src/ui/viewer/hooks/useStats.ts @@ -73,7 +73,7 @@ interface GitInfo { untracked: number; } -interface VaultAsset { +interface TeamsAsset { name: string; version: string; type: string; @@ -82,29 +82,31 @@ interface VaultAsset { scope: string; } -interface VaultCatalogItem { +interface TeamsCatalogItem { name: string; type: string; latestVersion: string; versionsCount: number; } -export interface VaultStatus { +export interface TeamsStatusData { installed: boolean; version: string | null; configured: boolean; - vaultUrl: string | null; + repoUrl: string | null; profile: string | null; - assets: VaultAsset[]; - catalog: VaultCatalogItem[]; + assets: TeamsAsset[]; + catalog: TeamsCatalogItem[]; isInstalling: boolean; } +export type TeamsStatus = TeamsStatusData; + interface UseStatsResult { stats: Stats; workerStatus: WorkerStatus; vexorStatus: VexorStatus; - vaultStatus: VaultStatus; + teamsStatus: TeamsStatusData; recentActivity: ActivityItem[]; planStatus: PlanStatus; gitInfo: GitInfo; @@ -161,11 +163,11 @@ export function useStats(): UseStatsResult { }); const [observationTimeline, setObservationTimeline] = useState([]); - const [vaultStatus, setVaultStatus] = useState({ + const [teamsStatusData, setTeamsStatusData] = useState({ installed: false, version: null, configured: false, - vaultUrl: null, + repoUrl: null, profile: null, assets: [], catalog: [], @@ -173,11 +175,11 @@ export function useStats(): UseStatsResult { }); const [isLoading, setIsLoading] = useState(true); - const loadVaultStatus = useCallback(async () => { + const loadTeamsStatusData = useCallback(async () => { try { - const res = await fetch("/api/vault/status"); + const res = await fetch("/api/teams/status"); const data = await res.json(); - setVaultStatus(data); + setTeamsStatusData(data); } catch {} }, []); @@ -323,7 +325,7 @@ export function useStats(): UseStatsResult { useEffect(() => { loadVexorStatus(); - loadVaultStatus(); + loadTeamsStatusData(); const vexorInterval = setInterval(loadVexorStatus, VEXOR_POLL_INTERVAL_MS); const eventSource = new EventSource("/stream"); @@ -354,13 +356,13 @@ export function useStats(): UseStatsResult { clearInterval(vexorInterval); eventSource.close(); }; - }, [loadVexorStatus, loadVaultStatus]); + }, [loadVexorStatus, loadTeamsStatusData]); return { stats, workerStatus, vexorStatus, - vaultStatus, + teamsStatus: teamsStatusData, recentActivity, planStatus, gitInfo, diff --git a/console/src/ui/viewer/hooks/useVault.ts b/console/src/ui/viewer/hooks/useTeams.ts similarity index 50% rename from console/src/ui/viewer/hooks/useVault.ts rename to console/src/ui/viewer/hooks/useTeams.ts index 7a6cf7d8..27266a0d 100644 --- a/console/src/ui/viewer/hooks/useVault.ts +++ b/console/src/ui/viewer/hooks/useTeams.ts @@ -1,7 +1,7 @@ import { useState, useCallback, useEffect, useRef } from "react"; -import type { VaultStatus } from "./useStats"; +import type { TeamsStatus } from "./useStats"; -interface VaultAsset { +interface TeamsAsset { name: string; version: string; type: string; @@ -10,7 +10,7 @@ interface VaultAsset { scope: string; } -interface VaultCatalogItem { +interface TeamsCatalogItem { name: string; type: string; latestVersion: string; @@ -46,8 +46,24 @@ export interface AssetDetail { }>; } -interface UseVaultResult { - vaultStatus: VaultStatus | null; +export interface DiscoveredAsset { + name: string; + type: string; + path: string; +} + +export interface DiscoverResult { + assets: DiscoveredAsset[]; + repoUrl: string | null; +} + +export interface PushResult { + success: boolean; + error: string | null; +} + +interface UseTeamsResult { + teamsStatus: TeamsStatus | null; mergedAssets: MergedAsset[]; isLoading: boolean; error: string | null; @@ -59,6 +75,20 @@ interface UseVaultResult { isInstalling: boolean; installError: string | null; refresh: () => Promise; + discover: () => Promise; + pushAsset: ( + asset: DiscoveredAsset, + scope: string, + scopeUrl: string | null, + ) => Promise; + initTeams: (type: string, repoUrl: string) => Promise; + removeAsset: (name: string) => Promise; + updateAsset: ( + name: string, + currentVersion: string, + scope: string, + scopeUrl: string | null, + ) => Promise; } const POLL_INTERVAL_MS = 2_000; @@ -71,10 +101,10 @@ function parseVersion(v: string | null | undefined): number { } export function mergeAssets( - catalog: VaultCatalogItem[], - assets: VaultAsset[], + catalog: TeamsCatalogItem[], + assets: TeamsAsset[], ): MergedAsset[] { - const assetMap = new Map(); + const assetMap = new Map(); for (const a of assets) { assetMap.set(a.name, a); } @@ -106,8 +136,8 @@ export function mergeAssets( }); } -export function useVault(): UseVaultResult { - const [vaultStatus, setVaultStatus] = useState(null); +export function useTeams(): UseTeamsResult { + const [teamsStatus, setTeamsStatus] = useState(null); const [mergedAssets, setMergedAssets] = useState([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); @@ -121,11 +151,11 @@ export function useVault(): UseVaultResult { const fetchStatus = useCallback(async () => { try { - const res = await fetch("/api/vault/status"); + const res = await fetch("/api/teams/status"); if (!res.ok) throw new Error(`Status fetch failed: ${res.status}`); - const data: VaultStatus = await res.json(); + const data: TeamsStatus = await res.json(); if (!mountedRef.current) return; - setVaultStatus(data); + setTeamsStatus(data); setMergedAssets(mergeAssets(data.catalog, data.assets)); setError(null); } catch (err) { @@ -136,40 +166,40 @@ export function useVault(): UseVaultResult { } }, []); - const fetchDetail = useCallback( - async (name: string) => { - if (detailCacheRef.current.has(name) || loadingDetailsRef.current.has(name)) return; - loadingDetailsRef.current.add(name); - detailErrorsRef.current.delete(name); - forceUpdate((c) => c + 1); - try { - const res = await fetch(`/api/vault/detail/${encodeURIComponent(name)}`); - if (!res.ok) throw new Error(`Detail fetch failed: ${res.status}`); - const data: AssetDetail = await res.json(); - if (mountedRef.current) { - detailCacheRef.current.set(name, data); - } - } catch (err) { - if (mountedRef.current) { - detailErrorsRef.current.set(name, (err as Error).message); - console.error("Failed to fetch vault detail:", name, err); - } - } finally { - loadingDetailsRef.current.delete(name); - if (mountedRef.current) forceUpdate((c) => c + 1); + const fetchDetail = useCallback(async (name: string) => { + if (detailCacheRef.current.has(name) || loadingDetailsRef.current.has(name)) + return; + loadingDetailsRef.current.add(name); + detailErrorsRef.current.delete(name); + forceUpdate((c) => c + 1); + try { + const res = await fetch(`/api/teams/detail/${encodeURIComponent(name)}`); + if (!res.ok) throw new Error(`Detail fetch failed: ${res.status}`); + const data: AssetDetail = await res.json(); + if (mountedRef.current) { + detailCacheRef.current.set(name, data); } - }, - [], - ); + } catch (err) { + if (mountedRef.current) { + detailErrorsRef.current.set(name, (err as Error).message); + console.error("Failed to fetch teams detail:", name, err); + } + } finally { + loadingDetailsRef.current.delete(name); + if (mountedRef.current) forceUpdate((c) => c + 1); + } + }, []); const installAll = useCallback(async () => { setIsInstalling(true); setInstallError(null); try { - const res = await fetch("/api/vault/install", { method: "POST" }); + const res = await fetch("/api/teams/install", { method: "POST" }); if (!res.ok) { - const data = await res.json().catch(() => ({ error: "Install failed" })); + const data = await res + .json() + .catch(() => ({ error: "Install failed" })); throw new Error(data.error || "Install failed"); } @@ -179,15 +209,15 @@ export function useVault(): UseVaultResult { if (!mountedRef.current) return; polls++; - const statusRes = await fetch("/api/vault/status"); + const statusRes = await fetch("/api/teams/status"); if (!statusRes.ok) continue; - const statusData: VaultStatus = await statusRes.json(); + const statusData: TeamsStatus = await statusRes.json(); if (!statusData.isInstalling) { detailCacheRef.current.clear(); detailErrorsRef.current.clear(); if (mountedRef.current) { - setVaultStatus(statusData); + setTeamsStatus(statusData); setMergedAssets(mergeAssets(statusData.catalog, statusData.assets)); setIsInstalling(false); } @@ -208,6 +238,84 @@ export function useVault(): UseVaultResult { } }, [fetchStatus]); + const initTeams = useCallback( + async (type: string, repoUrl: string): Promise => { + const res = await fetch("/api/teams/init", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ type, repoUrl }), + }); + if (!res.ok) return { success: false, error: "Init request failed" }; + const data = await res.json(); + if (data.success) { + fetchStatus(); + } + return data; + }, + [fetchStatus], + ); + + const discover = useCallback(async (): Promise => { + const res = await fetch("/api/teams/discover"); + if (!res.ok) return { assets: [], repoUrl: null }; + return res.json(); + }, []); + + const pushAsset = useCallback( + async ( + asset: DiscoveredAsset, + scope: string, + scopeUrl: string | null, + ): Promise => { + const res = await fetch("/api/teams/push", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + source: asset.path, + type: asset.type, + name: asset.name, + scope, + scopeUrl, + }), + }); + if (!res.ok) return { success: false, error: "Push request failed" }; + return res.json(); + }, + [], + ); + + const removeAsset = useCallback(async (name: string): Promise => { + const res = await fetch("/api/teams/remove", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name }), + }); + if (!res.ok) return { success: false, error: "Remove request failed" }; + return res.json(); + }, []); + + const updateAsset = useCallback( + async ( + name: string, + currentVersion: string, + scope: string, + scopeUrl: string | null, + ): Promise => { + const res = await fetch("/api/teams/update-asset", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name, currentVersion, scope, scopeUrl }), + }); + if (!res.ok) return { success: false, error: "Update request failed" }; + const data = await res.json(); + if (data.success) { + await fetchStatus(); + } + return data; + }, + [fetchStatus], + ); + useEffect(() => { mountedRef.current = true; fetchStatus(); @@ -217,7 +325,7 @@ export function useVault(): UseVaultResult { }, [fetchStatus]); return { - vaultStatus, + teamsStatus, mergedAssets, isLoading, error, @@ -229,5 +337,10 @@ export function useVault(): UseVaultResult { isInstalling, installError, refresh: fetchStatus, + discover, + pushAsset, + initTeams, + removeAsset, + updateAsset, }; } diff --git a/console/src/ui/viewer/layouts/Sidebar/SidebarNav.tsx b/console/src/ui/viewer/layouts/Sidebar/SidebarNav.tsx index 5c8c6df0..6f4f9088 100644 --- a/console/src/ui/viewer/layouts/Sidebar/SidebarNav.tsx +++ b/console/src/ui/viewer/layouts/Sidebar/SidebarNav.tsx @@ -1,5 +1,5 @@ -import React from 'react'; -import { SidebarNavItem } from './SidebarNavItem'; +import React from "react"; +import { SidebarNavItem } from "./SidebarNavItem"; interface SidebarNavProps { currentPath: string; @@ -7,16 +7,19 @@ interface SidebarNavProps { } const navItems = [ - { icon: 'lucide:layout-dashboard', label: 'Dashboard', href: '#/' }, - { icon: 'lucide:scroll', label: 'Specification', href: '#/spec' }, - { icon: 'lucide:brain', label: 'Memories', href: '#/memories' }, - { icon: 'lucide:history', label: 'Sessions', href: '#/sessions' }, - { icon: 'lucide:bar-chart-3', label: 'Usage', href: '#/usage' }, - { icon: 'lucide:archive', label: 'Vault', href: '#/vault' }, - { icon: 'lucide:settings', label: 'Settings', href: '#/settings' }, + { icon: "lucide:layout-dashboard", label: "Dashboard", href: "#/" }, + { icon: "lucide:scroll", label: "Specification", href: "#/spec" }, + { icon: "lucide:brain", label: "Memories", href: "#/memories" }, + { icon: "lucide:history", label: "Sessions", href: "#/sessions" }, + { icon: "lucide:bar-chart-3", label: "Usage", href: "#/usage" }, + { icon: "lucide:users", label: "Teams", href: "#/teams" }, + { icon: "lucide:settings", label: "Settings", href: "#/settings" }, ]; -export function SidebarNav({ currentPath, collapsed = false }: SidebarNavProps) { +export function SidebarNav({ + currentPath, + collapsed = false, +}: SidebarNavProps) { return (