@nextclaw/ui 0.12.0 → 0.12.2

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 (96) hide show
  1. package/CHANGELOG.md +57 -2
  2. package/dist/assets/ChannelsList-uKmkpD25.js +8 -0
  3. package/dist/assets/ChatPage-CslhBPfT.js +43 -0
  4. package/dist/assets/{DocBrowser-DxdSujSc.js → DocBrowser-C7-1sXqo.js} +1 -1
  5. package/dist/assets/DocBrowser-DQjtSsY3.js +1 -0
  6. package/dist/assets/{DocBrowserContext-CQ-8jMha.js → DocBrowserContext-DN5tjUoS.js} +1 -1
  7. package/dist/assets/{LogoBadge-D-KQIN4U.js → LogoBadge-DDS1sU_U.js} +1 -1
  8. package/dist/assets/MarketplacePage-BZQW70ti.js +1 -0
  9. package/dist/assets/{MarketplacePage-CRNvxtvx.js → MarketplacePage-DE0QjYVv.js} +1 -1
  10. package/dist/assets/{McpMarketplacePage-Cu7GmCcc.js → McpMarketplacePage-CeLvv1xy.js} +1 -1
  11. package/dist/assets/ModelConfig-D1JtGtQv.js +1 -0
  12. package/dist/assets/ProviderScopedModelInput-SAJH6nkC.js +1 -0
  13. package/dist/assets/{ProvidersList-BWbUb7-2.js → ProvidersList-1rKi3aQT.js} +1 -1
  14. package/dist/assets/{RemoteAccessPage-NsawrZb0.js → RemoteAccessPage-bIAKxDky.js} +1 -1
  15. package/dist/assets/RuntimeConfig-BTk9319O.js +1 -0
  16. package/dist/assets/SearchConfig-EjeszXbv.js +1 -0
  17. package/dist/assets/{SecretsConfig-CgDZOd3w.js → SecretsConfig-cnAXvREZ.js} +1 -1
  18. package/dist/assets/{SessionsConfig-Dd-KM7F7.js → SessionsConfig-BIXiDaK2.js} +1 -1
  19. package/dist/assets/{book-open-FnK2xCQd.js → book-open-DvWqOode.js} +1 -1
  20. package/dist/assets/chat-session-display-D4bYa0b8.js +1 -0
  21. package/dist/assets/{chunk-JZWAC4HX-B5l0hr_u.js → chunk-JZWAC4HX-CxfKRD7X.js} +1 -1
  22. package/dist/assets/{config-JKmXfZ3q.js → config-BeGwf2Ao.js} +1 -1
  23. package/dist/assets/{createLucideIcon-o1WWhwhd.js → createLucideIcon-C7MmdIX3.js} +1 -1
  24. package/dist/assets/{dist-C_moWYv7.js → dist-B6VMuIQN.js} +1 -1
  25. package/dist/assets/{dist-DazA6Wd_.js → dist-RWNFhxvR.js} +1 -1
  26. package/dist/assets/{external-link-BKje3SiD.js → external-link-U86Acd1t.js} +1 -1
  27. package/dist/assets/{hash-DfW4DT8O.js → hash-D-OVfV3Z.js} +1 -1
  28. package/dist/assets/i18n-hM3v-3YG.js +1 -0
  29. package/dist/assets/{index-BZaB1TqM.js → index-8XNPYwJu.js} +3 -3
  30. package/dist/assets/index-CpxuJa9o.css +1 -0
  31. package/dist/assets/{label-BzDWmdOe.js → label-CHJ1ATds.js} +1 -1
  32. package/dist/assets/loader-circle-C8cpaL0w.js +1 -0
  33. package/dist/assets/{logos-CTLlde_T.js → logos-U1_qDA3U.js} +1 -1
  34. package/dist/assets/{page-layout-BagR3t59.js → page-layout-Z1klaUFW.js} +1 -1
  35. package/dist/assets/plus-CrkO1kob.js +1 -0
  36. package/dist/assets/{popover-5DWhNfd4.js → popover-xWbqMnIN.js} +1 -1
  37. package/dist/assets/{react-C3yu5yge.js → react-3YE87-lE.js} +1 -1
  38. package/dist/assets/{refresh-ccw-BAJf-h7w.js → refresh-ccw-JQh1lwq-.js} +1 -1
  39. package/dist/assets/{save-aa6z4GJL.js → save-4VRlzkii.js} +1 -1
  40. package/dist/assets/search-EX-Papzl.js +1 -0
  41. package/dist/assets/{security-config-DRDxrApx.js → security-config-CGazBahs.js} +1 -1
  42. package/dist/assets/{select-BHJPiJWt.js → select-DF-AUoie.js} +1 -1
  43. package/dist/assets/skeleton-B0mmt1vo.js +1 -0
  44. package/dist/assets/{status-dot-DUwsTIdv.js → status-dot-Bq_8Ojvv.js} +1 -1
  45. package/dist/assets/{switch-B6nCfcOB.js → switch-D7JF_RZ-.js} +1 -1
  46. package/dist/assets/{tabs-custom-B57SMElx.js → tabs-custom-CLksZ2bO.js} +1 -1
  47. package/dist/assets/{trash-2-CrjYH5ok.js → trash-2-VV8jvziy.js} +1 -1
  48. package/dist/assets/{useConfirmDialog-DsxnXB1B.js → useConfirmDialog-D6HxybcM.js} +1 -1
  49. package/dist/assets/{useMutation-oTTWXgLG.js → useMutation-DBTWPbTg.js} +1 -1
  50. package/dist/assets/x-B4sxJkGY.js +1 -0
  51. package/dist/index.html +18 -18
  52. package/package.json +6 -6
  53. package/src/api/agents.ts +9 -1
  54. package/src/api/types.ts +25 -4
  55. package/src/components/agents/AgentDialogs.tsx +400 -0
  56. package/src/components/agents/AgentsPage.test.tsx +112 -1
  57. package/src/components/agents/AgentsPage.tsx +104 -112
  58. package/src/components/chat/ChatConversationPanel.test.tsx +31 -0
  59. package/src/components/chat/ChatConversationPanel.tsx +7 -6
  60. package/src/components/chat/ChatSidebar.test.tsx +41 -1
  61. package/src/components/chat/ChatWelcome.test.tsx +7 -2
  62. package/src/components/chat/ChatWelcome.tsx +38 -35
  63. package/src/components/chat/chat-page-runtime.test.ts +30 -0
  64. package/src/components/chat/chat-sidebar-session-item.tsx +6 -2
  65. package/src/components/chat/ncp/NcpChatPage.tsx +23 -1
  66. package/src/components/chat/ncp/ncp-session-adapter.ts +6 -1
  67. package/src/components/chat/useChatSessionTypeState.ts +1 -1
  68. package/src/components/common/ProviderScopedModelInput.tsx +149 -0
  69. package/src/components/config/ChannelForm.test.tsx +60 -0
  70. package/src/components/config/ChannelForm.tsx +52 -12
  71. package/src/components/config/ModelConfig.test.tsx +61 -0
  72. package/src/components/config/ModelConfig.tsx +15 -90
  73. package/src/components/config/RuntimeConfig.tsx +3 -24
  74. package/src/components/config/SearchConfig.test.tsx +150 -0
  75. package/src/components/config/SearchConfig.tsx +257 -71
  76. package/src/components/config/runtime-config-agent.utils.ts +5 -4
  77. package/src/hooks/agents/useAgents.ts +18 -1
  78. package/src/lib/i18n.agents.ts +21 -2
  79. package/src/lib/i18n.search.ts +37 -0
  80. package/src/lib/i18n.ts +6 -28
  81. package/dist/assets/ChannelsList-NKNKsf1J.js +0 -8
  82. package/dist/assets/ChatPage-p23OnnEI.js +0 -43
  83. package/dist/assets/DocBrowser-C8b2uPgL.js +0 -1
  84. package/dist/assets/MarketplacePage-GGkEXowp.js +0 -1
  85. package/dist/assets/ModelConfig-CEpx9fro.js +0 -1
  86. package/dist/assets/RuntimeConfig-BJHBsVTd.js +0 -1
  87. package/dist/assets/SearchConfig-BsaX_WYy.js +0 -1
  88. package/dist/assets/chat-session-display-BD_AN71I.js +0 -1
  89. package/dist/assets/i18n-BK1w-oBy.js +0 -1
  90. package/dist/assets/index-DaR9igPC.css +0 -1
  91. package/dist/assets/loader-circle-DdZPxBUz.js +0 -1
  92. package/dist/assets/plus-DP2PSCPO.js +0 -1
  93. package/dist/assets/provider-models-DJ29qHuA.js +0 -1
  94. package/dist/assets/search-pD6ZwQYF.js +0 -1
  95. package/dist/assets/skeleton-D6kCk9Y6.js +0 -1
  96. package/dist/assets/x-CTIQHUuD.js +0 -1
