@bibobonking/opensol 3.1.2 → 3.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +16 -1
  2. package/dist/assets/index-B1pQC9Fe.js +288 -0
  3. package/dist/assets/index-qLr4aZVC.css +1 -0
  4. package/dist/favicon.svg +11 -5
  5. package/dist/index.html +2 -2
  6. package/package.json +1 -1
  7. package/public/favicon.svg +11 -5
  8. package/server/daemon.mjs +42 -4
  9. package/server/lib/deploy.mjs +78 -7
  10. package/server/lib/skills.mjs +128 -0
  11. package/server/lib/tools.mjs +40 -0
  12. package/src/agent/adapters/openai-responses.ts +5 -1
  13. package/src/agent/adapters/openai.ts +15 -2
  14. package/src/agent/prompt.ts +4 -7
  15. package/src/agent/tools.ts +1 -0
  16. package/src/components/DeployModal.tsx +77 -15
  17. package/src/components/FileIcon.tsx +108 -0
  18. package/src/components/HomeDashboard.tsx +9 -2
  19. package/src/components/Logo.tsx +11 -5
  20. package/src/components/ModelDeck.tsx +12 -2
  21. package/src/components/SettingsModal.tsx +144 -199
  22. package/src/components/Sidebar.tsx +4 -5
  23. package/src/components/SystemPromptModal.tsx +168 -0
  24. package/src/components/TopBar.tsx +19 -0
  25. package/src/components/WorkspacePanel.tsx +94 -46
  26. package/src/core/kernel.ts +2 -0
  27. package/src/core/plugins/tools/skill.ts +101 -0
  28. package/src/core/services/agent-loop.ts +26 -28
  29. package/src/core/services/system-prompt.ts +15 -13
  30. package/src/core/services/tools.ts +1 -0
  31. package/src/lib/daemon.ts +19 -0
  32. package/src/lib/permissions.ts +1 -1
  33. package/src/lib/retry.test.mjs +17 -0
  34. package/src/lib/retry.ts +13 -0
  35. package/src/lib/skills.test.mjs +22 -0
  36. package/src/lib/skills.ts +93 -0
  37. package/src/lib/usage.ts +12 -0
  38. package/src/store/chats.ts +1 -0
  39. package/src/store/settings.ts +21 -9
  40. package/src/types.ts +14 -1
  41. package/tsconfig.tsbuildinfo +1 -1
  42. package/dist/assets/index-CeN54A2N.css +0 -1
  43. package/dist/assets/index-fhIO9a7_.js +0 -318
  44. package/src/data/skills.ts +0 -68
