@nextclaw/ui 0.12.19 → 0.12.20-beta.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 (185) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/dist/assets/api-BcqDx0tm.js +15 -0
  3. package/dist/assets/app-manager-provider-DVYBjif-.js +1 -0
  4. package/dist/assets/app-navigation.config-CMoWvFEI.js +1 -0
  5. package/dist/assets/{book-open-CVEuA0y5.js → book-open-DgLqYpNY.js} +1 -1
  6. package/dist/assets/{channels-list-page-BqhqaBf1.js → channels-list-page-CsoI4OJm.js} +2 -2
  7. package/dist/assets/{chat-D4KecKjB.js → chat-CA3aRmhx.js} +13 -12
  8. package/dist/assets/chat-page-gdSN6Pr6.js +1 -0
  9. package/dist/assets/chunk-JZWAC4HX-u4uYphxM.js +3 -0
  10. package/dist/assets/{config-split-page-BGjVACdO.js → config-split-page-BMRGuCJQ.js} +1 -1
  11. package/dist/assets/{createLucideIcon-PPrXCGK8.js → createLucideIcon-BZkY6emz.js} +1 -1
  12. package/dist/assets/desktop-update-config-CD6-2PfI.js +1 -0
  13. package/dist/assets/{dialog-CTCX7oLf.js → dialog-csshWetU.js} +1 -1
  14. package/dist/assets/{dist-FL5e8mMi.js → dist-Bl94Ahwx.js} +1 -1
  15. package/dist/assets/{doc-browser-C02neCIE.js → doc-browser-BUlCkZo2.js} +1 -1
  16. package/dist/assets/doc-browser-CzCV73NJ.js +1 -0
  17. package/dist/assets/doc-browser-Doh2541x.js +1 -0
  18. package/dist/assets/{doc-browser-context-C-WPOji4.js → doc-browser-context-DfLHAWbG.js} +1 -1
  19. package/dist/assets/{es2015-BNy4R8AC.js → es2015-JCM5-KtW.js} +1 -1
  20. package/dist/assets/{external-link-BNtqJE01.js → external-link-Sw3ah_JD.js} +1 -1
  21. package/dist/assets/{folder-QyJHVUNz.js → folder-D7-VTnkz.js} +1 -1
  22. package/dist/assets/{hash-BGYUE-zr.js → hash-zajSTDXZ.js} +1 -1
  23. package/dist/assets/i18n-C5Mibli1.js +1 -0
  24. package/dist/assets/index-BTDFuKka.js +2 -0
  25. package/dist/assets/index-CUmk8xFK.css +1 -0
  26. package/dist/assets/{key-round-DenCfA2w.js → key-round-CnI1mc9F.js} +1 -1
  27. package/dist/assets/loader-circle-B5i8oMMY.js +1 -0
  28. package/dist/assets/{logo-badge-CKAxvQFc.js → logo-badge-BQgKnVtz.js} +1 -1
  29. package/dist/assets/{logos-CqXnaJIm.js → logos-CqVm0q0W.js} +1 -1
  30. package/dist/assets/marketplace-page-DJGDpTAo.js +1 -0
  31. package/dist/assets/{marketplace-page-XnDa2ulT.js → marketplace-page-DxlxHCFm.js} +2 -2
  32. package/dist/assets/mcp-marketplace-page-5UjYRWOR.js +40 -0
  33. package/dist/assets/mcp-marketplace-page-C1XaHZZO.js +1 -0
  34. package/dist/assets/message-square-D6Z4NwpG.js +1 -0
  35. package/dist/assets/{model-config-ByeL6Toe.js → model-config-PccJ9XyH.js} +1 -1
  36. package/dist/assets/{notice-card-D00-02yg.js → notice-card-CCgk6FvF.js} +1 -1
  37. package/dist/assets/play-D8WJLnJe.js +1 -0
  38. package/dist/assets/plus-Di0KAkiO.js +1 -0
  39. package/dist/assets/{popover-AmJkxio3.js → popover-YAsxDBhY.js} +1 -1
  40. package/dist/assets/{provider-scoped-model-input-CfFJsJp-.js → provider-scoped-model-input-CzpF7cug.js} +1 -1
  41. package/dist/assets/{providers-list-HMQzW2WV.js → providers-list-8qDMER8o.js} +1 -1
  42. package/dist/assets/{refresh-ccw-B-dhb3yS.js → refresh-ccw-Bii4w8aB.js} +1 -1
  43. package/dist/assets/refresh-cw-BxojR62w.js +1 -0
  44. package/dist/assets/remote-D4TtLPAp.js +1 -0
  45. package/dist/assets/{rotate-cw-BWqAG3Fv.js → rotate-cw-1Xqa7LZ8.js} +1 -1
  46. package/dist/assets/runtime-config-page-D-4c5H5z.js +1 -0
  47. package/dist/assets/{save-DpdkGieJ.js → save--BVI5wZX.js} +1 -1
  48. package/dist/assets/search-config-D3a65l3r.js +1 -0
  49. package/dist/assets/{search-CQUdr7j_.js → search-vChioOoe.js} +1 -1
  50. package/dist/assets/{secrets-config-YCsGd1am.js → secrets-config-CoMlR_7i.js} +2 -2
  51. package/dist/assets/{select-DVUtSFHZ.js → select-DIZrwsKU.js} +1 -1
  52. package/dist/assets/{sessions-config-page-BKN-XdKr.js → sessions-config-page-Cc0TJStn.js} +2 -2
  53. package/dist/assets/{setting-row-Cb5-lFs-.js → setting-row-DiQyrE81.js} +1 -1
  54. package/dist/assets/{settings-DgtZZlnF.js → settings-CiRChctQ.js} +1 -1
  55. package/dist/assets/skeleton-CFQRIUzt.js +1 -0
  56. package/dist/assets/{sparkles-DNSCyDhL.js → sparkles-D1ZKWdm4.js} +1 -1
  57. package/dist/assets/{status-dot-X_j51OfA.js → status-dot-Dv_hiUVa.js} +1 -1
  58. package/dist/assets/{tabs-custom-CcWmekaF.js → tabs-custom-CsACkVji.js} +1 -1
  59. package/dist/assets/{tag-chip-fdbK2wE6.js → tag-chip-C3wDBe_-.js} +1 -1
  60. package/dist/assets/theme-provider-aOmrJ9J6.js +1 -0
  61. package/dist/assets/{tooltip-BkZCQcKw.js → tooltip-Dq5Xehpk.js} +1 -1
  62. package/dist/assets/{trash-2-CqciSCsg.js → trash-2-rY9ZteZX.js} +1 -1
  63. package/dist/assets/use-config-BQJjq1mP.js +1 -0
  64. package/dist/assets/{use-confirm-dialog-DSrb9205.js → use-confirm-dialog-DBoV5n5P.js} +1 -1
  65. package/dist/assets/{use-infinite-scroll-loader-DmowtyTI.js → use-infinite-scroll-loader-JAicqVC5.js} +1 -1
  66. package/dist/assets/{use-viewport-layout-CaALCA51.js → use-viewport-layout-BX3XqzJ4.js} +1 -1
  67. package/dist/assets/x-DpTzXQcX.js +1 -0
  68. package/dist/index.html +40 -39
  69. package/package.json +9 -6
  70. package/src/app/hooks/use-realtime-query-bridge.ts +5 -5
  71. package/src/app/index.tsx +7 -1
  72. package/src/features/channels/components/config/channel-form.tsx +3 -3
  73. package/src/features/channels/components/config/weixin-channel-auth-section.tsx +1 -1
  74. package/src/features/chat/components/conversation/chat-conversation-panel.tsx +1 -0
  75. package/src/features/chat/components/conversation/chat-input-bar.container.tsx +9 -4
  76. package/src/features/chat/components/conversation/chat-message-list.container.test.tsx +64 -6
  77. package/src/features/chat/components/conversation/chat-message-list.container.tsx +185 -17
  78. package/src/features/chat/components/session/session-context-icon.tsx +1 -4
  79. package/src/features/chat/hooks/use-ncp-chat-derived-state.ts +3 -1
  80. package/src/features/chat/hooks/use-ncp-chat-page-data.ts +7 -6
  81. package/src/features/chat/hooks/use-ncp-session-conversation.test.tsx +74 -2
  82. package/src/features/chat/hooks/use-ncp-session-conversation.ts +32 -10
  83. package/src/features/chat/hooks/use-selected-session-context-window-indicator.ts +20 -0
  84. package/src/features/chat/managers/ncp-chat-input.manager.test.ts +25 -0
  85. package/src/features/chat/managers/ncp-chat-input.manager.ts +5 -1
  86. package/src/features/chat/pages/ncp-chat-page.test.ts +22 -8
  87. package/src/features/chat/pages/ncp-chat-page.tsx +15 -11
  88. package/src/features/chat/stores/chat-thread.store.ts +8 -2
  89. package/src/features/chat/utils/chat-context-window-indicator.utils.ts +50 -0
  90. package/src/features/chat/utils/chat-runtime.utils.ts +1 -1
  91. package/src/features/chat/utils/chat-session-preference-governance.utils.test.tsx +114 -0
  92. package/src/features/chat/utils/chat-session-preference-governance.utils.ts +30 -36
  93. package/src/features/chat/utils/ncp-chat-runtime-availability.utils.test.ts +165 -0
  94. package/src/features/chat/utils/ncp-chat-runtime-availability.utils.ts +50 -0
  95. package/src/features/chat/utils/ncp-session-adapter.utils.test.ts +27 -0
  96. package/src/features/chat/utils/ncp-session-adapter.utils.ts +6 -4
  97. package/src/features/chat/utils/ncp-session-context-metadata.utils.ts +121 -0
  98. package/src/features/chat/utils/session-context.utils.ts +1 -2
  99. package/src/features/system-status/components/config/runtime-config-editor.tsx +6 -0
  100. package/src/features/system-status/components/config/runtime-settings-card.tsx +12 -0
  101. package/src/features/system-status/components/desktop-update-config.test.tsx +17 -7
  102. package/src/features/system-status/components/desktop-update-config.tsx +75 -30
  103. package/src/features/system-status/hooks/use-system-status.ts +0 -11
  104. package/src/features/system-status/index.ts +4 -1
  105. package/src/features/system-status/managers/runtime-update.manager.ts +330 -0
  106. package/src/features/system-status/managers/system-status.manager.test.ts +0 -25
  107. package/src/features/system-status/managers/system-status.manager.ts +1 -30
  108. package/src/features/system-status/stores/runtime-update.store.ts +24 -0
  109. package/src/features/system-status/types/system-status.types.ts +0 -2
  110. package/src/features/system-status/utils/runtime-config-agent.utils.ts +6 -1
  111. package/src/features/system-status/utils/system-status.utils.test.ts +1 -85
  112. package/src/features/system-status/utils/system-status.utils.ts +1 -23
  113. package/src/platforms/desktop/managers/desktop-update.manager.ts +6 -0
  114. package/src/platforms/desktop/types/desktop-update.types.ts +21 -19
  115. package/src/shared/components/common/brand-header.test.tsx +142 -0
  116. package/src/shared/components/common/brand-header.tsx +93 -0
  117. package/src/shared/components/cron-config.tsx +1 -1
  118. package/src/shared/components/doc-browser/doc-browser-context.test.tsx +1 -1
  119. package/src/shared/components/doc-browser/doc-browser.tsx +1 -1
  120. package/src/shared/components/search-config.tsx +3 -3
  121. package/src/shared/lib/api/README.md +3 -0
  122. package/src/shared/lib/api/index.ts +13 -11
  123. package/src/shared/lib/api/ncp-session.test.ts +17 -18
  124. package/src/shared/lib/api/ncp-session.types.ts +92 -0
  125. package/src/shared/lib/api/raw-client.utils.ts +3 -126
  126. package/src/shared/lib/api/services/agents.service.ts +18 -0
  127. package/src/shared/lib/api/services/channel-auth.service.ts +21 -0
  128. package/src/shared/lib/api/{client.ts → services/client.service.ts} +45 -1
  129. package/src/shared/lib/api/services/config.service.ts +171 -0
  130. package/src/shared/lib/api/services/marketplace.service.ts +66 -0
  131. package/src/shared/lib/api/services/mcp-marketplace.service.ts +70 -0
  132. package/src/shared/lib/api/services/ncp-attachments.service.ts +14 -0
  133. package/src/shared/lib/api/services/ncp-session.service.ts +39 -0
  134. package/src/shared/lib/api/services/remote.service.ts +50 -0
  135. package/src/shared/lib/api/services/runtime-control.service.ts +18 -0
  136. package/src/shared/lib/api/services/runtime-update.service.ts +26 -0
  137. package/src/shared/lib/api/services/server-path.service.ts +16 -0
  138. package/src/shared/lib/api/types.ts +9 -74
  139. package/src/shared/lib/i18n/{chat.ts → chat-labels.utils.ts} +13 -1
  140. package/src/shared/lib/i18n/desktop-update-labels.utils.ts +65 -0
  141. package/src/shared/lib/i18n/index.ts +4 -5
  142. package/src/shared/lib/i18n/runtime/i18n-language-owner.ts +5 -5
  143. package/src/shared/lib/transport/index.ts +1 -0
  144. package/src/shared/lib/transport/local-transport.service.ts +24 -4
  145. package/src/shared/lib/transport/remote-transport.service.ts +2 -2
  146. package/src/shared/lib/transport/request-raw-api-response.utils.ts +133 -0
  147. package/src/shared/lib/transport/transport.types.ts +8 -2
  148. package/src/shared/lib/ui-document-title/index.ts +1 -1
  149. package/tsconfig.json +1 -0
  150. package/dist/assets/api-BurjmW4A.js +0 -15
  151. package/dist/assets/app-manager-provider-DhxUmyTv.js +0 -1
  152. package/dist/assets/app-navigation.config-Bpd16Pem.js +0 -1
  153. package/dist/assets/chat-page-Cc7n80lW.js +0 -1
  154. package/dist/assets/chunk-JZWAC4HX-24FLdHl7.js +0 -3
  155. package/dist/assets/desktop-update-config-fMLlSStv.js +0 -1
  156. package/dist/assets/doc-browser-COj7x090.js +0 -1
  157. package/dist/assets/doc-browser-fyn7eDTp.js +0 -1
  158. package/dist/assets/i18n-CM4y8Mw9.js +0 -1
  159. package/dist/assets/index-CtVSzMPM.js +0 -2
  160. package/dist/assets/index-N3hjuljD.css +0 -1
  161. package/dist/assets/loader-circle-R23uEPkM.js +0 -1
  162. package/dist/assets/marketplace-page-mF-M5mku.js +0 -1
  163. package/dist/assets/mcp-marketplace-page-BArKWcRZ.js +0 -40
  164. package/dist/assets/mcp-marketplace-page-DBUcIIHJ.js +0 -1
  165. package/dist/assets/message-square-Dm34zD6k.js +0 -1
  166. package/dist/assets/play-ul4L6MWm.js +0 -1
  167. package/dist/assets/plus-D14303DH.js +0 -1
  168. package/dist/assets/remote-B4ELSd3u.js +0 -1
  169. package/dist/assets/runtime-config-page-N4FP6H0M.js +0 -1
  170. package/dist/assets/search-config-B62TY-z2.js +0 -1
  171. package/dist/assets/skeleton-BCPi52jT.js +0 -1
  172. package/dist/assets/theme-provider-WTWq_jYq.js +0 -1
  173. package/dist/assets/use-config-CyvhbRhf.js +0 -1
  174. package/dist/assets/x-tYcSDsrY.js +0 -1
  175. package/src/shared/lib/api/agents.ts +0 -34
  176. package/src/shared/lib/api/channel-auth.ts +0 -35
  177. package/src/shared/lib/api/config.ts +0 -362
  178. package/src/shared/lib/api/marketplace.ts +0 -156
  179. package/src/shared/lib/api/mcp-marketplace.ts +0 -138
  180. package/src/shared/lib/api/ncp-attachments.ts +0 -41
  181. package/src/shared/lib/api/ncp-session.ts +0 -78
  182. package/src/shared/lib/api/remote.ts +0 -86
  183. package/src/shared/lib/api/runtime-control.ts +0 -34
  184. package/src/shared/lib/api/server-path.ts +0 -46
  185. /package/dist/assets/{config-hints-CPNzbMEp.js → config-hints-MogHYQ8G.js} +0 -0