@@ -1,39 +1,29 @@
1
1
  import { useMemo, useState } from 'react';
2
2
  import { useNavigate } from 'react-router-dom';
3
- import { useCreateAgent, useDeleteAgent, useAgents } from '@/hooks/agents/useAgents';
3
+ import { useCreateAgent, useDeleteAgent, useAgents, useUpdateAgent } from '@/hooks/agents/useAgents';
4
+ import { useConfig, useConfigMeta } from '@/hooks/useConfig';
5
+ import type { AgentProfileView } from '@/api/types';
6
+ import {
7
+ AgentCreateDialog,
8
+ AgentEditDialog,
9
+ type AgentCreateFormState,
10
+ type AgentEditFormState
11
+ } from '@/components/agents/AgentDialogs';
12
+ import {
13
+ buildSessionTypeOptions,
14
+ normalizeSessionType,
15
+ resolveSessionTypeLabel
16
+ } from '@/components/chat/useChatSessionTypeState';
4
17
  import { useChatSessionListStore } from '@/components/chat/stores/chat-session-list.store';
5
18
  import { AgentAvatar } from '@/components/common/AgentAvatar';
6
19
  import { Button } from '@/components/ui/button';
7
20
  import { Card, CardContent } from '@/components/ui/card';
8
- import {
9
- Dialog,
10
- DialogContent,
11
- DialogDescription,
12
- DialogFooter,
13
- DialogHeader,
14
- DialogTitle
15
- } from '@/components/ui/dialog';
16
- import { Input } from '@/components/ui/input';
21
+ import { useNcpChatSessionTypes } from '@/hooks/use-ncp-chat-session-types';
17
22
  import { PageLayout } from '@/components/layout/page-layout';
