@agent-relay/dashboard 2.0.90 → 2.0.91
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.
- package/out/404.html +1 -1
- package/out/_next/static/chunks/5518-6d77237eefc8d5ae.js +1 -0
- package/out/_next/static/chunks/app/app/[[...slug]]/{page-4f01a33b51f23cea.js → page-c1376e695ba19e38.js} +1 -1
- package/out/_next/static/chunks/app/{page-2644ed4067d978e0.js → page-f2ebc7d0bc08e395.js} +1 -1
- package/out/about.html +1 -1
- package/out/about.txt +1 -1
- package/out/app/onboarding.html +1 -1
- package/out/app/onboarding.txt +1 -1
- package/out/app.html +1 -1
- package/out/app.txt +2 -2
- package/out/blog/go-to-bed-wake-up-to-a-finished-product.html +1 -1
- package/out/blog/go-to-bed-wake-up-to-a-finished-product.txt +1 -1
- package/out/blog/let-them-cook-multi-agent-orchestration.html +1 -1
- package/out/blog/let-them-cook-multi-agent-orchestration.txt +1 -1
- package/out/blog.html +1 -1
- package/out/blog.txt +1 -1
- package/out/careers.html +1 -1
- package/out/careers.txt +1 -1
- package/out/changelog.html +1 -1
- package/out/changelog.txt +1 -1
- package/out/cloud/link.html +1 -1
- package/out/cloud/link.txt +1 -1
- package/out/complete-profile.html +1 -1
- package/out/complete-profile.txt +1 -1
- package/out/connect-repos.html +1 -1
- package/out/connect-repos.txt +1 -1
- package/out/contact.html +1 -1
- package/out/contact.txt +1 -1
- package/out/dev/cli-tools.html +1 -1
- package/out/dev/cli-tools.txt +1 -1
- package/out/dev/log-viewer.html +1 -1
- package/out/dev/log-viewer.txt +1 -1
- package/out/docs.html +1 -1
- package/out/docs.txt +1 -1
- package/out/history.html +1 -1
- package/out/history.txt +1 -1
- package/out/index.html +1 -1
- package/out/index.txt +2 -2
- package/out/login.html +1 -1
- package/out/login.txt +1 -1
- package/out/metrics.html +1 -1
- package/out/metrics.txt +1 -1
- package/out/pricing.html +1 -1
- package/out/pricing.txt +1 -1
- package/out/privacy.html +1 -1
- package/out/privacy.txt +1 -1
- package/out/providers/setup/claude.html +1 -1
- package/out/providers/setup/claude.txt +1 -1
- package/out/providers/setup/codex.html +1 -1
- package/out/providers/setup/codex.txt +1 -1
- package/out/providers/setup/cursor.html +1 -1
- package/out/providers/setup/cursor.txt +1 -1
- package/out/providers.html +1 -1
- package/out/providers.txt +1 -1
- package/out/security.html +1 -1
- package/out/security.txt +1 -1
- package/out/signup.html +1 -1
- package/out/signup.txt +1 -1
- package/out/terms.html +1 -1
- package/out/terms.txt +1 -1
- package/package.json +1 -1
- package/src/components/App.tsx +6 -0
- package/src/components/SpawnModal.tsx +76 -171
- package/src/components/hooks/useMessages.test.ts +116 -0
- package/src/components/hooks/useMessages.ts +58 -4
- package/src/components/hooks/useModelOptions.ts +78 -0
- package/src/components/settings/SettingsPage.tsx +51 -105
- package/src/components/settings/types.ts +6 -9
- package/out/_next/static/chunks/9626-10e71fc51b892784.js +0 -1
- /package/out/_next/static/{pp6J1TkgtRc_o3BmtFoV0 → 2GceRdfHFOB2Vub1wjdqt}/_buildManifest.js +0 -0
- /package/out/_next/static/{pp6J1TkgtRc_o3BmtFoV0 → 2GceRdfHFOB2Vub1wjdqt}/_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
|
|
13
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
215
|
-
const
|
|
216
|
-
|
|
217
|
-
|
|
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 [
|
|
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
|
-
|
|
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
|
-
|
|
243
|
-
if (
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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,
|
|
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
|
|
493
|
-
{selectedTemplate.id
|
|
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=
|
|
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=
|
|
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={
|
|
568
|
-
onChange={(e) =>
|
|
472
|
+
value={getSelectedModel(selectedTemplate.id)}
|
|
473
|
+
onChange={(e) => setModelForCli(selectedTemplate.id, e.target.value)}
|
|
569
474
|
disabled={isSpawning}
|
|
570
475
|
>
|
|
571
|
-
{
|
|
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
|
-
||
|
|
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
|
-
(
|
|
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
|
+
}
|