@nextclaw/ui 0.11.23 → 0.12.1

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 (118) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/dist/assets/{ChannelsList-DVDu1xvz.js → ChannelsList-DekMP4a3.js} +1 -1
  3. package/dist/assets/ChatPage-Dgw4vlDt.js +43 -0
  4. package/dist/assets/DocBrowser-CExjX5is.js +1 -0
  5. package/dist/assets/{DocBrowser-BmtBLFU0.js → DocBrowser-DjcghYGO.js} +1 -1
  6. package/dist/assets/{DocBrowserContext-YIKkPb76.js → DocBrowserContext-CLlq7rZQ.js} +1 -1
  7. package/dist/assets/{LogoBadge-F7ZWdxLT.js → LogoBadge-D_dOy5U3.js} +1 -1
  8. package/dist/assets/{MarketplacePage-Buo9HrOz.js → MarketplacePage-BlIeNn3x.js} +2 -2
  9. package/dist/assets/MarketplacePage-DGfzg1LG.js +1 -0
  10. package/dist/assets/{McpMarketplacePage-JnkYwK7p.js → McpMarketplacePage-mz2_IX1O.js} +2 -2
  11. package/dist/assets/{ModelConfig-BYRhgp0c.js → ModelConfig-C_49_a9v.js} +1 -1
  12. package/dist/assets/{ProvidersList-DmLyyHvX.js → ProvidersList-B0RCb_Vg.js} +1 -1
  13. package/dist/assets/{RemoteAccessPage-CDSSvH7Z.js → RemoteAccessPage-CcfQjLtx.js} +1 -1
  14. package/dist/assets/RuntimeConfig-DBWzwoY-.js +1 -0
  15. package/dist/assets/{SearchConfig-D5f1EkLE.js → SearchConfig-jSdwlH4b.js} +1 -1
  16. package/dist/assets/{SecretsConfig-D61IKcYt.js → SecretsConfig-DbiS3txa.js} +1 -1
  17. package/dist/assets/{SessionsConfig-BRIxVTEv.js → SessionsConfig-CbIOcAp8.js} +2 -2
  18. package/dist/assets/{book-open-CXoF5nQC.js → book-open-BLxSL7Dk.js} +1 -1
  19. package/dist/assets/chat-session-display-8yW6-mtm.js +1 -0
  20. package/dist/assets/{chunk-JZWAC4HX-CvRWvTy5.js → chunk-JZWAC4HX-Bp0t5xoO.js} +1 -1
  21. package/dist/assets/{config-DJswxxE8.js → config-C96FWufn.js} +1 -1
  22. package/dist/assets/{createLucideIcon-CjGHOWb6.js → createLucideIcon-B_U7Nq4F.js} +1 -1
  23. package/dist/assets/{dist-Cl2QB-2y.js → dist-BFY-GyT4.js} +1 -1
  24. package/dist/assets/{dist-nqTTbVdA.js → dist-D9pHzW9z.js} +1 -1
  25. package/dist/assets/{external-link-tIO7zING.js → external-link-BydIQTIH.js} +1 -1
  26. package/dist/assets/{hash-JWUyl1pT.js → hash-Djdf0x1C.js} +1 -1
  27. package/dist/assets/i18n-DAekxt_G.js +1 -0
  28. package/dist/assets/index-CHEgQIiO.css +1 -0
  29. package/dist/assets/index-DqSv8Azv.js +6 -0
  30. package/dist/assets/{label-BIpeNu4r.js → label-Bvv4Mrea.js} +1 -1
  31. package/dist/assets/loader-circle-CGXXikVG.js +1 -0
  32. package/dist/assets/{logos-DThdM9lk.js → logos-CGJJRI5_.js} +1 -1
  33. package/dist/assets/{page-layout-D3Xo605Z.js → page-layout-6Nm4Cnvr.js} +1 -1
  34. package/dist/assets/plus-CrW9BJDy.js +1 -0
  35. package/dist/assets/{popover-BJRUGA_H.js → popover-b9rSYI6X.js} +1 -1
  36. package/dist/assets/{provider-models-bz5y28rq.js → provider-models-IJDA940D.js} +1 -1
  37. package/dist/assets/{react-7ZHqQtEV.js → react-CDZz_StC.js} +1 -1
  38. package/dist/assets/{refresh-ccw-CC6-_QuL.js → refresh-ccw-BvSSnnCw.js} +1 -1
  39. package/dist/assets/{save-DJM5RRWW.js → save-CAf0_-b9.js} +1 -1
  40. package/dist/assets/search-DgoXxocn.js +1 -0
  41. package/dist/assets/{security-config-DbUyWcQz.js → security-config-DF66-l25.js} +1 -1
  42. package/dist/assets/{select-DSkTc61S.js → select-CEIMqc0H.js} +1 -1
  43. package/dist/assets/skeleton-BiPUQkOD.js +1 -0
  44. package/dist/assets/{status-dot-LNBlDu3q.js → status-dot-CmQI5Qq2.js} +1 -1
  45. package/dist/assets/{switch-Bo-Y46HZ.js → switch-B7SxDXyR.js} +1 -1
  46. package/dist/assets/{tabs-custom-DXv507_2.js → tabs-custom-Dxt6EJJW.js} +1 -1
  47. package/dist/assets/{trash-2-DFZmW6Gg.js → trash-2-BnQ1PDTw.js} +1 -1
  48. package/dist/assets/{useConfirmDialog-COwYXDKm.js → useConfirmDialog-B-vMOmhG.js} +1 -1
  49. package/dist/assets/{useMutation-DrZrOgVL.js → useMutation-Bi39Z9_J.js} +1 -1
  50. package/dist/assets/x-PBSiWt3l.js +1 -0
  51. package/dist/index.html +18 -18
  52. package/package.json +7 -7
  53. package/src/App.tsx +2 -0
  54. package/src/api/agents.ts +26 -0
  55. package/src/api/types.ts +23 -5
  56. package/src/components/agents/AgentsPage.test.tsx +70 -0
  57. package/src/components/agents/AgentsPage.tsx +353 -0
  58. package/src/components/chat/ChatConversationPanel.test.tsx +172 -13
  59. package/src/components/chat/ChatConversationPanel.tsx +30 -7
  60. package/src/components/chat/ChatSidebar.test.tsx +48 -0
  61. package/src/components/chat/ChatSidebar.tsx +11 -0
  62. package/src/components/chat/ChatWelcome.test.tsx +30 -0
  63. package/src/components/chat/ChatWelcome.tsx +50 -1
  64. package/src/components/chat/adapters/chat-message-part.adapter.ts +5 -0
  65. package/src/components/chat/adapters/chat-message-tool-agent-id.test.ts +102 -0
  66. package/src/components/chat/adapters/chat-message-tool-agent-id.ts +47 -0
  67. package/src/components/chat/adapters/chat-message.adapter.test.ts +6 -0
  68. package/src/components/chat/adapters/chat-message.session-request-tool-card.ts +24 -15
  69. package/src/components/chat/chat-child-session-panel.tsx +115 -49
  70. package/src/components/chat/chat-page-runtime.test.ts +30 -0
  71. package/src/components/chat/chat-page-shell.tsx +8 -17
  72. package/src/components/chat/chat-session-route.ts +0 -14
  73. package/src/components/chat/chat-sidebar-session-item.tsx +20 -1
  74. package/src/components/chat/containers/chat-input-bar.container.tsx +2 -1
  75. package/src/components/chat/containers/chat-message-list.container.tsx +7 -0
  76. package/src/components/chat/ncp/NcpChatPage.tsx +77 -158
  77. package/src/components/chat/ncp/README.md +3 -0
  78. package/src/components/chat/ncp/ncp-chat-page-data.test.ts +2 -0
  79. package/src/components/chat/ncp/ncp-chat-thread.manager.ts +66 -10
  80. package/src/components/chat/ncp/ncp-session-adapter.test.ts +2 -0
  81. package/src/components/chat/ncp/ncp-session-adapter.ts +1 -0
  82. package/src/components/chat/ncp/page/ncp-chat-derived-state.ts +128 -0
  83. package/src/components/chat/ncp/session-conversation/use-ncp-child-session-tabs-view.ts +52 -0
  84. package/src/components/chat/ncp/session-conversation/use-ncp-session-conversation.test.tsx +101 -0
  85. package/src/components/chat/ncp/session-conversation/use-ncp-session-conversation.ts +72 -0
  86. package/src/components/chat/presenter/chat-presenter-context.tsx +1 -0
  87. package/src/components/chat/stores/chat-thread.store.ts +20 -6
  88. package/src/components/common/AgentAvatar.tsx +63 -0
  89. package/src/components/common/agent-identity/agent-identity-avatar.tsx +27 -0
  90. package/src/components/common/agent-identity/index.ts +3 -0
  91. package/src/components/common/agent-identity/use-agent-identity.ts +50 -0
  92. package/src/components/config/RuntimeConfig.tsx +14 -101
  93. package/src/components/config/runtime-config-agent.utils.ts +95 -0
  94. package/src/components/layout/AppLayout.tsx +3 -1
  95. package/src/components/layout/Sidebar.tsx +6 -1
  96. package/src/components/layout/app-layout.test.tsx +30 -0
  97. package/src/components/ui/tabs.tsx +2 -0
  98. package/src/hooks/README.md +3 -0
  99. package/src/hooks/agents/useAgents.ts +44 -0
  100. package/src/lib/i18n.agents.ts +66 -0
  101. package/src/lib/i18n.chat.ts +5 -0
  102. package/src/lib/i18n.ts +4 -6
  103. package/src/lib/ui-document-title.ts +1 -0
  104. package/dist/assets/ChatPage-Z9tRzm_n.js +0 -43
  105. package/dist/assets/DocBrowser-B9OaZjmg.js +0 -1
  106. package/dist/assets/MarketplacePage-D6rVQEQR.js +0 -1
  107. package/dist/assets/RuntimeConfig-v7a7Fe3x.js +0 -1
  108. package/dist/assets/chat-session-display-D0WpnuRZ.js +0 -1
  109. package/dist/assets/i18n-CDHMXlRZ.js +0 -1
  110. package/dist/assets/index-BuwbBgmT.js +0 -6
  111. package/dist/assets/index-bZ8cqQIS.css +0 -1
  112. package/dist/assets/loader-circle-Cs8XVFTw.js +0 -1
  113. package/dist/assets/plus-PHf8q-Ct.js +0 -1
  114. package/dist/assets/search-C91yH_6y.js +0 -1
  115. package/dist/assets/skeleton-Dzg-HOiN.js +0 -1
  116. package/dist/assets/x-D7Q1yqSF.js +0 -1
  117. /package/src/lib/{i18n → i18n-runtime}/i18n-language-owner.ts +0 -0
  118. /package/src/lib/{i18n → i18n-runtime}/i18n.path-picker.ts +0 -0
