@agent-relay/dashboard 2.0.90 → 2.0.92

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/out/404.html +1 -1
  2. package/out/_next/static/chunks/5518-6d77237eefc8d5ae.js +1 -0
  3. package/out/_next/static/chunks/app/app/[[...slug]]/{page-4f01a33b51f23cea.js → page-c1376e695ba19e38.js} +1 -1
  4. package/out/_next/static/chunks/app/{page-2644ed4067d978e0.js → page-f2ebc7d0bc08e395.js} +1 -1
  5. package/out/about.html +1 -1
  6. package/out/about.txt +1 -1
  7. package/out/app/onboarding.html +1 -1
  8. package/out/app/onboarding.txt +1 -1
  9. package/out/app.html +1 -1
  10. package/out/app.txt +2 -2
  11. package/out/blog/go-to-bed-wake-up-to-a-finished-product.html +1 -1
  12. package/out/blog/go-to-bed-wake-up-to-a-finished-product.txt +1 -1
  13. package/out/blog/let-them-cook-multi-agent-orchestration.html +1 -1
  14. package/out/blog/let-them-cook-multi-agent-orchestration.txt +1 -1
  15. package/out/blog.html +1 -1
  16. package/out/blog.txt +1 -1
  17. package/out/careers.html +1 -1
  18. package/out/careers.txt +1 -1
  19. package/out/changelog.html +1 -1
  20. package/out/changelog.txt +1 -1
  21. package/out/cloud/link.html +1 -1
  22. package/out/cloud/link.txt +1 -1
  23. package/out/complete-profile.html +1 -1
  24. package/out/complete-profile.txt +1 -1
  25. package/out/connect-repos.html +1 -1
  26. package/out/connect-repos.txt +1 -1
  27. package/out/contact.html +1 -1
  28. package/out/contact.txt +1 -1
  29. package/out/dev/cli-tools.html +1 -1
  30. package/out/dev/cli-tools.txt +1 -1
  31. package/out/dev/log-viewer.html +1 -1
  32. package/out/dev/log-viewer.txt +1 -1
  33. package/out/docs.html +1 -1
  34. package/out/docs.txt +1 -1
  35. package/out/history.html +1 -1
  36. package/out/history.txt +1 -1
  37. package/out/index.html +1 -1
  38. package/out/index.txt +2 -2
  39. package/out/login.html +1 -1
  40. package/out/login.txt +1 -1
  41. package/out/metrics.html +1 -1
  42. package/out/metrics.txt +1 -1
  43. package/out/pricing.html +1 -1
  44. package/out/pricing.txt +1 -1
  45. package/out/privacy.html +1 -1
  46. package/out/privacy.txt +1 -1
  47. package/out/providers/setup/claude.html +1 -1
  48. package/out/providers/setup/claude.txt +1 -1
  49. package/out/providers/setup/codex.html +1 -1
  50. package/out/providers/setup/codex.txt +1 -1
  51. package/out/providers/setup/cursor.html +1 -1
  52. package/out/providers/setup/cursor.txt +1 -1
  53. package/out/providers.html +1 -1
  54. package/out/providers.txt +1 -1
  55. package/out/security.html +1 -1
  56. package/out/security.txt +1 -1
  57. package/out/signup.html +1 -1
  58. package/out/signup.txt +1 -1
  59. package/out/terms.html +1 -1
  60. package/out/terms.txt +1 -1
  61. package/package.json +1 -1
  62. package/src/components/App.tsx +6 -0
  63. package/src/components/SpawnModal.tsx +76 -171
  64. package/src/components/hooks/useMessages.test.ts +116 -0
  65. package/src/components/hooks/useMessages.ts +58 -4
  66. package/src/components/hooks/useModelOptions.ts +78 -0
  67. package/src/components/settings/SettingsPage.tsx +51 -105
  68. package/src/components/settings/types.ts +6 -9
  69. package/out/_next/static/chunks/9626-10e71fc51b892784.js +0 -1
  70. /package/out/_next/static/{pp6J1TkgtRc_o3BmtFoV0 → 5cqIVzlh9DbJT28EbNrcC}/_buildManifest.js +0 -0
  71. /package/out/_next/static/{pp6J1TkgtRc_o3BmtFoV0 → 5cqIVzlh9DbJT28EbNrcC}/_ssgManifest.js +0 -0
@@ -9,53 +9,10 @@ import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react'
9
9
  import { useDashboardConfig } from '../adapters';
10
10
 
