@nextclaw/ui 0.18.4 → 0.19.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 (126) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/dist/assets/api-Cyxp93Fn.js +13 -0
  3. package/dist/assets/appearance-settings-page-NlQIHbXh.js +1 -0
  4. package/dist/assets/{book-open-BOJLGVsx.js → book-open-Cl5CzL0g.js} +1 -1
  5. package/dist/assets/{channels-list-page-DKrUSeNz.js → channels-list-page-DhXfsjxF.js} +2 -2
  6. package/dist/assets/chat-page-C4DHLN6T.js +1 -0
  7. package/dist/assets/config-split-page-C-YVl2mf.js +1 -0
  8. package/dist/assets/{confirm-dialog-Bsg9A62_.js → confirm-dialog-Bz3ieDQ5.js} +2 -2
  9. package/dist/assets/desktop-update-config-CpsXBUJK.js +1 -0
  10. package/dist/assets/{dist-IBUY9_9h.js → dist-CDPl8mm7.js} +1 -1
  11. package/dist/assets/{dist-DyUY9uQ0.js → dist-HFDWY6-K.js} +1 -1
  12. package/dist/assets/doc-browser-CWhs0JQN.js +1 -0
  13. package/dist/assets/doc-browser-D6H_nPzO.js +1 -0
  14. package/dist/assets/{doc-browser-context-BM6Af8gN.js → doc-browser-context-BKwz_j44.js} +1 -1
  15. package/dist/assets/form-actions-BUZOCfPR.js +1 -0
  16. package/dist/assets/index-Bfr0I0Ga.js +109 -0
  17. package/dist/assets/index-DcqrfCMy.css +1 -0
  18. package/dist/assets/loader-circle-s65a4ZzL.js +1 -0
  19. package/dist/assets/mcp-marketplace-page-BJvzGt7h.js +9 -0
  20. package/dist/assets/mcp-marketplace-page-EpFHeJzw.js +1 -0
  21. package/dist/assets/model-config-page-BRScCkS-.js +1 -0
  22. package/dist/assets/{navigation-link-DtPZZ_Q_.js → navigation-link-CDMYpkVv.js} +2 -2
  23. package/dist/assets/plus-XDIkqpkt.js +1 -0
  24. package/dist/assets/{provider-scoped-model-input-BFzyywsF.js → provider-scoped-model-input-CD93ZOgZ.js} +1 -1
  25. package/dist/assets/providers-config-page-CFdgth2A.js +1 -0
  26. package/dist/assets/react-B7Paq2b3.js +8 -0
  27. package/dist/assets/{refresh-cw-BQHORcAs.js → refresh-cw-XrnNVwuQ.js} +1 -1
  28. package/dist/assets/remote-DDN70hyU.js +1 -0
  29. package/dist/assets/rotate-cw-BhIWpLaU.js +1 -0
  30. package/dist/assets/runtime-config-page-BxJlm6qT.js +1 -0
  31. package/dist/assets/{save-BgiS2u-T.js → save-_nSNAYQu.js} +1 -1
  32. package/dist/assets/{search-B6mO7mmo.js → search-0OTHFRMx.js} +1 -1
  33. package/dist/assets/{search-config-page-ydeDD1OQ.js → search-config-page-2VAarRcG.js} +1 -1
  34. package/dist/assets/secrets-config-page-KoFrETds.js +3 -0
  35. package/dist/assets/security-config-BgIIrg1s.js +1 -0
  36. package/dist/assets/{select-DToos7Rq.js → select-BZHvnwQB.js} +2 -2
  37. package/dist/assets/{settings-2-ZAWXrq81.js → settings-2-CM_PvDNk.js} +1 -1
  38. package/dist/assets/skeleton-Bv4MMbH7.js +1 -0
  39. package/dist/assets/{tag-chip-urB3N-kk.js → tag-chip-Ce53PMTL.js} +1 -1
  40. package/dist/assets/x-CCjWN5ys.js +1 -0
  41. package/dist/index.html +20 -20
  42. package/package.json +5 -5
  43. package/src/app/components/layout/sidebar-items.tsx +49 -15
  44. package/src/app/configs/__tests__/app-navigation.config.test.ts +21 -0
  45. package/src/app/configs/app-navigation.config.ts +10 -1
  46. package/src/app/index.tsx +4 -0
  47. package/src/features/chat/components/conversation/chat-conversation-content.tsx +7 -0
  48. package/src/features/chat/components/layout/chat-page-shell.tsx +4 -1
  49. package/src/features/chat/components/layout/chat-sidebar-desktop-layout.tsx +26 -22
  50. package/src/features/chat/features/conversation/components/session-conversation-area.tsx +2 -0
  51. package/src/features/chat/features/input/input-surface-plugins/__tests__/context-reference-plugin.test.ts +1 -0
  52. package/src/features/chat/features/input/input-surface-plugins/__tests__/slash-command-plugin.test.ts +1 -0
  53. package/src/features/chat/features/message/components/chat-inline-panel-app-card.tsx +1 -3
  54. package/src/features/chat/features/message/components/chat-message-list.container.tsx +15 -1
  55. package/src/features/chat/features/message/components/chat-tool-call-stress-harness.tsx +178 -0
  56. package/src/features/chat/features/message/utils/__tests__/chat-message-process-summary.utils.test.ts +21 -0
  57. package/src/features/chat/features/message/utils/__tests__/chat-tool-call-stress-fixture.utils.test.ts +56 -0
  58. package/src/features/chat/features/message/utils/chat-message-core.utils.ts +1 -1
  59. package/src/features/chat/features/message/utils/chat-message-part.utils.ts +3 -10
  60. package/src/features/chat/features/message/utils/chat-message-process-summary.utils.ts +29 -1
  61. package/src/features/chat/features/message/utils/chat-message-texts.utils.ts +3 -0
  62. package/src/features/chat/features/message/utils/chat-message-tool-card.utils.ts +3 -28
  63. package/src/features/chat/features/message/utils/chat-tool-call-stress-fixture.utils.ts +122 -0
  64. package/src/features/chat/features/ncp/hooks/__tests__/use-ncp-session-conversation.test.tsx +69 -0
  65. package/src/features/chat/features/ncp/hooks/use-ncp-session-conversation.ts +29 -0
  66. package/src/features/chat/features/ncp/hooks/use-ncp-session-message-history.ts +106 -1
  67. package/src/features/chat/features/session/utils/ncp-session-adapter.utils.ts +2 -12
  68. package/src/features/chat/managers/__tests__/chat-ui.manager.test.ts +4 -2
  69. package/src/features/panel-apps/components/__tests__/panel-app-list-item.test.tsx +57 -2
  70. package/src/features/panel-apps/components/__tests__/panel-app-main-sidebar-nav.test.tsx +133 -0
  71. package/src/features/panel-apps/components/__tests__/panel-app-toolbar.test.tsx +68 -7
  72. package/src/features/panel-apps/components/__tests__/panel-apps-list.test.tsx +7 -1
  73. package/src/features/panel-apps/components/panel-app-icon.tsx +48 -0
  74. package/src/features/panel-apps/components/panel-app-list-item.tsx +7 -29
  75. package/src/features/panel-apps/components/panel-app-main-sidebar-menu-item.tsx +37 -0
  76. package/src/features/panel-apps/components/panel-app-main-sidebar-nav.tsx +123 -0
  77. package/src/features/panel-apps/components/panel-app-toolbar.tsx +61 -20
  78. package/src/features/panel-apps/components/panel-apps-list.tsx +5 -27
  79. package/src/features/panel-apps/hooks/__tests__/use-panel-apps.test.tsx +105 -0
  80. package/src/features/panel-apps/hooks/use-panel-app-client-grant.ts +50 -0
  81. package/src/features/panel-apps/hooks/use-panel-app-main-runtime.ts +66 -0
  82. package/src/features/panel-apps/hooks/use-panel-apps.ts +102 -13
  83. package/src/features/panel-apps/index.ts +2 -0
  84. package/src/features/panel-apps/managers/__tests__/panel-app-bridge.manager.test.ts +1 -14
  85. package/src/features/panel-apps/managers/panel-app-bridge.manager.ts +9 -5
  86. package/src/features/panel-apps/pages/__tests__/panel-app-main-page.test.tsx +183 -0
  87. package/src/features/panel-apps/pages/panel-app-main-page.tsx +125 -0
  88. package/src/features/panel-apps/utils/__tests__/panel-app-view.utils.test.ts +2 -1
  89. package/src/features/panel-apps/utils/panel-app-doc-browser.utils.tsx +5 -4
  90. package/src/features/right-panel-resources/configs/right-panel-resource-routes.config.ts +12 -25
  91. package/src/features/right-panel-resources/index.ts +2 -0
  92. package/src/features/right-panel-resources/utils/__tests__/right-panel-resource-uri.utils.test.ts +21 -0
  93. package/src/features/right-panel-resources/utils/right-panel-resource-uri.utils.ts +37 -0
  94. package/src/main.tsx +24 -11
  95. package/src/shared/components/doc-browser/__tests__/doc-browser.test.tsx +102 -0
  96. package/src/shared/lib/api/ncp-session.types.ts +1 -0
  97. package/src/shared/lib/api/types.ts +3 -0
  98. package/src/shared/lib/api/utils/ncp-session.utils.ts +24 -1
  99. package/src/shared/lib/i18n/locales/en-US/chat.json +4 -0
  100. package/src/shared/lib/i18n/locales/en-US/doc-browser.json +10 -0
  101. package/src/shared/lib/i18n/locales/zh-CN/chat.json +4 -0
  102. package/src/shared/lib/i18n/locales/zh-CN/doc-browser.json +10 -0
  103. package/dist/assets/api-BSsQ0I4l.js +0 -13
  104. package/dist/assets/appearance-settings-page-CEPxG1w0.js +0 -1
  105. package/dist/assets/chat-page-sGhIjLSr.js +0 -1
  106. package/dist/assets/config-split-page-aUTKtWJu.js +0 -1
  107. package/dist/assets/desktop-update-config-VgvIATTT.js +0 -1
  108. package/dist/assets/doc-browser-D0arfzHY.js +0 -1
  109. package/dist/assets/doc-browser-D73yN8ts.js +0 -1
  110. package/dist/assets/form-actions-Cpd7glh8.js +0 -1
  111. package/dist/assets/index-Bsoscjk7.css +0 -1
  112. package/dist/assets/index-CtPLj_hk.js +0 -109
  113. package/dist/assets/loader-circle-C1fL3kBZ.js +0 -1
  114. package/dist/assets/mcp-marketplace-page-D8V0GU4Y.js +0 -9
  115. package/dist/assets/mcp-marketplace-page-q6T-vKoH.js +0 -1
  116. package/dist/assets/model-config-page-DQ-iQFSO.js +0 -1
  117. package/dist/assets/plus-B9V7Z_Eu.js +0 -1
  118. package/dist/assets/providers-config-page-DUYwVuYe.js +0 -1
  119. package/dist/assets/react-D-jOo51s.js +0 -8
  120. package/dist/assets/remote-CNFQ6bFY.js +0 -1
  121. package/dist/assets/rotate-cw-DuCF-3PX.js +0 -1
  122. package/dist/assets/runtime-config-page-CaWoWoQk.js +0 -1
  123. package/dist/assets/secrets-config-page-BZYqw8BF.js +0 -3
  124. package/dist/assets/security-config-Ds_2SXBh.js +0 -1
  125. package/dist/assets/skeleton-Bs2eO2CA.js +0 -1
  126. package/dist/assets/x-BkjTUiww.js +0 -1
