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
@@ -3,190 +3,28 @@ import type { ActionFunctionArgs, LoaderFunctionArgs } from '@/server/lib/remix-
3
3
  import { getDb } from '@/server/db.server';
4
4
  import { INITIAL_MODELS_CATALOG } from '@/client/data/models/catalog';
5
5
  import { isMockMode } from '@/server/mock.server';
6
- import type { AIModelOption, ModelEntry, ModelsData, ProviderItem } from '@/shared/types';
6
+ import type { AIModelOption, ModelsData, ProviderItem } from '@/shared/types';
7
7
  import { readSettingsJson, writeSettingsJson } from '@/server/lib/db/settings-store';
8
- import { runUtilityCommand, type OmpModel } from '@/server/lib/omp/rpc/utility';
9
- import { readDisabledProviders } from '@/server/lib/omp/config/roles';
10
- import { invalidateModelsCaches } from '@/shared/lib/models/server-cache';
8
+ import { modelKey } from '@/shared/lib/models/identity';
9
+ import {
10
+ EMPTY_MODELS,
11
+ SAFE_MODEL_LOAD_FAILURE_MESSAGE,
12
+ invalidateModelsCache,
13
+ loadModelsWithCache,
14
+ readStoredProvidersForModels,
15
+ } from '@/server/lib/models/registry.server';
16
+ import {
17
+ readModelPreferencesFor,
18
+ recordRecentPreference,
19
+ toggleFavoritePreference,
20
+ } from '@/server/lib/models/preferences.server';
11
21
 
12
22
  const MODELS_CATALOG_KEY = 'omp_models_catalog';
13
23
  const SELECTED_MODEL_KEY = 'omp_selected_model';
14
- const PROVIDERS_CONFIG_KEY = 'omp_providers_config';
15
24
 
