ompchamber 3.4.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 (87) hide show
  1. package/dist/client/index.html +1 -1
  2. package/dist/client/index.js +387 -383
  3. package/dist/client/static/js/index-7n838fz5.css +1 -0
  4. package/dist/client/static/js/{index-h05z90mw.js → index-qp2sfmn2.js} +614 -615
  5. package/package.json +1 -1
  6. package/src/cli/lib/commands/status.js +7 -0
  7. package/src/client/components/common/session-actions-menu/index.tsx +201 -0
  8. package/src/client/components/common/session-delete-modal/index.tsx +128 -0
  9. package/src/client/components/layout/session-sidebar/CategoryItem.tsx +18 -0
  10. package/src/client/components/layout/session-sidebar/SessionItem.tsx +109 -99
  11. package/src/client/components/layout/session-sidebar/about-modal/UpdateLog.tsx +50 -0
  12. package/src/client/components/layout/session-sidebar/about-modal/UpdateSection.tsx +12 -17
  13. package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +18 -0
  14. package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +82 -64
  15. package/src/client/components/settings/ProjectSelectorDropdown.tsx +8 -2
  16. package/src/client/components/settings/SettingsSidebarList.tsx +5 -1
  17. package/src/client/components/settings/categories/CommandSettings.tsx +47 -9
  18. package/src/client/components/settings/categories/SkillCatalogSettings.tsx +23 -43
  19. package/src/client/components/settings/categories/SkillSettings.tsx +51 -7
  20. package/src/client/components/settings/categories/command-settings/DetailPane.tsx +109 -103
  21. package/src/client/components/settings/categories/command-settings/SidebarList.tsx +6 -1
  22. package/src/client/components/settings/categories/skill-settings/DetailPane.tsx +130 -31
  23. package/src/client/components/settings/categories/skill-settings/SidebarList.tsx +9 -3
  24. package/src/client/data/settings/command.ts +0 -40
  25. package/src/client/hooks/settings/workspace-roots.ts +99 -0
  26. package/src/client/hooks/ui/updates/index.ts +178 -0
  27. package/src/client/hooks/workspace/session-delete.ts +94 -0
  28. package/src/server/db.server.ts +41 -11
  29. package/src/server/index.ts +17 -1
  30. package/src/server/lib/btw/purge.server.ts +56 -0
  31. package/src/server/lib/db/client.ts +17 -0
  32. package/src/server/lib/db/compact-chat-overlay.test.ts +121 -0
  33. package/src/server/lib/db/compact-chat-overlay.ts +90 -0
  34. package/src/server/lib/fs/path-segment.test.ts +100 -0
  35. package/src/server/lib/fs/path-segment.ts +45 -0
  36. package/src/server/lib/lifecycle/fd-pressure.test.ts +66 -0
  37. package/src/server/lib/lifecycle/fd-pressure.ts +159 -0
  38. package/src/server/lib/omp/config/commands.test.ts +167 -0
  39. package/src/server/lib/omp/config/commands.ts +195 -0
  40. package/src/server/lib/omp/config/scope.test.ts +49 -0
  41. package/src/server/lib/omp/config/scope.ts +65 -0
  42. package/src/server/lib/omp/config/skills-catalog.ts +35 -3
  43. package/src/server/lib/omp/config/skills.test.ts +105 -0
  44. package/src/server/lib/omp/config/skills.ts +260 -47
  45. package/src/server/lib/omp/rpc/process.ts +22 -14
  46. package/src/server/lib/omp/rpc/session-registry.ts +6 -0
  47. package/src/server/lib/omp/rpc/utility.ts +45 -23
  48. package/src/server/lib/omp/session/delete.server.ts +136 -0
  49. package/src/server/lib/omp/session/merge-stored-anchor.test.ts +85 -0
  50. package/src/server/lib/omp/session/merge-stored-overlay-rows.test.ts +106 -0
  51. package/src/server/lib/omp/session/merge-stored.ts +78 -11
  52. package/src/server/lib/omp/session/reader.ts +13 -0
  53. package/src/server/lib/omp/session/reload.server.test.ts +84 -0
  54. package/src/server/lib/omp/session/reload.server.ts +47 -0
  55. package/src/server/lib/omp/session/subagent-presence.ts +40 -9
  56. package/src/server/lib/terminal/liveness.server.ts +65 -0
  57. package/src/server/lib/terminal/runtime.server.ts +70 -117
  58. package/src/server/lib/terminal/session.server.ts +5 -2
  59. package/src/server/lib/terminal/store.ts +72 -0
  60. package/src/server/lib/updates/apply.ts +62 -33
  61. package/src/server/lib/updates/install.ts +9 -2
  62. package/src/server/lib/updates/omp.ts +37 -9
  63. package/src/server/lib/updates/single-flight.test.ts +61 -0
  64. package/src/server/lib/updates/single-flight.ts +68 -0
  65. package/src/server/routes/chat/session.ts +13 -3
  66. package/src/server/routes/health/probe.ts +8 -0
  67. package/src/server/routes/omp/index.ts +0 -2
  68. package/src/server/routes/sessions/delete.test.ts +84 -0
  69. package/src/server/routes/sessions/delete.ts +83 -0
  70. package/src/server/routes/sessions/index.ts +5 -0
  71. package/src/server/routes/settings/commands.ts +102 -15
  72. package/src/server/routes/settings/skills.ts +165 -113
  73. package/src/server/routes/terminal/sessions.ts +1 -1
  74. package/src/server/routes/updates/apply.ts +101 -14
  75. package/src/shared/lib/chat/composer/client.ts +27 -18
  76. package/src/shared/lib/chat/composer/scope.test.ts +87 -0
  77. package/src/shared/lib/markdown/katex.ts +11 -1
  78. package/src/shared/lib/markdown/marked.ts +68 -13
  79. package/src/shared/lib/markdown/math.test.ts +53 -1
  80. package/src/shared/lib/updates/progress.test.ts +54 -0
  81. package/src/shared/lib/updates/progress.ts +50 -0
  82. package/src/shared/lib/workspace/session-state/store.ts +20 -0
  83. package/src/shared/types/settings/command.ts +15 -2
  84. package/src/shared/types/settings/skill.ts +13 -0
  85. package/dist/client/static/js/index-bsxxt89j.css +0 -1
  86. package/src/client/hooks/ui/updates.ts +0 -108
  87. package/src/server/routes/omp/commands.ts +0 -29