@@ -8,12 +8,16 @@ const captures = vi.hoisted(() => ({
8
8
  language: "en",
9
9
  }));
10
10
 
11
- vi.mock("@nextclaw/agent-chat-ui", () => ({
12
- ChatMessageList: (props: { messages: unknown[]; texts?: Record<string, unknown> }) => {
13
- captures.renders.push(props);
14
- return <div data-testid="chat-message-list" />;
15
- },
16
- }));
11
+ vi.mock("@nextclaw/agent-chat-ui", async (importOriginal) => {
12
+ const actual = await importOriginal();
13
+ return {
14
+ ...(actual as object),
15
+ ChatMessageList: (props: { messages: unknown[]; texts?: Record<string, unknown> }) => {
16
+ captures.renders.push(props);
17
+ return <div data-testid="chat-message-list" />;
18
+ },
19
+ };
20
+ });
17
21
 
18
22
  vi.mock("@/app/components/i18n-provider", () => ({
19
23
  useI18n: () => ({ language: captures.language }),
@@ -211,3 +215,57 @@ it("passes localized attachment card texts to the shared chat UI", () => {
211
215
  },
212
216
  });
213
217
  });
218
+
219
+ it("renders context compaction as an in-flow divider instead of a chat message", () => {
220
+ const beforeMessage = {
221
+ id: "message-before",
222
+ sessionId: "session-1",
223
+ role: "user",
224
+ status: "final",
225
+ timestamp: "2026-05-05T11:59:00.000Z",
226
+ parts: [{ type: "text", text: "before" }],
227
+ } satisfies NcpMessage;
228
+ const afterMessage = {
229
+ id: "message-after",
230
+ sessionId: "session-1",
231
+ role: "assistant",
232
+ status: "final",
233
+ timestamp: "2026-05-05T12:01:00.000Z",
234
+ parts: [{ type: "text", text: "after" }],
235
+ } satisfies NcpMessage;
236
+ const compactionMessage = {
237
+ id: "ctx-message",
238
+ sessionId: "session-1",
239
+ role: "service",
240
+ status: "final",
241
+ timestamp: "2026-05-05T12:00:00.000Z",
242
+ metadata: {
243
+ nextclaw_timeline_kind: "context_compaction",
244
+ checkpoint: {
245
+ id: "ctx-1",
246
+ status: "compressed",
247
+ summary: "Compressed Earlier Context",
248
+ coveredMessageCount: 8,
249
+ coveredSessionMessageCount: 8,
250
+ originalEstimatedTokens: 76000,
251
+ projectedEstimatedTokens: 51000,
252
+ createdAt: "2026-05-05T11:59:50.000Z",
253
+ updatedAt: "2026-05-05T12:00:00.000Z",
254
+ },
255
+ },
256
+ parts: [{ type: "text", text: "较早上下文已自动压缩" }],
257
+ } satisfies NcpMessage;
258
+
259
+ const { getByText } = render(
260
+ <ChatMessageListContainer
261
+ messages={[beforeMessage, compactionMessage, afterMessage]}
262
+ isSending={false}
263
+ />,
264
+ );
265
+
266
+ expect(getByText("chatContextCompactionCompressed")).toBeTruthy();
267
+ const renderedGroups = captures.renders.map((rendered) => rendered.messages);
268
+ expect(renderedGroups).toHaveLength(2);
269
+ expect(renderedGroups[0]).toMatchObject([{ id: "message-before" }]);
270
+ expect(renderedGroups[1]).toMatchObject([{ id: "message-after" }]);
271
+ });
@@ -13,6 +13,10 @@ import {
13
13
  } from "@/features/chat/utils/chat-message.utils";
