ompchamber 3.5.0 → 3.6.0

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 (71) hide show
  1. package/dist/client/index.html +1 -1
  2. package/dist/client/index.js +357 -353
  3. package/dist/client/static/js/index-7n838fz5.css +1 -0
  4. package/dist/client/static/js/{index-w0cem7n5.js → index-qp2sfmn2.js} +591 -592
  5. package/package.json +1 -1
  6. package/src/cli/lib/commands/status.js +7 -0
  7. package/src/client/components/layout/session-sidebar/about-modal/UpdateLog.tsx +50 -0
  8. package/src/client/components/layout/session-sidebar/about-modal/UpdateSection.tsx +12 -17
  9. package/src/client/components/settings/ProjectSelectorDropdown.tsx +8 -2
  10. package/src/client/components/settings/SettingsSidebarList.tsx +5 -1
  11. package/src/client/components/settings/categories/CommandSettings.tsx +47 -9
  12. package/src/client/components/settings/categories/SkillCatalogSettings.tsx +23 -43
  13. package/src/client/components/settings/categories/SkillSettings.tsx +51 -7
  14. package/src/client/components/settings/categories/command-settings/DetailPane.tsx +109 -103
  15. package/src/client/components/settings/categories/command-settings/SidebarList.tsx +6 -1
  16. package/src/client/components/settings/categories/skill-settings/DetailPane.tsx +130 -31
  17. package/src/client/components/settings/categories/skill-settings/SidebarList.tsx +9 -3
  18. package/src/client/data/settings/command.ts +0 -40
  19. package/src/client/hooks/settings/workspace-roots.ts +99 -0
  20. package/src/client/hooks/ui/updates/index.ts +178 -0
  21. package/src/server/db.server.ts +41 -11
  22. package/src/server/index.ts +17 -1
  23. package/src/server/lib/db/client.ts +17 -0
  24. package/src/server/lib/db/compact-chat-overlay.test.ts +121 -0
  25. package/src/server/lib/db/compact-chat-overlay.ts +90 -0
  26. package/src/server/lib/lifecycle/fd-pressure.test.ts +66 -0
  27. package/src/server/lib/lifecycle/fd-pressure.ts +159 -0
  28. package/src/server/lib/omp/config/commands.test.ts +167 -0
  29. package/src/server/lib/omp/config/commands.ts +195 -0
  30. package/src/server/lib/omp/config/scope.test.ts +49 -0
  31. package/src/server/lib/omp/config/scope.ts +65 -0
  32. package/src/server/lib/omp/config/skills-catalog.ts +35 -3
  33. package/src/server/lib/omp/config/skills.test.ts +105 -0
  34. package/src/server/lib/omp/config/skills.ts +260 -47
  35. package/src/server/lib/omp/rpc/process.ts +22 -14
  36. package/src/server/lib/omp/rpc/session-registry.ts +6 -0
  37. package/src/server/lib/omp/rpc/utility.ts +45 -23
  38. package/src/server/lib/omp/session/merge-stored-anchor.test.ts +85 -0
  39. package/src/server/lib/omp/session/merge-stored-overlay-rows.test.ts +106 -0
  40. package/src/server/lib/omp/session/merge-stored.ts +78 -11
  41. package/src/server/lib/omp/session/reload.server.test.ts +84 -0
  42. package/src/server/lib/omp/session/reload.server.ts +47 -0
  43. package/src/server/lib/omp/session/subagent-presence.ts +40 -9
  44. package/src/server/lib/terminal/liveness.server.ts +65 -0
  45. package/src/server/lib/terminal/runtime.server.ts +70 -117
  46. package/src/server/lib/terminal/session.server.ts +5 -2
  47. package/src/server/lib/terminal/store.ts +72 -0
  48. package/src/server/lib/updates/apply.ts +62 -33
  49. package/src/server/lib/updates/install.ts +9 -2
  50. package/src/server/lib/updates/omp.ts +37 -9
  51. package/src/server/lib/updates/single-flight.test.ts +61 -0
  52. package/src/server/lib/updates/single-flight.ts +68 -0
  53. package/src/server/routes/chat/session.ts +13 -3
  54. package/src/server/routes/health/probe.ts +8 -0
  55. package/src/server/routes/omp/index.ts +0 -2
  56. package/src/server/routes/settings/commands.ts +102 -15
  57. package/src/server/routes/settings/skills.ts +165 -113
  58. package/src/server/routes/terminal/sessions.ts +1 -1
  59. package/src/server/routes/updates/apply.ts +101 -14
  60. package/src/shared/lib/chat/composer/client.ts +27 -18
  61. package/src/shared/lib/chat/composer/scope.test.ts +87 -0
  62. package/src/shared/lib/markdown/katex.ts +11 -1
  63. package/src/shared/lib/markdown/marked.ts +68 -13
  64. package/src/shared/lib/markdown/math.test.ts +53 -1
  65. package/src/shared/lib/updates/progress.test.ts +54 -0
  66. package/src/shared/lib/updates/progress.ts +50 -0
  67. package/src/shared/types/settings/command.ts +15 -2
  68. package/src/shared/types/settings/skill.ts +13 -0
  69. package/dist/client/static/js/index-vx0ernrv.css +0 -1
  70. package/src/client/hooks/ui/updates.ts +0 -108
  71. package/src/server/routes/omp/commands.ts +0 -29
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ompchamber",
3
- "version": "3.5.0",
3
+ "version": "3.6.0",
4
4
  "description": "Developer web console and diagnostic chamber for AI Oh-My-Pi — Elysia + Preact on Bun.",