16
- const MODELS_CACHE_TTL_MS = 60_000;
17
- const SAFE_MODEL_LOAD_FAILURE_MESSAGE = 'Model list is temporarily unavailable. Check your configuration and try again.';
18
-
19
- const modelNameCollator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' });
20
-
21
- function compareModelEntries(a: { id: string; name: string; provider: string }, b: { id: string; name: string; provider: string }): number {
22
- return modelNameCollator.compare(a.name || a.id, b.name || b.id)
23
- || modelNameCollator.compare(a.provider, b.provider)
24
- || modelNameCollator.compare(a.id, b.id);
25
- }
26
-
27
- // "off" is always a valid selector; the concrete efforts come from the model's
28
- // baked thinking metadata (omp: getSupportedEfforts = reasoning ? efforts : []).
29
- function thinkingLevelsFor(model: OmpModel): string[] {
30
- if (!model.reasoning) return ['off'];
31
- return ['off', ...(model.thinking?.efforts ?? [])];
32
- }
33
-
34
- function supportsFastMode(model: OmpModel): boolean {
35
- return model.provider === 'anthropic' || model.provider === 'openai' || model.provider === 'google';
36
- }
37
-
38
- /**
39
- * Disabled providers recorded in the chamber's own provider overlay. Used only
40
- * by the MOCK path of `/api/models`, which has no config.yml `disabledProviders`
41
- * to read — real mode gets the same answer natively.
42
- */
43
- async function readStoredProvidersForModels(): Promise<ProviderItem[]> {
44
- try {
45
- const db = await getDb();
46
- const parsed = await readSettingsJson<unknown>(db, PROVIDERS_CONFIG_KEY, null);
47
- if (!Array.isArray(parsed)) return [];
48
- return parsed.filter((provider): provider is ProviderItem => (
49
- typeof provider === 'object' && provider !== null
50
- && typeof (provider as ProviderItem).slug === 'string'
51
- && (provider as ProviderItem).disabled === true
52
- ));
53
- } catch {
54
- return [];
55
- }
56
- }
57
-
58
- function hiddenModelKey(provider: string, modelId: string): string {
59
- return `${provider.trim().toLowerCase()}:${modelId}`;
60
- }
61
-
62
- async function readHiddenModelKeys(): Promise<Set<string>> {
63
- try {
64
- const db = await getDb();
65
- const parsed = await readSettingsJson<unknown>(db, PROVIDERS_CONFIG_KEY, null);
66
- if (!Array.isArray(parsed)) return new Set();
67
- const hidden = new Set<string>();
68
- for (const provider of parsed) {
69
- if (typeof provider?.slug !== 'string' || !Array.isArray(provider.models)) continue;
70
- for (const model of provider.models) {
71
- if (typeof model?.id === 'string' && model.isVisible === false) {
72
- hidden.add(hiddenModelKey(provider.slug, model.id));
73
- }
74
- }
75
- }
76
- return hidden;
77
- } catch {
78
- return new Set();
79
- }
80
- }
81
-
82
- /**
83
- * A model is offered only when its provider is neither disabled in omp's
84
- * config.yml (`disabledProviders`) nor carrying a per-model hide in the
85
- * chamber overlay. Disabling the provider is the coarse switch — Settings →
86
- * Providers flips it so the whole provider leaves the chat picker.
87
- */
88
- function filterSelectableModels(
89
- available: OmpModel[],
90
- disabledProviders: Set<string>,
91
- hiddenModelKeys: Set<string>,
92
- ): OmpModel[] {
93
- return available.filter((model) => (
94
- !disabledProviders.has(model.provider)
95
- && !hiddenModelKeys.has(hiddenModelKey(model.provider, model.id))
96
- ));
97
- }
98
-
99
- async function loadModels(): Promise<ModelsData> {
100
- const availableResponse = await runUtilityCommand<{ models?: unknown }>(
101
- { type: 'get_available_models' },
102
- 120_000,
103
- );
104
- const available = Array.isArray(availableResponse.models)
105
- ? availableResponse.models
106
- .filter((model): model is OmpModel => (
107
- typeof model === 'object' && model !== null
108
- && typeof (model as OmpModel).id === 'string'
109
- && typeof (model as OmpModel).provider === 'string'
110
- ))
111
- .map((model) => ({
112
- ...model,
113
- name: typeof model.name === 'string' && model.name.trim().length > 0 ? model.name : model.id,
114
- }))
115
- : [];
116
-
117
- const disabledProviders = await readDisabledProviders();
118
- const hiddenModelKeys = await readHiddenModelKeys();
119
- const nameMap: Record<string, string> = {};
120
- const thinkingLevels: Record<string, string[]> = {};
121
- const modelList: ModelEntry[] = filterSelectableModels(available, disabledProviders, hiddenModelKeys)
122
- .map((m) => ({
123
- id: m.id,
124
- name: m.name,
125
- provider: m.provider,
126
- thinkingLevels: thinkingLevelsFor(m),
127
- supportsFastMode: supportsFastMode(m),
128
- contextWindow: m.contextWindow ?? undefined,
129
- maxTokens: m.maxTokens ?? undefined,
130
- cost: m.cost,
131
- }))
132
- .sort(compareModelEntries);
133
-
134
- const loginResponse = await runUtilityCommand<{ providers?: unknown }>(
135
- { type: 'get_login_providers' },
136
- 30_000,
137
- );
138
- const loginProviders = Array.isArray(loginResponse.providers)
139
- ? loginResponse.providers.filter((provider): provider is { id: string; name: string; authenticated: boolean } => (
140
- typeof provider === 'object' && provider !== null
141
- && typeof (provider as { id?: unknown }).id === 'string'
142
- && typeof (provider as { name?: unknown }).name === 'string'
143
- && typeof (provider as { authenticated?: unknown }).authenticated === 'boolean'
144
- ))
145
- : [];
146
- const connectedProviders = loginProviders
147
- .filter((provider) => provider.authenticated)
148
- .map((provider) => ({ id: provider.id, name: provider.name, disabled: disabledProviders.has(provider.id) }));
149
- for (const m of available) {
150
- const key = `${m.provider}:${m.id}`;
151
- nameMap[key] = m.name;
152
- thinkingLevels[key] = thinkingLevelsFor(m);
153
- }
154
-
155
- let defaultModel: { provider: string; modelId: string } | null = null;
156
- try {
157
- const state = await runUtilityCommand<{ model?: { provider?: string; id?: string } }>(
158
- { type: 'get_state' },
159
- 30_000,
160
- );
161
- const provider = state.model?.provider;
162
- const modelId = state.model?.id;
163
- if (provider && modelId && available.some((m) => m.provider === provider && m.id === modelId)) {
164
- defaultModel = { provider, modelId };
165
- }
166
- } catch {
167
- // Default model is cosmetic — the models list is still useful without it.
168
- }
169
-
170
- return { models: nameMap, modelList, defaultModel, thinkingLevels, connectedProviders };
171
- }
172
-
173
- const EMPTY_MODELS: ModelsData = {
174
- models: {},
175
- modelList: [],
176
- defaultModel: null,
177
- thinkingLevels: {},
178
- };
179
-
180
- async function loadModelsWithCache(): Promise<ModelsData> {
181
- const cached = globalThis.__ompChamberModelsCache;
182
- if (cached && cached.expiresAt > Date.now()) return cached.data;
183
- const data = await loadModels();
184
- globalThis.__ompChamberModelsCache = { data, expiresAt: Date.now() + MODELS_CACHE_TTL_MS };
185
- return data;
186
- }
187
-
188
- function invalidateModelsCache(): void {
189
- invalidateModelsCaches();
25
+ /** Error text for a 500 envelope without leaking a non-Error throw as `undefined`. */
26
+ function errorMessage(error: unknown): string {
27
+ return error instanceof Error ? error.message : String(error);
190
28
  }
191
29
 
192
30
  /** Parse a persisted { provider, modelId|id } reference without trusting the JSON shape. */
@@ -200,7 +38,7 @@ function readSelectedModelRef(value: unknown): { provider: string; modelId: stri
200
38
  }
201
39
 
202
40
  /** The user's persisted pick resolved against the live registry; null when stale or unparseable. */
203
- async function loadPersistedModelOption(modelList: ModelEntry[]): Promise<AIModelOption | null> {
41
+ async function loadPersistedModelOption(modelList: ModelsData['modelList']): Promise<AIModelOption | null> {
204
42
  try {
205
43
  const db = await getDb();
206
44
  const selected = readSelectedModelRef(await readSettingsJson<unknown>(db, SELECTED_MODEL_KEY, null));
@@ -220,37 +58,44 @@ async function loadPersistedModelOption(modelList: ModelEntry[]): Promise<AIMode
220
58
  }
221
59
  }
222
60
 
223
- export async function loader({ request }: LoaderFunctionArgs) {
224
- const mock = isMockMode();
61
+ /**
62
+ * MOCK mode keeps the SQLite-backed demo catalog, but the favorite/recent
63
+ * rails come from the same preference store as real mode — the catalog's own
64
+ * `isFavorite`/`isRecent` flags are only its seed.
65
+ */
66
+ async function loadMockModels() {
67
+ const db = await getDb();
68
+ const catalog = await readSettingsJson<unknown>(db, MODELS_CATALOG_KEY, null);
69
+ const stored: AIModelOption[] = Array.isArray(catalog) && catalog.length > 0 ? catalog : INITIAL_MODELS_CATALOG;
70
+ // Demo mode has no config.yml to read, so the disable flag lives on the
71
+ // stored provider overlay. Filtering here keeps the model picker honest
72
+ // in MOCK=true too — the settings page must be able to prove the switch.
73
+ const disabledSlugs = new Set(
74
+ (await readStoredProvidersForModels()).map((provider: ProviderItem) => provider.slug.trim().toLowerCase()),
75
+ );
76
+ const models = stored.filter((model) => !disabledSlugs.has(model.provider.trim().toLowerCase()));
77
+ const persisted = await readSettingsJson<AIModelOption | null>(db, SELECTED_MODEL_KEY, null);
78
+ // Every provider disabled ⇒ empty list and no selection; the composer must
79
+ // see that honestly instead of holding on to a hidden provider's model.
80
+ let selectedModel: AIModelOption | null = models[5] || models[0] || null;
81
+ if (persisted && persisted.id && persisted.provider) {
82
+ const match = models.find(m => m.id === persisted.id && m.provider === persisted.provider);
83
+ if (match) selectedModel = match;
84
+ }
85
+ return { models, selectedModel, modelPreferences: await readModelPreferencesFor(models), isMock: true };
86
+ }
225
87
 
88
+ export async function loader({ request }: LoaderFunctionArgs) {
226
89
  // MOCK mode keeps the SQLite-backed catalog (chamber demo behavior).
227
- if (mock) {
90
+ if (isMockMode()) {
228
91
  try {
229
- const db = await getDb();
230
- const catalog = await readSettingsJson<unknown>(db, MODELS_CATALOG_KEY, null);
231
- const stored: AIModelOption[] = Array.isArray(catalog) && catalog.length > 0 ? catalog : INITIAL_MODELS_CATALOG;
232
- // Demo mode has no config.yml to read, so the disable flag lives on the
233
- // stored provider overlay. Filtering here keeps the model picker honest
234
- // in MOCK=true too — the settings page must be able to prove the switch.
235
- const disabledSlugs = new Set(
236
- (await readStoredProvidersForModels()).map((provider) => provider.slug.trim().toLowerCase()),
237
- );
238
- const models = stored.filter((model) => !disabledSlugs.has(model.provider.trim().toLowerCase()));
239
- const persisted = await readSettingsJson<AIModelOption | null>(db, SELECTED_MODEL_KEY, null);
240
- // Every provider disabled ⇒ empty list and no selection; the composer must
241
- // see that honestly instead of holding on to a hidden provider's model.
242
- let selectedModel: AIModelOption | null = models[5] || models[0] || null;
243
- if (persisted && persisted.id && persisted.provider) {
244
- const match = models.find(m => m.id === persisted.id && m.provider === persisted.provider);
245
- if (match) selectedModel = match;
246
- }
247
- return json({ models, selectedModel, isMock: mock });
248
- } catch (error: any) {
92
+ return json(await loadMockModels());
93
+ } catch (error: unknown) {
249
94
  return json({
250
- error: error.message,
95
+ error: errorMessage(error),
251
96
  models: INITIAL_MODELS_CATALOG,
252
97
  selectedModel: INITIAL_MODELS_CATALOG[5] || INITIAL_MODELS_CATALOG[0] || null,
253
- isMock: mock,
98
+ isMock: true,
254
99
  }, { status: 500 });
255
100
  }
256
101
  }
@@ -261,8 +106,13 @@ export async function loader({ request }: LoaderFunctionArgs) {
261
106
  void request;
262
107
  try {
263
108
  const data = await loadModelsWithCache();
264
- // The selection changes independently of the 60s registry cache.
265
- return json({ ...data, selectedModel: await loadPersistedModelOption(data.modelList) });
109
+ // The selection and the preferences change independently of the 60s
110
+ // registry cache, so both are read fresh on every request.
111
+ return json({
112
+ ...data,
113
+ selectedModel: await loadPersistedModelOption(data.modelList),
114
+ modelPreferences: await readModelPreferencesFor(data.modelList),
115
+ });
266
116
  } catch {
267
117
  return json({ ...EMPTY_MODELS, modelError: SAFE_MODEL_LOAD_FAILURE_MESSAGE });
268
118
  }
@@ -272,23 +122,42 @@ export async function action({ request }: ActionFunctionArgs) {
272
122
  try {
273
123
  const db = await getDb();
274
124
  const body = await request.json();
125
+ const mock = isMockMode();
275
126
 
276
127
  const catalog = await readSettingsJson<unknown>(db, MODELS_CATALOG_KEY, null);
277
128
  // The shipped demo catalog is MOCK-only: a real install starts from an empty
278
129
  // list, so a demo model can never be persisted into its stored catalog.
279
130
  let models: AIModelOption[] = Array.isArray(catalog)
280
131
  ? catalog
281
- : (isMockMode() ? INITIAL_MODELS_CATALOG : []);
132
+ : (mock ? INITIAL_MODELS_CATALOG : []);
282
133
 
283
134
  const { actionType, modelId, provider, thinkingLevel, model, models: newModels } = body;
284
135
 
136
+ /**
137
+ * Composite keys the registry currently serves. Preference writes prune
138
+ * against it so a model that left models.yml — or whose provider was
139
+ * disconnected — cannot hold one of the five recent slots. Real mode reads
140
+ * the same 60s-cached registry the loader serves; MOCK uses its demo list.
141
+ */
142
+ const knownModelKeys = async (): Promise<Set<string>> => {
143
+ const live = mock ? models : (await loadModelsWithCache()).modelList;
144
+ return new Set(live.map((entry) => modelKey(entry)));
145
+ };
146
+
285
147
  // Model-scoped mutations must match provider + id: the registry serves the
286
148
  // same model id from several providers, so an id-only match rewrites the
287
149
  // first provider's entry instead of the one the user acted on.
288
150
  if (actionType === 'toggleFavorite' && modelId && provider) {
289
- models = models.map(m => m.id === modelId && m.provider === provider ? { ...m, isFavorite: !m.isFavorite } : m);
290
- await writeSettingsJson(db, MODELS_CATALOG_KEY, models);
291
- return json({ success: true, models });
151
+ // Favorites live in the preference store, NOT in the model catalog: the
152
+ // catalog is a MOCK-only demo list that real mode never reads back, so a
153
+ // star written there vanished on the next render.
154
+ const preferences = await toggleFavoritePreference(modelKey({ provider, id: modelId }), await knownModelKeys());
155
+ // Keep the demo catalog's flag in step so MOCK mode's own views agree.
156
+ if (mock) {
157
+ models = models.map(m => m.id === modelId && m.provider === provider ? { ...m, isFavorite: !m.isFavorite } : m);
158
+ await writeSettingsJson(db, MODELS_CATALOG_KEY, models);
159
+ }
160
+ return json({ success: true, models, modelPreferences: preferences });
292
161
  }
293
162
 
294
163
  if (actionType === 'setThinking' && modelId && provider && thinkingLevel) {
@@ -308,8 +177,12 @@ export async function action({ request }: ActionFunctionArgs) {
308
177
  const parsedModel = readSelectedModelRef(model);
309
178
  if (parsedModel) {
310
179
  invalidateModelsCache();
311
- // Live set_model against the current session is handled by the agent
312
- // RPC bridge; here we only persist the selection for session spawn.
180
+ // "The user picked this model" is one event: recording the recent here
181
+ // keeps a second request from ever disagreeing with the selection.
182
+ await recordRecentPreference(
183
+ modelKey({ provider: parsedModel.provider, id: parsedModel.modelId }),
184
+ await knownModelKeys(),
185
+ );
313
186
  }
314
187
  return json({ success: true, selectedModel: model });
315
188
  }
@@ -326,7 +199,7 @@ export async function action({ request }: ActionFunctionArgs) {
326
199
  }
327
200
 
328
201
  return json({ error: 'Invalid action or parameters' }, { status: 400 });
329
- } catch (error: any) {
330
- return json({ error: error.message }, { status: 500 });
202
+ } catch (error: unknown) {
203
+ return json({ error: errorMessage(error) }, { status: 500 });
331
204
  }
332
205
  }
@@ -1,12 +1,11 @@
1
1
  import { actionBindings, bindingsFor, type HandlerBinding } from '@/server/lib/route-adapter';
2
2
  import * as sessionsList from '@/server/routes/sessions/list';
3
3
  import * as sessionsFolder from '@/server/routes/sessions/folder';
4
+ import * as sessionsQueue from '@/server/routes/sessions/queue';
4
5
  import {
5
6
  archiveSession,
6
- getQueue,
7
7
  getSessionState,
8
8
  markSeen,
9
- putQueue,
10
9
  putSessionState,
11
10
  renameSession,
12
11
  } from '@/server/routes/sessions/session';
@@ -16,7 +15,16 @@ export const sessionsBindings: HandlerBinding[] = [
16
15
  ...bindingsFor(sessionsList, '/api/sessions/list'),
17
16
  ...bindingsFor(sessionsFolder, '/api/sessions/:sessionId'),
18
17
  ...actionBindings(archiveSession, '/api/sessions/:sessionId/archive'),
19
- ...actionBindings(putQueue, '/api/sessions/:sessionId/queue', getQueue),
18
+ // Per-item queue routes. `actionBindings` mounts ALL four mutating verbs on
19
+ // one path, and Elysia lets a later registration override an earlier one —
20
+ // mounting reorder on the same `/queue` path that way would clobber the
21
+ // add/GET bindings with its 405 GET fallback. Only reorder gets an explicit
22
+ // PUT-only binding; add carries the shared GET loader.
23
+ ...actionBindings(sessionsQueue.addQueueItem, '/api/sessions/:sessionId/queue', sessionsQueue.getQueue),
24
+ ...actionBindings(sessionsQueue.editQueueItem, '/api/sessions/:sessionId/queue/:itemId'),
25
+ { method: 'PUT', path: '/api/sessions/:sessionId/queue', handler: sessionsQueue.reorderQueueItems },
26
+ { method: 'DELETE', path: '/api/sessions/:sessionId/queue/:itemId', handler: sessionsQueue.removeQueueItem },
27
+ ...actionBindings(sessionsQueue.nudgeQueueDelivery, '/api/sessions/:sessionId/queue/deliver'),
20
28
  ...actionBindings(renameSession, '/api/sessions/:sessionId/rename'),
21
29
  ...actionBindings(putSessionState, '/api/sessions/:sessionId/state', getSessionState),
22
30
  ...actionBindings(markSeen, '/api/sessions/:sessionId/stream-seen'),
@@ -0,0 +1,121 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Per-item follow-up queue routes (`queued_messages` table). The client panel
8
+ * is a VIEW: every mutation goes through one of these endpoints and returns
9
+ * the canonical queue, so the client never ships a full-list replacement
10
+ * (that shape is what let a reload wipe the table and a session switch copy
11
+ * one session's queue into another).
12
+ */
13
+
14
+ import { json } from '@/server/lib/remix-compat';
15
+ import type { ActionFunctionArgs, LoaderFunctionArgs } from '@/server/lib/remix-compat';
16
+ import { requireParam } from '@/server/lib/route-adapter';
17
+ import {
18
+ appendQueueItem,
19
+ deleteQueueItem,
20
+ listQueue,
21
+ patchQueueItem,
22
+ reorderQueue,
23
+ } from '@/server/lib/queue/store.server';
24
+ import { getRpcSession } from '@/server/lib/omp/rpc/manager';
25
+ import { scheduleQueueDelivery } from '@/server/lib/queue/delivery.server';
26
+
27
+ /** GET — the session's queued follow-ups in delivery order. */
28
+ export async function getQueue({ params }: LoaderFunctionArgs) {
29
+ const sessionId = requireParam(params, 'sessionId');
30
+ if (!sessionId) return json({ error: 'Session ID is required' }, { status: 400 });
31
+ return json({ queue: await listQueue(sessionId) });
32
+ }
33
+
34
+ /**
35
+ * POST — append one queued item. Body: `{ text, attachments?, model? }`.
36
+ * Returns the canonical queue.
37
+ */
38
+ export async function addQueueItem({ request, params }: ActionFunctionArgs) {
39
+ const sessionId = requireParam(params, 'sessionId');
40
+ if (!sessionId) return json({ error: 'Session ID is required' }, { status: 400 });
41
+ if (request.method !== 'POST') return json({ error: 'Method not allowed' }, { status: 405 });
42
+
43
+ const body = (await request.json().catch(() => null)) as Record<string, unknown> | null;
44
+ if (!body || typeof body.text !== 'string' || (!body.text.trim() && !Array.isArray(body.attachments))) {
45
+ return json({ error: 'Invalid body: expected { text: string, attachments?, model? }' }, { status: 400 });
46
+ }
47
+ const queue = await appendQueueItem(sessionId, {
48
+ text: body.text,
49
+ attachments: body.attachments,
50
+ model: body.model,
51
+ });
52
+ return json({ queue });
53
+ }
54
+
55
+ /**
56
+ * PATCH — edit one item's text and/or model snapshot. Body: `{ text?, model? }`.
57
+ * Returns the canonical queue; 404 when the id is unknown or already claimed.
58
+ */
59
+ export async function editQueueItem({ request, params }: ActionFunctionArgs) {
60
+ const sessionId = requireParam(params, 'sessionId');
61
+ if (!sessionId) return json({ error: 'Session ID is required' }, { status: 400 });
62
+ if (request.method !== 'PATCH') return json({ error: 'Method not allowed' }, { status: 405 });
63
+
64
+ const itemId = params.itemId;
65
+ if (!itemId) return json({ error: 'Item ID is required' }, { status: 400 });
66
+ const body = (await request.json().catch(() => null)) as Record<string, unknown> | null;
67
+ if (!body) return json({ error: 'Invalid JSON body' }, { status: 400 });
68
+
69
+ const queue = await patchQueueItem(sessionId, itemId, { text: body.text, model: body.model });
70
+ if (!queue) return json({ error: 'Queue item not found' }, { status: 404 });
71
+ return json({ queue });
72
+ }
73
+
74
+ /**
75
+ * DELETE — remove one item by id. 404 when it was already claimed for
76
+ * delivery or never existed (both leave nothing to remove).
77
+ */
78
+ export async function removeQueueItem({ request, params }: ActionFunctionArgs) {
79
+ const sessionId = requireParam(params, 'sessionId');
80
+ if (!sessionId) return json({ error: 'Session ID is required' }, { status: 400 });
81
+ if (request.method !== 'DELETE') return json({ error: 'Method not allowed' }, { status: 405 });
82
+
83
+ const itemId = params.itemId;
84
+ if (!itemId) return json({ error: 'Item ID is required' }, { status: 400 });
85
+ const removed = await deleteQueueItem(sessionId, itemId);
86
+ if (!removed) return json({ error: 'Queue item not found' }, { status: 404 });
87
+ return json({ success: true });
88
+ }
89
+
90
+ /**
91
+ * PUT — reorder the queue to exactly `{ orderedIds: string[] }` (a drag in the
92
+ * panel ships ids only, never item payloads). Returns the canonical queue.
93
+ */
94
+ export async function reorderQueueItems({ request, params }: ActionFunctionArgs) {
95
+ const sessionId = requireParam(params, 'sessionId');
96
+ if (!sessionId) return json({ error: 'Session ID is required' }, { status: 400 });
97
+ if (request.method !== 'PUT') return json({ error: 'Method not allowed' }, { status: 405 });
98
+
99
+ const body = (await request.json().catch(() => null)) as { orderedIds?: unknown } | null;
100
+ if (!body || !Array.isArray(body.orderedIds) || body.orderedIds.some((id) => typeof id !== 'string')) {
101
+ return json({ error: 'Invalid body: expected { orderedIds: string[] }' }, { status: 400 });
102
+ }
103
+ return json({ queue: await reorderQueue(sessionId, body.orderedIds) });
104
+ }
105
+
106
+ /**
107
+ * POST /queue/deliver — client nudge: a tab that (re)opens a session offers
108
+ * the server a delivery slot. The server-side claim makes this a no-op when a
109
+ * run is active or the head was already claimed; an idle session with queued
110
+ * items gets its head delivered exactly once.
111
+ */
112
+ export async function nudgeQueueDelivery({ request, params }: ActionFunctionArgs) {
113
+ const sessionId = requireParam(params, 'sessionId');
114
+ if (!sessionId) return json({ error: 'Session ID is required' }, { status: 400 });
115
+ if (request.method !== 'POST') return json({ error: 'Method not allowed' }, { status: 405 });
116
+
117
+ const session = getRpcSession(sessionId);
118
+ if (!session?.isAlive()) return json({ success: true, delivered: false, reason: 'session-not-live' });
119
+ scheduleQueueDelivery(session);
120
+ return json({ success: true });
121
+ }