14
14
  import { readInlineTokensFromMetadata } from "@/features/chat/utils/chat-inline-token.utils";
15
15
  import { adaptNcpMessageToUiMessage } from "@/features/chat/utils/ncp-session-adapter.utils";
16
+ import {
17
+ readContextCompactionTimeline,
18
+ type ContextCompactionTimelineView,
19
+ } from "@/features/chat/utils/ncp-session-context-metadata.utils";
16
20
  import { AgentIdentityAvatar } from "@/shared/components/common/agent-identity";
17
21
  import { useI18n } from "@/app/components/i18n-provider";
18
22
  import { formatDateTime, t } from "@/shared/lib/i18n";
@@ -30,6 +34,24 @@ const messageViewModelCache = new WeakMap<
30
34
  { language: string; viewModel: ChatMessageViewModel }
31
35
  >();
32
36
 
37
+ type ChatTimelineItem =
38
+ | {
39
+ kind: "messages";
40
+ key: string;
41
+ messages: ChatMessageViewModel[];
42
+ }
43
+ | {
44
+ kind: "compaction";
45
+ key: string;
46
+ checkpoint: ContextCompactionTimelineView;
47
+ };
48
+
49
+ type TimelineCheckpointPlacement = {
50
+ key: string;
51
+ checkpoint: ContextCompactionTimelineView;
52
+ boundaryIndex: number;
53
+ };
54
+
33
55
  function buildChatMessageAdapterTexts(
34
56
  language: string,
35
57
  ): ChatMessageAdapterTexts {
@@ -84,6 +106,137 @@ function buildChatMessageTexts(language: string) {
84
106
  };
85
107
  }
