@nextclaw/ui 0.12.19 → 0.12.20-beta.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 (152) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/dist/assets/api-C412zuay.js +15 -0
  3. package/dist/assets/app-manager-provider-Cm-KiZZG.js +1 -0
  4. package/dist/assets/app-navigation.config-BORqHkbN.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-sISO_4Yj.js} +2 -2
  7. package/dist/assets/{chat-D4KecKjB.js → chat-ChCu7LQD.js} +13 -12
  8. package/dist/assets/chat-page-BCaNZJGT.js +1 -0
  9. package/dist/assets/{chunk-JZWAC4HX-24FLdHl7.js → chunk-JZWAC4HX-DvbcIVPf.js} +1 -1
  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-BfJ5iSeY.js +1 -0
  13. package/dist/assets/{dialog-CTCX7oLf.js → dialog-B-CXiFPZ.js} +1 -1
  14. package/dist/assets/{dist-FL5e8mMi.js → dist-DYVfg3q5.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-BXroVnPi.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-CUmk8xFK.css +1 -0
  25. package/dist/assets/index-CqPDhosM.js +2 -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-C8uaWkfd.js +1 -0
  31. package/dist/assets/{marketplace-page-XnDa2ulT.js → marketplace-page-C9oZ01rM.js} +2 -2
  32. package/dist/assets/mcp-marketplace-page-DuEixgSs.js +40 -0
  33. package/dist/assets/mcp-marketplace-page-rNqr6ZpD.js +1 -0
  34. package/dist/assets/message-square-D6Z4NwpG.js +1 -0
  35. package/dist/assets/{model-config-ByeL6Toe.js → model-config-mfhqEZBG.js} +1 -1
  36. package/dist/assets/{notice-card-D00-02yg.js → notice-card-CozHB03G.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-CPUPma-w.js} +1 -1
  40. package/dist/assets/{provider-scoped-model-input-CfFJsJp-.js → provider-scoped-model-input-CL9sti2I.js} +1 -1
  41. package/dist/assets/{providers-list-HMQzW2WV.js → providers-list-HPmL2akJ.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-oDlAdgVA.js +1 -0
  45. package/dist/assets/{rotate-cw-BWqAG3Fv.js → rotate-cw-1Xqa7LZ8.js} +1 -1
  46. package/dist/assets/runtime-config-page-BCshTAAE.js +1 -0
  47. package/dist/assets/{save-DpdkGieJ.js → save--BVI5wZX.js} +1 -1
  48. package/dist/assets/search-config-Bcnk9VlL.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-Dde-5Y1w.js} +2 -2
  51. package/dist/assets/{select-DVUtSFHZ.js → select-BELPuXLW.js} +1 -1
  52. package/dist/assets/{sessions-config-page-BKN-XdKr.js → sessions-config-page-CG49_0Z6.js} +2 -2
  53. package/dist/assets/{setting-row-Cb5-lFs-.js → setting-row-D5DtT6Ny.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-D9BWWgYg.js} +1 -1
  60. package/dist/assets/theme-provider-DeBrTglS.js +1 -0
  61. package/dist/assets/{tooltip-BkZCQcKw.js → tooltip-CI0rpNee.js} +1 -1
  62. package/dist/assets/{trash-2-CqciSCsg.js → trash-2-rY9ZteZX.js} +1 -1
  63. package/dist/assets/use-config-CrWZ_TSF.js +1 -0
  64. package/dist/assets/{use-confirm-dialog-DSrb9205.js → use-confirm-dialog-hbynwWf2.js} +1 -1
  65. package/dist/assets/{use-infinite-scroll-loader-DmowtyTI.js → use-infinite-scroll-loader-Cw5qQr3-.js} +1 -1
  66. package/dist/assets/{use-viewport-layout-CaALCA51.js → use-viewport-layout-CWHVDC6z.js} +1 -1
  67. package/dist/assets/x-DpTzXQcX.js +1 -0
  68. package/dist/index.html +40 -39
  69. package/package.json +7 -6
  70. package/src/app/index.tsx +7 -1
  71. package/src/features/channels/components/config/weixin-channel-auth-section.tsx +1 -1
  72. package/src/features/chat/components/conversation/chat-conversation-panel.tsx +1 -0
  73. package/src/features/chat/components/conversation/chat-input-bar.container.tsx +9 -4
  74. package/src/features/chat/components/conversation/chat-message-list.container.test.tsx +64 -6
  75. package/src/features/chat/components/conversation/chat-message-list.container.tsx +185 -17
  76. package/src/features/chat/components/session/session-context-icon.tsx +1 -4
  77. package/src/features/chat/hooks/use-ncp-chat-derived-state.ts +3 -1
  78. package/src/features/chat/hooks/use-ncp-session-conversation.test.tsx +74 -2
  79. package/src/features/chat/hooks/use-ncp-session-conversation.ts +32 -10
  80. package/src/features/chat/hooks/use-selected-session-context-window-indicator.ts +20 -0
  81. package/src/features/chat/managers/ncp-chat-input.manager.test.ts +25 -0
  82. package/src/features/chat/managers/ncp-chat-input.manager.ts +5 -1
  83. package/src/features/chat/pages/ncp-chat-page.tsx +15 -11
  84. package/src/features/chat/stores/chat-thread.store.ts +8 -2
  85. package/src/features/chat/utils/chat-context-window-indicator.utils.ts +50 -0
  86. package/src/features/chat/utils/chat-runtime.utils.ts +1 -1
  87. package/src/features/chat/utils/ncp-chat-runtime-availability.utils.test.ts +165 -0
  88. package/src/features/chat/utils/ncp-chat-runtime-availability.utils.ts +50 -0
  89. package/src/features/chat/utils/ncp-session-adapter.utils.test.ts +27 -0
  90. package/src/features/chat/utils/ncp-session-adapter.utils.ts +6 -4
  91. package/src/features/chat/utils/ncp-session-context-metadata.utils.ts +121 -0
  92. package/src/features/chat/utils/session-context.utils.ts +1 -2
  93. package/src/features/system-status/components/config/runtime-config-editor.tsx +6 -0
  94. package/src/features/system-status/components/config/runtime-settings-card.tsx +12 -0
  95. package/src/features/system-status/components/desktop-update-config.test.tsx +17 -7
  96. package/src/features/system-status/components/desktop-update-config.tsx +75 -30
  97. package/src/features/system-status/hooks/use-system-status.ts +0 -11
  98. package/src/features/system-status/index.ts +4 -1
  99. package/src/features/system-status/managers/runtime-update.manager.ts +330 -0
  100. package/src/features/system-status/managers/system-status.manager.test.ts +0 -25
  101. package/src/features/system-status/managers/system-status.manager.ts +1 -30
  102. package/src/features/system-status/stores/runtime-update.store.ts +24 -0
  103. package/src/features/system-status/types/system-status.types.ts +0 -2
  104. package/src/features/system-status/utils/runtime-config-agent.utils.ts +6 -1
  105. package/src/features/system-status/utils/system-status.utils.test.ts +1 -85
  106. package/src/features/system-status/utils/system-status.utils.ts +1 -23
  107. package/src/platforms/desktop/managers/desktop-update.manager.ts +6 -0
  108. package/src/platforms/desktop/types/desktop-update.types.ts +21 -19
  109. package/src/shared/components/common/brand-header.test.tsx +142 -0
  110. package/src/shared/components/common/brand-header.tsx +93 -0
  111. package/src/shared/components/cron-config.tsx +1 -1
  112. package/src/shared/components/doc-browser/doc-browser-context.test.tsx +1 -1
  113. package/src/shared/components/doc-browser/doc-browser.tsx +1 -1
  114. package/src/shared/components/search-config.tsx +3 -3
  115. package/src/shared/lib/api/README.md +3 -0
  116. package/src/shared/lib/api/index.ts +2 -0
  117. package/src/shared/lib/api/ncp-attachments.ts +2 -2
  118. package/src/shared/lib/api/ncp-session.types.ts +92 -0
  119. package/src/shared/lib/api/runtime-update.service.ts +50 -0
  120. package/src/shared/lib/api/types.ts +9 -74
  121. package/src/shared/lib/i18n/{chat.ts → chat-labels.utils.ts} +13 -1
  122. package/src/shared/lib/i18n/desktop-update-labels.utils.ts +65 -0
  123. package/src/shared/lib/i18n/index.ts +4 -5
  124. package/src/shared/lib/i18n/runtime/i18n-language-owner.ts +5 -5
  125. package/src/shared/lib/transport/remote-transport.service.ts +1 -1
  126. package/src/shared/lib/ui-document-title/index.ts +1 -1
  127. package/tsconfig.json +1 -0
  128. package/dist/assets/api-BurjmW4A.js +0 -15
  129. package/dist/assets/app-manager-provider-DhxUmyTv.js +0 -1
  130. package/dist/assets/app-navigation.config-Bpd16Pem.js +0 -1
  131. package/dist/assets/chat-page-Cc7n80lW.js +0 -1
  132. package/dist/assets/desktop-update-config-fMLlSStv.js +0 -1
  133. package/dist/assets/doc-browser-COj7x090.js +0 -1
  134. package/dist/assets/doc-browser-fyn7eDTp.js +0 -1
  135. package/dist/assets/i18n-CM4y8Mw9.js +0 -1
  136. package/dist/assets/index-CtVSzMPM.js +0 -2
  137. package/dist/assets/index-N3hjuljD.css +0 -1
  138. package/dist/assets/loader-circle-R23uEPkM.js +0 -1
  139. package/dist/assets/marketplace-page-mF-M5mku.js +0 -1
  140. package/dist/assets/mcp-marketplace-page-BArKWcRZ.js +0 -40
  141. package/dist/assets/mcp-marketplace-page-DBUcIIHJ.js +0 -1
  142. package/dist/assets/message-square-Dm34zD6k.js +0 -1
  143. package/dist/assets/play-ul4L6MWm.js +0 -1
  144. package/dist/assets/plus-D14303DH.js +0 -1
  145. package/dist/assets/remote-B4ELSd3u.js +0 -1
  146. package/dist/assets/runtime-config-page-N4FP6H0M.js +0 -1
  147. package/dist/assets/search-config-B62TY-z2.js +0 -1
  148. package/dist/assets/skeleton-BCPi52jT.js +0 -1
  149. package/dist/assets/theme-provider-WTWq_jYq.js +0 -1
  150. package/dist/assets/use-config-CyvhbRhf.js +0 -1
  151. package/dist/assets/x-tYcSDsrY.js +0 -1
  152. /package/dist/assets/{config-hints-CPNzbMEp.js → config-hints-MogHYQ8G.js} +0 -0
@@ -0,0 +1,50 @@
1
+ import type { ChatContextWindowIndicator } from '@nextclaw/agent-chat-ui';
2
+ import type { SessionContextWindowView } from '@/shared/lib/api';
3
+ import { t } from '@/shared/lib/i18n';
4
+
5
+ function formatTokenCount(value: number): string {
6
+ if (value >= 1_000_000) {
7
+ return `${(value / 1_000_000).toFixed(value >= 10_000_000 ? 0 : 1)}M`;
8
+ }
9
+ if (value >= 1_000) {
10
+ return `${(value / 1_000).toFixed(value >= 10_000 ? 0 : 1)}k`;
11
+ }
12
+ return String(value);
13
+ }
14
+
15
+ export function buildChatContextWindowIndicator(
16
+ contextWindow: SessionContextWindowView | null | undefined
17
+ ): ChatContextWindowIndicator | null {
18
+ if (!contextWindow || contextWindow.totalContextTokens <= 0) {
19
+ return null;
20
+ }
21
+ const ratio = contextWindow.usedContextTokens / contextWindow.totalContextTokens;
22
+ const clampedRatio = Math.max(0, Math.min(1, ratio));
23
+ const percentLabel = `${Math.round(clampedRatio * 100)}%`;
24
+ const tone: ChatContextWindowIndicator['tone'] =
25
+ clampedRatio >= 0.9 ? 'danger' : clampedRatio >= 0.75 ? 'warning' : 'neutral';
26
+ const details: ChatContextWindowIndicator['details'] = [
27
+ { label: t('chatContextWindowUsed'), value: formatTokenCount(contextWindow.usedContextTokens) },
28
+ { label: t('chatContextWindowTotal'), value: formatTokenCount(contextWindow.totalContextTokens) },
29
+ { label: t('chatContextWindowAvailable'), value: formatTokenCount(contextWindow.availableContextTokens) }
30
+ ];
31
+ if (contextWindow.prunedUsedContextTokens !== contextWindow.usedContextTokens) {
32
+ details.push({
33
+ label: t('chatContextWindowPruned'),
34
+ value: formatTokenCount(contextWindow.prunedUsedContextTokens)
35
+ });
36
+ }
37
+ if (contextWindow.droppedHistoryCount > 0) {
38
+ details.push({ label: t('chatContextWindowDroppedHistory'), value: String(contextWindow.droppedHistoryCount) });
39
+ }
40
+ if (contextWindow.truncatedToolResultCount > 0) {
41
+ details.push({ label: t('chatContextWindowTruncatedTools'), value: String(contextWindow.truncatedToolResultCount) });
42
+ }
43
+ return {
44
+ label: t('chatContextWindow'),
45
+ percentLabel,
46
+ ratio: clampedRatio,
47
+ tone,
48
+ details
49
+ };
50
+ }
@@ -179,7 +179,7 @@ class HistoryMessageBuilder {
179
179
  };
180
180
 
181
181
  private appendAssistantMessage = (message: SessionMessageView): void => {
182
- const timestamp = message.timestamp;
182
+ const { timestamp } = message;
183
183
  const text = extractMessageText(message.content).trim();
184
184
  if (text) {
185
185
  this.appendAssistantText(timestamp, text);
@@ -0,0 +1,165 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { t } from '@/shared/lib/i18n';
3
+ import type { SystemStatusView } from '@/features/system-status';
4
+ import {
5
+ isNcpChatRuntimeBlocked,
6
+ resolveNcpChatRuntimeMessage,
7
+ resolveNcpChatSendErrorMessage,
8
+ } from './ncp-chat-runtime-availability.utils';
9
+
10
+ function createStatus(overrides: Partial<SystemStatusView> = {}): SystemStatusView {
11
+ return {
12
+ lifecyclePhase: 'ready',
13
+ phase: 'ready',
14
+ connectionStatus: 'connected',
15
+ hasReachedReady: true,
16
+ lastReadyAt: Date.now(),
17
+ recoveryStartedAt: null,
18
+ bootstrapStatus: {
19
+ phase: 'ready',
20
+ ncpAgent: {
21
+ state: 'ready',
22
+ },
23
+ pluginHydration: {
24
+ state: 'ready',
25
+ loadedPluginCount: 1,
26
+ totalPluginCount: 1,
27
+ },
28
+ channels: {
29
+ state: 'ready',
30
+ enabled: [],
31
+ },
32
+ remote: {
33
+ state: 'pending',
34
+ },
35
+ },
36
+ lastError: null,
37
+ lastTransportError: null,
38
+ runtimeControlView: null,
39
+ runtimeControlError: null,
40
+ activeSystemAction: null,
41
+ lastSystemActionError: null,
42
+ ...overrides,
43
+ };
44
+ }
45
+
46
+ describe('ncp-chat-runtime-availability.utils', () => {
47
+ it('allows chat send when the NCP agent is ready even if the aggregate phase is stalled', () => {
48
+ expect(isNcpChatRuntimeBlocked(createStatus())).toBe(false);
49
+ expect(
50
+ isNcpChatRuntimeBlocked(
51
+ createStatus({
52
+ lifecyclePhase: 'stalled',
53
+ phase: 'stalled',
54
+ connectionStatus: 'disconnected',
55
+ recoveryStartedAt: Date.now(),
56
+ })
57
+ )
58
+ ).toBe(false);
59
+ });
60
+
61
+ it('blocks chat send while the NCP agent is not ready', () => {
62
+ expect(
63
+ isNcpChatRuntimeBlocked(
64
+ createStatus({
65
+ bootstrapStatus: null,
66
+ })
67
+ )
68
+ ).toBe(true);
69
+ });
70
+
71
+ it('uses the startup message during cold start', () => {
72
+ expect(
73
+ resolveNcpChatRuntimeMessage(
74
+ createStatus({
75
+ lifecyclePhase: 'cold-starting',
76
+ phase: 'cold-starting',
77
+ connectionStatus: 'connecting',
78
+ hasReachedReady: false,
79
+ lastReadyAt: null,
80
+ })
81
+ )
82
+ ).toBe(t('chatRuntimeInitializing'));
83
+ });
84
+
85
+ it('uses the bootstrap error when startup failed', () => {
86
+ expect(
87
+ resolveNcpChatRuntimeMessage(
88
+ createStatus({
89
+ lifecyclePhase: 'startup-failed',
90
+ phase: 'startup-failed',
91
+ connectionStatus: 'disconnected',
92
+ hasReachedReady: false,
93
+ lastReadyAt: null,
94
+ bootstrapStatus: {
95
+ phase: 'error',
96
+ ncpAgent: {
97
+ state: 'error',
98
+ error: 'boom',
99
+ },
100
+ pluginHydration: {
101
+ state: 'pending',
102
+ loadedPluginCount: 0,
103
+ totalPluginCount: 0,
104
+ },
105
+ channels: {
106
+ state: 'pending',
107
+ enabled: [],
108
+ },
109
+ remote: {
110
+ state: 'pending',
111
+ },
112
+ lastError: 'boom',
113
+ },
114
+ })
115
+ )
116
+ ).toBe('boom');
117
+ });
118
+
119
+ it('prefers the centralized action message while a system action is running', () => {
120
+ expect(
121
+ resolveNcpChatRuntimeMessage(
122
+ createStatus({
123
+ activeSystemAction: {
124
+ action: 'restart-service',
125
+ lifecycle: 'recovering',
126
+ serviceState: null,
127
+ message: 'NextClaw 正在恢复连接',
128
+ },
129
+ })
130
+ )
131
+ ).toBe('NextClaw 正在恢复连接');
132
+ });
133
+
134
+ it('maps transient chat errors to friendly recovery copy while recovering', () => {
135
+ expect(
136
+ resolveNcpChatSendErrorMessage({
137
+ message: 'NCP fetch failed for POST /api/ncp/agent: Error: Failed to fetch',
138
+ status: createStatus({
139
+ lifecyclePhase: 'recovering',
140
+ phase: 'recovering',
141
+ connectionStatus: 'connecting',
142
+ recoveryStartedAt: Date.now(),
143
+ lastError: 'Failed to fetch',
144
+ lastTransportError: 'Failed to fetch',
145
+ }),
146
+ })
147
+ ).toBe(t('runtimeControlRecoveringHelp'));
148
+ });
149
+
150
+ it('suppresses transient transport errors after recovery stalls', () => {
151
+ expect(
152
+ resolveNcpChatSendErrorMessage({
153
+ message: 'NCP fetch failed for POST /api/ncp/agent: Error: Failed to fetch',
154
+ status: createStatus({
155
+ lifecyclePhase: 'stalled',
156
+ phase: 'stalled',
157
+ connectionStatus: 'disconnected',
158
+ recoveryStartedAt: Date.now(),
159
+ lastError: 'Failed to fetch',
160
+ lastTransportError: 'Failed to fetch',
161
+ }),
162
+ })
163
+ ).toBeNull();
164
+ });
165
+ });
@@ -0,0 +1,50 @@
1
+ import { isTransientRuntimeConnectionErrorMessage, type SystemStatusView } from '@/features/system-status';
2
+ import { t } from '@/shared/lib/i18n';
3
+
4
+ type ChatRuntimeStatus = Pick<SystemStatusView, 'activeSystemAction' | 'bootstrapStatus' | 'lastError' | 'lastReadyAt' | 'lifecyclePhase' | 'phase'>;
5
+
6
+ export function isNcpChatRuntimeBlocked(status: Pick<SystemStatusView, 'bootstrapStatus'>): boolean {
7
+ return status.bootstrapStatus?.ncpAgent.state !== 'ready';
8
+ }
9
+
10
+ export function resolveNcpChatRuntimeMessage(
11
+ status: ChatRuntimeStatus
12
+ ): string | null {
13
+ const actionMessage = status.activeSystemAction?.message?.trim();
14
+ if (actionMessage) return actionMessage;
15
+ if (status.lifecyclePhase === 'cold-starting') {
16
+ return t('chatRuntimeInitializing');
17
+ }
18
+ if (status.lifecyclePhase === 'startup-failed') {
19
+ return (
20
+ status.bootstrapStatus?.ncpAgent.error?.trim() ||
21
+ status.bootstrapStatus?.lastError?.trim() ||
22
+ status.lastError?.trim() ||
23
+ t('chatRuntimeInitializationFailed')
24
+ );
25
+ }
26
+ return null;
27
+ }
28
+
29
+ export function resolveNcpChatSendErrorMessage(params: {
30
+ message: string | null | undefined;
31
+ status: ChatRuntimeStatus;
32
+ }): string | null {
33
+ const { message: rawMessage, status } = params;
34
+ const message = rawMessage?.trim();
35
+ if (!message) {
36
+ return resolveNcpChatRuntimeMessage(status);
37
+ }
38
+ const actionMessage = status.activeSystemAction?.message?.trim();
39
+ if (status.phase === 'service-transitioning' && actionMessage) {
40
+ return actionMessage;
41
+ }
42
+ const isTransientTransportError = isTransientRuntimeConnectionErrorMessage(message);
43
+ if (status.phase === 'recovering' && isTransientTransportError) {
44
+ return t('runtimeControlRecoveringHelp');
45
+ }
46
+ if (status.phase === 'stalled' && isTransientTransportError) {
47
+ return null;
48
+ }
49
+ return message;
50
+ }
@@ -69,6 +69,33 @@ describe('adaptNcpSessionSummary', () => {
69
69
  spawnedByRequestId: 'request-1',
70
70
  });
71
71
  });
72
+
73
+ it('does not hydrate context window metadata from persisted session summaries', () => {
74
+ const adapted = adaptNcpSessionSummary(
75
+ createSummary({
76
+ metadata: {
77
+ last_context_window: {
78
+ version: 1,
79
+ usedContextTokens: 76000,
80
+ totalContextTokens: 200000,
81
+ prunedUsedContextTokens: 61200,
82
+ availableContextTokens: 124000,
83
+ droppedHistoryCount: 3,
84
+ truncatedToolResultCount: 1,
85
+ truncatedSystemPrompt: false,
86
+ truncatedUserMessage: false,
87
+ compacted: true,
88
+ checkpointId: 'ctx-20260505123456-8',
89
+ compactedMessageCount: 8,
90
+ compactedUsedContextTokens: 51000,
91
+ updatedAt: '2026-05-05T12:34:56.000Z',
92
+ },
93
+ },
94
+ }),
95
+ );
96
+
97
+ expect(adapted.contextWindow).toBeUndefined();
98
+ });
72
99
  });
