@nextclaw/ui 0.11.22 → 0.12.0

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 (122) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/dist/assets/{ChannelsList-Zeys_w43.js → ChannelsList-NKNKsf1J.js} +1 -1
  3. package/dist/assets/ChatPage-p23OnnEI.js +43 -0
  4. package/dist/assets/DocBrowser-C8b2uPgL.js +1 -0
  5. package/dist/assets/{DocBrowser-BmtBLFU0.js → DocBrowser-DxdSujSc.js} +1 -1
  6. package/dist/assets/{DocBrowserContext-YIKkPb76.js → DocBrowserContext-CQ-8jMha.js} +1 -1
  7. package/dist/assets/{LogoBadge-F7ZWdxLT.js → LogoBadge-D-KQIN4U.js} +1 -1
  8. package/dist/assets/{MarketplacePage-Cd4faegU.js → MarketplacePage-CRNvxtvx.js} +2 -2
  9. package/dist/assets/MarketplacePage-GGkEXowp.js +1 -0
  10. package/dist/assets/{McpMarketplacePage-C09Ngs7O.js → McpMarketplacePage-Cu7GmCcc.js} +2 -2
  11. package/dist/assets/{ModelConfig-DJgdcgvQ.js → ModelConfig-CEpx9fro.js} +1 -1
  12. package/dist/assets/{ProvidersList-w0rVFIBf.js → ProvidersList-BWbUb7-2.js} +1 -1
  13. package/dist/assets/{RemoteAccessPage-BJ_ckkOV.js → RemoteAccessPage-NsawrZb0.js} +1 -1
  14. package/dist/assets/RuntimeConfig-BJHBsVTd.js +1 -0
  15. package/dist/assets/{SearchConfig-BT13qpR_.js → SearchConfig-BsaX_WYy.js} +1 -1
  16. package/dist/assets/{SecretsConfig-CvqEVn0B.js → SecretsConfig-CgDZOd3w.js} +1 -1
  17. package/dist/assets/{SessionsConfig-DHHcYznk.js → SessionsConfig-Dd-KM7F7.js} +2 -2
  18. package/dist/assets/{book-open-CXoF5nQC.js → book-open-FnK2xCQd.js} +1 -1
  19. package/dist/assets/chat-session-display-BD_AN71I.js +1 -0
  20. package/dist/assets/{chunk-JZWAC4HX-CvRWvTy5.js → chunk-JZWAC4HX-B5l0hr_u.js} +1 -1
  21. package/dist/assets/{config-DJswxxE8.js → config-JKmXfZ3q.js} +1 -1
  22. package/dist/assets/{createLucideIcon-CjGHOWb6.js → createLucideIcon-o1WWhwhd.js} +1 -1
  23. package/dist/assets/{dist-nqTTbVdA.js → dist-C_moWYv7.js} +1 -1
  24. package/dist/assets/{dist-Cl2QB-2y.js → dist-DazA6Wd_.js} +1 -1
  25. package/dist/assets/{external-link-tIO7zING.js → external-link-BKje3SiD.js} +1 -1
  26. package/dist/assets/{hash-JWUyl1pT.js → hash-DfW4DT8O.js} +1 -1
  27. package/dist/assets/i18n-BK1w-oBy.js +1 -0
  28. package/dist/assets/index-BZaB1TqM.js +6 -0
  29. package/dist/assets/index-DaR9igPC.css +1 -0
  30. package/dist/assets/{label-BIpeNu4r.js → label-BzDWmdOe.js} +1 -1
  31. package/dist/assets/loader-circle-DdZPxBUz.js +1 -0
  32. package/dist/assets/{logos-DThdM9lk.js → logos-CTLlde_T.js} +1 -1
  33. package/dist/assets/{page-layout-D3Xo605Z.js → page-layout-BagR3t59.js} +1 -1
  34. package/dist/assets/plus-DP2PSCPO.js +1 -0
  35. package/dist/assets/{popover-BJRUGA_H.js → popover-5DWhNfd4.js} +1 -1
  36. package/dist/assets/{provider-models-bz5y28rq.js → provider-models-DJ29qHuA.js} +1 -1
  37. package/dist/assets/{react-7ZHqQtEV.js → react-C3yu5yge.js} +1 -1
  38. package/dist/assets/{refresh-ccw-CC6-_QuL.js → refresh-ccw-BAJf-h7w.js} +1 -1
  39. package/dist/assets/{save-DJM5RRWW.js → save-aa6z4GJL.js} +1 -1
  40. package/dist/assets/search-pD6ZwQYF.js +1 -0
  41. package/dist/assets/{security-config-T5zpg16O.js → security-config-DRDxrApx.js} +1 -1
  42. package/dist/assets/{select-DSkTc61S.js → select-BHJPiJWt.js} +1 -1
  43. package/dist/assets/skeleton-D6kCk9Y6.js +1 -0
  44. package/dist/assets/{status-dot-LNBlDu3q.js → status-dot-DUwsTIdv.js} +1 -1
  45. package/dist/assets/{switch-Bo-Y46HZ.js → switch-B6nCfcOB.js} +1 -1
  46. package/dist/assets/{tabs-custom-DXv507_2.js → tabs-custom-B57SMElx.js} +1 -1
  47. package/dist/assets/{trash-2-DFZmW6Gg.js → trash-2-CrjYH5ok.js} +1 -1
  48. package/dist/assets/{useConfirmDialog-Bs5Ll17m.js → useConfirmDialog-DsxnXB1B.js} +1 -1
  49. package/dist/assets/{useMutation-DrZrOgVL.js → useMutation-oTTWXgLG.js} +1 -1
  50. package/dist/assets/x-CTIQHUuD.js +1 -0
  51. package/dist/index.html +18 -18
  52. package/package.json +5 -5
  53. package/src/App.tsx +2 -0
  54. package/src/api/agents.ts +26 -0
  55. package/src/api/types.ts +27 -2
  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 +144 -8
  59. package/src/components/chat/ChatConversationPanel.tsx +136 -77
  60. package/src/components/chat/ChatSidebar.test.tsx +8 -0
  61. package/src/components/chat/ChatSidebar.tsx +11 -0
  62. package/src/components/chat/ChatWelcome.test.tsx +25 -0
  63. package/src/components/chat/ChatWelcome.tsx +47 -1
  64. package/src/components/chat/adapters/chat-message-part.adapter.ts +18 -5
  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 +89 -9
  68. package/src/components/chat/adapters/chat-message.session-request-tool-card.ts +200 -0
  69. package/src/components/chat/chat-child-session-panel.tsx +166 -0
  70. package/src/components/chat/chat-page-runtime.test.ts +1 -0
  71. package/src/components/chat/chat-page-shell.tsx +8 -17
  72. package/src/components/chat/chat-session-display.test.ts +1 -0
  73. package/src/components/chat/chat-session-route.ts +0 -14
  74. package/src/components/chat/chat-sidebar-session-item.tsx +16 -1
  75. package/src/components/chat/containers/chat-input-bar.container.tsx +2 -1
  76. package/src/components/chat/containers/chat-message-list.container.tsx +11 -0
  77. package/src/components/chat/ncp/NcpChatPage.tsx +153 -190
  78. package/src/components/chat/ncp/README.md +3 -0
  79. package/src/components/chat/ncp/ncp-chat-page-data.test.ts +2 -0
  80. package/src/components/chat/ncp/ncp-chat-thread.manager.ts +106 -1
  81. package/src/components/chat/ncp/ncp-session-adapter.test.ts +23 -0
  82. package/src/components/chat/ncp/ncp-session-adapter.ts +32 -0
  83. package/src/components/chat/ncp/page/ncp-chat-derived-state.ts +128 -0
  84. package/src/components/chat/ncp/session-conversation/use-ncp-child-session-tabs-view.ts +52 -0
  85. package/src/components/chat/ncp/session-conversation/use-ncp-session-conversation.test.tsx +101 -0
  86. package/src/components/chat/ncp/session-conversation/use-ncp-session-conversation.ts +72 -0
  87. package/src/components/chat/ncp/use-ncp-session-list-view.ts +10 -1
  88. package/src/components/chat/presenter/chat-presenter-context.tsx +5 -1
  89. package/src/components/chat/stores/chat-thread.store.ts +25 -1
  90. package/src/components/chat/useHydratedNcpAgent.test.tsx +30 -23
  91. package/src/components/common/AgentAvatar.tsx +63 -0
  92. package/src/components/common/agent-identity/agent-identity-avatar.tsx +27 -0
  93. package/src/components/common/agent-identity/index.ts +3 -0
  94. package/src/components/common/agent-identity/use-agent-identity.ts +50 -0
  95. package/src/components/config/RuntimeConfig.tsx +13 -79
  96. package/src/components/config/runtime-config-agent.utils.ts +95 -0
  97. package/src/components/layout/AppLayout.tsx +3 -1
  98. package/src/components/layout/Sidebar.tsx +6 -1
  99. package/src/components/layout/app-layout.test.tsx +30 -0
  100. package/src/components/ui/tabs.tsx +2 -0
  101. package/src/hooks/README.md +3 -0
  102. package/src/hooks/agents/useAgents.ts +44 -0
  103. package/src/lib/i18n.agents.ts +66 -0
  104. package/src/lib/i18n.chat.ts +5 -0
  105. package/src/lib/i18n.ts +4 -4
  106. package/src/lib/ui-document-title.ts +1 -0
  107. package/dist/assets/ChatPage-DWOU_8P6.js +0 -43
  108. package/dist/assets/DocBrowser-B9OaZjmg.js +0 -1
  109. package/dist/assets/MarketplacePage-BfaTTqN6.js +0 -1
  110. package/dist/assets/RuntimeConfig-Cmn2xPQO.js +0 -1
  111. package/dist/assets/chat-session-display-VW6ZMvZP.js +0 -1
  112. package/dist/assets/i18n-CDHMXlRZ.js +0 -1
  113. package/dist/assets/index-BlH4-cBw.css +0 -1
  114. package/dist/assets/index-C6d0xmtm.js +0 -6
  115. package/dist/assets/loader-circle-Cs8XVFTw.js +0 -1
  116. package/dist/assets/plus-PHf8q-Ct.js +0 -1
  117. package/dist/assets/search-C91yH_6y.js +0 -1
  118. package/dist/assets/skeleton-Dzg-HOiN.js +0 -1
  119. package/dist/assets/x-D7Q1yqSF.js +0 -1
  120. package/src/components/chat/adapters/chat-message.subagent-tool-card.ts +0 -154
  121. /package/src/lib/{i18n → i18n-runtime}/i18n-language-owner.ts +0 -0
  122. /package/src/lib/{i18n → i18n-runtime}/i18n.path-picker.ts +0 -0
