ompchamber 0.8.0 → 1.1.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 (130) hide show
  1. package/dist/client/index.html +1 -1
  2. package/dist/client/static/css/index.b859cc1cc5.css +2 -0
  3. package/dist/client/static/js/async/{5b.0d6ae50fe8.js → 5b.a5ccbc19dc.js} +2 -2
  4. package/dist/client/static/js/async/g.a06ca3b3e9.js +2 -0
  5. package/dist/client/static/js/async/{m.0896dab823.js → m.c038c71b9f.js} +2 -2
  6. package/dist/client/static/js/async/ms.98960af4eb.js +67 -0
  7. package/dist/client/static/js/async/ms.98960af4eb.js.LICENSE.txt +12 -0
  8. package/dist/client/static/js/async/ot.84c71b308f.js +2 -0
  9. package/dist/client/static/js/async/{x4.69ec69c61b.js.LICENSE.txt → ot.84c71b308f.js.LICENSE.txt} +5 -0
  10. package/dist/client/static/js/index.6e065017eb.js +33 -0
  11. package/package.json +2 -1
  12. package/src/client/components/common/ComposerPicker.tsx +16 -4
  13. package/src/client/components/common/ComposerTextarea.tsx +1 -0
  14. package/src/client/components/layout/RightActivityBar.tsx +1 -1
  15. package/src/client/components/layout/desktop-layout/ResizeHandle.tsx +2 -2
  16. package/src/client/components/layout/desktop-layout/TopNavbar.tsx +13 -2
  17. package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +107 -56
  18. package/src/client/components/layout/desktop-layout/index.tsx +25 -96
  19. package/src/client/components/layout/desktop-layout/resizer/index.tsx +79 -118
  20. package/src/client/components/layout/desktop-layout/resizer/separator-drag.ts +140 -0
  21. package/src/client/components/layout/session-sidebar/CategoryItem.tsx +1 -1
  22. package/src/client/components/layout/session-sidebar/Header.tsx +1 -1
  23. package/src/client/components/layout/session-sidebar/SessionItem.tsx +27 -13
  24. package/src/client/components/layout/session-sidebar/SessionList.tsx +1 -1
  25. package/src/client/components/layout/session-sidebar/SubagentList.tsx +6 -4
  26. package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +1 -1
  27. package/src/client/components/mobile/mobile-session-sidebar/List.tsx +1 -1
  28. package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +2 -2
  29. package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +14 -4
  30. package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +3 -0
  31. package/src/client/components/workspace/chat-timeline/QueueList.tsx +18 -14
  32. package/src/client/components/workspace/chat-timeline/SubagentView.tsx +3 -1
  33. package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +23 -3
  34. package/src/client/components/workspace/chat-timeline/index.tsx +7 -2
  35. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Goal.tsx +3 -3
  36. package/src/client/components/workspace/git-panel/BranchToolbar.tsx +2 -2
  37. package/src/client/components/workspace/git-panel/index.tsx +5 -1
  38. package/src/client/components/workspace/model-dropdown/groups.ts +35 -2
  39. package/src/client/components/workspace/model-dropdown/index.tsx +48 -7
  40. package/src/client/components/workspace/model-dropdown/use-catalog.ts +28 -4
  41. package/src/client/components/workspace/terminal-panel/Header.tsx +51 -19
  42. package/src/client/components/workspace/terminal-panel/RealtimeXtermView.tsx +86 -282
  43. package/src/client/components/workspace/terminal-panel/index.tsx +76 -50
  44. package/src/client/components/workspace/terminal-panel/mount.ts +275 -0
  45. package/src/client/data/theme/terminal.ts +27 -29
  46. package/src/client/hooks/chat/composer/index.ts +28 -8
  47. package/src/client/hooks/chat/omp/session-list.tsx +19 -2
  48. package/src/client/hooks/chat/omp/session-sidebar-controller.ts +1 -1
  49. package/src/client/hooks/chat/timeline/actions.ts +47 -28
  50. package/src/client/hooks/chat/timeline/deferred-model.test.ts +219 -0
  51. package/src/client/hooks/chat/timeline/deferred-model.ts +92 -0
  52. package/src/client/hooks/chat/timeline/index.ts +25 -21
  53. package/src/client/hooks/chat/timeline/queue.ts +110 -46
  54. package/src/client/hooks/chat/timeline/send.ts +16 -5
  55. package/src/client/hooks/workspace/available-width.ts +33 -0
  56. package/src/client/hooks/workspace/panel-widths.ts +34 -37
  57. package/src/client/hooks/workspace/terminal/index.ts +217 -0
  58. package/src/client/hooks/workspace/terminal/socket.ts +227 -0
  59. package/src/server/db.server.ts +19 -0
  60. package/src/server/lib/fs/git-sync.ts +191 -0
  61. package/src/server/lib/models/preferences.server.ts +110 -0
  62. package/src/server/lib/models/registry.server.ts +206 -0
  63. package/src/server/lib/omp/rpc/manager.ts +58 -3
  64. package/src/server/lib/omp/rpc/session-commands.ts +6 -0
  65. package/src/server/lib/omp/rpc/utility.ts +7 -1
  66. package/src/server/lib/omp/session/sidebar-data.server.ts +3 -3
  67. package/src/server/lib/queue/delivery.server.test.ts +203 -0
  68. package/src/server/lib/queue/delivery.server.ts +144 -0
  69. package/src/server/lib/queue/schema-migration.server.ts +50 -0
  70. package/src/server/lib/queue/store.server.ts +185 -0
  71. package/src/server/lib/terminal/foreground.ts +73 -0
  72. package/src/server/lib/terminal/history.test.ts +118 -0
  73. package/src/server/lib/terminal/history.ts +205 -0
  74. package/src/server/lib/terminal/runtime.server.ts +338 -0
  75. package/src/server/lib/terminal/session.server.ts +215 -0
  76. package/src/server/lib/terminal/shell.test.ts +97 -0
  77. package/src/server/lib/terminal/shell.ts +103 -0
  78. package/src/server/routes/fs/git.ts +22 -12
  79. package/src/server/routes/index.ts +2 -1
  80. package/src/server/routes/models/root.ts +88 -215
  81. package/src/server/routes/sessions/index.ts +11 -3
  82. package/src/server/routes/sessions/queue.ts +121 -0
  83. package/src/server/routes/sessions/session.ts +1 -132
  84. package/src/server/routes/settings/commands.ts +59 -18
  85. package/src/server/routes/terminal/index.ts +2 -2
  86. package/src/server/routes/terminal/run.ts +8 -100
  87. package/src/server/routes/terminal/sessions.ts +24 -0
  88. package/src/server/routes/terminal/ws.ts +149 -0
  89. package/src/shared/lib/chat/composer/client.ts +17 -8
  90. package/src/shared/lib/chat/composer/commands.ts +290 -0
  91. package/src/shared/lib/chat/composer/composer.test.ts +157 -0
  92. package/src/shared/lib/chat/composer/filter.ts +21 -7
  93. package/src/shared/lib/chat/composer/trigger.ts +109 -20
  94. package/src/shared/lib/chat/omp/agent-events.test.ts +37 -1
  95. package/src/shared/lib/chat/omp/agent-events.ts +30 -0
  96. package/src/shared/lib/chat/timeline/omp-callbacks.test.ts +9 -1
  97. package/src/shared/lib/chat/timeline/omp-callbacks.ts +45 -8
  98. package/src/shared/lib/models/preferences.test.ts +131 -0
  99. package/src/shared/lib/models/preferences.ts +88 -0
  100. package/src/shared/lib/models/provider-models.ts +8 -1
  101. package/src/shared/lib/omp/session/stream-state.server.ts +2 -3
  102. package/src/shared/lib/omp/subagent/label.test.ts +94 -0
  103. package/src/shared/lib/omp/subagent/label.ts +84 -0
  104. package/src/shared/lib/workspace/panel-widths.test.ts +113 -18
  105. package/src/shared/lib/workspace/panel-widths.ts +161 -30
  106. package/src/shared/lib/workspace/right-panels.ts +50 -11
  107. package/src/shared/lib/workspace/session-state/store.ts +4 -4
  108. package/src/shared/lib/workspace/terminal/protocol.ts +222 -0
  109. package/src/shared/types/chat.ts +38 -65
  110. package/src/shared/types/index.ts +1 -0
  111. package/src/shared/types/models.ts +101 -0
  112. package/src/shared/types/omp/agent.ts +12 -0
  113. package/src/shared/types/omp/subagent.ts +4 -0
  114. package/src/shared/types/settings/command.ts +13 -0
  115. package/src/shared/types/terminal.ts +17 -15
  116. package/src/shared/types/workspace.ts +2 -2
  117. package/dist/client/static/css/index.5c0447cefe.css +0 -2
  118. package/dist/client/static/js/async/g.d1c2fe09aa.js +0 -2
  119. package/dist/client/static/js/async/x4.69ec69c61b.js +0 -6
  120. package/dist/client/static/js/index.dc5f5fabe8.js +0 -33
  121. package/src/client/components/workspace/terminal-panel/InputBar.tsx +0 -82
  122. package/src/client/components/workspace/terminal-panel/LogEntry.tsx +0 -70
  123. package/src/client/hooks/workspace/terminal-output.ts +0 -66
  124. package/src/client/hooks/workspace/terminal.ts +0 -283
  125. package/src/server/lib/fs/terminal-cwd.ts +0 -49
  126. package/src/server/routes/terminal/stream.ts +0 -100
  127. /package/dist/client/static/js/async/{5b.0d6ae50fe8.js.LICENSE.txt → 5b.a5ccbc19dc.js.LICENSE.txt} +0 -0
  128. /package/dist/client/static/js/async/{g.d1c2fe09aa.js.LICENSE.txt → g.a06ca3b3e9.js.LICENSE.txt} +0 -0
  129. /package/dist/client/static/js/async/{m.0896dab823.js.LICENSE.txt → m.c038c71b9f.js.LICENSE.txt} +0 -0
  130. /package/dist/client/static/js/{index.dc5f5fabe8.js.LICENSE.txt → index.6e065017eb.js.LICENSE.txt} +0 -0