11
11
  /**
12
- * Model options inlined from @agent-relay/config (cli-registry.yaml).
13
- * Inlined to avoid importing Node.js dependencies into the browser bundle.
12
+ * Model options are fetched from the server (/api/models) which sources them
13
+ * from @agent-relay/config (generated from cli-registry.yaml via codegen).
14
+ * The modelOptions prop is the primary source of model data.
14
15
  */
15
- const RegistryModelOptions = {
16
- Claude: [
17
- { value: 'sonnet', label: 'Sonnet' },
18
- { value: 'opus', label: 'Opus' },
19
- { value: 'haiku', label: 'Haiku' },
20
- ],
21
- Codex: [
22
- { value: 'gpt-5.2-codex', label: 'GPT-5.2 Codex — Frontier agentic coding model' },
23
- { value: 'gpt-5.3-codex', label: 'GPT-5.3 Codex — Latest frontier agentic coding model' },
24
- { value: 'gpt-5.3-codex-spark', label: 'GPT-5.3 Codex Spark — Ultra-fast coding model' },
25
- { value: 'gpt-5.1-codex-max', label: 'GPT-5.1 Codex Max — Deep and fast reasoning' },
26
- { value: 'gpt-5.2', label: 'GPT-5.2 — Frontier model, knowledge & reasoning' },
27
- { value: 'gpt-5.1-codex-mini', label: 'GPT-5.1 Codex Mini — Cheaper, faster' },
28
- ],
29
- Gemini: [
30
- { value: 'gemini-3-pro-preview', label: 'Gemini 3 Pro Preview' },
31
- { value: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro' },
32
- { value: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash' },
33
- { value: 'gemini-2.5-flash-lite', label: 'Gemini 2.5 Flash Lite' },
34
- ],
35
- Cursor: [
36
- { value: 'opus-4.5-thinking', label: 'Claude 4.5 Opus (Thinking)' },
37
- { value: 'opus-4.5', label: 'Claude 4.5 Opus' },
38
- { value: 'sonnet-4.5', label: 'Claude 4.5 Sonnet' },
39
- { value: 'sonnet-4.5-thinking', label: 'Claude 4.5 Sonnet (Thinking)' },
40
- { value: 'gpt-5.2-codex', label: 'GPT-5.2 Codex' },
41
- { value: 'gpt-5.2-codex-high', label: 'GPT-5.2 Codex High' },
42
- { value: 'gpt-5.2-codex-low', label: 'GPT-5.2 Codex Low' },
43
- { value: 'gpt-5.2-codex-xhigh', label: 'GPT-5.2 Codex Extra High' },
44
- { value: 'gpt-5.2-codex-fast', label: 'GPT-5.2 Codex Fast' },
45
- { value: 'gpt-5.2-codex-high-fast', label: 'GPT-5.2 Codex High Fast' },
46
- { value: 'gpt-5.2-codex-low-fast', label: 'GPT-5.2 Codex Low Fast' },
47
- { value: 'gpt-5.2-codex-xhigh-fast', label: 'GPT-5.2 Codex Extra High Fast' },
48
- { value: 'gpt-5.1-codex-max', label: 'GPT-5.1 Codex Max' },
49
- { value: 'gpt-5.1-codex-max-high', label: 'GPT-5.1 Codex Max High' },
50
- { value: 'gpt-5.2', label: 'GPT-5.2' },
51
- { value: 'gpt-5.2-high', label: 'GPT-5.2 High' },
52
- { value: 'gpt-5.1-high', label: 'GPT-5.1 High' },
53
- { value: 'gemini-3-pro', label: 'Gemini 3 Pro' },
54
- { value: 'gemini-3-flash', label: 'Gemini 3 Flash' },
55
- { value: 'composer-1', label: 'Composer 1' },
56
- { value: 'grok', label: 'Grok' },
57
- ],
58
- };
59
16
  import { getAgentColor, getAgentInitials } from '../lib/colors';
60
17
 
61
18
  export type SpeakOnTrigger = 'SESSION_END' | 'CODE_WRITTEN' | 'REVIEW_REQUEST' | 'EXPLICIT_ASK' | 'ALL_MESSAGES';
@@ -91,12 +48,7 @@ export interface SpawnModalProps {
91
48
  /** Agent defaults from settings */
92
49
  agentDefaults?: {
93
50
  defaultCliType: string | null;
94
- defaultModels: {
95
- claude: string;
96
- cursor: string;
97
- codex: string;
98
- gemini: string;
99
- };
51
+ defaultModels: Record<string, string>;
100
52
  };
101
53
  /** Available workspace repos (cloud mode) */
102
54
  repos?: Array<{ id: string; githubFullName: string }>;
@@ -104,13 +56,12 @@ export interface SpawnModalProps {
104
56
  activeRepoId?: string;
105
57
  /** Connected provider IDs (cloud mode) - used to disable unconnected providers */
106
58
  connectedProviders?: string[];
107
- /** Model options per agent type — provided by the host app */
59
+ /** Model options per agent type — provided by the host app (fetched from /api/models) */
108
60
  modelOptions?: {
109
- claude?: ModelOption[];
110
- cursor?: ModelOption[];
111
- codex?: ModelOption[];
112
- gemini?: ModelOption[];
61
+ [cli: string]: ModelOption[];
113
62
  };
63
+ /** Default model per CLI from cli-registry.yaml (fetched from /api/models) */
64
+ registryDefaultModels?: Record<string, string>;
114
65
  }
115
66
 
116
67
  export interface ModelOption {
@@ -120,15 +71,19 @@ export interface ModelOption {
120
71
 
121
72
  const EMPTY_MODEL_OPTIONS: ModelOption[] = [];
122
73
 
123
- /** Built-in model options sourced from @agent-relay/config (cli-registry.yaml) */
124
- export const DEFAULT_MODEL_OPTIONS: Record<string, ModelOption[]> = {
125
- claude: RegistryModelOptions.Claude,
126
- cursor: RegistryModelOptions.Cursor,
127
- codex: RegistryModelOptions.Codex,
128
- gemini: RegistryModelOptions.Gemini,
129
- };
130
74
 
131
- const AGENT_TEMPLATES = [
75
+ interface AgentTemplate {
76
+ id: string;
77
+ name: string;
78
+ command: string;
79
+ description: string;
80
+ icon: string;
81
+ providerId: string | null;
82
+ supportsModelSelection?: boolean;
83
+ comingSoon?: boolean;
84
+ }
85
+
86
+ const AGENT_TEMPLATES: AgentTemplate[] = [
132
87
  {
133
88
  id: 'claude',
134
89
  name: 'Claude',
@@ -163,7 +118,7 @@ const AGENT_TEMPLATES = [
163
118
  description: 'OpenCode AI agent',
164
119
  icon: '🔷',
165
120
  providerId: 'opencode',
166
- comingSoon: true, // Not yet fully tested
121
+ supportsModelSelection: true,
167
122
  },
168
123
  {
169
124
  id: 'droid',
@@ -172,7 +127,7 @@ const AGENT_TEMPLATES = [
172
127
  description: 'Factory Droid agent',
173
128
  icon: '🤖',
174
129
  providerId: 'droid',
175
- comingSoon: true, // Not yet fully tested
130
+ supportsModelSelection: true,
176
131
  },
177
132
  {
178
133
  id: 'cursor',
@@ -206,23 +161,28 @@ export function SpawnModal({
206
161
  activeRepoId,
207
162
  connectedProviders,
208
163
  modelOptions,
164
+ registryDefaultModels,
209
165
  }: SpawnModalProps) {
210
166
  const { features } = useDashboardConfig();
211
167
  const hasWorkspaceFeature = features.workspaces;
212
168
  const canUseWorkspaceRepoSelection = hasWorkspaceFeature && !!repos?.length;
213
169
 
214
- const claudeModels = modelOptions?.claude ?? DEFAULT_MODEL_OPTIONS.claude ?? EMPTY_MODEL_OPTIONS;
215
- const cursorModels = modelOptions?.cursor ?? DEFAULT_MODEL_OPTIONS.cursor ?? EMPTY_MODEL_OPTIONS;
216
- const codexModels = modelOptions?.codex ?? DEFAULT_MODEL_OPTIONS.codex ?? EMPTY_MODEL_OPTIONS;
217
- const geminiModels = modelOptions?.gemini ?? DEFAULT_MODEL_OPTIONS.gemini ?? EMPTY_MODEL_OPTIONS;
170
+ /** Resolve model options for a CLI from the modelOptions prop */
171
+ const getModelsForCli = useCallback((cli: string): ModelOption[] => {
172
+ return modelOptions?.[cli] ?? EMPTY_MODEL_OPTIONS;
173
+ }, [modelOptions]);
174
+
175
+ const getDefaultModelForCli = useCallback((cli: string): string => {
176
+ return agentDefaults?.defaultModels?.[cli]
177
+ ?? registryDefaultModels?.[cli]
178
+ ?? getModelsForCli(cli)[0]?.value
179
+ ?? '';
180
+ }, [agentDefaults, registryDefaultModels, getModelsForCli]);
218
181
 
219
182
  const [selectedTemplate, setSelectedTemplate] = useState(AGENT_TEMPLATES[0]);
220
183
  const [name, setName] = useState('');
221
184
  const [customCommand, setCustomCommand] = useState('');
222
- const [selectedModel, setSelectedModel] = useState(agentDefaults?.defaultModels?.claude ?? claudeModels[0]?.value ?? '');
223
- const [selectedCursorModel, setSelectedCursorModel] = useState(agentDefaults?.defaultModels?.cursor ?? cursorModels[0]?.value ?? '');
224
- const [selectedCodexModel, setSelectedCodexModel] = useState(agentDefaults?.defaultModels?.codex ?? codexModels[0]?.value ?? '');
225
- const [selectedGeminiModel, setSelectedGeminiModel] = useState(agentDefaults?.defaultModels?.gemini ?? geminiModels[0]?.value ?? '');
185
+ const [selectedModels, setSelectedModels] = useState<Record<string, string>>({});
226
186
  const [cwd, setCwd] = useState('');
227
187
  const [selectedRepoId, setSelectedRepoId] = useState<string | undefined>(activeRepoId);
228
188
  const [team, setTeam] = useState('');
@@ -233,30 +193,31 @@ export function SpawnModal({
233
193
  const [shadowSpeakOn, setShadowSpeakOn] = useState<SpeakOnTrigger[]>(['EXPLICIT_ASK']);
234
194
  const [localError, setLocalError] = useState<string | null>(null);
235
195
  const nameInputRef = useRef<HTMLInputElement>(null);
196
+ const prevIsOpenRef = useRef(false);
197
+
198
+ /** Get selected model for the current template */
199
+ const getSelectedModel = useCallback((cli: string): string => {
200
+ return selectedModels[cli] ?? getDefaultModelForCli(cli);
201
+ }, [selectedModels, getDefaultModelForCli]);
236
202
 
237
- // Build effective command, always including model flag for Claude, Cursor, and Codex
203
+ const setModelForCli = useCallback((cli: string, model: string) => {
204
+ setSelectedModels(prev => ({ ...prev, [cli]: model }));
205
+ }, []);
206
+
207
+ // Build effective command, always including model flag for CLIs with model selection
238
208
  const effectiveCommand = useMemo(() => {
239
209
  if (selectedTemplate.id === 'custom') {
240
210
  return customCommand;
241
211
  }
242
- // For Claude, always append model flag
243
- if (selectedTemplate.id === 'claude') {
244
- return `${selectedTemplate.command} --model ${selectedModel}`;
245
- }
246
- // For Cursor, always append model flag
247
- if (selectedTemplate.id === 'cursor') {
248
- return `${selectedTemplate.command} --model ${selectedCursorModel}`;
249
- }
250
- // For Codex, always append model flag
251
- if (selectedTemplate.id === 'codex') {
252
- return `${selectedTemplate.command} --model ${selectedCodexModel}`;
253
- }
254
- // For Gemini, always append model flag
255
- if (selectedTemplate.id === 'gemini') {
256
- return `${selectedTemplate.command} --model ${selectedGeminiModel}`;
212
+ const template = AGENT_TEMPLATES.find(t => t.id === selectedTemplate.id);
213
+ if (template?.supportsModelSelection) {
214
+ const model = getSelectedModel(selectedTemplate.id);
215
+ if (model) {
216
+ return `${selectedTemplate.command} --model ${model}`;
217
+ }
257
218
  }
258
219
  return selectedTemplate.command;
259
- }, [selectedTemplate, customCommand, selectedModel, selectedCursorModel, selectedCodexModel, selectedGeminiModel]);
220
+ }, [selectedTemplate, customCommand, getSelectedModel]);
260
221
 
261
222
  const shadowMode = useMemo(() => deriveShadowMode(effectiveCommand), [effectiveCommand]);
262
223
 
@@ -277,8 +238,14 @@ export function SpawnModal({
277
238
  return `${prefix}-${num}`;
278
239
  }, [selectedTemplate, existingAgents]);
279
240
 
241
+ // Full form reset: only runs when modal is freshly opened (isOpen transitions false -> true)
242
+ // This prevents form state from being reset when modelOptions loads asynchronously while the modal is open
280
243
  useEffect(() => {
281
- if (isOpen) {
244
+ const wasOpen = prevIsOpenRef.current;
245
+ prevIsOpenRef.current = isOpen;
246
+
247
+ // Only reset form when modal opens, not on every dependency change while open
248
+ if (isOpen && !wasOpen) {
282
249
  // Determine default template based on settings
283
250
  // In cloud mode, also skip templates whose provider isn't connected
284
251
  const isTemplateAvailable = (t: typeof AGENT_TEMPLATES[number]) => {
@@ -296,10 +263,14 @@ export function SpawnModal({
296
263
  setSelectedTemplate(defaultTemplate);
297
264
  setName('');
298
265
  setCustomCommand('');
299
- setSelectedModel(agentDefaults?.defaultModels?.claude ?? claudeModels[0]?.value ?? '');
300
- setSelectedCursorModel(agentDefaults?.defaultModels?.cursor ?? cursorModels[0]?.value ?? '');
301
- setSelectedCodexModel(agentDefaults?.defaultModels?.codex ?? codexModels[0]?.value ?? '');
302
- setSelectedGeminiModel(agentDefaults?.defaultModels?.gemini ?? geminiModels[0]?.value ?? '');
266
+ // Reset all model selections to defaults
267
+ const initialModels: Record<string, string> = {};
268
+ for (const t of AGENT_TEMPLATES) {
269
+ if (t.supportsModelSelection) {
270
+ initialModels[t.id] = getDefaultModelForCli(t.id);
271
+ }
272
+ }
273
+ setSelectedModels(initialModels);
303
274
  setCwd('');
304
275
  setSelectedRepoId(activeRepoId);
305
276
  setTeam('');
@@ -311,7 +282,7 @@ export function SpawnModal({
311
282
  setLocalError(null);
312
283
  setTimeout(() => nameInputRef.current?.focus(), 100);
313
284
  }
314
- }, [isOpen, agentDefaults, activeRepoId, repos, connectedProviders, hasWorkspaceFeature, claudeModels, cursorModels, codexModels, geminiModels]);
285
+ }, [isOpen, agentDefaults, activeRepoId, repos, connectedProviders, hasWorkspaceFeature, getDefaultModelForCli]);
315
286
 
316
287
  const validateName = useCallback(
317
288
  (value: string): string | null => {
@@ -489,86 +460,20 @@ export function SpawnModal({
489
460
  </div>
490
461
  </div>
491
462
 
492
- {/* Model Selection (Claude only) */}
493
- {selectedTemplate.id === 'claude' && (
494
- <div className="mb-5">
495
- <label className="block text-sm font-semibold text-text-primary mb-2" htmlFor="claude-model">
496
- Model
497
- </label>
498
- <select
499
- id="claude-model"
500
- className="w-full py-2.5 px-3.5 border border-border rounded-md text-sm font-sans outline-none bg-bg-primary text-text-primary transition-colors duration-150 focus:border-accent disabled:bg-bg-hover disabled:text-text-muted"
501
- value={selectedModel}
502
- onChange={(e) => setSelectedModel(e.target.value)}
503
- disabled={isSpawning}
504
- >
505
- {claudeModels.map((model) => (
506
- <option key={model.value} value={model.value}>
507
- {model.label}
508
- </option>
509
- ))}
510
- </select>
511
- </div>
512
- )}
513
-
514
- {/* Model Selection (Cursor only) */}
515
- {selectedTemplate.id === 'cursor' && (
516
- <div className="mb-5">
517
- <label className="block text-sm font-semibold text-text-primary mb-2" htmlFor="cursor-model">
518
- Model
519
- </label>
520
- <select
521
- id="cursor-model"
522
- className="w-full py-2.5 px-3.5 border border-border rounded-md text-sm font-sans outline-none bg-bg-primary text-text-primary transition-colors duration-150 focus:border-accent disabled:bg-bg-hover disabled:text-text-muted"
523
- value={selectedCursorModel}
524
- onChange={(e) => setSelectedCursorModel(e.target.value)}
525
- disabled={isSpawning}
526
- >
527
- {cursorModels.map((model) => (
528
- <option key={model.value} value={model.value}>
529
- {model.label}
530
- </option>
531
- ))}
532
- </select>
533
- </div>
534
- )}
535
-
536
- {/* Model Selection (Codex only) */}
537
- {selectedTemplate.id === 'codex' && (
538
- <div className="mb-5">
539
- <label className="block text-sm font-semibold text-text-primary mb-2" htmlFor="codex-model">
540
- Model
541
- </label>
542
- <select
543
- id="codex-model"
544
- className="w-full py-2.5 px-3.5 border border-border rounded-md text-sm font-sans outline-none bg-bg-primary text-text-primary transition-colors duration-150 focus:border-accent disabled:bg-bg-hover disabled:text-text-muted"
545
- value={selectedCodexModel}
546
- onChange={(e) => setSelectedCodexModel(e.target.value)}
547
- disabled={isSpawning}
548
- >
549
- {codexModels.map((model) => (
550
- <option key={model.value} value={model.value}>
551
- {model.label}
552
- </option>
553
- ))}
554
- </select>
555
- </div>
556
- )}
557
-
558
- {/* Model Selection (Gemini only) */}
559
- {selectedTemplate.id === 'gemini' && (
463
+ {/* Model Selection shown for any template with supportsModelSelection and available models */}
464
+ {selectedTemplate.supportsModelSelection && getModelsForCli(selectedTemplate.id).length > 0 && (
560
465
  <div className="mb-5">
561
- <label className="block text-sm font-semibold text-text-primary mb-2" htmlFor="gemini-model">
466
+ <label className="block text-sm font-semibold text-text-primary mb-2" htmlFor={`${selectedTemplate.id}-model`}>
562
467
  Model
563
468
  </label>
564
469
  <select
565
- id="gemini-model"
470
+ id={`${selectedTemplate.id}-model`}
566
471
  className="w-full py-2.5 px-3.5 border border-border rounded-md text-sm font-sans outline-none bg-bg-primary text-text-primary transition-colors duration-150 focus:border-accent disabled:bg-bg-hover disabled:text-text-muted"
567
- value={selectedGeminiModel}
568
- onChange={(e) => setSelectedGeminiModel(e.target.value)}
472
+ value={getSelectedModel(selectedTemplate.id)}
473
+ onChange={(e) => setModelForCli(selectedTemplate.id, e.target.value)}
569
474
  disabled={isSpawning}
570
475
  >
571
- {geminiModels.map((model) => (
476
+ {getModelsForCli(selectedTemplate.id).map((model) => (
572
477
  <option key={model.value} value={model.value}>
573
478
  {model.label}
574
479
  </option>
@@ -5,6 +5,22 @@ import { useMessages } from './useMessages';
5
5
 
6
6
  const mockSendMessage = vi.fn();
7
7
 
8
+ const createLocalStorageMock = () => {
9
+ let store: Record<string, string> = {};
10
+ return {
11
+ getItem: vi.fn((key: string) => store[key] || null),
12
+ setItem: vi.fn((key: string, value: string) => {
13
+ store[key] = value;
14
+ }),
15
+ removeItem: vi.fn((key: string) => {
16
+ delete store[key];
17
+ }),
18
+ clear: vi.fn(() => {
19
+ store = {};
20
+ }),
21
+ };
22
+ };
23
+
8
24
  vi.mock('../../lib/api', () => ({
9
25
  api: {
10
26
  sendMessage: (...args: unknown[]) => mockSendMessage(...args),
@@ -12,8 +28,12 @@ vi.mock('../../lib/api', () => ({
12
28
  }));
13
29
 
14
30
  describe('useMessages', () => {
31
+ let localStorageMock: ReturnType<typeof createLocalStorageMock>;
32
+
15
33
  beforeEach(() => {
16
34
  mockSendMessage.mockReset();
35
+ localStorageMock = createLocalStorageMock();
36
+ vi.stubGlobal('localStorage', localStorageMock);
17
37
  });
18
38
 
19
39
  it('keeps optimistic messages in sending status after successful send', async () => {
@@ -77,4 +97,100 @@ describe('useMessages', () => {
77
97
  expect(result.current.messages).toHaveLength(0);
78
98
  expect(result.current.sendError).toBe('send failed');
79
99
  });
100
+
101
+ it('hides third-party private DMs from the selected agent feed', () => {
102
+ const baseMessages: Parameters<typeof useMessages>[0]['messages'] = [
103
+ {
104
+ id: 'viewer-to-lead',
105
+ from: 'khaliqgant',
106
+ to: 'Lead',
107
+ content: 'Need an update',
108
+ timestamp: '2026-03-11T10:00:00.000Z',
109
+ },
110
+ {
111
+ id: 'lead-to-viewer',
112
+ from: 'Lead',
113
+ to: 'khaliqgant',
114
+ content: 'On it',
115
+ timestamp: '2026-03-11T10:01:00.000Z',
116
+ },
117
+ {
118
+ id: 'lead-to-fixer',
119
+ from: 'Lead',
120
+ to: 'Fixer',
121
+ content: 'Private handoff',
122
+ timestamp: '2026-03-11T10:02:00.000Z',
123
+ },
124
+ {
125
+ id: 'fixer-to-lead',
126
+ from: 'Fixer',
127
+ to: 'Lead',
128
+ content: 'Sending private notes',
129
+ timestamp: '2026-03-11T10:03:00.000Z',
130
+ },
131
+ {
132
+ id: 'lead-broadcast',
133
+ from: 'Lead',
134
+ to: '*',
135
+ content: 'Broadcast update',
136
+ timestamp: '2026-03-11T10:04:00.000Z',
137
+ isBroadcast: true,
138
+ },
139
+ ];
140
+
141
+ const { result } = renderHook(() =>
142
+ useMessages({
143
+ messages: baseMessages,
144
+ currentChannel: 'Lead',
145
+ senderName: 'khaliqgant',
146
+ })
147
+ );
148
+
149
+ expect(result.current.messages.map((message) => message.id)).toEqual([
150
+ 'viewer-to-lead',
151
+ 'lead-to-viewer',
152
+ 'lead-broadcast',
153
+ ]);
154
+ });
155
+
156
+ it('keeps legacy Dashboard messages visible for local project conversations', () => {
157
+ localStorage.setItem('relay_username', 'relay-dashboard');
158
+
159
+ const baseMessages: Parameters<typeof useMessages>[0]['messages'] = [
160
+ {
161
+ id: 'dashboard-to-lead',
162
+ from: 'Dashboard',
163
+ to: 'Lead',
164
+ content: 'Please investigate',
165
+ timestamp: '2026-03-11T10:00:00.000Z',
166
+ },
167
+ {
168
+ id: 'lead-to-dashboard',
169
+ from: 'Lead',
170
+ to: 'Dashboard',
171
+ content: 'Looking now',
172
+ timestamp: '2026-03-11T10:01:00.000Z',
173
+ },
174
+ {
175
+ id: 'lead-to-fixer',
176
+ from: 'Lead',
177
+ to: 'Fixer',
178
+ content: 'Private follow-up',
179
+ timestamp: '2026-03-11T10:02:00.000Z',
180
+ },
181
+ ];
182
+
183
+ const { result } = renderHook(() =>
184
+ useMessages({
185
+ messages: baseMessages,
186
+ currentChannel: 'Lead',
187
+ senderName: 'relay-dashboard',
188
+ })
189
+ );
190
+
191
+ expect(result.current.messages.map((message) => message.id)).toEqual([
192
+ 'dashboard-to-lead',
193
+ 'lead-to-dashboard',
194
+ ]);
195
+ });
80
196
  });
@@ -8,6 +8,7 @@
8
8
  import { useState, useMemo, useCallback, useEffect } from 'react';
9
9
  import type { Message, SendMessageRequest } from '../../types';
10
10
  import { api } from '../../lib/api';
11
+ import { isDashboardVariant } from '../../lib/identity';
11
12
 
12
13
  export interface UseMessagesOptions {
13
14
  messages: Message[];
@@ -66,10 +67,38 @@ export function useMessages({
66
67
 
67
68
  // Effective sender name for the current user (used for filtering own messages).
68
69
  // Read localStorage directly as a fallback so we never display "Dashboard".
70
+ const storedProjectIdentity = typeof window !== 'undefined'
71
+ ? localStorage.getItem('relay_username')
72
+ : null;
69
73
  const effectiveSenderName = senderName
70
- || (typeof window !== 'undefined' ? localStorage.getItem('relay_username') : null)
74
+ || storedProjectIdentity
71
75
  || 'You';
72
76
 
77
+ const viewerIdentityKeys = useMemo(() => {
78
+ const keys = new Set<string>();
79
+ const add = (value?: string | null) => {
80
+ const normalized = value?.trim().toLowerCase();
81
+ if (normalized) {
82
+ keys.add(normalized);
83
+ }
84
+ };
85
+
86
+ add(effectiveSenderName);
87
+
88
+ const senderKey = senderName?.trim().toLowerCase() ?? '';
89
+ const projectKey = storedProjectIdentity?.trim().toLowerCase() ?? '';
90
+ const shouldIncludeDashboardAliases = !senderKey
91
+ || isDashboardVariant(senderName ?? '')
92
+ || (projectKey.length > 0 && senderKey === projectKey);
93
+
94
+ if (shouldIncludeDashboardAliases) {
95
+ add('Dashboard');
96
+ add('human:dashboard');
97
+ }
98
+
99
+ return keys;
100
+ }, [effectiveSenderName, senderName, storedProjectIdentity]);
101
+
73
102
  // Optimistic messages: shown immediately before server confirms
74
103
  // These have status='sending' and a temp ID prefixed with 'optimistic-'
75
104
  const [optimisticMessages, setOptimisticMessages] = useState<Message[]>([]);
@@ -123,13 +152,38 @@ export function useMessages({
123
152
  return !messageIds.has(m.thread);
124
153
  });
125
154
 
155
+ const isChannelMessage = (message: Message) => {
156
+ return Boolean(message.channel) || message.to.startsWith('#');
157
+ };
158
+
159
+ const isBroadcastMessage = (message: Message) => {
160
+ return message.isBroadcast || message.to === '*';
161
+ };
162
+
163
+ const involvesViewerIdentity = (message: Message) => {
164
+ return viewerIdentityKeys.has(message.from.toLowerCase())
165
+ || viewerIdentityKeys.has(message.to.toLowerCase());
166
+ };
167
+
126
168
  if (currentChannel === 'general') {
127
- return mainViewMessages;
169
+ return mainViewMessages.filter((message) => {
170
+ if (isChannelMessage(message)) return false;
171
+ if (isBroadcastMessage(message)) return true;
172
+ return involvesViewerIdentity(message);
173
+ });
128
174
  }
175
+
129
176
  return mainViewMessages.filter(
130
- (m) => m.from === currentChannel || m.to === currentChannel
177
+ (message) => {
178
+ if (message.from !== currentChannel && message.to !== currentChannel) {
179
+ return false;
180
+ }
181
+ if (isChannelMessage(message)) return false;
182
+ if (isBroadcastMessage(message)) return true;
183
+ return involvesViewerIdentity(message);
184
+ }
131
185
  );
132
- }, [allMessages, currentChannel]);
186
+ }, [allMessages, currentChannel, viewerIdentityKeys]);
133
187
 
134
188
  // Get messages for a specific thread
135
189
  const threadMessages = useCallback(
@@ -0,0 +1,78 @@
1
+ import { useState, useEffect, useCallback } from 'react';
2
+ import type { ModelOption } from '../SpawnModal';
3
+
4
+ export interface ModelOptionsMap {
5
+ [cli: string]: ModelOption[];
6
+ }
7
+
8
+ function isModelOption(item: unknown): item is ModelOption {
9
+ return (
10
+ typeof item === 'object' &&
11
+ item !== null &&
12
+ typeof (item as ModelOption).value === 'string' &&
13
+ typeof (item as ModelOption).label === 'string'
14
+ );
15
+ }
16
+
17
+ /**
18
+ * Fetches model options from the server (sourced from cli-registry.yaml via codegen).
19
+ * Returns model options keyed by lowercase CLI name (e.g., claude, codex, gemini, opencode, droid).
20
+ */
21
+ export interface DefaultModelsMap {
22
+ [cli: string]: string;
23
+ }
24
+
25
+ export function useModelOptions() {
26
+ const [modelOptions, setModelOptions] = useState<ModelOptionsMap>({});
27
+ const [defaultModels, setDefaultModels] = useState<DefaultModelsMap>({});
28
+ const [isLoading, setIsLoading] = useState(true);
29
+ const [error, setError] = useState<Error | null>(null);
30
+
31
+ const fetchModels = useCallback(async (signal?: AbortSignal) => {
32
+ setIsLoading(true);
33
+ setError(null);
34
+ try {
35
+ const res = await fetch('/api/models', { signal });
36
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
37
+ const data = await res.json();
38
+
39
+ if (data.success && data.modelOptions) {
40
+ // ModelOptions from codegen uses PascalCase keys (Claude, Codex, etc.)
41
+ // Normalize to lowercase for consistent lookup
42
+ const normalized: ModelOptionsMap = {};
43
+ for (const [key, options] of Object.entries(data.modelOptions)) {
44
+ if (Array.isArray(options) && options.every(isModelOption)) {
45
+ normalized[key.toLowerCase()] = options;
46
+ }
47
+ }
48
+ setModelOptions(normalized);
49
+ }
50
+
51
+ if (data.defaultModels && typeof data.defaultModels === 'object') {
52
+ // Normalize to lowercase to match modelOptions keys
53
+ const normalizedDefaults: DefaultModelsMap = {};
54
+ for (const [key, value] of Object.entries(data.defaultModels)) {
55
+ if (typeof value === 'string') {
56
+ normalizedDefaults[key.toLowerCase()] = value;
57
+ }
58
+ }
59
+ setDefaultModels(normalizedDefaults);
60
+ }
61
+ } catch (err) {
62
+ if (err instanceof DOMException && err.name === 'AbortError') return;
63
+ const error = err instanceof Error ? err : new Error(String(err));
64
+ setError(error);
65
+ console.warn('[useModelOptions] Failed to fetch model options:', error);
66
+ } finally {
67
+ setIsLoading(false);
68
+ }
69
+ }, []);
70
+
71
+ useEffect(() => {
72
+ const controller = new AbortController();
73
+ fetchModels(controller.signal);
74
+ return () => controller.abort();
75
+ }, [fetchModels]);
76
+
77
+ return { modelOptions, defaultModels, isLoading, error, refetch: fetchModels };
78
+ }