@bibobonking/opensol 3.1.4 → 3.1.5

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 (72) hide show
  1. package/CHANGELOG.md +10 -12
  2. package/desktop/src-tauri/icons/128x128.png +0 -0
  3. package/desktop/src-tauri/icons/128x128@2x.png +0 -0
  4. package/desktop/src-tauri/icons/32x32.png +0 -0
  5. package/desktop/src-tauri/icons/64x64.png +0 -0
  6. package/desktop/src-tauri/icons/Square107x107Logo.png +0 -0
  7. package/desktop/src-tauri/icons/Square142x142Logo.png +0 -0
  8. package/desktop/src-tauri/icons/Square150x150Logo.png +0 -0
  9. package/desktop/src-tauri/icons/Square284x284Logo.png +0 -0
  10. package/desktop/src-tauri/icons/Square30x30Logo.png +0 -0
  11. package/desktop/src-tauri/icons/Square310x310Logo.png +0 -0
  12. package/desktop/src-tauri/icons/Square44x44Logo.png +0 -0
  13. package/desktop/src-tauri/icons/Square71x71Logo.png +0 -0
  14. package/desktop/src-tauri/icons/Square89x89Logo.png +0 -0
  15. package/desktop/src-tauri/icons/StoreLogo.png +0 -0
  16. package/desktop/src-tauri/icons/icon.icns +0 -0
  17. package/desktop/src-tauri/icons/icon.ico +0 -0
  18. package/desktop/src-tauri/icons/icon.png +0 -0
  19. package/dist/assets/index-C0yvs6Vl.js +281 -0
  20. package/dist/assets/index-CxNPLBg7.css +1 -0
  21. package/dist/favicon.svg +18 -15
  22. package/dist/index.html +2 -2
  23. package/package.json +1 -1
  24. package/public/favicon.svg +18 -15
  25. package/server/daemon.mjs +66 -49
  26. package/server/lib/deploy.mjs +222 -121
  27. package/server/lib/providers.mjs +23 -1
  28. package/server/lib/skills.mjs +385 -100
  29. package/server/lib/tools.mjs +0 -40
  30. package/src/agent/adapters/openai-responses.ts +1 -5
  31. package/src/agent/adapters/openai.ts +4 -11
  32. package/src/agent/prompt.ts +7 -14
  33. package/src/agent/subagent.ts +3 -14
  34. package/src/agent/tools.ts +2 -0
  35. package/src/components/ChatView.tsx +2 -1
  36. package/src/components/Composer.tsx +2 -1
  37. package/src/components/ContextBar.tsx +10 -0
  38. package/src/components/DeployModal.tsx +266 -252
  39. package/src/components/FileIcon.tsx +391 -99
  40. package/src/components/HomeDashboard.tsx +5 -10
  41. package/src/components/Logo.tsx +9 -11
  42. package/src/components/ModelDeck.tsx +8 -11
  43. package/src/components/SettingsModal.tsx +160 -122
  44. package/src/components/Sidebar.tsx +4 -3
  45. package/src/components/SystemPromptButton.tsx +198 -0
  46. package/src/components/ToolCallCard.tsx +2 -0
  47. package/src/components/TopBar.tsx +2 -19
  48. package/src/components/WorkspacePanel.tsx +76 -93
  49. package/src/core/plugins/tools/skill.ts +19 -76
  50. package/src/core/services/agent-loop.ts +16 -31
  51. package/src/core/services/subagents.ts +2 -2
  52. package/src/core/services/system-prompt.ts +16 -23
  53. package/src/core/services/tools.ts +0 -1
  54. package/src/lib/boot.ts +2 -0
  55. package/src/lib/credits.ts +33 -0
  56. package/src/lib/daemon.ts +40 -19
  57. package/src/lib/permissions.ts +1 -1
  58. package/src/lib/retry.test.mjs +40 -12
  59. package/src/lib/retry.ts +41 -8
  60. package/src/lib/system-prompt.ts +23 -0
  61. package/src/lib/usage.ts +8 -13
  62. package/src/store/chats.ts +0 -1
  63. package/src/store/settings.ts +12 -44
  64. package/src/store/skills.ts +79 -0
  65. package/src/store/ui.ts +3 -1
  66. package/src/types.ts +30 -15
  67. package/tsconfig.tsbuildinfo +1 -1
  68. package/dist/assets/index-B1pQC9Fe.js +0 -288
  69. package/dist/assets/index-qLr4aZVC.css +0 -1
  70. package/src/components/SystemPromptModal.tsx +0 -168
  71. package/src/lib/skills.test.mjs +0 -22
  72. package/src/lib/skills.ts +0 -93