@@ -25,10 +25,14 @@ const ABORTED_TURN = {
25
25
  function makeDeps() {
26
26
  const ended: ChatMessageData[] = [];
27
27
  const activity: string[] = [];
28
+ const commandOutputs: string[] = [];
29
+ let settledCount = 0;
28
30
  let state: OmpAgentState = { isGenerating: false, connected: true, error: null };
29
31
  const callbacks: OmpAgentCallbacks = {
30
32
  onMessageEnd: (msg) => ended.push(msg),
31
33
  onActivity: (verb) => activity.push(verb),
34
+ onPromptSettled: () => { settledCount += 1; },
35
+ onCommandOutput: (text) => commandOutputs.push(text),
32
36
  };
33
37
  const deps: OmpAgentFoldDeps = {
34
38
  sessionId: 's1',
@@ -43,7 +47,7 @@ function makeDeps() {
43
47
  currentThinkingLevelRef: { current: undefined },
44
48
  fileMutatingCallsRef: { current: new Set<string>() },
45
49
  };
46
- return { deps, ended, activity };
50
+ return { deps, ended, activity, commandOutputs, settled: () => settledCount, state: () => state };
47
51
  }
48
52
 
49
53
  describe('toChatMessage error derivation', () => {
@@ -60,6 +64,38 @@ describe('toChatMessage error derivation', () => {
60
64
  });
61
65
  });
62
66
 
67
+ describe('foldAgentEvent prompt_result (builtin slash commands)', () => {
68
+ test('agentInvoked:false settles generating state and fires onPromptSettled', () => {
69
+ const { deps, state, settled } = makeDeps();
70
+ foldAgentEvent({ type: 'agent_start' }, deps);
71
+ expect(state().isGenerating).toBe(true);
72
+ foldAgentEvent({ type: 'prompt_result', agentInvoked: false }, deps);
73
+ expect(state().isGenerating).toBe(false);
74
+ expect(settled()).toBe(1);
75
+ });
76
+
77
+ test('prompt_result with agentInvoked:true does not settle (a run follows)', () => {
78
+ const { deps, settled } = makeDeps();
79
+ foldAgentEvent({ type: 'prompt_result', agentInvoked: true }, deps);
80
+ expect(settled()).toBe(0);
81
+ });
82
+ });
83
+
84
+ describe('foldAgentEvent command_output (builtin slash output)', () => {
85
+ test('forwards trimmed text to onCommandOutput', () => {
86
+ const { deps, commandOutputs } = makeDeps();
87
+ foldAgentEvent({ type: 'command_output', text: 'Usage\nInput tokens: 0\n' }, deps);
88
+ expect(commandOutputs).toEqual(['Usage\nInput tokens: 0']);
89
+ });
90
+
91
+ test('drops empty output frames', () => {
92
+ const { deps, commandOutputs } = makeDeps();
93
+ foldAgentEvent({ type: 'command_output', text: ' ' }, deps);
94
+ foldAgentEvent({ type: 'command_output' }, deps);
95
+ expect(commandOutputs).toEqual([]);
96
+ });
97
+ });
98
+
63
99
  describe('foldAgentEvent activity phrases', () => {
64
100
  test('names the tool being executed, not a generic reasoning verb', () => {
65
101
  const { deps, activity } = makeDeps();
@@ -165,6 +165,11 @@ export function foldAgentEvent(data: OmpAgentEvent, deps: OmpAgentFoldDeps): voi
165
165
  callbacks?.onAgentStart?.();
166
166
  break;
167
167
 
168
+ case 'turn_start':
169
+ setActivity(PHASE_VERBS.thinking, deps);
170
+ callbacks?.onTurnStart?.();
171
+ break;
172
+
168
173
  case 'message_start':
169
174
  case 'message_update': {
170
175
  const msg = data.message as Record<string, unknown> | undefined;
@@ -177,6 +182,10 @@ export function foldAgentEvent(data: OmpAgentEvent, deps: OmpAgentFoldDeps): voi
177
182
  // Assistant phases (thinking / prose / tool-call assembly) name the
178
183
  // action directly — a tool that never starts still shows its phrase.
179
184
  setActivity(describeAssistantPhase(data.assistantMessageEvent), deps);
185
+ // The first message frame of a turn is another proof the run is live —
186
+ // re-signal so the sidebar can restore a lost `stream` status row.
187
+ // message_update frames arrive per delta and must stay silent here.
188
+ if (data.type === 'message_start') callbacks?.onTurnStart?.();
180
189
  // Steering (abort_and_prompt) and follow-up deliveries create no
181
190
  // optimistic bubble — the user turn only exists on the stream. Convert
182
191
  // it here so the bubble renders live instead of after a JSONL reload.
@@ -298,6 +307,27 @@ export function foldAgentEvent(data: OmpAgentEvent, deps: OmpAgentFoldDeps): voi
298
307
  break;
299
308
  }
300
309
 
310
+ // omp's prompt ack ran a built-in slash command instead of an agent turn
311
+ // (`agentInvoked:false`, mirrored server-side as this frame): there will be
312
+ // no agent_start/agent_end pair. Clear the optimistic generating state —
313
+ // the callbacks drop the AI placeholder and keep the user's message row.
314
+ case 'prompt_result': {
315
+ if (data.agentInvoked === false) {
316
+ deps.setState((prev) => ({ ...prev, isGenerating: false }));
317
+ deps.activityRef.current = '';
318
+ callbacks?.onPromptSettled?.();
319
+ }
320
+ break;
321
+ }
322
+
323
+ // Built-in slash command output (/usage, /compact result, …). Rendered as
324
+ // a notice row — see the callback type note: this frame is the ONLY copy.
325
+ case 'command_output': {
326
+ const text = typeof data.text === 'string' ? data.text.trim() : '';
327
+ if (text) callbacks?.onCommandOutput?.(text);
328
+ break;
329
+ }
330
+
301
331
  case 'notice': {
302
332
  callbacks?.onNotice?.(
303
333
  typeof data.level === 'string' ? data.level : 'info',
@@ -63,7 +63,6 @@ describe('createOmpAgentCallbacks first-assistant sidebar signal', () => {
63
63
 
64
64
  function makeCallbacks(): OmpAgentCallbacks {
65
65
  const deps: OmpAgentCallbacksDeps = {
66
- removeDeliveredFromQueue: () => {},
67
66
  setGenerating: () => {},
68
67
  setGeneratingVerb: () => {},
69
68
  scrollToBottom: () => {},
@@ -133,6 +132,15 @@ describe('createOmpAgentCallbacks first-assistant sidebar signal', () => {
133
132
  expect(signals).toHaveLength(2);
134
133
  });
135
134
 
135
+ test('onTurnStart signals the sidebar on every turn of the run', () => {
136
+ const callbacks = makeCallbacks();
137
+
138
+ callbacks.onTurnStart?.();
139
+ callbacks.onTurnStart?.();
140
+ expect(signals).toHaveLength(2);
141
+ expect(signals.every(s => s.name === 'omp:session-updated' && s.sessionId === 'sess-1')).toBe(true);
142
+ });
143
+
136
144
  test('never signals from user turns or notice rows alone', () => {
137
145
  const callbacks = makeCallbacks();
138
146
 
@@ -47,7 +47,6 @@ export function cancelStreamingCoalescer(): void {
47
47
  }
48
48
 
49
49
  export interface OmpAgentCallbacksDeps {
50
- removeDeliveredFromQueue: (text: string) => void;
51
50
  setGenerating: (v: boolean) => void;
52
51
  setGeneratingVerb: (v: string) => void;
53
52
  scrollToBottom: (behavior?: ScrollBehavior) => void;
@@ -73,7 +72,6 @@ export interface OmpAgentCallbacksDeps {
73
72
 
74
73
  export function createOmpAgentCallbacks(deps: OmpAgentCallbacksDeps): OmpAgentCallbacks {
75
74
  const {
76
- removeDeliveredFromQueue,
77
75
  setGenerating,
78
76
  setGeneratingVerb,
79
77
  scrollToBottom,
@@ -97,12 +95,6 @@ export function createOmpAgentCallbacks(deps: OmpAgentCallbacksDeps): OmpAgentCa
97
95
  scrollAfterFlush = () => scrollToBottom('smooth');
98
96
 
99
97
  return {
100
- // A queued steer/follow-up text was picked up by the agent (user turn
101
- // arrived) — drop it from whichever queue mirrors it so the panel stays
102
- // truthful without a chat bubble for every delivery.
103
- onQueuedMessageDelivered: (text) => {
104
- removeDeliveredFromQueue(text);
105
- },
106
98
  onAgentStart: () => {
107
99
  setGenerating(true);
108
100
  setGeneratingVerb(PHASE_VERBS.thinking);
@@ -122,6 +114,15 @@ export function createOmpAgentCallbacks(deps: OmpAgentCallbacksDeps): OmpAgentCa
122
114
  }
123
115
  }
124
116
  },
117
+ onTurnStart: () => {
118
+ // Every turn inside the run re-signals the sidebar: the throttle makes
119
+ // this cheap, and it recovers the `stream` status row if a previous
120
+ // dispatch raced with the status write (or the row was healed away).
121
+ const sid = adoptedSessionIdRef.current ?? sessionIdRef.current;
122
+ if (sid && typeof window !== 'undefined') {
123
+ window.dispatchEvent(new CustomEvent('omp:session-updated', { detail: { sessionId: sid } }));
124
+ }
125
+ },
125
126
  // Reload recovery: the omp process kept running server-side, so the event
126
127
  // stream is reattached and the generating UI must resume (the timeline
127
128
  // fetch already loaded the committed messages; live updates continue).
@@ -308,6 +309,42 @@ export function createOmpAgentCallbacks(deps: OmpAgentCallbacksDeps): OmpAgentCa
308
309
  abortControllerRef.current = null;
309
310
  console.error('OMP prompt error:', errorMessage);
310
311
  },
312
+ // The prompt ran a built-in slash command (omp answered on the command
313
+ // path, no agent run): drop the optimistic AI placeholder and settle the
314
+ // optimistic user row — it stays as the record of what the user invoked.
315
+ onPromptSettled: () => {
316
+ disposeStreamingCoalescer();
317
+ setGenerating(false);
318
+ abortControllerRef.current = null;
319
+ optimisticUserIdRef.current = null;
320
+ const placeholderId = aiPlaceholderIdRef.current;
321
+ aiPlaceholderIdRef.current = null;
322
+ if (placeholderId) {
323
+ setLocalMessages(prev => {
324
+ if (!prev.some(m => m.id === placeholderId)) return prev;
325
+ return prev.filter(m => m.id !== placeholderId);
326
+ });
327
+ }
328
+ },
329
+ // Built-in slash command output only rides the live stream — append it as
330
+ // a notice row before any active placeholder, exactly like omp notices.
331
+ onCommandOutput: (text) => {
332
+ const row: ChatMessageData = {
333
+ id: `cmdout-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
334
+ role: 'ai',
335
+ content: '',
336
+ notice: text,
337
+ };
338
+ setLocalMessages(prev => {
339
+ const placeholderId = aiPlaceholderIdRef.current;
340
+ if (placeholderId) {
341
+ const pIdx = prev.findIndex(m => m.id === placeholderId);
342
+ if (pIdx !== -1) return [...prev.slice(0, pIdx), row, ...prev.slice(pIdx)];
343
+ }
344
+ if (prev.some(m => m.notice === text)) return prev;
345
+ return [...prev, row];
346
+ });
347
+ },
311
348
  onNotice: (_level, message) => {
312
349
  console.info('OMP notice:', message);
313
350
  },
@@ -0,0 +1,131 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The preference rules are shared verbatim by the server (which persists the
8
+ * arrays) and the picker (which applies them optimistically), so these lock the
9
+ * behaviour both sides read back: composite keys, most-recent-first order, and
10
+ * the five-entry cap.
11
+ */
12
+
13
+ import { describe, expect, test } from 'bun:test';
14
+
15
+ import {
16
+ EMPTY_MODEL_PREFERENCES,
17
+ RECENT_MODELS_LIMIT,
18
+ applyModelPreferences,
19
+ filterKnownKeys,
20
+ readModelPreferences,
21
+ recordRecentKey,
22
+ toggleFavoriteKey,
23
+ } from '@/shared/lib/models/preferences';
24
+ import type { AIModelOption } from '@/shared/types';
25
+
26
+ describe('toggleFavoriteKey', () => {
27
+ test('adds an unknown key at the end and removes a known one', () => {
28
+ expect(toggleFavoriteKey(['a:1'], 'b:2')).toEqual(['a:1', 'b:2']);
29
+ expect(toggleFavoriteKey(['a:1', 'b:2'], 'a:1')).toEqual(['b:2']);
30
+ });
31
+
32
+ test('keys on provider + id, so the same id under another provider is a separate favorite', () => {
33
+ // The registry serves `deepseek-v4-pro` from both `deepseek` and `kenari`.
34
+ // An id-only list would unstar both rows when either is clicked.
35
+ const favorites = toggleFavoriteKey([], 'deepseek:deepseek-v4-pro');
36
+ expect(toggleFavoriteKey(favorites, 'kenari:deepseek-v4-pro')).toEqual([
37
+ 'deepseek:deepseek-v4-pro',
38
+ 'kenari:deepseek-v4-pro',
39
+ ]);
40
+ });
41
+ });
42
+
43
+ describe('recordRecentKey', () => {
44
+ test('moves a re-picked model to the front instead of duplicating it', () => {
45
+ expect(recordRecentKey(['a:1', 'b:2', 'c:3'], 'b:2')).toEqual(['b:2', 'a:1', 'c:3']);
46
+ });
47
+
48
+ test('caps the list at five, evicting the oldest entry', () => {
49
+ const full = ['a:1', 'b:2', 'c:3', 'd:4', 'e:5'];
50
+ const next = recordRecentKey(full, 'f:6');
51
+
52
+ expect(next).toHaveLength(RECENT_MODELS_LIMIT);
53
+ expect(next[0]).toBe('f:6');
54
+ expect(next).not.toContain('e:5');
55
+ });
56
+
57
+ test('re-picking inside a full list keeps the other four', () => {
58
+ const full = ['a:1', 'b:2', 'c:3', 'd:4', 'e:5'];
59
+ expect(recordRecentKey(full, 'c:3')).toEqual(['c:3', 'a:1', 'b:2', 'd:4', 'e:5']);
60
+ });
61
+ });
62
+
63
+ describe('readModelPreferences', () => {
64
+ test('reads a well-formed pair', () => {
65
+ expect(readModelPreferences({ favorites: ['a:1'], recentKeys: ['b:2'] }))
66
+ .toEqual({ favorites: ['a:1'], recentKeys: ['b:2'] });
67
+ });
68
+
69
+ test('drops non-string entries and caps an over-long recent list', () => {
70
+ const raw = {
71
+ favorites: ['a:1', 7, null, ''],
72
+ recentKeys: ['a:1', 'b:2', 'c:3', 'd:4', 'e:5', 'f:6', 'g:7'],
73
+ };
74
+
75
+ expect(readModelPreferences(raw)).toEqual({
76
+ favorites: ['a:1'],
77
+ recentKeys: ['a:1', 'b:2', 'c:3', 'd:4', 'e:5'],
78
+ });
79
+ });
80
+
81
+ test('an absent or malformed row yields empty rails', () => {
82
+ expect(readModelPreferences(null)).toEqual(EMPTY_MODEL_PREFERENCES);
83
+ expect(readModelPreferences('nonsense')).toEqual(EMPTY_MODEL_PREFERENCES);
84
+ expect(readModelPreferences({ favorites: 'not-a-list' })).toEqual(EMPTY_MODEL_PREFERENCES);
85
+ });
86
+ });
87
+
88
+ describe('filterKnownKeys', () => {
89
+ test('drops keys the registry no longer serves', () => {
90
+ // A model removed from models.yml, or every model of a disconnected
91
+ // provider, must not hold a slot in the five-entry rail.
92
+ const known = new Set(['a:1', 'c:3']);
93
+ expect(filterKnownKeys(['a:1', 'gone:9', 'c:3'], known)).toEqual(['a:1', 'c:3']);
94
+ });
95
+
96
+ test('pruning before a write frees the slot instead of merely hiding it', () => {
97
+ // The rail reads at most five entries. A stale key left in the stored array
98
+ // occupies one of them across every reload, so the user sees four models
99
+ // and the fifth pick appears to do nothing.
100
+ const stored = ['gone:9', 'a:1', 'b:2', 'c:3', 'd:4'];
101
+ const pruned = recordRecentKey(filterKnownKeys(stored, new Set(['a:1', 'b:2', 'c:3', 'd:4', 'e:5'])), 'e:5');
102
+
103
+ expect(pruned).toEqual(['e:5', 'a:1', 'b:2', 'c:3', 'd:4']);
104
+ expect(pruned).toHaveLength(RECENT_MODELS_LIMIT);
105
+ });
106
+ });
107
+
108
+ describe('applyModelPreferences', () => {
109
+ const rows: AIModelOption[] = [
110
+ { id: '1', name: 'One', provider: 'a' },
111
+ { id: '2', name: 'Two', provider: 'a', isFavorite: true },
112
+ { id: '3', name: 'Three', provider: 'b' },
113
+ ];
114
+
115
+ test('lights only the rows named by the stored key list', () => {
116
+ const applied = applyModelPreferences(rows, { favorites: ['b:3'], recentKeys: [] });
117
+
118
+ // Truthiness is the contract the star reads: a registry row has no
119
+ // `isFavorite` field at all, so `false` and `undefined` both mean "unlit".
120
+ expect(applied.map((m) => Boolean(m.isFavorite))).toEqual([false, false, true]);
121
+ });
122
+
123
+ test('leaves already-correct rows identical so only the changed row re-renders', () => {
124
+ const applied = applyModelPreferences(rows, { favorites: [], recentKeys: [] });
125
+
126
+ expect(applied[0]).toBe(rows[0]);
127
+ expect(applied[2]).toBe(rows[2]);
128
+ expect(applied[1]).not.toBe(rows[1]);
129
+ expect(applied[1].isFavorite).toBe(false);
130
+ });
131
+ });
@@ -0,0 +1,88 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Favorite + recent model preferences, keyed by the composite `provider:id`
8
+ * identity (`modelKey`). A model id alone is not unique — the omp registry
9
+ * serves the same id from several providers — so an id-keyed favorite list
10
+ * lights up every provider's row and records the wrong "recent".
11
+ *
12
+ * Pure list operations only: the server persists the arrays (one
13
+ * `app_settings` row) and the picker applies them optimistically, so both
14
+ * sides MUST agree on the ordering and the cap. Keeping the rules here is what
15
+ * makes them agree — a second copy in the route or the hook would drift.
16
+ */
17
+
18
+ import type { AIModelOption, ModelPreferences } from '@/shared/types';
19
+ import { modelKey } from '@/shared/lib/models/identity';
20
+
21
+ /** How many last-used models the RECENT rail holds. */
22
+ export const RECENT_MODELS_LIMIT = 5;
23
+
24
+ /** Used before the first write, and when a payload carries no preferences. */
25
+ export const EMPTY_MODEL_PREFERENCES: ModelPreferences = { favorites: [], recentKeys: [] };
26
+
27
+ /** Add or remove one key; the relative order of the survivors is untouched. */
28
+ export function toggleFavoriteKey(favorites: readonly string[], key: string): string[] {
29
+ return favorites.includes(key)
30
+ ? favorites.filter((entry) => entry !== key)
31
+ : [...favorites, key];
32
+ }
33
+
34
+ /**
35
+ * Move a key to the front of the recents, de-duplicated and capped. Re-picking
36
+ * a model must not leave a second copy behind — the rail would then show the
37
+ * same model twice and the cap would evict a genuinely older entry.
38
+ */
39
+ export function recordRecentKey(
40
+ recentKeys: readonly string[],
41
+ key: string,
42
+ limit: number = RECENT_MODELS_LIMIT,
43
+ ): string[] {
44
+ return [key, ...recentKeys.filter((entry) => entry !== key)].slice(0, Math.max(1, limit));
45
+ }
46
+
47
+ /**
48
+ * Drop keys the live registry no longer offers — a model removed from
49
+ * models.yml, or every model of a provider the user disconnected. Without this
50
+ * a stale key holds one of the five recent slots forever, so a rail that looks
51
+ * empty is hiding real entries behind keys nothing can render.
52
+ */
53
+ export function filterKnownKeys(keys: readonly string[], known: ReadonlySet<string>): string[] {
54
+ return keys.filter((key) => known.has(key));
55
+ }
56
+
57
+ /** Parse a persisted preferences row without trusting the JSON shape. */
58
+ export function readModelPreferences(value: unknown): ModelPreferences {
59
+ const source = typeof value === 'object' && value !== null ? value as Record<string, unknown> : {};
60
+ const strings = (input: unknown): string[] =>
61
+ Array.isArray(input) ? input.filter((entry): entry is string => typeof entry === 'string' && entry.length > 0) : [];
62
+ return {
63
+ favorites: strings(source.favorites),
64
+ recentKeys: strings(source.recentKeys).slice(0, RECENT_MODELS_LIMIT),
65
+ };
66
+ }
67
+
68
+ /**
69
+ * Stamp the favorite flag onto a catalog list. The registry carries no
70
+ * favorites of its own — it is a read-only view of omp's config — so the picker
71
+ * learns which rows are starred from the preference store, never from the
72
+ * catalog's own `isFavorite` (that is MOCK seed data).
73
+ *
74
+ * The comparison is by truthiness, not identity: a registry row has no
75
+ * `isFavorite` field at all, so an `undefined !== false` test would rebuild
76
+ * every row on every load and re-render the whole panel. Rows whose star state
77
+ * already reads correctly are returned untouched.
78
+ */
79
+ export function applyModelPreferences(
80
+ models: AIModelOption[],
81
+ preferences: ModelPreferences,
82
+ ): AIModelOption[] {
83
+ const favorites = new Set(preferences.favorites);
84
+ return models.map((model) => {
85
+ const isFavorite = favorites.has(modelKey(model));
86
+ return Boolean(model.isFavorite) === isFavorite ? model : { ...model, isFavorite };
87
+ });
88
+ }
@@ -63,6 +63,14 @@ export function mergeProviderModels(
63
63
  return { merged: [...existing, ...added], added, addedCount: added.length };
64
64
  }
65
65
 
66
+ /**
67
+ * Register newly added provider models on the chamber's catalog.
68
+ *
69
+ * `isFavorite` is deliberately NOT sent: the picker derives favorites from the
70
+ * stored preference keys, so a catalog flag would be a second, silently
71
+ * ignored source of truth for the FAVORITES rail. A new provider's models are
72
+ * starred by the user, not by this sync.
73
+ */
66
74
  export async function syncProviderModelsToCatalog(
67
75
  providerName: string,
68
76
  models: ProviderModel[],
@@ -81,7 +89,6 @@ export async function syncProviderModelsToCatalog(
81
89
  provider: providerName,
82
90
  contextWindow: model.contextWindow?.split(' ')[0] || '128K',
83
91
  thinkingLevel: 'Default',
84
- isFavorite: true,
85
92
  capabilities: ['Tool calling', 'Reasoning'],
86
93
  inputFormats: ['text'],
87
94
  outputFormats: ['text'],
@@ -11,7 +11,6 @@
11
11
  * stream — a run is in flight (prompt dispatch → agent_start)
12
12
  * finish — the run ended normally (agent_end), not yet seen
13
13
  * abort — the user stopped it, not yet seen
14
- * error — the run failed (prompt_error / process exit), not yet seen
15
14
  *
16
15
  * A terminal status is a one-shot badge: opening the session acknowledges it
17
16
  * (`markStreamSeen` deletes the row), which is exactly the "check appears
@@ -25,7 +24,7 @@
25
24
 
26
25
  import { getDb } from '@/server/db.server';
27
26
 
28
- export type SessionStreamStatus = 'stream' | 'finish' | 'abort' | 'error';
27
+ export type SessionStreamStatus = 'stream' | 'finish' | 'abort';
29
28
 
30
29
  export async function markStreamStatus(sessionId: string, status: SessionStreamStatus): Promise<void> {
31
30
  try {
@@ -87,7 +86,7 @@ export async function loadStreamStatuses(): Promise<Record<string, SessionStream
87
86
  }[];
88
87
  const map: Record<string, SessionStreamStatus> = {};
89
88
  for (const row of rows) {
90
- if (row.status === 'stream' || row.status === 'finish' || row.status === 'abort' || row.status === 'error') {
89
+ if (row.status === 'stream' || row.status === 'finish' || row.status === 'abort') {
91
90
  map[row.session_id] = row.status;
92
91
  }
93
92
  }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Covers the boundaries of the subagent label derivation: the Target-section
8
+ * scan (heading level, colon variant, section end) and the two identity shapes.
9
+ *
10
+ * These are the cases where the label is derived from prompt text the caller
11
+ * controls, so a wrong boundary silently renders template boilerplate or a
12
+ * section name where the assignment's headline belongs.
13
+ */
14
+
15
+ import { describe, expect, test } from 'bun:test';
16
+
17
+ import { assignmentTarget, subagentHeaderTitle, subagentRowLabel } from '@/shared/lib/omp/subagent/label';
18
+ import type { SubagentInfo } from '@/shared/types/omp/subagent';
19
+
20
+ /** Minimal roster entry — only the fields the label helpers read. */
21
+ function makeSubagent(overrides: Partial<SubagentInfo> = {}): SubagentInfo {
22
+ return { id: 'Scout', agent: 'task', status: 'completed', index: 0, ...overrides };
23
+ }
24
+
25
+ describe('assignmentTarget', () => {
26
+ test('reads the headline under the task template Target section', () => {
27
+ const task = 'Complete assignment thoroughly:\n\n# Target\nCount .tsx files under src/.\n\n# Change\nNone.\n\n# Acceptance\nReport the integer.';
28
+ expect(assignmentTarget(task)).toBe('Count .tsx files under src/.');
29
+ });
30
+
31
+ test('stops at the next heading so Change prose never becomes the headline', () => {
32
+ const task = '# Target\nMap the panel data model.\n\n# Change\nDo the thing';
33
+ expect(assignmentTarget(task)).toBe('Map the panel data model.');
34
+ });
35
+
36
+ test('accepts any heading level and a trailing colon', () => {
37
+ expect(assignmentTarget('## Target:\nHeadline here')).toBe('Headline here');
38
+ expect(assignmentTarget('### Target\nHeadline here')).toBe('Headline here');
39
+ });
40
+
41
+ test('skips an empty Target section instead of promoting the next section', () => {
42
+ expect(assignmentTarget('# Target\n\n# Change\nDo the thing')).toBe('Do the thing');
43
+ });
44
+
45
+ test('falls back to the first prose line when no Target section exists', () => {
46
+ expect(assignmentTarget('Fix the sidebar expand bug')).toBe('Fix the sidebar expand bug');
47
+ expect(assignmentTarget('# Scope\nNot a target section')).toBe('Not a target section');
48
+ });
49
+
50
+ test('never returns the template lead-in or a bare heading', () => {
51
+ expect(assignmentTarget('Complete assignment thoroughly:')).toBeUndefined();
52
+ expect(assignmentTarget('# Target')).toBeUndefined();
53
+ });
54
+
55
+ test('returns undefined for absent or empty text', () => {
56
+ expect(assignmentTarget(undefined)).toBeUndefined();
57
+ expect(assignmentTarget(' \n\n ')).toBeUndefined();
58
+ });
59
+ });
60
+
61
+ describe('subagentHeaderTitle', () => {
62
+ test('appends the model role recorded on a history entry', () => {
63
+ expect(subagentHeaderTitle(makeSubagent({ modelRole: 'smol' }))).toBe('Scout (task:smol)');
64
+ });
65
+
66
+ test('falls back to the role carried by a live progress frame', () => {
67
+ expect(subagentHeaderTitle(makeSubagent({ progress: { modelRole: 'task' } }))).toBe('Scout (task:task)');
68
+ });
69
+
70
+ test('omits the role when the run recorded none', () => {
71
+ expect(subagentHeaderTitle(makeSubagent())).toBe('Scout (task)');
72
+ });
73
+ });
74
+
75
+ describe('subagentRowLabel', () => {
76
+ test('appends the assignment headline to the identity', () => {
77
+ const subagent = makeSubagent({ task: 'Complete assignment thoroughly:\n\n# Target\nHeadline here.\n\n# Change\nx' });
78
+ expect(subagentRowLabel(subagent)).toBe('Scout (task): Headline here.');
79
+ });
80
+
81
+ test('reads the assignment field when the task template is absent', () => {
82
+ expect(subagentRowLabel(makeSubagent({ assignment: '# Target\nFrom the assignment field.' })))
83
+ .toBe('Scout (task): From the assignment field.');
84
+ });
85
+
86
+ test('falls back to the description, then to the bare identity', () => {
87
+ expect(subagentRowLabel(makeSubagent({ description: 'fallback description' }))).toBe('Scout (task): fallback description');
88
+ expect(subagentRowLabel(makeSubagent())).toBe('Scout (task)');
89
+ });
90
+
91
+ test('keeps the identity when the assignment has no usable headline', () => {
92
+ expect(subagentRowLabel(makeSubagent({ task: '# Target' }))).toBe('Scout (task)');
93
+ });
94
+ });
@@ -0,0 +1,84 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * One-line labels for a subagent roster entry.
8
+ *
9
+ * A delegated assignment is a task prompt, not a sentence. The wire carries it
10
+ * twice: `assignment` is the document the caller wrote, and `task` is that same
11
+ * document wrapped by the task tool's template ("Complete assignment
12
+ * thoroughly:" followed by `# Target` / `# Change` / `# Acceptance` sections).
13
+ * Rendering either verbatim in a 268px sidebar row would spend the whole row on
14
+ * the template's lead-in, so a row shows the Target section's headline — the
15
+ * one line that says what the agent is for.
16
+ *
17
+ * The two surfaces differ only in what they append to the identity:
18
+ * roster row → `id (agent): target`
19
+ * banner → `id (agent:modelRole)`
20
+ */
21
+
22
+ import type { SubagentInfo } from '@/shared/types/omp/subagent';
23
+
24
+ /** Any markdown heading — the boundary between prompt sections. */
25
+ const HEADING_RE = /^#{1,6}\s+\S/;
26
+ /** The section the task prompt template mandates as the assignment's summary. */
27
+ const TARGET_HEADING_RE = /^#{1,6}\s+target\s*:?\s*$/i;
28
+ /** The task template's own lead-in; it says nothing about the assignment. */
29
+ const LEAD_IN_RE = /^complete (the )?assignment\b/i;
30
+
31
+ /** First line of `lines` that has content, trimmed. */
32
+ function firstLine(lines: string[]): string | undefined {
33
+ for (const line of lines) {
34
+ const trimmed = line.trim();
35
+ if (trimmed) return trimmed;
36
+ }
37
+ return undefined;
38
+ }
39
+
40
+ /**
41
+ * The headline of a delegated assignment: the first line of its `# Target`
42
+ * section, falling back to the prompt's own first prose line when there is no
43
+ * such section (or it is empty).
44
+ */
45
+ export function assignmentTarget(text: string | undefined): string | undefined {
46
+ if (!text) return undefined;
47
+ const lines = text.split('\n');
48
+ const headingAt = lines.findIndex((line) => TARGET_HEADING_RE.test(line.trim()));
49
+ if (headingAt !== -1) {
50
+ // Bounded by the next heading: an empty Target section must not promote the
51
+ // Change/Acceptance prose that follows it into the headline.
52
+ const body = lines.slice(headingAt + 1);
53
+ const nextHeading = body.findIndex((line) => HEADING_RE.test(line.trim()));
54
+ const headline = firstLine(nextHeading === -1 ? body : body.slice(0, nextHeading));
55
+ if (headline) return headline;
56
+ }
57
+ // No Target section, or an empty one: the prompt's first prose line. Headings
58
+ // are skipped because a section name is not a summary of the assignment.
59
+ return firstLine(lines.filter((line) => {
60
+ const trimmed = line.trim();
61
+ return !HEADING_RE.test(trimmed) && !LEAD_IN_RE.test(trimmed);
62
+ }));
63
+ }
64
+
65
+ /** `id (agent:modelRole)`, or `id (agent)` when no role was recorded. Live
66
+ * snapshots carry the role on `progress`; history entries carry it top-level,
67
+ * because the on-disk fold records it from the parent's task toolResult rather
68
+ * than from a progress frame. */
69
+ export function subagentHeaderTitle(subagent: SubagentInfo): string {
70
+ const role = subagent.modelRole ?? subagent.progress?.modelRole;
71
+ return role
72
+ ? `${subagent.id} (${subagent.agent}:${role})`
73
+ : `${subagent.id} (${subagent.agent})`;
74
+ }
75
+
76
+ /** `id (agent): target` — the roster row's one-line label. The description is
77
+ * the fallback for entries whose assignment text never reached the roster. */
78
+ export function subagentRowLabel(subagent: SubagentInfo): string {
79
+ const target = assignmentTarget(subagent.task)
80
+ ?? assignmentTarget(subagent.assignment)
81
+ ?? subagent.description;
82
+ const identity = `${subagent.id} (${subagent.agent})`;
83
+ return target ? `${identity}: ${target}` : identity;
84
+ }