From b3bad89e56a7cd5d20d71a3eee210c38aa0c5d8d Mon Sep 17 00:00:00 2001 From: prem315 Date: Sat, 7 Nov 2026 14:21:25 +0530 Subject: [PATCH] Add comprehensive settings sections and MCP configuration - Add routes and pages for general, chat, models, MCP, skills, plugins, sandboxes, and archive settings - Organize settings navigation with grouped sections, search keywords, and new icons --- apps/web/src/components/vuesax.tsx | 7 + apps/web/src/routeTree.gen.ts | 190 +++++ .../src/routes/_shell.settings.archive.tsx | 5 + apps/web/src/routes/_shell.settings.chat.tsx | 5 + .../src/routes/_shell.settings.general.tsx | 5 + .../routes/_shell.settings.keybindings.tsx | 5 + apps/web/src/routes/_shell.settings.mcp.tsx | 5 + .../web/src/routes/_shell.settings.models.tsx | 5 + .../src/routes/_shell.settings.plugins.tsx | 5 + .../src/routes/_shell.settings.sandboxes.tsx | 5 + .../web/src/routes/_shell.settings.skills.tsx | 5 + apps/web/src/settings/archive.tsx | 179 +++++ apps/web/src/settings/chat.tsx | 109 +++ apps/web/src/settings/controls.tsx | 141 ++++ apps/web/src/settings/general.tsx | 205 +++++ apps/web/src/settings/keybindings.tsx | 317 ++++++++ apps/web/src/settings/mcp.tsx | 736 ++++++++++++++++++ apps/web/src/settings/models.tsx | 342 ++++++++ apps/web/src/settings/plugins.tsx | 340 ++++++++ apps/web/src/settings/sandboxes.tsx | 362 +++++++++ apps/web/src/settings/sections.ts | 116 ++- apps/web/src/settings/skills.tsx | 386 +++++++++ 22 files changed, 3473 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/routes/_shell.settings.archive.tsx create mode 100644 apps/web/src/routes/_shell.settings.chat.tsx create mode 100644 apps/web/src/routes/_shell.settings.general.tsx create mode 100644 apps/web/src/routes/_shell.settings.keybindings.tsx create mode 100644 apps/web/src/routes/_shell.settings.mcp.tsx create mode 100644 apps/web/src/routes/_shell.settings.models.tsx create mode 100644 apps/web/src/routes/_shell.settings.plugins.tsx create mode 100644 apps/web/src/routes/_shell.settings.sandboxes.tsx create mode 100644 apps/web/src/routes/_shell.settings.skills.tsx create mode 100644 apps/web/src/settings/archive.tsx create mode 100644 apps/web/src/settings/chat.tsx create mode 100644 apps/web/src/settings/controls.tsx create mode 100644 apps/web/src/settings/general.tsx create mode 100644 apps/web/src/settings/keybindings.tsx create mode 100644 apps/web/src/settings/mcp.tsx create mode 100644 apps/web/src/settings/models.tsx create mode 100644 apps/web/src/settings/plugins.tsx create mode 100644 apps/web/src/settings/sandboxes.tsx create mode 100644 apps/web/src/settings/skills.tsx diff --git a/apps/web/src/components/vuesax.tsx b/apps/web/src/components/vuesax.tsx index 62a0825c..b9c3d4ac 100644 --- a/apps/web/src/components/vuesax.tsx +++ b/apps/web/src/components/vuesax.tsx @@ -98,3 +98,10 @@ export const FolderUp = icon("community/linear/arrow-up-1"); export const CornerDownLeft = icon("community/linear/arrow-left"); export const CornerDownRight = icon("community/linear/arrow-right"); export const CircleDashed = icon("community/linear/refresh"); +export const Keyboard = icon("community/linear/keyboard"); +export const Sparkles = icon("community/linear/magic-star"); +export const Archive = icon("community/linear/archive"); +export const Cpu = icon("community/linear/cpu"); +export const Puzzle = icon("community/linear/main-component"); +export const DocumentText = icon("community/linear/document-text"); +export const CloudConnection = icon("community/linear/cloud-connection"); diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 61aa57c9..bde17270 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -15,7 +15,16 @@ import { Route as ShellProjectsRouteImport } from './routes/_shell.projects' import { Route as ShellSettingsRouteImport } from './routes/_shell.settings' import { Route as ShellThreadsRouteImport } from './routes/_shell.threads' import { Route as ShellSettingsIndexRouteImport } from './routes/_shell.settings.index' +import { Route as ShellSettingsArchiveRouteImport } from './routes/_shell.settings.archive' +import { Route as ShellSettingsChatRouteImport } from './routes/_shell.settings.chat' +import { Route as ShellSettingsGeneralRouteImport } from './routes/_shell.settings.general' +import { Route as ShellSettingsKeybindingsRouteImport } from './routes/_shell.settings.keybindings' +import { Route as ShellSettingsMcpRouteImport } from './routes/_shell.settings.mcp' +import { Route as ShellSettingsModelsRouteImport } from './routes/_shell.settings.models' +import { Route as ShellSettingsPluginsRouteImport } from './routes/_shell.settings.plugins' +import { Route as ShellSettingsSandboxesRouteImport } from './routes/_shell.settings.sandboxes' import { Route as ShellSettingsServersRouteImport } from './routes/_shell.settings.servers' +import { Route as ShellSettingsSkillsRouteImport } from './routes/_shell.settings.skills' import { Route as ShellSettingsThemesRouteImport } from './routes/_shell.settings.themes' import { Route as ShellSettingsWorkspacesRouteImport } from './routes/_shell.settings.workspaces' import { Route as ShellWorkspacesWorkspaceIdRouteImport } from './routes/_shell.workspaces.$workspaceId' @@ -49,11 +58,57 @@ const ShellSettingsIndexRoute = ShellSettingsIndexRouteImport.update({ path: '/', getParentRoute: () => ShellSettingsRoute, } as any) +const ShellSettingsArchiveRoute = ShellSettingsArchiveRouteImport.update({ + id: '/archive', + path: '/archive', + getParentRoute: () => ShellSettingsRoute, +} as any) +const ShellSettingsChatRoute = ShellSettingsChatRouteImport.update({ + id: '/chat', + path: '/chat', + getParentRoute: () => ShellSettingsRoute, +} as any) +const ShellSettingsGeneralRoute = ShellSettingsGeneralRouteImport.update({ + id: '/general', + path: '/general', + getParentRoute: () => ShellSettingsRoute, +} as any) +const ShellSettingsKeybindingsRoute = + ShellSettingsKeybindingsRouteImport.update({ + id: '/keybindings', + path: '/keybindings', + getParentRoute: () => ShellSettingsRoute, + } as any) +const ShellSettingsMcpRoute = ShellSettingsMcpRouteImport.update({ + id: '/mcp', + path: '/mcp', + getParentRoute: () => ShellSettingsRoute, +} as any) +const ShellSettingsModelsRoute = ShellSettingsModelsRouteImport.update({ + id: '/models', + path: '/models', + getParentRoute: () => ShellSettingsRoute, +} as any) +const ShellSettingsPluginsRoute = ShellSettingsPluginsRouteImport.update({ + id: '/plugins', + path: '/plugins', + getParentRoute: () => ShellSettingsRoute, +} as any) +const ShellSettingsSandboxesRoute = ShellSettingsSandboxesRouteImport.update({ + id: '/sandboxes', + path: '/sandboxes', + getParentRoute: () => ShellSettingsRoute, +} as any) const ShellSettingsServersRoute = ShellSettingsServersRouteImport.update({ id: '/servers', path: '/servers', getParentRoute: () => ShellSettingsRoute, } as any) +const ShellSettingsSkillsRoute = ShellSettingsSkillsRouteImport.update({ + id: '/skills', + path: '/skills', + getParentRoute: () => ShellSettingsRoute, +} as any) const ShellSettingsThemesRoute = ShellSettingsThemesRouteImport.update({ id: '/themes', path: '/themes', @@ -76,7 +131,16 @@ export interface FileRoutesByFullPath { '/projects': typeof ShellProjectsRoute '/settings': typeof ShellSettingsRouteWithChildren '/threads': typeof ShellThreadsRoute + '/settings/archive': typeof ShellSettingsArchiveRoute + '/settings/chat': typeof ShellSettingsChatRoute + '/settings/general': typeof ShellSettingsGeneralRoute + '/settings/keybindings': typeof ShellSettingsKeybindingsRoute + '/settings/mcp': typeof ShellSettingsMcpRoute + '/settings/models': typeof ShellSettingsModelsRoute + '/settings/plugins': typeof ShellSettingsPluginsRoute + '/settings/sandboxes': typeof ShellSettingsSandboxesRoute '/settings/servers': typeof ShellSettingsServersRoute + '/settings/skills': typeof ShellSettingsSkillsRoute '/settings/themes': typeof ShellSettingsThemesRoute '/settings/workspaces': typeof ShellSettingsWorkspacesRoute '/workspaces/$workspaceId': typeof ShellWorkspacesWorkspaceIdRoute @@ -86,7 +150,16 @@ export interface FileRoutesByTo { '/': typeof IndexRoute '/projects': typeof ShellProjectsRoute '/threads': typeof ShellThreadsRoute + '/settings/archive': typeof ShellSettingsArchiveRoute + '/settings/chat': typeof ShellSettingsChatRoute + '/settings/general': typeof ShellSettingsGeneralRoute + '/settings/keybindings': typeof ShellSettingsKeybindingsRoute + '/settings/mcp': typeof ShellSettingsMcpRoute + '/settings/models': typeof ShellSettingsModelsRoute + '/settings/plugins': typeof ShellSettingsPluginsRoute + '/settings/sandboxes': typeof ShellSettingsSandboxesRoute '/settings/servers': typeof ShellSettingsServersRoute + '/settings/skills': typeof ShellSettingsSkillsRoute '/settings/themes': typeof ShellSettingsThemesRoute '/settings/workspaces': typeof ShellSettingsWorkspacesRoute '/workspaces/$workspaceId': typeof ShellWorkspacesWorkspaceIdRoute @@ -99,7 +172,16 @@ export interface FileRoutesById { '/_shell/projects': typeof ShellProjectsRoute '/_shell/settings': typeof ShellSettingsRouteWithChildren '/_shell/threads': typeof ShellThreadsRoute + '/_shell/settings/archive': typeof ShellSettingsArchiveRoute + '/_shell/settings/chat': typeof ShellSettingsChatRoute + '/_shell/settings/general': typeof ShellSettingsGeneralRoute + '/_shell/settings/keybindings': typeof ShellSettingsKeybindingsRoute + '/_shell/settings/mcp': typeof ShellSettingsMcpRoute + '/_shell/settings/models': typeof ShellSettingsModelsRoute + '/_shell/settings/plugins': typeof ShellSettingsPluginsRoute + '/_shell/settings/sandboxes': typeof ShellSettingsSandboxesRoute '/_shell/settings/servers': typeof ShellSettingsServersRoute + '/_shell/settings/skills': typeof ShellSettingsSkillsRoute '/_shell/settings/themes': typeof ShellSettingsThemesRoute '/_shell/settings/workspaces': typeof ShellSettingsWorkspacesRoute '/_shell/workspaces/$workspaceId': typeof ShellWorkspacesWorkspaceIdRoute @@ -112,7 +194,16 @@ export interface FileRouteTypes { | '/projects' | '/settings' | '/threads' + | '/settings/archive' + | '/settings/chat' + | '/settings/general' + | '/settings/keybindings' + | '/settings/mcp' + | '/settings/models' + | '/settings/plugins' + | '/settings/sandboxes' | '/settings/servers' + | '/settings/skills' | '/settings/themes' | '/settings/workspaces' | '/workspaces/$workspaceId' @@ -122,7 +213,16 @@ export interface FileRouteTypes { | '/' | '/projects' | '/threads' + | '/settings/archive' + | '/settings/chat' + | '/settings/general' + | '/settings/keybindings' + | '/settings/mcp' + | '/settings/models' + | '/settings/plugins' + | '/settings/sandboxes' | '/settings/servers' + | '/settings/skills' | '/settings/themes' | '/settings/workspaces' | '/workspaces/$workspaceId' @@ -134,7 +234,16 @@ export interface FileRouteTypes { | '/_shell/projects' | '/_shell/settings' | '/_shell/threads' + | '/_shell/settings/archive' + | '/_shell/settings/chat' + | '/_shell/settings/general' + | '/_shell/settings/keybindings' + | '/_shell/settings/mcp' + | '/_shell/settings/models' + | '/_shell/settings/plugins' + | '/_shell/settings/sandboxes' | '/_shell/settings/servers' + | '/_shell/settings/skills' | '/_shell/settings/themes' | '/_shell/settings/workspaces' | '/_shell/workspaces/$workspaceId' @@ -190,6 +299,62 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ShellSettingsIndexRouteImport parentRoute: typeof ShellSettingsRoute } + '/_shell/settings/archive': { + id: '/_shell/settings/archive' + path: '/archive' + fullPath: '/settings/archive' + preLoaderRoute: typeof ShellSettingsArchiveRouteImport + parentRoute: typeof ShellSettingsRoute + } + '/_shell/settings/chat': { + id: '/_shell/settings/chat' + path: '/chat' + fullPath: '/settings/chat' + preLoaderRoute: typeof ShellSettingsChatRouteImport + parentRoute: typeof ShellSettingsRoute + } + '/_shell/settings/general': { + id: '/_shell/settings/general' + path: '/general' + fullPath: '/settings/general' + preLoaderRoute: typeof ShellSettingsGeneralRouteImport + parentRoute: typeof ShellSettingsRoute + } + '/_shell/settings/keybindings': { + id: '/_shell/settings/keybindings' + path: '/keybindings' + fullPath: '/settings/keybindings' + preLoaderRoute: typeof ShellSettingsKeybindingsRouteImport + parentRoute: typeof ShellSettingsRoute + } + '/_shell/settings/mcp': { + id: '/_shell/settings/mcp' + path: '/mcp' + fullPath: '/settings/mcp' + preLoaderRoute: typeof ShellSettingsMcpRouteImport + parentRoute: typeof ShellSettingsRoute + } + '/_shell/settings/models': { + id: '/_shell/settings/models' + path: '/models' + fullPath: '/settings/models' + preLoaderRoute: typeof ShellSettingsModelsRouteImport + parentRoute: typeof ShellSettingsRoute + } + '/_shell/settings/plugins': { + id: '/_shell/settings/plugins' + path: '/plugins' + fullPath: '/settings/plugins' + preLoaderRoute: typeof ShellSettingsPluginsRouteImport + parentRoute: typeof ShellSettingsRoute + } + '/_shell/settings/sandboxes': { + id: '/_shell/settings/sandboxes' + path: '/sandboxes' + fullPath: '/settings/sandboxes' + preLoaderRoute: typeof ShellSettingsSandboxesRouteImport + parentRoute: typeof ShellSettingsRoute + } '/_shell/settings/servers': { id: '/_shell/settings/servers' path: '/servers' @@ -197,6 +362,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ShellSettingsServersRouteImport parentRoute: typeof ShellSettingsRoute } + '/_shell/settings/skills': { + id: '/_shell/settings/skills' + path: '/skills' + fullPath: '/settings/skills' + preLoaderRoute: typeof ShellSettingsSkillsRouteImport + parentRoute: typeof ShellSettingsRoute + } '/_shell/settings/themes': { id: '/_shell/settings/themes' path: '/themes' @@ -222,14 +394,32 @@ declare module '@tanstack/react-router' { } interface ShellSettingsRouteChildren { + ShellSettingsArchiveRoute: typeof ShellSettingsArchiveRoute + ShellSettingsChatRoute: typeof ShellSettingsChatRoute + ShellSettingsGeneralRoute: typeof ShellSettingsGeneralRoute + ShellSettingsKeybindingsRoute: typeof ShellSettingsKeybindingsRoute + ShellSettingsMcpRoute: typeof ShellSettingsMcpRoute + ShellSettingsModelsRoute: typeof ShellSettingsModelsRoute + ShellSettingsPluginsRoute: typeof ShellSettingsPluginsRoute + ShellSettingsSandboxesRoute: typeof ShellSettingsSandboxesRoute ShellSettingsServersRoute: typeof ShellSettingsServersRoute + ShellSettingsSkillsRoute: typeof ShellSettingsSkillsRoute ShellSettingsThemesRoute: typeof ShellSettingsThemesRoute ShellSettingsWorkspacesRoute: typeof ShellSettingsWorkspacesRoute ShellSettingsIndexRoute: typeof ShellSettingsIndexRoute } const ShellSettingsRouteChildren: ShellSettingsRouteChildren = { + ShellSettingsArchiveRoute: ShellSettingsArchiveRoute, + ShellSettingsChatRoute: ShellSettingsChatRoute, + ShellSettingsGeneralRoute: ShellSettingsGeneralRoute, + ShellSettingsKeybindingsRoute: ShellSettingsKeybindingsRoute, + ShellSettingsMcpRoute: ShellSettingsMcpRoute, + ShellSettingsModelsRoute: ShellSettingsModelsRoute, + ShellSettingsPluginsRoute: ShellSettingsPluginsRoute, + ShellSettingsSandboxesRoute: ShellSettingsSandboxesRoute, ShellSettingsServersRoute: ShellSettingsServersRoute, + ShellSettingsSkillsRoute: ShellSettingsSkillsRoute, ShellSettingsThemesRoute: ShellSettingsThemesRoute, ShellSettingsWorkspacesRoute: ShellSettingsWorkspacesRoute, ShellSettingsIndexRoute: ShellSettingsIndexRoute, diff --git a/apps/web/src/routes/_shell.settings.archive.tsx b/apps/web/src/routes/_shell.settings.archive.tsx new file mode 100644 index 00000000..beafabe4 --- /dev/null +++ b/apps/web/src/routes/_shell.settings.archive.tsx @@ -0,0 +1,5 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { ArchiveSettings } from "../settings/archive"; + +export const Route = createFileRoute("/_shell/settings/archive")({ component: ArchiveSettings }); diff --git a/apps/web/src/routes/_shell.settings.chat.tsx b/apps/web/src/routes/_shell.settings.chat.tsx new file mode 100644 index 00000000..8dd1316f --- /dev/null +++ b/apps/web/src/routes/_shell.settings.chat.tsx @@ -0,0 +1,5 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { ChatSettings } from "../settings/chat"; + +export const Route = createFileRoute("/_shell/settings/chat")({ component: ChatSettings }); diff --git a/apps/web/src/routes/_shell.settings.general.tsx b/apps/web/src/routes/_shell.settings.general.tsx new file mode 100644 index 00000000..ee512b24 --- /dev/null +++ b/apps/web/src/routes/_shell.settings.general.tsx @@ -0,0 +1,5 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { GeneralSettings } from "../settings/general"; + +export const Route = createFileRoute("/_shell/settings/general")({ component: GeneralSettings }); diff --git a/apps/web/src/routes/_shell.settings.keybindings.tsx b/apps/web/src/routes/_shell.settings.keybindings.tsx new file mode 100644 index 00000000..76f77168 --- /dev/null +++ b/apps/web/src/routes/_shell.settings.keybindings.tsx @@ -0,0 +1,5 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { KeybindingsSettings } from "../settings/keybindings"; + +export const Route = createFileRoute("/_shell/settings/keybindings")({ component: KeybindingsSettings }); diff --git a/apps/web/src/routes/_shell.settings.mcp.tsx b/apps/web/src/routes/_shell.settings.mcp.tsx new file mode 100644 index 00000000..d449ef77 --- /dev/null +++ b/apps/web/src/routes/_shell.settings.mcp.tsx @@ -0,0 +1,5 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { McpSettings } from "../settings/mcp"; + +export const Route = createFileRoute("/_shell/settings/mcp")({ component: McpSettings }); diff --git a/apps/web/src/routes/_shell.settings.models.tsx b/apps/web/src/routes/_shell.settings.models.tsx new file mode 100644 index 00000000..5009e7df --- /dev/null +++ b/apps/web/src/routes/_shell.settings.models.tsx @@ -0,0 +1,5 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { ModelsSettings } from "../settings/models"; + +export const Route = createFileRoute("/_shell/settings/models")({ component: ModelsSettings }); diff --git a/apps/web/src/routes/_shell.settings.plugins.tsx b/apps/web/src/routes/_shell.settings.plugins.tsx new file mode 100644 index 00000000..7e064292 --- /dev/null +++ b/apps/web/src/routes/_shell.settings.plugins.tsx @@ -0,0 +1,5 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { PluginsSettings } from "../settings/plugins"; + +export const Route = createFileRoute("/_shell/settings/plugins")({ component: PluginsSettings }); diff --git a/apps/web/src/routes/_shell.settings.sandboxes.tsx b/apps/web/src/routes/_shell.settings.sandboxes.tsx new file mode 100644 index 00000000..e2730ac5 --- /dev/null +++ b/apps/web/src/routes/_shell.settings.sandboxes.tsx @@ -0,0 +1,5 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { SandboxesSettings } from "../settings/sandboxes"; + +export const Route = createFileRoute("/_shell/settings/sandboxes")({ component: SandboxesSettings }); diff --git a/apps/web/src/routes/_shell.settings.skills.tsx b/apps/web/src/routes/_shell.settings.skills.tsx new file mode 100644 index 00000000..66fefc08 --- /dev/null +++ b/apps/web/src/routes/_shell.settings.skills.tsx @@ -0,0 +1,5 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { SkillsSettings } from "../settings/skills"; + +export const Route = createFileRoute("/_shell/settings/skills")({ component: SkillsSettings }); diff --git a/apps/web/src/settings/archive.tsx b/apps/web/src/settings/archive.tsx new file mode 100644 index 00000000..45f7fd99 --- /dev/null +++ b/apps/web/src/settings/archive.tsx @@ -0,0 +1,179 @@ +import { Archive, Folder, Trash2, Undo2 } from "@/components/vuesax"; +import { useState } from "react"; + +import { ProviderIcon } from "@/components/provider"; +import { Button } from "@/components/ui/button"; +import { Switch } from "@/components/ui/switch"; +import { Group, Page, Row, Section } from "./blocks"; +import { EmptyState } from "./controls"; +import { Badge } from "./servers/local"; + +interface ArchivedSessionItem { + readonly id: string; + readonly title: string; + readonly project: string; + readonly provider: string; + readonly updatedDate: string; +} + +interface ArchivedProjectItem { + readonly id: string; + readonly name: string; + readonly path: string; + readonly sessionsCount: number; +} + +const INITIAL_SESSIONS: readonly ArchivedSessionItem[] = [ + { + id: "ses_01jn8k3m", + title: "Migrate OAuth token storage and refresh locks", + project: "codework", + provider: "anthropic", + updatedDate: "Mar 28", + }, + { + id: "ses_01jn5v2p", + title: "Debug WebSocket ndjson backpressure on slow subscribers", + project: "codework", + provider: "openai-codex", + updatedDate: "Mar 21", + }, + { + id: "ses_01jm9q8x", + title: "Benchmark prompt cache retention across OpenRouter models", + project: "aikit", + provider: "openrouter", + updatedDate: "Mar 14", + }, +]; + +const INITIAL_PROJECTS: readonly ArchivedProjectItem[] = [ + { + id: "prj_legacy_tui", + name: "codework-tui-prototype", + path: "~/personal/open-source/archive/codework-tui-prototype", + sessionsCount: 12, + }, + { + id: "prj_aikit_bench", + name: "aikit-eval-suite", + path: "~/personal/open-source/archive/aikit-eval-suite", + sessionsCount: 5, + }, +]; + +export function ArchiveSettings() { + const [showArchived, setShowArchived] = useState(false); + const [sessions, setSessions] = useState(INITIAL_SESSIONS); + const [projects, setProjects] = useState(INITIAL_PROJECTS); + + return ( + +
+ + + + + +
+ +
+ {sessions.length === 0 ? ( + } + title="No archived sessions" + description="Sessions you archive from a workspace or the threads list appear here." + /> + ) : ( + + {sessions.map((session) => ( +
+
+ + + +
+ {session.title} + {session.project} +
+
+
+ {session.updatedDate} + + +
+
+ ))} +
+ )} +
+ +
+ {projects.length === 0 ? ( + } + title="No archived projects" + description="Projects you archive from the Projects view are kept here with their history intact." + /> + ) : ( + + {projects.map((project) => ( +
+
+
+ {project.name} + {project.sessionsCount} sessions +
+ {project.path} +
+
+ + +
+
+ ))} +
+ )} +
+
+ ); +} diff --git a/apps/web/src/settings/chat.tsx b/apps/web/src/settings/chat.tsx new file mode 100644 index 00000000..c51d6ac4 --- /dev/null +++ b/apps/web/src/settings/chat.tsx @@ -0,0 +1,109 @@ +import { isMac } from "@codeworksh/widget/view"; +import { useState } from "react"; + +import { Switch } from "@/components/ui/switch"; +import { Group, Keys, Page, Row, Section } from "./blocks"; +import { Segmented } from "./controls"; + +export function ChatSettings() { + const [anchorPrompts, setAnchorPrompts] = useState(true); + const [expandWorkLog, setExpandWorkLog] = useState(true); + const [showUsage, setShowUsage] = useState(true); + const [smoothReveal, setSmoothReveal] = useState(true); + const [followUpBehavior, setFollowUpBehavior] = useState<"steer" | "followUp">("steer"); + const [spellcheck, setSpellcheck] = useState(true); + const [emptySession, setEmptySession] = useState<"games" | "minimal">("games"); + + return ( + +
+ + + + + + + + + + + + + + +
+ +
+ + + Choose what Enter does while the agent works.{" "} + {isMac ? "⌘Enter" : "Ctrl+Enter"} sends the other mode. + + } + > + + + + + + +
+ +
+ + + + + +
+
+ ); +} diff --git a/apps/web/src/settings/controls.tsx b/apps/web/src/settings/controls.tsx new file mode 100644 index 00000000..8109df7d --- /dev/null +++ b/apps/web/src/settings/controls.tsx @@ -0,0 +1,141 @@ +import { Minus, Plus } from "@/components/vuesax"; +import type { ReactNode } from "react"; + +import { Button } from "@/components/ui/button"; +import { quietField, SavingInput } from "./blocks"; + +export type StatusTone = "success" | "warning" | "danger" | "info" | "muted"; + +const DOTS: Record = { + success: "bg-success", + warning: "bg-warning", + danger: "bg-destructive", + info: "bg-info", + muted: "bg-ink-muted/50", +}; + +/** A compact status dot paired with a text label. */ +export function StatusDot({ tone, children }: { readonly tone: StatusTone; readonly children: ReactNode }) { + return ( + + + {children} + + ); +} + +/** A compact segmented control for choosing one of a few options. */ +export function Segmented({ + label, + value, + options, + onChange, +}: { + readonly label: string; + readonly value: T; + readonly options: ReadonlyArray<{ readonly value: T; readonly label: ReactNode }>; + readonly onChange: (value: T) => void; +}) { + return ( +
+ {options.map((option) => { + const active = option.value === value; + return ( + + ); + })} +
+ ); +} + +/** A numeric stepper with an inline editable input and optional unit suffix. */ +export function Stepper({ + label, + value, + min = 0, + max = 999_999, + step = 1, + unit, + onChange, +}: { + readonly label: string; + readonly value: number; + readonly min?: number; + readonly max?: number; + readonly step?: number; + readonly unit?: string; + readonly onChange: (next: number) => void; +}) { + const clamp = (n: number) => Math.min(max, Math.max(min, n)); + return ( +
+
+ + { + const parsed = Number(typed); + if (Number.isFinite(parsed)) onChange(clamp(Math.round(parsed))); + }} + /> + +
+ {unit !== undefined && {unit}} +
+ ); +} + +/** A quiet empty-state card inside a settings section. */ +export function EmptyState({ + icon, + title, + description, + action, +}: { + readonly icon?: ReactNode; + readonly title: string; + readonly description: string; + readonly action?: ReactNode; +}) { + return ( +
+ {icon !== undefined &&
{icon}
} +

{title}

+

{description}

+ {action !== undefined &&
{action}
} +
+ ); +} diff --git a/apps/web/src/settings/general.tsx b/apps/web/src/settings/general.tsx new file mode 100644 index 00000000..76a47849 --- /dev/null +++ b/apps/web/src/settings/general.tsx @@ -0,0 +1,205 @@ +import { Check, Copy, RefreshCw } from "@/components/vuesax"; +import { isMac } from "@codeworksh/widget/view"; +import { useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { Code, Group, Keys, Page, quietField, Row, Section } from "./blocks"; +import { Badge } from "./servers/local"; + +const VERSION = "0.4.2"; +const HOME_PATH = "~/.codework"; + +const EDITORS = [ + { id: "cursor", label: "Cursor" }, + { id: "vscode", label: "VS Code" }, + { id: "zed", label: "Zed" }, + { id: "system", label: "System default" }, +] as const; + +const TERMINALS = [ + { id: "ghostty", label: "Ghostty" }, + { id: "iterm", label: "iTerm2" }, + { id: "warp", label: "Warp" }, + { id: "terminal", label: "Terminal" }, +] as const; + +const RELEASE_HIGHLIGHTS = [ + "Streamable HTTP and stdio MCP server support with OAuth 2.1 sign-in and deferred tool search.", + "Thread forking at any committed message with inherited conversation history.", + "Multi-workspace tiling and monocle window layouts with keyboard navigation.", + "Local and cloud sandbox drivers for isolated session execution.", +] as const; + +export function GeneralSettings() { + const [notifications, setNotifications] = useState(true); + const [sounds, setSounds] = useState(false); + const [quickComposer, setQuickComposer] = useState(true); + const [confirmDelete, setConfirmDelete] = useState(true); + const [editor, setEditor] = useState("cursor"); + const [terminal, setTerminal] = useState("ghostty"); + const [checking, setChecking] = useState(false); + const [updateStatus, setUpdateStatus] = useState("CodeWork is up to date."); + const [whatsNewOpen, setWhatsNewOpen] = useState(false); + const [copied, setCopied] = useState(false); + + const checkUpdates = () => { + setChecking(true); + setTimeout(() => { + setChecking(false); + setUpdateStatus(`Checked just now · v${VERSION} is the latest release.`); + }, 600); + }; + + const copyHome = () => { + void navigator.clipboard?.writeText(HOME_PATH); + setCopied(true); + setTimeout(() => setCopied(false), 1800); + }; + + return ( + +
+ + + + + + + + +
+ +
+ + + Press {isMac ? "⌘⇧Space" : "Ctrl+Shift+Space"} from any app to start or steer a + session. + + } + > + + + + + + + + + + + + +
+ +
+ + + Version + v{VERSION} + Latest + + } + description={updateStatus} + > +
+ + +
+
+ + User settings (settings.jsonc), MCP servers (mcp.json), and OAuth + tokens live in {HOME_PATH}. + + } + > + + +
+
+ + {whatsNewOpen && ( + !next && setWhatsNewOpen(false)}> + + + What’s new in CodeWork v{VERSION} + Highlights from the latest release. + +
    + {RELEASE_HIGHLIGHTS.map((item) => ( +
  • + + {item} +
  • + ))} +
+ + + +
+
+ )} +
+ ); +} diff --git a/apps/web/src/settings/keybindings.tsx b/apps/web/src/settings/keybindings.tsx new file mode 100644 index 00000000..a91d217e --- /dev/null +++ b/apps/web/src/settings/keybindings.tsx @@ -0,0 +1,317 @@ +import { Keyboard, Search, Undo2 } from "@/components/vuesax"; +import { isMac } from "@codeworksh/widget/view"; +import { type KeyboardEvent, useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Group, Keys, Page, quietField, Section } from "./blocks"; +import { EmptyState } from "./controls"; +import { Badge } from "./servers/local"; + +interface ShortcutItem { + readonly id: string; + readonly group: "Navigation" | "Session & Composer" | "Window Layout"; + readonly label: string; + readonly when: string; + readonly defaultMac: string; + readonly defaultOther: string; + readonly fixed?: boolean; +} + +const SHORTCUTS: readonly ShortcutItem[] = [ + { + id: "palette", + group: "Navigation", + label: "Command palette", + when: "Always", + defaultMac: "⌘K", + defaultOther: "Ctrl+K", + }, + { + id: "workspaces", + group: "Navigation", + label: "Switch to workspace 1–9", + when: "Always", + defaultMac: "⌘1 … ⌘9", + defaultOther: "Ctrl+1 … Ctrl+9", + fixed: true, + }, + { + id: "threads", + group: "Navigation", + label: "Go to All Threads", + when: "Always", + defaultMac: "⌘⇧T", + defaultOther: "Ctrl+Shift+T", + }, + { + id: "projects", + group: "Navigation", + label: "Go to Projects", + when: "Always", + defaultMac: "⌘⇧P", + defaultOther: "Ctrl+Shift+P", + }, + { + id: "sidebar", + group: "Navigation", + label: "Toggle sidebar", + when: "Always", + defaultMac: "⌘B", + defaultOther: "Ctrl+B", + }, + { + id: "settings", + group: "Navigation", + label: "Open Settings", + when: "Always", + defaultMac: "⌘,", + defaultOther: "Ctrl+,", + }, + { + id: "send", + group: "Session & Composer", + label: "Send prompt or steer active turn", + when: "Composer focused", + defaultMac: "Enter", + defaultOther: "Enter", + fixed: true, + }, + { + id: "followup", + group: "Session & Composer", + label: "Queue follow-up for next turn", + when: "Composer focused", + defaultMac: "⌘Enter", + defaultOther: "Ctrl+Enter", + }, + { + id: "stop", + group: "Session & Composer", + label: "Stop running thread", + when: "Thread running", + defaultMac: "Esc", + defaultOther: "Esc", + fixed: true, + }, + { + id: "new-session", + group: "Session & Composer", + label: "New session", + when: "Always", + defaultMac: "⌘N", + defaultOther: "Ctrl+N", + }, + { + id: "fork-thread", + group: "Session & Composer", + label: "Fork conversation at latest turn", + when: "Session focused", + defaultMac: "⌘⇧F", + defaultOther: "Ctrl+Shift+F", + }, + { + id: "model-picker", + group: "Session & Composer", + label: "Switch model", + when: "Session focused", + defaultMac: "⌘M", + defaultOther: "Ctrl+M", + }, + { + id: "layout-toggle", + group: "Window Layout", + label: "Toggle Tiled / Focused view", + when: "Workspace active", + defaultMac: "⌘⇧L", + defaultOther: "Ctrl+Shift+L", + }, + { + id: "panel-next", + group: "Window Layout", + label: "Focus next panel", + when: "Workspace active", + defaultMac: "⌘]", + defaultOther: "Ctrl+]", + }, + { + id: "panel-prev", + group: "Window Layout", + label: "Focus previous panel", + when: "Workspace active", + defaultMac: "⌘[", + defaultOther: "Ctrl+[", + }, + { + id: "panel-pin", + group: "Window Layout", + label: "Pin or unpin focused panel", + when: "Workspace active", + defaultMac: "⌘⇧I", + defaultOther: "Ctrl+Shift+I", + }, + { + id: "panel-close", + group: "Window Layout", + label: "Close focused panel", + when: "Workspace active", + defaultMac: "⌘W", + defaultOther: "Ctrl+W", + }, +]; + +const GROUPS = ["Navigation", "Session & Composer", "Window Layout"] as const; + +function formatRecordedKey(event: KeyboardEvent): string | null { + if (["Meta", "Control", "Alt", "Shift"].includes(event.key)) return null; + const parts: string[] = []; + if (isMac) { + if (event.ctrlKey) parts.push("⌃"); + if (event.altKey) parts.push("⌥"); + if (event.shiftKey) parts.push("⇧"); + if (event.metaKey) parts.push("⌘"); + } else { + if (event.ctrlKey) parts.push("Ctrl+"); + if (event.altKey) parts.push("Alt+"); + if (event.shiftKey) parts.push("Shift+"); + if (event.metaKey) parts.push("Meta+"); + } + const key = event.key.length === 1 ? event.key.toUpperCase() : event.key; + return `${parts.join("")}${key}`; +} + +export function KeybindingsSettings() { + const [query, setQuery] = useState(""); + const [custom, setCustom] = useState>>({}); + const [recordingId, setRecordingId] = useState(null); + + const normalized = query.trim().toLowerCase(); + const filtered = SHORTCUTS.filter((item) => { + if (normalized === "") return true; + const binding = custom[item.id] ?? (isMac ? item.defaultMac : item.defaultOther); + return `${item.label} ${item.group} ${item.when} ${binding}`.toLowerCase().includes(normalized); + }); + + const hasCustom = Object.keys(custom).length > 0; + + return ( + setCustom({})}> + + Reset all + + ) : undefined + } + > +
+ + setQuery(event.target.value)} + /> +
+ + {filtered.length === 0 ? ( + } + title="No matching shortcuts" + description={`No keybinding matches “${query}”.`} + /> + ) : ( + GROUPS.map((group) => { + const items = filtered.filter((item) => item.group === group); + if (items.length === 0) return null; + return ( +
+ + {items.map((item) => { + const defaultKeys = isMac ? item.defaultMac : item.defaultOther; + const currentKeys = custom[item.id] ?? defaultKeys; + const modified = custom[item.id] !== undefined && custom[item.id] !== defaultKeys; + const recording = recordingId === item.id; + return ( +
+
+
+ {item.label} + {modified && Modified} +
+ {item.when} +
+
+ {modified && ( + + )} + {item.fixed ? ( + + {currentKeys} + + ) : ( + + )} +
+
+ ); + })} +
+
+ ); + }) + )} +
+ ); +} diff --git a/apps/web/src/settings/mcp.tsx b/apps/web/src/settings/mcp.tsx new file mode 100644 index 00000000..c0ca1e09 --- /dev/null +++ b/apps/web/src/settings/mcp.tsx @@ -0,0 +1,736 @@ +import { + ChevronRight, + CloudConnection, + GlobeIcon, + Plus, + RefreshCw, + Server, + Trash2, + TriangleAlert, +} from "@/components/vuesax"; +import { useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { Code, Group, Page, quietField, Row, Section } from "./blocks"; +import { EmptyState, Segmented, StatusDot, type StatusTone, Stepper } from "./controls"; +import { Badge } from "./servers/local"; + +type Exposure = "direct" | "deferred" | "codemode" | "hidden"; +type SharingScope = "session" | "space" | "app"; +type ConfigLayer = "user" | "project"; +type ServerState = "connected" | "authenticate" | "failed" | "disconnected" | "disabled"; + +interface McpToolItem { + readonly name: string; + readonly exposure: Exposure; +} + +interface McpServerEntry { + readonly id: string; + readonly name: string; + readonly description: string; + readonly transport: "stdio" | "http"; + readonly endpoint: string; + readonly layer: ConfigLayer; + readonly configPath: string; + readonly scope: SharingScope; + readonly exposure: Exposure; + readonly enabled: boolean; + readonly state: ServerState; + readonly oauthSignedIn?: boolean | undefined; + readonly error?: string | undefined; + readonly resources: boolean; + readonly tools: readonly McpToolItem[]; + readonly skipped?: readonly string[] | undefined; +} + +const INITIAL_SERVERS: readonly McpServerEntry[] = [ + { + id: "linear", + name: "linear", + description: "Search, create, and update Linear issues, cycles, and projects.", + transport: "http", + endpoint: "https://mcp.linear.app/mcp", + layer: "user", + configPath: "~/.codework/mcp.json", + scope: "app", + exposure: "deferred", + enabled: true, + state: "connected", + oauthSignedIn: true, + resources: true, + tools: [ + { name: "list_issues", exposure: "deferred" }, + { name: "get_issue", exposure: "deferred" }, + { name: "create_issue", exposure: "deferred" }, + { name: "update_issue", exposure: "deferred" }, + { name: "list_projects", exposure: "deferred" }, + { name: "list_teams", exposure: "deferred" }, + ], + }, + { + id: "filesystem", + name: "filesystem", + description: "Read and manage workspace files through the MCP reference server.", + transport: "stdio", + endpoint: "npx -y @modelcontextprotocol/server-filesystem .", + layer: "project", + configPath: ".codework/mcp.json", + scope: "session", + exposure: "direct", + enabled: true, + state: "connected", + resources: true, + tools: [ + { name: "read_text_file", exposure: "direct" }, + { name: "write_file", exposure: "direct" }, + { name: "list_directory", exposure: "direct" }, + { name: "search_files", exposure: "direct" }, + ], + }, + { + id: "sentry", + name: "sentry", + description: "Inspect production issues, stack traces, and release health from Sentry.", + transport: "http", + endpoint: "https://mcp.sentry.dev/mcp", + layer: "user", + configPath: "~/.codework/mcp.json", + scope: "space", + exposure: "deferred", + enabled: true, + state: "authenticate", + oauthSignedIn: false, + error: 'MCP server "sentry" requires sign-in. Authenticate via OAuth to load its tools.', + resources: false, + tools: [], + }, + { + id: "postgres", + name: "postgres", + description: "Inspect schema and run read-only SQL queries against local Postgres.", + transport: "stdio", + endpoint: "uvx mcp-server-postgres --dsn ${DATABASE_URL}", + layer: "project", + configPath: ".codework/mcp.json", + scope: "session", + exposure: "direct", + enabled: true, + state: "failed", + error: "Environment variable DATABASE_URL is not set or empty.", + resources: true, + tools: [{ name: "query", exposure: "direct" }], + skipped: ["raw_admin_dump"], + }, + { + id: "playwright", + name: "playwright", + description: "Drive a headless browser for UI inspection and end-to-end verification.", + transport: "stdio", + endpoint: "npx @playwright/mcp@latest", + layer: "user", + configPath: "~/.codework/mcp.json", + scope: "session", + exposure: "deferred", + enabled: false, + state: "disabled", + resources: false, + tools: [ + { name: "browser_navigate", exposure: "deferred" }, + { name: "browser_snapshot", exposure: "deferred" }, + { name: "browser_click", exposure: "deferred" }, + ], + }, +]; + +function stateMeta(server: McpServerEntry): { tone: StatusTone; badge: string; detail: string } { + if (!server.enabled || server.state === "disabled") { + return { tone: "muted", badge: "Disabled", detail: "Disabled in configuration" }; + } + switch (server.state) { + case "connected": + return { + tone: "success", + badge: "Connected", + detail: `${server.tools.length} ${server.tools.length === 1 ? "tool" : "tools"}${server.resources ? " · resources" : ""}`, + }; + case "authenticate": + return { tone: "warning", badge: "Needs sign-in", detail: "OAuth authentication required" }; + case "failed": + return { tone: "danger", badge: "Failed", detail: server.error ?? "Connection failed" }; + case "disconnected": + return { tone: "muted", badge: "Disconnected", detail: "Closed until reconnected" }; + } +} + +export function McpSettings() { + const [servers, setServers] = useState(INITIAL_SERVERS); + const [filter, setFilter] = useState<"all" | ConfigLayer>("all"); + const [expandedId, setExpandedId] = useState("linear"); + const [refreshing, setRefreshing] = useState(false); + const [busyId, setBusyId] = useState(null); + const [adding, setAdding] = useState(false); + + const [toolSearchEnabled, setToolSearchEnabled] = useState(true); + const [startupTimeoutSec, setStartupTimeoutSec] = useState(10); + const [idleTimeoutMin, setIdleTimeoutMin] = useState(10); + + const visible = servers.filter((server) => filter === "all" || server.layer === filter); + + const refreshAll = () => { + setRefreshing(true); + setTimeout(() => setRefreshing(false), 500); + }; + + const updateServer = (id: string, patch: Partial) => { + setServers((current) => current.map((item) => (item.id === id ? { ...item, ...patch } : item))); + }; + + const signInServer = (server: McpServerEntry) => { + setBusyId(server.id); + setTimeout(() => { + updateServer(server.id, { + state: "connected", + oauthSignedIn: true, + error: undefined, + tools: + server.tools.length > 0 + ? server.tools + : [ + { name: "list_issues", exposure: server.exposure }, + { name: "get_event_trace", exposure: server.exposure }, + { name: "search_releases", exposure: server.exposure }, + ], + }); + setBusyId(null); + }, 500); + }; + + const reconnectServer = (server: McpServerEntry) => { + setBusyId(server.id); + setTimeout(() => { + updateServer(server.id, { + state: "connected", + error: undefined, + }); + setBusyId(null); + }, 450); + }; + + return ( + + + + + } + > +
+ s.layer === "user").length})` }, + { value: "project", label: `Project (${servers.filter((s) => s.layer === "project").length})` }, + ]} + onChange={setFilter} + /> + + Tools reach the model as mcp__<server>__<tool> + +
+ +
+ {visible.length === 0 ? ( + } + title="No MCP servers in this scope" + description="Add a stdio or streamable HTTP MCP server to give its tools and resources to the model." + action={ + + } + /> + ) : ( + + {visible.map((server) => { + const meta = stateMeta(server); + const expanded = expandedId === server.id; + const busy = busyId === server.id; + return ( +
+
+ + +
+ {server.enabled && server.state === "authenticate" && ( + + )} + {server.enabled && (server.state === "failed" || server.state === "disconnected") && ( + + )} + + updateServer(server.id, { + enabled, + state: enabled ? "connected" : "disabled", + }) + } + /> +
+
+ + {expanded && ( +
+ {server.error !== undefined && ( +
+ + {server.error} +
+ )} + + {server.skipped !== undefined && server.skipped.length > 0 && ( +
+ + + Skipped {server.skipped.length} invalid tool schema ( + {server.skipped.join(", ")}). See ~/.codework/mcp.log. + +
+ )} + +
+
+ + {server.transport === "http" ? "Endpoint URL" : "Command"} + + + {server.endpoint} + +
+
+ Tool exposure + +
+
+ Instance sharing scope + +
+
+ + {server.tools.length > 0 && ( +
+ + Tools ({server.tools.length}){server.resources && " + MCP resource tools"} + +
+ {server.tools.map((tool) => ( + + {tool.name} + · {server.exposure} + + ))} +
+
+ )} + +
+ + Defined in {server.configPath} + +
+ {server.enabled && server.state === "connected" && ( + + )} + {server.oauthSignedIn && ( + + )} + +
+
+
+ )} +
+ ); + })} +
+ )} +
+ +
+ + + Deferred tool search + codework.tool.toolsearch + + } + description="Let the model search and load tools on demand from servers configured with deferred exposure." + > + + + + + + + + + +
+ + {adding && ( + setAdding(false)} + onAdd={(entry) => { + setServers((current) => [...current, entry]); + setExpandedId(entry.id); + setAdding(false); + }} + /> + )} +
+ ); +} + +function AddMcpServerDialog({ + onClose, + onAdd, +}: { + readonly onClose: () => void; + readonly onAdd: (server: McpServerEntry) => void; +}) { + const [transport, setTransport] = useState<"stdio" | "http">("stdio"); + const [layer, setLayer] = useState("user"); + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [endpoint, setEndpoint] = useState(""); + const [exposure, setExposure] = useState("direct"); + const [scope, setScope] = useState("session"); + + const canSubmit = name.trim() !== "" && endpoint.trim() !== ""; + + const submit = () => { + if (!canSubmit) return; + const cleanName = name.trim().replace(/[^A-Za-z0-9_-]/g, "-"); + onAdd({ + id: `${cleanName}-${Date.now()}`, + name: cleanName, + description: description.trim() || `Custom ${transport.toUpperCase()} MCP server.`, + transport, + endpoint: endpoint.trim(), + layer, + configPath: layer === "user" ? "~/.codework/mcp.json" : ".codework/mcp.json", + scope, + exposure, + enabled: true, + state: "connected", + resources: false, + tools: [{ name: "example_tool", exposure }], + }); + }; + + return ( + !next && onClose()}> + + + Add MCP Server + + Write a new entry to {layer === "user" ? "~/.codework/mcp.json" : ".codework/mcp.json"}. + + + +
+ + +
+ +
+ + + +
+
+ Exposure + +
+
+ Sharing scope + +
+
+
+ + + + + +
+
+ ); +} diff --git a/apps/web/src/settings/models.tsx b/apps/web/src/settings/models.tsx new file mode 100644 index 00000000..7404ba50 --- /dev/null +++ b/apps/web/src/settings/models.tsx @@ -0,0 +1,342 @@ +import { RefreshCw } from "@/components/vuesax"; +import { useState } from "react"; + +import { ProviderIcon } from "@/components/provider"; +import { Button } from "@/components/ui/button"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Code, Group, Page, quietField, Row, Section } from "./blocks"; +import { Segmented, StatusDot, Stepper } from "./controls"; +import { Badge } from "./servers/local"; + +interface OAuthAccount { + readonly id: "openai-codex" | "github-copilot"; + readonly providerIcon: string; + readonly name: string; + readonly subtitle: string; + readonly connected: boolean; + readonly detail: string; +} + +interface EnvProvider { + readonly id: string; + readonly name: string; + readonly envVar: string; + readonly modelsCount: number; + readonly configured: boolean; +} + +const DEFAULT_MODELS = [ + { value: "openai/gpt-5.4", provider: "openai", label: "GPT-5.4 (OpenAI)" }, + { value: "openai-codex/gpt-5.3-codex", provider: "openai-codex", label: "GPT-5.3 Codex (Codex OAuth)" }, + { value: "anthropic/claude-sonnet-4-6", provider: "anthropic", label: "Claude Sonnet 4.6 (Anthropic)" }, + { value: "anthropic/claude-opus-4-6", provider: "anthropic", label: "Claude Opus 4.6 (Anthropic)" }, + { value: "google/gemini-2.5-pro", provider: "google", label: "Gemini 2.5 Pro (Google)" }, + { + value: "openrouter/deepseek/deepseek-v4.1-flash", + provider: "openrouter", + label: "DeepSeek V4.1 Flash (OpenRouter)", + }, +] as const; + +const THINKING_LEVELS = [ + { value: "off", label: "Off" }, + { value: "minimal", label: "Minimal" }, + { value: "low", label: "Low" }, + { value: "medium", label: "Medium" }, + { value: "high", label: "High" }, + { value: "xhigh", label: "Extra High" }, + { value: "max", label: "Max" }, +] as const; + +export function ModelsSettings() { + const [scope, setScope] = useState<"user" | "project">("user"); + const [oauth, setOauth] = useState([ + { + id: "openai-codex", + providerIcon: "openai-codex", + name: "OpenAI Codex", + subtitle: "ChatGPT Plus or Pro subscription via OAuth", + connected: true, + detail: "Connected · acct_94f2a8 · expires in 6d", + }, + { + id: "github-copilot", + providerIcon: "github-copilot", + name: "GitHub Copilot", + subtitle: "GitHub Copilot subscription token", + connected: false, + detail: "Not signed in", + }, + ]); + const [busyOauth, setBusyOauth] = useState(null); + + const [envProviders] = useState([ + { id: "anthropic", name: "Anthropic", envVar: "ANTHROPIC_API_KEY", modelsCount: 8, configured: true }, + { id: "openai", name: "OpenAI", envVar: "OPENAI_API_KEY", modelsCount: 11, configured: true }, + { id: "openrouter", name: "OpenRouter", envVar: "OPENROUTER_API_KEY", modelsCount: 42, configured: true }, + { id: "google", name: "Google Gemini", envVar: "GEMINI_API_KEY", modelsCount: 6, configured: false }, + { id: "xai", name: "xAI", envVar: "XAI_API_KEY", modelsCount: 4, configured: false }, + ]); + + const [defaultModel, setDefaultModel] = useState("anthropic/claude-sonnet-4-6"); + const [thinkingLevel, setThinkingLevel] = useState("medium"); + const [toolExecution, setToolExecution] = useState<"sequential" | "parallel">("sequential"); + const [cacheRetention, setCacheRetention] = useState<"none" | "short" | "long">("short"); + + const [maxRetries, setMaxRetries] = useState(3); + const [baseDelayMs, setBaseDelayMs] = useState(2000); + const [maxDelayMs, setMaxDelayMs] = useState(60000); + + const toggleOauth = (id: OAuthAccount["id"]) => { + setBusyOauth(id); + setTimeout(() => { + setOauth((current) => + current.map((item) => + item.id !== id + ? item + : item.connected + ? { ...item, connected: false, detail: "Not signed in" } + : { + ...item, + connected: true, + detail: + id === "openai-codex" + ? "Connected · acct_94f2a8 · expires in 7d" + : "Connected · 14 models available", + }, + ), + ); + setBusyOauth(null); + }, 450); + }; + + return ( + + } + > +
+ + {oauth.map((account) => { + const busy = busyOauth === account.id; + return ( +
+
+ + + +
+
+ {account.name} + + {account.connected ? "Connected" : "Signed out"} + +
+ {account.subtitle} + {account.detail} +
+
+
+ {account.connected ? ( + <> + + + + ) : ( + + )} +
+
+ ); + })} +
+
+ +
+ + {envProviders.map((provider) => ( +
+
+ + + +
+
+ {provider.name} + {`\${${provider.envVar}}`} +
+ + {provider.configured + ? `${provider.modelsCount} catalog models enabled` + : "Set this variable in your provider server environment to enable."} + +
+
+ + {provider.configured ? "Detected" : "Not set"} + +
+ ))} +
+
+ +
+ + + + + + + + + + + + + + +
+ +
+ + + + + + + + + + + +
+
+ ); +} diff --git a/apps/web/src/settings/plugins.tsx b/apps/web/src/settings/plugins.tsx new file mode 100644 index 00000000..10dca571 --- /dev/null +++ b/apps/web/src/settings/plugins.tsx @@ -0,0 +1,340 @@ +import { Plus, Puzzle, RefreshCw, Trash2 } from "@/components/vuesax"; +import { useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; +import { Code, Group, Page, quietField, Section } from "./blocks"; +import { EmptyState, Segmented, StatusDot } from "./controls"; +import { Badge } from "./servers/local"; + +interface BuiltinPlugin { + readonly id: string; + readonly domain: "tool" | "prompt"; + readonly description: string; + readonly optIn?: boolean; + readonly enabled: boolean; +} + +interface ExternalPlugin { + readonly id: string; + readonly spec: string; + readonly kind: "npm" | "local"; + readonly layer: "user" | "project"; + readonly domain: "tool" | "prompt" | "mcp" | "sandbox"; + readonly description: string; + readonly enabled: boolean; +} + +const INITIAL_BUILTINS: readonly BuiltinPlugin[] = [ + { + id: "codework.tool.bash", + domain: "tool", + description: "Execute shell commands in the session's active sandbox.", + enabled: true, + }, + { + id: "codework.tool.read", + domain: "tool", + description: "Read file contents and inspect images with line offset and limit.", + enabled: true, + }, + { + id: "codework.tool.write", + domain: "tool", + description: "Create new files or overwrite existing files in the workspace.", + enabled: true, + }, + { + id: "codework.tool.edit", + domain: "tool", + description: "Perform targeted, non-overlapping text replacements in files.", + enabled: true, + }, + { + id: "codework.tool.grep", + domain: "tool", + description: "Search file contents with regular expressions and glob filters.", + optIn: true, + enabled: true, + }, + { + id: "codework.tool.find", + domain: "tool", + description: "Find files by name and glob pattern across the workspace.", + optIn: true, + enabled: true, + }, + { + id: "codework.tool.toolsearch", + domain: "tool", + description: "Discover and activate deferred tools from MCP servers and plugins.", + enabled: true, + }, + { + id: "codework.prompt.default", + domain: "prompt", + description: "Render the default system prompt foundation and workspace environment sections.", + enabled: true, + }, +]; + +const INITIAL_EXTERNAL: readonly ExternalPlugin[] = [ + { + id: "proc", + spec: "@codeworksh/codework-tool-proc@0.3.1", + kind: "npm", + layer: "user", + domain: "tool", + description: "Manage long-running background processes and inspect their output streams.", + enabled: true, + }, + { + id: "prompt-life", + spec: "@codeworksh/codework-prompt-life@0.2.0", + kind: "npm", + layer: "user", + domain: "prompt", + description: "Inject persistent project notes and session continuity context into the prompt.", + enabled: true, + }, + { + id: "house-style", + spec: "./extras/plugins/house-style.ts", + kind: "local", + layer: "project", + domain: "prompt", + description: "Project-specific TypeScript, Effect, and testing conventions for this repository.", + enabled: true, + }, +]; + +export function PluginsSettings() { + const [builtins, setBuiltins] = useState(INITIAL_BUILTINS); + const [external, setExternal] = useState(INITIAL_EXTERNAL); + const [reloading, setReloading] = useState(false); + const [reloadNote, setReloadNote] = useState(null); + const [adding, setAdding] = useState(false); + + const reloadAll = () => { + setReloading(true); + setTimeout(() => { + setReloading(false); + setReloadNote( + `Reloaded ${builtins.filter((b) => b.enabled).length + external.filter((e) => e.enabled).length} active plugins.`, + ); + }, 450); + }; + + return ( + + + + + } + > + {reloadNote !== null && ( +
+ {reloadNote} + +
+ )} + +
+ {external.length === 0 ? ( + } + title="No external plugins configured" + description="Add an npm package or a local TypeScript/JavaScript module to settings.jsonc." + /> + ) : ( + + {external.map((plugin) => ( +
+
+ + + +
+
+ {plugin.spec} + {plugin.kind} + {plugin.domain} + {plugin.layer} +
+

{plugin.description}

+
+
+
+ + + setExternal((current) => + current.map((item) => (item.id === plugin.id ? { ...item, enabled } : item)), + ) + } + /> +
+
+ ))} +
+ )} +
+ +
+ + {builtins.map((plugin) => ( +
+
+
+ {plugin.id} + {plugin.domain} + {plugin.optIn && Opt-in} +
+ {plugin.description} +
+ + setBuiltins((current) => + current.map((item) => (item.id === plugin.id ? { ...item, enabled } : item)), + ) + } + /> +
+ ))} +
+
+ + {adding && ( + setAdding(false)} + onAdd={(entry) => { + setExternal((current) => [...current, entry]); + setAdding(false); + }} + /> + )} +
+ ); +} + +function AddPluginDialog({ + onClose, + onAdd, +}: { + readonly onClose: () => void; + readonly onAdd: (plugin: ExternalPlugin) => void; +}) { + const [layer, setLayer] = useState<"user" | "project">("user"); + const [spec, setSpec] = useState(""); + const [description, setDescription] = useState(""); + + const trimmed = spec.trim(); + const isLocal = + trimmed.startsWith(".") || trimmed.startsWith("/") || trimmed.startsWith("~/") || trimmed.startsWith("file:"); + + const submit = () => { + if (!trimmed) return; + onAdd({ + id: `plugin-${Date.now()}`, + spec: trimmed, + kind: isLocal ? "local" : "npm", + layer, + domain: "tool", + description: + description.trim() || (isLocal ? "Local workspace plugin module." : "Installed npm plugin package."), + enabled: true, + }); + }; + + return ( + !next && onClose()}> + + + Add Plugin + + Register an npm package or local module in{" "} + {layer === "user" ? "~/.codework/settings.jsonc" : ".codework/settings.jsonc"}. + + + +
+ Settings layer + +
+ +
+ + +
+ + + + + +
+
+ ); +} diff --git a/apps/web/src/settings/sandboxes.tsx b/apps/web/src/settings/sandboxes.tsx new file mode 100644 index 00000000..183e564e --- /dev/null +++ b/apps/web/src/settings/sandboxes.tsx @@ -0,0 +1,362 @@ +import { Box, FolderTree, GitBranch, Plus, Square, Trash2 } from "@/components/vuesax"; +import { useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Code, Group, Page, quietField, Row, Section } from "./blocks"; +import { EmptyState, StatusDot } from "./controls"; +import { Badge } from "./servers/local"; + +interface SandboxInstanceItem { + readonly id: string; + readonly driver: "memory" | "vercel" | "daytona"; + readonly kind: "local" | "remote"; + readonly resourceId?: string; + readonly refCount: number; + readonly status: "running" | "stopping"; + readonly createdAgo: string; +} + +interface WorktreeItem { + readonly id: string; + readonly name: string; + readonly path: string; + readonly branch: string; + readonly sessions: number; + readonly dirty: boolean; + readonly unpushed: number; +} + +const DRIVERS = [ + { + id: "host", + name: "Local Host", + kind: "local", + ready: true, + detail: "Runs directly in the project's host folder.", + }, + { + id: "memory", + name: "Memory", + kind: "local", + ready: true, + detail: "Isolated copy-on-write virtual filesystem in RAM.", + }, + { id: "vercel", name: "Vercel Sandbox", kind: "remote", ready: true, detail: "Cloud microVM via @vercel/sandbox." }, + { + id: "daytona", + name: "Daytona", + kind: "remote", + ready: false, + detail: "Set DAYTONA_API_KEY to provision cloud workspaces.", + }, +] as const; + +const INITIAL_INSTANCES: readonly SandboxInstanceItem[] = [ + { + id: "sbx_01jp9k2m7v", + driver: "vercel", + kind: "remote", + resourceId: "vcel_vm_8821a", + refCount: 2, + status: "running", + createdAgo: "18m ago", + }, + { + id: "sbx_01jp8w4a1c", + driver: "memory", + kind: "local", + refCount: 1, + status: "running", + createdAgo: "42m ago", + }, +]; + +const INITIAL_WORKTREES: readonly WorktreeItem[] = [ + { + id: "wt-mcp-settings", + name: "codework-mcp-settings", + path: "~/personal/open-source/worktrees/codework-mcp-settings", + branch: "feat/mcp-settings", + sessions: 2, + dirty: true, + unpushed: 1, + }, + { + id: "wt-rpc-sync", + name: "codework-rpc-sync", + path: "~/personal/open-source/worktrees/codework-rpc-sync", + branch: "fix/rpc-reconnect", + sessions: 0, + dirty: false, + unpushed: 0, + }, +]; + +let nextSandboxSeq = 1; + +export function SandboxesSettings() { + const [defaultDriver, setDefaultDriver] = useState("host"); + const [instances, setInstances] = useState(INITIAL_INSTANCES); + const [worktrees, setWorktrees] = useState(INITIAL_WORKTREES); + const [creatingWorktree, setCreatingWorktree] = useState(false); + + const provisionSandbox = (driver: "memory" | "vercel") => { + const seq = nextSandboxSeq++; + const id = `sbx_01jp9x${String(seq).padStart(4, "0")}`; + setInstances((current) => [ + { + id, + driver, + kind: driver === "vercel" ? "remote" : "local", + ...(driver === "vercel" ? { resourceId: `vcel_vm_9${String(seq).padStart(4, "0")}` } : {}), + refCount: 0, + status: "running", + createdAgo: "just now", + }, + ...current, + ]); + }; + + return ( + +
+ + + + + +
+ +
+ + {DRIVERS.map((driver) => ( +
+
+ + + +
+
+ {driver.name} + {driver.id} + + {driver.ready ? "Available" : "Not configured"} + + {driver.kind} +
+ {driver.detail} +
+
+ {(driver.id === "memory" || driver.id === "vercel") && ( + + )} +
+ ))} +
+
+ +
+ {instances.length === 0 ? ( + } + title="No active sandboxes" + description="Managed sandboxes start automatically when a session requests one and stop when you end them." + /> + ) : ( + + {instances.map((instance) => ( +
+
+
+ {instance.id} + {instance.driver} + {instance.resourceId !== undefined && {instance.resourceId}} +
+ + Running · {instance.refCount}{" "} + {instance.refCount === 1 ? "session attached" : "sessions attached"} · created{" "} + {instance.createdAgo} + +
+ +
+ ))} +
+ )} +
+ +
+
+ + Linked worktrees let sessions work on separate branches concurrently without stashing. + + +
+ {worktrees.length === 0 ? ( + } + title="No additional worktrees" + description="Create a worktree to run sessions on another branch in an isolated folder." + /> + ) : ( + + {worktrees.map((tree) => ( +
+
+ + + +
+
+ {tree.name} + + + {tree.branch} + + + {tree.dirty ? "Uncommitted changes" : "Clean"} + +
+ {tree.path} + + {tree.sessions} {tree.sessions === 1 ? "session" : "sessions"} in this worktree + {tree.unpushed > 0 ? ` · ${tree.unpushed} unpushed commit` : ""} + +
+
+ +
+ ))} +
+ )} +
+ + {creatingWorktree && ( + setCreatingWorktree(false)} + onCreate={(tree) => { + setWorktrees((current) => [tree, ...current]); + setCreatingWorktree(false); + }} + /> + )} +
+ ); +} + +function CreateWorktreeDialog({ + onClose, + onCreate, +}: { + readonly onClose: () => void; + readonly onCreate: (tree: WorktreeItem) => void; +}) { + const [branch, setBranch] = useState(""); + const cleanBranch = branch.trim(); + const folderName = `codework-${cleanBranch.replace(/[^a-zA-Z0-9-_]+/g, "-") || "branch"}`; + + return ( + !next && onClose()}> + + + Create Worktree + + Check out a new or existing branch into a linked folder under{" "} + ~/personal/open-source/worktrees. + + + +

+ Folder: {`~/personal/open-source/worktrees/${folderName}`} +

+ + + + +
+
+ ); +} diff --git a/apps/web/src/settings/sections.ts b/apps/web/src/settings/sections.ts index fd390c07..5438cd53 100644 --- a/apps/web/src/settings/sections.ts +++ b/apps/web/src/settings/sections.ts @@ -1,7 +1,33 @@ -import { Palette, Server, WorkspaceIcon, type IconComponent } from "@/components/vuesax"; +import { + Archive, + Box, + CloudConnection, + Cpu, + Keyboard, + MessageSquare, + Palette, + Puzzle, + Server, + SlidersHorizontal, + Sparkles, + WorkspaceIcon, + type IconComponent, +} from "@/components/vuesax"; export interface SettingsSection { - readonly to: "/settings/workspaces" | "/settings/themes" | "/settings/servers"; + readonly to: + | "/settings/general" + | "/settings/workspaces" + | "/settings/themes" + | "/settings/servers" + | "/settings/keybindings" + | "/settings/models" + | "/settings/chat" + | "/settings/mcp" + | "/settings/skills" + | "/settings/plugins" + | "/settings/sandboxes" + | "/settings/archive"; readonly label: string; readonly icon: IconComponent; /** Sidebar heading the section sits under. */ @@ -11,6 +37,13 @@ export interface SettingsSection { } const sections: readonly SettingsSection[] = [ + { + to: "/settings/general", + label: "General", + icon: SlidersHorizontal, + group: "App", + keywords: ["notifications", "sounds", "alerts", "quick", "composer", "editor", "terminal", "version", "updates"], + }, { to: "/settings/workspaces", label: "Workspaces", @@ -32,6 +65,85 @@ const sections: readonly SettingsSection[] = [ group: "App", keywords: ["servers", "provider", "local", "remote", "cli", "service", "install", "codework", "version", "rpc"], }, + { + to: "/settings/keybindings", + label: "Keybindings", + icon: Keyboard, + group: "App", + keywords: ["shortcuts", "hotkeys", "keyboard", "bindings", "keys", "palette"], + }, + { + to: "/settings/models", + label: "Models", + icon: Cpu, + group: "Agents", + keywords: [ + "accounts", + "oauth", + "codex", + "copilot", + "anthropic", + "openai", + "gemini", + "openrouter", + "thinking", + "retry", + "cache", + ], + }, + { + to: "/settings/chat", + label: "Chat", + icon: MessageSquare, + group: "Agents", + keywords: ["transcript", "composer", "prompt", "steer", "queue", "follow-up", "usage", "tokens", "games"], + }, + { + to: "/settings/mcp", + label: "MCP", + icon: CloudConnection, + group: "Agents", + keywords: [ + "mcp", + "tools", + "servers", + "stdio", + "http", + "oauth", + "exposure", + "deferred", + "toolsearch", + "resources", + ], + }, + { + to: "/settings/skills", + label: "Skills", + icon: Sparkles, + group: "Agents", + keywords: ["skills", "skill.md", "instructions", "workflows", "project", "personal"], + }, + { + to: "/settings/plugins", + label: "Plugins", + icon: Puzzle, + group: "Agents", + keywords: ["plugins", "extensions", "npm", "tools", "prompt", "bash", "grep", "find", "reload"], + }, + { + to: "/settings/sandboxes", + label: "Sandboxes", + icon: Box, + group: "Workspace", + keywords: ["sandboxes", "worktrees", "git", "branch", "vercel", "daytona", "memory", "isolation"], + }, + { + to: "/settings/archive", + label: "Archive", + icon: Archive, + group: "Workspace", + keywords: ["archived", "restore", "unarchive", "sessions", "projects", "hidden"], + }, ]; /** Sections whose label or keywords contain every word of the query, in sidebar order. */ diff --git a/apps/web/src/settings/skills.tsx b/apps/web/src/settings/skills.tsx new file mode 100644 index 00000000..d963bb32 --- /dev/null +++ b/apps/web/src/settings/skills.tsx @@ -0,0 +1,386 @@ +import { + Check, + ChevronRight, + Copy, + DocumentText, + Plus, + RefreshCw, + Search, + Sparkles, + Trash2, +} from "@/components/vuesax"; +import { useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; +import { Code, Group, Page, quietField, Section } from "./blocks"; +import { EmptyState, Segmented } from "./controls"; +import { Badge } from "./servers/local"; + +type SkillScope = "project" | "personal" | "builtin"; + +interface SkillItem { + readonly id: string; + readonly name: string; + readonly description: string; + readonly scope: SkillScope; + readonly path: string; + readonly enabled: boolean; + readonly content: string; +} + +const INITIAL_SKILLS: readonly SkillItem[] = [ + { + id: "effect-architecture", + name: "effect-architecture", + description: "Idiomatic Effect service definitions, Schema codecs, and Layer composition patterns.", + scope: "project", + path: ".codework/skills/effect-architecture/SKILL.md", + enabled: true, + content: `---\nname: effect-architecture\ndescription: Idiomatic Effect service definitions, Schema codecs, and Layer composition patterns.\n---\n\n# Effect Architecture Guidelines\n\n1. Define services with \`Context.Service\` and expose a clean \`layer\` constructor.\n2. Keep wire and persistence boundaries typed with \`Schema\`.\n3. Avoid leaking raw promises or untyped exceptions across service boundaries.`, + }, + { + id: "e2e-artifacts", + name: "e2e-artifacts", + description: "End-to-end test execution with deterministic path-normalized __artifacts__ snapshots.", + scope: "project", + path: ".codework/skills/e2e-artifacts/SKILL.md", + enabled: true, + content: `---\nname: e2e-artifacts\ndescription: End-to-end test execution with deterministic path-normalized __artifacts__ snapshots.\n---\n\n# E2E Artifact Verification\n\n- Spawn the real CLI or server runtime against a temporary home directory.\n- Normalize dynamic IDs, ports, and timestamps before asserting.\n- Persist repeatable JSON snapshots under \`test/__artifacts__/\`.`, + }, + { + id: "release-workflow", + name: "release-workflow", + description: "Changelog curation, package version bumping, and canary/latest publishing steps.", + scope: "personal", + path: "~/.codework/skills/release-workflow/SKILL.md", + enabled: true, + content: `---\nname: release-workflow\ndescription: Changelog curation, package version bumping, and canary/latest publishing steps.\n---\n\n# Release Flow\n\n1. Move entries from \`[Unreleased]\` in \`CHANGELOG.md\`.\n2. Run \`vp run bump \` for each modified package.\n3. Preview with \`vp run release latest --dry-run\`.`, + }, + { + id: "mcp-troubleshooting", + name: "mcp-troubleshooting", + description: "Diagnose stdio and HTTP MCP server connection, OAuth, and schema validation issues.", + scope: "builtin", + path: "builtin://skills/mcp-troubleshooting/SKILL.md", + enabled: true, + content: `---\nname: mcp-troubleshooting\ndescription: Diagnose stdio and HTTP MCP server connection, OAuth, and schema validation issues.\n---\n\n# MCP Troubleshooting\n\n- Inspect \`~/.codework/mcp.log\` for skipped tools or transport errors.\n- Run \`codework mcp list\` to verify tool exposure and connection state.`, + }, + { + id: "legacy-migration", + name: "legacy-migration", + description: "Playbook for migrating legacy REST endpoints to typed Effect RPC contracts.", + scope: "personal", + path: "~/.codework/skills/legacy-migration/SKILL.md", + enabled: false, + content: `---\nname: legacy-migration\ndescription: Playbook for migrating legacy REST endpoints to typed Effect RPC contracts.\n---\n\n# Legacy RPC Migration\n\nDefine request, success, and tagged error schemas in \`contract.ts\` and wire handlers in \`handlers.ts\`.`, + }, +]; + +const SCOPE_BADGE: Record = { + project: { label: "Project", tone: "info" }, + personal: { label: "Personal", tone: "success" }, + builtin: { label: "Built-in", tone: "muted" }, +}; + +export function SkillsSettings() { + const [skills, setSkills] = useState(INITIAL_SKILLS); + const [query, setQuery] = useState(""); + const [scopeFilter, setScopeFilter] = useState<"all" | SkillScope>("all"); + const [previewId, setPreviewId] = useState("effect-architecture"); + const [copiedId, setCopiedId] = useState(null); + const [refreshing, setRefreshing] = useState(false); + const [creating, setCreating] = useState(false); + + const normalized = query.trim().toLowerCase(); + const visible = skills.filter((skill) => { + if (scopeFilter !== "all" && skill.scope !== scopeFilter) return false; + if (normalized === "") return true; + return `${skill.name} ${skill.description} ${skill.path} ${skill.scope}`.toLowerCase().includes(normalized); + }); + + const refresh = () => { + setRefreshing(true); + setTimeout(() => setRefreshing(false), 450); + }; + + const copyPath = (skill: SkillItem) => { + void navigator.clipboard?.writeText(skill.path); + setCopiedId(skill.id); + setTimeout(() => setCopiedId(null), 1600); + }; + + return ( + + + + + } + > +
+
+ + setQuery(event.target.value)} + /> +
+ +
+ +
+ {visible.length === 0 ? ( + } + title="No matching skills" + description={ + query.trim() !== "" + ? `No skill matches “${query}”.` + : "Create a SKILL.md in your project or personal folder to teach the agent repeatable workflows." + } + /> + ) : ( + + {visible.map((skill) => { + const badge = SCOPE_BADGE[skill.scope]; + const previewing = previewId === skill.id; + const copied = copiedId === skill.id; + return ( +
+
+ + +
+ + {skill.scope !== "builtin" && ( + + )} + + setSkills((current) => + current.map((item) => (item.id === skill.id ? { ...item, enabled } : item)), + ) + } + /> +
+
+ + {previewing && ( +
+
+ + Previewing {skill.path} + + SKILL.md +
+
+												{skill.content}
+											
+
+ )} +
+ ); + })} +
+ )} +
+ +