73
100
 
74
101
  describe('adaptNcpMessageToUiMessage file rendering', () => {
@@ -1,6 +1,11 @@
1
1
  import { ToolInvocationStatus, type UIMessage } from '@nextclaw/agent-chat';
2
2
  import type { NcpMessagePart } from '@nextclaw/ncp';
3
- import type { NcpMessageView, NcpSessionSummaryView, SessionEntryView, ThinkingLevel } from '@/shared/lib/api';
3
+ import type {
4
+ NcpMessageView,
5
+ NcpSessionSummaryView,
6
+ SessionEntryView,
7
+ ThinkingLevel
8
+ } from '@/shared/lib/api';
4
9
  import { API_BASE } from '@/shared/lib/api';
5
10
  import {
6
11
  getSessionProjectName,
@@ -132,9 +137,6 @@ function readPromotedChildSession(summary: NcpSessionSummaryView): boolean {
132
137
  }
133
138
 
134
139
  function parseSessionContext(sessionKey: string): { channel?: string; type?: string } {
135
- if (sessionKey === 'heartbeat') {
136
- return { type: 'heartbeat' };
137
- }
138
140
  if (sessionKey.startsWith('cron:')) {
139
141
  return { type: 'cron' };
140
142
  }
@@ -0,0 +1,121 @@
1
+ import type {
2
+ NcpMessageView,
3
+ SessionContextWindowView,
4
+ } from '@/shared/lib/api';
5
+
6
+ function readOptionalString(value: unknown): string | null {
7
+ if (typeof value !== 'string') {
8
+ return null;
9
+ }
10
+ const trimmed = value.trim();
11
+ return trimmed.length > 0 ? trimmed : null;
12
+ }
13
+
14
+ function readNonNegativeInteger(value: unknown): number | null {
15
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
16
+ return null;
17
+ }
18
+ const normalized = Math.trunc(value);
19
+ return normalized >= 0 ? normalized : null;
20
+ }
21
+
22
+ function readBoolean(value: unknown): boolean {
23
+ return value === true;
24
+ }
25
+
26
+ export function readNcpContextWindowValue(value: unknown): SessionContextWindowView | null {
27
+ const rawContextWindow = value;
28
+ if (!rawContextWindow || typeof rawContextWindow !== 'object' || Array.isArray(rawContextWindow)) {
29
+ return null;
30
+ }
31
+ const contextWindow = rawContextWindow as Record<string, unknown>;
32
+ const usedContextTokens = readNonNegativeInteger(contextWindow.usedContextTokens);
33
+ const totalContextTokens = readNonNegativeInteger(contextWindow.totalContextTokens);
34
+ const prunedUsedContextTokens = readNonNegativeInteger(contextWindow.prunedUsedContextTokens);
35
+ const updatedAt = readOptionalString(contextWindow.updatedAt);
36
+ if (usedContextTokens === null || totalContextTokens === null || prunedUsedContextTokens === null || !updatedAt) {
37
+ return null;
38
+ }
39
+ const compactedUsedContextTokens = readNonNegativeInteger(contextWindow.compactedUsedContextTokens);
40
+ return {
41
+ usedContextTokens,
42
+ totalContextTokens,
43
+ prunedUsedContextTokens,
44
+ availableContextTokens: readNonNegativeInteger(contextWindow.availableContextTokens) ?? Math.max(0, totalContextTokens - usedContextTokens),
45
+ droppedHistoryCount: readNonNegativeInteger(contextWindow.droppedHistoryCount) ?? 0,
46
+ truncatedToolResultCount: readNonNegativeInteger(contextWindow.truncatedToolResultCount) ?? 0,
47
+ truncatedSystemPrompt: readBoolean(contextWindow.truncatedSystemPrompt),
48
+ truncatedUserMessage: readBoolean(contextWindow.truncatedUserMessage),
49
+ compacted: readBoolean(contextWindow.compacted),
50
+ ...(readOptionalString(contextWindow.checkpointId)
51
+ ? { checkpointId: readOptionalString(contextWindow.checkpointId) ?? undefined }
52
+ : {}),
53
+ compactedMessageCount: readNonNegativeInteger(contextWindow.compactedMessageCount) ?? 0,
54
+ ...(compactedUsedContextTokens !== null
55
+ ? { compactedUsedContextTokens }
56
+ : {}),
57
+ updatedAt,
58
+ };
59
+ }
60
+
61
+ export const NEXTCLAW_TIMELINE_KIND_METADATA_KEY = 'nextclaw_timeline_kind';
62
+ export const CONTEXT_COMPACTION_TIMELINE_KIND = 'context_compaction';
63
+
64
+ export type ContextCompactionTimelineView = {
65
+ id: string;
66
+ status: 'compressing' | 'compressed';
67
+ summary: string;
68
+ coveredMessageCount: number;
69
+ coveredSessionMessageCount: number;
70
+ originalEstimatedTokens: number;
71
+ projectedEstimatedTokens: number;
72
+ createdAt: string;
73
+ updatedAt: string;
74
+ };
75
+
76
+ export function readContextCompactionTimeline(message: Pick<NcpMessageView, 'metadata'>): ContextCompactionTimelineView | null {
77
+ const { metadata } = message;
78
+ if (!metadata || metadata[NEXTCLAW_TIMELINE_KIND_METADATA_KEY] !== CONTEXT_COMPACTION_TIMELINE_KIND) {
79
+ return null;
80
+ }
81
+ const rawCheckpoint =
82
+ metadata.checkpoint && typeof metadata.checkpoint === 'object' && !Array.isArray(metadata.checkpoint)
83
+ ? (metadata.checkpoint as Record<string, unknown>)
84
+ : null;
85
+ if (!rawCheckpoint) {
86
+ return null;
87
+ }
88
+ const id = readOptionalString(rawCheckpoint.id);
89
+ const status = rawCheckpoint.status === 'compressing' ? 'compressing' : rawCheckpoint.status === 'compressed' ? 'compressed' : null;
90
+ const summary = readOptionalString(rawCheckpoint.summary);
91
+ const coveredMessageCount = readNonNegativeInteger(rawCheckpoint.coveredMessageCount);
92
+ const coveredSessionMessageCount = readNonNegativeInteger(rawCheckpoint.coveredSessionMessageCount);
93
+ const originalEstimatedTokens = readNonNegativeInteger(rawCheckpoint.originalEstimatedTokens);
94
+ const projectedEstimatedTokens = readNonNegativeInteger(rawCheckpoint.projectedEstimatedTokens);
95
+ const createdAt = readOptionalString(rawCheckpoint.createdAt);
96
+ const updatedAt = readOptionalString(rawCheckpoint.updatedAt);
97
+ if (
98
+ !id ||
99
+ !status ||
100
+ !summary ||
101
+ coveredMessageCount === null ||
102
+ coveredSessionMessageCount === null ||
103
+ originalEstimatedTokens === null ||
104
+ projectedEstimatedTokens === null ||
105
+ !createdAt ||
106
+ !updatedAt
107
+ ) {
108
+ return null;
109
+ }
110
+ return {
111
+ id,
112
+ status,
113
+ summary,
114
+ coveredMessageCount,
115
+ coveredSessionMessageCount,
116
+ originalEstimatedTokens,
117
+ projectedEstimatedTokens,
118
+ createdAt,
119
+ updatedAt,
120
+ };
121
+ }
@@ -2,7 +2,7 @@ import type { SessionEntryView, SessionTypeIconView } from '@/shared/lib/api';
2
2
  import { t } from '@/shared/lib/i18n';
3
3
  import { getChannelLogo } from '@/shared/lib/logos';
4
4
 
5
- type SessionContextSymbolIcon = 'heartbeat' | 'cron';
5
+ type SessionContextSymbolIcon = 'cron';
6
6
 
7
7
  export type SessionContextIcon =
8
8
  | { kind: 'channel-logo'; channel: string }
@@ -20,7 +20,6 @@ const CHANNEL_ALIAS_REGISTRY: Record<string, string> = {
20
20
  };
21
21
 
22
22
  const TYPE_CONTEXT_REGISTRY: Record<string, { icon: SessionContextSymbolIcon }> = {
23
- heartbeat: { icon: 'heartbeat' },
24
23
  cron: { icon: 'cron' },
25
24
  };
26
25
 
@@ -35,6 +35,7 @@ export function RuntimeConfigEditor(props: {
35
35
  const [bindings, setBindings] = useState(initialState.bindings);
36
36
  const [runtimeEntries, setRuntimeEntries] = useState(initialState.runtimeEntries);
37
37
  const [dmScope, setDmScope] = useState<DmScope>(initialState.dmScope);
38
+ const [companionEnabled, setCompanionEnabled] = useState(initialState.companionEnabled);
38
39
  const [defaultContextTokens, setDefaultContextTokens] = useState(initialState.defaultContextTokens);
39
40
  const [defaultEngine, setDefaultEngine] = useState(initialState.defaultEngine);
40
41
 
@@ -60,6 +61,7 @@ export function RuntimeConfigEditor(props: {
60
61
  const handleSave = () => {
61
62
  try {
62
63
  const data = createRuntimeConfigUpdatePayload({
64
+ companionEnabled,
63
65
  agents,
64
66
  bindings,
65
67
  runtimeEntries,
@@ -78,12 +80,16 @@ export function RuntimeConfigEditor(props: {
78
80
  <PageLayout className="space-y-6">
79
81
  <RuntimeConfigOverview />
80
82
  <RuntimeSettingsCard
83
+ companionEnabled={companionEnabled}
81
84
  dmScope={dmScope}
82
85
  defaultContextTokens={defaultContextTokens}
83
86
  defaultEngine={defaultEngine}
87
+ onCompanionEnabledChange={setCompanionEnabled}
84
88
  onDmScopeChange={setDmScope}
85
89
  onDefaultContextTokensChange={setDefaultContextTokens}
86
90
  onDefaultEngineChange={setDefaultEngine}
91
+ companionEnabledLabel={hintForPath('companion.enabled', props.uiHints)?.label}
92
+ companionEnabledHelp={hintForPath('companion.enabled', props.uiHints)?.help}
87
93
  dmScopeLabel={hintForPath('session.dmScope', props.uiHints)?.label}
88
94
  dmScopeHelp={hintForPath('session.dmScope', props.uiHints)?.help}
89
95
  defaultContextTokensLabel={hintForPath('agents.defaults.contextTokens', props.uiHints)?.label}
@@ -1,6 +1,7 @@
1
1
  import { Input } from '@/shared/components/ui/input';
2
2
  import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select';
3
3
  import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/components/ui/card';
4
+ import { Switch } from '@/shared/components/ui/switch';
4
5
  import { t } from '@/shared/lib/i18n';
5
6
  import type { DmScope } from '@/features/system-status/utils/runtime-config-agent.utils';
6
7
 
@@ -13,11 +14,15 @@ const DM_SCOPE_OPTIONS: Array<{ value: DmScope; label: string }> = [
13
14
 
14
15
  export function RuntimeSettingsCard(props: {
15
16
  dmScope: DmScope;
17
+ companionEnabled: boolean;
16
18
  defaultContextTokens: number;
17
19
  defaultEngine: string;
20
+ onCompanionEnabledChange: (value: boolean) => void;
18
21
  onDmScopeChange: (value: DmScope) => void;
19
22
  onDefaultContextTokensChange: (value: number) => void;
20
23
  onDefaultEngineChange: (value: string) => void;
24
+ companionEnabledLabel?: string;
25
+ companionEnabledHelp?: string;
21
26
  dmScopeLabel?: string;
22
27
  dmScopeHelp?: string;
23
28
  defaultContextTokensLabel?: string;
@@ -32,6 +37,13 @@ export function RuntimeSettingsCard(props: {
32
37
  <CardDescription>{props.dmScopeHelp ?? t('dmScopeHelp')}</CardDescription>
33
38
  </CardHeader>
34
39
  <CardContent className="space-y-4">
40
+ <div className="flex items-start justify-between gap-4 rounded-md border border-gray-200 px-4 py-3">
41
+ <div className="space-y-1">
42
+ <div className="text-sm font-medium text-gray-800">{props.companionEnabledLabel ?? t('runtimeCompanionEnabled')}</div>
43
+ <p className="text-xs text-gray-500">{props.companionEnabledHelp ?? t('runtimeCompanionEnabledHelp')}</p>
44
+ </div>
45
+ <Switch checked={props.companionEnabled} onCheckedChange={props.onCompanionEnabledChange} />
46
+ </div>
35
47
  <div className="space-y-2">
36
48
  <label className="text-sm font-medium text-gray-800">{props.defaultContextTokensLabel ?? t('defaultContextTokens')}</label>
37
49
  <Input
@@ -1,9 +1,9 @@
1
1
  import { render, screen } from '@testing-library/react';
2
2
  import userEvent from '@testing-library/user-event';
3
3
  import { beforeEach, describe, expect, it, vi } from 'vitest';
4
+ import { useRuntimeUpdateStore } from '@/features/system-status';
4
5
  import { DesktopUpdateConfig } from '@/features/system-status/components/desktop-update-config';
5
6
  import { setLanguage } from '@/shared/lib/i18n';
6
- import { useDesktopUpdateStore } from '@/platforms/desktop';
7
7
 
8
8
  const mocks = vi.hoisted(() => ({
9
9
  start: vi.fn(),
@@ -15,13 +15,13 @@ const mocks = vi.hoisted(() => ({
15
15
  updateChannel: vi.fn()
16
16
  }));
17
17
 
18
- vi.mock('@/platforms/desktop', async () => {
19
- const actual = await vi.importActual<typeof import('@/platforms/desktop')>(
20
- '@/platforms/desktop'
18
+ vi.mock('@/features/system-status', async () => {
19
+ const actual = await vi.importActual<typeof import('@/features/system-status')>(
20
+ '@/features/system-status'
21
21
  );
22
22
  return {
23
23
  ...actual,
24
- desktopUpdateManager: mocks,
24
+ runtimeUpdateManager: mocks,
25
25
  };
26
26
  });
27
27
 
@@ -46,19 +46,27 @@ describe('DesktopUpdateConfig', () => {
46
46
  HTMLElement.prototype.releasePointerCapture = () => {};
47
47
  }
48
48
 
49
- useDesktopUpdateStore.setState({
49
+ useRuntimeUpdateStore.setState({
50
50
  supported: true,
51
51
  initialized: true,
52
52
  busyAction: null,
53
53
  snapshot: {
54
54
  status: 'idle',
55
+ installationKind: 'npm-runtime-bundle',
55
56
  channel: 'beta',
56
- launcherVersion: '0.0.138',
57
+ hostVersion: '0.0.138',
57
58
  currentVersion: '0.18.0',
58
59
  availableVersion: '0.18.2-beta.1',
59
60
  downloadedVersion: null,
61
+ minimumHostVersion: null,
60
62
  releaseNotesUrl: 'https://example.com/release-notes',
61
63
  lastCheckedAt: '2026-04-13T12:00:00.000Z',
64
+ progress: null,
65
+ canAutoDownload: false,
66
+ canApplyInApp: false,
67
+ requiresRestart: false,
68
+ blockReason: null,
69
+ recoveryCommand: null,
62
70
  errorMessage: null,
63
71
  preferences: {
64
72
  automaticChecks: true,
@@ -72,6 +80,8 @@ describe('DesktopUpdateConfig', () => {
72
80
  render(<DesktopUpdateConfig />);
73
81
 
74
82
  expect(mocks.start).toHaveBeenCalledTimes(1);
83
+ expect(screen.getByText('版本更新')).toBeTruthy();
84
+ expect(screen.getByText('宿主版本')).toBeTruthy();
75
85
  expect(screen.getByText('当前更新通道')).toBeTruthy();
76
86
  expect(screen.getAllByText('Beta').length).toBeGreaterThan(0);
77
87
  expect(screen.getByText('当前正在跟随 Beta 通道')).toBeTruthy();