5
5
  "license": "UNLICENSED",
6
6
  "author": "Wijanarko Putra Rajeb <wijanarko.rajeb@gmail.com>",
@@ -47,6 +47,13 @@ function printInstance(entry) {
47
47
  log(` launch: ${entry.launchMode ?? 'unknown'}${entry.source ? ` (${entry.source})` : ''}`);
48
48
  log(` uptime: ${formatUptime(entry.startedAt)}`);
49
49
  log(` health: ${formatHealth(entry.health)}`);
50
+ // Descriptor pressure, when the server can measure it. A dev server climbs
51
+ // toward 10,240 by holding the client module graph across rebuilds, and past
52
+ // that point every `Bun.spawn` in the process fails at once (the terminal
53
+ // shell, the omp child, git) — this line is the warning that arrives before
54
+ // the breakage instead of after it.
55
+ const fds = entry.health?.fds;
56
+ if (fds) log(` fds: ${fds.open}/${fds.limit}${fds.nearCliff ? ' (near the spawn limit — restart this instance)' : ''}`);
50
57
  log(` log: ${entry.logFile}`);
51
58
  }
52
59
 
@@ -0,0 +1,50 @@
1
+ import { useEffect, useRef } from 'preact/hooks';
2
+ import { Loader2, Terminal } from 'lucide-preact';
3
+ import { updateLogText, type UpdateLogState } from '@/shared/lib/updates/progress';
4
+
5
+ interface UpdateLogProps {
6
+ state: UpdateLogState;
7
+ /** A run is in flight: the label says so and the tail keeps following it. */
8
+ active: boolean;
9
+ }
10
+
11
+ /**
12
+ * The update's own output, as it arrives.
13
+ *
14
+ * A live install prints the commands it runs and what they answered, and that
15
+ * is the only honest progress signal available — there is no percentage to
16
+ * report. It follows the tail while the run is active so the newest line stays
17
+ * on screen, and stays readable afterwards, because the failure that matters
18
+ * explains itself in the last few lines.
19
+ */
20
+ export function UpdateLog({ state, active }: UpdateLogProps) {
21
+ const scroller = useRef<HTMLPreElement>(null);
22
+ const text = updateLogText(state);
23
+
24
+ useEffect(() => {
25
+ const element = scroller.current;
26
+ if (element) element.scrollTop = element.scrollHeight;
27
+ }, [text]);
28
+
29
+ if (!text && !active) return null;
30
+
31
+ return (
32
+ <div className="mt-2.5">
33
+ <div className="mb-1 flex items-center gap-1.5 text-[10px] text-ink/50">
34
+ {active ? (
35
+ <Loader2 size={11} className="animate-spin motion-reduce:animate-none" />
36
+ ) : (
37
+ <Terminal size={11} />
38
+ )}
39
+ <span>{active ? 'Updating…' : 'Update output'}</span>
40
+ {state.truncated && <span className="text-ink/35">· older lines trimmed</span>}
41
+ </div>
42
+ <pre
43
+ ref={scroller}
44
+ className="max-h-40 overflow-auto rounded-md border border-ink/10 bg-canvas px-2 py-1.5 font-mono text-[10px] leading-4 whitespace-pre-wrap break-words text-ink/70 select-text"
45
+ >
46
+ {text || '…'}
47
+ </pre>
48
+ </div>
49
+ );
50
+ }
@@ -1,6 +1,7 @@
1
1
  import { AlertCircle, Check, Download, Loader2, RefreshCw } from 'lucide-preact';