+ CodeWork scans .codework/skills/*/SKILL.md in the project and{" "} + ~/.codework/skills/*/SKILL.md in your home directory. +

+ + {creating && ( + setCreating(false)} + onCreate={(item) => { + setSkills((current) => [item, ...current]); + setPreviewId(item.id); + setCreating(false); + }} + /> + )} +
+ ); +} + +function CreateSkillDialog({ + onClose, + onCreate, +}: { + readonly onClose: () => void; + readonly onCreate: (skill: SkillItem) => void; +}) { + const [scope, setScope] = useState<"project" | "personal">("project"); + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + + const slug = name + .trim() + .toLowerCase() + .replace(/[^a-z0-9-_]+/g, "-") + .replace(/^-+|-+$/g, ""); + const path = + scope === "project" + ? `.codework/skills/${slug || "my-skill"}/SKILL.md` + : `~/.codework/skills/${slug || "my-skill"}/SKILL.md`; + + const submit = () => { + if (!slug) return; + const desc = description.trim() || `Custom ${scope} skill instructions.`; + onCreate({ + id: `${slug}-${Date.now()}`, + name: slug, + description: desc, + scope, + path, + enabled: true, + content: `---\nname: ${slug}\ndescription: ${desc}\n---\n\n# ${slug}\n\nWrite instructions and examples for the agent here.`, + }); + }; + + return ( + !next && onClose()}> + + + Create Skill + + Scaffold a new SKILL.md instruction file. + + + +
+ Location + +
+ +
+ + +

+ Will create {path} +

+
+ + + + + +
+
+ ); +}