@nextclaw/ui 0.15.17 → 0.15.18

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 (25) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/dist/assets/{app-presenter-provider-C9AcZKKs.js → app-presenter-provider-De7Zwitu.js} +14 -14
  3. package/dist/assets/{appearance-settings-page-CiD9Ey8f.js → appearance-settings-page-BkQaPtQ-.js} +1 -1
  4. package/dist/assets/{chat-page-B0cC3jsB.js → chat-page-AvOuaxuv.js} +1 -1
  5. package/dist/assets/{index-bOqpao-w.js → index-B5AoUrMf.js} +2 -2
  6. package/dist/assets/remote-B6TXClIg.js +1 -0
  7. package/dist/assets/{runtime-config-page-CbSrKeJt.js → runtime-config-page-BLrTVO3n.js} +1 -1
  8. package/dist/index.html +2 -2
  9. package/package.json +6 -6
  10. package/src/features/chat/components/conversation/__tests__/chat-conversation-content.test.tsx +11 -0
  11. package/src/features/chat/components/conversation/chat-conversation-content.tsx +7 -1
  12. package/src/features/chat/features/conversation/components/__tests__/session-conversation-area.test.tsx +50 -4
  13. package/src/features/chat/features/conversation/components/session-conversation-area.tsx +25 -1
  14. package/src/features/chat/features/conversation/components/session-conversation-input.tsx +3 -0
  15. package/src/features/chat/features/conversation/hooks/__tests__/use-session-conversation-input-state.test.tsx +41 -0
  16. package/src/features/chat/features/conversation/hooks/__tests__/use-session-conversation-slash-commands.test.tsx +28 -0
  17. package/src/features/chat/features/conversation/hooks/use-session-conversation-input-query.ts +1 -1
  18. package/src/features/chat/features/conversation/hooks/use-session-conversation-input-state.ts +0 -1
  19. package/src/features/chat/features/conversation/hooks/use-session-conversation-preference-actions.ts +10 -11
  20. package/src/features/chat/features/conversation/hooks/use-session-conversation-slash-commands.ts +5 -2
  21. package/src/features/chat/features/message/components/chat-message-list.container.tsx +12 -9
  22. package/src/features/chat/features/ncp/hooks/__tests__/use-hydrated-ncp-agent.test.tsx +122 -1
  23. package/src/features/chat/features/ncp/hooks/__tests__/use-ncp-agent-runtime-queue.test.tsx +9 -2
  24. package/src/features/chat/features/ncp/hooks/__tests__/use-ncp-agent-runtime.test.tsx +107 -1
  25. package/dist/assets/remote-B0nlynVp.js +0 -1
@@ -1,5 +1,5 @@
1
1
  import { memo, type ReactNode } from "react";
2
- import { render, screen } from "@testing-library/react";
2
+ import { act, render, screen } from "@testing-library/react";
3
3
  import { MemoryRouter } from "react-router-dom";
4
4
  import { describe, expect, it, vi, beforeEach } from "vitest";
5
5
 