2
2
  import type { UpdateTarget, UpdateTargetInfo } from '@/shared/types/updates';
3
3
  import type { UseUpdatesResult } from '@/client/hooks/ui/updates';
4
+ import { UpdateLog } from '@/client/components/layout/session-sidebar/about-modal/UpdateLog';
4
5
 
5
6
  interface UpdateSectionProps {
6
7
  updates: UseUpdatesResult;
@@ -62,23 +63,13 @@ export function UpdateSection({ updates, onToast }: UpdateSectionProps) {
62
63
  const result = await updates.apply(target);
63
64
  if (!onToast) return;
64
65
 
65
- if (!result) {
66
- onToast('The update request could not be completed.', 'error');
67
- return;
68
- }
69
-
70
- if (target === 'ompchamber') {
71
- // `manual` means the environment cannot self-update and the message is
72
- // the command to run — action required, not a success.
73
- onToast(result.message, result.success ? 'success' : 'error');
74
- return;
75
- }
76
-
77
- if (result.success) {
78
- onToast(`${result.message} A restart may be needed.`, 'success');
79
- } else {
80
- onToast(result.message, 'error');
81
- }
66
+ // A null result means the run was aborted with the panel (an unmount, a
67
+ // page tear-down): there is nobody left to tell.
68
+ if (!result) return;
69
+
70
+ // `manual` means the environment cannot self-update and the message is the
71
+ // command to run — action required, not a success.
72
+ onToast(result.message, result.success && !result.manual ? 'success' : 'error');
82
73
  };
83
74
 
84
75
  const showResult = Boolean(updates.info && !updates.checking && !updates.error);
@@ -90,6 +81,10 @@ export function UpdateSection({ updates, onToast }: UpdateSectionProps) {
90
81
  {updates.info && <span className="font-mono text-[9px] text-ink/40">checked</span>}
91
82
  </div>
92
83
 
84
+ {updates.progress && (
85
+ <UpdateLog state={updates.progress} active={updates.applying !== null} />
86
+ )}
87
+
93
88
  <div className="mt-2.5">
94
89
  {updates.checking && (
95
90
  <button type="button" disabled className={`${actionClassName} w-full justify-center`}>
@@ -22,8 +22,14 @@ export const PROJECT_OPTIONS: ProjectOption[] = [
22
22
 
23
23
  const optionValue = (option: ProjectOption): string => option.value ?? option.id;
24
24
 
25
- const defaultTriggerLabel = (_options: ProjectOption[], selectedProject: string): string =>
26
- selectedProject;
25
+ /**
26
+ * Trigger label for a selected value. A caller that stores the option's VALUE
27
+ * (the skills/commands panels store a project path, not an id) must still get
28
+ * the option's human label rather than the raw path it selected.
29
+ */
30
+ const defaultTriggerLabel = (options: ProjectOption[], selectedProject: string): string =>
31
+ options.find((option) => option.id === selectedProject || optionValue(option) === selectedProject)?.name
32
+ ?? selectedProject;
27
33
 
28
34
  interface ProjectSelectorDropdownProps {
29
35
  selectedProject: string;
@@ -1,7 +1,7 @@
1
1
  import type { ComponentChildren } from 'preact';
2
2
  import type { FunctionComponent } from 'preact/compat';
3
3
  import { Plus } from 'lucide-preact';
4
- import { ProjectSelectorDropdown } from '@/client/components/settings/ProjectSelectorDropdown';
4
+ import { ProjectSelectorDropdown, type ProjectOption } from '@/client/components/settings/ProjectSelectorDropdown';
5
5
 
6
6
  export interface SettingsSidebarItem {
7
7
  id: string;
@@ -24,6 +24,8 @@ interface SettingsSidebarListProps {
24
24
  renderIcon: (isBuiltIn: boolean) => ComponentChildren;
25
25
  selectedProject: string;
26
26
  onChangeProject: (project: string) => void;
27
+ /** Real workspace folders; defaults to the shared placeholder list. */
28
+ projectOptions?: ProjectOption[];
27
29
  }
28
30
 
29
31
  /**
@@ -43,6 +45,7 @@ export const SettingsSidebarList: FunctionComponent<SettingsSidebarListProps> =
43
45
  renderIcon,
44
46
  selectedProject,
45
47
  onChangeProject,
48
+ projectOptions,
46
49
  }) => {
47
50
  const builtInItems = items.filter((item) => item.isBuiltIn);
48
51
  const customItems = items.filter((item) => !item.isBuiltIn);
@@ -81,6 +84,7 @@ export const SettingsSidebarList: FunctionComponent<SettingsSidebarListProps> =
81
84
  <ProjectSelectorDropdown
82
85
  selectedProject={selectedProject}
83
86
  onChangeProject={onChangeProject}
87
+ {...(projectOptions ? { options: projectOptions } : {})}
84
88
  />
85
89
 
86
90
  <div className="px-3.5 py-2.5 border-b border-ink/10 flex items-center justify-between">
@@ -1,4 +1,4 @@
1
- import { useState } from 'preact/hooks';
1
+ import { useRef, useState } from 'preact/hooks';
2
2
  import type { FunctionComponent } from 'preact/compat';
3
3
  import type { CommandItem, SettingsState } from '@/shared/types';
4
4
  import { CommandSidebarList } from '@/client/components/settings/categories/command-settings/SidebarList';
@@ -7,18 +7,40 @@ import { LoadingState } from '@/client/components/settings/LoadingState';
7
7
  import { useCrudList } from '@/client/hooks/settings/crud-list';
8
8
  import { useSettingsMasterDetail } from '@/client/hooks/settings/master-detail';
9
9
  import { SettingsMasterDetail } from '@/client/components/settings/master-detail';
10
+ import { GLOBAL_SCOPE_ID, useWorkspaceRoots } from '@/client/hooks/settings/workspace-roots';
10
11
 
11
12
  interface CommandSettingsProps {
12
13
  settings: SettingsState;
13
14
  onUpdate: (settings: SettingsState) => void;
14
15
  }
15
16
 
17
+ /**
18
+ * The Commands panel, scoped to one root at a time.
19
+ *
20
+ * The list is omp's own `get_available_commands` for the selected scope: the
21
+ * builtins, the `skill:<name>` entries, extension and TypeScript commands, and
22
+ * every markdown command file omp discovers — the user-level
23
+ * `~/.omp/agent/commands`, `~/.claude/commands`, … plus the selected
24
+ * workspace's `.omp/commands`, `.claude/commands`, `.agents/commands` and
25
+ * `.github/prompts`. The server resolves each live name against those roots so
26
+ * the pane can tell a file command (editable, and the file is the truth) from
27
+ * a builtin (read-only), and reports the scope each one lives at.
28
+ */
16
29
  export const CommandSettings: FunctionComponent<CommandSettingsProps> = () => {
17
30
  const masterDetail = useSettingsMasterDetail();
18
- const [selectedProject, setSelectedProject] = useState('ompchamber');
31
+ const roots = useWorkspaceRoots();
32
+ const [selectedRootId, setSelectedRootId] = useState<string>(GLOBAL_SCOPE_ID);
33
+ // The name a command had when it was selected: a rename has to travel so the
34
+ // file is moved rather than left behind as a second command. Read from a ref
35
+ // because the save body is built outside the list controller's own state.
36
+ const selectedNameRef = useRef<string | null>(null);
37
+ const query = roots.queryFor(selectedRootId);
38
+ const isWorkspaceScope = roots.isWorkspace(selectedRootId);
39
+
19
40
  const { items: commands, selectedId, selected: selectedCommand, isCreatingNew, isLoading, select, startCreate, save, remove } =
20
41
  useCrudList<CommandItem>({
21
42
  endpoint: '/api/settings/commands',
43
+ query,
22
44
  listKey: 'commands',
23
45
  bodyKey: 'command',
24
46
  messages: {
@@ -27,7 +49,20 @@ export const CommandSettings: FunctionComponent<CommandSettingsProps> = () => {
27
49
  delete: 'Failed to delete command via API:',
28
50
  },
29
51
  cacheKey: 'command',
30
- buildNew: (updated) => ({ ...updated, id: `cmd-${Date.now()}`, isBuiltIn: false }),
52
+ // A live command's id is derived from its name; the rename has to travel
53
+ // so the file is moved rather than duplicated.
54
+ buildBody: (target) => ({
55
+ command: target,
56
+ ...roots.scopeBodyFor(selectedRootId),
57
+ ...(selectedNameRef.current ? { previousName: selectedNameRef.current } : {}),
58
+ }),
59
+ buildDeleteQuery: (id) => `?id=${encodeURIComponent(id)}&${query.slice(1)}`,
60
+ buildNew: (updated) => ({
61
+ ...updated,
62
+ id: `cmd-${Date.now()}`,
63
+ isBuiltIn: false,
64
+ scope: isWorkspaceScope ? 'project' : 'user',
65
+ }),
31
66
  buildUpdate: (updated) => updated,
32
67
  isDeleteError: (data) => Boolean((data as { error?: string } | null)?.error),
33
68
  });
@@ -36,15 +71,13 @@ export const CommandSettings: FunctionComponent<CommandSettingsProps> = () => {
36
71
  id: `new-${Date.now()}`,
37
72
  name: 'new-command',
38
73
  description: '',
39
- scope: 'user',
40
- overrideAgent: 'Not selected',
41
- overrideModel: 'Not selected',
74
+ scope: isWorkspaceScope ? 'project' : 'user',
42
75
  template: 'Execute task: $ARGUMENTS',
43
76
  isBuiltIn: false,
44
77
  };
45
78
 
46
79
  if (isLoading) {
47
- return <LoadingState>Loading commands from database...</LoadingState>;
80
+ return <LoadingState>Loading commands…</LoadingState>;
48
81
  }
49
82
 
50
83
  return (
@@ -58,15 +91,18 @@ export const CommandSettings: FunctionComponent<CommandSettingsProps> = () => {
58
91
  commands={commands}
59
92
  selectedCommandId={isCreatingNew ? null : selectedId}
60
93
  onSelectCommand={(id) => {
94
+ selectedNameRef.current = commands.find((command) => command.id === id)?.name ?? null;
61
95
  select(id);
62
96
  masterDetail.openDetail();
63
97
  }}
64
98
  onAddNewCommand={() => {
99
+ selectedNameRef.current = null;
65
100
  startCreate();
66
101
  masterDetail.openDetail();
67
102
  }}
68
- selectedProject={selectedProject}
69
- onChangeProject={setSelectedProject}
103
+ selectedProject={selectedRootId}
104
+ onChangeProject={setSelectedRootId}
105
+ projectOptions={roots.options}
70
106
  />
71
107
  }
72
108
  detail={
@@ -76,6 +112,7 @@ export const CommandSettings: FunctionComponent<CommandSettingsProps> = () => {
76
112
  command={emptyCommandTemplate}
77
113
  isNew={true}
78
114
  onSave={save}
115
+ projectScopeDisabled={!isWorkspaceScope}
79
116
  />
80
117
  ) : selectedCommand ? (
81
118
  <CommandDetailPane
@@ -84,6 +121,7 @@ export const CommandSettings: FunctionComponent<CommandSettingsProps> = () => {
84
121
  isNew={false}
85
122
  onSave={save}
86
123
  onDelete={remove}
124
+ projectScopeDisabled={!isWorkspaceScope}
87
125
  />
88
126
  ) : (
89
127
  <div className="flex-1 flex items-center justify-center text-xs font-mono text-ink/40">
@@ -1,6 +1,6 @@
1
1
  import { useState } from 'preact/hooks';
2
2
  import { Check, Download, ExternalLink, GitBranch, Plus, RefreshCw, Search, Star } from 'lucide-preact';
3
- import type { CatalogSkillItem, SkillCatalogSource, SkillItem } from '@/shared/types';
3
+ import type { CatalogSkillItem, SkillCatalogSource } from '@/shared/types';
4
4
  import { AddSourceModal } from '@/client/components/settings/categories/skill-catalog/AddSourceModal';
5
5
  import { invalidateComposerCache } from '@/shared/lib/chat/composer/client';
6
6
  import { useSkillCatalog } from '@/client/hooks/settings/skill-catalog';
@@ -31,49 +31,29 @@ export function SkillCatalogSettings() {
31
31
  const handleInstallToggle = (skill: CatalogSkillItem) => {
32
32
  const existing = userSkills.find((s) => s.name === skill.name);
33
33
 
34
- if (existing) {
35
- // Uninstall via API
36
- fetch(`/api/settings/skills?id=${encodeURIComponent(existing.id)}`, { method: 'DELETE' })
37
- .then(res => res.json())
38
- .then(data => {
39
- if (data?.error) return;
40
- invalidateComposerCache('command');
41
- fetch('/api/settings/skills')
42
- .then(r => r.json())
43
- .then(d => {
44
- if (d?.skills) setUserSkills(d.skills);
45
- });
46
- showToast(`Uninstalled skill "${skill.name}"`);
47
- })
48
- .catch(console.error);
49
- } else {
50
- // Install via API
51
- const newSkill: SkillItem = {
52
- id: `skill-cat-${Date.now()}`,
53
- name: skill.name,
54
- description: skill.description,
55
- location: 'user',
56
- locationLabel: 'User / OpenCode',
57
- instructions: skill.instructions || `---\ndescription: "${skill.description}"\n---\n\n## Instructions\nFollow standard prompt protocol for ${skill.name}.`,
58
- project: 'ompchamber',
59
- isInstalledFromCatalog: true,
60
- catalogSource: skill.sourceId,
61
- };
62
-
63
- fetch('/api/settings/skills', {
64
- method: 'POST',
65
- headers: { 'Content-Type': 'application/json' },
66
- body: JSON.stringify({ skill: newSkill }),
34
+ // Both directions go through the same endpoint: an install runs the skills
35
+ // CLI (which is what puts the skill where omp reads it), an uninstall runs
36
+ // its remove counterpart. Deleting only the chamber's own record used to
37
+ // leave the skill installed and running.
38
+ fetch('/api/settings/skills', {
39
+ method: 'POST',
40
+ headers: { 'Content-Type': 'application/json' },
41
+ body: JSON.stringify({ type: 'install_toggle', skill, install: !existing }),
42
+ })
43
+ .then((res) => res.json())
44
+ .then((data) => {
45
+ if (data?.error) {
46
+ showToast(`Failed: ${data.error}`);
47
+ return;
48
+ }
49
+ if (Array.isArray(data?.skills)) setUserSkills(data.skills);
50
+ invalidateComposerCache('command');
51
+ showToast(existing ? `Uninstalled skill "${skill.name}"` : `Installed skill "${skill.name}"`);
67
52
  })
68
- .then(res => res.json())
69
- .then(data => {
70
- if (data?.skills) setUserSkills(data.skills);
71
- else setUserSkills(prev => [...prev, newSkill]);
72
- showToast(`Installed skill "${skill.name}"`);
73
- invalidateComposerCache('command');
74
- })
75
- .catch(console.error);
76
- }
53
+ .catch((error) => {
54
+ console.error(error);
55
+ showToast(`Failed to ${existing ? 'uninstall' : 'install'} "${skill.name}"`);
56
+ });
77
57
  };
78
58
 
79
59
  const handleAddSource = (newSource: SkillCatalogSource) => {
@@ -6,6 +6,7 @@ import { LoadingState } from '@/client/components/settings/LoadingState';
6
6
  import { useCrudList } from '@/client/hooks/settings/crud-list';
7
7
  import { useSettingsMasterDetail } from '@/client/hooks/settings/master-detail';
8
8
  import { SettingsMasterDetail } from '@/client/components/settings/master-detail';
9
+ import { GLOBAL_SCOPE_ID, useWorkspaceRoots } from '@/client/hooks/settings/workspace-roots';
9
10
 
10
11
  interface SkillSettingsProps {
11
12
  settings?: SettingsState;
@@ -13,12 +14,31 @@ interface SkillSettingsProps {
13
14
  onNavigateToCatalog?: () => void;
14
15
  }
15
16
 
17
+ /**
18
+ * The Skills panel, scoped to one root at a time.
19
+ *
20
+ * The picker selects WHICH omp scope is read: a workspace root answers with the
21
+ * skills an omp session started there loads (its `.omp/skills`, `.claude`,
22
+ * `.agents`, `.github`, `.codex` entries plus the user-level ones), while the
23
+ * user scope answers for the agent dir alone. That is not a client-side filter
24
+ * — the server passes the scope's cwd to `omp skill list`, because omp's
25
+ * project walk-up is what decides the list.
26
+ *
27
+ * A write lands in the scope that is selected: a project skill under
28
+ * `<workspace>/.omp/skills`, a user skill under `~/.omp/agent/skills`.
29
+ */
16
30
  export function SkillSettings({ onNavigateToCatalog }: SkillSettingsProps) {
17
31
  const masterDetail = useSettingsMasterDetail();
18
- const [selectedProjectId, setSelectedProjectId] = useState('ompchamber');
32
+ const roots = useWorkspaceRoots();
33
+ const [selectedRootId, setSelectedRootId] = useState<string>(GLOBAL_SCOPE_ID);
34
+ const [isReloading, setIsReloading] = useState(false);
35
+ const query = roots.queryFor(selectedRootId);
36
+ const isWorkspaceScope = roots.isWorkspace(selectedRootId);
37
+
19
38
  const { items: skills, selectedId, selected: selectedSkill, isCreatingNew, isLoading, select, startCreate, save, remove } =
20
39
  useCrudList<SkillItem, Partial<SkillItem>>({
21
40
  endpoint: '/api/settings/skills',
41
+ query,
22
42
  listKey: 'skills',
23
43
  bodyKey: 'skill',
24
44
  messages: {
@@ -28,14 +48,22 @@ export function SkillSettings({ onNavigateToCatalog }: SkillSettingsProps) {
28
48
  },
29
49
  cacheKey: 'command',
30
50
  fallbackToFirst: false,
51
+ // The write carries the scope it was made in, because the draft's own
52
+ // location decides which `.omp/skills` root it lands in.
53
+ buildBody: (target) => ({ skill: target, ...roots.scopeBodyFor(selectedRootId) }),
54
+ buildDeleteQuery: (id) => `?id=${encodeURIComponent(id)}&${query.slice(1)}`,
31
55
  buildNew: (skillData) => ({
32
56
  id: `skill-${Date.now()}`,
33
57
  name: skillData.name || 'new-skill',
34
58
  description: skillData.description || '',
35
- location: skillData.location || 'user',
36
- locationLabel: skillData.locationLabel || 'User / OpenCode',
59
+ // A new skill defaults to the scope the panel is showing: creating a
60
+ // project skill while the user scope is selected would write outside
61
+ // the list they are looking at.
62
+ location: skillData.location || (isWorkspaceScope ? 'project' : 'user'),
63
+ locationLabel: skillData.locationLabel || (isWorkspaceScope ? 'Project / .omp/skills' : 'User / omp agent'),
37
64
  instructions: skillData.instructions || '',
38
- project: selectedProjectId,
65
+ hidden: skillData.hidden === true,
66
+ project: 'omp',
39
67
  }),
40
68
  buildUpdate: (skillData, selected, selectedId) => ({
41
69
  ...(selected || { id: selectedId || `skill-${Date.now()}` }),
@@ -44,8 +72,19 @@ export function SkillSettings({ onNavigateToCatalog }: SkillSettingsProps) {
44
72
  isDeleteError: (data) => Boolean((data as { error?: string } | null)?.error),
45
73
  });
46
74
 
75
+ const reload = () => {
76
+ setIsReloading(true);
77
+ fetch('/api/settings/skills', {
78
+ method: 'POST',
79
+ headers: { 'Content-Type': 'application/json' },
80
+ body: JSON.stringify({ type: 'reload', ...roots.scopeBodyFor(selectedRootId) }),
81
+ })
82
+ .catch((error) => console.error('Failed to reload plugins:', error))
83
+ .finally(() => setIsReloading(false));
84
+ };
85
+
47
86
  if (isLoading) {
48
- return <LoadingState>Loading skills from database...</LoadingState>;
87
+ return <LoadingState>Loading skills…</LoadingState>;
49
88
  }
50
89
 
51
90
  return (
@@ -67,8 +106,9 @@ export function SkillSettings({ onNavigateToCatalog }: SkillSettingsProps) {
67
106
  startCreate();
68
107
  masterDetail.openDetail();
69
108
  }}
70
- selectedProject={selectedProjectId}
71
- onSelectProject={setSelectedProjectId}
109
+ selectedProject={selectedRootId}
110
+ onSelectProject={setSelectedRootId}
111
+ projectOptions={roots.options}
72
112
  />
73
113
  }
74
114
  detail={
@@ -78,6 +118,10 @@ export function SkillSettings({ onNavigateToCatalog }: SkillSettingsProps) {
78
118
  onSave={save}
79
119
  onDelete={remove}
80
120
  onOpenCatalog={onNavigateToCatalog}
121
+ onReload={reload}
122
+ isReloading={isReloading}
123
+ projectScopeDisabled={!isWorkspaceScope}
124
+ defaultLocation={isWorkspaceScope ? 'project' : 'user'}
81
125
  />
82
126
  }
83
127
  />