@@ -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
  }));
@@ -71,6 +72,14 @@ vi.mock('@/components/common/StatusBadge', () => ({
71
72
  StatusBadge: () => <div data-testid="status-badge" />
72
73
  }));
73
74
 
75
+ vi.mock('@/hooks/agents/useAgents', () => ({
76
+ useAgents: () => ({
77
+ data: {
78
+ agents: mocks.agents
79
+ }
80
+ })
81
+ }));
82
+
74
83
  vi.mock('@/components/providers/I18nProvider', () => ({
75
84
  useI18n: () => ({
76
85
  language: 'en',
@@ -98,6 +107,7 @@ describe('ChatSidebar', () => {
98
107
  mocks.docOpen.mockReset();
99
108
  mocks.updateNcpSession.mockReset();
100
109
  mocks.updateNcpSession.mockResolvedValue({});
110
+ mocks.agents = [];
101
111
  mocks.sessionItems = [];
102
112
  mocks.isLoading = false;
103
113
 
@@ -246,6 +256,44 @@ describe('ChatSidebar', () => {
246
256
  expect(screen.queryByText('Native')).toBeNull();
247
257
  });
248
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
+
249
297
  it('edits the session label inline and saves through the ncp session api by default', async () => {
250
298
  mocks.sessionItems = [
251
299
  createSessionItem({
@@ -13,6 +13,7 @@ import { useNcpSessionListView, type NcpSessionListItemView } from '@/components
13
13
  import { usePresenter } from '@/components/chat/presenter/chat-presenter-context';
14
14
  import { useChatInputStore } from '@/components/chat/stores/chat-input.store';
15
15
  import { useChatSessionListStore } from '@/components/chat/stores/chat-session-list.store';
16
+ import { useAgents } from '@/hooks/agents/useAgents';
16
17
  import { cn } from '@/lib/utils';
17
18
  import { LANGUAGE_OPTIONS, t, type I18nLanguage } from '@/lib/i18n';
18
19
  import { THEME_OPTIONS, type UiTheme } from '@/lib/theme';
@@ -23,6 +24,7 @@ import { SidebarActionItem, SidebarNavLinkItem, SidebarSelectItem } from '@/comp
23
24
  import { useUiStore } from '@/stores/ui.store';
24
25
  import {
25
26
  AlarmClock,
27
+ Bot,
26
28
  BookOpen,
27
29
  BrainCircuit,
28
30
  ChevronDown,
@@ -93,6 +95,7 @@ function resolveSessionTypeStatusText(option: {
93
95
  const navItems = [
94
96
  { target: '/cron', label: () => t('chatSidebarScheduledTasks'), icon: AlarmClock },
95
97
  { target: '/skills', label: () => t('chatSidebarSkills'), icon: BrainCircuit },
98
+ { target: '/agents', label: () => t('agentsPageTitle'), icon: Bot },
96
99
  ];
97
100
 
98
101
  export function ChatSidebar() {
@@ -105,12 +108,17 @@ export function ChatSidebar() {
105
108
  const inputSnapshot = useChatInputStore((state) => state.snapshot);
106
109
  const listSnapshot = useChatSessionListStore((state) => state.snapshot);
107
110
  const connectionStatus = useUiStore((state) => state.connectionStatus);
111
+ const agentsQuery = useAgents();
108
112
  const { isLoading, items } = useNcpSessionListView();
109
113
  const { language, setLanguage } = useI18n();
110
114
  const { theme, setTheme } = useTheme();
111
115
  const updateSessionLabel = useChatSessionLabel();
112
116
  const currentThemeLabel = t(THEME_OPTIONS.find((o) => o.value === theme)?.labelKey ?? 'themeWarm');
113
117
  const currentLanguageLabel = LANGUAGE_OPTIONS.find((o) => o.value === language)?.label ?? language;
118
+ const agentsById = useMemo(
119
+ () => new Map((agentsQuery.data?.agents ?? []).map((agent) => [agent.id, agent])),
120
+ [agentsQuery.data?.agents]
121
+ );
114
122
 
115
123
  const groups = useMemo(() => groupSessionsByDate(items), [items]);
116
124
  const defaultSessionType = inputSnapshot.defaultSessionType || 'native';
@@ -293,6 +301,9 @@ export function ChatSidebar() {
293
301
  runStatus={runStatus}
294
302
  context={context}
295
303
  title={sessionTitle(session)}
304
+ agentId={session.agentId ?? null}
305
+ agentLabel={session.agentId ? (agentsById.get(session.agentId)?.displayName ?? session.agentId) : null}
306
+ agentAvatarUrl={session.agentId ? (agentsById.get(session.agentId)?.avatarUrl ?? null) : null}
296
307
  isEditing={isEditing}
297
308
  draftLabel={draftLabel}
298
309
  isSaving={isSaving}
@@ -0,0 +1,30 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import { fireEvent } from '@testing-library/react';
3
+ import { describe, expect, it, vi } from 'vitest';
4
+ import { ChatWelcome } from '@/components/chat/ChatWelcome';
5
+
6
+ describe('ChatWelcome', () => {
7
+ it('renders a lightweight draft agent select and allows switching', () => {
8
+ const onCreateSession = vi.fn();
9
+ const onSelectAgent = vi.fn();
10
+
11
+ render(
12
+ <ChatWelcome
13
+ onCreateSession={onCreateSession}
14
+ agents={[
15
+ { id: 'main', displayName: 'Main' },
16
+ { id: 'engineer', displayName: 'Engineer' }
17
+ ]}
18
+ selectedAgentId="main"
19
+ onSelectAgent={onSelectAgent}
20
+ />
21
+ );
22
+
23
+ const trigger = screen.getByRole('combobox', { name: 'Draft agent' });
24
+ fireEvent.keyDown(trigger, { key: 'ArrowDown' });
25
+ fireEvent.click(screen.getByText('Engineer'));
26
+
27
+ expect(onSelectAgent).toHaveBeenCalledWith('engineer');
28
+ expect(screen.queryByText('Current Agent:')).toBeNull();
29
+ });
30
+ });
@@ -1,8 +1,14 @@
1
+ import type { AgentProfileView } from '@/api/types';
2
+ import { AgentAvatar } from '@/components/common/AgentAvatar';
3
+ import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select';
1
4
  import { t } from '@/lib/i18n';
2
5
  import { Bot, BrainCircuit, AlarmClock, MessageCircle } from 'lucide-react';
3
6
 
4
7
  type ChatWelcomeProps = {
5
8
  onCreateSession: () => void;
9
+ agents: AgentProfileView[];
10
+ selectedAgentId: string;
11
+ onSelectAgent: (agentId: string) => void;
6
12
  };
7
13
 
8
14
  const capabilities = [
@@ -23,7 +29,9 @@ const capabilities = [
23
29
  },
24
30
  ];
25
31
 
26
- export function ChatWelcome({ onCreateSession }: ChatWelcomeProps) {
32
+ export function ChatWelcome({ onCreateSession, agents, selectedAgentId, onSelectAgent }: ChatWelcomeProps) {
33
+ const selectedAgent = agents.find((agent) => agent.id === selectedAgentId) ?? null;
34
+
27
35
  return (
28
36
  <div className="h-full flex items-center justify-center p-8">
29
37
  <div className="max-w-lg w-full text-center">
@@ -36,6 +44,47 @@ export function ChatWelcome({ onCreateSession }: ChatWelcomeProps) {
36
44
  <h2 className="text-xl font-semibold text-gray-900 mb-2">{t('chatWelcomeTitle')}</h2>
37
45
  <p className="text-sm text-gray-500 mb-8">{t('chatWelcomeSubtitle')}</p>
38
46
 
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 ? (
59
+ <AgentAvatar
60
+ agentId={selectedAgent.id}
61
+ displayName={selectedAgent.displayName}
62
+ avatarUrl={selectedAgent.avatarUrl}
63
+ className="h-7 w-7 shrink-0"
64
+ />
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>
86
+ </div>
87
+
39
88
  {/* Capability cards */}
40
89
  <div className="grid grid-cols-3 gap-3">
41
90
  {capabilities.map((cap) => {
@@ -8,6 +8,7 @@ import {
8
8
  buildRenderableText,
9
9
  buildTextPart,
10
10
  } from "@/components/chat/adapters/chat-message-inline-content.adapter";
11
+ import { resolveToolInvocationAgentId } from "@/components/chat/adapters/chat-message-tool-agent-id";
11
12
  import { buildFileOperationCardData } from "@/components/chat/adapters/file-operation/card";
12
13
  import { buildSessionRequestToolCard } from "@/components/chat/adapters/chat-message.session-request-tool-card";
13
14
  import type {
@@ -77,6 +78,7 @@ export type ChatMessagePartSource =
77
78
  type ToolCardViewSource = ToolCard & {
78
79
  statusTone: ChatToolPartViewModel["statusTone"];
79
80
  statusLabel: string;
81
+ agentId?: string;
80
82
  action?: ChatToolPartViewModel["action"];
81
83
  fileOperation?: ChatToolPartViewModel["fileOperation"];
82
84
  outputData?: unknown;
@@ -180,6 +182,7 @@ function buildToolCard(
180
182
  return {
181
183
  kind: toolCard.kind,
182
184
  toolName: toolCard.name,
185
+ ...('agentId' in toolCard && toolCard.agentId ? { agentId: toolCard.agentId } : {}),
183
186
  summary: toolCard.detail,
184
187
  inputLabel: texts.toolInputLabel,
185
188
  input:
@@ -381,9 +384,11 @@ function buildToolInvocationPart(
381
384
  const shouldShowRawResult =
382
385
  (!fileOperationCardData?.fileOperation || Boolean(invocation.error)) &&
383
386
  !shouldHideStructuredTerminalJson;
387
+ const agentId = resolveToolInvocationAgentId(invocation);
384
388
  const card: ToolCardViewSource = {
385
389
  kind: statusView.kind,
386
390
  name: invocation.toolName,
391
+ ...(agentId ? { agentId } : {}),
387
392
  detail,
388
393
  ...(input ? { input } : {}),
389
394
  text: shouldShowRawResult && rawResult ? rawResult : undefined,
@@ -0,0 +1,102 @@
1
+ import { ToolInvocationStatus } from "@nextclaw/agent-chat";
2
+ import { adaptChatMessages, type ChatMessageSource } from "@/components/chat/adapters/chat-message.adapter";
3
+
4
+ const defaultTexts = {
5
+ roleLabels: {
6
+ user: "You",
7
+ assistant: "Assistant",
8
+ tool: "Tool",
9
+ system: "System",
10
+ fallback: "Message",
11
+ },
12
+ reasoningLabel: "Reasoning",
13
+ toolCallLabel: "Tool Call",
14
+ toolResultLabel: "Tool Result",
15
+ toolInputLabel: "Input",
16
+ toolNoOutputLabel: "No output",
17
+ toolOutputLabel: "Output",
18
+ toolStatusPreparingLabel: "Preparing",
19
+ toolStatusRunningLabel: "Running",
20
+ toolStatusCompletedLabel: "Completed",
21
+ toolStatusFailedLabel: "Failed",
22
+ toolStatusCancelledLabel: "Cancelled",
23
+ imageAttachmentLabel: "Image attachment",
24
+ fileAttachmentLabel: "File attachment",
25
+ unknownPartLabel: "Unknown Part",
26
+ };
27
+
28
+ function adapt(uiMessages: ChatMessageSource[]) {
29
+ return adaptChatMessages({
30
+ uiMessages,
31
+ formatTimestamp: (value) => `formatted:${value}`,
32
+ texts: defaultTexts,
33
+ });
34
+ }
35
+
36
+ it("exposes agentId on spawn call cards when the invocation args include it", () => {
37
+ const adapted = adapt([
38
+ {
39
+ id: "assistant-spawn-call",
40
+ role: "assistant",
41
+ parts: [
42
+ {
43
+ type: "tool-invocation",
44
+ toolInvocation: {
45
+ status: ToolInvocationStatus.PARTIAL_CALL,
46
+ toolCallId: "spawn-call-args-1",
47
+ toolName: "spawn",
48
+ args: '{"agentId":"planner-agent","label":"Planner","task":"Plan the rollout"}',
49
+ result: {
50
+ kind: "nextclaw.session_request",
51
+ requestId: "request-3",
52
+ sessionId: "child-session-3",
53
+ isChildSession: true,
54
+ title: "Planner",
55
+ task: "Plan the rollout",
56
+ status: "running",
57
+ },
58
+ },
59
+ },
60
+ ],
61
+ },
62
+ ] as unknown as ChatMessageSource[]);
63
+
64
+ expect(adapted[0]?.parts[0]).toMatchObject({
65
+ type: "tool-card",
66
+ card: {
67
+ toolName: "spawn",
68
+ agentId: "planner-agent",
69
+ statusTone: "running",
70
+ },
71
+ });
72
+ });
73
+
74
+ it("exposes agentId on running tool call cards even before a session-request result exists", () => {
75
+ const adapted = adapt([
76
+ {
77
+ id: "assistant-spawn-call-running",
78
+ role: "assistant",
79
+ parts: [
80
+ {
81
+ type: "tool-invocation",
82
+ toolInvocation: {
83
+ status: ToolInvocationStatus.PARTIAL_CALL,
84
+ toolCallId: "spawn-call-running-1",
85
+ toolName: "spawn",
86
+ args: '{"agentId":"planner-agent","task":"Plan the rollout"}',
87
+ },
88
+ },
89
+ ],
90
+ },
91
+ ] as unknown as ChatMessageSource[]);
92
+
93
+ expect(adapted[0]?.parts[0]).toMatchObject({
94
+ type: "tool-card",
95
+ card: {
96
+ toolName: "spawn",
97
+ agentId: "planner-agent",
98
+ statusTone: "running",
99
+ titleLabel: "Tool Call",
100
+ },
101
+ });
102
+ });
@@ -0,0 +1,47 @@
1
+ type ToolInvocationAgentIdSource = {
2
+ args?: unknown;
3
+ parsedArgs?: unknown;
4
+ result?: unknown;
5
+ };
6
+
7
+ function isRecord(value: unknown): value is Record<string, unknown> {
8
+ return typeof value === "object" && value !== null;
9
+ }
10
+
11
+ function parseStructuredValue(value: unknown): unknown {
12
+ if (typeof value !== "string") {
13
+ return value;
14
+ }
15
+ const trimmed = value.trim();
16
+ if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) {
17
+ return value;
18
+ }
19
+ try {
20
+ return JSON.parse(trimmed) as unknown;
21
+ } catch {
22
+ return value;
23
+ }
24
+ }
25
+
26
+ function readOptionalString(value: unknown): string | null {
27
+ if (typeof value !== "string") {
28
+ return null;
29
+ }
30
+ const trimmed = value.trim();
31
+ return trimmed.length > 0 ? trimmed : null;
32
+ }
33
+
34
+ function readAgentIdFromValue(value: unknown): string | null {
35
+ const parsedValue = parseStructuredValue(value);
36
+ return isRecord(parsedValue) ? readOptionalString(parsedValue.agentId) : null;
37
+ }
38
+
39
+ export function resolveToolInvocationAgentId(
40
+ source: ToolInvocationAgentIdSource,
41
+ ): string | null {
42
+ return (
43
+ readAgentIdFromValue(source.parsedArgs) ??
44
+ readAgentIdFromValue(source.args) ??
45
+ readAgentIdFromValue(source.result)
46
+ );
47
+ }
@@ -240,6 +240,7 @@ it("renders session request tool cards from structured child-session status upda
240
240
  kind: "nextclaw.session_request",
241
241
  requestId: "request-1",
242
242
  sessionId: "child-session-1",
243
+ agentId: "verifier-agent",
243
244
  isChildSession: true,
244
245
  title: "Verifier",
245
246
  task: "Verify 1+1=2",
@@ -257,6 +258,7 @@ it("renders session request tool cards from structured child-session status upda
257
258
  type: "tool-card",
258
259
  card: {
259
260
  toolName: "spawn",
261
+ agentId: "verifier-agent",
260
262
  summary: "title: Verifier · session: child-session-1 · task: Verify 1+1=2",
261
263
  output: [
262
264
  "Request ID: request-1",
@@ -280,6 +282,7 @@ it("renders session request tool cards from structured child-session status upda
280
282
  kind: "open-session",
281
283
  sessionId: "child-session-1",
282
284
  sessionKind: "child",
285
+ agentId: "verifier-agent",
283
286
  label: "Verifier",
284
287
  parentSessionId: "parent-session-1",
285
288
  },
@@ -304,6 +307,7 @@ it("renders regular session request tool cards with session navigation instead o
304
307
  kind: "nextclaw.session_request",
305
308
  requestId: "request-2",
306
309
  sessionId: "session-2",
310
+ agentId: "research-agent",
307
311
  isChildSession: false,
308
312
  title: "Research thread",
309
313
  task: "Summarize the latest findings",
@@ -320,6 +324,7 @@ it("renders regular session request tool cards with session navigation instead o
320
324
  type: "tool-card",
321
325
  card: {
322
326
  toolName: "sessions_request",
327
+ agentId: "research-agent",
323
328
  summary: "title: Research thread · session: session-2 · task: Summarize the latest findings",
324
329
  output: [
325
330
  "Request ID: request-2",
@@ -341,6 +346,7 @@ it("renders regular session request tool cards with session navigation instead o
341
346
  kind: "open-session",
342
347
  sessionId: "session-2",
343
348
  sessionKind: "session",
349
+ agentId: "research-agent",
344
350
  label: "Research thread",
345
351
  },
346
352
  },
@@ -3,6 +3,7 @@ import {
3
3
  summarizeToolArgs,
4
4
  type ToolCard,
5
5
  } from "@/lib/chat-message";
6
+ import { resolveToolInvocationAgentId } from "@/components/chat/adapters/chat-message-tool-agent-id";
6
7
  import type { ChatToolPartViewModel } from "@nextclaw/agent-chat-ui";
7
8
 
8
9
  type ToolCardViewSource = ToolCard & {
@@ -28,6 +29,7 @@ type SessionRequestResult = {
28
29
  kind: string;
29
30
  requestId?: string;
30
31
  sessionId?: string;
32
+ agentId?: string;
31
33
  isChildSession?: boolean;
32
34
  title?: string;
33
35
  task?: string;
@@ -118,28 +120,32 @@ export function buildSessionRequestToolCard(params: {
118
120
  invocation: SessionRequestInvocation;
119
121
  texts: SessionRequestToolCardTexts;
120
122
  }): ToolCardViewSource | null {
121
- if (
122
- params.invocation.toolName !== "spawn" &&
123
- params.invocation.toolName !== "sessions_request"
124
- ) {
123
+ const { invocation, texts } = params;
124
+ const { toolName, toolCallId, args, result } = invocation;
125
+
126
+ if (toolName !== "spawn" && toolName !== "sessions_request") {
125
127
  return null;
126
128
  }
127
129
 
128
- const sessionRequest = readSessionRequestResult(params.invocation.result);
130
+ const sessionRequest = readSessionRequestResult(result);
129
131
  if (!sessionRequest) {
130
132
  return null;
131
133
  }
132
134
 
133
135
  const normalizedStatus = readOptionalString(sessionRequest.status)?.toLowerCase();
134
- const detail = buildSessionRequestDetail(sessionRequest, params.invocation.args);
136
+ const detail = buildSessionRequestDetail(sessionRequest, args);
135
137
  const output = buildSessionRequestOutput(sessionRequest);
136
138
  const targetSessionId = readOptionalString(sessionRequest.sessionId);
139
+ const agentId = resolveToolInvocationAgentId({ args, result: sessionRequest });
137
140
  const action =
138
141
  targetSessionId
139
142
  ? {
140
143
  kind: "open-session" as const,
141
144
  sessionId: targetSessionId,
142
145
  sessionKind: sessionRequest.isChildSession === true ? ("child" as const) : ("session" as const),
146
+ ...(agentId
147
+ ? { agentId }
148
+ : {}),
143
149
  ...(readOptionalString(sessionRequest.title)
144
150
  ? { label: sessionRequest.title!.trim() }
145
151
  : {}),
@@ -152,13 +158,14 @@ export function buildSessionRequestToolCard(params: {
152
158
  if (normalizedStatus === "failed") {
153
159
  return {
154
160
  kind: "result",
155
- name: params.invocation.toolName,
161
+ name: toolName,
156
162
  detail,
157
163
  text: output,
158
- callId: params.invocation.toolCallId || undefined,
164
+ callId: toolCallId || undefined,
159
165
  hasResult: Boolean(output),
160
166
  statusTone: "error",
161
- statusLabel: params.texts.toolStatusFailedLabel,
167
+ statusLabel: texts.toolStatusFailedLabel,
168
+ ...(agentId ? { agentId } : {}),
162
169
  ...(action ? { action } : {}),
163
170
  };
164
171
  }
@@ -166,26 +173,28 @@ export function buildSessionRequestToolCard(params: {
166
173
  if (normalizedStatus === "completed") {
167
174
  return {
168
175
  kind: "result",
169
- name: params.invocation.toolName,
176
+ name: toolName,
170
177
  detail,
171
178
  text: output,
172
- callId: params.invocation.toolCallId || undefined,
179
+ callId: toolCallId || undefined,
173
180
  hasResult: Boolean(output),
174
181
  statusTone: "success",
175
- statusLabel: params.texts.toolStatusCompletedLabel,
182
+ statusLabel: texts.toolStatusCompletedLabel,
183
+ ...(agentId ? { agentId } : {}),
176
184
  ...(action ? { action } : {}),
177
185
  };
178
186
  }
179
187
 
180
188
  return {
181
189
  kind: "result",
182
- name: params.invocation.toolName,
190
+ name: toolName,
183
191
  detail,
184
192
  text: output,
185
- callId: params.invocation.toolCallId || undefined,
193
+ callId: toolCallId || undefined,
186
194
  hasResult: Boolean(output),
187
195
  statusTone: "running",
188
- statusLabel: params.texts.toolStatusRunningLabel,
196
+ statusLabel: texts.toolStatusRunningLabel,
197
+ ...(agentId ? { agentId } : {}),
189
198
  ...(action ? { action } : {}),
190
199
  };
191
200
  }