@nextclaw/ui 0.12.1 → 0.12.3

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 (95) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/dist/assets/ChannelsList-DZWam3Ob.js +8 -0
  3. package/dist/assets/ChatPage-YBL7iJ1X.js +43 -0
  4. package/dist/assets/{DocBrowser-DjcghYGO.js → DocBrowser-C7-1sXqo.js} +1 -1
  5. package/dist/assets/DocBrowser-DQjtSsY3.js +1 -0
  6. package/dist/assets/{DocBrowserContext-CLlq7rZQ.js → DocBrowserContext-DN5tjUoS.js} +1 -1
  7. package/dist/assets/{LogoBadge-D_dOy5U3.js → LogoBadge-DDS1sU_U.js} +1 -1
  8. package/dist/assets/{MarketplacePage-BlIeNn3x.js → MarketplacePage-2tWWgwAb.js} +1 -1
  9. package/dist/assets/MarketplacePage-BorWJftJ.js +1 -0
  10. package/dist/assets/{McpMarketplacePage-mz2_IX1O.js → McpMarketplacePage-N-fB4HID.js} +1 -1
  11. package/dist/assets/ModelConfig-DvsBTUiE.js +1 -0
  12. package/dist/assets/ProviderScopedModelInput-D9woCARc.js +1 -0
  13. package/dist/assets/{ProvidersList-B0RCb_Vg.js → ProvidersList-D-qPGgC4.js} +1 -1
  14. package/dist/assets/{RemoteAccessPage-CcfQjLtx.js → RemoteAccessPage-COnjm8_x.js} +1 -1
  15. package/dist/assets/RuntimeConfig-BHpqcaHm.js +1 -0
  16. package/dist/assets/SearchConfig-DIT6M65Q.js +1 -0
  17. package/dist/assets/{SecretsConfig-DbiS3txa.js → SecretsConfig-Cefg1LFJ.js} +1 -1
  18. package/dist/assets/{SessionsConfig-CbIOcAp8.js → SessionsConfig-BZnmVTIu.js} +1 -1
  19. package/dist/assets/{book-open-BLxSL7Dk.js → book-open-DvWqOode.js} +1 -1
  20. package/dist/assets/chat-session-display-D4bYa0b8.js +1 -0
  21. package/dist/assets/{chunk-JZWAC4HX-Bp0t5xoO.js → chunk-JZWAC4HX-CxfKRD7X.js} +1 -1
  22. package/dist/assets/{config-C96FWufn.js → config-BeGwf2Ao.js} +1 -1
  23. package/dist/assets/{createLucideIcon-B_U7Nq4F.js → createLucideIcon-C7MmdIX3.js} +1 -1
  24. package/dist/assets/{dist-D9pHzW9z.js → dist-B6VMuIQN.js} +1 -1
  25. package/dist/assets/{dist-BFY-GyT4.js → dist-RWNFhxvR.js} +1 -1
  26. package/dist/assets/{external-link-BydIQTIH.js → external-link-U86Acd1t.js} +1 -1
  27. package/dist/assets/{hash-Djdf0x1C.js → hash-D-OVfV3Z.js} +1 -1
  28. package/dist/assets/i18n-hM3v-3YG.js +1 -0
  29. package/dist/assets/index-CpxuJa9o.css +1 -0
  30. package/dist/assets/{index-DqSv8Azv.js → index-DHmCjcxq.js} +3 -3
  31. package/dist/assets/{label-Bvv4Mrea.js → label-CHJ1ATds.js} +1 -1
  32. package/dist/assets/loader-circle-C8cpaL0w.js +1 -0
  33. package/dist/assets/{logos-CGJJRI5_.js → logos-U1_qDA3U.js} +1 -1
  34. package/dist/assets/{page-layout-6Nm4Cnvr.js → page-layout-Z1klaUFW.js} +1 -1
  35. package/dist/assets/plus-CrkO1kob.js +1 -0
  36. package/dist/assets/{popover-b9rSYI6X.js → popover-xWbqMnIN.js} +1 -1
  37. package/dist/assets/{react-CDZz_StC.js → react-3YE87-lE.js} +1 -1
  38. package/dist/assets/{refresh-ccw-BvSSnnCw.js → refresh-ccw-JQh1lwq-.js} +1 -1
  39. package/dist/assets/{save-CAf0_-b9.js → save-4VRlzkii.js} +1 -1
  40. package/dist/assets/search-EX-Papzl.js +1 -0
  41. package/dist/assets/{security-config-DF66-l25.js → security-config-DEgOD4VX.js} +1 -1
  42. package/dist/assets/{select-CEIMqc0H.js → select-DF-AUoie.js} +1 -1
  43. package/dist/assets/skeleton-B0mmt1vo.js +1 -0
  44. package/dist/assets/{status-dot-CmQI5Qq2.js → status-dot-Bq_8Ojvv.js} +1 -1
  45. package/dist/assets/{switch-B7SxDXyR.js → switch-D7JF_RZ-.js} +1 -1
  46. package/dist/assets/{tabs-custom-Dxt6EJJW.js → tabs-custom-CLksZ2bO.js} +1 -1
  47. package/dist/assets/{trash-2-BnQ1PDTw.js → trash-2-VV8jvziy.js} +1 -1
  48. package/dist/assets/{useConfirmDialog-B-vMOmhG.js → useConfirmDialog-CuQqiPx7.js} +1 -1
  49. package/dist/assets/{useMutation-Bi39Z9_J.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 +4 -4
  53. package/src/api/agents.ts +9 -1
  54. package/src/api/types.ts +25 -1
  55. package/src/components/agents/AgentDialogs.tsx +400 -0
  56. package/src/components/agents/AgentsPage.test.tsx +148 -1
  57. package/src/components/agents/AgentsPage.tsx +114 -115
  58. package/src/components/chat/ChatConversationPanel.test.tsx +69 -3
  59. package/src/components/chat/ChatConversationPanel.tsx +24 -3
  60. package/src/components/chat/managers/chat-session-list.manager.test.ts +34 -3
  61. package/src/components/chat/managers/chat-session-list.manager.ts +17 -4
  62. package/src/components/chat/ncp/NcpChatPage.tsx +1 -0
  63. package/src/components/chat/ncp/ncp-chat.presenter.ts +5 -1
  64. package/src/components/chat/ncp/ncp-session-adapter.ts +6 -1
  65. package/src/components/chat/stores/chat-session-list.store.ts +6 -1
  66. package/src/components/chat/useChatSessionTypeState.ts +10 -2
  67. package/src/components/common/ProviderScopedModelInput.tsx +149 -0
  68. package/src/components/config/ChannelForm.test.tsx +60 -0
  69. package/src/components/config/ChannelForm.tsx +52 -12
  70. package/src/components/config/ModelConfig.test.tsx +61 -0
  71. package/src/components/config/ModelConfig.tsx +15 -90
  72. package/src/components/config/RuntimeConfig.tsx +2 -2
  73. package/src/components/config/SearchConfig.test.tsx +150 -0
  74. package/src/components/config/SearchConfig.tsx +257 -71
  75. package/src/components/config/runtime-config-agent.utils.ts +5 -4
  76. package/src/hooks/agents/useAgents.ts +18 -1
  77. package/src/lib/i18n.agents.ts +19 -0
  78. package/src/lib/i18n.search.ts +37 -0
  79. package/src/lib/i18n.ts +6 -26
  80. package/dist/assets/ChannelsList-DekMP4a3.js +0 -8
  81. package/dist/assets/ChatPage-Dgw4vlDt.js +0 -43
  82. package/dist/assets/DocBrowser-CExjX5is.js +0 -1
  83. package/dist/assets/MarketplacePage-DGfzg1LG.js +0 -1
  84. package/dist/assets/ModelConfig-C_49_a9v.js +0 -1
  85. package/dist/assets/RuntimeConfig-DBWzwoY-.js +0 -1
  86. package/dist/assets/SearchConfig-jSdwlH4b.js +0 -1
  87. package/dist/assets/chat-session-display-8yW6-mtm.js +0 -1
  88. package/dist/assets/i18n-DAekxt_G.js +0 -1
  89. package/dist/assets/index-CHEgQIiO.css +0 -1
  90. package/dist/assets/loader-circle-CGXXikVG.js +0 -1
  91. package/dist/assets/plus-CrW9BJDy.js +0 -1
  92. package/dist/assets/provider-models-IJDA940D.js +0 -1
  93. package/dist/assets/search-DgoXxocn.js +0 -1
  94. package/dist/assets/skeleton-BiPUQkOD.js +0 -1
  95. package/dist/assets/x-PBSiWt3l.js +0 -1
@@ -1,39 +1,31 @@
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
+ resolveAgentRuntimeSessionType,
16
+ resolveSessionTypeLabel
17
+ } from '@/components/chat/useChatSessionTypeState';
18
+ import { useChatInputStore } from '@/components/chat/stores/chat-input.store';
4
19
  import { useChatSessionListStore } from '@/components/chat/stores/chat-session-list.store';
