@nextclaw/ui 0.7.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 +30 -0
- package/dist/assets/ChannelsList-C7F_As4r.js +1 -0
- package/dist/assets/ChatPage-Oo7-OUsx.js +37 -0
- package/dist/assets/{DocBrowser-B9ws5JL7.js → DocBrowser-Dsd8Dlq8.js} +1 -1
- package/dist/assets/{LogoBadge-DvGAzkZ3.js → LogoBadge-2ChEc_oz.js} +1 -1
- package/dist/assets/MarketplacePage-BXck6-X3.js +49 -0
- package/dist/assets/{ModelConfig-BL_HsOsm.js → ModelConfig-CgHRSD0b.js} +1 -1
- package/dist/assets/ProvidersList-PPfZucvS.js +1 -0
- package/dist/assets/RuntimeConfig-ClLEKNTN.js +1 -0
- package/dist/assets/{SearchConfig-BhaI0fUf.js → SearchConfig-CuXVCbrf.js} +1 -1
- package/dist/assets/{SecretsConfig-CFoimOh9.js → SecretsConfig-udJz6Ake.js} +2 -2
- package/dist/assets/SessionsConfig-C1XnFfiC.js +2 -0
- package/dist/assets/{session-run-status-TkIuGbVw.js → chat-message-BETwXLD4.js} +3 -3
- package/dist/assets/{index-uMsNsQX6.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-D8ly4a2P.js → label-BGL-ztxh.js} +1 -1
- package/dist/assets/{page-layout-BSYfvwbp.js → page-layout-aw88k7tG.js} +1 -1
- package/dist/assets/popover-DyEvzhmV.js +1 -0
- package/dist/assets/security-config-BuPAQn82.js +1 -0
- package/dist/assets/skeleton-drzO_tdU.js +1 -0
- package/dist/assets/{switch-Ce_g9lpN.js → switch-BK8jIzto.js} +1 -1
- package/dist/assets/{tabs-custom-Cf5azvT5.js → tabs-custom-Da3cEOji.js} +1 -1
- package/dist/assets/{useConfirmDialog-A8Ek8Wu7.js → useConfirmDialog-z0CE92iS.js} +2 -2
- package/dist/assets/{vendor-B7ozqnFC.js → vendor-CkJHmX1g.js} +65 -70
- package/dist/index.html +3 -3
- package/package.json +5 -2
- package/src/api/config.ts +9 -0
- package/src/api/ncp-session.ts +50 -0
- package/src/api/types.ts +20 -0
- package/src/components/chat/ChatConversationPanel.test.tsx +65 -0
- package/src/components/chat/ChatConversationPanel.tsx +21 -12
- package/src/components/chat/ChatPage.tsx +10 -324
- 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 -81
- package/src/components/chat/adapters/chat-message.adapter.ts +27 -9
- package/src/components/chat/chat-chain.test.ts +22 -0
- package/src/components/chat/chat-chain.ts +23 -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-page-shell.tsx +103 -0
- 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 +34 -26
- package/src/components/chat/legacy/LegacyChatPage.tsx +252 -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 +381 -0
- package/src/components/chat/ncp/ncp-chat-input.manager.ts +179 -0
- package/src/components/chat/ncp/ncp-chat-page-data.ts +166 -0
- package/src/components/chat/ncp/ncp-chat-thread.manager.ts +89 -0
- package/src/components/chat/ncp/ncp-chat.presenter.ts +33 -0
- package/src/components/chat/ncp/ncp-session-adapter.test.ts +75 -0
- package/src/components/chat/ncp/ncp-session-adapter.ts +214 -0
- package/src/components/chat/presenter/chat-presenter-context.tsx +43 -4
- 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 +41 -1
- package/src/hooks/useMarketplace.ts +7 -4
- package/src/hooks/useWebSocket.ts +23 -2
- package/src/lib/i18n.ts +1 -1
- package/tailwind.config.js +8 -3
- package/tsconfig.json +4 -1
- package/dist/assets/ChannelsList-DF2U-LY1.js +0 -1
- package/dist/assets/ChatPage-BX39y0U5.js +0 -36
- package/dist/assets/MarketplacePage-DG5mHWJ8.js +0 -49
- package/dist/assets/ProvidersList-CH5z00YT.js +0 -1
- package/dist/assets/RuntimeConfig-BplBgkwo.js +0 -1
- package/dist/assets/SessionsConfig-BHTAYn9T.js +0 -2
- package/dist/assets/index-BLeJkJ0o.css +0 -1
- package/dist/assets/index-DK4TS5ev.js +0 -8
- package/dist/assets/index-X5J6Mm--.js +0 -1
- package/dist/assets/security-config-DlKEYHNN.js +0 -1
- package/dist/assets/skeleton-CWbsNx2h.js +0 -1
|
@@ -61,6 +61,8 @@ export type ChatMessageAdapterTexts = {
|
|
|
61
61
|
unknownPartLabel: string;
|
|
62
62
|
};
|
|
63
63
|
|
|
64
|
+
const INVISIBLE_ONLY_TEXT_PATTERN = /\u200B|\u200C|\u200D|\u2060|\uFEFF/g;
|
|
65
|
+
|
|
64
66
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
65
67
|
return typeof value === 'object' && value !== null;
|
|
66
68
|
}
|
|
@@ -95,7 +97,10 @@ function resolveMessageTimestamp(message: ChatMessageSource): string {
|
|
|
95
97
|
return new Date().toISOString();
|
|
96
98
|
}
|
|
97
99
|
|
|
98
|
-
function resolveRoleLabel(
|
|
100
|
+
function resolveRoleLabel(
|
|
101
|
+
role: string,
|
|
102
|
+
texts: ChatMessageAdapterTexts['roleLabels']
|
|
103
|
+
): string {
|
|
99
104
|
if (role === 'user') {
|
|
100
105
|
return texts.user;
|
|
101
106
|
}
|
|
@@ -118,7 +123,10 @@ function resolveUiRole(role: string): ChatMessageRole {
|
|
|
118
123
|
return 'message';
|
|
119
124
|
}
|
|
120
125
|
|
|
121
|
-
function buildToolCard(
|
|
126
|
+
function buildToolCard(
|
|
127
|
+
toolCard: ToolCard,
|
|
128
|
+
texts: ChatMessageAdapterTexts
|
|
129
|
+
): ChatToolPartViewModel {
|
|
122
130
|
return {
|
|
123
131
|
kind: toolCard.kind,
|
|
124
132
|
toolName: toolCard.name,
|
|
@@ -131,6 +139,15 @@ function buildToolCard(toolCard: ToolCard, texts: ChatMessageAdapterTexts): Chat
|
|
|
131
139
|
};
|
|
132
140
|
}
|
|
133
141
|
|
|
142
|
+
function toRenderableText(value: string): string | null {
|
|
143
|
+
const trimmed = value.trim();
|
|
144
|
+
if (!trimmed) {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
const visible = trimmed.replace(INVISIBLE_ONLY_TEXT_PATTERN, "").trim();
|
|
148
|
+
return visible ? trimmed : null;
|
|
149
|
+
}
|
|
150
|
+
|
|
134
151
|
export function adaptChatMessages(params: {
|
|
135
152
|
uiMessages: ChatMessageSource[];
|
|
136
153
|
texts: ChatMessageAdapterTexts;
|
|
@@ -145,7 +162,7 @@ export function adaptChatMessages(params: {
|
|
|
145
162
|
parts: message.parts
|
|
146
163
|
.map((part) => {
|
|
147
164
|
if (isTextPart(part)) {
|
|
148
|
-
const text = part.text
|
|
165
|
+
const text = toRenderableText(part.text);
|
|
149
166
|
if (!text) {
|
|
150
167
|
return null;
|
|
151
168
|
}
|
|
@@ -155,7 +172,7 @@ export function adaptChatMessages(params: {
|
|
|
155
172
|
};
|
|
156
173
|
}
|
|
157
174
|
if (isReasoningPart(part)) {
|
|
158
|
-
const text = part.reasoning
|
|
175
|
+
const text = toRenderableText(part.reasoning);
|
|
159
176
|
if (!text) {
|
|
160
177
|
return null;
|
|
161
178
|
}
|
|
@@ -168,11 +185,12 @@ export function adaptChatMessages(params: {
|
|
|
168
185
|
if (isToolInvocationPart(part)) {
|
|
169
186
|
const invocation = part.toolInvocation;
|
|
170
187
|
const detail = summarizeToolArgs(invocation.parsedArgs ?? invocation.args);
|
|
171
|
-
const rawResult =
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
188
|
+
const rawResult =
|
|
189
|
+
typeof invocation.error === 'string' && invocation.error.trim()
|
|
190
|
+
? invocation.error.trim()
|
|
191
|
+
: invocation.result != null
|
|
192
|
+
? stringifyUnknown(invocation.result).trim()
|
|
193
|
+
: '';
|
|
176
194
|
const hasResult =
|
|
177
195
|
invocation.status === 'result' || invocation.status === 'error' || invocation.status === 'cancelled';
|
|
178
196
|
const card: ToolCard = {
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { resolveChatChain } from '@/components/chat/chat-chain';
|
|
3
|
+
|
|
4
|
+
describe('resolveChatChain', () => {
|
|
5
|
+
it('defaults to ncp when no query or env override is provided', () => {
|
|
6
|
+
vi.stubEnv('VITE_CHAT_CHAIN', '');
|
|
7
|
+
|
|
8
|
+
expect(resolveChatChain('')).toBe('ncp');
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('allows explicit legacy rollback from query string', () => {
|
|
12
|
+
vi.stubEnv('VITE_CHAT_CHAIN', 'ncp');
|
|
13
|
+
|
|
14
|
+
expect(resolveChatChain('?chatChain=legacy')).toBe('legacy');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('accepts env override when query string is absent', () => {
|
|
18
|
+
vi.stubEnv('VITE_CHAT_CHAIN', 'legacy');
|
|
19
|
+
|
|
20
|
+
expect(resolveChatChain('')).toBe('legacy');
|
|
21
|
+
});
|
|
22
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export type ChatChain = 'legacy' | 'ncp';
|
|
2
|
+
|
|
3
|
+
const DEFAULT_CHAT_CHAIN: ChatChain = 'ncp';
|
|
4
|
+
|
|
5
|
+
function normalizeChatChain(value: string | null | undefined): ChatChain | null {
|
|
6
|
+
if (typeof value !== 'string') {
|
|
7
|
+
return null;
|
|
8
|
+
}
|
|
9
|
+
const normalized = value.trim().toLowerCase();
|
|
10
|
+
if (normalized === 'legacy' || normalized === 'ncp') {
|
|
11
|
+
return normalized;
|
|
12
|
+
}
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function resolveChatChain(search: string): ChatChain {
|
|
17
|
+
const fromSearch = normalizeChatChain(new URLSearchParams(search).get('chatChain'));
|
|
18
|
+
if (fromSearch) {
|
|
19
|
+
return fromSearch;
|
|
20
|
+
}
|
|
21
|
+
const fromEnv = normalizeChatChain(import.meta.env.VITE_CHAT_CHAIN);
|
|
22
|
+
return fromEnv ?? DEFAULT_CHAT_CHAIN;
|
|
23
|
+
}
|
|
@@ -3,7 +3,11 @@ import type { Dispatch, SetStateAction } from 'react';
|
|
|
3
3
|
import type { SessionEntryView, ThinkingLevel } from '@/api/types';
|
|
4
4
|
import type { ChatModelOption } from '@/components/chat/chat-input.types';
|
|
5
5
|
import { useChatSessionTypeState } from '@/components/chat/useChatSessionTypeState';
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
resolveSelectedModelValue,
|
|
8
|
+
resolveRecentSessionPreferredModel,
|
|
9
|
+
useSyncSelectedModel
|
|
10
|
+
} from '@/components/chat/chat-page-runtime';
|
|
7
11
|
import {
|
|
8
12
|
useChatCapabilities,
|
|
9
13
|
useChatSessionTypes,
|
|
@@ -98,14 +102,38 @@ export function useChatPageData(params: UseChatPageDataParams) {
|
|
|
98
102
|
setPendingSessionType: params.setPendingSessionType,
|
|
99
103
|
sessionTypesData: sessionTypesQuery.data
|
|
100
104
|
});
|
|
105
|
+
const recentSessionPreferredModel = useMemo(
|
|
106
|
+
() =>
|
|
107
|
+
resolveRecentSessionPreferredModel({
|
|
108
|
+
sessions,
|
|
109
|
+
selectedSessionKey: params.selectedSessionKey,
|
|
110
|
+
sessionType: sessionTypeState.selectedSessionType
|
|
111
|
+
}),
|
|
112
|
+
[params.selectedSessionKey, sessionTypeState.selectedSessionType, sessions]
|
|
113
|
+
);
|
|
101
114
|
|
|
102
115
|
useSyncSelectedModel({
|
|
103
116
|
modelOptions,
|
|
117
|
+
selectedSessionKey: params.selectedSessionKey,
|
|
104
118
|
selectedSessionPreferredModel: selectedSession?.preferredModel,
|
|
119
|
+
fallbackPreferredModel: recentSessionPreferredModel,
|
|
105
120
|
defaultModel: configQuery.data?.agents.defaults.model,
|
|
106
121
|
setSelectedModel: params.setSelectedModel
|
|
107
122
|
});
|
|
108
123
|
|
|
124
|
+
const hydratedSessionModel = useMemo(
|
|
125
|
+
() =>
|
|
126
|
+
resolveSelectedModelValue({
|
|
127
|
+
currentSelectedModel: '',
|
|
128
|
+
modelOptions,
|
|
129
|
+
selectedSessionPreferredModel: selectedSession?.preferredModel,
|
|
130
|
+
fallbackPreferredModel: recentSessionPreferredModel,
|
|
131
|
+
defaultModel: configQuery.data?.agents.defaults.model,
|
|
132
|
+
preferSessionPreferredModel: true
|
|
133
|
+
}),
|
|
134
|
+
[configQuery.data?.agents.defaults.model, modelOptions, recentSessionPreferredModel, selectedSession?.preferredModel]
|
|
135
|
+
);
|
|
136
|
+
|
|
109
137
|
const historyMessages = useMemo(() => historyQuery.data?.messages ?? [], [historyQuery.data?.messages]);
|
|
110
138
|
const selectedSessionThinkingLevel = useMemo(() => {
|
|
111
139
|
if (!params.selectedSessionKey) {
|
|
@@ -143,6 +171,7 @@ export function useChatPageData(params: UseChatPageDataParams) {
|
|
|
143
171
|
sessions,
|
|
144
172
|
skillRecords,
|
|
145
173
|
selectedSession,
|
|
174
|
+
hydratedSessionModel,
|
|
146
175
|
historyMessages,
|
|
147
176
|
selectedSessionThinkingLevel,
|
|
148
177
|
...sessionTypeState
|
|
@@ -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,103 @@
|
|
|
1
|
+
import { useEffect } from 'react';
|
|
2
|
+
import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
|
|
3
|
+
import { ChatSidebar } from '@/components/chat/ChatSidebar';
|
|
4
|
+
import { ChatConversationPanel } from '@/components/chat/ChatConversationPanel';
|
|
5
|
+
import { CronConfig } from '@/components/config/CronConfig';
|
|
6
|
+
import { MarketplacePage } from '@/components/marketplace/MarketplacePage';
|
|
7
|
+
|
|
8
|
+
export type MainPanelView = 'chat' | 'cron' | 'skills';
|
|
9
|
+
|
|
10
|
+
export type ChatPageProps = {
|
|
11
|
+
view: MainPanelView;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
type UseChatSessionSyncParams = {
|
|
15
|
+
view: MainPanelView;
|
|
16
|
+
routeSessionKey: string | null;
|
|
17
|
+
selectedSessionKey: string | null;
|
|
18
|
+
selectedAgentId: string;
|
|
19
|
+
setSelectedSessionKey: Dispatch<SetStateAction<string | null>>;
|
|
20
|
+
setSelectedAgentId: Dispatch<SetStateAction<string>>;
|
|
21
|
+
selectedSessionKeyRef: MutableRefObject<string | null>;
|
|
22
|
+
resetStreamState: () => void;
|
|
23
|
+
resolveAgentIdFromSessionKey: (sessionKey: string) => string | null;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export function useChatSessionSync(params: UseChatSessionSyncParams): void {
|
|
27
|
+
const {
|
|
28
|
+
view,
|
|
29
|
+
routeSessionKey,
|
|
30
|
+
selectedSessionKey,
|
|
31
|
+
selectedAgentId,
|
|
32
|
+
setSelectedSessionKey,
|
|
33
|
+
setSelectedAgentId,
|
|
34
|
+
selectedSessionKeyRef,
|
|
35
|
+
resetStreamState,
|
|
36
|
+
resolveAgentIdFromSessionKey
|
|
37
|
+
} = params;
|
|
38
|
+
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
if (view !== 'chat') {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (routeSessionKey) {
|
|
44
|
+
if (selectedSessionKey !== routeSessionKey) {
|
|
45
|
+
setSelectedSessionKey(routeSessionKey);
|
|
46
|
+
}
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (selectedSessionKey !== null) {
|
|
50
|
+
setSelectedSessionKey(null);
|
|
51
|
+
resetStreamState();
|
|
52
|
+
}
|
|
53
|
+
}, [resetStreamState, routeSessionKey, selectedSessionKey, setSelectedSessionKey, view]);
|
|
54
|
+
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
const inferred = selectedSessionKey ? resolveAgentIdFromSessionKey(selectedSessionKey) : null;
|
|
57
|
+
if (!inferred) {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (selectedAgentId !== inferred) {
|
|
61
|
+
setSelectedAgentId(inferred);
|
|
62
|
+
}
|
|
63
|
+
}, [resolveAgentIdFromSessionKey, selectedAgentId, selectedSessionKey, setSelectedAgentId]);
|
|
64
|
+
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
selectedSessionKeyRef.current = selectedSessionKey;
|
|
67
|
+
}, [selectedSessionKey, selectedSessionKeyRef]);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
type ChatPageLayoutProps = {
|
|
71
|
+
view: MainPanelView;
|
|
72
|
+
confirmDialog: JSX.Element;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export function ChatPageLayout({ view, confirmDialog }: ChatPageLayoutProps) {
|
|
76
|
+
return (
|
|
77
|
+
<div className="h-full flex">
|
|
78
|
+
<ChatSidebar />
|
|
79
|
+
|
|
80
|
+
{view === 'chat' ? (
|
|
81
|
+
<ChatConversationPanel />
|
|
82
|
+
) : (
|
|
83
|
+
<section className="flex-1 min-h-0 overflow-hidden bg-gradient-to-b from-gray-50/60 to-white">
|
|
84
|
+
{view === 'cron' ? (
|
|
85
|
+
<div className="h-full overflow-auto custom-scrollbar">
|
|
86
|
+
<div className="mx-auto w-full max-w-[min(1120px,100%)] px-6 py-5">
|
|
87
|
+
<CronConfig />
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
) : (
|
|
91
|
+
<div className="h-full overflow-hidden">
|
|
92
|
+
<div className="mx-auto flex h-full min-h-0 w-full max-w-[min(1120px,100%)] flex-col px-6 py-5">
|
|
93
|
+
<MarketplacePage forcedType="skills" />
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
)}
|
|
97
|
+
</section>
|
|
98
|
+
)}
|
|
99
|
+
|
|
100
|
+
{confirmDialog}
|
|
101
|
+
</div>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
@@ -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
|
+
});
|