@@ -23,7 +23,6 @@ import {
23
23
  Moon,
24
24
  Package,
25
25
  Palette,
26
- Plus,
27
26
  RefreshCw,
28
27
  Rocket,
29
28
  RotateCcw,
@@ -42,7 +41,6 @@ import {
42
41
  import type { DeployProviderId, Effort, ModelInfo, ProviderId, SearchProvider, Skill, BuiltinToolName } from '../types'
43
42
  import type { LucideIcon } from 'lucide-react'
44
43
  import { EFFORT_LABELS, PROVIDERS, PROVIDER_MAP } from '../data/providers'
45
- import { STARTER_SKILL } from '../data/skills'
46
44
  import {
47
45
  DEFAULT_SYSTEM_PROMPT,
48
46
  activeModelId,
@@ -55,10 +53,11 @@ import { useChats, exportChats, importChats } from '../store/chats'
55
53
  import { useWorkspace } from '../store/workspace'
56
54
  import { stopAllRuns } from '../agent'
57
55
  import { applySnapshot } from '../lib/boot'
58
- import { executeDaemonTool, resetScope } from '../lib/daemon'
56
+ import { executeDaemonTool, installSkillFromNpmRequest, resetScope } from '../lib/daemon'
57
+ import { skillFromFolder } from '../lib/skills'
59
58
  import { effortOptions, reasoningSummary } from '../lib/reasoning'
60
59
  import { PERMISSION_MODES, permissionMode, permissionTools } from '../lib/permissions'
61
- import { cn, formatBytes, readFileAsText } from '../lib/utils'
60
+ import { cn, formatBytes } from '../lib/utils'
62
61
  import { LogoMark } from './Logo'
63
62
  import { PluginManager } from './PluginManager'
64
63
  import { useMcp } from '../store/mcp'
@@ -70,7 +69,7 @@ const TABS = [
70
69
  { id: 'models', label: 'Models', icon: Cpu, description: 'Providers, encrypted keys, live models and reasoning defaults.' },
71
70
  { id: 'tools', label: 'Tools', icon: Wrench, description: 'What OpenSol can do on this machine, and when it must ask.' },
72
71
  { id: 'search', label: 'Search', icon: Globe2, description: 'Give the agent current web results with your own key.' },
73
- { id: 'skills', label: 'Skills', icon: Sparkles, description: 'Instructions added to every request when enabled.' },
72
+ { id: 'skills', label: 'Skills', icon: Sparkles, description: 'Install skill folders or npm packages. The skill tool loads SKILL.md and runs the scripts a skill ships.' },
74
73
  { id: 'plugins', label: 'Plugins', icon: Package, description: 'Install local plugin packages, review what they ask for, and run them once you trust the code.' },
75
74
  { id: 'mcp', label: 'MCP', icon: Link2, description: 'Model Context Protocol servers now have their own panel, with OAuth sign-in and local, HTTP and SSE connections.' },
76
75
  { id: 'deploy', label: 'Deploy', icon: Rocket, description: 'Hosting credentials and command-line tools.' },
@@ -631,7 +630,7 @@ function ModelsTab() {
631
630
  className="thin-scroll w-full resize-y rounded-lg border border-line bg-panel px-2.5 py-2 font-mono text-[10.5px] leading-relaxed text-ink outline-none focus:border-accent/50"
632
631
  />
633
632
  <p className="mt-1 text-[9px] leading-relaxed text-faint">
634
- Enabled skill instructions and the current workspace/git context are appended after this prompt.
633
+ This is the shared prompt. Each session and workspace can override it from the prompt button in the chat header.
635
634
  </p>
636
635
  </div>
637
636
  </div>
@@ -647,7 +646,7 @@ const TOOL_ORDER: Array<{ title: string; tools: BuiltinToolName[] }> = [
647
646
  tools: ['read_file', 'list_files', 'grep', 'write_file', 'edit_file', 'rewrite_file', 'shell', 'git'],
648
647
  },
649
648
  { title: 'Web', tools: ['web_search', 'read_url'] },
650
- { title: 'Delegation', tools: ['subagent'] },
649
+ { title: 'Delegation', tools: ['subagent', 'skill'] },
651
650
  ]
652
651
 
653
652
  const TOOL_ICONS: Record<BuiltinToolName, LucideIcon> = {
@@ -662,6 +661,7 @@ const TOOL_ICONS: Record<BuiltinToolName, LucideIcon> = {
662
661
  read_url: Link2,
663
662
  git: GitBranch,
664
663
  subagent: Bot,
664
+ skill: Sparkles,
665
665
  }
666
666
 
667
667
  const TOOL_COPY: Record<BuiltinToolName, { label: string; description: string }> = {
@@ -676,6 +676,7 @@ const TOOL_COPY: Record<BuiltinToolName, { label: string; description: string }>
676
676
  read_url: { label: 'Read URL', description: 'Open a public http(s) page as plain text.' },
677
677
  git: { label: 'Git', description: 'Inspect or change repository state. Plan mode is read-only.' },
678
678
  subagent: { label: 'Subagent', description: 'Delegate a read-only investigation and return a concise report.' },
679
+ skill: { label: 'Skill', description: 'List installed skills, load SKILL.md, and run commands inside a skill folder.' },
679
680
  }
680
681
 
681
682
  function ToolsTab() {
@@ -971,17 +972,6 @@ function SearchTab() {
971
972
  )
972
973
  }
973
974
 
974
- interface SkillDraft {
975
- id?: string
976
- name: string
977
- description: string
978
- instructions: string
979
- }
980
-
981
- function newSkillId() {
982
- return typeof crypto !== 'undefined' && 'randomUUID' in crypto ? `skill_${crypto.randomUUID()}` : `skill_${Date.now()}`
983
- }
984
-
985
975
  function SkillsTab() {
986
976
  const settings = useSettings()
987
977
  const addSkill = useSettings((state) => state.addSkill)
@@ -989,63 +979,58 @@ function SkillsTab() {
989
979
  const removeSkill = useSettings((state) => state.removeSkill)
990
980
  const askConfirm = useUi((state) => state.askConfirm)
991
981
  const notify = useUi((state) => state.notify)
992
- const [draft, setDraft] = useState<SkillDraft | null>(null)
993
- const fileRef = useRef<HTMLInputElement>(null)
982
+ const [installing, setInstalling] = useState('')
983
+ const [npmName, setNpmName] = useState('')
984
+ const folderRef = useRef<HTMLInputElement>(null)
994
985
  const active = settings.skills.filter((skill) => skill.enabled).length
995
986
 
996
- const loadFile = async (file: File) => {
987
+ useEffect(() => {
988
+ const input = folderRef.current
989
+ if (!input) return
990
+ input.setAttribute('webkitdirectory', '')
991
+ input.setAttribute('directory', '')
992
+ input.setAttribute('multiple', '')
993
+ }, [])
994
+
995
+ const store = (skill: Skill, verb: string) => {
996
+ const exists = useSettings.getState().skills.some((entry) => entry.id === skill.id)
997
+ if (exists) updateSkill(skill.id, skill)
998
+ else addSkill(skill)
999
+ notify(`${skill.name} ${verb}.`, 'success')
1000
+ }
1001
+
1002
+ const installFolder = async (list: FileList | null) => {
1003
+ if (!list || list.length === 0) return
1004
+ setInstalling('folder')
997
1005
  try {
998
- const text = await readFileAsText(file)
999
- const heading = /^#\s+(.+)$/m.exec(text)
1000
- const name = heading?.[1]?.trim() || file.name.replace(/\.[^.]+$/, '')
1001
- const body = heading ? text.replace(heading[0], '').trim() : text.trim()
1002
- const paragraph = body.split(/\n\s*\n/).find((part) => part.trim() && !part.trim().startsWith('#'))
1003
- setDraft({
1004
- name,
1005
- description: (paragraph ?? '').replace(/[#>*`]/g, '').replace(/\s+/g, ' ').trim().slice(0, 180),
1006
- instructions: text.trim(),
1007
- })
1008
- notify(`Loaded ${file.name}. Review it, then save the skill.`, 'info')
1006
+ const skill = await skillFromFolder(Array.from(list))
1007
+ store(skill, 'installed')
1009
1008
  } catch (cause) {
1010
- notify(cause instanceof Error ? cause.message : 'Could not read that skill.', 'error')
1009
+ notify(cause instanceof Error ? cause.message : 'Could not read that folder.', 'error')
1010
+ } finally {
1011
+ setInstalling('')
1011
1012
  }
1012
1013
  }
1013
1014
 
1014
- const save = () => {
1015
- if (!draft) return
1016
- const name = draft.name.trim()
1017
- if (!name) {
1018
- notify('Give the skill a name.', 'error')
1019
- return
1020
- }
1021
- if (!draft.instructions.trim()) {
1022
- notify('Add instructions for the skill.', 'error')
1023
- return
1024
- }
1025
- if (draft.id) {
1026
- updateSkill(draft.id, {
1027
- name,
1028
- description: draft.description.trim(),
1029
- instructions: draft.instructions.trim(),
1030
- })
1031
- notify('Skill updated.', 'success')
1032
- } else {
1033
- addSkill({
1034
- id: newSkillId(),
1035
- name,
1036
- description: draft.description.trim() || 'Custom skill',
1037
- instructions: draft.instructions.trim(),
1038
- enabled: true,
1039
- })
1040
- notify('Skill saved and enabled.', 'success')
1015
+ const installNpm = async () => {
1016
+ const name = npmName.trim()
1017
+ if (!name) return
1018
+ setInstalling('npm')
1019
+ try {
1020
+ const { skill } = await installSkillFromNpmRequest(name)
1021
+ store(skill, 'installed from npm')
1022
+ setNpmName('')
1023
+ } catch (cause) {
1024
+ notify(cause instanceof Error ? cause.message : 'Could not install that npm package.', 'error')
1025
+ } finally {
1026
+ setInstalling('')
1041
1027
  }
1042
- setDraft(null)
1043
1028
  }
1044
1029
 
1045
1030
  const remove = async (skill: Skill) => {
1046
1031
  const confirmed = await askConfirm({
1047
1032
  title: 'Delete this skill?',
1048
- body: `“${skill.name}” will be removed from this computer.`,
1033
+ body: `“${skill.name}” and its files will be removed from this computer.`,
1049
1034
  confirmLabel: 'Delete skill',
1050
1035
  tone: 'danger',
1051
1036
  })
@@ -1054,159 +1039,115 @@ function SkillsTab() {
1054
1039
 
1055
1040
  return (
1056
1041
  <SettingsSection
1057
- title="Loaded skills"
1058
- description={`${active} of ${settings.skills.length} enabled. Enabled instructions are appended to the system prompt for every request.`}
1042
+ title="Installed skills"
1043
+ description={`${active} of ${settings.skills.length} enabled. The model sees each skill’s name and description, loads SKILL.md through the skill tool, and can run the scripts inside the skill folder.`}
1059
1044
  >
1060
- <div className="mb-3 flex flex-wrap gap-2">
1061
- <button
1062
- type="button"
1063
- onClick={() => setDraft({ name: '', description: '', instructions: '' })}
1064
- className="inline-flex h-8 items-center gap-1.5 rounded-lg bg-ink px-2.5 text-[10.5px] font-medium text-panel"
1065
- >
1066
- <Plus size={12} /> New skill
1067
- </button>
1045
+ <div className="mb-3 grid gap-2 sm:grid-cols-[auto_minmax(0,1fr)]">
1068
1046
  <button
1069
1047
  type="button"
1070
- onClick={() => fileRef.current?.click()}
1071
- className="inline-flex h-8 items-center gap-1.5 rounded-lg border border-line bg-panel px-2.5 text-[10.5px] font-medium text-ink hover:bg-raised"
1048
+ onClick={() => folderRef.current?.click()}
1049
+ disabled={Boolean(installing)}
1050
+ className="inline-flex h-9 items-center justify-center gap-1.5 rounded-lg bg-ink px-3 text-[10.5px] font-medium text-panel disabled:opacity-50"
1072
1051
  >
1073
- <Upload size={12} /> Load skill file
1052
+ {installing === 'folder' ? <Spinner size={11} /> : <Upload size={12} />}
1053
+ Install folder
1074
1054
  </button>
1075
1055
  <input
1076
- ref={fileRef}
1056
+ ref={folderRef}
1077
1057
  type="file"
1078
- accept=".md,.markdown,.txt"
1079
1058
  className="hidden"
1080
1059
  onChange={(event) => {
1081
- const file = event.target.files?.[0]
1082
- if (file) void loadFile(file)
1060
+ void installFolder(event.target.files)
1083
1061
  event.target.value = ''
1084
1062
  }}
1085
1063
  />
1064
+ <div className="flex gap-1.5">
1065
+ <input
1066
+ value={npmName}
1067
+ onChange={(event) => setNpmName(event.target.value)}
1068
+ onKeyDown={(event) => {
1069
+ if (event.key === 'Enter') void installNpm()
1070
+ }}
1071
+ placeholder="npm package, e.g. @scope/my-skill"
1072
+ spellCheck={false}
1073
+ className={cn(fieldClass, 'font-mono text-[10.5px]')}
1074
+ />
1075
+ <button
1076
+ type="button"
1077
+ onClick={() => void installNpm()}
1078
+ disabled={!npmName.trim() || Boolean(installing)}
1079
+ className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-lg border border-line bg-panel px-2.5 text-[10.5px] font-medium text-ink hover:bg-raised disabled:opacity-40"
1080
+ >
1081
+ {installing === 'npm' ? <Spinner size={11} /> : <ArrowDownToLine size={12} />}
1082
+ Install
1083
+ </button>
1084
+ </div>
1086
1085
  </div>
1087
-
1088
- {draft && (
1089
- <div className="mb-3 rounded-xl border border-accent/30 bg-accent-soft/20 p-3">
1090
- <div className="mb-2 flex items-center justify-between">
1091
- <p className="text-[11px] font-medium text-ink">{draft.id ? 'Edit skill' : 'Load a skill'}</p>
1092
- <button type="button" className="icon-button h-6 w-6" onClick={() => setDraft(null)} aria-label="Close editor">
1093
- <X size={12} />
1094
- </button>
1095
- </div>
1096
- <div className="grid gap-2 sm:grid-cols-2">
1097
- <div>
1098
- <Label>Name</Label>
1099
- <input
1100
- value={draft.name}
1101
- onChange={(event) => setDraft({ ...draft, name: event.target.value })}
1102
- placeholder="API contract reviewer"
1103
- className={fieldClass}
1104
- autoFocus
1105
- />
1106
- </div>
1107
- <div>
1108
- <Label hint="Shown in the skills list">Description</Label>
1109
- <input
1110
- value={draft.description}
1111
- onChange={(event) => setDraft({ ...draft, description: event.target.value })}
1112
- placeholder="Checks a diff against an API contract"
1113
- className={fieldClass}
1114
- />
1115
- </div>
1116
- </div>
1117
- <div className="mt-2">
1118
- <Label hint="Markdown">Instructions</Label>
1119
- <textarea
1120
- value={draft.instructions}
1121
- onChange={(event) => setDraft({ ...draft, instructions: event.target.value })}
1122
- placeholder={STARTER_SKILL}
1123
- rows={8}
1124
- className="thin-scroll w-full resize-y rounded-lg border border-line bg-panel px-2.5 py-2 font-mono text-[10.5px] leading-relaxed text-ink outline-none focus:border-accent/50"
1125
- />
1126
- </div>
1127
- <div className="mt-2 flex justify-end gap-2">
1128
- <button type="button" className="chip h-7 text-[10.5px]" onClick={() => setDraft(null)}>
1129
- Cancel
1130
- </button>
1131
- <button
1132
- type="button"
1133
- onClick={save}
1134
- className="inline-flex h-7 items-center gap-1 rounded-lg bg-accent px-2.5 text-[10.5px] font-medium text-on-accent hover:bg-accent/90"
1135
- >
1136
- <Check size={11} /> Save skill
1137
- </button>
1138
- </div>
1086
+ <p className="mb-3 text-[9.5px] leading-relaxed text-faint">
1087
+ A skill is a folder with a <span className="font-mono">SKILL.md</span> (name, description, instructions) plus any scripts it needs.
1088
+ Folder uploads are read in your browser; npm packages come from the public registry through the local daemon.
1089
+ </p>
1090
+
1091
+ {settings.skills.length === 0 ? (
1092
+ <div className="rounded-xl border border-dashed border-line px-4 py-8 text-center">
1093
+ <Sparkles size={17} className="mx-auto text-faint" />
1094
+ <p className="mt-2 text-[11.5px] font-medium text-ink">No skills installed</p>
1095
+ <p className="mt-1 text-[10px] text-muted">Install a skill folder or an npm package to give the agent new procedures and scripts.</p>
1139
1096
  </div>
1140
- )}
1141
-
1142
- <div className="overflow-hidden rounded-xl border border-line">
1143
- {settings.skills.map((skill, index) => (
1144
- <div key={skill.id} className={cn('px-3 py-3', index !== 0 && 'border-t border-line')}>
1145
- <div className="flex items-start gap-2.5">
1146
- <span className="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-accent-soft text-accent">
1147
- <Sparkles size={11} />
1148
- </span>
1149
- <div className="min-w-0 flex-1">
1150
- <div className="flex flex-wrap items-center gap-1.5">
1151
- <p className="text-[11.5px] font-medium text-ink">{skill.name}</p>
1152
- {skill.builtIn && (
1153
- <span className="rounded bg-raised px-1.5 py-0.5 text-[8px] font-medium text-faint">Built-in</span>
1154
- )}
1155
- <span className={cn('text-[8.5px]', skill.enabled ? 'text-success' : 'text-faint')}>
1156
- {skill.enabled ? 'Enabled' : 'Off'}
1157
- </span>
1097
+ ) : (
1098
+ <div className="overflow-hidden rounded-xl border border-line">
1099
+ {settings.skills.map((skill, index) => (
1100
+ <div key={skill.id} className={cn('px-3 py-3', index !== 0 && 'border-t border-line')}>
1101
+ <div className="flex items-start gap-2.5">
1102
+ <span className="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-accent-soft text-accent">
1103
+ <Sparkles size={11} />
1104
+ </span>
1105
+ <div className="min-w-0 flex-1">
1106
+ <div className="flex flex-wrap items-center gap-1.5">
1107
+ <p className="text-[11.5px] font-medium text-ink">{skill.name}</p>
1108
+ <span className="rounded bg-raised px-1.5 py-0.5 text-[8px] font-medium uppercase tracking-wide text-faint">
1109
+ {skill.source ?? 'folder'}
1110
+ </span>
1111
+ {skill.version && <span className="font-mono text-[8.5px] text-faint">v{skill.version}</span>}
1112
+ <span className={cn('text-[8.5px]', skill.enabled ? 'text-success' : 'text-faint')}>
1113
+ {skill.enabled ? 'Enabled' : 'Off'}
1114
+ </span>
1115
+ </div>
1116
+ <p className="mt-0.5 text-[9.5px] leading-relaxed text-muted">{skill.description}</p>
1117
+ <p className="mt-0.5 truncate font-mono text-[8.5px] text-faint" title={skill.package || skill.id}>
1118
+ {skill.package || skill.id} · {Object.keys(skill.files ?? {}).length} files
1119
+ </p>
1120
+ <details className="group/skill mt-1">
1121
+ <summary className="inline-flex cursor-pointer list-none items-center gap-1 text-[9px] font-medium text-faint hover:text-muted [&::-webkit-details-marker]:hidden">
1122
+ View SKILL.md
1123
+ <ChevronDown size={9} className="transition-transform group-open/skill:rotate-180" />
1124
+ </summary>
1125
+ <pre className="thin-scroll mt-1.5 max-h-44 overflow-auto whitespace-pre-wrap rounded-lg border border-line bg-raised/40 p-2 font-mono text-[9.5px] leading-relaxed text-muted">
1126
+ {skill.instructions}
1127
+ </pre>
1128
+ </details>
1129
+ </div>
1130
+ <div className="flex shrink-0 items-center gap-1">
1131
+ <button
1132
+ type="button"
1133
+ onClick={() => void remove(skill)}
1134
+ className="icon-button h-7 w-7 hover:text-danger"
1135
+ aria-label={`Delete ${skill.name}`}
1136
+ title="Delete skill"
1137
+ >
1138
+ <Trash2 size={12} />
1139
+ </button>
1140
+ <Switch
1141
+ checked={skill.enabled}
1142
+ onChange={(enabled) => updateSkill(skill.id, { enabled })}
1143
+ label={`Enable ${skill.name}`}
1144
+ />
1158
1145
  </div>
1159
- <p className="mt-0.5 text-[9.5px] leading-relaxed text-muted">{skill.description}</p>
1160
- <details className="group/skill mt-1">
1161
- <summary className="inline-flex cursor-pointer list-none items-center gap-1 text-[9px] font-medium text-faint hover:text-muted [&::-webkit-details-marker]:hidden">
1162
- View instructions
1163
- <ChevronDown size={9} className="transition-transform group-open/skill:rotate-180" />
1164
- </summary>
1165
- <pre className="thin-scroll mt-1.5 max-h-44 overflow-auto whitespace-pre-wrap rounded-lg border border-line bg-raised/40 p-2 font-mono text-[9.5px] leading-relaxed text-muted">
1166
- {skill.instructions}
1167
- </pre>
1168
- </details>
1169
- </div>
1170
- <div className="flex shrink-0 items-center gap-1">
1171
- {!skill.builtIn && (
1172
- <>
1173
- <button
1174
- type="button"
1175
- onClick={() =>
1176
- setDraft({
1177
- id: skill.id,
1178
- name: skill.name,
1179
- description: skill.description,
1180
- instructions: skill.instructions,
1181
- })
1182
- }
1183
- className="icon-button h-7 w-7"
1184
- aria-label={`Edit ${skill.name}`}
1185
- title="Edit skill"
1186
- >
1187
- <FilePenLine size={12} />
1188
- </button>
1189
- <button
1190
- type="button"
1191
- onClick={() => void remove(skill)}
1192
- className="icon-button h-7 w-7 hover:text-danger"
1193
- aria-label={`Delete ${skill.name}`}
1194
- title="Delete skill"
1195
- >
1196
- <Trash2 size={12} />
1197
- </button>
1198
- </>
1199
- )}
1200
- <Switch
1201
- checked={skill.enabled}
1202
- onChange={(enabled) => updateSkill(skill.id, { enabled })}
1203
- label={`Enable ${skill.name}`}
1204
- />
1205
1146
  </div>
1206
1147
  </div>
1207
- </div>
1208
- ))}
1209
- </div>
1148
+ ))}
1149
+ </div>
1150
+ )}
1210
1151
  </SettingsSection>
1211
1152
  )
1212
1153
  }
@@ -2038,14 +1979,17 @@ export function SettingsModal() {
2038
1979
  {TABS.map((item, index) => {
2039
1980
  const Icon = item.icon
2040
1981
  const active = tab === item.id
1982
+ const disabled = item.id === 'plugins'
2041
1983
  return (
2042
1984
  <button
2043
1985
  key={item.id}
2044
1986
  type="button"
1987
+ disabled={disabled}
2045
1988
  onClick={() => openSettings(item.id)}
2046
- title={item.description}
1989
+ title={disabled ? 'Plugins are disabled for maintenance' : item.description}
2047
1990
  className={cn(
2048
1991
  'group/tab relative mb-[2px] flex h-10 w-full items-center gap-3 rounded-2xl px-3 text-left text-[12.5px] transition-colors duration-200',
1992
+ disabled && 'cursor-not-allowed opacity-40',
2049
1993
  active ? 'font-semibold text-ink' : 'text-muted hover:text-ink',
2050
1994
  )}
2051
1995
  >
@@ -2054,6 +1998,7 @@ export function SettingsModal() {
2054
1998
  </span>
2055
1999
  <Icon size={14} className={cn('transition-transform duration-300 group-hover/tab:scale-110', active ? 'text-accent' : 'text-faint')} />
2056
2000
  {item.label}
2001
+ {disabled && <span className="ml-auto text-[8px] text-faint">off</span>}
2057
2002
  </button>
2058
2003
  )
2059
2004
  })}
@@ -243,14 +243,13 @@ export function Sidebar() {
243
243
  </button>
244
244
  <button
245
245
  type="button"
246
- onClick={() => {
247
- setMobileOpen(false)
248
- openSettings('plugins')
249
- }}
250
- className="nav-item"
246
+ disabled
247
+ title="Plugins are disabled for maintenance"
248
+ className="nav-item cursor-not-allowed opacity-45"
251
249
  >
252
250
  <Package size={15} strokeWidth={1.7} className="shrink-0 text-faint" />
253
251
  Plugins
252
+ <span className="ml-auto rounded bg-raised px-1.5 py-0.5 text-[8.5px] font-medium text-faint">maintenance</span>
254
253
  </button>
255
254
  <button
256
255
  type="button"
@@ -0,0 +1,168 @@
1
+ import { useEffect, useState } from 'react'
2
+ import { Check, MessageSquareCode, RotateCcw } from 'lucide-react'
3
+ import type { ChatSession } from '../types'
4
+ import { useChats } from '../store/chats'
5
+ import { DEFAULT_SYSTEM_PROMPT, useSettings } from '../store/settings'
6
+ import { useUi } from '../store/ui'
7
+ import { useWorkspace } from '../store/workspace'
8
+ import { cn } from '../lib/utils'
9
+ import { Modal, ModalHeader } from './ui'
10
+
11
+ type Mode = 'shared' | 'workspace' | 'session'
12
+
13
+ const MODES: Array<{ id: Mode; label: string; hint: string }> = [
14
+ { id: 'shared', label: 'Shared prompt', hint: 'The general prompt from Settings → Models.' },
15
+ { id: 'workspace', label: 'This workspace', hint: 'Applies to every session opened in this folder.' },
16
+ { id: 'session', label: 'This session', hint: 'Only this conversation. Overrides the workspace prompt.' },
17
+ ]
18
+
19
+ export function SystemPromptModal({
20
+ session,
21
+ open,
22
+ onClose,
23
+ }: {
24
+ session: ChatSession | null
25
+ open: boolean
26
+ onClose: () => void
27
+ }) {
28
+ const settings = useSettings()
29
+ const update = useSettings((state) => state.update)
30
+ const updateSession = useChats((state) => state.updateSession)
31
+ const notify = useUi((state) => state.notify)
32
+ const workspaceRoot = useWorkspace((state) => state.root)
33
+ const root = session?.workspace || workspaceRoot || ''
34
+ const workspacePrompt = root ? settings.workspacePrompts[root] ?? '' : ''
35
+ const sessionPrompt = session?.systemPrompt ?? ''
36
+ const current: Mode = sessionPrompt.trim() ? 'session' : workspacePrompt.trim() ? 'workspace' : 'shared'
37
+
38
+ const [mode, setMode] = useState<Mode>(current)
39
+ const [draft, setDraft] = useState('')
40
+
41
+ useEffect(() => {
42
+ if (!open) return
43
+ setMode(current)
44
+ setDraft(current === 'session' ? sessionPrompt : current === 'workspace' ? workspacePrompt : '')
45
+ }, [open, current, sessionPrompt, workspacePrompt])
46
+
47
+ const editorValue = mode === 'shared' ? settings.systemPrompt : draft
48
+
49
+ const save = () => {
50
+ if (mode === 'shared') {
51
+ if (session) updateSession(session.id, { systemPrompt: '' })
52
+ if (root) update({ workspacePrompts: { ...settings.workspacePrompts, [root]: '' } })
53
+ notify('Using the shared system prompt.', 'success')
54
+ onClose()
55
+ return
56
+ }
57
+ if (mode === 'workspace') {
58
+ if (!root) {
59
+ notify('Open a workspace folder before saving a workspace prompt.', 'error')
60
+ return
61
+ }
62
+ const value = draft.trim()
63
+ if (!value) {
64
+ notify('Write a prompt or choose the shared prompt.', 'error')
65
+ return
66
+ }
67
+ update({ workspacePrompts: { ...settings.workspacePrompts, [root]: value } })
68
+ if (session) updateSession(session.id, { systemPrompt: '' })
69
+ notify('Workspace system prompt saved.', 'success')
70
+ onClose()
71
+ return
72
+ }
73
+ if (!session) return
74
+ const value = draft.trim()
75
+ if (!value) {
76
+ notify('Write a prompt or choose the shared prompt.', 'error')
77
+ return
78
+ }
79
+ updateSession(session.id, { systemPrompt: value })
80
+ notify('Session system prompt saved.', 'success')
81
+ onClose()
82
+ }
83
+
84
+ return (
85
+ <Modal open={open} onClose={onClose} labelledBy="system-prompt-title">
86
+ <ModalHeader
87
+ id="system-prompt-title"
88
+ title="System prompt"
89
+ description="Choose what the model is told before this conversation. Session overrides workspace, which overrides the shared prompt."
90
+ onClose={onClose}
91
+ />
92
+ <div className="thin-scroll min-h-0 flex-1 overflow-y-auto p-5">
93
+ <div className="grid gap-1.5 sm:grid-cols-3">
94
+ {MODES.map((entry) => {
95
+ const active = mode === entry.id
96
+ const disabled = entry.id === 'workspace' && !root
97
+ return (
98
+ <button
99
+ key={entry.id}
100
+ type="button"
101
+ disabled={disabled}
102
+ onClick={() => {
103
+ setMode(entry.id)
104
+ setDraft(entry.id === 'workspace' ? workspacePrompt : entry.id === 'session' ? sessionPrompt : '')
105
+ }}
106
+ className={cn(
107
+ 'rounded-xl border px-3 py-2.5 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-40',
108
+ active ? 'border-accent/40 bg-accent-soft' : 'border-line bg-panel hover:bg-raised/60',
109
+ )}
110
+ >
111
+ <span className={cn('flex items-center gap-1.5 text-[11.5px] font-medium', active ? 'text-accent' : 'text-ink')}>
112
+ <MessageSquareCode size={11} />
113
+ {entry.label}
114
+ </span>
115
+ <span className="mt-1 block text-[9.5px] leading-relaxed text-faint">{entry.hint}</span>
116
+ </button>
117
+ )
118
+ })}
119
+ </div>
120
+
121
+ <div className="mt-4">
122
+ <div className="mb-1 flex items-center justify-between gap-3">
123
+ <span className="text-[10.5px] font-medium text-muted">
124
+ {mode === 'shared' ? 'Shared prompt (edited in Settings → Models)' : mode === 'workspace' ? `Workspace prompt · ${root}` : 'Session prompt'}
125
+ </span>
126
+ {mode !== 'shared' && (
127
+ <button
128
+ type="button"
129
+ onClick={() => setDraft(DEFAULT_SYSTEM_PROMPT)}
130
+ className="inline-flex items-center gap-1 text-[9.5px] font-medium text-faint hover:text-ink"
131
+ >
132
+ <RotateCcw size={10} /> Start from default
133
+ </button>
134
+ )}
135
+ </div>
136
+ <textarea
137
+ value={editorValue}
138
+ onChange={(event) => {
139
+ if (mode === 'shared') update({ systemPrompt: event.target.value })
140
+ else setDraft(event.target.value)
141
+ }}
142
+ readOnly={mode === 'shared'}
143
+ rows={11}
144
+ spellCheck={false}
145
+ data-autofocus
146
+ className="thin-scroll w-full resize-y rounded-xl border border-line bg-canvas/50 px-3 py-2.5 font-mono text-[10.5px] leading-relaxed text-ink outline-none focus:border-accent/50 read-only:opacity-70"
147
+ placeholder="You are a focused React reviewer…"
148
+ />
149
+ <p className="mt-1.5 text-[9px] leading-relaxed text-faint">
150
+ Workspace context, project instructions, skills and tool rules are appended after this prompt. A custom prompt replaces the shared one.
151
+ </p>
152
+ </div>
153
+ </div>
154
+ <div className="flex shrink-0 items-center justify-end gap-2 border-t border-line px-5 py-3">
155
+ <button type="button" onClick={onClose} className="chip h-8 px-3 text-[11px]">
156
+ Cancel
157
+ </button>
158
+ <button
159
+ type="button"
160
+ onClick={save}
161
+ className="inline-flex h-8 items-center gap-1.5 rounded-lg bg-accent px-3 text-[11px] font-medium text-on-accent hover:bg-accent/90"
162
+ >
163
+ <Check size={12} /> Save
164
+ </button>
165
+ </div>
166
+ </Modal>
167
+ )
168
+ }