@nextclaw/ui 0.15.16 → 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.
- package/CHANGELOG.md +26 -0
- package/dist/assets/{app-presenter-provider-DcpcRgLn.js → app-presenter-provider-De7Zwitu.js} +14 -14
- package/dist/assets/{appearance-settings-page-yJR6Kt9c.js → appearance-settings-page-BkQaPtQ-.js} +1 -1
- package/dist/assets/{chat-page-CXYXMIQQ.js → chat-page-AvOuaxuv.js} +1 -1
- package/dist/assets/{index-6Esxu-Pp.js → index-B5AoUrMf.js} +2 -2
- package/dist/assets/index-QveCjxCj.css +1 -0
- package/dist/assets/remote-B6TXClIg.js +1 -0
- package/dist/assets/{runtime-config-page-CMLVC5UV.js → runtime-config-page-BLrTVO3n.js} +1 -1
- package/dist/index.html +3 -3
- package/package.json +9 -9
- package/src/app/configs/app-navigation.config.ts +1 -1
- package/src/features/chat/components/conversation/__tests__/chat-conversation-content.test.tsx +11 -0
- package/src/features/chat/components/conversation/chat-conversation-content.tsx +7 -1
- package/src/features/chat/features/conversation/components/__tests__/session-conversation-area.test.tsx +50 -4
- package/src/features/chat/features/conversation/components/session-conversation-area.tsx +25 -1
- package/src/features/chat/features/conversation/components/session-conversation-input.tsx +3 -0
- package/src/features/chat/features/conversation/hooks/__tests__/use-session-conversation-input-state.test.tsx +41 -0
- package/src/features/chat/features/conversation/hooks/__tests__/use-session-conversation-slash-commands.test.tsx +28 -0
- package/src/features/chat/features/conversation/hooks/use-session-conversation-input-query.ts +1 -1
- package/src/features/chat/features/conversation/hooks/use-session-conversation-input-state.ts +0 -1
- package/src/features/chat/features/conversation/hooks/use-session-conversation-preference-actions.ts +10 -11
- package/src/features/chat/features/conversation/hooks/use-session-conversation-slash-commands.ts +5 -2
- package/src/features/chat/features/message/components/chat-message-list.container.tsx +12 -9
- package/src/features/chat/features/message/hooks/__tests__/use-chat-message-virtualizer.test.tsx +49 -41
- package/src/features/chat/features/message/hooks/use-chat-message-virtualizer.ts +1 -1
- package/src/features/chat/features/ncp/hooks/__tests__/use-hydrated-ncp-agent.test.tsx +122 -1
- package/src/features/chat/features/ncp/hooks/__tests__/use-ncp-agent-runtime-queue.test.tsx +9 -2
- package/src/features/chat/features/ncp/hooks/__tests__/use-ncp-agent-runtime.test.tsx +107 -1
- package/src/platforms/mobile/components/__tests__/mobile-app-shell.test.tsx +5 -1
- package/src/platforms/mobile/components/__tests__/mobile-bottom-nav.test.tsx +24 -3
- package/src/platforms/mobile/components/mobile-bottom-nav.tsx +29 -32
- package/dist/assets/index-BzSgA8tR.css +0 -1
- package/dist/assets/remote-CPpTKDgX.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',
|
package/src/features/chat/features/conversation/hooks/use-session-conversation-input-query.ts
CHANGED
|
@@ -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,
|
package/src/features/chat/features/conversation/hooks/use-session-conversation-input-state.ts
CHANGED
|
@@ -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;
|
package/src/features/chat/features/conversation/hooks/use-session-conversation-preference-actions.ts
CHANGED
|
@@ -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
|
|
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
|
|
56
|
-
const
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
}), [
|
|
92
|
+
}), [setSelectedThinkingLevel, syncSessionPreferences]);
|
|
94
93
|
};
|
package/src/features/chat/features/conversation/hooks/use-session-conversation-slash-commands.ts
CHANGED
|
@@ -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
|
|
165
|
+
checkpoint?: ContextCompactionTimelineView;
|
|
166
166
|
}) {
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
{
|
|
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
|
-
{
|
|
188
|
+
{isCompacting
|
|
186
189
|
? t("chatContextCompactionCompressing")
|
|
187
190
|
: t("chatContextCompactionCompressed")}
|
|
188
191
|
</span>
|
package/src/features/chat/features/message/hooks/__tests__/use-chat-message-virtualizer.test.tsx
CHANGED
|
@@ -207,47 +207,55 @@ describe("useChatMessageVirtualizer", () => {
|
|
|
207
207
|
expect(scrollElement.scrollTop - (anchored?.start ?? 0)).toBe(anchorOffset);
|
|
208
208
|
});
|
|
209
209
|
|
|
210
|
-
it(
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
210
|
+
it.each([11, 20])(
|
|
211
|
+
"does not reclaim sticky escape when the active row grows %d px from the bottom",
|
|
212
|
+
async (escapeDistance) => {
|
|
213
|
+
const scrollElement = document.createElement("div");
|
|
214
|
+
Object.defineProperties(scrollElement, {
|
|
215
|
+
clientHeight: { configurable: true, value: 800 },
|
|
216
|
+
offsetHeight: { configurable: true, value: 800 },
|
|
217
|
+
offsetWidth: { configurable: true, value: 800 },
|
|
218
|
+
scrollTop: { configurable: true, writable: true, value: 0 },
|
|
219
|
+
});
|
|
220
|
+
scrollElement.scrollTo = vi.fn((options) => {
|
|
221
|
+
if (typeof options === "object" && options.top !== undefined) {
|
|
222
|
+
scrollElement.scrollTop = options.top;
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
const rows = Array.from({ length: 10 }, (_, index) => ({
|
|
226
|
+
key: `message:${index}`,
|
|
227
|
+
}));
|
|
228
|
+
const { result } = renderHook(() =>
|
|
229
|
+
useChatMessageVirtualizer({
|
|
230
|
+
rows,
|
|
231
|
+
scrollRef: { current: scrollElement },
|
|
232
|
+
activeRowKey: "message:9",
|
|
233
|
+
}),
|
|
234
|
+
);
|
|
235
|
+
await waitFor(() =>
|
|
236
|
+
expect(
|
|
237
|
+
result.current.virtualizer.getVirtualItems().length,
|
|
238
|
+
).toBeGreaterThan(0),
|
|
239
|
+
);
|
|
240
|
+
act(() => result.current.virtualizer.resizeItem(9, 1_000));
|
|
241
|
+
const escapedScrollTop =
|
|
242
|
+
result.current.virtualizer.getTotalSize() -
|
|
243
|
+
scrollElement.clientHeight -
|
|
244
|
+
escapeDistance;
|
|
245
|
+
scrollElement.scrollTop = escapedScrollTop;
|
|
246
|
+
act(() => scrollElement.dispatchEvent(new Event("scroll")));
|
|
247
|
+
await waitFor(() =>
|
|
248
|
+
expect(result.current.virtualizer.scrollOffset).toBe(escapedScrollTop),
|
|
249
|
+
);
|
|
245
250
|
|
|
246
|
-
|
|
251
|
+
act(() => result.current.virtualizer.resizeItem(9, 1_100));
|
|
247
252
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
+
expect(scrollElement.scrollTop).toBe(escapedScrollTop);
|
|
254
|
+
expect(
|
|
255
|
+
result.current.virtualizer.getTotalSize() -
|
|
256
|
+
scrollElement.scrollTop -
|
|
257
|
+
scrollElement.clientHeight,
|
|
258
|
+
).toBe(escapeDistance + 100);
|
|
259
|
+
},
|
|
260
|
+
);
|
|
253
261
|
});
|
|
@@ -53,7 +53,7 @@ export function useChatMessageVirtualizer(params: {
|
|
|
53
53
|
},
|
|
54
54
|
});
|
|
55
55
|
virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item) =>
|
|
56
|
-
item.
|
|
56
|
+
item.end <= (virtualizer.scrollOffset ?? 0);
|
|
57
57
|
const containerRef = useCallback(
|
|
58
58
|
(node: HTMLDivElement | null) => {
|
|
59
59
|
sizeContainerRef.current = node;
|