@@ -6,6 +6,7 @@ import {
6
6
  ChevronDown,
7
7
  Cloud,
8
8
  Cpu,
9
+ Download,
9
10
  Eye,
10
11
  EyeOff,
11
12
  FilePenLine,
@@ -13,6 +14,7 @@ import {
13
14
  FileSearch,
14
15
  FileText,
15
16
  FolderTree,
17
+ FolderUp,
16
18
  GitBranch,
17
19
  Globe2,
18
20
  HardDrive,
@@ -38,9 +40,10 @@ import {
38
40
  Wrench,
39
41
  X,
40
42
  } from 'lucide-react'
41
- import type { DeployProviderId, Effort, ModelInfo, ProviderId, SearchProvider, Skill, BuiltinToolName } from '../types'
43
+ import type { DeployProviderId, Effort, InstalledSkill, ModelInfo, ProviderId, SearchProvider, BuiltinToolName } from '../types'
42
44
  import type { LucideIcon } from 'lucide-react'
43
45
  import { EFFORT_LABELS, PROVIDERS, PROVIDER_MAP } from '../data/providers'
46
+ import { useSkills } from '../store/skills'
44
47
  import {
45
48
  DEFAULT_SYSTEM_PROMPT,
46
49
  activeModelId,
@@ -53,8 +56,7 @@ import { useChats, exportChats, importChats } from '../store/chats'
53
56
  import { useWorkspace } from '../store/workspace'
54
57
  import { stopAllRuns } from '../agent'
55
58
  import { applySnapshot } from '../lib/boot'
56
- import { executeDaemonTool, installSkillFromNpmRequest, resetScope } from '../lib/daemon'
57
- import { skillFromFolder } from '../lib/skills'
59
+ import { executeDaemonTool, resetScope } from '../lib/daemon'
58
60
  import { effortOptions, reasoningSummary } from '../lib/reasoning'
59
61
  import { PERMISSION_MODES, permissionMode, permissionTools } from '../lib/permissions'
60
62
  import { cn, formatBytes } from '../lib/utils'
@@ -64,18 +66,19 @@ import { useMcp } from '../store/mcp'
64
66
  import { pluginRuntime } from '../plugins'
65
67
  import { Modal, Segmented, Spinner, Switch } from './ui'
66
68
 
67
- const TABS = [
69
+ type TabDef = { id: string; label: string; icon: typeof Info; description: string; disabled?: boolean }
70
+ const TABS: readonly TabDef[] = [
68
71
  { id: 'general', label: 'General', icon: SlidersHorizontal, description: 'Permissions, appearance and conversation display.' },
69
72
  { id: 'models', label: 'Models', icon: Cpu, description: 'Providers, encrypted keys, live models and reasoning defaults.' },
70
73
  { id: 'tools', label: 'Tools', icon: Wrench, description: 'What OpenSol can do on this machine, and when it must ask.' },
71
74
  { id: 'search', label: 'Search', icon: Globe2, description: 'Give the agent current web results with your own key.' },
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.' },
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
+ { id: 'skills', label: 'Skills', icon: Sparkles, description: 'Skill folders the agent loads on demand: upload a folder or install from npm.' },
76
+ { id: 'plugins', label: 'Plugins', icon: Package, description: 'Plugins are temporarily disabled for maintenance.', disabled: true },
74
77
  { 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.' },
75
78
  { id: 'deploy', label: 'Deploy', icon: Rocket, description: 'Hosting credentials and command-line tools.' },
76
79
  { id: 'appearance', label: 'Appearance', icon: Palette, description: 'Theme and interface preferences.' },
77
80
  { id: 'about', label: 'About', icon: Info, description: 'Local storage, history and reset controls.' },
78
- ] as const
81
+ ]
79
82
 
80
83
  const fieldClass =
81
84
  'h-8 w-full rounded-lg border border-line bg-panel px-2.5 text-[12px] text-ink outline-none transition-colors placeholder:text-faint focus:border-accent/50 disabled:opacity-50'
@@ -630,7 +633,7 @@ function ModelsTab() {
630
633
  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"
631
634
  />
632
635
  <p className="mt-1 text-[9px] leading-relaxed text-faint">
633
- This is the shared prompt. Each session and workspace can override it from the prompt button in the chat header.
636
+ The installed skill list and the current workspace/git context are appended after this prompt.
634
637
  </p>
635
638
  </div>
636
639
  </div>
@@ -676,7 +679,7 @@ const TOOL_COPY: Record<BuiltinToolName, { label: string; description: string }>
676
679
  read_url: { label: 'Read URL', description: 'Open a public http(s) page as plain text.' },
677
680
  git: { label: 'Git', description: 'Inspect or change repository state. Plan mode is read-only.' },
678
681
  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.' },
682
+ skill: { label: 'Skill', description: 'Load an installed skill (SKILL.md and its bundled files) when a task matches it.' },
680
683
  }
681
684
 
682
685
  function ToolsTab() {
@@ -972,165 +975,202 @@ function SearchTab() {
972
975
  )
973
976
  }
974
977
 
978
+ interface PickedFile {
979
+ path: string
980
+ file: File
981
+ }
982
+
983
+ const SKIP_PATH = /(^|\/)(node_modules|\.git|__pycache__)(\/|$)/
984
+ const MAX_SKILL_FILE = 1024 * 1024
985
+
986
+ async function fileToBase64(file: File): Promise<string> {
987
+ const bytes = new Uint8Array(await file.arrayBuffer())
988
+ let binary = ''
989
+ const chunk = 0x8000
990
+ for (let index = 0; index < bytes.length; index += chunk) {
991
+ binary += String.fromCharCode(...bytes.subarray(index, index + chunk))
992
+ }
993
+ return btoa(binary)
994
+ }
995
+
996
+ function formatSkillSource(source: string) {
997
+ if (source.startsWith('npm:')) return source
998
+ if (source === 'upload') return 'Folder'
999
+ if (source === 'legacy') return 'Migrated'
1000
+ return 'Local'
1001
+ }
1002
+
975
1003
  function SkillsTab() {
976
- const settings = useSettings()
977
- const addSkill = useSettings((state) => state.addSkill)
978
- const updateSkill = useSettings((state) => state.updateSkill)
979
- const removeSkill = useSettings((state) => state.removeSkill)
1004
+ const skills = useSkills((state) => state.skills)
1005
+ const loading = useSkills((state) => state.loading)
1006
+ const error = useSkills((state) => state.error)
1007
+ const load = useSkills((state) => state.load)
1008
+ const upload = useSkills((state) => state.upload)
1009
+ const installNpm = useSkills((state) => state.installNpm)
1010
+ const setEnabled = useSkills((state) => state.setEnabled)
1011
+ const remove = useSkills((state) => state.remove)
980
1012
  const askConfirm = useUi((state) => state.askConfirm)
981
1013
  const notify = useUi((state) => state.notify)
982
- const [installing, setInstalling] = useState('')
983
- const [npmName, setNpmName] = useState('')
984
1014
  const folderRef = useRef<HTMLInputElement>(null)
985
- const active = settings.skills.filter((skill) => skill.enabled).length
1015
+ const [busy, setBusy] = useState<'folder' | 'npm' | null>(null)
1016
+ const [npmSpec, setNpmSpec] = useState('')
1017
+ const active = skills.filter((skill) => skill.enabled).length
986
1018
 
987
1019
  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
- }
1020
+ void load()
1021
+ }, [load])
1001
1022
 
1002
- const installFolder = async (list: FileList | null) => {
1003
- if (!list || list.length === 0) return
1004
- setInstalling('folder')
1023
+ const uploadFolder = async (list: FileList) => {
1024
+ const picked: PickedFile[] = Array.from(list)
1025
+ .map((file) => ({ file, path: (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name }))
1026
+ .filter((item) => !SKIP_PATH.test(item.path))
1027
+ if (picked.length === 0) return
1028
+ const tooBig = picked.filter((item) => item.file.size > MAX_SKILL_FILE)
1029
+ const usable = picked.filter((item) => item.file.size <= MAX_SKILL_FILE)
1030
+ if (!usable.some((item) => /(^|\/)skill\.md$/i.test(item.path))) {
1031
+ notify('That folder has no SKILL.md. Choose the skill folder itself (or a folder that contains skill folders).', 'error')
1032
+ return
1033
+ }
1034
+ setBusy('folder')
1005
1035
  try {
1006
- const skill = await skillFromFolder(Array.from(list))
1007
- store(skill, 'installed')
1036
+ const files = await Promise.all(usable.map(async (item) => ({ path: item.path, data: await fileToBase64(item.file) })))
1037
+ const installed = await upload(files)
1038
+ notify(
1039
+ `Installed ${installed.join(', ')}${tooBig.length > 0 ? ` · skipped ${tooBig.length} file${tooBig.length === 1 ? '' : 's'} over 1 MB` : ''}.`,
1040
+ 'success',
1041
+ )
1008
1042
  } catch (cause) {
1009
- notify(cause instanceof Error ? cause.message : 'Could not read that folder.', 'error')
1043
+ notify(cause instanceof Error ? cause.message : 'Could not install that skill folder.', 'error')
1010
1044
  } finally {
1011
- setInstalling('')
1045
+ setBusy(null)
1012
1046
  }
1013
1047
  }
1014
1048
 
1015
- const installNpm = async () => {
1016
- const name = npmName.trim()
1017
- if (!name) return
1018
- setInstalling('npm')
1049
+ const installFromNpm = async () => {
1050
+ const spec = npmSpec.trim()
1051
+ if (!spec) return
1052
+ setBusy('npm')
1019
1053
  try {
1020
- const { skill } = await installSkillFromNpmRequest(name)
1021
- store(skill, 'installed from npm')
1022
- setNpmName('')
1054
+ const installed = await installNpm(spec)
1055
+ setNpmSpec('')
1056
+ notify(`Installed ${installed.join(', ')} from npm.`, 'success')
1023
1057
  } catch (cause) {
1024
1058
  notify(cause instanceof Error ? cause.message : 'Could not install that npm package.', 'error')
1025
1059
  } finally {
1026
- setInstalling('')
1060
+ setBusy(null)
1027
1061
  }
1028
1062
  }
1029
1063
 
1030
- const remove = async (skill: Skill) => {
1064
+ const askRemove = async (skill: InstalledSkill) => {
1031
1065
  const confirmed = await askConfirm({
1032
1066
  title: 'Delete this skill?',
1033
- body: `“${skill.name}” and its files will be removed from this computer.`,
1067
+ body: `“${skill.name}” and its folder will be removed from this computer.`,
1034
1068
  confirmLabel: 'Delete skill',
1035
1069
  tone: 'danger',
1036
1070
  })
1037
- if (confirmed) removeSkill(skill.id)
1071
+ if (!confirmed) return
1072
+ try {
1073
+ await remove(skill.name)
1074
+ } catch (cause) {
1075
+ notify(cause instanceof Error ? cause.message : 'Could not delete the skill.', 'error')
1076
+ }
1038
1077
  }
1039
1078
 
1040
1079
  return (
1041
1080
  <SettingsSection
1042
1081
  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.`}
1082
+ description={`${active} of ${skills.length} enabled. The model sees each skill's name and description, then loads the full SKILL.md and bundled files on demand with the skill tool.`}
1044
1083
  >
1045
- <div className="mb-3 grid gap-2 sm:grid-cols-[auto_minmax(0,1fr)]">
1046
- <button
1047
- type="button"
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"
1051
- >
1052
- {installing === 'folder' ? <Spinner size={11} /> : <Upload size={12} />}
1053
- Install folder
1054
- </button>
1055
- <input
1056
- ref={folderRef}
1057
- type="file"
1058
- className="hidden"
1059
- onChange={(event) => {
1060
- void installFolder(event.target.files)
1061
- event.target.value = ''
1062
- }}
1063
- />
1064
- <div className="flex gap-1.5">
1084
+ <div className="mb-3 rounded-xl border border-line p-3">
1085
+ <div className="flex flex-wrap items-center gap-2">
1086
+ <button
1087
+ type="button"
1088
+ onClick={() => folderRef.current?.click()}
1089
+ disabled={busy !== null}
1090
+ className="inline-flex h-8 items-center gap-1.5 rounded-lg bg-ink px-2.5 text-[10.5px] font-medium text-panel disabled:opacity-50"
1091
+ >
1092
+ {busy === 'folder' ? <Spinner size={12} /> : <FolderUp size={12} />} Upload skill folder
1093
+ </button>
1065
1094
  <input
1066
- value={npmName}
1067
- onChange={(event) => setNpmName(event.target.value)}
1068
- onKeyDown={(event) => {
1069
- if (event.key === 'Enter') void installNpm()
1095
+ ref={folderRef}
1096
+ type="file"
1097
+ className="hidden"
1098
+ multiple
1099
+ // @ts-expect-error webkitdirectory is a non-standard but widely supported attribute
1100
+ webkitdirectory=""
1101
+ directory=""
1102
+ onChange={(event) => {
1103
+ if (event.target.files) void uploadFolder(event.target.files)
1104
+ event.target.value = ''
1070
1105
  }}
1071
- placeholder="npm package, e.g. @scope/my-skill"
1072
- spellCheck={false}
1073
- className={cn(fieldClass, 'font-mono text-[10.5px]')}
1074
1106
  />
1107
+ <span className="text-[10px] text-faint">A folder with a SKILL.md, plus optional scripts/ and references/.</span>
1108
+ </div>
1109
+ <form
1110
+ className="mt-2.5 flex flex-wrap items-center gap-2"
1111
+ onSubmit={(event) => {
1112
+ event.preventDefault()
1113
+ void installFromNpm()
1114
+ }}
1115
+ >
1116
+ <div className="relative min-w-[220px] flex-1">
1117
+ <Package size={12} className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-faint" />
1118
+ <input
1119
+ value={npmSpec}
1120
+ onChange={(event) => setNpmSpec(event.target.value)}
1121
+ placeholder="npm package, e.g. @scope/my-skills"
1122
+ className={cn(fieldClass, 'pl-7 font-mono text-[11px]')}
1123
+ aria-label="npm package"
1124
+ spellCheck={false}
1125
+ />
1126
+ </div>
1075
1127
  <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"
1128
+ type="submit"
1129
+ disabled={busy !== null || !npmSpec.trim()}
1130
+ 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 disabled:opacity-50"
1080
1131
  >
1081
- {installing === 'npm' ? <Spinner size={11} /> : <ArrowDownToLine size={12} />}
1082
- Install
1132
+ {busy === 'npm' ? <Spinner size={12} /> : <Download size={12} />} Install from npm
1083
1133
  </button>
1084
- </div>
1134
+ </form>
1135
+ <p className="mt-2 text-[9.5px] leading-relaxed text-faint">
1136
+ npm installs run with scripts disabled; OpenSol only copies the SKILL.md folders the package ships. Skill scripts run
1137
+ only when the model calls the shell tool, and that still asks for approval.
1138
+ </p>
1085
1139
  </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
1140
 
1091
- {settings.skills.length === 0 ? (
1141
+ {error && <p className="mb-2 text-[10.5px] text-danger">{error}</p>}
1142
+
1143
+ {skills.length === 0 ? (
1092
1144
  <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>
1145
+ <Sparkles size={16} className="mx-auto text-faint" />
1146
+ <p className="mt-2 text-[12px] font-medium text-ink">{loading ? 'Loading skills…' : 'No skills installed'}</p>
1147
+ <p className="mt-1 text-[10.5px] text-muted">Upload a skill folder or install one from npm to give the agent new abilities.</p>
1096
1148
  </div>
1097
1149
  ) : (
1098
1150
  <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')}>
1151
+ {skills.map((skill, index) => (
1152
+ <div key={skill.name} className={cn('px-3 py-3', index !== 0 && 'border-t border-line')}>
1101
1153
  <div className="flex items-start gap-2.5">
1102
1154
  <span className="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-accent-soft text-accent">
1103
1155
  <Sparkles size={11} />
1104
1156
  </span>
1105
1157
  <div className="min-w-0 flex-1">
1106
1158
  <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'}
1159
+ <p className="font-mono text-[11.5px] font-medium text-ink">{skill.name}</p>
1160
+ <span className="rounded bg-raised px-1.5 py-0.5 text-[8px] font-medium text-faint">{formatSkillSource(skill.source)}</span>
1161
+ <span className="text-[8.5px] text-faint">
1162
+ {skill.fileCount} file{skill.fileCount === 1 ? '' : 's'} · {formatBytes(skill.size)}
1114
1163
  </span>
1115
1164
  </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
1165
+ <p className="mt-0.5 text-[9.5px] leading-relaxed text-muted">{skill.description || 'No description.'}</p>
1166
+ <p className="mt-0.5 truncate font-mono text-[8.5px] text-faint" title={skill.path}>
1167
+ {skill.path}
1119
1168
  </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
1169
  </div>
1130
1170
  <div className="flex shrink-0 items-center gap-1">
1131
1171
  <button
1132
1172
  type="button"
1133
- onClick={() => void remove(skill)}
1173
+ onClick={() => void askRemove(skill)}
1134
1174
  className="icon-button h-7 w-7 hover:text-danger"
1135
1175
  aria-label={`Delete ${skill.name}`}
1136
1176
  title="Delete skill"
@@ -1139,7 +1179,7 @@ function SkillsTab() {
1139
1179
  </button>
1140
1180
  <Switch
1141
1181
  checked={skill.enabled}
1142
- onChange={(enabled) => updateSkill(skill.id, { enabled })}
1182
+ onChange={(enabled) => void setEnabled(skill.name, enabled)}
1143
1183
  label={`Enable ${skill.name}`}
1144
1184
  />
1145
1185
  </div>
@@ -1979,17 +2019,15 @@ export function SettingsModal() {
1979
2019
  {TABS.map((item, index) => {
1980
2020
  const Icon = item.icon
1981
2021
  const active = tab === item.id
1982
- const disabled = item.id === 'plugins'
1983
2022
  return (
1984
2023
  <button
1985
2024
  key={item.id}
1986
2025
  type="button"
1987
- disabled={disabled}
1988
- onClick={() => openSettings(item.id)}
1989
- title={disabled ? 'Plugins are disabled for maintenance' : item.description}
2026
+ onClick={() => openSettings(item.id as typeof tab)}
2027
+ disabled={item.disabled}
2028
+ title={item.description}
1990
2029
  className={cn(
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',
2030
+ '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 disabled:cursor-not-allowed disabled:opacity-45',
1993
2031
  active ? 'font-semibold text-ink' : 'text-muted hover:text-ink',
1994
2032
  )}
1995
2033
  >
@@ -1998,7 +2036,6 @@ export function SettingsModal() {
1998
2036
  </span>
1999
2037
  <Icon size={14} className={cn('transition-transform duration-300 group-hover/tab:scale-110', active ? 'text-accent' : 'text-faint')} />
2000
2038
  {item.label}
2001
- {disabled && <span className="ml-auto text-[8px] text-faint">off</span>}
2002
2039
  </button>
2003
2040
  )
2004
2041
  })}
@@ -2016,9 +2053,10 @@ export function SettingsModal() {
2016
2053
  <button
2017
2054
  key={item.id}
2018
2055
  type="button"
2019
- onClick={() => openSettings(item.id)}
2056
+ onClick={() => openSettings(item.id as typeof tab)}
2057
+ disabled={item.disabled}
2020
2058
  className={cn(
2021
- 'shrink-0 rounded-full px-3 py-1.5 text-[11px] font-medium transition-all duration-200',
2059
+ 'shrink-0 rounded-full px-3 py-1.5 disabled:opacity-45 text-[11px] font-medium transition-all duration-200',
2022
2060
  tab === item.id ? 'bg-ink text-panel' : 'bg-raised text-muted',
2023
2061
  )}
2024
2062
  >
@@ -244,12 +244,13 @@ export function Sidebar() {
244
244
  <button
245
245
  type="button"
246
246
  disabled
247
- title="Plugins are disabled for maintenance"
248
- className="nav-item cursor-not-allowed opacity-45"
247
+ aria-disabled="true"
248
+ title="Plugins are temporarily disabled for maintenance"
249
+ className="nav-item cursor-not-allowed opacity-50"
249
250
  >
250
251
  <Package size={15} strokeWidth={1.7} className="shrink-0 text-faint" />
251
252
  Plugins
252
- <span className="ml-auto rounded bg-raised px-1.5 py-0.5 text-[8.5px] font-medium text-faint">maintenance</span>
253
+ <span className="ml-auto rounded-full bg-warning/10 px-1.5 py-px text-[9px] font-medium text-warning">Maintenance</span>
253
254
  </button>
254
255
  <button
255
256
  type="button"
@@ -0,0 +1,198 @@
1
+ import { useLayoutEffect, useRef, useState } from 'react'
2
+ import { createPortal } from 'react-dom'
3
+ import { Building2, MessageSquare, ScrollText } from 'lucide-react'
4
+ import type { ChatSession } from '../types'
5
+ import { useSettings } from '../store/settings'
6
+ import { useChats } from '../store/chats'
7
+ import { useUi } from '../store/ui'
8
+ import { useWorkspace } from '../store/workspace'
9
+ import { inheritedPrompt, resolveBasePrompt } from '../lib/system-prompt'
10
+ import { useClickOutside } from '../lib/hooks'
11
+ import { cn } from '../lib/utils'
12
+ import { Segmented } from './ui'
13
+
14
+ type Scope = 'session' | 'workspace'
15
+
16
+ function Choice({
17
+ active,
18
+ title,
19
+ description,
20
+ onClick,
21
+ disabled,
22
+ }: {
23
+ active: boolean
24
+ title: string
25
+ description: string
26
+ onClick: () => void
27
+ disabled?: boolean
28
+ }) {
29
+ return (
30
+ <button
31
+ type="button"
32
+ onClick={onClick}
33
+ disabled={disabled}
34
+ aria-pressed={active}
35
+ className={cn(
36
+ 'flex w-full items-start gap-2.5 rounded-xl border px-3 py-2.5 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-50',
37
+ active ? 'border-accent/40 bg-accent-soft/60' : 'border-line hover:bg-raised/60',
38
+ )}
39
+ >
40
+ <span
41
+ className={cn(
42
+ 'mt-[3px] flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded-full border',
43
+ active ? 'border-accent' : 'border-faint',
44
+ )}
45
+ >
46
+ {active && <span className="h-1.5 w-1.5 rounded-full bg-accent" />}
47
+ </span>
48
+ <span className="min-w-0">
49
+ <span className="block text-[12px] font-medium text-ink">{title}</span>
50
+ <span className="mt-0.5 block text-[10.5px] leading-snug text-muted">{description}</span>
51
+ </span>
52
+ </button>
53
+ )
54
+ }
55
+
56
+ /** Small editor for the base system prompt: shared, custom for this session, or custom for this workspace. */
57
+ export function SystemPromptButton({ session }: { session: ChatSession | null }) {
58
+ const settings = useSettings()
59
+ const update = useSettings((state) => state.update)
60
+ const updateSession = useChats((state) => state.updateSession)
61
+ const notify = useUi((state) => state.notify)
62
+ const root = useWorkspace((state) => state.root || state.info.root)
63
+ const workspaceName = useWorkspace((state) => state.info.name)
64
+ const [open, setOpen] = useState(false)
65
+ const [scope, setScope] = useState<Scope>('session')
66
+ const panelRef = useRef<HTMLDivElement>(null)
67
+ const triggerRef = useRef<HTMLButtonElement>(null)
68
+ useClickOutside(panelRef, () => setOpen(false), open, triggerRef)
69
+ const [position, setPosition] = useState<{ top: number; right: number }>({ top: 56, right: 12 })
70
+
71
+ // The top bar blurs its backdrop, which traps children in its own stacking context.
72
+ // Render the panel in a portal and place it from the trigger's rectangle.
73
+ useLayoutEffect(() => {
74
+ if (!open) return
75
+ const place = () => {
76
+ const rect = triggerRef.current?.getBoundingClientRect()
77
+ if (rect) setPosition({ top: rect.bottom + 8, right: Math.max(8, window.innerWidth - rect.right) })
78
+ }
79
+ place()
80
+ window.addEventListener('resize', place)
81
+ return () => window.removeEventListener('resize', place)
82
+ }, [open])
83
+
84
+ const effective = resolveBasePrompt(settings, session, root)
85
+ const sessionCustom = typeof session?.systemPrompt === 'string'
86
+ const workspaceCustom = Boolean(root) && typeof settings.workspacePrompts[root] === 'string'
87
+ const customized = sessionCustom || workspaceCustom
88
+
89
+ // Session needs a conversation, workspace needs a folder; fall back to whichever scope is usable.
90
+ const activeScope: Scope = scope === 'session' && !session && root ? 'workspace' : scope
91
+ const isSession = activeScope === 'session'
92
+ const custom = isSession ? sessionCustom : workspaceCustom
93
+ const value = isSession ? (session?.systemPrompt ?? '') : (settings.workspacePrompts[root] ?? '')
94
+ const fallback = isSession ? inheritedPrompt(settings, root) : { text: settings.systemPrompt, source: 'app' as const }
95
+ const unavailable = isSession ? !session : !root
96
+
97
+ const setCustom = (text: string | undefined) => {
98
+ if (isSession) {
99
+ if (session) updateSession(session.id, { systemPrompt: text })
100
+ return
101
+ }
102
+ if (!root) return
103
+ const next = { ...settings.workspacePrompts }
104
+ if (text === undefined) delete next[root]
105
+ else next[root] = text
106
+ update({ workspacePrompts: next })
107
+ }
108
+
109
+ const sharedDescription = isSession
110
+ ? fallback.source === 'workspace'
111
+ ? 'Uses this workspace’s custom prompt.'
112
+ : 'Uses the app-wide prompt from Settings → General.'
113
+ : 'Uses the app-wide prompt from Settings → General.'
114
+
115
+ return (
116
+ <div className="relative hidden sm:block">
117
+ <button
118
+ ref={triggerRef}
119
+ type="button"
120
+ onClick={() => setOpen((value) => !value)}
121
+ title={`System prompt · ${effective.source === 'session' ? 'custom for this session' : effective.source === 'workspace' ? 'custom for this workspace' : 'shared'}`}
122
+ aria-label="Edit system prompt"
123
+ aria-expanded={open}
124
+ className={cn('icon-button relative', open && 'bg-raised text-ink')}
125
+ >
126
+ <ScrollText size={16} strokeWidth={1.7} />
127
+ {customized && <span className="absolute right-1 top-1 h-1.5 w-1.5 rounded-full bg-accent" />}
128
+ </button>
129
+
130
+ {open &&
131
+ createPortal(
132
+ <div
133
+ ref={panelRef}
134
+ role="dialog"
135
+ aria-label="System prompt"
136
+ style={{ top: position.top, right: position.right }}
137
+ className="fixed z-[95] max-h-[calc(100dvh-80px)] w-[min(420px,92vw)] overflow-y-auto rounded-2xl border border-line bg-panel p-3.5 shadow-float"
138
+ >
139
+ <div className="mb-2.5 flex items-center justify-between gap-3">
140
+ <p className="text-[12.5px] font-semibold text-ink">System prompt</p>
141
+ <Segmented<Scope>
142
+ value={activeScope}
143
+ onChange={setScope}
144
+ options={[
145
+ { value: 'session', label: 'Session', title: 'Only this conversation' },
146
+ { value: 'workspace', label: 'Workspace', title: 'Every conversation in this folder' },
147
+ ]}
148
+ />
149
+ </div>
150
+
151
+ <p className="mb-2 flex items-center gap-1.5 text-[10.5px] text-muted">
152
+ {isSession ? <MessageSquare size={11} /> : <Building2 size={11} />}
153
+ {isSession ? (session ? 'Applies to this conversation only.' : 'Send a message to start a conversation first.') : root ? `Applies to every conversation in ${workspaceName || 'this folder'}.` : 'Open a workspace folder first.'}
154
+ </p>
155
+
156
+ <div className="space-y-1.5">
157
+ <Choice
158
+ active={!custom}
159
+ disabled={unavailable}
160
+ title="Use shared prompt"
161
+ description={sharedDescription}
162
+ onClick={() => setCustom(undefined)}
163
+ />
164
+ <Choice
165
+ active={custom}
166
+ disabled={unavailable}
167
+ title={isSession ? 'Custom for this session' : 'Custom for this workspace'}
168
+ description="Write a prompt that replaces the shared one here."
169
+ onClick={() => {
170
+ if (!custom) {
171
+ setCustom(fallback.text)
172
+ notify('Started from the current shared prompt. Edit it below.', 'info')
173
+ }
174
+ }}
175
+ />
176
+ </div>
177
+
178
+ {custom && !unavailable && (
179
+ <textarea
180
+ value={value}
181
+ onChange={(event) => setCustom(event.target.value)}
182
+ rows={9}
183
+ spellCheck={false}
184
+ aria-label="Custom system prompt"
185
+ className="thin-scroll mt-2.5 w-full resize-y rounded-xl border border-line bg-canvas px-3 py-2 font-mono text-[11px] leading-relaxed text-ink outline-none focus:border-accent/50"
186
+ placeholder="You are…"
187
+ />
188
+ )}
189
+
190
+ <p className="mt-2 text-[10px] leading-relaxed text-faint">
191
+ Order of precedence: session, then workspace, then shared. Skills, workspace context and mode rules are always added after it.
192
+ </p>
193
+ </div>,
194
+ document.body,
195
+ )}
196
+ </div>
197
+ )
198
+ }