5
20
  import { AgentAvatar } from '@/components/common/AgentAvatar';
6
21
  import { Button } from '@/components/ui/button';
7
22
  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';
23
+ import { useNcpChatSessionTypes } from '@/hooks/use-ncp-chat-session-types';
17
24
  import { PageLayout } from '@/components/layout/page-layout';
18
25
  import { t } from '@/lib/i18n';
26
+ import { buildProviderModelCatalog } from '@/lib/provider-models';
19
27
  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
- };
28
+ import { Bot, House, MessageCircle, Pencil, Plus, ShieldCheck, Sparkles, Trash2 } from 'lucide-react';
37
29
 
38
30
  const CARD_TONES = [
39
31
  {
@@ -63,10 +55,14 @@ function resolveAgentTone(index: number, builtIn: boolean) {
63
55
  export function AgentsPage() {
64
56
  const navigate = useNavigate();
65
57
  const agentsQuery = useAgents();
58
+ const configQuery = useConfig();
59
+ const configMetaQuery = useConfigMeta();
60
+ const sessionTypesQuery = useNcpChatSessionTypes();
66
61
  const createAgent = useCreateAgent();
62
+ const updateAgent = useUpdateAgent();
67
63
  const deleteAgent = useDeleteAgent();
68
64
  const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
69
- const [form, setForm] = useState<CreateFormState>(EMPTY_FORM);
65
+ const [editingAgent, setEditingAgent] = useState<AgentProfileView | null>(null);
70
66
  const setSessionListSnapshot = useChatSessionListStore((state) => state.setSnapshot);
71
67
 
72
68
  const agents = useMemo(() => agentsQuery.data?.agents ?? [], [agentsQuery.data?.agents]);
@@ -79,26 +75,66 @@ export function AgentsPage() {
79
75
  ),
80
76
  [agents]
81
77
  );
78
+ const providerCatalog = useMemo(
79
+ () => buildProviderModelCatalog({ config: configQuery.data, meta: configMetaQuery.data, onlyConfigured: true }),
80
+ [configMetaQuery.data, configQuery.data]
81
+ );
82
+ const runtimeOptions = useMemo(
83
+ () => buildSessionTypeOptions(sessionTypesQuery.data?.options ?? []),
84
+ [sessionTypesQuery.data?.options]
85
+ );
86
+ const defaultRuntime = useMemo(
87
+ () => normalizeSessionType(sessionTypesQuery.data?.defaultType ?? 'native'),
88
+ [sessionTypesQuery.data?.defaultType]
89
+ );
90
+ const defaultRuntimeLabel = useMemo(
91
+ () => runtimeOptions.find((option) => option.value === defaultRuntime)?.label ?? resolveSessionTypeLabel(defaultRuntime),
92
+ [defaultRuntime, runtimeOptions]
93
+ );
82
94
 
83
- const handleCreate = async () => {
95
+ const handleCreate = async (form: AgentCreateFormState) => {
84
96
  await createAgent.mutateAsync({
85
97
  data: {
86
98
  id: form.id,
87
99
  ...(form.displayName.trim() ? { displayName: form.displayName.trim() } : {}),
88
100
  ...(form.description.trim() ? { description: form.description.trim() } : {}),
89
101
  ...(form.avatar.trim() ? { avatar: form.avatar.trim() } : {}),
90
- ...(form.home.trim() ? { home: form.home.trim() } : {})
102
+ ...(form.home.trim() ? { home: form.home.trim() } : {}),
103
+ ...(form.model.trim() ? { model: form.model.trim() } : {}),
104
+ ...(form.runtime.trim() ? { runtime: form.runtime.trim() } : {})
91
105
  }
92
106
  });
93
- setForm(EMPTY_FORM);
94
107
  setIsCreateDialogOpen(false);
95
108
  };
96
109
 
97
- const startChatWithAgent = (agentId: string) => {
110
+ const handleStartEdit = (agent: AgentProfileView) => {
111
+ setEditingAgent(agent);
112
+ };
113
+
114
+ const handleUpdate = async (agentId: string, form: AgentEditFormState) => {
115
+ await updateAgent.mutateAsync({
116
+ agentId,
117
+ data: {
118
+ displayName: form.displayName,
119
+ description: form.description,
120
+ avatar: form.avatar,
121
+ model: form.model,
122
+ ...(form.runtime.trim() ? { runtime: form.runtime.trim() } : { runtime: "" })
123
+ }
124
+ });
125
+ setEditingAgent(null);
126
+ };
127
+
128
+ const startChatWithAgent = (agent: AgentProfileView) => {
98
129
  setSessionListSnapshot({
99
- selectedAgentId: agentId,
130
+ selectedAgentId: agent.id,
100
131
  selectedSessionKey: null
101
132
  });
133
+ useChatInputStore.getState().setSnapshot({
134
+ pendingSessionType: resolveAgentRuntimeSessionType(agent, defaultRuntime),
135
+ pendingProjectRoot: null,
136
+ pendingProjectRootSessionKey: null
137
+ });
102
138
  navigate('/chat');
103
139
  };
104
140
 
@@ -178,6 +214,11 @@ export function AgentsPage() {
178
214
  ) : (
179
215
  sortedAgents.map((agent, index) => {
180
216
  const tone = resolveAgentTone(index, Boolean(agent.builtIn));
217
+ const runtimeValue = agent.runtime?.trim() || agent.engine?.trim() || '';
218
+ const runtimeLabel = runtimeValue
219
+ ? runtimeOptions.find((option) => option.value === normalizeSessionType(runtimeValue))?.label ??
220
+ resolveSessionTypeLabel(runtimeValue)
221
+ : defaultRuntimeLabel;
181
222
  return (
182
223
  <Card
183
224
  key={agent.id}
@@ -223,6 +264,16 @@ export function AgentsPage() {
223
264
  </p>
224
265
 
225
266
  <div className="mt-auto flex flex-col gap-4">
267
+ <div>
268
+ <div className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-[#94a3b8]">
269
+ <Sparkles className="h-3.5 w-3.5" />
270
+ {t('agentsCardRuntimeLabel')}
271
+ </div>
272
+ <div className="mt-1.5 text-sm leading-6 text-[#475569]">
273
+ {runtimeLabel}
274
+ </div>
275
+ </div>
276
+
226
277
  <div className="border-t border-gray-100 pt-3">
227
278
  <div className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-[#94a3b8]">
228
279
  <House className="h-3.5 w-3.5" />
@@ -237,11 +288,21 @@ export function AgentsPage() {
237
288
  <Button
238
289
  type="button"
239
290
  className="h-9 rounded-xl bg-[#1f5c4d] px-4 text-white hover:bg-[#184d40]"
240
- onClick={() => startChatWithAgent(agent.id)}
291
+ onClick={() => startChatWithAgent(agent)}
241
292
  >
242
293
  <MessageCircle className="mr-2 h-4 w-4" />
243
294
  {t('agentsCardStartChat')}
244
295
  </Button>
296
+ <Button
297
+ type="button"
298
+ variant="ghost"
299
+ className="h-8 rounded-xl px-3 text-xs text-[#7b8794] hover:bg-[#f3f4f6] hover:text-[#475569]"
300
+ onClick={() => handleStartEdit(agent)}
301
+ disabled={updateAgent.isPending}
302
+ >
303
+ <Pencil className="mr-1.5 h-3.5 w-3.5" />
304
+ {t('agentsEditAction')}
305
+ </Button>
245
306
  {!agent.builtIn ? (
246
307
  <Button
247
308
  type="button"
@@ -263,91 +324,29 @@ export function AgentsPage() {
263
324
  )}
264
325
  </div>
265
326
 
266
- <Dialog
327
+ <AgentCreateDialog
267
328
  open={isCreateDialogOpen}
329
+ pending={createAgent.isPending}
330
+ providerCatalog={providerCatalog}
331
+ runtimeOptions={runtimeOptions}
332
+ defaultRuntime={defaultRuntime}
333
+ onOpenChange={setIsCreateDialogOpen}
334
+ onSubmit={handleCreate}
335
+ />
336
+
337
+ <AgentEditDialog
338
+ agent={editingAgent}
339
+ pending={updateAgent.isPending}
340
+ providerCatalog={providerCatalog}
341
+ runtimeOptions={runtimeOptions}
342
+ defaultRuntime={defaultRuntime}
268
343
  onOpenChange={(open) => {
269
- setIsCreateDialogOpen(open);
270
- if (!open && !createAgent.isPending) {
271
- setForm(EMPTY_FORM);
344
+ if (!open && !updateAgent.isPending) {
345
+ setEditingAgent(null);
272
346
  }
273
347
  }}
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>
348
+ onSubmit={handleUpdate}
349
+ />
351
350
  </PageLayout>
352
351
  );
353
352
  }
@@ -1,12 +1,17 @@
1
1
  import { render, screen } from '@testing-library/react';
2
+ import userEvent from '@testing-library/user-event';
2
3
  import { beforeEach, describe, expect, it, vi } from 'vitest';
3
4
  import { ChatChildSessionPanel } from '@/components/chat/chat-child-session-panel';
4
5
  import { ChatConversationPanel } from '@/components/chat/ChatConversationPanel';
6
+ import { useChatInputStore } from '@/components/chat/stores/chat-input.store';
5
7
  import { useChatThreadStore } from '@/components/chat/stores/chat-thread.store';
6
8
 
7
9
  const mocks = vi.hoisted(() => ({
8
10
  deleteSession: vi.fn(),
9
11
  goToProviders: vi.fn(),
12
+ createSession: vi.fn(),
13
+ setSelectedAgentId: vi.fn(),
14
+ setPendingSessionType: vi.fn(),
10
15
  resolvedChildTabs: [
11
16
  {
12
17
  sessionKey: 'child-session-1',
@@ -39,7 +44,22 @@ vi.mock('@/components/chat/containers/chat-message-list.container', () => ({
39
44
  }));
40
45
 
41
46
  vi.mock('@/components/chat/ChatWelcome', () => ({
42
- ChatWelcome: () => <div data-testid="chat-welcome" />
47
+ ChatWelcome: ({
48
+ onCreateSession,
49
+ onSelectAgent
50
+ }: {
51
+ onCreateSession: () => void;
52
+ onSelectAgent: (agentId: string) => void;
53
+ }) => (
54
+ <div data-testid="chat-welcome">
55
+ <button type="button" onClick={onCreateSession}>
56
+ create draft session
57
+ </button>
58
+ <button type="button" onClick={() => onSelectAgent('engineer')}>
59
+ switch draft agent
60
+ </button>
61
+ </div>
62
+ )
43
63
  }));
44
64
 
45
65
  vi.mock('@/components/chat/presenter/chat-presenter-context', () => ({
@@ -47,14 +67,18 @@ vi.mock('@/components/chat/presenter/chat-presenter-context', () => ({
47
67
  chatThreadManager: {
48
68
  deleteSession: mocks.deleteSession,
49
69
  goToProviders: mocks.goToProviders,
50
- createSession: vi.fn(),
51
70
  openSessionFromToolAction: vi.fn(),
52
71
  selectChildSessionDetail: vi.fn(),
53
72
  closeChildSessionDetail: vi.fn(),
54
73
  goToParentSession: vi.fn(),
55
74
  },
56
75
  chatSessionListManager: {
57
- selectSession: vi.fn()
76
+ selectSession: vi.fn(),
77
+ createSession: mocks.createSession,
78
+ setSelectedAgentId: mocks.setSelectedAgentId
79
+ },
80
+ chatInputManager: {
81
+ setPendingSessionType: mocks.setPendingSessionType
58
82
  }
59
83
  })
60
84
  }));
@@ -96,6 +120,15 @@ describe('ChatConversationPanel', () => {
96
120
  beforeEach(() => {
97
121
  mocks.deleteSession.mockReset();
98
122
  mocks.goToProviders.mockReset();
123
+ mocks.createSession.mockReset();
124
+ mocks.setSelectedAgentId.mockReset();
125
+ mocks.setPendingSessionType.mockReset();
126
+ useChatInputStore.setState({
127
+ snapshot: {
128
+ ...useChatInputStore.getState().snapshot,
129
+ defaultSessionType: 'native'
130
+ }
131
+ });
99
132
  useChatThreadStore.setState({
100
133
  snapshot: {
101
134
  ...useChatThreadStore.getState().snapshot,
@@ -116,6 +149,10 @@ describe('ChatConversationPanel', () => {
116
149
  isAwaitingAssistantOutput: false,
117
150
  parentSessionKey: null,
118
151
  parentSessionLabel: null,
152
+ availableAgents: [
153
+ { id: 'main', displayName: 'Main', runtime: 'native' },
154
+ { id: 'engineer', displayName: 'Engineer', runtime: 'codex' }
155
+ ],
119
156
  childSessionTabs: [],
120
157
  activeChildSessionKey: null,
121
158
  }
@@ -177,6 +214,35 @@ describe('ChatConversationPanel', () => {
177
214
  expect(screen.getByTestId('agent-avatar').textContent).toBe('engineer');
178
215
  expect(screen.queryByText('Engineer')).toBeNull();
179
216
  });
217
+
218
+ it('creates a draft session with the selected draft agent runtime', async () => {
219
+ const user = userEvent.setup();
220
+
221
+ useChatThreadStore.setState({
222
+ snapshot: {
223
+ ...useChatThreadStore.getState().snapshot,
224
+ agentId: 'engineer',
225
+ agentDisplayName: 'Engineer'
226
+ }
227
+ });
228
+
229
+ render(<ChatConversationPanel />);
230
+
231
+ await user.click(screen.getByRole('button', { name: 'create draft session' }));
232
+
233
+ expect(mocks.createSession).toHaveBeenCalledWith('codex');
234
+ });
235
+
236
+ it('syncs the pending session type when switching the draft agent', async () => {
237
+ const user = userEvent.setup();
238
+
239
+ render(<ChatConversationPanel />);
240
+
241
+ await user.click(screen.getByRole('button', { name: 'switch draft agent' }));
242
+
243
+ expect(mocks.setSelectedAgentId).toHaveBeenCalledWith('engineer');
244
+ expect(mocks.setPendingSessionType).toHaveBeenCalledWith('codex');
245
+ });
180
246
  });
181
247
 
182
248
  describe('ChatChildSessionPanel', () => {
@@ -11,7 +11,9 @@ import { AgentAvatar } from "@/components/common/AgentAvatar";
11
11
  import { usePresenter } from "@/components/chat/presenter/chat-presenter-context";
12
12
  import { ChatSessionHeaderActions } from "@/components/chat/session-header/chat-session-header-actions";
13
13
  import { ChatSessionProjectBadge } from "@/components/chat/session-header/chat-session-project-badge";
14
+ import { useChatInputStore } from "@/components/chat/stores/chat-input.store";
14
15
  import { useChatThreadStore } from "@/components/chat/stores/chat-thread.store";
16
+ import { resolveAgentRuntimeSessionType } from "@/components/chat/useChatSessionTypeState";
15
17
  import { t } from "@/lib/i18n";
16
18
  import { cn } from "@/lib/utils";
17
19
 
@@ -45,6 +47,9 @@ function ChatConversationSkeleton() {
45
47
 
46
48
  export function ChatConversationPanel() {
47
49
  const presenter = usePresenter();
50
+ const defaultSessionType = useChatInputStore(
51
+ (state) => state.snapshot.defaultSessionType,
52
+ );
48
53
  const snapshot = useChatThreadStore((state) => state.snapshot);
49
54
  const fallbackThreadRef = useRef<HTMLDivElement | null>(null);
50
55
  const threadRef = snapshot.threadRef ?? fallbackThreadRef;
@@ -80,6 +85,22 @@ export function ChatConversationPanel() {
80
85
  snapshot.messages.length === 0 &&
81
86
  !snapshot.isSending &&
82
87
  !snapshot.isAwaitingAssistantOutput;
88
+ const availableAgents = snapshot.availableAgents ?? [];
89
+ const resolveDraftAgent = (agentId: string) =>
90
+ availableAgents.find((agent) => agent.id === agentId) ?? null;
91
+ const createDraftSessionForAgent = () => {
92
+ const sessionType = resolveAgentRuntimeSessionType(
93
+ resolveDraftAgent(snapshot.agentId ?? "main"),
94
+ defaultSessionType,
95
+ );
96
+ presenter.chatSessionListManager.createSession(sessionType);
97
+ };
98
+ const selectDraftAgent = (agentId: string) => {
99
+ presenter.chatSessionListManager.setSelectedAgentId(agentId);
100
+ presenter.chatInputManager.setPendingSessionType(
101
+ resolveAgentRuntimeSessionType(resolveDraftAgent(agentId), defaultSessionType),
102
+ );
103
+ };
83
104
 
84
105
  const { onScroll: handleScroll } = useStickyBottomScroll({
85
106
  scrollRef: threadRef,
@@ -192,10 +213,10 @@ export function ChatConversationPanel() {
192
213
  >
193
214
  {showWelcome ? (
194
215
  <ChatWelcome
195
- onCreateSession={presenter.chatThreadManager.createSession}
196
- agents={snapshot.availableAgents ?? []}
216
+ onCreateSession={createDraftSessionForAgent}
217
+ agents={availableAgents}
197
218
  selectedAgentId={snapshot.agentId ?? "main"}
198
- onSelectAgent={presenter.chatSessionListManager.setSelectedAgentId}
219
+ onSelectAgent={selectDraftAgent}
199
220
  />
200
221
  ) : hideEmptyHint ? (
201
222
  <div className="h-full" />
@@ -9,13 +9,16 @@ describe('ChatSessionListManager', () => {
9
9
  snapshot: {
10
10
  ...useChatInputStore.getState().snapshot,
11
11
  defaultSessionType: 'native',
12
- pendingSessionType: 'native'
12
+ pendingSessionType: 'native',
13
+ pendingProjectRoot: null,
14
+ pendingProjectRootSessionKey: null
13
15
  }
14
16
  });
15
17
  useChatSessionListStore.setState({
16
18
  snapshot: {
17
19
  ...useChatSessionListStore.getState().snapshot,
18
- selectedSessionKey: 'session-1'
20
+ selectedSessionKey: 'session-1',
21
+ listMode: 'time-first'
19
22
  }
20
23
  });
21
24
  });
@@ -28,13 +31,30 @@ describe('ChatSessionListManager', () => {
28
31
  resetStreamState: vi.fn()
29
32
  } as unknown as ConstructorParameters<typeof ChatSessionListManager>[1];
30
33
 
31
- const manager = new ChatSessionListManager(uiManager, streamActionsManager);
34
+ const manager = new ChatSessionListManager(uiManager, streamActionsManager, () => 'draft-session-1');
32
35
  manager.createSession('codex');
33
36
 
34
37
  expect(streamActionsManager.resetStreamState).toHaveBeenCalledTimes(1);
35
38
  expect(uiManager.goToChatRoot).toHaveBeenCalledTimes(1);
36
39
  expect(useChatSessionListStore.getState().snapshot.selectedSessionKey).toBe('session-1');
37
40
  expect(useChatInputStore.getState().snapshot.pendingSessionType).toBe('codex');
41
+ expect(useChatInputStore.getState().snapshot.pendingProjectRoot).toBeNull();
42
+ expect(useChatInputStore.getState().snapshot.pendingProjectRootSessionKey).toBeNull();
43
+ });
44
+
45
+ it('hydrates the draft project root when creating a session inside a project group', () => {
46
+ const uiManager = {
47
+ goToChatRoot: vi.fn()
48
+ } as unknown as ConstructorParameters<typeof ChatSessionListManager>[0];
49
+ const streamActionsManager = {
50
+ resetStreamState: vi.fn()
51
+ } as unknown as ConstructorParameters<typeof ChatSessionListManager>[1];
52
+
53
+ const manager = new ChatSessionListManager(uiManager, streamActionsManager, () => 'draft-session-9');
54
+ manager.createSession('native', '/tmp/project-alpha');
55
+
56
+ expect(useChatInputStore.getState().snapshot.pendingProjectRoot).toBe('/tmp/project-alpha');
57
+ expect(useChatInputStore.getState().snapshot.pendingProjectRootSessionKey).toBe('draft-session-9');
38
58
  });
39
59
 
40
60
  it('delegates existing-session selection to routing without eagerly mutating the selected session state', () => {
@@ -51,4 +71,15 @@ describe('ChatSessionListManager', () => {
51
71
  expect(uiManager.goToSession).toHaveBeenCalledWith('session-2');
52
72
  expect(useChatSessionListStore.getState().snapshot.selectedSessionKey).toBe('session-1');
53
73
  });
74
+
75
+ it('updates the sidebar list mode without touching other session list state', () => {
76
+ const uiManager = {} as ConstructorParameters<typeof ChatSessionListManager>[0];
77
+ const streamActionsManager = {} as ConstructorParameters<typeof ChatSessionListManager>[1];
78
+
79
+ const manager = new ChatSessionListManager(uiManager, streamActionsManager);
80
+ manager.setListMode('project-first');
81
+
82
+ expect(useChatSessionListStore.getState().snapshot.listMode).toBe('project-first');
83
+ expect(useChatSessionListStore.getState().snapshot.selectedSessionKey).toBe('session-1');
84
+ });
54
85
  });
@@ -3,11 +3,13 @@ import { useChatInputStore } from '@/components/chat/stores/chat-input.store';
3
3
  import type { ChatUiManager } from '@/components/chat/managers/chat-ui.manager';
4
4
  import type { SetStateAction } from 'react';
5
5
  import type { ChatStreamActionsManager } from '@/components/chat/managers/chat-stream-actions.manager';
6
+ import { normalizeSessionProjectRootValue } from '@/lib/session-project/session-project.utils';
6
7
 
7
8
  export class ChatSessionListManager {
8
9
  constructor(
9
10
  private uiManager: ChatUiManager,
10
- private streamActionsManager: ChatStreamActionsManager
11
+ private streamActionsManager: ChatStreamActionsManager,
12
+ private getDraftSessionId: () => string = () => ''
11
13
  ) {}
12
14
 
13
15
  private resolveUpdateValue = <T>(prev: T, next: SetStateAction<T>): T => {
@@ -35,7 +37,16 @@ export class ChatSessionListManager {
35
37
  useChatSessionListStore.getState().setSnapshot({ selectedSessionKey: value });
36
38
  };
37
39
 
38
- createSession = (sessionType?: string) => {
40
+ setListMode = (next: SetStateAction<'time-first' | 'project-first'>) => {
41
+ const prev = useChatSessionListStore.getState().snapshot.listMode;
42
+ const value = this.resolveUpdateValue(prev, next);
43
+ if (value === prev) {
44
+ return;
45
+ }
46
+ useChatSessionListStore.getState().setSnapshot({ listMode: value });
47
+ };
48
+
49
+ createSession = (sessionType?: string, projectRoot?: string | null) => {
39
50
  const { snapshot } = useChatInputStore.getState();
40
51
  const { defaultSessionType: configuredDefaultSessionType } = snapshot;
41
52
  const defaultSessionType = configuredDefaultSessionType || 'native';
@@ -43,11 +54,13 @@ export class ChatSessionListManager {
43
54
  typeof sessionType === 'string' && sessionType.trim().length > 0
44
55
  ? sessionType.trim()
45
56
  : defaultSessionType;
57
+ const normalizedProjectRoot = normalizeSessionProjectRootValue(projectRoot);
58
+ const draftSessionId = normalizedProjectRoot ? this.getDraftSessionId() : null;
46
59
  this.streamActionsManager.resetStreamState();
47
60
  useChatInputStore.getState().setSnapshot({
48
61
  pendingSessionType: nextSessionType,
49
- pendingProjectRoot: null,
50
- pendingProjectRootSessionKey: null
62
+ pendingProjectRoot: normalizedProjectRoot,
63
+ pendingProjectRootSessionKey: draftSessionId
51
64
  });
52
65
  this.uiManager.goToChatRoot();
53
66
  };
@@ -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();
@@ -7,7 +7,11 @@ import { NcpChatThreadManager } from '@/components/chat/ncp/ncp-chat-thread.mana
7
7
  export class NcpChatPresenter {
8
8
  chatUiManager = new ChatUiManager();
9
9
  chatStreamActionsManager = new ChatStreamActionsManager();
10
- chatSessionListManager = new ChatSessionListManager(this.chatUiManager, this.chatStreamActionsManager);
10
+ chatSessionListManager = new ChatSessionListManager(
11
+ this.chatUiManager,
12
+ this.chatStreamActionsManager,
13
+ () => this.getDraftSessionId()
14
+ );
11
15
  chatInputManager = new NcpChatInputManager(
12
16
  this.chatUiManager,
13
17
  this.chatStreamActionsManager,
@@ -85,7 +85,12 @@ function readNcpSessionType(summary: NcpSessionSummaryView): string {
85
85
  if (!metadata) {
86
86
  return 'native';
87
87
  }
88
- return readOptionalString(metadata.session_type) ?? readOptionalString(metadata.sessionType) ?? 'native';
88
+ return (
89
+ readOptionalString(metadata.runtime) ??
90
+ readOptionalString(metadata.session_type) ??
91
+ readOptionalString(metadata.sessionType) ??
92
+ 'native'
93
+ );
89
94
  }
90
95
 
91
96
  function readNcpParentSessionId(summary: NcpSessionSummaryView): string | null {
@@ -1,8 +1,12 @@
1
1
  import { create } from 'zustand';
2
+
3
+ export type ChatSessionListMode = 'time-first' | 'project-first';
4
+
2
5
  export type ChatSessionListSnapshot = {
3
6
  selectedSessionKey: string | null;
4
7
  selectedAgentId: string;
5
8
  query: string;
9
+ listMode: ChatSessionListMode;
6
10
  };
7
11
 
8
12
  type ChatSessionListStore = {
@@ -13,7 +17,8 @@ type ChatSessionListStore = {
13
17
  const initialSnapshot: ChatSessionListSnapshot = {
14
18
  selectedSessionKey: null,
15
19
  selectedAgentId: 'main',
16
- query: ''
20
+ query: '',
21
+ listMode: 'time-first'
17
22
  };
18
23
 
19
24
  export const useChatSessionListStore = create<ChatSessionListStore>((set) => ({