@@ -20,7 +20,6 @@ const mocks = vi.hoisted(() => {
20
20
  setAttachments: vi.fn(),
21
21
  addAttachments: vi.fn(() => []),
22
22
  removeAttachment: vi.fn(),
23
- setSelectedModel: vi.fn(),
24
23
  setSelectedThinkingLevel: vi.fn(),
25
24
  syncSessionPreferences: vi.fn(),
26
25
  setPendingSessionType: vi.fn(),
@@ -43,9 +42,9 @@ const mocks = vi.hoisted(() => {
43
42
  sendError: null,
44
43
  };
45
44
  const inputQuery = {
46
- defaultModel: undefined,
45
+ defaultModel: undefined as string | undefined,
47
46
  defaultProjectRoot: null,
48
- fallbackPreferredModel: undefined,
47
+ fallbackPreferredModel: undefined as string | undefined,
49
48
  fallbackPreferredThinking: null,
50
49
  isProviderStateResolved: true,
51
50
  isSkillsLoading: false,
@@ -138,17 +137,20 @@ vi.mock(
138
137
  () => ({
139
138
  ChatConversationContent: ({
140
139
  bottomSlot,
140
+ isContextCompacting,
141
141
  messages,
142
142
  showWelcome,
143
143
  welcomeSlot,
144
144
  }: {
145
145
  bottomSlot?: ReactNode;
146
+ isContextCompacting?: boolean;
146
147
  messages: readonly unknown[];
147
148
  showWelcome: boolean;
148
149
  welcomeSlot?: ReactNode;
149
150
  }) => (
150
151
  <div
151
152
  data-testid="conversation-content"
153
+ data-context-compacting={String(Boolean(isContextCompacting))}
152
154
  data-show-welcome={String(showWelcome)}
153
155
  >
154
156
  {showWelcome ? (
@@ -264,7 +266,10 @@ describe("SessionConversationArea input boundary", () => {
264
266
  mocks.agent.isRunning = true;
265
267
  mocks.agent.isSending = true;
266
268
  mocks.agent.snapshot.contextWindow = null;
269
+ mocks.inputQuery.defaultModel = undefined;
270
+ mocks.inputQuery.fallbackPreferredModel = undefined;
267
271
  mocks.inputQuery.selectedSession = null;
272
+ mocks.inputQuery.sessionTypeState.selectedSessionType = "default";
268
273
  });
269
274
 
270
275
  it("passes running state without treating an active run as a send request in flight", () => {
@@ -297,6 +302,23 @@ describe("SessionConversationArea input boundary", () => {
297
302
  expect(mocks.inputRenderSpy).toHaveBeenCalledOnce();
298
303
  });
299
304
 
305
+ it("shows compaction feedback only for the active session", () => {
306
+ renderArea("session-1");
307
+ const inputProps = mocks.inputRenderSpy.mock.calls[0]?.[0] as {
308
+ onContextCompactingChange: (sessionId: string, isCompacting: boolean) => void;
309
+ };
310
+
311
+ act(() => inputProps.onContextCompactingChange("session-1", true));
312
+
313
+ expect(screen.getByTestId("conversation-content").dataset.contextCompacting)
314
+ .toBe("true");
315
+
316
+ act(() => inputProps.onContextCompactingChange("session-1", false));
317
+
318
+ expect(screen.getByTestId("conversation-content").dataset.contextCompacting)
319
+ .toBe("false");
320
+ });
321
+
300
322
  it("does not replace the welcome composer just because draft send starts", () => {
301
323
  renderArea(null);
302
324
 
@@ -330,6 +352,30 @@ describe("SessionConversationArea input boundary", () => {
330
352
  expect(mocks.initialPromptSpy).toHaveBeenCalledWith("每天整理项目风险");
331
353
  });
332
354
 
355
+ it("syncs draft preferences with the selected runtime context", () => {
356
+ mocks.inputQuery.defaultModel = "openai/gpt-5";
357
+ mocks.inputQuery.fallbackPreferredModel = "minimax/MiniMax-M3";
358
+ const rendered = renderArea(null);
359
+ mocks.inputActions.syncSessionPreferences.mockClear();
360
+
361
+ mocks.inputQuery.sessionTypeState.selectedSessionType = "codex";
362
+ rendered.rerender(
363
+ <MemoryRouter>
364
+ <SessionConversationArea sessionKey={null} />
365
+ </MemoryRouter>,
366
+ );
367
+
368
+ expect(mocks.inputActions.syncSessionPreferences).toHaveBeenCalledOnce();
369
+ expect(mocks.inputActions.syncSessionPreferences).toHaveBeenLastCalledWith(
370
+ expect.objectContaining({
371
+ defaultModel: "openai/gpt-5",
372
+ fallbackPreferredModel: "minimax/MiniMax-M3",
373
+ selectedSessionKey: null,
374
+ selectedSessionType: "codex",
375
+ }),
376
+ );
377
+ });
378
+
333
379
  it("surfaces selected-session failure previews at the conversation bottom", () => {
334
380
  mocks.inputQuery.selectedSession = {
335
381
  activityPreview: {
@@ -1,4 +1,4 @@
1
- import { useCallback, useEffect, useMemo, useRef } from "react";
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
2
  import { useLocation } from "react-router-dom";
3
3
 
4
4
  import { useAppPresenter } from "@/app/components/app-presenter-provider";
@@ -200,6 +200,23 @@ export function SessionConversationArea(props: SessionConversationAreaProps) {
200
200
  inputSnapshot,
201
201
  setPendingSessionType: inputActions.setPendingSessionType,
202
202
  });
203
+ const [compactingSessionIds, setCompactingSessionIds] = useState<ReadonlySet<string>>(
204
+ () => new Set(),
205
+ );
206
+ const handleContextCompactingChange = useCallback(
207
+ (compactingSessionId: string, isCompacting: boolean) => {
208
+ setCompactingSessionIds((current) => {
209
+ const next = new Set(current);
210
+ if (isCompacting) {
211
+ next.add(compactingSessionId);
212
+ } else {
213
+ next.delete(compactingSessionId);
214
+ }
215
+ return next;
216
+ });
217
+ },
218
+ [],
219
+ );
203
220
  useEffect(() => {
204
221
  inputActions.syncSessionPreferences({
205
222
  defaultModel: inputQuery.defaultModel,
@@ -208,6 +225,7 @@ export function SessionConversationArea(props: SessionConversationAreaProps) {
208
225
  modelOptions: inputQuery.modelOptions,
209
226
  selectedSessionExists: Boolean(inputQuery.selectedSession),
210
227
  selectedSessionKey: inputQuery.selectedSessionKey,
228
+ selectedSessionType: inputQuery.sessionTypeState.selectedSessionType,
211
229
  selectedSessionPreferredModel:
212
230
  inputQuery.selectedSession?.preferredModel ?? undefined,
213
231
  selectedSessionPreferredThinking:
@@ -221,6 +239,7 @@ export function SessionConversationArea(props: SessionConversationAreaProps) {
221
239
  inputQuery.modelOptions,
222
240
  inputQuery.selectedSession,
223
241
  inputQuery.selectedSessionKey,
242
+ inputQuery.sessionTypeState.selectedSessionType,
224
243
  ]);
225
244
  useSessionConversationDraftRouteState({
226
245
  sessionKey,
@@ -336,6 +355,7 @@ export function SessionConversationArea(props: SessionConversationAreaProps) {
336
355
  inputActions={inputActions}
337
356
  inputQuery={inputQuery}
338
357
  inputSnapshot={displayInputSnapshot}
358
+ onContextCompactingChange={handleContextCompactingChange}
339
359
  surface={surface}
340
360
  />
341
361
  ),
@@ -345,6 +365,7 @@ export function SessionConversationArea(props: SessionConversationAreaProps) {
345
365
  inputController,
346
366
  inputActions,
347
367
  inputQuery,
368
+ handleContextCompactingChange,
348
369
  ],
349
370
  );
350
371
  const showWelcome =
@@ -365,6 +386,9 @@ export function SessionConversationArea(props: SessionConversationAreaProps) {
365
386
  isHistoryLoading={agent.isHydrating}
366
387
  isLoadingPreviousMessages={agent.isLoadingPreviousMessages}
367
388
  isSending={controller.isSending}
389
+ isContextCompacting={Boolean(
390
+ sessionKey && compactingSessionIds.has(sessionKey),
391
+ )}
368
392
  bottomSlot={sessionFailureSlot}
369
393
  messages={agent.visibleMessages}
370
394
  sessionKey={sessionKey}
@@ -192,6 +192,7 @@ type SessionConversationInputProps = {
192
192
  readonly inputActions: SessionConversationInputActions;
193
193
  readonly inputQuery: SessionConversationInputQuery;
194
194
  readonly inputSnapshot: SessionConversationInputSnapshot;
195
+ readonly onContextCompactingChange?: (sessionId: string, isCompacting: boolean) => void;
195
196
  readonly surface?: 'default' | 'embedded';
196
197
  };
197
198
 
@@ -202,6 +203,7 @@ export const SessionConversationInput = memo(function SessionConversationInput(p
202
203
  inputActions,
203
204
  inputQuery,
204
205
  inputSnapshot,
206
+ onContextCompactingChange,
205
207
  surface = 'default',
206
208
  } = props;
207
209
  const presenter = usePresenter();
@@ -225,6 +227,7 @@ export const SessionConversationInput = memo(function SessionConversationInput(p
225
227
  });
226
228
  const slashCommands = useSessionConversationSlashCommands({
227
229
  language,
230
+ onContextCompactingChange,
228
231
  selectedSessionKey: inputQuery.selectedSessionKey,
229
232
  });
230
233
  const handleSlashPanelAppSelect = useCallback(
@@ -36,6 +36,7 @@ describe('useSessionConversationInputState session preferences', () => {
36
36
  modelOptions: MODEL_OPTIONS,
37
37
  selectedSessionExists: true,
38
38
  selectedSessionKey: 'session-a',
39
+ selectedSessionType: 'native',
39
40
  selectedSessionPreferredModel: 'deepseek/deepseek-v4-flash',
40
41
  });
41
42
  });
@@ -46,6 +47,7 @@ describe('useSessionConversationInputState session preferences', () => {
46
47
  modelOptions: MODEL_OPTIONS,
47
48
  selectedSessionExists: false,
48
49
  selectedSessionKey: 'session-b',
50
+ selectedSessionType: 'native',
49
51
  });
50
52
  });
51
53
  expect(result.current.inputSnapshot.selectedModel).toBe('deepseek/deepseek-v4-flash');
@@ -55,9 +57,48 @@ describe('useSessionConversationInputState session preferences', () => {
55
57
  modelOptions: MODEL_OPTIONS,
56
58
  selectedSessionExists: true,
57
59
  selectedSessionKey: 'session-b',
60
+ selectedSessionType: 'native',
58
61
  selectedSessionPreferredModel: 'minimax/MiniMax-M3',
59
62
  });
60
63
  });
61
64
  expect(result.current.inputSnapshot.selectedModel).toBe('minimax/MiniMax-M3');
62
65
  });
66
+
67
+ it('prefers the runtime recent model and otherwise restores the runtime default in a draft', () => {
68
+ const { result } = renderHook(() => useSessionConversationInputState());
69
+
70
+ act(() => {
71
+ result.current.inputActions.syncSessionPreferences({
72
+ fallbackPreferredModel: 'deepseek/deepseek-v4-flash',
73
+ modelOptions: MODEL_OPTIONS,
74
+ selectedSessionExists: false,
75
+ selectedSessionKey: null,
76
+ selectedSessionType: 'native',
77
+ });
78
+ });
79
+ expect(result.current.inputSnapshot.selectedModel).toBe('deepseek/deepseek-v4-flash');
80
+
81
+ act(() => {
82
+ result.current.inputActions.syncSessionPreferences({
83
+ defaultModel: 'deepseek/deepseek-v4-flash',
84
+ fallbackPreferredModel: 'minimax/MiniMax-M3',
85
+ modelOptions: MODEL_OPTIONS,
86
+ selectedSessionExists: false,
87
+ selectedSessionKey: null,
88
+ selectedSessionType: 'codex',
89
+ });
90
+ });
91
+ expect(result.current.inputSnapshot.selectedModel).toBe('minimax/MiniMax-M3');
92
+
93
+ act(() => {
94
+ result.current.inputActions.syncSessionPreferences({
95
+ defaultModel: 'deepseek/deepseek-v4-flash',
96
+ modelOptions: MODEL_OPTIONS,
97
+ selectedSessionExists: false,
98
+ selectedSessionKey: null,
99
+ selectedSessionType: 'hermes',
100
+ });
101
+ });
102
+ expect(result.current.inputSnapshot.selectedModel).toBe('deepseek/deepseek-v4-flash');
103
+ });
63
104
  });
@@ -68,6 +68,34 @@ describe('useSessionConversationSlashCommands', () => {
68
68
  ));
69
69
  });
70
70
 
71
+ it('reports visible compaction state for the lifetime of the request', async () => {
72
+ let resolveRequest!: () => void;
73
+ mocks.compactNcpSessionContext.mockReturnValue(new Promise((resolve) => {
74
+ resolveRequest = () => resolve({
75
+ compacted: true,
76
+ sessionId: 'session-1',
77
+ });
78
+ }));
79
+ const onContextCompactingChange = vi.fn();
80
+ const { result } = renderHook(() => useSessionConversationSlashCommands({
81
+ language: 'en',
82
+ selectedSessionKey: 'session-1',
83
+ onContextCompactingChange,
84
+ }));
85
+
86
+ act(() => result.current.find((entry) => entry.key === 'compact-context')?.onSelect());
87
+
88
+ expect(onContextCompactingChange).toHaveBeenCalledWith('session-1', true);
89
+ expect(onContextCompactingChange).not.toHaveBeenCalledWith('session-1', false);
90
+
91
+ act(() => resolveRequest());
92
+
93
+ await waitFor(() => expect(onContextCompactingChange).toHaveBeenLastCalledWith(
94
+ 'session-1',
95
+ false,
96
+ ));
97
+ });
98
+
71
99
  it('does not expose session commands before a session exists', () => {
72
100
  const { result } = renderHook(() => useSessionConversationSlashCommands({
73
101
  language: 'en',
@@ -124,7 +124,7 @@ export function useSessionConversationInputQuery(params: UseSessionConversationI
124
124
  );
125
125
 
126
126
  return useMemo(() => ({
127
- defaultModel: config?.agents.defaults.model,
127
+ defaultModel: sessionTypeState.selectedSessionTypeOption?.recommendedModel ?? config?.agents.defaults.model,
128
128
  defaultProjectRoot,
129
129
  fallbackPreferredModel,
130
130
  fallbackPreferredThinking,
@@ -53,7 +53,6 @@ export type SessionConversationInputActions = {
53
53
  readonly setAttachments: (attachments: readonly NcpDraftAttachment[]) => void;
54
54
  readonly addAttachments: (attachments: readonly NcpDraftAttachment[]) => readonly NcpDraftAttachment[];
55
55
  readonly removeAttachment: (attachmentId: string) => void;
56
- readonly setSelectedModel: (model: SessionConversationInputStateValue) => void;
57
56
  readonly setSelectedThinkingLevel: (level: ThinkingLevel | null) => void;
58
57
  readonly syncSessionPreferences: (params: SessionConversationPreferenceSyncParams) => void;
59
58
  readonly setPendingSessionType: (sessionType: SetStateAction<string>) => void;
@@ -16,6 +16,7 @@ export type SessionConversationPreferenceSyncParams = {
16
16
  readonly modelOptions: ChatModelOption[];
17
17
  readonly selectedSessionExists: boolean;
18
18
  readonly selectedSessionKey?: string | null;
19
+ readonly selectedSessionType: string;
19
20
  readonly selectedSessionPreferredModel?: string;
20
21
  readonly selectedSessionPreferredThinking?: ThinkingLevel | null;
21
22
  };
@@ -34,10 +35,7 @@ export const useSessionConversationPreferenceActions = ({
34
35
  selectedThinkingLevel: currentSelectedThinkingLevel,
35
36
  updatePreferences,
36
37
  }: SessionConversationPreferenceActionsParams) => {
37
- const previousSessionKeyRef = useRef<string | null | undefined>(undefined);
38
- const setSelectedModel = useCallback((selectedModel: ConversationPreferenceValue) => {
39
- updatePreferences({ selectedModel });
40
- }, [updatePreferences]);
38
+ const previousPreferenceContextRef = useRef<string | undefined>(undefined);
41
39
  const setSelectedThinkingLevel = useCallback((selectedThinkingLevel: ThinkingLevel | null) => {
42
40
  updatePreferences({ selectedThinkingLevel });
43
41
  }, [updatePreferences]);
@@ -48,19 +46,21 @@ export const useSessionConversationPreferenceActions = ({
48
46
  modelOptions,
49
47
  selectedSessionExists,
50
48
  selectedSessionKey: sessionKey,
49
+ selectedSessionType,
51
50
  selectedSessionPreferredModel,
52
51
  selectedSessionPreferredThinking,
53
52
  }: SessionConversationPreferenceSyncParams) => {
54
53
  const selectedSessionKey = sessionKey ?? null;
55
- const sessionChanged = previousSessionKeyRef.current !== selectedSessionKey;
56
- const preserveCurrentPreference = sessionChanged && Boolean(selectedSessionKey) && !selectedSessionExists;
54
+ const preferenceContextKey = JSON.stringify([selectedSessionType, selectedSessionKey]);
55
+ const preferenceContextChanged = previousPreferenceContextRef.current !== preferenceContextKey;
56
+ const preserveCurrentPreference = preferenceContextChanged && Boolean(selectedSessionKey) && !selectedSessionExists;
57
57
  const selectedModel = resolveSelectedModelValue({
58
58
  currentSelectedModel: currentSelectedModel ?? undefined,
59
59
  modelOptions,
60
60
  selectedSessionPreferredModel,
61
61
  fallbackPreferredModel,
62
62
  defaultModel,
63
- preferSessionPreferredModel: sessionChanged,
63
+ preferSessionPreferredModel: preferenceContextChanged,
64
64
  preserveCurrentSelectedModelOnSessionChange: preserveCurrentPreference,
65
65
  });
66
66
  const modelOption = modelOptions.find((option) => option.value === selectedModel);
@@ -72,7 +72,7 @@ export const useSessionConversationPreferenceActions = ({
72
72
  fallbackPreferredThinking,
73
73
  defaultThinkingLevel:
74
74
  (modelOption?.thinkingCapability?.default as ThinkingLevel | null | undefined) ?? null,
75
- preferSessionPreferredThinking: sessionChanged,
75
+ preferSessionPreferredThinking: preferenceContextChanged,
76
76
  preserveCurrentSelectedThinkingOnSessionChange: preserveCurrentPreference,
77
77
  });
78
78
  if (
@@ -82,13 +82,12 @@ export const useSessionConversationPreferenceActions = ({
82
82
  updatePreferences({ selectedModel, selectedThinkingLevel });
83
83
  }
84
84
  if (!selectedSessionKey || selectedSessionExists) {
85
- previousSessionKeyRef.current = selectedSessionKey;
85
+ previousPreferenceContextRef.current = preferenceContextKey;
86
86
  }
87
87
  }, [currentSelectedModel, currentSelectedThinkingLevel, updatePreferences]);
88
88
 
89
89
  return useMemo(() => ({
90
- setSelectedModel,
91
90
  setSelectedThinkingLevel,
92
91
  syncSessionPreferences,
93
- }), [setSelectedModel, setSelectedThinkingLevel, syncSessionPreferences]);
92
+ }), [setSelectedThinkingLevel, syncSessionPreferences]);
94
93
  };
@@ -7,9 +7,10 @@ import type { ChatSlashCommandDescriptor } from '@/features/chat/features/input/
7
7
 
8
8
  export function useSessionConversationSlashCommands(params: {
9
9
  language: I18nLanguage;
10
+ onContextCompactingChange?: (sessionId: string, isCompacting: boolean) => void;
10
11
  selectedSessionKey?: string | null;
11
12
  }): readonly ChatSlashCommandDescriptor[] {
12
- const { language, selectedSessionKey } = params;
13
+ const { language, onContextCompactingChange, selectedSessionKey } = params;
13
14
  const presenter = usePresenter();
14
15
  const compactingSessionIdsRef = useRef(new Set<string>());
15
16
  const compactContext = useCallback(async (sessionId: string) => {
@@ -17,6 +18,7 @@ export function useSessionConversationSlashCommands(params: {
17
18
  return;
18
19
  }
19
20
  compactingSessionIdsRef.current.add(sessionId);
21
+ onContextCompactingChange?.(sessionId, true);
20
22
  try {
21
23
  await compactNcpSessionContext(sessionId);
22
24
  toast.success(t('chatSlashCommandCompactContextSuccess', language));
@@ -25,8 +27,9 @@ export function useSessionConversationSlashCommands(params: {
25
27
  toast.error(`${t('chatSlashCommandCompactContextFailed', language)}: ${message}`);
26
28
  } finally {
27
29
  compactingSessionIdsRef.current.delete(sessionId);
30
+ onContextCompactingChange?.(sessionId, false);
28
31
  }
29
- }, [language]);
32
+ }, [language, onContextCompactingChange]);
30
33
 
31
34
  return useMemo(() => {
32
35
  const sessionId = selectedSessionKey?.trim();
@@ -159,16 +159,19 @@ const renderChatPanelAppCard = (panelApp: ChatPanelAppCardViewModel) => (
159
159
  <ChatInlinePanelAppCard panelApp={panelApp} />
160
160
  );
161
161
 
162
- function ChatContextCompactionDivider({
162
+ export function ChatContextCompactionDivider({
163
163
  checkpoint,
164
164
  }: {
165
- checkpoint: ContextCompactionTimelineView;
165
+ checkpoint?: ContextCompactionTimelineView;
166
166
  }) {
167
- const title = [
168
- `${t("chatContextCompactionCoveredMessages")}: ${checkpoint.coveredSessionMessageCount}`,
169
- `${t("chatContextCompactionOriginalTokens")}: ${checkpoint.originalEstimatedTokens}`,
170
- `${t("chatContextCompactionProjectedTokens")}: ${checkpoint.projectedEstimatedTokens}`,
171
- ].join("\n");
167
+ const isCompacting = !checkpoint || checkpoint.status === "compressing";
168
+ const title = checkpoint
169
+ ? [
170
+ `${t("chatContextCompactionCoveredMessages")}: ${checkpoint.coveredSessionMessageCount}`,
171
+ `${t("chatContextCompactionOriginalTokens")}: ${checkpoint.originalEstimatedTokens}`,
172
+ `${t("chatContextCompactionProjectedTokens")}: ${checkpoint.projectedEstimatedTokens}`,
173
+ ].join("\n")
174
+ : undefined;
172
175
  return (
173
176
  <div
174
177
  className="my-4 flex items-center gap-3 text-[11px] text-muted-foreground"
@@ -176,13 +179,13 @@ function ChatContextCompactionDivider({
176
179
  >
177
180
  <div className="h-px flex-1 bg-border" />
178
181
  <div className="inline-flex items-center gap-2 rounded-full border border-border bg-muted px-3 py-1">
179
- {checkpoint.status === "compressing" ? (
182
+ {isCompacting ? (
180
183
  <span className="h-1.5 w-1.5 animate-pulse rounded-full bg-muted-foreground" />
181
184
  ) : (
182
185
  <span className="h-1.5 w-1.5 rounded-full bg-muted-foreground/45" />
183
186
  )}
184
187
  <span>
185
- {checkpoint.status === "compressing"
188
+ {isCompacting
186
189
  ? t("chatContextCompactionCompressing")
187
190
  : t("chatContextCompactionCompressed")}
188
191
  </span>
@@ -1,5 +1,10 @@
1
1
  import { act, renderHook, waitFor } from "@testing-library/react";
2
- import { NcpEventType, type NcpAgentClientEndpoint } from "@nextclaw/ncp";
2
+ import {
3
+ NcpEventType,
4
+ type NcpAgentClientEndpoint,
5
+ type NcpEndpointSubscriber,
6
+ type NcpMessage,
7
+ } from "@nextclaw/ncp";
3
8
  import { useHydratedNcpAgent, useNcpAgentRuntime } from "@nextclaw/ncp-react";
4
9
  import { DefaultNcpAgentConversationStateManager } from "@nextclaw/ncp-toolkit";
5
10
  import { beforeEach, describe, expect, it, vi } from "vitest";
@@ -147,4 +152,120 @@ describe("useHydratedNcpAgent", () => {
147
152
  });
148
153
  expect(result.current.visibleMessages).toEqual([historyMessage]);
149
154
  });
155
+
156
+ it("starts the live stream when a draft manager already contains the materialized session", async () => {
157
+ let subscriber: NcpEndpointSubscriber | null = null;
158
+ const client = {
159
+ stop: mocks.stop.mockResolvedValue(undefined),
160
+ stream: mocks.stream.mockImplementation(() => new Promise(() => {})),
161
+ subscribe: vi.fn((nextSubscriber: NcpEndpointSubscriber) => {
162
+ subscriber = nextSubscriber;
163
+ return () => {};
164
+ }),
165
+ } as unknown as NcpAgentClientEndpoint;
166
+ const loadSeed = vi.fn().mockResolvedValue({
167
+ messages: [],
168
+ status: "idle",
169
+ });
170
+ const materializedMessage: NcpMessage = {
171
+ id: "message-materialized",
172
+ sessionId: "session-materialized",
173
+ role: "user",
174
+ status: "final",
175
+ parts: [{ type: "text", text: "Visible immediately" }],
176
+ timestamp: "2026-07-24T00:00:00.000Z",
177
+ };
178
+ const { result, rerender } = renderHook(
179
+ ({ sessionId }: { sessionId?: string }) =>
180
+ useHydratedNcpAgent({
181
+ sessionId,
182
+ client: client as never,
183
+ loadSeed,
184
+ }),
185
+ { initialProps: { sessionId: undefined as string | undefined } },
186
+ );
187
+
188
+ await waitFor(() => {
189
+ expect(subscriber).not.toBeNull();
190
+ });
191
+ act(() => {
192
+ subscriber?.({
193
+ type: NcpEventType.MessageSent,
194
+ payload: {
195
+ sessionId: "session-materialized",
196
+ message: materializedMessage,
197
+ },
198
+ });
199
+ });
200
+ await waitFor(() => {
201
+ expect(result.current.visibleMessages).toEqual([materializedMessage]);
202
+ });
203
+
204
+ rerender({ sessionId: "session-materialized" });
205
+
206
+ await waitFor(() => {
207
+ expect(result.current.isHydrating).toBe(false);
208
+ });
209
+ expect(loadSeed).not.toHaveBeenCalled();
210
+ expect(mocks.stream).toHaveBeenCalledWith({
211
+ sessionId: "session-materialized",
212
+ });
213
+ });
214
+
215
+ it("reloads missed state and reconnects after the live stream disconnects", async () => {
216
+ vi.useFakeTimers();
217
+ try {
218
+ const connected = new Promise<void>(() => {});
219
+ const client = {
220
+ stop: mocks.stop.mockResolvedValue(undefined),
221
+ stream: mocks.stream
222
+ .mockRejectedValueOnce(new Error("stream disconnected"))
223
+ .mockImplementationOnce(() => connected),
224
+ subscribe: vi.fn(() => () => {}),
225
+ send: mocks.send,
226
+ } as unknown as NcpAgentClientEndpoint;
227
+ const recoveredMessage = {
228
+ id: "message-recovered",
229
+ sessionId: "session-recovery",
230
+ role: "assistant",
231
+ status: "final",
232
+ parts: [{ type: "text", text: "Recovered" }],
233
+ timestamp: "2026-07-24T00:01:00.000Z",
234
+ } as const;
235
+ const loadSeed = vi
236
+ .fn()
237
+ .mockResolvedValueOnce({ messages: [], status: "running" })
238
+ .mockResolvedValueOnce({
239
+ messages: [recoveredMessage],
240
+ status: "idle",
241
+ });
242
+ const { result } = renderHook(() =>
243
+ useHydratedNcpAgent({
244
+ sessionId: "session-recovery",
245
+ client: client as never,
246
+ loadSeed,
247
+ }),
248
+ );
249
+
250
+ await vi.waitFor(() => {
251
+ expect(result.current.hydrateError?.message).toBe(
252
+ "stream disconnected",
253
+ );
254
+ });
255
+ await act(async () => {
256
+ await vi.runOnlyPendingTimersAsync();
257
+ });
258
+ await vi.waitFor(() => {
259
+ expect(result.current.visibleMessages).toEqual([recoveredMessage]);
260
+ });
261
+
262
+ expect(loadSeed).toHaveBeenCalledTimes(2);
263
+ expect(mocks.stream).toHaveBeenCalledTimes(2);
264
+ expect(mocks.send).not.toHaveBeenCalled();
265
+ expect(result.current.hydrateError).toBeNull();
266
+ expect(result.current.isRunning).toBe(false);
267
+ } finally {
268
+ vi.useRealTimers();
269
+ }
270
+ });
150
271
  });
@@ -5,7 +5,7 @@ import { useNcpAgentRuntime } from '@nextclaw/ncp-react';
5
5
  import { DefaultNcpAgentConversationStateManager } from '@nextclaw/ncp-toolkit';
6
6
 
7
7
  describe('useNcpAgentRuntime backend queue submission', () => {
8
- it('forwards a new message while a run is active and waits for backend events to add it', async () => {
8
+ it('forwards and immediately shows a new message while a run is active', async () => {
9
9
  const manager = new DefaultNcpAgentConversationStateManager();
10
10
  manager.hydrate({ sessionId: 'session-1', messages: [] });
11
11
  await manager.dispatch({
@@ -46,6 +46,13 @@ describe('useNcpAgentRuntime backend queue submission', () => {
46
46
  parts: [{ type: 'text', text: 'queue this' }],
47
47
  }),
48
48
  }));
49
- expect(result.current.visibleMessages).toEqual([]);
49
+ expect(result.current.visibleMessages).toEqual([
50
+ expect.objectContaining({
51
+ sessionId: 'session-1',
52
+ role: 'user',
53
+ status: 'final',
54
+ parts: [{ type: 'text', text: 'queue this' }],
55
+ }),
56
+ ]);
50
57
  });
51
58
  });