@nextclaw/ui 0.8.0 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/dist/assets/ChannelsList-DhvjpZcs.js +1 -0
- package/dist/assets/ChatPage-B8VBaMQm.js +38 -0
- package/dist/assets/{DocBrowser-DDX2HMXW.js → DocBrowser-LpzGe8An.js} +1 -1
- package/dist/assets/{LogoBadge-J53F_3JA.js → LogoBadge-Be4lktJN.js} +1 -1
- package/dist/assets/{MarketplacePage-0BZ4bza0.js → MarketplacePage-Cx9AI3_h.js} +3 -3
- package/dist/assets/{ModelConfig-Wzq9wGHV.js → ModelConfig-DuImUHIX.js} +1 -1
- package/dist/assets/ProvidersList-Ccleg25k.js +1 -0
- package/dist/assets/{RuntimeConfig-N771_AM6.js → RuntimeConfig-C6iqpJR_.js} +1 -1
- package/dist/assets/{SearchConfig-DVt5QVa_.js → SearchConfig-Dvp1TAXu.js} +1 -1
- package/dist/assets/{SecretsConfig-CkwauPa8.js → SecretsConfig-D5Ymlvt9.js} +1 -1
- package/dist/assets/{SessionsConfig-C3mnHzkZ.js → SessionsConfig-CIA_jA1P.js} +2 -2
- package/dist/assets/{chat-message-pxr79GDs.js → chat-message-B60Fh9kI.js} +1 -1
- package/dist/assets/index-BiPDnzv0.js +8 -0
- package/dist/assets/index-C8GsgIUn.css +1 -0
- package/dist/assets/{index-GdpEEKnz.js → index-CPDASUXh.js} +1 -1
- package/dist/assets/{label-CmksBHgc.js → label-D4fGx6Wb.js} +1 -1
- package/dist/assets/{page-layout-Db0GbnhS.js → page-layout-twy8gmBE.js} +1 -1
- package/dist/assets/popover-DYbYpt1j.js +1 -0
- package/dist/assets/{security-config-CjLFME5Q.js → security-config-BcIZ4rpb.js} +1 -1
- package/dist/assets/skeleton-DypBy7jp.js +1 -0
- package/dist/assets/{switch-C24d-UJU.js → switch-DqA6r5XR.js} +1 -1
- package/dist/assets/tabs-custom-C6enKKs1.js +1 -0
- package/dist/assets/{useConfirmDialog-BeP35LcG.js → useConfirmDialog-CHBf5Of7.js} +1 -1
- package/dist/assets/{vendor-psXJBy9u.js → vendor-DKBNiC31.js} +1 -1
- package/dist/index.html +3 -3
- package/package.json +6 -6
- package/src/api/config.ts +9 -38
- package/src/api/ncp-session.ts +50 -0
- package/src/api/types.ts +1 -0
- package/src/components/chat/ChatConversationPanel.test.tsx +65 -0
- package/src/components/chat/ChatConversationPanel.tsx +21 -12
- package/src/components/chat/ChatSidebar.test.tsx +203 -0
- package/src/components/chat/ChatSidebar.tsx +97 -7
- package/src/components/chat/adapters/chat-message.adapter.test.ts +132 -82
- package/src/components/chat/adapters/chat-message.adapter.ts +27 -9
- package/src/components/chat/chat-composer-state.ts +53 -0
- package/src/components/chat/chat-page-data.ts +30 -1
- package/src/components/chat/chat-page-runtime.test.ts +181 -0
- package/src/components/chat/chat-page-runtime.ts +101 -15
- package/src/components/chat/chat-session-preference-sync.test.ts +62 -0
- package/src/components/chat/chat-session-preference-sync.ts +75 -0
- package/src/components/chat/chat-stream/types.ts +3 -0
- package/src/components/chat/containers/chat-input-bar.container.tsx +12 -63
- package/src/components/chat/containers/chat-message-list.container.tsx +31 -27
- package/src/components/chat/legacy/LegacyChatPage.tsx +25 -0
- package/src/components/chat/managers/chat-input.manager.ts +48 -13
- package/src/components/chat/managers/chat-session-list.manager.test.ts +39 -0
- package/src/components/chat/managers/chat-session-list.manager.ts +9 -3
- package/src/components/chat/ncp/NcpChatPage.tsx +53 -13
- package/src/components/chat/ncp/ncp-chat-input.manager.ts +48 -12
- package/src/components/chat/ncp/ncp-chat-page-data.ts +34 -2
- package/src/components/chat/ncp/ncp-chat-thread.manager.ts +1 -1
- package/src/components/chat/ncp/ncp-session-adapter.test.ts +27 -1
- package/src/components/chat/ncp/ncp-session-adapter.ts +20 -0
- package/src/components/chat/presenter/chat-presenter-context.tsx +2 -0
- package/src/components/chat/stores/chat-input.store.ts +4 -0
- package/src/components/chat/stores/chat-thread.store.ts +2 -0
- package/src/components/chat/useChatSessionTypeState.test.tsx +58 -0
- package/src/components/chat/useChatSessionTypeState.ts +25 -8
- package/src/hooks/use-ncp-chat-session-types.ts +11 -0
- package/src/hooks/useConfig.ts +2 -4
- package/src/hooks/useMarketplace.ts +7 -4
- package/src/hooks/useWebSocket.ts +23 -2
- package/dist/assets/ChannelsList-DBcoVJRW.js +0 -1
- package/dist/assets/ChatPage-CD3cxyyM.js +0 -37
- package/dist/assets/ProvidersList-kwzRS8_M.js +0 -1
- package/dist/assets/index-BIvFMkN4.js +0 -1
- package/dist/assets/index-CzkY1reu.js +0 -8
- package/dist/assets/index-RZ0kHHRI.css +0 -1
- package/dist/assets/skeleton-CkpQeVWN.js +0 -1
- package/dist/assets/tabs-custom-D89bh-fc.js +0 -1
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import type { SessionEntryView } from '@/api/types';
|
|
3
|
+
import { resolveRecentSessionPreferredModel, resolveSelectedModelValue } from '@/components/chat/chat-page-runtime';
|
|
4
|
+
|
|
5
|
+
const modelOptions = [
|
|
6
|
+
{
|
|
7
|
+
value: 'anthropic/claude-sonnet-4',
|
|
8
|
+
modelLabel: 'claude-sonnet-4',
|
|
9
|
+
providerLabel: 'Anthropic',
|
|
10
|
+
thinkingCapability: null
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
value: 'openai/gpt-5',
|
|
14
|
+
modelLabel: 'gpt-5',
|
|
15
|
+
providerLabel: 'OpenAI',
|
|
16
|
+
thinkingCapability: null
|
|
17
|
+
}
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
function createSession(overrides: Partial<SessionEntryView> & Pick<SessionEntryView, 'key'>): SessionEntryView {
|
|
21
|
+
return {
|
|
22
|
+
key: overrides.key,
|
|
23
|
+
createdAt: overrides.createdAt ?? '2026-03-19T00:00:00.000Z',
|
|
24
|
+
updatedAt: overrides.updatedAt ?? '2026-03-19T00:00:00.000Z',
|
|
25
|
+
sessionType: overrides.sessionType ?? 'native',
|
|
26
|
+
sessionTypeMutable: overrides.sessionTypeMutable ?? false,
|
|
27
|
+
messageCount: overrides.messageCount ?? 0,
|
|
28
|
+
...(overrides.label ? { label: overrides.label } : {}),
|
|
29
|
+
...(overrides.preferredModel ? { preferredModel: overrides.preferredModel } : {}),
|
|
30
|
+
...(overrides.lastRole ? { lastRole: overrides.lastRole } : {}),
|
|
31
|
+
...(overrides.lastTimestamp ? { lastTimestamp: overrides.lastTimestamp } : {})
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe('resolveSelectedModelValue', () => {
|
|
36
|
+
it('keeps the current selected model when it is still available', () => {
|
|
37
|
+
expect(
|
|
38
|
+
resolveSelectedModelValue({
|
|
39
|
+
currentSelectedModel: 'openai/gpt-5',
|
|
40
|
+
modelOptions,
|
|
41
|
+
selectedSessionPreferredModel: 'anthropic/claude-sonnet-4',
|
|
42
|
+
fallbackPreferredModel: 'anthropic/claude-sonnet-4',
|
|
43
|
+
defaultModel: 'anthropic/claude-sonnet-4'
|
|
44
|
+
})
|
|
45
|
+
).toBe('openai/gpt-5');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('prefers the current session preferred model over runtime fallback and global default', () => {
|
|
49
|
+
expect(
|
|
50
|
+
resolveSelectedModelValue({
|
|
51
|
+
currentSelectedModel: 'missing/model',
|
|
52
|
+
modelOptions,
|
|
53
|
+
selectedSessionPreferredModel: 'openai/gpt-5',
|
|
54
|
+
fallbackPreferredModel: 'anthropic/claude-sonnet-4',
|
|
55
|
+
defaultModel: 'anthropic/claude-sonnet-4'
|
|
56
|
+
})
|
|
57
|
+
).toBe('openai/gpt-5');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('prefers the current session preferred model over a stale in-memory selection after switching sessions', () => {
|
|
61
|
+
expect(
|
|
62
|
+
resolveSelectedModelValue({
|
|
63
|
+
currentSelectedModel: 'anthropic/claude-sonnet-4',
|
|
64
|
+
modelOptions,
|
|
65
|
+
selectedSessionPreferredModel: 'openai/gpt-5',
|
|
66
|
+
fallbackPreferredModel: 'anthropic/claude-sonnet-4',
|
|
67
|
+
defaultModel: 'anthropic/claude-sonnet-4',
|
|
68
|
+
preferSessionPreferredModel: true
|
|
69
|
+
})
|
|
70
|
+
).toBe('openai/gpt-5');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('ignores the stale in-memory selection when a switched session has no explicit preferred model', () => {
|
|
74
|
+
expect(
|
|
75
|
+
resolveSelectedModelValue({
|
|
76
|
+
currentSelectedModel: 'anthropic/claude-sonnet-4',
|
|
77
|
+
modelOptions,
|
|
78
|
+
fallbackPreferredModel: 'openai/gpt-5',
|
|
79
|
+
defaultModel: 'anthropic/claude-sonnet-4',
|
|
80
|
+
preferSessionPreferredModel: true
|
|
81
|
+
})
|
|
82
|
+
).toBe('openai/gpt-5');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('uses the recent same-runtime model when the current session has no valid preferred model', () => {
|
|
86
|
+
expect(
|
|
87
|
+
resolveSelectedModelValue({
|
|
88
|
+
currentSelectedModel: 'missing/model',
|
|
89
|
+
modelOptions,
|
|
90
|
+
fallbackPreferredModel: 'openai/gpt-5',
|
|
91
|
+
defaultModel: 'anthropic/claude-sonnet-4'
|
|
92
|
+
})
|
|
93
|
+
).toBe('openai/gpt-5');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('falls back to the global default model when the recent same-runtime model is unavailable', () => {
|
|
97
|
+
expect(
|
|
98
|
+
resolveSelectedModelValue({
|
|
99
|
+
currentSelectedModel: 'missing/model',
|
|
100
|
+
modelOptions,
|
|
101
|
+
fallbackPreferredModel: 'missing/model',
|
|
102
|
+
defaultModel: 'anthropic/claude-sonnet-4'
|
|
103
|
+
})
|
|
104
|
+
).toBe('anthropic/claude-sonnet-4');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('falls back to the first available model when no candidate is valid', () => {
|
|
108
|
+
expect(
|
|
109
|
+
resolveSelectedModelValue({
|
|
110
|
+
currentSelectedModel: 'missing/model',
|
|
111
|
+
modelOptions,
|
|
112
|
+
selectedSessionPreferredModel: 'missing/model',
|
|
113
|
+
fallbackPreferredModel: 'missing/model',
|
|
114
|
+
defaultModel: 'missing/model'
|
|
115
|
+
})
|
|
116
|
+
).toBe('anthropic/claude-sonnet-4');
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe('resolveRecentSessionPreferredModel', () => {
|
|
121
|
+
it('returns the most recent preferred model from the same runtime', () => {
|
|
122
|
+
const sessions = [
|
|
123
|
+
createSession({
|
|
124
|
+
key: 'native-1',
|
|
125
|
+
sessionType: 'native',
|
|
126
|
+
preferredModel: 'anthropic/claude-sonnet-4',
|
|
127
|
+
updatedAt: '2026-03-18T01:00:00.000Z'
|
|
128
|
+
}),
|
|
129
|
+
createSession({
|
|
130
|
+
key: 'codex-1',
|
|
131
|
+
sessionType: 'codex',
|
|
132
|
+
preferredModel: 'openai/gpt-5',
|
|
133
|
+
updatedAt: '2026-03-18T03:00:00.000Z'
|
|
134
|
+
}),
|
|
135
|
+
createSession({
|
|
136
|
+
key: 'codex-2',
|
|
137
|
+
sessionType: 'codex',
|
|
138
|
+
preferredModel: 'anthropic/claude-sonnet-4',
|
|
139
|
+
updatedAt: '2026-03-18T02:00:00.000Z'
|
|
140
|
+
})
|
|
141
|
+
];
|
|
142
|
+
|
|
143
|
+
expect(
|
|
144
|
+
resolveRecentSessionPreferredModel({
|
|
145
|
+
sessions,
|
|
146
|
+
selectedSessionKey: 'draft',
|
|
147
|
+
sessionType: 'codex'
|
|
148
|
+
})
|
|
149
|
+
).toBe('openai/gpt-5');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('ignores the currently selected session and sessions without preferred models', () => {
|
|
153
|
+
const sessions = [
|
|
154
|
+
createSession({
|
|
155
|
+
key: 'codex-current',
|
|
156
|
+
sessionType: 'codex',
|
|
157
|
+
preferredModel: 'openai/gpt-5',
|
|
158
|
+
updatedAt: '2026-03-18T03:00:00.000Z'
|
|
159
|
+
}),
|
|
160
|
+
createSession({
|
|
161
|
+
key: 'codex-empty',
|
|
162
|
+
sessionType: 'codex',
|
|
163
|
+
updatedAt: '2026-03-18T04:00:00.000Z'
|
|
164
|
+
}),
|
|
165
|
+
createSession({
|
|
166
|
+
key: 'codex-fallback',
|
|
167
|
+
sessionType: 'codex',
|
|
168
|
+
preferredModel: 'anthropic/claude-sonnet-4',
|
|
169
|
+
updatedAt: '2026-03-18T02:00:00.000Z'
|
|
170
|
+
})
|
|
171
|
+
];
|
|
172
|
+
|
|
173
|
+
expect(
|
|
174
|
+
resolveRecentSessionPreferredModel({
|
|
175
|
+
sessions,
|
|
176
|
+
selectedSessionKey: 'codex-current',
|
|
177
|
+
sessionType: 'codex'
|
|
178
|
+
})
|
|
179
|
+
).toBe('anthropic/claude-sonnet-4');
|
|
180
|
+
});
|
|
181
|
+
});
|
|
@@ -1,39 +1,125 @@
|
|
|
1
1
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
2
2
|
import type { Dispatch, SetStateAction } from 'react';
|
|
3
|
-
import type { ChatRunView } from '@/api/types';
|
|
3
|
+
import type { ChatRunView, SessionEntryView } from '@/api/types';
|
|
4
4
|
import type { ChatModelOption } from '@/components/chat/chat-input.types';
|
|
5
5
|
import { useChatRuns } from '@/hooks/useConfig';
|
|
6
6
|
import { buildActiveRunBySessionKey, buildSessionRunStatusByKey } from '@/lib/session-run-status';
|
|
7
7
|
|
|
8
8
|
export type ChatMainPanelView = 'chat' | 'cron' | 'skills';
|
|
9
9
|
|
|
10
|
+
function normalizeSessionType(value: string | null | undefined): string {
|
|
11
|
+
const normalized = value?.trim().toLowerCase();
|
|
12
|
+
return normalized || 'native';
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function hasModelOption(modelOptions: ChatModelOption[], value: string | null | undefined): value is string {
|
|
16
|
+
const normalized = value?.trim();
|
|
17
|
+
if (!normalized) {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
return modelOptions.some((option) => option.value === normalized);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function resolveSelectedModelValue(params: {
|
|
24
|
+
currentSelectedModel?: string;
|
|
25
|
+
modelOptions: ChatModelOption[];
|
|
26
|
+
selectedSessionPreferredModel?: string;
|
|
27
|
+
fallbackPreferredModel?: string;
|
|
28
|
+
defaultModel?: string;
|
|
29
|
+
preferSessionPreferredModel?: boolean;
|
|
30
|
+
}): string {
|
|
31
|
+
const {
|
|
32
|
+
currentSelectedModel,
|
|
33
|
+
modelOptions,
|
|
34
|
+
selectedSessionPreferredModel,
|
|
35
|
+
fallbackPreferredModel,
|
|
36
|
+
defaultModel,
|
|
37
|
+
preferSessionPreferredModel = false
|
|
38
|
+
} = params;
|
|
39
|
+
if (modelOptions.length === 0) {
|
|
40
|
+
return '';
|
|
41
|
+
}
|
|
42
|
+
if (!preferSessionPreferredModel && hasModelOption(modelOptions, currentSelectedModel)) {
|
|
43
|
+
return currentSelectedModel.trim();
|
|
44
|
+
}
|
|
45
|
+
if (hasModelOption(modelOptions, selectedSessionPreferredModel)) {
|
|
46
|
+
return selectedSessionPreferredModel.trim();
|
|
47
|
+
}
|
|
48
|
+
if (hasModelOption(modelOptions, fallbackPreferredModel)) {
|
|
49
|
+
return fallbackPreferredModel.trim();
|
|
50
|
+
}
|
|
51
|
+
if (hasModelOption(modelOptions, defaultModel)) {
|
|
52
|
+
return defaultModel.trim();
|
|
53
|
+
}
|
|
54
|
+
return modelOptions[0]?.value ?? '';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function resolveRecentSessionPreferredModel(params: {
|
|
58
|
+
sessions: readonly SessionEntryView[];
|
|
59
|
+
selectedSessionKey?: string | null;
|
|
60
|
+
sessionType?: string | null;
|
|
61
|
+
}): string | undefined {
|
|
62
|
+
const targetSessionType = normalizeSessionType(params.sessionType);
|
|
63
|
+
let bestSession: SessionEntryView | null = null;
|
|
64
|
+
let bestTimestamp = Number.NEGATIVE_INFINITY;
|
|
65
|
+
for (const session of params.sessions) {
|
|
66
|
+
if (session.key === params.selectedSessionKey) {
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (normalizeSessionType(session.sessionType) !== targetSessionType) {
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
const preferredModel = session.preferredModel?.trim();
|
|
73
|
+
if (!preferredModel) {
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
const updatedAtTimestamp = Date.parse(session.updatedAt);
|
|
77
|
+
const comparableTimestamp = Number.isFinite(updatedAtTimestamp) ? updatedAtTimestamp : Number.NEGATIVE_INFINITY;
|
|
78
|
+
if (!bestSession || comparableTimestamp > bestTimestamp) {
|
|
79
|
+
bestSession = session;
|
|
80
|
+
bestTimestamp = comparableTimestamp;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return bestSession?.preferredModel?.trim();
|
|
84
|
+
}
|
|
85
|
+
|
|
10
86
|
export function useSyncSelectedModel(params: {
|
|
11
87
|
modelOptions: ChatModelOption[];
|
|
88
|
+
selectedSessionKey?: string | null;
|
|
12
89
|
selectedSessionPreferredModel?: string;
|
|
90
|
+
fallbackPreferredModel?: string;
|
|
13
91
|
defaultModel?: string;
|
|
14
92
|
setSelectedModel: Dispatch<SetStateAction<string>>;
|
|
15
93
|
}) {
|
|
16
|
-
const {
|
|
94
|
+
const {
|
|
95
|
+
modelOptions,
|
|
96
|
+
selectedSessionKey,
|
|
97
|
+
selectedSessionPreferredModel,
|
|
98
|
+
fallbackPreferredModel,
|
|
99
|
+
defaultModel,
|
|
100
|
+
setSelectedModel
|
|
101
|
+
} = params;
|
|
102
|
+
const previousSessionKeyRef = useRef<string | null | undefined>(undefined);
|
|
103
|
+
|
|
17
104
|
useEffect(() => {
|
|
105
|
+
const sessionChanged = previousSessionKeyRef.current !== selectedSessionKey;
|
|
18
106
|
if (modelOptions.length === 0) {
|
|
19
107
|
setSelectedModel('');
|
|
108
|
+
previousSessionKeyRef.current = selectedSessionKey;
|
|
20
109
|
return;
|
|
21
110
|
}
|
|
22
111
|
setSelectedModel((prev) => {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
if (fallback && modelOptions.some((option) => option.value === fallback)) {
|
|
32
|
-
return fallback;
|
|
33
|
-
}
|
|
34
|
-
return modelOptions[0]?.value ?? '';
|
|
112
|
+
return resolveSelectedModelValue({
|
|
113
|
+
currentSelectedModel: prev,
|
|
114
|
+
modelOptions,
|
|
115
|
+
selectedSessionPreferredModel,
|
|
116
|
+
fallbackPreferredModel,
|
|
117
|
+
defaultModel,
|
|
118
|
+
preferSessionPreferredModel: sessionChanged
|
|
119
|
+
});
|
|
35
120
|
});
|
|
36
|
-
|
|
121
|
+
previousSessionKeyRef.current = selectedSessionKey;
|
|
122
|
+
}, [defaultModel, fallbackPreferredModel, modelOptions, selectedSessionKey, selectedSessionPreferredModel, setSelectedModel]);
|
|
37
123
|
}
|
|
38
124
|
|
|
39
125
|
export function useSessionRunStatus(params: {
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { updateSession } from '@/api/config';
|
|
3
|
+
import { ChatSessionPreferenceSync } from '@/components/chat/chat-session-preference-sync';
|
|
4
|
+
import { useChatInputStore } from '@/components/chat/stores/chat-input.store';
|
|
5
|
+
import { useChatSessionListStore } from '@/components/chat/stores/chat-session-list.store';
|
|
6
|
+
|
|
7
|
+
vi.mock('@/api/config', () => ({
|
|
8
|
+
updateSession: vi.fn(async () => ({
|
|
9
|
+
key: 'session-1',
|
|
10
|
+
totalMessages: 0,
|
|
11
|
+
totalEvents: 0,
|
|
12
|
+
sessionType: 'native',
|
|
13
|
+
sessionTypeMutable: false,
|
|
14
|
+
metadata: {},
|
|
15
|
+
messages: [],
|
|
16
|
+
events: []
|
|
17
|
+
}))
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
describe('ChatSessionPreferenceSync', () => {
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
useChatInputStore.setState((state) => ({
|
|
23
|
+
snapshot: {
|
|
24
|
+
...state.snapshot,
|
|
25
|
+
selectedModel: '',
|
|
26
|
+
selectedThinkingLevel: null
|
|
27
|
+
}
|
|
28
|
+
}));
|
|
29
|
+
useChatSessionListStore.setState((state) => ({
|
|
30
|
+
snapshot: {
|
|
31
|
+
...state.snapshot,
|
|
32
|
+
selectedSessionKey: null
|
|
33
|
+
}
|
|
34
|
+
}));
|
|
35
|
+
vi.clearAllMocks();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('persists the selected model and thinking to the current session metadata', async () => {
|
|
39
|
+
useChatInputStore.setState((state) => ({
|
|
40
|
+
snapshot: {
|
|
41
|
+
...state.snapshot,
|
|
42
|
+
selectedModel: 'openai/gpt-5',
|
|
43
|
+
selectedThinkingLevel: 'high'
|
|
44
|
+
}
|
|
45
|
+
}));
|
|
46
|
+
useChatSessionListStore.setState((state) => ({
|
|
47
|
+
snapshot: {
|
|
48
|
+
...state.snapshot,
|
|
49
|
+
selectedSessionKey: 'session-1'
|
|
50
|
+
}
|
|
51
|
+
}));
|
|
52
|
+
|
|
53
|
+
const sync = new ChatSessionPreferenceSync(updateSession);
|
|
54
|
+
sync.syncSelectedSessionPreferences();
|
|
55
|
+
await vi.waitFor(() => {
|
|
56
|
+
expect(updateSession).toHaveBeenCalledWith('session-1', {
|
|
57
|
+
preferredModel: 'openai/gpt-5',
|
|
58
|
+
preferredThinking: 'high'
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { SessionPatchUpdate, ThinkingLevel } from '@/api/types';
|
|
2
|
+
import { useChatInputStore } from '@/components/chat/stores/chat-input.store';
|
|
3
|
+
import { useChatSessionListStore } from '@/components/chat/stores/chat-session-list.store';
|
|
4
|
+
|
|
5
|
+
type QueuedSessionPreferenceSync = {
|
|
6
|
+
sessionKey: string;
|
|
7
|
+
patch: SessionPatchUpdate;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
function normalizeOptionalModel(value: string): string | null {
|
|
11
|
+
const normalized = value.trim();
|
|
12
|
+
return normalized.length > 0 ? normalized : null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function normalizeOptionalThinking(value: ThinkingLevel | null): ThinkingLevel | null {
|
|
16
|
+
return value ?? null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class ChatSessionPreferenceSync {
|
|
20
|
+
private inFlight: Promise<void> | null = null;
|
|
21
|
+
private queued: QueuedSessionPreferenceSync | null = null;
|
|
22
|
+
|
|
23
|
+
constructor(
|
|
24
|
+
private readonly updateSession: (
|
|
25
|
+
sessionKey: string,
|
|
26
|
+
patch: SessionPatchUpdate
|
|
27
|
+
) => Promise<unknown>
|
|
28
|
+
) {}
|
|
29
|
+
|
|
30
|
+
syncSelectedSessionPreferences = (): void => {
|
|
31
|
+
const inputSnapshot = useChatInputStore.getState().snapshot;
|
|
32
|
+
const sessionSnapshot = useChatSessionListStore.getState().snapshot;
|
|
33
|
+
const sessionKey = sessionSnapshot.selectedSessionKey;
|
|
34
|
+
if (!sessionKey) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
this.enqueue({
|
|
39
|
+
sessionKey,
|
|
40
|
+
patch: {
|
|
41
|
+
preferredModel: normalizeOptionalModel(inputSnapshot.selectedModel),
|
|
42
|
+
preferredThinking: normalizeOptionalThinking(inputSnapshot.selectedThinkingLevel)
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
private enqueue(next: QueuedSessionPreferenceSync): void {
|
|
48
|
+
this.queued = next;
|
|
49
|
+
if (this.inFlight) {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
this.startFlush();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
private startFlush(): void {
|
|
56
|
+
this.inFlight = this.flush()
|
|
57
|
+
.catch((error) => {
|
|
58
|
+
console.error(`Failed to sync chat session preferences: ${String(error)}`);
|
|
59
|
+
})
|
|
60
|
+
.finally(() => {
|
|
61
|
+
this.inFlight = null;
|
|
62
|
+
if (this.queued) {
|
|
63
|
+
this.startFlush();
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
private async flush(): Promise<void> {
|
|
69
|
+
while (this.queued) {
|
|
70
|
+
const current = this.queued;
|
|
71
|
+
this.queued = null;
|
|
72
|
+
await this.updateSession(current.sessionKey, current.patch);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ChatComposerNode } from '@nextclaw/agent-chat-ui';
|
|
1
2
|
import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
|
|
2
3
|
import type {
|
|
3
4
|
ChatRunView,
|
|
@@ -20,6 +21,7 @@ export type SendMessageParams = {
|
|
|
20
21
|
stopSupported?: boolean;
|
|
21
22
|
stopReason?: string;
|
|
22
23
|
restoreDraftOnError?: boolean;
|
|
24
|
+
composerNodes?: ChatComposerNode[];
|
|
23
25
|
};
|
|
24
26
|
|
|
25
27
|
export type ActiveRunState = {
|
|
@@ -65,6 +67,7 @@ export type UseChatStreamControllerParams = {
|
|
|
65
67
|
selectedSessionKeyRef: MutableRefObject<string | null>;
|
|
66
68
|
setSelectedSessionKey: Dispatch<SetStateAction<string | null>>;
|
|
67
69
|
setDraft: Dispatch<SetStateAction<string>>;
|
|
70
|
+
setComposerNodes: Dispatch<SetStateAction<ChatComposerNode[]>>;
|
|
68
71
|
refetchSessions: () => Promise<unknown>;
|
|
69
72
|
refetchHistory: () => Promise<unknown>;
|
|
70
73
|
};
|
|
@@ -1,19 +1,15 @@
|
|
|
1
|
-
import { useMemo } from 'react';
|
|
1
|
+
import { useMemo, useState } from 'react';
|
|
2
2
|
import { ChatInputBar } from '@nextclaw/agent-chat-ui';
|
|
3
3
|
import {
|
|
4
4
|
buildChatSlashItems,
|
|
5
5
|
buildModelStateHint,
|
|
6
6
|
buildModelToolbarSelect,
|
|
7
|
-
buildSelectedSkillItems,
|
|
8
|
-
buildSessionTypeToolbarSelect,
|
|
9
7
|
buildSkillPickerModel,
|
|
10
8
|
buildThinkingToolbarSelect,
|
|
11
|
-
resolveSlashQuery,
|
|
12
9
|
type ChatModelRecord,
|
|
13
10
|
type ChatSkillRecord,
|
|
14
11
|
type ChatThinkingLevel
|
|
15
12
|
} from '@/components/chat/adapters/chat-input-bar.adapter';
|
|
16
|
-
import { useChatInputBarController } from '@/components/chat/chat-input/chat-input-bar.controller';
|
|
17
13
|
import { usePresenter } from '@/components/chat/presenter/chat-presenter-context';
|
|
18
14
|
import { useI18n } from '@/components/providers/I18nProvider';
|
|
19
15
|
import { useChatInputStore } from '@/components/chat/stores/chat-input.store';
|
|
@@ -73,6 +69,7 @@ export function ChatInputBarContainer() {
|
|
|
73
69
|
const presenter = usePresenter();
|
|
74
70
|
const { language } = useI18n();
|
|
75
71
|
const snapshot = useChatInputStore((state) => state.snapshot);
|
|
72
|
+
const [slashQuery, setSlashQuery] = useState<string | null>(null);
|
|
76
73
|
|
|
77
74
|
const officialSkillBadgeLabel = useMemo(() => {
|
|
78
75
|
// Keep memo reactive to locale switches even though `t` is imported as a stable function.
|
|
@@ -111,41 +108,11 @@ export function ChatInputBarContainer() {
|
|
|
111
108
|
? t('chatInputPlaceholder')
|
|
112
109
|
: t('chatModelNoOptions');
|
|
113
110
|
|
|
114
|
-
const slashQuery = resolveSlashQuery(snapshot.draft);
|
|
115
111
|
const slashItems = useMemo(
|
|
116
112
|
() => buildChatSlashItems(skillRecords, slashQuery ?? '', slashTexts),
|
|
117
113
|
[slashQuery, skillRecords, slashTexts]
|
|
118
114
|
);
|
|
119
115
|
|
|
120
|
-
const controller = useChatInputBarController({
|
|
121
|
-
isSlashMode: slashQuery !== null,
|
|
122
|
-
slashItems,
|
|
123
|
-
isSlashLoading: snapshot.isSkillsLoading,
|
|
124
|
-
onSelectSlashItem: (item) => {
|
|
125
|
-
if (!item.value) {
|
|
126
|
-
return;
|
|
127
|
-
}
|
|
128
|
-
if (!snapshot.selectedSkills.includes(item.value)) {
|
|
129
|
-
presenter.chatInputManager.selectSkills([...snapshot.selectedSkills, item.value]);
|
|
130
|
-
}
|
|
131
|
-
presenter.chatInputManager.setDraft('');
|
|
132
|
-
},
|
|
133
|
-
onSend: presenter.chatInputManager.send,
|
|
134
|
-
onStop: presenter.chatInputManager.stop,
|
|
135
|
-
isSending: snapshot.isSending,
|
|
136
|
-
canStopGeneration: snapshot.canStopGeneration
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
const selectedSessionTypeOption =
|
|
140
|
-
snapshot.sessionTypeOptions.find((option) => option.value === snapshot.selectedSessionType) ??
|
|
141
|
-
(snapshot.selectedSessionType
|
|
142
|
-
? { value: snapshot.selectedSessionType, label: snapshot.selectedSessionType }
|
|
143
|
-
: null);
|
|
144
|
-
const shouldShowSessionTypeSelector =
|
|
145
|
-
snapshot.canEditSessionType &&
|
|
146
|
-
(snapshot.sessionTypeOptions.length > 1 ||
|
|
147
|
-
Boolean(snapshot.selectedSessionType && snapshot.selectedSessionType !== 'native'));
|
|
148
|
-
|
|
149
116
|
const selectedModelOption = modelRecords.find((option) => option.value === snapshot.selectedModel);
|
|
150
117
|
const selectedModelThinkingCapability = selectedModelOption?.thinkingCapability;
|
|
151
118
|
const thinkingSupportedLevels = selectedModelThinkingCapability?.supported ?? [];
|
|
@@ -156,17 +123,6 @@ export function ChatInputBarContainer() {
|
|
|
156
123
|
: snapshot.stopDisabledReason?.trim() || t('chatStopUnavailable');
|
|
157
124
|
|
|
158
125
|
const toolbarSelects = [
|
|
159
|
-
buildSessionTypeToolbarSelect({
|
|
160
|
-
selectedSessionType: snapshot.selectedSessionType,
|
|
161
|
-
selectedSessionTypeOption,
|
|
162
|
-
sessionTypeOptions: snapshot.sessionTypeOptions,
|
|
163
|
-
onValueChange: presenter.chatInputManager.selectSessionType,
|
|
164
|
-
canEditSessionType: snapshot.canEditSessionType,
|
|
165
|
-
shouldShow: shouldShowSessionTypeSelector,
|
|
166
|
-
texts: {
|
|
167
|
-
sessionTypePlaceholder: t('chatSessionTypeLabel')
|
|
168
|
-
}
|
|
169
|
-
}),
|
|
170
126
|
buildModelToolbarSelect({
|
|
171
127
|
modelOptions: modelRecords,
|
|
172
128
|
selectedModel: snapshot.selectedModel,
|
|
@@ -205,27 +161,23 @@ export function ChatInputBarContainer() {
|
|
|
205
161
|
|
|
206
162
|
return (
|
|
207
163
|
<ChatInputBar
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
164
|
+
composer={{
|
|
165
|
+
nodes: snapshot.composerNodes,
|
|
166
|
+
placeholder: textareaPlaceholder,
|
|
167
|
+
disabled: inputDisabled,
|
|
168
|
+
onNodesChange: presenter.chatInputManager.setComposerNodes,
|
|
169
|
+
onSlashQueryChange: setSlashQuery
|
|
170
|
+
}}
|
|
213
171
|
slashMenu={{
|
|
214
|
-
isOpen: controller.isSlashPanelOpen,
|
|
215
172
|
isLoading: snapshot.isSkillsLoading,
|
|
216
173
|
items: slashItems,
|
|
217
|
-
activeIndex: controller.activeSlashIndex,
|
|
218
|
-
activeItem: controller.activeSlashItem,
|
|
219
174
|
texts: {
|
|
220
175
|
slashLoadingLabel: t('chatSlashLoading'),
|
|
221
176
|
slashSectionLabel: t('chatSlashSectionSkills'),
|
|
222
177
|
slashEmptyLabel: t('chatSlashNoResult'),
|
|
223
178
|
slashHintLabel: t('chatSlashHint'),
|
|
224
179
|
slashSkillHintLabel: t('chatSlashSkillHint')
|
|
225
|
-
}
|
|
226
|
-
onSelectItem: controller.onSelectSlashItem,
|
|
227
|
-
onOpenChange: controller.onSlashPanelOpenChange,
|
|
228
|
-
onSetActiveIndex: controller.onSetActiveSlashIndex
|
|
180
|
+
}
|
|
229
181
|
}}
|
|
230
182
|
hint={buildModelStateHint({
|
|
231
183
|
isModelOptionsLoading,
|
|
@@ -236,17 +188,14 @@ export function ChatInputBarContainer() {
|
|
|
236
188
|
configureProviderLabel: t('chatGoConfigureProvider')
|
|
237
189
|
}
|
|
238
190
|
})}
|
|
239
|
-
selectedItems={{
|
|
240
|
-
items: buildSelectedSkillItems(snapshot.selectedSkills, skillRecords),
|
|
241
|
-
onRemove: (key) => presenter.chatInputManager.selectSkills(snapshot.selectedSkills.filter((skill) => skill !== key))
|
|
242
|
-
}}
|
|
243
191
|
toolbar={{
|
|
244
192
|
selects: toolbarSelects,
|
|
245
193
|
accessories: [
|
|
246
194
|
{
|
|
247
195
|
key: 'attach',
|
|
248
|
-
label: t('
|
|
196
|
+
label: t('chatInputAttach'),
|
|
249
197
|
icon: 'paperclip',
|
|
198
|
+
iconOnly: true,
|
|
250
199
|
disabled: true,
|
|
251
200
|
tooltip: t('chatInputAttachComingSoon')
|
|
252
201
|
}
|