@@ -0,0 +1,122 @@
1
+ import type { ChatMessageViewModel } from "@nextclaw/agent-chat-ui";
2
+ import type { NcpMessage } from "@nextclaw/ncp";
3
+ import { adaptNcpMessagePartsForChat, adaptNcpMessageToUiMessage } from "@/features/chat/features/session/utils/ncp-session-adapter.utils";
4
+ import { adaptChatMessage } from "./chat-message.utils";
5
+
6
+ export type ChatToolCallStressConfig = {
7
+ toolCallCount: number;
8
+ argumentBytesPerCall: number;
9
+ resultBytesPerCall: number;
10
+ };
11
+
12
+ export const DEFAULT_CHAT_TOOL_CALL_STRESS_CONFIG: ChatToolCallStressConfig = {
13
+ toolCallCount: 200,
14
+ argumentBytesPerCall: 8_192,
15
+ resultBytesPerCall: 8_192,
16
+ };
17
+
18
+ const STRESS_TOOL_NAMES = ["exec_command", "read_file", "web_fetch"];
19
+ const FILLER = "0123456789abcdefghijklmnopqrstuvwxyz";
20
+
21
+ const STRESS_ADAPTER_TEXTS = {
22
+ roleLabels: {
23
+ user: "User",
24
+ assistant: "Assistant",
25
+ tool: "Tool",
26
+ system: "System",
27
+ fallback: "Message",
28
+ },
29
+ reasoningLabel: "Reasoning",
30
+ toolCallLabel: "Tool call",
31
+ toolResultLabel: "Tool result",
32
+ toolInputLabel: "Input",
33
+ toolNoOutputLabel: "No output",
34
+ toolOutputLabel: "Output",
35
+ toolStatusPreparingLabel: "Preparing",
36
+ toolStatusRunningLabel: "Running",
37
+ toolStatusCompletedLabel: "Completed",
38
+ toolStatusFailedLabel: "Failed",
39
+ toolStatusCancelledLabel: "Cancelled",
40
+ imageAttachmentLabel: "Image attachment",
41
+ fileAttachmentLabel: "File attachment",
42
+ unknownPartLabel: "Unknown part",
43
+ };
44
+
45
+ function createPayload(bytes: number, callIndex: number, kind: "argument" | "result") {
46
+ return {
47
+ request: {
48
+ id: `${kind}-${callIndex}`,
49
+ metadata: {
50
+ callIndex,
51
+ kind,
52
+ labels: ["chat", "stress", "nested-payload"],
53
+ },
54
+ nested: {
55
+ levelOne: {
56
+ levelTwo: {
57
+ levelThree: {
58
+ payload: FILLER.repeat(Math.ceil(bytes / FILLER.length)).slice(0, bytes),
59
+ },
60
+ },
61
+ },
62
+ },
63
+ },
64
+ };
65
+ }
66
+
67
+ export function createChatToolCallStressMessage(
68
+ config: ChatToolCallStressConfig = DEFAULT_CHAT_TOOL_CALL_STRESS_CONFIG,
69
+ messageId = "chat-tool-call-stress-message",
70
+ ): NcpMessage {
71
+ const parts: NcpMessage["parts"] = [
72
+ {
73
+ type: "reasoning",
74
+ text: "Simulating a dense tool-call execution trace.",
75
+ },
76
+ ];
77
+ for (let index = 0; index < config.toolCallCount; index += 1) {
78
+ parts.push({
79
+ type: "tool-invocation",
80
+ toolCallId: `stress-call-${index}`,
81
+ toolName: STRESS_TOOL_NAMES[index % STRESS_TOOL_NAMES.length]!,
82
+ state: "result",
83
+ args: createPayload(config.argumentBytesPerCall, index, "argument"),
84
+ result: createPayload(config.resultBytesPerCall, index, "result"),
85
+ execution: {
86
+ durationMs: 15 + (index % 200),
87
+ },
88
+ });
89
+ }
90
+ parts.push({
91
+ type: "text",
92
+ text: "Stress fixture completed.",
93
+ });
94
+ return {
95
+ id: messageId,
96
+ sessionId: "chat-tool-call-stress-session",
97
+ role: "assistant",
98
+ status: "final",
99
+ timestamp: "2026-08-19T00:00:00.000Z",
100
+ parts,
101
+ };
102
+ }
103
+
104
+ export function createChatToolCallStressViewModel(
105
+ config: ChatToolCallStressConfig = DEFAULT_CHAT_TOOL_CALL_STRESS_CONFIG,
106
+ messageId?: string,
107
+ ): ChatMessageViewModel {
108
+ const message = createChatToolCallStressMessage(config, messageId);
109
+ const uiMessage = adaptNcpMessageToUiMessage(message);
110
+ return adaptChatMessage(
111
+ {
112
+ id: uiMessage.id,
113
+ role: uiMessage.role,
114
+ meta: uiMessage.meta,
115
+ parts: adaptNcpMessagePartsForChat(message.parts),
116
+ },
117
+ {
118
+ texts: STRESS_ADAPTER_TEXTS,
119
+ formatTimestamp: (value) => value,
120
+ },
121
+ );
122
+ }
@@ -10,6 +10,7 @@ import {
10
10
  const mocks = vi.hoisted(() => ({
11
11
  acceptRun: vi.fn(),
12
12
  continueRun: vi.fn(),
13
+ fetchNcpSessionMessageDetail: vi.fn(),
13
14
  fetchNcpSessionMessages: vi.fn(),
14
15
  editMessage: vi.fn(),
15
16
  prependHistory: vi.fn(),
@@ -47,6 +48,7 @@ vi.mock("@/shared/lib/api", async (importOriginal) => {
47
48
  const actual = await importOriginal<typeof SharedApi>();
48
49
  return {
49
50
  ...actual,
51
+ fetchNcpSessionMessageDetail: mocks.fetchNcpSessionMessageDetail,
50
52
  fetchNcpSessionMessages: mocks.fetchNcpSessionMessages,
51
53
  nextclawClient: {
52
54
  ...actual.nextclawClient,
@@ -89,6 +91,7 @@ vi.mock("@/features/system-status", () => ({
89
91
  function resetConversationMocks(): void {
90
92
  mocks.acceptRun.mockReset();
91
93
  mocks.continueRun.mockReset();
94
+ mocks.fetchNcpSessionMessageDetail.mockReset();
92
95
  mocks.fetchNcpSessionMessages.mockReset();
93
96
  mocks.editMessage.mockReset();
94
97
  mocks.prependHistory.mockReset();
@@ -134,6 +137,7 @@ describe("useNcpSessionConversation", () => {
134
137
 
135
138
  expect(mocks.fetchNcpSessionMessages).toHaveBeenCalledWith("session-1", {
136
139
  limit: 300,
140
+ toolPayload: "summary",
137
141
  signal: expect.any(AbortSignal),
138
142
  });
139
143
  expect(result).toEqual({
@@ -152,6 +156,7 @@ describe("useNcpSessionConversation", () => {
152
156
  compactedMessageCount: 0,
153
157
  updatedAt: "2026-05-05T00:00:00.000Z",
154
158
  },
159
+ deferredToolPayloads: {},
155
160
  total: 1,
156
161
  pageInfo: { startCursor: "cursor-1", hasPreviousPage: false },
157
162
  });
@@ -171,6 +176,7 @@ describe("useNcpSessionConversation", () => {
171
176
  messages: [],
172
177
  status: "idle",
173
178
  total: 0,
179
+ deferredToolPayloads: {},
174
180
  pageInfo: { startCursor: null, hasPreviousPage: false },
175
181
  });
176
182
  });
@@ -433,6 +439,7 @@ describe("useNcpSessionConversation history and hydration", () => {
433
439
  {
434
440
  limit: 40,
435
441
  cursor: "cursor-2",
442
+ toolPayload: "summary",
436
443
  signal: expect.any(AbortSignal),
437
444
  },
438
445
  );
@@ -448,6 +455,68 @@ describe("useNcpSessionConversation history and hydration", () => {
448
455
  expect(result.current.messageTotal).toBe(5);
449
456
  });
450
457
 
458
+ it("loads and caches one complete message when deferred tool details are requested", async () => {
459
+ const summaryMessage: NcpMessage = {
460
+ id: "assistant-large",
461
+ sessionId: "session-1",
462
+ role: "assistant",
463
+ status: "final",
464
+ timestamp: "2026-08-19T00:00:00.000Z",
465
+ parts: [{
466
+ type: "tool-invocation",
467
+ toolCallId: "tool-1",
468
+ toolName: "exec_command",
469
+ state: "result",
470
+ args: { command: "pnpm test" },
471
+ }],
472
+ };
473
+ const fullMessage: NcpMessage = {
474
+ ...summaryMessage,
475
+ parts: [{
476
+ ...summaryMessage.parts[0] as Extract<NcpMessage["parts"][number], { type: "tool-invocation" }>,
477
+ result: { output: "complete output" },
478
+ }],
479
+ };
480
+ mocks.visibleMessages.push(summaryMessage);
481
+ mocks.fetchNcpSessionMessages
482
+ .mockResolvedValueOnce({
483
+ sessionId: "session-1",
484
+ status: "idle",
485
+ total: 1,
486
+ pageInfo: { startCursor: "cursor-1", hasPreviousPage: false },
487
+ messages: [summaryMessage],
488
+ deferredToolPayloads: { "assistant-large": { cursor: "cursor-detail" } },
489
+ });
490
+ mocks.fetchNcpSessionMessageDetail.mockResolvedValue(fullMessage);
491
+ const { result } = renderHook(() => useNcpSessionConversation("session-1"));
492
+ const loadSeed = mocks.hydratedCalls[0]?.loadSeed as (
493
+ sessionId: string,
494
+ signal: AbortSignal,
495
+ ) => Promise<{ messages: unknown[]; status: string }>;
496
+ await act(async () => {
497
+ await loadSeed("session-1", new AbortController().signal);
498
+ });
499
+ expect(result.current.messageDetailStates["assistant-large"]).toBe("summary");
500
+
501
+ await act(async () => {
502
+ await Promise.all([
503
+ result.current.loadMessageDetails("assistant-large"),
504
+ result.current.loadMessageDetails("assistant-large"),
505
+ ]);
506
+ });
507
+
508
+ expect(mocks.fetchNcpSessionMessageDetail).toHaveBeenCalledWith(
509
+ "session-1",
510
+ "assistant-large",
511
+ "cursor-detail",
512
+ expect.any(AbortSignal),
513
+ );
514
+ expect(mocks.fetchNcpSessionMessageDetail).toHaveBeenCalledTimes(1);
515
+ expect(mocks.fetchNcpSessionMessages).toHaveBeenCalledTimes(1);
516
+ expect(result.current.messageDetailStates["assistant-large"]).toBe("ready");
517
+ expect(result.current.visibleMessages[0]).toBe(fullMessage);
518
+ });
519
+
451
520
  it("drops an in-flight history page when the viewer switches sessions", async () => {
452
521
  let resolvePreviousPage = (_value: unknown): void => {
453
522
  throw new Error("Previous page request was not initialized.");
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
2
  import { NcpHttpAgentClientEndpoint } from "@nextclaw/ncp-http-agent-client";
3
3
  import { useHydratedNcpAgent } from "@nextclaw/ncp-react";
4
4
  import type {
5
+ NcpMessage,
5
6
  NcpRunHandle,
6
7
  } from "@nextclaw/ncp";
7
8
  import type {
@@ -74,6 +75,21 @@ function useSyncReadyRetryVersion(
74
75
  }, [readyRetrySignature, syncReadyRetryVersion]);
75
76
  }
76
77
 
78
+ function overlaySessionMessageDetails(
79
+ messages: readonly NcpMessage[],
80
+ details: Readonly<Record<string, NcpMessage>>,
81
+ ) {
82
+ return messages.map((message) => {
83
+ const detail = details[message.id];
84
+ if (!detail) return message;
85
+ if (!message.metadata && !detail.metadata) return detail;
86
+ return {
87
+ ...detail,
88
+ metadata: { ...(message.metadata ?? {}), ...(detail.metadata ?? {}) },
89
+ };
90
+ });
91
+ }
92
+
77
93
  export function useNcpSessionConversation(
78
94
  sessionId: string | undefined,
79
95
  options: UseNcpSessionConversationOptions = {},
@@ -85,6 +101,7 @@ export function useNcpSessionConversation(
85
101
  const pendingCommandSessionIdRef = useRef<string | null>(null);
86
102
  const {
87
103
  loadSeed,
104
+ loadMessageDetails,
88
105
  loadPreviousMessages: loadPreviousHistory,
89
106
  state: visibleHistoryState,
90
107
  } = useNcpSessionMessageHistory({
@@ -180,9 +197,17 @@ export function useNcpSessionConversation(
180
197
  useSyncReadyRetryVersion(readyRetrySignature, () => {
181
198
  setHydrationRetryVersion((current) => current + 1);
182
199
  });
200
+ const visibleMessages = useMemo(
201
+ () => overlaySessionMessageDetails(
202
+ agent.visibleMessages,
203
+ visibleHistoryState.messageDetails,
204
+ ),
205
+ [agent.visibleMessages, visibleHistoryState.messageDetails],
206
+ );
183
207
  return useMemo(
184
208
  () => ({
185
209
  ...agent,
210
+ visibleMessages,
186
211
  isSending:
187
212
  agent.isSending || pendingCommandSessionId === sessionId,
188
213
  editMessage,
@@ -196,16 +221,20 @@ export function useNcpSessionConversation(
196
221
  historyError: visibleHistoryState.error,
197
222
  isLoadingPreviousMessages: visibleHistoryState.isLoading,
198
223
  loadPreviousMessages,
224
+ loadMessageDetails,
225
+ messageDetailStates: visibleHistoryState.messageDetailStates,
199
226
  messageTotal: visibleHistoryState.total,
200
227
  }),
201
228
  [
202
229
  agent,
203
230
  continueRun,
204
231
  editMessage,
232
+ loadMessageDetails,
205
233
  loadPreviousMessages,
206
234
  pendingCommandSessionId,
207
235
  sessionId,
208
236
  visibleHistoryState,
237
+ visibleMessages,
209
238
  ],
210
239
  );
211
240
  }
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
2
2
  import type { NcpMessage, NcpSessionMessagePageInfo } from "@nextclaw/ncp";
3
3
  import type { NcpConversationSeed } from "@nextclaw/ncp-react";
4
4
  import {
5
+ fetchNcpSessionMessageDetail,
5
6
  fetchNcpSessionMessages,
6
7
  type SessionContextWindowView,
7
8
  } from "@/shared/lib/api";
@@ -10,10 +11,13 @@ export const DEFAULT_NCP_SESSION_MESSAGE_LIMIT = 40;
10
11
 
11
12
  type NcpConversationSeedWithContextWindow = NcpConversationSeed & {
12
13
  contextWindow?: SessionContextWindowView | null;
14
+ deferredToolPayloads: Record<string, { cursor: string }>;
13
15
  total: number;
14
16
  pageInfo: NcpSessionMessagePageInfo;
15
17
  };
16
18
 
19
+ export type SessionMessageToolPayloadState = "summary" | "loading" | "ready" | "error";
20
+
17
21
  type SessionHistoryState = {
18
22
  sessionId: string | null;
19
23
  contextWindow: SessionContextWindowView | null;
@@ -22,6 +26,9 @@ type SessionHistoryState = {
22
26
  hasPreviousPage: boolean;
23
27
  isLoading: boolean;
24
28
  error: Error | null;
29
+ deferredToolPayloads: Record<string, { cursor: string }>;
30
+ messageDetails: Record<string, NcpMessage>;
31
+ messageDetailStates: Record<string, SessionMessageToolPayloadState>;
25
32
  };
26
33
 
27
34
  const EMPTY_SESSION_HISTORY_STATE: SessionHistoryState = {
@@ -32,6 +39,9 @@ const EMPTY_SESSION_HISTORY_STATE: SessionHistoryState = {
32
39
  hasPreviousPage: false,
33
40
  isLoading: false,
34
41
  error: null,
42
+ deferredToolPayloads: {},
43
+ messageDetails: {},
44
+ messageDetailStates: {},
35
45
  };
36
46
 
37
47
  function isMissingNcpSessionError(error: unknown): boolean {
@@ -49,6 +59,7 @@ export async function fetchNcpSessionConversationSeed(
49
59
  try {
50
60
  const response = await fetchNcpSessionMessages(sessionId, {
51
61
  limit: messageLimit,
62
+ toolPayload: "summary",
52
63
  signal,
53
64
  });
54
65
  signal.throwIfAborted();
@@ -56,6 +67,7 @@ export async function fetchNcpSessionConversationSeed(
56
67
  messages: response.messages,
57
68
  status: response.status ?? "idle",
58
69
  contextWindow: response.contextWindow ?? null,
70
+ deferredToolPayloads: response.deferredToolPayloads ?? {},
59
71
  total: response.total,
60
72
  pageInfo: response.pageInfo,
61
73
  };
@@ -68,11 +80,80 @@ export async function fetchNcpSessionConversationSeed(
68
80
  messages: [],
69
81
  status: "idle",
70
82
  total: 0,
83
+ deferredToolPayloads: {},
71
84
  pageInfo: { startCursor: null, hasPreviousPage: false },
72
85
  };
73
86
  }
74
87
  }
75
88
 
89
+ type UpdateSessionHistoryState = (
90
+ sessionId: string,
91
+ update: (current: SessionHistoryState) => SessionHistoryState,
92
+ ) => void;
93
+
94
+ function useSessionMessageDetailLoader(params: {
95
+ sessionId: string | undefined;
96
+ historyStateRef: { current: SessionHistoryState };
97
+ updateHistoryState: UpdateSessionHistoryState;
98
+ }) {
99
+ const { historyStateRef, sessionId, updateHistoryState } = params;
100
+ const requestsRef = useRef(new Map<string, {
101
+ controller: AbortController;
102
+ promise: Promise<void>;
103
+ }>());
104
+ useEffect(() => {
105
+ const requests = requestsRef.current;
106
+ for (const request of requests.values()) request.controller.abort();
107
+ requests.clear();
108
+ return () => {
109
+ for (const request of requests.values()) request.controller.abort();
110
+ requests.clear();
111
+ };
112
+ }, [sessionId]);
113
+ return useCallback(async (messageId: string): Promise<void> => {
114
+ if (!sessionId) return;
115
+ const history = historyStateRef.current;
116
+ if (history.sessionId !== sessionId || history.messageDetails[messageId]) return;
117
+ const cursor = history.deferredToolPayloads[messageId]?.cursor;
118
+ if (!cursor) return;
119
+ const existing = requestsRef.current.get(messageId);
120
+ if (existing) return await existing.promise;
121
+ const controller = new AbortController();
122
+ updateHistoryState(sessionId, (current) => ({
123
+ ...current,
124
+ messageDetailStates: { ...current.messageDetailStates, [messageId]: "loading" },
125
+ }));
126
+ const promise = (async () => {
127
+ try {
128
+ const message = await fetchNcpSessionMessageDetail(
129
+ sessionId,
130
+ messageId,
131
+ cursor,
132
+ controller.signal,
133
+ );
134
+ if (controller.signal.aborted) return;
135
+ updateHistoryState(sessionId, (current) => ({
136
+ ...current,
137
+ messageDetails: { ...current.messageDetails, [messageId]: message },
138
+ messageDetailStates: { ...current.messageDetailStates, [messageId]: "ready" },
139
+ }));
140
+ } catch {
141
+ if (controller.signal.aborted) return;
142
+ updateHistoryState(sessionId, (current) => ({
143
+ ...current,
144
+ messageDetailStates: { ...current.messageDetailStates, [messageId]: "error" },
145
+ }));
146
+ } finally {
147
+ if (requestsRef.current.get(messageId)?.controller === controller) {
148
+ requestsRef.current.delete(messageId);
149
+ }
150
+ }
151
+ })();
152
+ requestsRef.current.set(messageId, { controller, promise });
153
+ await promise;
154
+ }, [historyStateRef, sessionId, updateHistoryState]);
155
+ }
156
+
76
157
  export function useNcpSessionMessageHistory(params: {
77
158
  sessionId: string | undefined;
78
159
  messageLimit: number;
@@ -104,8 +185,15 @@ export function useNcpSessionMessageHistory(params: {
104
185
  useEffect(() => {
105
186
  historyRequestRef.current?.abort();
106
187
  historyRequestRef.current = null;
107
- return () => historyRequestRef.current?.abort();
188
+ return () => {
189
+ historyRequestRef.current?.abort();
190
+ };
108
191
  }, [sessionId]);
192
+ const loadMessageDetails = useSessionMessageDetailLoader({
193
+ historyStateRef,
194
+ sessionId,
195
+ updateHistoryState,
196
+ });
109
197
  const loadSeed = useCallback(
110
198
  async (targetSessionId: string, signal: AbortSignal) => {
111
199
  void hydrationRetryVersion;
@@ -118,6 +206,11 @@ export function useNcpSessionMessageHistory(params: {
118
206
  updateHistoryState(targetSessionId, (current) => ({
119
207
  ...current,
120
208
  contextWindow: seed.contextWindow ?? null,
209
+ deferredToolPayloads: seed.deferredToolPayloads,
210
+ messageDetails: {},
211
+ messageDetailStates: Object.fromEntries(
212
+ Object.keys(seed.deferredToolPayloads).map((messageId) => [messageId, "summary"]),
213
+ ),
121
214
  total: seed.total,
122
215
  cursor: seed.pageInfo.startCursor,
123
216
  hasPreviousPage: seed.pageInfo.hasPreviousPage,
@@ -151,6 +244,7 @@ export function useNcpSessionMessageHistory(params: {
151
244
  const response = await fetchNcpSessionMessages(sessionId, {
152
245
  limit: messageLimit,
153
246
  cursor: history.cursor,
247
+ toolPayload: "summary",
154
248
  signal: controller.signal,
155
249
  });
156
250
  if (
@@ -163,6 +257,16 @@ export function useNcpSessionMessageHistory(params: {
163
257
  updateHistoryState(sessionId, (current) => ({
164
258
  ...current,
165
259
  contextWindow: response.contextWindow ?? current.contextWindow,
260
+ deferredToolPayloads: {
261
+ ...current.deferredToolPayloads,
262
+ ...(response.deferredToolPayloads ?? {}),
263
+ },
264
+ messageDetailStates: {
265
+ ...Object.fromEntries(
266
+ Object.keys(response.deferredToolPayloads ?? {}).map((messageId) => [messageId, "summary"]),
267
+ ),
268
+ ...current.messageDetailStates,
269
+ },
166
270
  total: response.total,
167
271
  cursor: response.pageInfo.startCursor,
168
272
  hasPreviousPage: response.pageInfo.hasPreviousPage,
@@ -189,6 +293,7 @@ export function useNcpSessionMessageHistory(params: {
189
293
  return {
190
294
  loadSeed,
191
295
  loadPreviousMessages,
296
+ loadMessageDetails,
192
297
  state:
193
298
  historyState.sessionId === sessionId
194
299
  ? historyState
@@ -29,17 +29,6 @@ const SESSION_ACTIVITY_PREVIEW_STATUS_KIND_SET = new Set<NonNullable<SessionActi
29
29
  'run-interrupted'
30
30
  ]);
31
31
 
32
- function stringifyUnknown(value: unknown): string {
33
- if (typeof value === 'string') {
34
- return value;
35
- }
36
- try {
37
- return JSON.stringify(value ?? {});
38
- } catch {
39
- return String(value ?? '');
40
- }
41
- }
42
-
43
32
  function buildNcpAssetContentUrl(assetUri: string): string {
44
33
  const query = new URLSearchParams({ uri: assetUri });
45
34
  return `${API_BASE}/api/ncp/assets/content?${query.toString()}`;
@@ -283,7 +272,8 @@ function toUiParts(parts: NcpMessagePart[]): UIMessage['parts'] {
283
272
  status: mapToolStatus(part),
284
273
  toolCallId: part.toolCallId ?? `${part.toolName}-${Math.random().toString(36).slice(2, 8)}`,
285
274
  toolName: part.toolName,
286
- args: stringifyUnknown(part.args),
275
+ args: typeof part.args === "string" ? part.args : "",
276
+ parsedArgs: part.args,
287
277
  result: part.result,
288
278
  ...(part.execution ? { execution: { ...part.execution } } : {})
289
279
  }
@@ -34,6 +34,7 @@ function createPanelAppEntry(overrides: Partial<PanelAppEntryView> = {}): PanelA
34
34
  contentPath: '/api/panel-apps/encoded-reader/content',
35
35
  createdAt: '2026-06-09T00:00:00.000Z',
36
36
  favorite: false,
37
+ mainSidebar: false,
37
38
  fileName: 'reader.panel',
38
39
  id: 'encoded-reader',
39
40
  kind: 'folder',
@@ -97,13 +98,14 @@ describe('ChatUiManager content display', () => {
97
98
  });
98
99
 
99
100
  expect(docBrowserManager.openTarget).toHaveBeenCalledWith({
100
- dedupeKey: 'panel-app:encoded-reader',
101
+ dedupeKey: 'panel-app:reader',
101
102
  contentParams: {
102
103
  file: { path: '/tmp/photo.png' },
103
104
  },
105
+ dockIcon: undefined,
104
106
  historyPolicy: 'managed',
105
107
  kind: 'panel-app',
106
- resourceUri: 'nextclaw://panel-app/encoded-reader',
108
+ resourceUri: 'nextclaw://panel-app/reader',
107
109
  title: 'Reader',
108
110
  url: '/api/panel-apps/encoded-reader/content',
109
111
  }, {
@@ -1,7 +1,16 @@
1
1
  import { render, screen } from '@testing-library/react';
2
2
  import userEvent from '@testing-library/user-event';
3
- import { describe, expect, it, vi } from 'vitest';
4
- import { PanelAppListItem } from '../panel-app-list-item';
3
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
4
+ import { PanelAppListItem } from '@/features/panel-apps/components/panel-app-list-item';
5
+
6
+ const mainSidebarMutation = vi.hoisted(() => ({
7
+ isPending: false,
8
+ mutate: vi.fn(),
9
+ }));
10
+
11
+ vi.mock('@/features/panel-apps/hooks/use-panel-apps', () => ({
12
+ useUpdatePanelAppPreferences: () => mainSidebarMutation,
13
+ }));
5
14
 
6
15
  const baseEntry = {
7
16
  id: 'demo',
@@ -15,12 +24,18 @@ const baseEntry = {
15
24
  updatedAt: '2026-05-28T09:00:00.000Z',
16
25
  sizeBytes: 12,
17
26
  favorite: false,
27
+ mainSidebar: false,
18
28
  clientDeclared: false,
19
29
  clientGranted: false,
20
30
  openCount: 0,
21
31
  };
22
32
 
23
33
  describe('PanelAppListItem', () => {
34
+ beforeEach(() => {
35
+ mainSidebarMutation.isPending = false;
36
+ mainSidebarMutation.mutate.mockReset();
37
+ });
38
+
24
39
  it('keeps panel app metadata compact below the icon-title row', () => {
25
40
  const { container } = render(
26
41
  <PanelAppListItem
@@ -61,4 +76,44 @@ describe('PanelAppListItem', () => {
61
76
 
62
77
  expect(onDelete).toHaveBeenCalledTimes(1);
63
78
  });
79
+
80
+ it('keeps main-sidebar placement in the low-frequency more-actions menu', async () => {
81
+ const user = userEvent.setup();
82
+ const { rerender } = render(
83
+ <PanelAppListItem
84
+ deletePending={false}
85
+ entry={baseEntry}
86
+ favoritePending={false}
87
+ onDelete={vi.fn()}
88
+ onOpen={vi.fn()}
89
+ onToggleFavorite={vi.fn()}
90
+ />,
91
+ );
92
+
93
+ expect(screen.queryByRole('button', { name: 'Add to main sidebar' })).toBeNull();
94
+ await user.click(screen.getByRole('button', { name: 'More panel app actions' }));
95
+ await user.click(screen.getByRole('button', { name: 'Add to main sidebar' }));
96
+ expect(mainSidebarMutation.mutate).toHaveBeenCalledWith({
97
+ id: 'demo',
98
+ preferences: { mainSidebar: true },
99
+ });
100
+
101
+ rerender(
102
+ <PanelAppListItem
103
+ deletePending={false}
104
+ entry={{ ...baseEntry, mainSidebar: true }}
105
+ favoritePending={false}
106
+ onDelete={vi.fn()}
107
+ onOpen={vi.fn()}
108
+ onToggleFavorite={vi.fn()}
109
+ />,
110
+ );
111
+ expect(screen.queryByRole('button', { name: 'Remove from main sidebar' })).toBeNull();
112
+ await user.click(screen.getByRole('button', { name: 'More panel app actions' }));
113
+ await user.click(screen.getByRole('button', { name: 'Remove from main sidebar' }));
114
+ expect(mainSidebarMutation.mutate).toHaveBeenLastCalledWith({
115
+ id: 'demo',
116
+ preferences: { mainSidebar: false },
117
+ });
118
+ });
64
119
  });