86
108
 
109
+ function ChatContextCompactionDivider({
110
+ checkpoint,
111
+ }: {
112
+ checkpoint: ContextCompactionTimelineView;
113
+ }) {
114
+ const title = [
115
+ `${t("chatContextCompactionCoveredMessages")}: ${checkpoint.coveredSessionMessageCount}`,
116
+ `${t("chatContextCompactionOriginalTokens")}: ${checkpoint.originalEstimatedTokens}`,
117
+ `${t("chatContextCompactionProjectedTokens")}: ${checkpoint.projectedEstimatedTokens}`,
118
+ ].join("\n");
119
+ return (
120
+ <div className="my-4 flex items-center gap-3 text-[11px] text-gray-500" title={title}>
121
+ <div className="h-px flex-1 bg-gray-200" />
122
+ <div className="inline-flex items-center gap-2 rounded-full border border-gray-200 bg-gray-50 px-3 py-1">
123
+ {checkpoint.status === "compressing" ? (
124
+ <span className="h-1.5 w-1.5 rounded-full bg-gray-400 animate-pulse" />
125
+ ) : (
126
+ <span className="h-1.5 w-1.5 rounded-full bg-gray-300" />
127
+ )}
128
+ <span>
129
+ {checkpoint.status === "compressing"
130
+ ? t("chatContextCompactionCompressing")
131
+ : t("chatContextCompactionCompressed")}
132
+ </span>
133
+ </div>
134
+ <div className="h-px flex-1 bg-gray-200" />
135
+ </div>
136
+ );
137
+ }
138
+
139
+ function resolveCompactionBoundaryIndex(params: {
140
+ rawMessages: readonly NcpMessage[];
141
+ normalRawMessages: readonly NcpMessage[];
142
+ rawMessageId: string;
143
+ }): number {
144
+ const {
145
+ normalRawMessages,
146
+ rawMessageId,
147
+ rawMessages,
148
+ } = params;
149
+ const physicalIndex = rawMessages.findIndex(
150
+ (message) => message.id === rawMessageId,
151
+ );
152
+ if (physicalIndex < 0) {
153
+ return normalRawMessages.length - 1;
154
+ }
155
+ const previousNormalCount = rawMessages
156
+ .slice(0, physicalIndex)
157
+ .filter((message) => !readContextCompactionTimeline(message)).length;
158
+ return previousNormalCount - 1;
159
+ }
160
+
161
+ function buildTimelineItems(params: {
162
+ rawMessages: readonly NcpMessage[];
163
+ messages: ChatMessageViewModel[];
164
+ }): ChatTimelineItem[] {
165
+ const normalRawMessages = params.rawMessages.filter(
166
+ (message) => !readContextCompactionTimeline(message),
167
+ );
168
+ const checkpoints = params.rawMessages
169
+ .map((message) => ({
170
+ rawMessageId: message.id,
171
+ checkpoint: readContextCompactionTimeline(message),
172
+ }))
173
+ .filter(
174
+ (entry): entry is { rawMessageId: string; checkpoint: ContextCompactionTimelineView } =>
175
+ Boolean(entry.checkpoint),
176
+ )
177
+ .map((entry) => ({
178
+ key: entry.rawMessageId,
179
+ checkpoint: entry.checkpoint,
180
+ boundaryIndex: resolveCompactionBoundaryIndex({
181
+ rawMessages: params.rawMessages,
182
+ normalRawMessages,
183
+ rawMessageId: entry.rawMessageId,
184
+ }),
185
+ }))
186
+ .sort((left, right) => left.boundaryIndex - right.boundaryIndex);
187
+
188
+ const items: ChatTimelineItem[] = [];
189
+ let pendingMessages: ChatMessageViewModel[] = [];
190
+ let checkpointCursor = 0;
191
+ const flushPendingMessages = (key: string) => {
192
+ if (pendingMessages.length === 0) {
193
+ return;
194
+ }
195
+ items.push({
196
+ kind: "messages",
197
+ key,
198
+ messages: pendingMessages,
199
+ });
200
+ pendingMessages = [];
201
+ };
202
+
203
+ normalRawMessages.forEach((rawMessage, index) => {
204
+ const message = params.messages[index];
205
+ if (message) {
206
+ pendingMessages.push(message);
207
+ }
208
+ while (checkpointCursor < checkpoints.length && checkpoints[checkpointCursor]?.boundaryIndex <= index) {
209
+ const currentCheckpoint = checkpoints[checkpointCursor];
210
+ flushPendingMessages(`messages-before-${currentCheckpoint.key}`);
211
+ items.push({
212
+ kind: "compaction",
213
+ key: currentCheckpoint.key,
214
+ checkpoint: currentCheckpoint.checkpoint,
215
+ });
216
+ checkpointCursor += 1;
217
+ }
218
+ });
219
+ while (checkpointCursor < checkpoints.length) {
220
+ const currentCheckpoint = checkpoints[checkpointCursor];
221
+ flushPendingMessages(`messages-before-${currentCheckpoint.key}`);
222
+ items.push({
223
+ kind: "compaction",
224
+ key: currentCheckpoint.key,
225
+ checkpoint: currentCheckpoint.checkpoint,
226
+ });
227
+ checkpointCursor += 1;
228
+ }
229
+ flushPendingMessages("messages-final");
230
+ if (items.length === 0) {
231
+ items.push({
232
+ kind: "messages",
233
+ key: "messages-empty",
234
+ messages: [],
235
+ });
236
+ }
237
+ return items;
238
+ }
239
+
87
240
  export function ChatMessageListContainer({
88
241
  messages: rawMessages,
89
242
  isSending,
@@ -98,10 +251,13 @@ export function ChatMessageListContainer({
98
251
  );
99
252
 
100
253
  const messages = useMemo(() => {
101
- return rawMessages.map((message) => {
254
+ return rawMessages.flatMap((message) => {
255
+ if (readContextCompactionTimeline(message)) {
256
+ return [];
257
+ }
102
258
  const cached = messageViewModelCache.get(message);
103
259
  if (cached && cached.language === language) {
104
- return cached.viewModel;
260
+ return [cached.viewModel];
105
261
  }
106
262
 
107
263
  const uiMessage = adaptNcpMessageToUiMessage(message);
@@ -121,7 +277,7 @@ export function ChatMessageListContainer({
121
277
  });
122
278
 
123
279
  messageViewModelCache.set(message, { language, viewModel });
124
- return viewModel;
280
+ return [viewModel];
125
281
  });
126
282
  }, [language, rawMessages, texts]);
127
283
 
@@ -138,22 +294,34 @@ export function ChatMessageListContainer({
138
294
  () => buildChatMessageTexts(language),
139
295
  [language],
140
296
  );
297
+ const timelineItems = useMemo(
298
+ () => buildTimelineItems({ rawMessages, messages }),
299
+ [messages, rawMessages],
300
+ );
141
301
 
142
302
  return (
143
- <ChatMessageList
144
- messages={messages}
145
- isSending={isSending}
146
- hasAssistantDraft={hasAssistantDraft}
147
- className={className}
148
- texts={messageTexts}
149
- onToolAction={onToolAction}
150
- onFileOpen={onFileOpen}
151
- renderToolAgent={(agentId) => (
152
- <AgentIdentityAvatar
153
- agentId={agentId}
154
- className="h-4 w-4 shrink-0"
155
- />
303
+ <div className={className}>
304
+ {timelineItems.map((item, index) =>
305
+ item.kind === "compaction" ? (
306
+ <ChatContextCompactionDivider key={item.key} checkpoint={item.checkpoint} />
307
+ ) : (
308
+ <ChatMessageList
309
+ key={item.key}
310
+ messages={item.messages}
311
+ isSending={index === timelineItems.length - 1 ? isSending : false}
312
+ hasAssistantDraft={hasAssistantDraft}
313
+ texts={messageTexts}
314
+ onToolAction={onToolAction}
315
+ onFileOpen={onFileOpen}
316
+ renderToolAgent={(agentId) => (
317
+ <AgentIdentityAvatar
318
+ agentId={agentId}
319
+ className="h-4 w-4 shrink-0"
320
+ />
321
+ )}
322
+ />
323
+ ),
156
324
  )}
157
- />
325
+ </div>
158
326
  );
159
327
  }
@@ -3,7 +3,7 @@ import { resolveAppResourceUri } from '@/shared/lib/app-resource-uri';
3
3
  import { LogoBadge } from '@/shared/components/common/logo-badge';
4
4
  import { getChannelLogo } from '@/shared/lib/logos';
5
5
  import { cn } from '@/shared/lib/utils';
6
- import { AlarmClock, Bot, HeartPulse } from 'lucide-react';
6
+ import { AlarmClock, Bot } from 'lucide-react';
7
7
 
8
8
  export function SessionContextIconNode({ icon, className }: { icon: SessionContextIcon; className?: string }) {
9
9
  if (icon.kind === 'channel-logo') {
@@ -21,9 +21,6 @@ export function SessionContextIconNode({ icon, className }: { icon: SessionConte
21
21
  />
22
22
  );
23
23
  }
24
- if (icon.icon === 'heartbeat') {
25
- return <HeartPulse className={cn('h-3.5 w-3.5', className)} />;
26
- }
27
24
  return <AlarmClock className={cn('h-3.5 w-3.5', className)} />;
28
25
  }
29
26
 
@@ -13,6 +13,7 @@ import type { ChatModelOption } from '@/features/chat/types/chat-input.types';
13
13
  import type { ChatChildSessionTab } from '@/features/chat/stores/chat-thread.store';
14
14
  import type { ChatSessionTypeOption } from '@/features/chat/hooks/use-chat-session-type-state';
15
15
  import { resolveSessionTypeLabel } from '@/features/chat/hooks/use-chat-session-type-state';
16
+ import { readNcpContextWindowValue } from '@/features/chat/utils/ncp-session-context-metadata.utils';
16
17
 
17
18
  function buildChildSessionTabs(params: {
18
19
  parentSessionKey: string | null;
@@ -121,7 +122,7 @@ export function useNcpChatSnapshotSync(params: {
121
122
  effectiveSessionProjectName: string | null;
122
123
  selectedSession: SessionEntryView | null;
123
124
  threadRef: MutableRefObject<HTMLDivElement | null>;
124
- agent: Pick<UseHydratedNcpAgentResult, 'isHydrating' | 'visibleMessages'>;
125
+ agent: Pick<UseHydratedNcpAgentResult, 'isHydrating' | 'snapshot' | 'visibleMessages'>;
125
126
  isAwaitingAssistantOutput: boolean;
126
127
  parentSession: SessionEntryView | null;
127
128
  childSessionTabs: ChatChildSessionTab[];
@@ -165,6 +166,7 @@ export function useNcpChatSnapshotSync(params: {
165
166
  messages: params.agent.visibleMessages,
166
167
  isSending: params.isSending,
167
168
  isAwaitingAssistantOutput: params.isAwaitingAssistantOutput,
169
+ contextWindow: readNcpContextWindowValue(params.agent.snapshot.contextWindow),
168
170
  parentSessionKey: params.parentSession?.key ?? null,
169
171
  parentSessionLabel: params.parentSession
170
172
  ? sessionDisplayName(params.parentSession)
@@ -6,8 +6,7 @@ import { sessionMatchesQuery } from '@/features/chat/utils/chat-session-display.
6
6
  import { adaptNcpSessionSummaries } from '@/features/chat/utils/ncp-session-adapter.utils';
7
7
  import { useChatSessionTypeState } from '@/features/chat/hooks/use-chat-session-type-state';
8
8
  import {
9
- resolveRecentSessionPreferredThinking,
10
- resolveRecentSessionPreferredModel,
9
+ resolveRecentSessionPreferredValue,
11
10
  useSyncSelectedModel,
12
11
  useSyncSelectedThinking
13
12
  } from '@/features/chat/utils/chat-session-preference-governance.utils';
@@ -83,19 +82,21 @@ function useRecentSessionPreferences(params: {
83
82
  const { sessions, sessionKey, sessionType } = params;
84
83
  const recentSessionPreferredModel = useMemo(
85
84
  () =>
86
- resolveRecentSessionPreferredModel({
85
+ resolveRecentSessionPreferredValue<string>({
87
86
  sessions,
88
87
  selectedSessionKey: sessionKey,
89
- sessionType
88
+ sessionType,
89
+ readPreference: (session) => session.preferredModel?.trim() || undefined
90
90
  }),
91
91
  [sessionKey, sessionType, sessions]
92
92
  );
93
93
  const recentSessionPreferredThinking = useMemo(
94
94
  () =>
95
- resolveRecentSessionPreferredThinking({
95
+ resolveRecentSessionPreferredValue<ThinkingLevel>({
96
96
  sessions,
97
97
  selectedSessionKey: sessionKey,
98
- sessionType
98
+ sessionType,
99
+ readPreference: (session) => session.preferredThinking ?? undefined
99
100
  }),
100
101
  [sessionKey, sessionType, sessions]
101
102
  );
@@ -1,5 +1,6 @@
1
1
  import { act, renderHook, waitFor } from "@testing-library/react";
2
2
  import { beforeEach, describe, expect, it, vi } from "vitest";
3
+ import type * as SharedApi from "@/shared/lib/api";
3
4
  import { fetchNcpSessionConversationSeed, useNcpSessionConversation } from "./use-ncp-session-conversation";
4
5
 
5
6
  const mocks = vi.hoisted(() => ({
@@ -30,7 +31,7 @@ const mocks = vi.hoisted(() => ({
30
31
  }));
31
32
 
32
33
  vi.mock("@/shared/lib/api", async (importOriginal) => {
33
- const actual = await importOriginal<typeof import("@/shared/lib/api")>();
34
+ const actual = await importOriginal<typeof SharedApi>();
34
35
  return {
35
36
  ...actual,
36
37
  fetchNcpSessionMessages: mocks.fetchNcpSessionMessages,
@@ -51,7 +52,13 @@ vi.mock("@nextclaw/ncp-http-agent-client", () => ({
51
52
  }));
52
53
 
53
54
  vi.mock("@/features/system-status", () => ({
54
- useChatRuntimeAvailability: vi.fn(() => mocks.runtimeAvailability),
55
+ useSystemStatus: vi.fn(() => ({
56
+ ...mocks.runtimeAvailability,
57
+ lifecyclePhase: mocks.runtimeAvailability.phase,
58
+ activeSystemAction: null,
59
+ bootstrapStatus: null,
60
+ lastError: null,
61
+ })),
55
62
  }));
56
63
 
57
64
  describe("useNcpSessionConversation", () => {
@@ -70,6 +77,19 @@ describe("useNcpSessionConversation", () => {
70
77
  status: "running",
71
78
  total: 1,
72
79
  messages: [{ id: "msg-1" }],
80
+ contextWindow: {
81
+ usedContextTokens: 42,
82
+ totalContextTokens: 100,
83
+ prunedUsedContextTokens: 42,
84
+ availableContextTokens: 58,
85
+ droppedHistoryCount: 0,
86
+ truncatedToolResultCount: 0,
87
+ truncatedSystemPrompt: false,
88
+ truncatedUserMessage: false,
89
+ compacted: false,
90
+ compactedMessageCount: 0,
91
+ updatedAt: "2026-05-05T00:00:00.000Z",
92
+ },
73
93
  });
74
94
 
75
95
  const result = await fetchNcpSessionConversationSeed(
@@ -82,6 +102,19 @@ describe("useNcpSessionConversation", () => {
82
102
  expect(result).toEqual({
83
103
  messages: [{ id: "msg-1" }],
84
104
  status: "running",
105
+ contextWindow: {
106
+ usedContextTokens: 42,
107
+ totalContextTokens: 100,
108
+ prunedUsedContextTokens: 42,
109
+ availableContextTokens: 58,
110
+ droppedHistoryCount: 0,
111
+ truncatedToolResultCount: 0,
112
+ truncatedSystemPrompt: false,
113
+ truncatedUserMessage: false,
114
+ compacted: false,
115
+ compactedMessageCount: 0,
116
+ updatedAt: "2026-05-05T00:00:00.000Z",
117
+ },
85
118
  });
86
119
  });
87
120
 
@@ -113,6 +146,45 @@ describe("useNcpSessionConversation", () => {
113
146
  expect(mocks.hydratedCalls[1]?.client).toBe(mocks.clientInstances[1]);
114
147
  });
115
148
 
149
+ it("exposes the hydrated session context window without changing the generic ncp agent seed", async () => {
150
+ const contextWindow = {
151
+ usedContextTokens: 42,
152
+ totalContextTokens: 100,
153
+ prunedUsedContextTokens: 42,
154
+ availableContextTokens: 58,
155
+ droppedHistoryCount: 0,
156
+ truncatedToolResultCount: 0,
157
+ truncatedSystemPrompt: false,
158
+ truncatedUserMessage: false,
159
+ compacted: false,
160
+ compactedMessageCount: 0,
161
+ updatedAt: "2026-05-05T00:00:00.000Z",
162
+ };
163
+ mocks.fetchNcpSessionMessages.mockResolvedValue({
164
+ sessionId: "session-1",
165
+ status: "idle",
166
+ total: 0,
167
+ messages: [],
168
+ contextWindow,
169
+ });
170
+
171
+ const { result, rerender } = renderHook(() => useNcpSessionConversation("session-1"));
172
+ const loadSeed = mocks.hydratedCalls[0]?.loadSeed as (
173
+ sessionId: string,
174
+ signal: AbortSignal
175
+ ) => Promise<{ messages: unknown[]; status: string }>;
176
+
177
+ await act(async () => {
178
+ await expect(loadSeed("session-1", new AbortController().signal)).resolves.toEqual({
179
+ messages: [],
180
+ status: "idle",
181
+ });
182
+ });
183
+ rerender();
184
+
185
+ expect(result.current.snapshot.contextWindow).toEqual(contextWindow);
186
+ });
187
+
116
188
  it("retries hydration once the runtime becomes ready after a startup placeholder error", async () => {
117
189
  mocks.useHydratedNcpAgent.mockImplementation(() => ({
118
190
  snapshot: {
@@ -1,9 +1,9 @@
1
- import { useCallback, useEffect, useRef, useState } from "react";
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
2
  import { NcpHttpAgentClientEndpoint } from "@nextclaw/ncp-http-agent-client";
3
3
  import { useHydratedNcpAgent, type NcpConversationSeed } from "@nextclaw/ncp-react";
4
- import { API_BASE, fetchNcpSessionMessages } from "@/shared/lib/api";
4
+ import { API_BASE, fetchNcpSessionMessages, type SessionContextWindowView } from "@/shared/lib/api";
5
5
  import { createNcpAppClientFetch } from "@/features/chat/utils/ncp-app-client-fetch.utils";
6
- import { useChatRuntimeAvailability } from "@/features/system-status";
6
+ import { useSystemStatus } from "@/features/system-status";
7
7
 
8
8
  const DEFAULT_MESSAGE_LIMIT = 300;
9
9
  const NCP_AGENT_UNAVAILABLE_DURING_STARTUP = "ncp agent unavailable during startup";
@@ -12,6 +12,10 @@ type UseNcpSessionConversationOptions = {
12
12
  messageLimit?: number;
13
13
  };
14
14
 
15
+ type NcpConversationSeedWithContextWindow = NcpConversationSeed & {
16
+ contextWindow?: SessionContextWindowView | null;
17
+ };
18
+
15
19
  function isMissingNcpSessionError(error: unknown): boolean {
16
20
  if (!(error instanceof Error)) {
17
21
  return false;
@@ -40,7 +44,7 @@ export async function fetchNcpSessionConversationSeed(
40
44
  sessionId: string,
41
45
  signal: AbortSignal,
42
46
  messageLimit = DEFAULT_MESSAGE_LIMIT,
43
- ): Promise<NcpConversationSeed> {
47
+ ): Promise<NcpConversationSeedWithContextWindow> {
44
48
  signal.throwIfAborted();
45
49
 
46
50
  try {
@@ -49,6 +53,7 @@ export async function fetchNcpSessionConversationSeed(
49
53
  return {
50
54
  messages: response.messages,
51
55
  status: response.status ?? "idle",
56
+ contextWindow: response.contextWindow ?? null,
52
57
  };
53
58
  } catch (error) {
54
59
  signal.throwIfAborted();
@@ -92,13 +97,24 @@ export function useNcpSessionConversation(
92
97
  options: UseNcpSessionConversationOptions = {},
93
98
  ) {
94
99
  const [client] = useState(() => createNcpSessionConversationClient());
95
- const runtimeAvailability = useChatRuntimeAvailability();
100
+ const systemStatus = useSystemStatus();
96
101
  const [hydrationRetryVersion, setHydrationRetryVersion] = useState(0);
102
+ const [seedContextWindow, setSeedContextWindow] = useState<SessionContextWindowView | null>(null);
97
103
  const messageLimit = options.messageLimit ?? DEFAULT_MESSAGE_LIMIT;
104
+ useEffect(() => {
105
+ setSeedContextWindow(null);
106
+ }, [sessionId]);
98
107
  const loadSeed = useCallback(
99
- (targetSessionId: string, signal: AbortSignal) => {
108
+ async (targetSessionId: string, signal: AbortSignal) => {
100
109
  void hydrationRetryVersion;
101
- return fetchNcpSessionConversationSeed(targetSessionId, signal, messageLimit);
110
+ const seed = await fetchNcpSessionConversationSeed(targetSessionId, signal, messageLimit);
111
+ if (!signal.aborted) {
112
+ setSeedContextWindow(seed.contextWindow ?? null);
113
+ }
114
+ return {
115
+ messages: seed.messages,
116
+ status: seed.status,
117
+ };
102
118
  },
103
119
  [hydrationRetryVersion, messageLimit],
104
120
  );
@@ -110,12 +126,18 @@ export function useNcpSessionConversation(
110
126
  const currentAgentError =
111
127
  agent.hydrateError?.message ?? agent.snapshot.error?.message ?? null;
112
128
  const readyRetrySignature =
113
- runtimeAvailability.phase === "ready" &&
129
+ systemStatus.phase === "ready" &&
114
130
  isNcpAgentStartupUnavailableErrorMessage(currentAgentError)
115
- ? `${sessionId}:${runtimeAvailability.lastReadyAt ?? 0}`
131
+ ? `${sessionId}:${systemStatus.lastReadyAt ?? 0}`
116
132
  : null;
117
133
  useSyncReadyRetryVersion(readyRetrySignature, () => {
118
134
  setHydrationRetryVersion((current) => current + 1);
119
135
  });
120
- return agent;
136
+ return useMemo(() => ({
137
+ ...agent,
138
+ snapshot: {
139
+ ...agent.snapshot,
140
+ contextWindow: agent.snapshot.contextWindow ?? seedContextWindow,
141
+ },
142
+ }), [agent, seedContextWindow]);
121
143
  }
@@ -0,0 +1,20 @@
1
+ import { useMemo } from 'react';
2
+ import type { ChatContextWindowIndicator } from '@nextclaw/agent-chat-ui';
3
+ import { useChatSessionListStore } from '@/features/chat/stores/chat-session-list.store';
4
+ import { useChatThreadStore } from '@/features/chat/stores/chat-thread.store';
5
+ import { buildChatContextWindowIndicator } from '@/features/chat/utils/chat-context-window-indicator.utils';
6
+
7
+ export function useSelectedSessionContextWindowIndicator(): ChatContextWindowIndicator | null {
8
+ const selectedSessionKey = useChatSessionListStore((state) => state.snapshot.selectedSessionKey);
9
+ const draftSessionKey = useChatSessionListStore((state) => state.snapshot.draftSessionKey);
10
+ const liveSessionKey = useChatThreadStore((state) => state.snapshot.sessionKey);
11
+ const liveContextWindow = useChatThreadStore((state) => state.snapshot.contextWindow);
12
+ const currentSessionKey = selectedSessionKey ?? draftSessionKey;
13
+
14
+ return useMemo(() => {
15
+ if (liveSessionKey === currentSessionKey && liveContextWindow) {
16
+ return buildChatContextWindowIndicator(liveContextWindow);
17
+ }
18
+ return null;
19
+ }, [currentSessionKey, liveContextWindow, liveSessionKey]);
20
+ }
@@ -33,6 +33,24 @@ describe('NcpChatInputManager', () => {
33
33
  state: {
34
34
  ...useSystemStatusStore.getState().state,
35
35
  lifecyclePhase: 'ready',
36
+ bootstrapStatus: {
37
+ phase: 'ready',
38
+ ncpAgent: {
39
+ state: 'ready',
40
+ },
41
+ pluginHydration: {
42
+ state: 'ready',
43
+ loadedPluginCount: 1,
44
+ totalPluginCount: 1,
45
+ },
46
+ channels: {
47
+ state: 'ready',
48
+ enabled: [],
49
+ },
50
+ remote: {
51
+ state: 'pending',
52
+ },
53
+ },
36
54
  },
37
55
  });
38
56
  useChatSessionListStore.setState({
@@ -125,6 +143,13 @@ describe('NcpChatInputManager', () => {
125
143
  state: {
126
144
  ...useSystemStatusStore.getState().state,
127
145
  lifecyclePhase: 'cold-starting',
146
+ bootstrapStatus: {
147
+ ...useSystemStatusStore.getState().state.bootstrapStatus!,
148
+ phase: 'kernel-starting',
149
+ ncpAgent: {
150
+ state: 'pending',
151
+ },
152
+ },
128
153
  },
129
154
  });
130
155
  const streamActionsManager = {