@@ -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`}>
@@ -7,6 +7,8 @@ import { WorkspaceOptionsMenu } from '@/client/components/common/workspace-optio
7
7
  import { useShowMore } from '@/client/hooks/ui/show-more';
8
8
  import { useExpandedSessions } from '@/client/hooks/workspace/expanded-sessions';
9
9
  import { useWorkspaceFolderActions } from '@/client/hooks/workspace/workspace-folder-actions';
10
+ import { useSessionDelete } from '@/client/hooks/workspace/session-delete';
11
+ import { SessionDeleteModal } from '@/client/components/common/session-delete-modal';
10
12
  import { getProjectIcon } from '@/shared/lib/workspace/project-icon';
11
13
  import { relativeTimeAgo } from '@/shared/lib/workspace/relative-time';
12
14
  import { useOnClickOutside } from '@/client/hooks/ui/on-click-outside';
@@ -53,6 +55,7 @@ export function MobileSessionCategory({
53
55
  handleArchive,
54
56
  handleRename,
55
57
  } = useWorkspaceFolderActions(folder, refresh);
58
+ const sessionDelete = useSessionDelete();
56
59
 
57
60
  useOnClickOutside(menuRef, () => {
58
61
  setShowMenu(false);
@@ -195,6 +198,13 @@ export function MobileSessionCategory({
195
198
  onToggleExpand={() => toggleSession(sessionKey)}
196
199
  onSelect={() => onSelectSession(session.id)}
197
200
  onArchive={() => handleArchive(session)}
201
+ onDelete={
202
+ // A pending `new-…` chat has no transcript anywhere yet —
203
+ // there is nothing to delete, and the server refuses it.
204
+ sessionKey.startsWith('new-')
205
+ ? undefined
206
+ : () => sessionDelete.requestDelete(session)
207
+ }
198
208
  onRename={sessionKey.startsWith('new-') ? undefined : (name) => void handleRename(session, name)}
199
209
  />
200
210
  {isRosterOpen && (
@@ -216,6 +226,14 @@ export function MobileSessionCategory({
216
226
  )}
217
227
  </div>
218
228
  )}
229
+
230
+ <SessionDeleteModal
231
+ session={sessionDelete.pending}
232
+ isDeleting={sessionDelete.isDeleting}
233
+ error={sessionDelete.error}
234
+ onClose={sessionDelete.cancelDelete}
235
+ onConfirm={() => void sessionDelete.confirmDelete()}
236
+ />
219
237
  </div>
220
238
  );
221
239
  }
@@ -1,6 +1,7 @@
1
- import { Archive, ArchiveRestore, Check, ChevronDown, ChevronRight, CircleQuestionMark, Loader2, Pencil } from 'lucide-preact';
1
+ import { Check, ChevronDown, ChevronRight, CircleQuestionMark, Loader2, MoreHorizontal } from 'lucide-preact';
2
2
  import type { SessionItemData } from '@/shared/types';
3
3
  import { useInlineRename } from '@/client/hooks/ui/inline-rename';
4
+ import { SessionActionsMenu, useRowMenu } from '@/client/components/common/session-actions-menu';
4
5
 
5
6
  export interface MobileSessionRowProps {
6
7
  session: SessionItemData;
@@ -16,6 +17,9 @@ export interface MobileSessionRowProps {
16
17
  onToggleExpand?: () => void;
17
18
  onSelect: () => void;
18
19
  onArchive: () => void;
20
+ /** Omitted for a session that cannot be deleted yet (a pending `new-…` chat,
21
+ * which has no transcript anywhere). */
22
+ onDelete?: () => void;
19
23
  onRename?: (name: string) => void;
20
24
  }
21
25
 
@@ -30,6 +34,7 @@ export function MobileSessionRow({
30
34
  onToggleExpand,
31
35
  onSelect,
32
36
  onArchive,
37
+ onDelete,
33
38
  onRename,
34
39
  }: MobileSessionRowProps) {
35
40
  const {
@@ -41,6 +46,9 @@ export function MobileSessionRow({
41
46
  handleKeyDown,
42
47
  handleBlur,
43
48
  } = useInlineRename(session.title, onRename);
49
+ // One trigger for every row action; also reachable by long-press contextmenu.
50
+ const menu = useRowMenu();
51
+ const hasActions = Boolean(onRename || onArchive || onDelete);
44
52
 
45
53
  // The roster toggle is the row's LAST element, so its glyph lands in the same
46
54
  // column as the folder header's expand chevron — one vertical line of
@@ -71,76 +79,86 @@ export function MobileSessionRow({
71
79
  }
72
80
 
73
81
  return (
74
- <div
75
- className={`w-full rounded-lg flex items-center transition-colors ${
76
- isActive ? 'bg-ink/10 font-medium text-ink' : 'hover:bg-ink/5 text-ink/85'
77
- }`}
78
- >
79
- <button
80
- type="button"
81
- onClick={onSelect}
82
- className="flex-1 text-left pl-2 pr-3 py-2 flex items-center justify-between min-w-0"
82
+ <>
83
+ <div
84
+ onContextMenu={hasActions ? menu.openAtCursor : undefined}
85
+ className={`w-full rounded-lg flex items-center transition-colors ${
86
+ isActive ? 'bg-ink/10 font-medium text-ink' : 'hover:bg-ink/5 text-ink/85'
87
+ }`}
83
88
  >
84
- <div className="flex items-center min-w-0 pr-2">
85
- {/* Status slot, 16px at the same x as the folder header's icon slot,
86
- so the left column reads as one line. Waiting on an answer
87
- outranks the run spinner — the spinner says work is happening,
88
- the question mark says it is YOUR turn (desktop parity). */}
89
- <span className="w-4 flex-shrink-0 flex items-center justify-center">
90
- {awaitingInput ? (
91
- <CircleQuestionMark size={13} className="text-ink/70 animate-pulse" />
92
- ) : status === 'stream' ? (
93
- <Loader2 size={13} className="text-ink/50 animate-spin" />
94
- ) : status ? (
95
- <Check size={13} className="text-ink/50" />
96
- ) : null}
97
- </span>
98
- <span className="text-xs truncate leading-snug ml-2">{title}</span>
99
- </div>
100
-
101
- <span className="text-[11px] text-ink/45 font-mono flex-shrink-0 ml-2">{timeAgo ?? ''}</span>
102
- </button>
103
-
104
- {onRename && (
105
89
  <button
106
90
  type="button"
107
- onClick={startRename}
108
- title="Rename session"
109
- className="flex-shrink-0 p-2 text-ink/35 hover:text-ink rounded-lg cursor-pointer"
91
+ onClick={onSelect}
92
+ className="flex-1 text-left pl-2 pr-3 py-2 flex items-center justify-between min-w-0"
110
93
  >
111
- <Pencil size={14} />
94
+ <div className="flex items-center min-w-0 pr-2">
95
+ {/* Status slot, 16px at the same x as the folder header's icon slot,
96
+ so the left column reads as one line. Waiting on an answer
97
+ outranks the run spinner — the spinner says work is happening,
98
+ the question mark says it is YOUR turn (desktop parity). */}
99
+ <span className="w-4 flex-shrink-0 flex items-center justify-center">
100
+ {awaitingInput ? (
101
+ <CircleQuestionMark size={13} className="text-ink/70 animate-pulse" />
102
+ ) : status === 'stream' ? (
103
+ <Loader2 size={13} className="text-ink/50 animate-spin" />
104
+ ) : status ? (
105
+ <Check size={13} className="text-ink/50" />
106
+ ) : null}
107
+ </span>
108
+ <span className="text-xs truncate leading-snug ml-2">{title}</span>
109
+ </div>
110
+
111
+ <span className="text-[11px] text-ink/45 font-mono flex-shrink-0 ml-2">{timeAgo ?? ''}</span>
112
112
  </button>
113
- )}
114
113
 
115
- {/* A row WITHOUT a roster toggle still has to keep the toggle's column
116
- empty, or its archive glyph would sit where the toggle is and the
117
- trailing icons would step in and out row to row. This `mr-1` is what
118
- reserves that column: it moves the 14px glyph's centre from x=363 to
119
- x=359, the exact x the toggle uses on a row that has one. */}
120
- <button
121
- type="button"
122
- onClick={onArchive}
123
- title={session.is_archived === 1 ? 'Unarchive session' : 'Archive session'}
124
- className={`flex-shrink-0 p-2 text-ink/35 hover:text-ink rounded-lg cursor-pointer ${showChevron ? '' : 'mr-1'}`}
125
- >
126
- {session.is_archived === 1 ? <ArchiveRestore size={14} /> : <Archive size={14} />}
127
- </button>
114
+ {/* One trigger for rename / archive / delete, always visible: a touch
115
+ surface has no hover, and the always-on action buttons this replaced
116
+ consumed a third of a phone's row width.
117
+ `mr-1` only when the row has NO roster toggle — it reserves that
118
+ column so the trailing glyph's centre stays 19px from the row's right
119
+ edge whether the last element is this trigger or the chevron. Without
120
+ it the glyphs step in and out from row to row. */}
121
+ {hasActions && (
122
+ <button
123
+ type="button"
124
+ onClick={menu.openBelow}
125
+ title="Session actions"
126
+ aria-label="Session actions"
127
+ aria-haspopup="menu"
128
+ aria-expanded={menu.anchor !== null}
129
+ className={`flex-shrink-0 p-2 text-ink/35 hover:text-ink rounded-lg cursor-pointer ${showChevron ? '' : 'mr-1'}`}
130
+ >
131
+ <MoreHorizontal size={16} />
132
+ </button>
133
+ )}
128
134
 
129
- {/* Last element, so its glyph sits in the folder header's chevron column.
130
- `p-1.5 mr-1.5` centres a 14px glyph on that column while keeping a
131
- touch-sized tap target. */}
132
- {showChevron && (
133
- <button
134
- type="button"
135
- onClick={onToggleExpand}
136
- title={isExpanded ? 'Collapse subagents' : 'Expand subagents'}
137
- aria-expanded={isExpanded}
138
- aria-label={isExpanded ? 'Collapse subagents' : 'Expand subagents'}
139
- className="flex-shrink-0 p-1.5 mr-1.5 flex items-center justify-center text-ink/50 hover:text-ink rounded-lg cursor-pointer"
140
- >
141
- {isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
142
- </button>
135
+ {/* Last element, so its glyph sits in the folder header's chevron column.
136
+ `p-1.5 mr-1.5` centres a 14px glyph on that column while keeping a
137
+ touch-sized tap target. */}
138
+ {showChevron && (
139
+ <button
140
+ type="button"
141
+ onClick={onToggleExpand}
142
+ title={isExpanded ? 'Collapse subagents' : 'Expand subagents'}
143
+ aria-expanded={isExpanded}
144
+ aria-label={isExpanded ? 'Collapse subagents' : 'Expand subagents'}
145
+ className="flex-shrink-0 p-1.5 mr-1.5 flex items-center justify-center text-ink/50 hover:text-ink rounded-lg cursor-pointer"
146
+ >
147
+ {isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
148
+ </button>
149
+ )}
150
+ </div>
151
+
152
+ {menu.anchor && (
153
+ <SessionActionsMenu
154
+ anchor={menu.anchor}
155
+ isArchived={session.is_archived === 1}
156
+ onRename={onRename ? startRename : undefined}
157
+ onArchive={onArchive}
158
+ onDelete={onDelete}
159
+ onClose={menu.close}
160
+ />
143
161
  )}
144
- </div>
162
+ </>
145
163
  );
146
164
  }
@@ -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) => {