18
23
  import { t } from '@/lib/i18n';
24
+ import { buildProviderModelCatalog } from '@/lib/provider-models';
19
25
  import { cn } from '@/lib/utils';
20
- import { Bot, House, MessageCircle, Plus, ShieldCheck, Sparkles, Trash2 } from 'lucide-react';
21
-
22
- type CreateFormState = {
23
- id: string;
24
- displayName: string;
25
- description: string;
26
- avatar: string;
27
- home: string;
28
- };
29
-
30
- const EMPTY_FORM: CreateFormState = {
31
- id: '',
32
- displayName: '',
33
- description: '',
34
- avatar: '',
35
- home: ''
36
- };
26
+ import { Bot, House, MessageCircle, Pencil, Plus, ShieldCheck, Sparkles, Trash2 } from 'lucide-react';
37
27
 
38
28
  const CARD_TONES = [
39
29
  {
@@ -63,10 +53,14 @@ function resolveAgentTone(index: number, builtIn: boolean) {
63
53
  export function AgentsPage() {
64
54
  const navigate = useNavigate();
65
55
  const agentsQuery = useAgents();
56
+ const configQuery = useConfig();
57
+ const configMetaQuery = useConfigMeta();
58
+ const sessionTypesQuery = useNcpChatSessionTypes();
66
59
  const createAgent = useCreateAgent();
60
+ const updateAgent = useUpdateAgent();
67
61
  const deleteAgent = useDeleteAgent();
68
62
  const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
69
- const [form, setForm] = useState<CreateFormState>(EMPTY_FORM);
63
+ const [editingAgent, setEditingAgent] = useState<AgentProfileView | null>(null);
70
64
  const setSessionListSnapshot = useChatSessionListStore((state) => state.setSnapshot);
71
65
 
72
66
  const agents = useMemo(() => agentsQuery.data?.agents ?? [], [agentsQuery.data?.agents]);
@@ -79,21 +73,56 @@ export function AgentsPage() {
79
73
  ),
80
74
  [agents]
81
75
  );
76
+ const providerCatalog = useMemo(
77
+ () => buildProviderModelCatalog({ config: configQuery.data, meta: configMetaQuery.data, onlyConfigured: true }),
78
+ [configMetaQuery.data, configQuery.data]
79
+ );
80
+ const runtimeOptions = useMemo(
81
+ () => buildSessionTypeOptions(sessionTypesQuery.data?.options ?? []),
82
+ [sessionTypesQuery.data?.options]
83
+ );
84
+ const defaultRuntime = useMemo(
85
+ () => normalizeSessionType(sessionTypesQuery.data?.defaultType ?? 'native'),
86
+ [sessionTypesQuery.data?.defaultType]
87
+ );
88
+ const defaultRuntimeLabel = useMemo(
89
+ () => runtimeOptions.find((option) => option.value === defaultRuntime)?.label ?? resolveSessionTypeLabel(defaultRuntime),
90
+ [defaultRuntime, runtimeOptions]
91
+ );
82
92
 
83
- const handleCreate = async () => {
93
+ const handleCreate = async (form: AgentCreateFormState) => {
84
94
  await createAgent.mutateAsync({
85
95
  data: {
86
96
  id: form.id,
87
97
  ...(form.displayName.trim() ? { displayName: form.displayName.trim() } : {}),
88
98
  ...(form.description.trim() ? { description: form.description.trim() } : {}),
89
99
  ...(form.avatar.trim() ? { avatar: form.avatar.trim() } : {}),
90
- ...(form.home.trim() ? { home: form.home.trim() } : {})
100
+ ...(form.home.trim() ? { home: form.home.trim() } : {}),
101
+ ...(form.model.trim() ? { model: form.model.trim() } : {}),
102
+ ...(form.runtime.trim() ? { runtime: form.runtime.trim() } : {})
91
103
  }
92
104
  });
93
- setForm(EMPTY_FORM);
94
105
  setIsCreateDialogOpen(false);
95
106
  };
96
107
 
108
+ const handleStartEdit = (agent: AgentProfileView) => {
109
+ setEditingAgent(agent);
110
+ };
111
+
112
+ const handleUpdate = async (agentId: string, form: AgentEditFormState) => {
113
+ await updateAgent.mutateAsync({
114
+ agentId,
115
+ data: {
116
+ displayName: form.displayName,
117
+ description: form.description,
118
+ avatar: form.avatar,
119
+ model: form.model,
120
+ ...(form.runtime.trim() ? { runtime: form.runtime.trim() } : { runtime: "" })
121
+ }
122
+ });
123
+ setEditingAgent(null);
124
+ };
125
+
97
126
  const startChatWithAgent = (agentId: string) => {
98
127
  setSessionListSnapshot({
99
128
  selectedAgentId: agentId,
@@ -178,6 +207,11 @@ export function AgentsPage() {
178
207
  ) : (
179
208
  sortedAgents.map((agent, index) => {
180
209
  const tone = resolveAgentTone(index, Boolean(agent.builtIn));
210
+ const runtimeValue = agent.runtime?.trim() || agent.engine?.trim() || '';
211
+ const runtimeLabel = runtimeValue
212
+ ? runtimeOptions.find((option) => option.value === normalizeSessionType(runtimeValue))?.label ??
213
+ resolveSessionTypeLabel(runtimeValue)
214
+ : defaultRuntimeLabel;
181
215
  return (
182
216
  <Card
183
217
  key={agent.id}
@@ -223,6 +257,16 @@ export function AgentsPage() {
223
257
  </p>
224
258
 
225
259
  <div className="mt-auto flex flex-col gap-4">
260
+ <div>
261
+ <div className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-[#94a3b8]">
262
+ <Sparkles className="h-3.5 w-3.5" />
263
+ {t('agentsCardRuntimeLabel')}
264
+ </div>
265
+ <div className="mt-1.5 text-sm leading-6 text-[#475569]">
266
+ {runtimeLabel}
267
+ </div>
268
+ </div>
269
+
226
270
  <div className="border-t border-gray-100 pt-3">
227
271
  <div className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-[#94a3b8]">
228
272
  <House className="h-3.5 w-3.5" />
@@ -242,6 +286,16 @@ export function AgentsPage() {
242
286
  <MessageCircle className="mr-2 h-4 w-4" />
243
287
  {t('agentsCardStartChat')}
244
288
  </Button>
289
+ <Button
290
+ type="button"
291
+ variant="ghost"
292
+ className="h-8 rounded-xl px-3 text-xs text-[#7b8794] hover:bg-[#f3f4f6] hover:text-[#475569]"
293
+ onClick={() => handleStartEdit(agent)}
294
+ disabled={updateAgent.isPending}
295
+ >
296
+ <Pencil className="mr-1.5 h-3.5 w-3.5" />
297
+ {t('agentsEditAction')}
298
+ </Button>
245
299
  {!agent.builtIn ? (
246
300
  <Button
247
301
  type="button"
@@ -263,91 +317,29 @@ export function AgentsPage() {
263
317
  )}
264
318
  </div>
265
319
 
266
- <Dialog
320
+ <AgentCreateDialog
267
321
  open={isCreateDialogOpen}
322
+ pending={createAgent.isPending}
323
+ providerCatalog={providerCatalog}
324
+ runtimeOptions={runtimeOptions}
325
+ defaultRuntime={defaultRuntime}
326
+ onOpenChange={setIsCreateDialogOpen}
327
+ onSubmit={handleCreate}
328
+ />
329
+
330
+ <AgentEditDialog
331
+ agent={editingAgent}
332
+ pending={updateAgent.isPending}
333
+ providerCatalog={providerCatalog}
334
+ runtimeOptions={runtimeOptions}
335
+ defaultRuntime={defaultRuntime}
268
336
  onOpenChange={(open) => {
269
- setIsCreateDialogOpen(open);
270
- if (!open && !createAgent.isPending) {
271
- setForm(EMPTY_FORM);
337
+ if (!open && !updateAgent.isPending) {
338
+ setEditingAgent(null);
272
339
  }
273
340
  }}
274
- >
275
- <DialogContent className="overflow-hidden border-none bg-[linear-gradient(180deg,#fff9f1_0%,#ffffff_24%)] p-0 sm:max-w-xl">
276
- <div className="border-b border-[#f0e2c8] px-6 py-6">
277
- <DialogHeader className="text-left">
278
- <DialogTitle>{t('agentsCreateDialogTitle')}</DialogTitle>
279
- <DialogDescription>{t('agentsCreateDialogDescription')}</DialogDescription>
280
- </DialogHeader>
281
- </div>
282
- <div className="space-y-4 px-6 py-6">
283
- <div className="grid gap-4 md:grid-cols-2">
284
- <Input
285
- value={form.id}
286
- onChange={(event) =>
287
- setForm((prev) => ({ ...prev, id: event.target.value }))
288
- }
289
- placeholder={t('agentsFormIdPlaceholder')}
290
- />
291
- <Input
292
- value={form.displayName}
293
- onChange={(event) =>
294
- setForm((prev) => ({
295
- ...prev,
296
- displayName: event.target.value
297
- }))
298
- }
299
- placeholder={t('agentsFormNamePlaceholder')}
300
- />
301
- <Input
302
- value={form.description}
303
- onChange={(event) =>
304
- setForm((prev) => ({
305
- ...prev,
306
- description: event.target.value
307
- }))
308
- }
309
- placeholder={t('agentsFormDescriptionPlaceholder')}
310
- />
311
- <Input
312
- value={form.avatar}
313
- onChange={(event) =>
314
- setForm((prev) => ({ ...prev, avatar: event.target.value }))
315
- }
316
- placeholder={t('agentsFormAvatarPlaceholder')}
317
- />
318
- <Input
319
- value={form.home}
320
- onChange={(event) =>
321
- setForm((prev) => ({ ...prev, home: event.target.value }))
322
- }
323
- placeholder={t('agentsFormHomePlaceholder')}
324
- />
325
- </div>
326
- <div className="rounded-2xl border border-[#efe3ca] bg-[#fff9ef] px-4 py-3 text-xs leading-6 text-[#7a6246]">
327
- {t('agentsCreateDialogHint')}
328
- </div>
329
- </div>
330
- <DialogFooter className="border-t border-[#f1e7d4] px-6 py-5">
331
- <Button
332
- type="button"
333
- variant="ghost"
334
- onClick={() => setIsCreateDialogOpen(false)}
335
- disabled={createAgent.isPending}
336
- >
337
- {t('cancel')}
338
- </Button>
339
- <Button
340
- type="button"
341
- className="rounded-2xl bg-[#1f5c4d] px-5 text-white hover:bg-[#184d40]"
342
- onClick={() => void handleCreate()}
343
- disabled={createAgent.isPending || form.id.trim().length === 0}
344
- >
345
- <Plus className="mr-2 h-4 w-4" />
346
- {t('agentsCreateAction')}
347
- </Button>
348
- </DialogFooter>
349
- </DialogContent>
350
- </Dialog>
341
+ onSubmit={handleUpdate}
342
+ />
351
343
  </PageLayout>
352
344
  );
353
345
  }
@@ -104,6 +104,8 @@ describe('ChatConversationPanel', () => {
104
104
  sessionTypeLabel: 'Codex',
105
105
  sessionKey: 'draft-session-1',
106
106
  sessionDisplayName: undefined,
107
+ agentId: null,
108
+ agentDisplayName: null,
107
109
  sessionProjectRoot: null,
108
110
  sessionProjectName: null,
109
111
  canDeleteSession: false,
@@ -146,6 +148,35 @@ describe('ChatConversationPanel', () => {
146
148
  expect(screen.getByText('project-alpha')).toBeTruthy();
147
149
  expect(screen.getByLabelText('More actions')).toBeTruthy();
148
150
  });
151
+
152
+ it('does not show a header agent marker for the main agent', () => {
153
+ useChatThreadStore.setState({
154
+ snapshot: {
155
+ ...useChatThreadStore.getState().snapshot,
156
+ agentId: 'main',
157
+ agentDisplayName: 'Main',
158
+ }
159
+ });
160
+
161
+ render(<ChatConversationPanel />);
162
+
163
+ expect(screen.queryByTestId('agent-avatar')).toBeNull();
164
+ });
165
+
166
+ it('shows only a lightweight avatar marker for a specialist agent', () => {
167
+ useChatThreadStore.setState({
168
+ snapshot: {
169
+ ...useChatThreadStore.getState().snapshot,
170
+ agentId: 'engineer',
171
+ agentDisplayName: 'Engineer',
172
+ }
173
+ });
174
+
175
+ render(<ChatConversationPanel />);
176
+
177
+ expect(screen.getByTestId('agent-avatar').textContent).toBe('engineer');
178
+ expect(screen.queryByText('Engineer')).toBeNull();
179
+ });
149
180
  });
150
181
 
151
182
  describe('ChatChildSessionPanel', () => {
@@ -63,6 +63,10 @@ export function ChatConversationPanel() {
63
63
  snapshot.sessionDisplayName ||
64
64
  (snapshot.canDeleteSession && snapshot.sessionKey ? snapshot.sessionKey : null) ||
65
65
  t("chatSidebarNewTask");
66
+ const normalizedAgentId = snapshot.agentId?.trim() ?? "";
67
+ const shouldShowHeaderAgentAvatar =
68
+ normalizedAgentId.length > 0 &&
69
+ normalizedAgentId.toLowerCase() !== "main";
66
70
 
67
71
  const showWelcome =
68
72
  !snapshot.canDeleteSession &&
@@ -119,17 +123,14 @@ export function ChatConversationPanel() {
119
123
  )}
120
124
  >
121
125
  <div className="min-w-0 flex-1 flex items-center gap-2">
122
- {snapshot.agentId ? (
123
- <div className="inline-flex items-center gap-2 shrink-0 rounded-full border border-gray-200 bg-white/80 px-2 py-1">
126
+ {shouldShowHeaderAgentAvatar ? (
127
+ <div className="inline-flex shrink-0 items-center">
124
128
  <AgentAvatar
125
- agentId={snapshot.agentId}
129
+ agentId={normalizedAgentId}
126
130
  displayName={snapshot.agentDisplayName}
127
131
  avatarUrl={snapshot.agentAvatarUrl}
128
132
  className="h-5 w-5"
129
133
  />
130
- <span className="max-w-[120px] truncate text-xs font-medium text-gray-700">
131
- {snapshot.agentDisplayName?.trim() || snapshot.agentId}
132
- </span>
133
134
  </div>
134
135
  ) : null}
135
136
  <span className="text-sm font-medium text-gray-700 truncate">
@@ -12,6 +12,7 @@ const mocks = vi.hoisted(() => ({
12
12
  selectSession: vi.fn(),
13
13
  docOpen: vi.fn(),
14
14
  updateNcpSession: vi.fn(),
15
+ agents: [] as Array<{ id: string; displayName?: string; avatarUrl?: string | null }>,
15
16
  sessionItems: [] as NcpSessionListItemView[],
16
17
  isLoading: false
17
18
  }));
@@ -74,7 +75,7 @@ vi.mock('@/components/common/StatusBadge', () => ({
74
75
  vi.mock('@/hooks/agents/useAgents', () => ({
75
76
  useAgents: () => ({
76
77
  data: {
77
- agents: []
78
+ agents: mocks.agents
78
79
  }
79
80
  })
80
81
  }));
@@ -106,6 +107,7 @@ describe('ChatSidebar', () => {
106
107
  mocks.docOpen.mockReset();
107
108
  mocks.updateNcpSession.mockReset();
108
109
  mocks.updateNcpSession.mockResolvedValue({});
110
+ mocks.agents = [];
109
111
  mocks.sessionItems = [];
110
112
  mocks.isLoading = false;
111
113
 
@@ -254,6 +256,44 @@ describe('ChatSidebar', () => {
254
256
  expect(screen.queryByText('Native')).toBeNull();
255
257
  });
256
258
 
259
+ it('hides the sidebar agent avatar for the main agent but keeps specialist avatars', () => {
260
+ mocks.agents = [
261
+ { id: 'main', displayName: 'Main' },
262
+ { id: 'engineer', displayName: 'Engineer' }
263
+ ];
264
+ mocks.sessionItems = [
265
+ createSessionItem({
266
+ key: 'session:main-1',
267
+ createdAt: '2026-03-19T09:00:00.000Z',
268
+ updatedAt: '2026-03-19T09:05:00.000Z',
269
+ label: 'Main Task',
270
+ sessionType: 'native',
271
+ sessionTypeMutable: false,
272
+ messageCount: 1,
273
+ agentId: 'main'
274
+ }),
275
+ createSessionItem({
276
+ key: 'session:engineer-1',
277
+ createdAt: '2026-03-19T10:00:00.000Z',
278
+ updatedAt: '2026-03-19T10:05:00.000Z',
279
+ label: 'Engineer Task',
280
+ sessionType: 'native',
281
+ sessionTypeMutable: false,
282
+ messageCount: 1,
283
+ agentId: 'engineer'
284
+ })
285
+ ];
286
+
287
+ render(
288
+ <MemoryRouter>
289
+ <ChatSidebar />
290
+ </MemoryRouter>
291
+ );
292
+
293
+ expect(screen.queryByLabelText('Main')).toBeNull();
294
+ expect(screen.getByLabelText('Engineer')).not.toBeNull();
295
+ });
296
+
257
297
  it('edits the session label inline and saves through the ncp session api by default', async () => {
258
298
  mocks.sessionItems = [
259
299
  createSessionItem({
@@ -1,9 +1,10 @@
1
- import { fireEvent, render, screen } from '@testing-library/react';
1
+ import { render, screen } from '@testing-library/react';
2
+ import { fireEvent } from '@testing-library/react';
2
3
  import { describe, expect, it, vi } from 'vitest';
3
4
  import { ChatWelcome } from '@/components/chat/ChatWelcome';
4
5
 
5
6
  describe('ChatWelcome', () => {
6
- it('renders draft agent choices and allows switching', () => {
7
+ it('renders a lightweight draft agent select and allows switching', () => {
7
8
  const onCreateSession = vi.fn();
8
9
  const onSelectAgent = vi.fn();
9
10
 
@@ -19,7 +20,11 @@ describe('ChatWelcome', () => {
19
20
  />
20
21
  );
21
22
 
23
+ const trigger = screen.getByRole('combobox', { name: 'Draft agent' });
24
+ fireEvent.keyDown(trigger, { key: 'ArrowDown' });
22
25
  fireEvent.click(screen.getByText('Engineer'));
26
+
23
27
  expect(onSelectAgent).toHaveBeenCalledWith('engineer');
28
+ expect(screen.queryByText('Current Agent:')).toBeNull();
24
29
  });
25
30
  });
@@ -1,5 +1,6 @@
1
1
  import type { AgentProfileView } from '@/api/types';
2
2
  import { AgentAvatar } from '@/components/common/AgentAvatar';
3
+ import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select';
3
4
  import { t } from '@/lib/i18n';
4
5
  import { Bot, BrainCircuit, AlarmClock, MessageCircle } from 'lucide-react';
5
6
 
@@ -43,43 +44,45 @@ export function ChatWelcome({ onCreateSession, agents, selectedAgentId, onSelect
43
44
  <h2 className="text-xl font-semibold text-gray-900 mb-2">{t('chatWelcomeTitle')}</h2>
44
45
  <p className="text-sm text-gray-500 mb-8">{t('chatWelcomeSubtitle')}</p>
45
46
 
46
- <div className="mb-8 rounded-2xl border border-gray-200 bg-white/90 p-4 text-left shadow-card">
47
- <div className="text-sm font-semibold text-gray-900">{t('chatDraftAgentTitle')}</div>
48
- <p className="mt-1 text-xs text-gray-500">{t('chatDraftAgentDescription')}</p>
49
- <div className="mt-4 flex flex-wrap gap-2">
50
- {agents.map((agent) => {
51
- const active = agent.id === selectedAgentId;
52
- return (
53
- <button
54
- key={agent.id}
55
- type="button"
56
- onClick={() => onSelectAgent(agent.id)}
57
- className={[
58
- 'inline-flex items-center gap-2 rounded-full border px-3 py-2 text-left transition-colors',
59
- active
60
- ? 'border-gray-900 bg-gray-900 text-white'
61
- : 'border-gray-200 bg-white text-gray-700 hover:bg-gray-50'
62
- ].join(' ')}
63
- >
47
+ <div className="mb-6 flex items-center justify-center gap-2.5">
48
+ <span className="text-[13px] font-medium text-gray-500">
49
+ {t('chatDraftAgentTitle')}
50
+ </span>
51
+ <Select value={selectedAgentId} onValueChange={onSelectAgent}>
52
+ <SelectTrigger
53
+ aria-label={t('chatDraftAgentTitle')}
54
+ className="h-auto w-auto gap-1 rounded-full border-0 bg-transparent px-1.5 py-1 text-gray-500 shadow-none hover:bg-white/70 hover:text-gray-800 focus:ring-0"
55
+ >
56
+ <span className="sr-only">{t('chatDraftAgentTitle')}</span>
57
+ <div className="flex items-center gap-1.5">
58
+ {selectedAgent ? (
64
59
  <AgentAvatar
65
- agentId={agent.id}
66
- displayName={agent.displayName}
67
- avatarUrl={agent.avatarUrl}
68
- className="h-6 w-6"
60
+ agentId={selectedAgent.id}
61
+ displayName={selectedAgent.displayName}
62
+ avatarUrl={selectedAgent.avatarUrl}
63
+ className="h-7 w-7 shrink-0"
69
64
  />
70
- <span className="text-xs font-medium">
71
- {agent.displayName?.trim() || agent.id}
72
- </span>
73
- </button>
74
- );
75
- })}
76
- </div>
77
- {selectedAgent ? (
78
- <div className="mt-4 flex items-center gap-2 text-xs text-gray-500">
79
- <span>{t('chatDraftAgentCurrent')}:</span>
80
- <span className="font-medium text-gray-700">{selectedAgent.displayName?.trim() || selectedAgent.id}</span>
81
- </div>
82
- ) : null}
65
+ ) : null}
66
+ </div>
67
+ </SelectTrigger>
68
+ <SelectContent className="rounded-xl border-gray-200/80 shadow-lg">
69
+ {agents.map((agent) => (
70
+ <SelectItem key={agent.id} value={agent.id} className="rounded-lg pr-10">
71
+ <div className="flex min-w-0 items-center gap-2">
72
+ <AgentAvatar
73
+ agentId={agent.id}
74
+ displayName={agent.displayName}
75
+ avatarUrl={agent.avatarUrl}
76
+ className="h-5 w-5 shrink-0"
77
+ />
78
+ <span className="truncate text-sm font-medium text-gray-700">
79
+ {agent.displayName?.trim() || agent.id}
80
+ </span>
81
+ </div>
82
+ </SelectItem>
83
+ ))}
84
+ </SelectContent>
85
+ </Select>
83
86
  </div>
84
87
 
85
88
  {/* Capability cards */}
@@ -6,6 +6,7 @@ import {
6
6
  resolveSelectedModelValue,
7
7
  resolveSelectedThinkingLevelValue
8
8
  } from '@/components/chat/chat-session-preference-governance';
9
+ import { shouldRefreshDraftSessionId } from '@/components/chat/ncp/NcpChatPage';
9
10
 
10
11
  const modelOptions = [
11
12
  {
@@ -154,6 +155,35 @@ describe('resolveSelectedModelValue', () => {
154
155
  });
155
156
  });
156
157
 
158
+ describe('shouldRefreshDraftSessionId', () => {
159
+ it('does not replace the initial draft session id on first mount', () => {
160
+ expect(
161
+ shouldRefreshDraftSessionId({
162
+ previousSelectedSessionKey: undefined,
163
+ nextSelectedSessionKey: null
164
+ })
165
+ ).toBe(false);
166
+ });
167
+
168
+ it('replaces the draft session id after leaving an existing session', () => {
169
+ expect(
170
+ shouldRefreshDraftSessionId({
171
+ previousSelectedSessionKey: 'session-1',
172
+ nextSelectedSessionKey: null
173
+ })
174
+ ).toBe(true);
175
+ });
176
+
177
+ it('does not replace the draft session id while staying on the same session', () => {
178
+ expect(
179
+ shouldRefreshDraftSessionId({
180
+ previousSelectedSessionKey: 'session-1',
181
+ nextSelectedSessionKey: 'session-1'
182
+ })
183
+ ).toBe(false);
184
+ });
185
+ });
186
+
157
187
  describe('resolveRecentSessionPreferredModel', () => {
158
188
  it('returns the most recent preferred model from the same runtime', () => {
159
189
  const sessions = [
@@ -50,6 +50,10 @@ export function ChatSidebarSessionItem(props: ChatSidebarSessionItemProps) {
50
50
  } = props;
51
51
 
52
52
  const iconTone = active ? 'text-gray-700' : 'text-gray-500';
53
+ const normalizedAgentId = agentId?.trim() ?? '';
54
+ const shouldShowAgentAvatar = Boolean(
55
+ normalizedAgentId && normalizedAgentId.toLowerCase() !== 'main',
56
+ );
53
57
 
54
58
  return (
55
59
  <div
@@ -112,9 +116,9 @@ export function ChatSidebarSessionItem(props: ChatSidebarSessionItemProps) {
112
116
  <button type="button" onClick={onSelect} className="w-full text-left">
113
117
  <div className="grid grid-cols-[minmax(0,1fr)_0.875rem] items-center gap-1.5 pr-8">
114
118
  <span className="flex min-w-0 items-center gap-1.5">
115
- {agentId ? (
119
+ {shouldShowAgentAvatar ? (
116
120
  <AgentAvatar
117
- agentId={agentId}
121
+ agentId={normalizedAgentId}
118
122
  displayName={agentLabel}
119
123
  avatarUrl={agentAvatarUrl}
120
124
  className="h-5 w-5 shrink-0"
@@ -57,6 +57,7 @@ export function buildNcpSendMetadata(payload: {
57
57
  }
58
58
  if (payload.sessionType?.trim()) {
59
59
  metadata.session_type = payload.sessionType.trim();
60
+ metadata.runtime = payload.sessionType.trim();
60
61
  }
61
62
  if (payload.agentId?.trim()) {
62
63
  metadata.agent_id = payload.agentId.trim();
@@ -78,6 +79,17 @@ export function buildNcpSendMetadata(payload: {
78
79
  return metadata;
79
80
  }
80
81
 
82
+ export function shouldRefreshDraftSessionId(params: {
83
+ previousSelectedSessionKey: string | null | undefined;
84
+ nextSelectedSessionKey: string | null;
85
+ }): boolean {
86
+ return (
87
+ params.nextSelectedSessionKey === null &&
88
+ params.previousSelectedSessionKey !== undefined &&
89
+ params.previousSelectedSessionKey !== null
90
+ );
91
+ }
92
+
81
93
  export function NcpChatPage({ view }: ChatPageProps) {
82
94
  const [presenter] = useState(() => new NcpChatPresenter());
83
95
  const [draftSessionId, setDraftSessionId] = useState(() =>
@@ -111,6 +123,9 @@ export function NcpChatPage({ view }: ChatPageProps) {
111
123
  }>();
112
124
  const threadRef = useRef<HTMLDivElement | null>(null);
113
125
  const selectedSessionKeyRef = useRef<string | null>(selectedSessionKey);
126
+ const previousSelectedSessionKeyRef = useRef<string | null | undefined>(
127
+ undefined,
128
+ );
114
129
  const routeSessionKey = useMemo(
115
130
  () => parseSessionKeyFromRoute(routeSessionIdParam),
116
131
  [routeSessionIdParam],
@@ -153,11 +168,18 @@ export function NcpChatPage({ view }: ChatPageProps) {
153
168
  }, [draftSessionId, presenter]);
154
169
 
155
170
  useEffect(() => {
156
- if (selectedSessionKey === null) {
171
+ if (
172
+ shouldRefreshDraftSessionId({
173
+ previousSelectedSessionKey:
174
+ previousSelectedSessionKeyRef.current,
175
+ nextSelectedSessionKey: selectedSessionKey,
176
+ })
177
+ ) {
157
178
  const nextDraftSessionId = createNcpSessionId();
158
179
  setDraftSessionId(nextDraftSessionId);
159
180
  presenter.setDraftSessionId(nextDraftSessionId);
160
181
  }
182
+ previousSelectedSessionKeyRef.current = selectedSessionKey;
161
183
  }, [presenter, selectedSessionKey]);
162
184
 
163
185
  const effectiveSessionProjectRoot = hasSessionProjectRootOverride