@nextclaw/ui 0.8.0 → 0.9.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.
- package/CHANGELOG.md +16 -0
- package/dist/assets/ChannelsList-C7F_As4r.js +1 -0
- package/dist/assets/ChatPage-Oo7-OUsx.js +37 -0
- package/dist/assets/{DocBrowser-DDX2HMXW.js → DocBrowser-Dsd8Dlq8.js} +1 -1
- package/dist/assets/{LogoBadge-J53F_3JA.js → LogoBadge-2ChEc_oz.js} +1 -1
- package/dist/assets/{MarketplacePage-0BZ4bza0.js → MarketplacePage-BXck6-X3.js} +3 -3
- package/dist/assets/{ModelConfig-Wzq9wGHV.js → ModelConfig-CgHRSD0b.js} +1 -1
- package/dist/assets/ProvidersList-PPfZucvS.js +1 -0
- package/dist/assets/{RuntimeConfig-N771_AM6.js → RuntimeConfig-ClLEKNTN.js} +1 -1
- package/dist/assets/{SearchConfig-DVt5QVa_.js → SearchConfig-CuXVCbrf.js} +1 -1
- package/dist/assets/{SecretsConfig-CkwauPa8.js → SecretsConfig-udJz6Ake.js} +1 -1
- package/dist/assets/{SessionsConfig-C3mnHzkZ.js → SessionsConfig-C1XnFfiC.js} +2 -2
- package/dist/assets/{chat-message-pxr79GDs.js → chat-message-BETwXLD4.js} +1 -1
- package/dist/assets/{index-GdpEEKnz.js → index-COJdlL0e.js} +1 -1
- package/dist/assets/index-CsvP4CER.js +8 -0
- package/dist/assets/index-D-bXl7qL.css +1 -0
- package/dist/assets/{label-CmksBHgc.js → label-BGL-ztxh.js} +1 -1
- package/dist/assets/{page-layout-Db0GbnhS.js → page-layout-aw88k7tG.js} +1 -1
- package/dist/assets/popover-DyEvzhmV.js +1 -0
- package/dist/assets/{security-config-CjLFME5Q.js → security-config-BuPAQn82.js} +1 -1
- package/dist/assets/skeleton-drzO_tdU.js +1 -0
- package/dist/assets/{switch-C24d-UJU.js → switch-BK8jIzto.js} +1 -1
- package/dist/assets/tabs-custom-Da3cEOji.js +1 -0
- package/dist/assets/{useConfirmDialog-BeP35LcG.js → useConfirmDialog-z0CE92iS.js} +1 -1
- package/dist/assets/{vendor-psXJBy9u.js → vendor-CkJHmX1g.js} +1 -1
- package/dist/index.html +3 -3
- package/package.json +2 -2
- 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-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/containers/chat-input-bar.container.tsx +0 -22
- package/src/components/chat/containers/chat-message-list.container.tsx +31 -27
- package/src/components/chat/legacy/LegacyChatPage.tsx +24 -0
- package/src/components/chat/managers/chat-input.manager.ts +5 -0
- 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 +42 -10
- package/src/components/chat/ncp/ncp-chat-input.manager.ts +6 -0
- 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/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
|
+
}
|
|
@@ -5,7 +5,6 @@ import {
|
|
|
5
5
|
buildModelStateHint,
|
|
6
6
|
buildModelToolbarSelect,
|
|
7
7
|
buildSelectedSkillItems,
|
|
8
|
-
buildSessionTypeToolbarSelect,
|
|
9
8
|
buildSkillPickerModel,
|
|
10
9
|
buildThinkingToolbarSelect,
|
|
11
10
|
resolveSlashQuery,
|
|
@@ -136,16 +135,6 @@ export function ChatInputBarContainer() {
|
|
|
136
135
|
canStopGeneration: snapshot.canStopGeneration
|
|
137
136
|
});
|
|
138
137
|
|
|
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
138
|
const selectedModelOption = modelRecords.find((option) => option.value === snapshot.selectedModel);
|
|
150
139
|
const selectedModelThinkingCapability = selectedModelOption?.thinkingCapability;
|
|
151
140
|
const thinkingSupportedLevels = selectedModelThinkingCapability?.supported ?? [];
|
|
@@ -156,17 +145,6 @@ export function ChatInputBarContainer() {
|
|
|
156
145
|
: snapshot.stopDisabledReason?.trim() || t('chatStopUnavailable');
|
|
157
146
|
|
|
158
147
|
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
148
|
buildModelToolbarSelect({
|
|
171
149
|
modelOptions: modelRecords,
|
|
172
150
|
selectedModel: snapshot.selectedModel,
|
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
import { useMemo } from
|
|
2
|
-
import { type UiMessage } from
|
|
3
|
-
import { ChatMessageList } from
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
import { type UiMessage } from "@nextclaw/agent-chat";
|
|
3
|
+
import { ChatMessageList } from "@nextclaw/agent-chat-ui";
|
|
4
|
+
import {
|
|
5
|
+
adaptChatMessages,
|
|
6
|
+
type ChatMessageSource,
|
|
7
|
+
} from "@/components/chat/adapters/chat-message.adapter";
|
|
8
|
+
import { useI18n } from "@/components/providers/I18nProvider";
|
|
9
|
+
import { formatDateTime, t } from "@/lib/i18n";
|
|
7
10
|
|
|
8
11
|
type ChatMessageListContainerProps = {
|
|
9
12
|
uiMessages: UiMessage[];
|
|
@@ -20,11 +23,11 @@ export function ChatMessageListContainer(props: ChatMessageListContainerProps) {
|
|
|
20
23
|
role: message.role,
|
|
21
24
|
meta: {
|
|
22
25
|
timestamp: message.meta?.timestamp,
|
|
23
|
-
status: message.meta?.status
|
|
26
|
+
status: message.meta?.status,
|
|
24
27
|
},
|
|
25
|
-
parts: message.parts as unknown as ChatMessageSource[
|
|
28
|
+
parts: message.parts as unknown as ChatMessageSource["parts"],
|
|
26
29
|
})),
|
|
27
|
-
[props.uiMessages]
|
|
30
|
+
[props.uiMessages],
|
|
28
31
|
);
|
|
29
32
|
|
|
30
33
|
const messages = useMemo(
|
|
@@ -34,21 +37,21 @@ export function ChatMessageListContainer(props: ChatMessageListContainerProps) {
|
|
|
34
37
|
formatTimestamp: (value) => formatDateTime(value, language),
|
|
35
38
|
texts: {
|
|
36
39
|
roleLabels: {
|
|
37
|
-
user: t(
|
|
38
|
-
assistant: t(
|
|
39
|
-
tool: t(
|
|
40
|
-
system: t(
|
|
41
|
-
fallback: t(
|
|
40
|
+
user: t("chatRoleUser"),
|
|
41
|
+
assistant: t("chatRoleAssistant"),
|
|
42
|
+
tool: t("chatRoleTool"),
|
|
43
|
+
system: t("chatRoleSystem"),
|
|
44
|
+
fallback: t("chatRoleMessage"),
|
|
42
45
|
},
|
|
43
|
-
reasoningLabel: t(
|
|
44
|
-
toolCallLabel: t(
|
|
45
|
-
toolResultLabel: t(
|
|
46
|
-
toolNoOutputLabel: t(
|
|
47
|
-
toolOutputLabel: t(
|
|
48
|
-
unknownPartLabel: t(
|
|
49
|
-
}
|
|
46
|
+
reasoningLabel: t("chatReasoning"),
|
|
47
|
+
toolCallLabel: t("chatToolCall"),
|
|
48
|
+
toolResultLabel: t("chatToolResult"),
|
|
49
|
+
toolNoOutputLabel: t("chatToolNoOutput"),
|
|
50
|
+
toolOutputLabel: t("chatToolOutput"),
|
|
51
|
+
unknownPartLabel: t("chatUnknownPart"),
|
|
52
|
+
},
|
|
50
53
|
}),
|
|
51
|
-
[language, sourceMessages]
|
|
54
|
+
[language, sourceMessages],
|
|
52
55
|
);
|
|
53
56
|
|
|
54
57
|
return (
|
|
@@ -57,14 +60,15 @@ export function ChatMessageListContainer(props: ChatMessageListContainerProps) {
|
|
|
57
60
|
isSending={props.isSending}
|
|
58
61
|
hasAssistantDraft={props.uiMessages.some(
|
|
59
62
|
(message) =>
|
|
60
|
-
message.role ===
|
|
61
|
-
(message.meta?.status ===
|
|
63
|
+
message.role === "assistant" &&
|
|
64
|
+
(message.meta?.status === "streaming" ||
|
|
65
|
+
message.meta?.status === "pending"),
|
|
62
66
|
)}
|
|
63
67
|
className={props.className}
|
|
64
68
|
texts={{
|
|
65
|
-
copyCodeLabel: t(
|
|
66
|
-
copiedCodeLabel: t(
|
|
67
|
-
typingLabel: t(
|
|
69
|
+
copyCodeLabel: t("chatCodeCopy"),
|
|
70
|
+
copiedCodeLabel: t("chatCodeCopied"),
|
|
71
|
+
typingLabel: t("chatTyping"),
|
|
68
72
|
}}
|
|
69
73
|
/>
|
|
70
74
|
);
|