@@ -20,6 +20,7 @@ describe('adaptNcpSessionSummary', () => {
20
20
  it('maps session metadata into shared session entry fields', () => {
21
21
  const adapted = adaptNcpSessionSummary(
22
22
  createSummary({
23
+ agentId: 'engineer',
23
24
  metadata: {
24
25
  label: 'NCP Planning Thread',
25
26
  model: 'openai/gpt-5',
@@ -32,6 +33,7 @@ describe('adaptNcpSessionSummary', () => {
32
33
 
33
34
  expect(adapted).toMatchObject({
34
35
  key: 'ncp-session-1',
36
+ agentId: 'engineer',
35
37
  label: 'NCP Planning Thread',
36
38
  preferredModel: 'openai/gpt-5',
37
39
  preferredThinking: 'medium',
@@ -39,9 +41,30 @@ describe('adaptNcpSessionSummary', () => {
39
41
  projectName: 'project-alpha',
40
42
  sessionType: 'native',
41
43
  sessionTypeMutable: false,
44
+ isChildSession: false,
42
45
  messageCount: 3
43
46
  });
44
47
  });
48
+
49
+ it('marks child sessions from parent_session_id metadata and keeps the request link', () => {
50
+ const adapted = adaptNcpSessionSummary(
51
+ createSummary({
52
+ metadata: {
53
+ label: 'Verifier',
54
+ session_type: 'native',
55
+ parent_session_id: 'parent-session-1',
56
+ spawned_by_request_id: 'request-1',
57
+ },
58
+ }),
59
+ );
60
+
61
+ expect(adapted).toMatchObject({
62
+ key: 'ncp-session-1',
63
+ isChildSession: true,
64
+ parentSessionId: 'parent-session-1',
65
+ spawnedByRequestId: 'request-1',
66
+ });
67
+ });
45
68
  });
46
69
 
47
70
  describe('adaptNcpMessageToUiMessage', () => {
@@ -88,6 +88,30 @@ function readNcpSessionType(summary: NcpSessionSummaryView): string {
88
88
  return readOptionalString(metadata.session_type) ?? readOptionalString(metadata.sessionType) ?? 'native';
89
89
  }
90
90
 
91
+ function readNcpParentSessionId(summary: NcpSessionSummaryView): string | null {
92
+ const metadata = readMetadata(summary);
93
+ if (!metadata) {
94
+ return null;
95
+ }
96
+ return readOptionalString(metadata.parent_session_id) ?? readOptionalString(metadata.parentSessionId);
97
+ }
98
+
99
+ function readNcpSpawnedByRequestId(summary: NcpSessionSummaryView): string | null {
100
+ const metadata = readMetadata(summary);
101
+ if (!metadata) {
102
+ return null;
103
+ }
104
+ return readOptionalString(metadata.spawned_by_request_id) ?? readOptionalString(metadata.spawnedByRequestId);
105
+ }
106
+
107
+ function readPromotedChildSession(summary: NcpSessionSummaryView): boolean {
108
+ const metadata = readMetadata(summary);
109
+ if (!metadata) {
110
+ return false;
111
+ }
112
+ return metadata.child_session_promoted === true;
113
+ }
114
+
91
115
  function parseSessionContext(sessionKey: string): { channel?: string; type?: string } {
92
116
  if (sessionKey === 'heartbeat') {
93
117
  return { type: 'heartbeat' };
@@ -222,10 +246,14 @@ export function adaptNcpSessionSummary(summary: NcpSessionSummaryView): SessionE
222
246
  const projectRoot = readNcpSessionProjectRoot(summary);
223
247
  const projectName = getSessionProjectName(projectRoot);
224
248
  const context = parseSessionContext(summary.sessionId);
249
+ const parentSessionId = readNcpParentSessionId(summary);
250
+ const spawnedByRequestId = readNcpSpawnedByRequestId(summary);
251
+ const isPromotedChildSession = readPromotedChildSession(summary);
225
252
  return {
226
253
  key: summary.sessionId,
227
254
  createdAt: summary.updatedAt,
228
255
  updatedAt: summary.updatedAt,
256
+ ...(typeof summary.agentId === 'string' && summary.agentId.trim().length > 0 ? { agentId: summary.agentId.trim() } : {}),
229
257
  ...(label ? { label } : {}),
230
258
  ...context,
231
259
  ...(preferredModel ? { preferredModel } : {}),
@@ -234,6 +262,10 @@ export function adaptNcpSessionSummary(summary: NcpSessionSummaryView): SessionE
234
262
  ...(projectName ? { projectName } : {}),
235
263
  sessionType: readNcpSessionType(summary),
236
264
  sessionTypeMutable: false,
265
+ isChildSession: Boolean(parentSessionId),
266
+ ...(isPromotedChildSession ? { isPromotedChildSession } : {}),
267
+ ...(parentSessionId ? { parentSessionId } : {}),
268
+ ...(spawnedByRequestId ? { spawnedByRequestId } : {}),
237
269
  messageCount: summary.messageCount
238
270
  };
239
271
  }
@@ -0,0 +1,128 @@
1
+ import { useEffect, useMemo, type MutableRefObject } from 'react';
2
+ import type {
3
+ AgentProfileView,
4
+ NcpSessionSummaryView,
5
+ SessionEntryView,
6
+ SessionSkillEntryView
7
+ } from '@/api/types';
8
+ import { sessionDisplayName } from '@/components/chat/chat-session-display';
9
+ import { adaptNcpSessionSummary } from '@/components/chat/ncp/ncp-session-adapter';
10
+ import type { NcpChatPresenter } from '@/components/chat/ncp/ncp-chat.presenter';
11
+ import type { UseHydratedNcpAgentResult } from '@nextclaw/ncp-react';
12
+ import type { ChatModelOption } from '@/components/chat/chat-input.types';
13
+ import { resolveSessionTypeLabel } from '@/components/chat/useChatSessionTypeState';
14
+ export function useNcpChatDerivedState(params: {
15
+ selectedSession: SessionEntryView | null;
16
+ selectedAgentId: string;
17
+ availableAgents: AgentProfileView[];
18
+ parentSessionId: string | null;
19
+ sessionSummaries: NcpSessionSummaryView[];
20
+ selectedSessionType: string;
21
+ sessionTypeOptions: Array<{ value: string; label: string }>;
22
+ }) {
23
+ const currentSessionDisplayName = params.selectedSession
24
+ ? sessionDisplayName(params.selectedSession)
25
+ : undefined;
26
+ const currentAgentId = params.selectedSession?.agentId ?? params.selectedAgentId;
27
+ const currentAgent =
28
+ params.availableAgents.find((agent) => agent.id === currentAgentId) ?? null;
29
+ const parentSession = useMemo(() => {
30
+ if (!params.parentSessionId) {
31
+ return null;
32
+ }
33
+ const parentSummary =
34
+ params.sessionSummaries.find(
35
+ (summary) => summary.sessionId === params.parentSessionId,
36
+ ) ?? null;
37
+ return parentSummary ? adaptNcpSessionSummary(parentSummary) : null;
38
+ }, [params.parentSessionId, params.sessionSummaries]);
39
+ const currentSessionTypeLabel =
40
+ params.sessionTypeOptions.find((option) => option.value === params.selectedSessionType)
41
+ ?.label ?? resolveSessionTypeLabel(params.selectedSessionType);
42
+
43
+ return {
44
+ currentSessionDisplayName,
45
+ currentAgentId,
46
+ currentAgent,
47
+ parentSession,
48
+ currentSessionTypeLabel
49
+ };
50
+ }
51
+
52
+ export function useNcpChatSnapshotSync(params: {
53
+ presenter: NcpChatPresenter;
54
+ isProviderStateResolved: boolean;
55
+ defaultSessionType: string;
56
+ canStopCurrentRun: boolean;
57
+ stopDisabledReason: string | null;
58
+ lastSendError: string | null;
59
+ isSending: boolean;
60
+ modelOptions: ChatModelOption[];
61
+ sessionTypeOptions: Array<{ value: string; label: string }>;
62
+ selectedSessionType: string;
63
+ canEditSessionType: boolean;
64
+ sessionTypeUnavailable: boolean;
65
+ skillRecords: SessionSkillEntryView[];
66
+ isSkillsLoading: boolean;
67
+ sessionTypeUnavailableMessage: string | null;
68
+ currentSessionTypeLabel: string;
69
+ sessionKey: string;
70
+ currentAgentId: string;
71
+ currentAgent: AgentProfileView | null;
72
+ availableAgents: AgentProfileView[];
73
+ currentSessionDisplayName?: string;
74
+ effectiveSessionProjectRoot: string | null;
75
+ effectiveSessionProjectName: string | null;
76
+ selectedSession: SessionEntryView | null;
77
+ threadRef: MutableRefObject<HTMLDivElement | null>;
78
+ agent: Pick<UseHydratedNcpAgentResult, 'isHydrating' | 'visibleMessages'>;
79
+ isAwaitingAssistantOutput: boolean;
80
+ parentSession: SessionEntryView | null;
81
+ }) {
82
+ useEffect(() => {
83
+ params.presenter.chatInputManager.syncSnapshot({
84
+ isProviderStateResolved: params.isProviderStateResolved,
85
+ defaultSessionType: params.defaultSessionType,
86
+ canStopGeneration: params.canStopCurrentRun,
87
+ stopDisabledReason: params.stopDisabledReason,
88
+ stopSupported: true,
89
+ stopReason: undefined,
90
+ sendError: params.lastSendError,
91
+ isSending: params.isSending,
92
+ modelOptions: params.modelOptions,
93
+ sessionTypeOptions: params.sessionTypeOptions,
94
+ selectedSessionType: params.selectedSessionType,
95
+ canEditSessionType: params.canEditSessionType,
96
+ sessionTypeUnavailable: params.sessionTypeUnavailable,
97
+ skillRecords: params.skillRecords,
98
+ isSkillsLoading: params.isSkillsLoading,
99
+ });
100
+ params.presenter.chatThreadManager.syncSnapshot({
101
+ isProviderStateResolved: params.isProviderStateResolved,
102
+ modelOptions: params.modelOptions,
103
+ sessionTypeUnavailable: params.sessionTypeUnavailable,
104
+ sessionTypeUnavailableMessage: params.sessionTypeUnavailableMessage,
105
+ sessionTypeLabel: params.currentSessionTypeLabel,
106
+ sessionKey: params.sessionKey,
107
+ agentId: params.currentAgentId,
108
+ agentDisplayName: params.currentAgent?.displayName ?? null,
109
+ agentAvatarUrl: params.currentAgent?.avatarUrl ?? null,
110
+ availableAgents: params.availableAgents,
111
+ sessionDisplayName: params.currentSessionDisplayName,
112
+ sessionProjectRoot: params.effectiveSessionProjectRoot,
113
+ sessionProjectName: params.effectiveSessionProjectName,
114
+ canDeleteSession: Boolean(params.selectedSession),
115
+ threadRef: params.threadRef,
116
+ isHistoryLoading: params.agent.isHydrating,
117
+ messages: params.agent.visibleMessages,
118
+ isSending: params.isSending,
119
+ isAwaitingAssistantOutput: params.isAwaitingAssistantOutput,
120
+ parentSessionKey: params.parentSession?.key ?? null,
121
+ parentSessionLabel: params.parentSession
122
+ ? sessionDisplayName(params.parentSession)
123
+ : null,
124
+ });
125
+ }, [
126
+ params
127
+ ]);
128
+ }
@@ -0,0 +1,52 @@
1
+ import { useMemo } from 'react';
2
+ import type { SessionEntryView } from '@/api/types';
3
+ import { sessionDisplayName } from '@/components/chat/chat-session-display';
4
+ import { adaptNcpSessionSummaries } from '@/components/chat/ncp/ncp-session-adapter';
5
+ import type { ChatChildSessionTab } from '@/components/chat/stores/chat-thread.store';
6
+ import { useNcpSessions } from '@/hooks/useConfig';
7
+
8
+ export type ResolvedChildSessionTab = {
9
+ sessionKey: string;
10
+ parentSessionKey: string | null;
11
+ title: string;
12
+ agentId: string | null;
13
+ };
14
+
15
+ function resolveChildSessionTitle(
16
+ tab: ChatChildSessionTab,
17
+ session: SessionEntryView | null,
18
+ ): string {
19
+ if (tab.label?.trim()) {
20
+ return tab.label.trim();
21
+ }
22
+ if (session) {
23
+ return sessionDisplayName(session);
24
+ }
25
+ return tab.sessionKey;
26
+ }
27
+
28
+ export function useNcpChildSessionTabsView(
29
+ tabs: readonly ChatChildSessionTab[],
30
+ ): ResolvedChildSessionTab[] {
31
+ const sessionsQuery = useNcpSessions({ limit: 200 });
32
+
33
+ const sessionByKey = useMemo(() => {
34
+ const sessions = adaptNcpSessionSummaries(sessionsQuery.data?.sessions ?? []);
35
+ return new Map(sessions.map((session) => [session.key, session]));
36
+ }, [sessionsQuery.data?.sessions]);
37
+
38
+ return useMemo(
39
+ () =>
40
+ tabs.map((tab) => {
41
+ const session = sessionByKey.get(tab.sessionKey) ?? null;
42
+ const agentId = tab.agentId?.trim() || session?.agentId || null;
43
+ return {
44
+ sessionKey: tab.sessionKey,
45
+ parentSessionKey: tab.parentSessionKey,
46
+ title: resolveChildSessionTitle(tab, session),
47
+ agentId,
48
+ };
49
+ }),
50
+ [sessionByKey, tabs],
51
+ );
52
+ }
@@ -0,0 +1,101 @@
1
+ import { renderHook } from "@testing-library/react";
2
+ import { beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { fetchNcpSessionConversationSeed, useNcpSessionConversation } from "./use-ncp-session-conversation";
4
+
5
+ const mocks = vi.hoisted(() => ({
6
+ fetchNcpSessionMessages: vi.fn(),
7
+ hydratedCalls: [] as Array<{ client: unknown }>,
8
+ useHydratedNcpAgent: vi.fn(() => ({
9
+ snapshot: {
10
+ messages: [],
11
+ streamingMessage: null,
12
+ activeRun: null,
13
+ error: null,
14
+ },
15
+ visibleMessages: [],
16
+ activeRunId: null,
17
+ isRunning: false,
18
+ isSending: false,
19
+ send: vi.fn(),
20
+ abort: vi.fn(),
21
+ streamRun: vi.fn(),
22
+ isHydrating: false,
23
+ hydrateError: null,
24
+ })),
25
+ clientInstances: [] as unknown[],
26
+ }));
27
+
28
+ vi.mock("@/api/ncp-session", () => ({
29
+ fetchNcpSessionMessages: mocks.fetchNcpSessionMessages,
30
+ }));
31
+
32
+ vi.mock("@nextclaw/ncp-react", () => ({
33
+ useHydratedNcpAgent: vi.fn((params: { client: unknown }) => {
34
+ mocks.hydratedCalls.push(params);
35
+ return mocks.useHydratedNcpAgent();
36
+ }),
37
+ }));
38
+
39
+ vi.mock("@nextclaw/ncp-http-agent-client", () => ({
40
+ NcpHttpAgentClientEndpoint: vi.fn().mockImplementation(function MockClient(this: object) {
41
+ mocks.clientInstances.push(this);
42
+ }),
43
+ }));
44
+
45
+ describe("useNcpSessionConversation", () => {
46
+ beforeEach(() => {
47
+ mocks.fetchNcpSessionMessages.mockReset();
48
+ mocks.useHydratedNcpAgent.mockClear();
49
+ mocks.hydratedCalls.length = 0;
50
+ mocks.clientInstances.length = 0;
51
+ });
52
+
53
+ it("hydrates seed from the shared session messages endpoint payload", async () => {
54
+ mocks.fetchNcpSessionMessages.mockResolvedValue({
55
+ sessionId: "session-1",
56
+ status: "running",
57
+ total: 1,
58
+ messages: [{ id: "msg-1" }],
59
+ });
60
+
61
+ const result = await fetchNcpSessionConversationSeed(
62
+ "session-1",
63
+ new AbortController().signal,
64
+ 300,
65
+ );
66
+
67
+ expect(mocks.fetchNcpSessionMessages).toHaveBeenCalledWith("session-1", 300);
68
+ expect(result).toEqual({
69
+ messages: [{ id: "msg-1" }],
70
+ status: "running",
71
+ });
72
+ });
73
+
74
+ it("treats a missing session as an empty idle draft seed", async () => {
75
+ mocks.fetchNcpSessionMessages.mockRejectedValue(
76
+ new Error("ncp session not found: draft-session"),
77
+ );
78
+
79
+ const result = await fetchNcpSessionConversationSeed(
80
+ "draft-session",
81
+ new AbortController().signal,
82
+ );
83
+
84
+ expect(result).toEqual({
85
+ messages: [],
86
+ status: "idle",
87
+ });
88
+ });
89
+
90
+ it("creates an isolated endpoint instance per viewer", () => {
91
+ renderHook(() => useNcpSessionConversation("session-a"));
92
+ renderHook(() => useNcpSessionConversation("session-b"));
93
+
94
+ expect(mocks.useHydratedNcpAgent).toHaveBeenCalledTimes(2);
95
+ expect(mocks.clientInstances).toHaveLength(2);
96
+ expect(mocks.hydratedCalls).toHaveLength(2);
97
+ expect(mocks.clientInstances[0]).not.toBe(mocks.clientInstances[1]);
98
+ expect(mocks.hydratedCalls[0]?.client).toBe(mocks.clientInstances[0]);
99
+ expect(mocks.hydratedCalls[1]?.client).toBe(mocks.clientInstances[1]);
100
+ });
101
+ });
@@ -0,0 +1,72 @@
1
+ import { useCallback, useState } from "react";
2
+ import { NcpHttpAgentClientEndpoint } from "@nextclaw/ncp-http-agent-client";
3
+ import { useHydratedNcpAgent, type NcpConversationSeed } from "@nextclaw/ncp-react";
4
+ import { API_BASE } from "@/api/api-base";
5
+ import { fetchNcpSessionMessages } from "@/api/ncp-session";
6
+ import { createNcpAppClientFetch } from "@/components/chat/ncp/ncp-app-client-fetch";
7
+
8
+ const DEFAULT_MESSAGE_LIMIT = 300;
9
+
10
+ type UseNcpSessionConversationOptions = {
11
+ messageLimit?: number;
12
+ };
13
+
14
+ function isMissingNcpSessionError(error: unknown): boolean {
15
+ if (!(error instanceof Error)) {
16
+ return false;
17
+ }
18
+ return error.message.includes("ncp session not found:");
19
+ }
20
+
21
+ export function createNcpSessionConversationClient(): NcpHttpAgentClientEndpoint {
22
+ return new NcpHttpAgentClientEndpoint({
23
+ baseUrl: API_BASE,
24
+ basePath: "/api/ncp/agent",
25
+ fetchImpl: createNcpAppClientFetch(),
26
+ });
27
+ }
28
+
29
+ export async function fetchNcpSessionConversationSeed(
30
+ sessionId: string,
31
+ signal: AbortSignal,
32
+ messageLimit = DEFAULT_MESSAGE_LIMIT,
33
+ ): Promise<NcpConversationSeed> {
34
+ signal.throwIfAborted();
35
+
36
+ try {
37
+ const response = await fetchNcpSessionMessages(sessionId, messageLimit);
38
+ signal.throwIfAborted();
39
+ return {
40
+ messages: response.messages,
41
+ status: response.status ?? "idle",
42
+ };
43
+ } catch (error) {
44
+ signal.throwIfAborted();
45
+ if (!isMissingNcpSessionError(error)) {
46
+ throw error;
47
+ }
48
+ return {
49
+ messages: [],
50
+ status: "idle",
51
+ };
52
+ }
53
+ }
54
+
55
+ export function useNcpSessionConversation(
56
+ sessionId: string,
57
+ options: UseNcpSessionConversationOptions = {},
58
+ ) {
59
+ const [client] = useState(() => createNcpSessionConversationClient());
60
+ const messageLimit = options.messageLimit ?? DEFAULT_MESSAGE_LIMIT;
61
+ const loadSeed = useCallback(
62
+ (targetSessionId: string, signal: AbortSignal) =>
63
+ fetchNcpSessionConversationSeed(targetSessionId, signal, messageLimit),
64
+ [messageLimit],
65
+ );
66
+
67
+ return useHydratedNcpAgent({
68
+ sessionId,
69
+ client,
70
+ loadSeed,
71
+ });
72
+ }
@@ -15,13 +15,22 @@ function filterSessionsByQuery(sessions: readonly SessionEntryView[], query: str
15
15
  return sessions.filter((session) => sessionMatchesQuery(session, query));
16
16
  }
17
17
 
18
+ function shouldShowSessionInSidebar(session: SessionEntryView): boolean {
19
+ if (!session.isChildSession) {
20
+ return true;
21
+ }
22
+ return session.isPromotedChildSession === true;
23
+ }
24
+
18
25
  export function useNcpSessionListView(params: { limit?: number } = {}) {
19
26
  const query = useChatSessionListStore((state) => state.snapshot.query);
20
27
  const sessionsQuery = useNcpSessions({ limit: params.limit ?? 200 });
21
28
 
22
29
  const items = useMemo<NcpSessionListItemView[]>(() => {
23
30
  const summaries = sessionsQuery.data?.sessions ?? [];
24
- const sessions = adaptNcpSessionSummaries(summaries);
31
+ const sessions = adaptNcpSessionSummaries(summaries).filter(
32
+ shouldShowSessionInSidebar,
33
+ );
25
34
  const filteredSessions = filterSessionsByQuery(sessions, query);
26
35
  const summaryBySessionId = new Map(summaries.map((summary) => [summary.sessionId, summary]));
27
36
 
@@ -1,4 +1,4 @@
1
- import type { ChatComposerNode } from '@nextclaw/agent-chat-ui';
1
+ import type { ChatComposerNode, ChatToolActionViewModel } from '@nextclaw/agent-chat-ui';
2
2
  import type { NcpDraftAttachment } from '@nextclaw/ncp-react';
3
3
  import { createContext, useContext } from 'react';
4
4
  import type { ReactNode } from 'react';
@@ -37,6 +37,10 @@ export type ChatThreadManagerLike = {
37
37
  deleteSession: () => void;
38
38
  createSession: () => void;
39
39
  goToProviders: () => void;
40
+ openSessionFromToolAction: (action: ChatToolActionViewModel) => void;
41
+ selectChildSessionDetail: (sessionKey: string) => void;
42
+ closeChildSessionDetail: () => void;
43
+ goToParentSession: () => void;
40
44
  };
41
45
 
42
46
  export type ChatPresenterLike = {
@@ -2,6 +2,14 @@ import { create } from 'zustand';
2
2
  import type { MutableRefObject } from 'react';
3
3
  import type { NcpMessage } from '@nextclaw/ncp';
4
4
  import type { ChatModelOption } from '@/components/chat/chat-input.types';
5
+ import type { AgentProfileView } from '@/api/types';
6
+
7
+ export type ChatChildSessionTab = {
8
+ sessionKey: string;
9
+ parentSessionKey: string | null;
10
+ label?: string | null;
11
+ agentId?: string | null;
12
+ };
5
13
 
6
14
  export type ChatThreadSnapshot = {
7
15
  isProviderStateResolved: boolean;
@@ -10,6 +18,10 @@ export type ChatThreadSnapshot = {
10
18
  sessionTypeUnavailableMessage?: string | null;
11
19
  sessionTypeLabel?: string | null;
12
20
  sessionKey: string | null;
21
+ agentId?: string | null;
22
+ agentDisplayName?: string | null;
23
+ agentAvatarUrl?: string | null;
24
+ availableAgents?: AgentProfileView[];
13
25
  sessionDisplayName?: string;
14
26
  sessionProjectRoot?: string | null;
15
27
  sessionProjectName?: string | null;
@@ -20,6 +32,10 @@ export type ChatThreadSnapshot = {
20
32
  messages: readonly NcpMessage[];
21
33
  isSending: boolean;
22
34
  isAwaitingAssistantOutput: boolean;
35
+ parentSessionKey?: string | null;
36
+ parentSessionLabel?: string | null;
37
+ childSessionTabs: ChatChildSessionTab[];
38
+ activeChildSessionKey?: string | null;
23
39
  };
24
40
 
25
41
  type ChatThreadStore = {
@@ -34,6 +50,10 @@ const initialSnapshot: ChatThreadSnapshot = {
34
50
  sessionTypeUnavailableMessage: null,
35
51
  sessionTypeLabel: null,
36
52
  sessionKey: null,
53
+ agentId: null,
54
+ agentDisplayName: null,
55
+ agentAvatarUrl: null,
56
+ availableAgents: [],
37
57
  sessionDisplayName: undefined,
38
58
  sessionProjectRoot: null,
39
59
  sessionProjectName: null,
@@ -43,7 +63,11 @@ const initialSnapshot: ChatThreadSnapshot = {
43
63
  isHistoryLoading: false,
44
64
  messages: [],
45
65
  isSending: false,
46
- isAwaitingAssistantOutput: false
66
+ isAwaitingAssistantOutput: false,
67
+ parentSessionKey: null,
68
+ parentSessionLabel: null,
69
+ childSessionTabs: [],
70
+ activeChildSessionKey: null,
47
71
  };
48
72
 
49
73
  export const useChatThreadStore = create<ChatThreadStore>((set) => ({
@@ -1,18 +1,19 @@
1
- import { renderHook, waitFor } from '@testing-library/react';
2
- import { beforeEach, describe, expect, it, vi } from 'vitest';
3
- import { useHydratedNcpAgent } from '../../../../ncp-packages/nextclaw-ncp-react/src/hooks/use-hydrated-ncp-agent.ts';
1
+ import { renderHook, waitFor } from "@testing-library/react";
2
+ import type { NcpAgentClientEndpoint } from "@nextclaw/ncp";
3
+ import { beforeEach, describe, expect, it, vi } from "vitest";
4
+ import { useHydratedNcpAgent } from "../../../../ncp-packages/nextclaw-ncp-react/src/hooks/use-hydrated-ncp-agent.ts";
4
5
 
5
6
  const mocks = vi.hoisted(() => ({
6
7
  manager: {
7
8
  reset: vi.fn(),
8
- hydrate: vi.fn()
9
+ hydrate: vi.fn(),
9
10
  },
10
11
  runtime: {
11
12
  snapshot: {
12
13
  messages: [],
13
14
  streamingMessage: null,
14
15
  activeRun: null,
15
- error: null
16
+ error: null,
16
17
  },
17
18
  visibleMessages: [],
18
19
  activeRunId: null,
@@ -20,16 +21,19 @@ const mocks = vi.hoisted(() => ({
20
21
  isSending: false,
21
22
  send: vi.fn(),
22
23
  abort: vi.fn(),
23
- streamRun: vi.fn()
24
- }
24
+ streamRun: vi.fn(),
25
+ },
25
26
  }));
26
27
 
27
- vi.mock('../../../../ncp-packages/nextclaw-ncp-react/src/hooks/use-ncp-agent-runtime.js', () => ({
28
- useScopedAgentManager: () => mocks.manager,
29
- useNcpAgentRuntime: () => mocks.runtime
30
- }));
28
+ vi.mock(
29
+ "../../../../ncp-packages/nextclaw-ncp-react/src/hooks/use-ncp-agent-runtime.js",
30
+ () => ({
31
+ useScopedAgentManager: () => mocks.manager,
32
+ useNcpAgentRuntime: () => mocks.runtime,
33
+ }),
34
+ );
31
35
 
32
- describe('useHydratedNcpAgent', () => {
36
+ describe("useHydratedNcpAgent", () => {
33
37
  beforeEach(() => {
34
38
  mocks.manager.reset.mockReset();
35
39
  mocks.manager.hydrate.mockReset();
@@ -38,40 +42,43 @@ describe('useHydratedNcpAgent', () => {
38
42
  mocks.runtime.streamRun.mockReset();
39
43
  });
40
44
 
41
- it('treats a newly selected session as hydrating immediately on rerender', async () => {
45
+ it("treats a newly selected session as hydrating immediately on rerender", async () => {
42
46
  const client = {
43
47
  stop: vi.fn().mockResolvedValue(undefined),
44
- stream: vi.fn().mockResolvedValue(undefined)
45
- } as never;
48
+ stream: vi.fn().mockResolvedValue(undefined),
49
+ } satisfies Pick<NcpAgentClientEndpoint, "stop" | "stream">;
46
50
  const loadSeed = vi
47
51
  .fn()
48
- .mockResolvedValueOnce({ messages: [], status: 'idle' })
49
- .mockResolvedValueOnce({ messages: [], status: 'idle' });
52
+ .mockResolvedValueOnce({ messages: [], status: "idle" })
53
+ .mockResolvedValueOnce({ messages: [], status: "idle" });
50
54
 
51
55
  const { result, rerender } = renderHook(
52
56
  ({ sessionId }: { sessionId: string }) =>
53
57
  useHydratedNcpAgent({
54
58
  sessionId,
55
- client,
56
- loadSeed
59
+ client: client as never,
60
+ loadSeed,
57
61
  }),
58
62
  {
59
63
  initialProps: {
60
- sessionId: 'session-a'
61
- }
62
- }
64
+ sessionId: "session-a",
65
+ },
66
+ },
63
67
  );
64
68
 
65
69
  await waitFor(() => {
66
70
  expect(result.current.isHydrating).toBe(false);
67
71
  });
72
+ expect(client.stream).toHaveBeenCalledWith({ sessionId: "session-a" });
68
73
 
69
- rerender({ sessionId: 'session-b' });
74
+ rerender({ sessionId: "session-b" });
70
75
 
71
76
  expect(result.current.isHydrating).toBe(true);
72
77
 
73
78
  await waitFor(() => {
74
79
  expect(result.current.isHydrating).toBe(false);
75
80
  });
81
+ expect(client.stream).toHaveBeenCalledWith({ sessionId: "session-b" });
82
+ expect(client.stream).toHaveBeenCalledTimes(2);
76
83
  });
77
84
  });