@nextclaw/ui 0.12.7 → 0.12.8
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 +50 -0
- package/dist/assets/{ChannelsList-D8p4OlM6.js → ChannelsList-KIQIxluX.js} +1 -1
- package/dist/assets/{DocBrowser-Cse_F8Nn.js → DocBrowser-BMxf9CIK.js} +1 -1
- package/dist/assets/DocBrowser-CyDgAtO9.js +1 -0
- package/dist/assets/{DocBrowserContext-Bai1WU2H.js → DocBrowserContext-Ce28gRXt.js} +1 -1
- package/dist/assets/{LogoBadge-BdxMPc9v.js → LogoBadge-o92MOA2L.js} +1 -1
- package/dist/assets/{MarketplacePage-BbpAkllU.js → MarketplacePage-BySqkYDh.js} +1 -1
- package/dist/assets/MarketplacePage-C0olZaek.js +1 -0
- package/dist/assets/{McpMarketplacePage-CxPFOgxv.js → McpMarketplacePage-DqKaiXO9.js} +1 -1
- package/dist/assets/{ModelConfig-3GLqQ5GY.js → ModelConfig-IrmzoslW.js} +1 -1
- package/dist/assets/{ProviderScopedModelInput-BYNouw-i.js → ProviderScopedModelInput-CmTIzgI7.js} +1 -1
- package/dist/assets/{ProvidersList-BR1gJ4Dm.js → ProvidersList-8_Kalfwl.js} +1 -1
- package/dist/assets/{RemoteAccessPage-DyYVWsyK.js → RemoteAccessPage-CyQlSjPf.js} +1 -1
- package/dist/assets/RuntimeConfig-Bk0uYBhf.js +1 -0
- package/dist/assets/{SearchConfig-DTeJvp8m.js → SearchConfig-DNBR-UbE.js} +1 -1
- package/dist/assets/{SecretsConfig-CCYO6NcV.js → SecretsConfig-Ba1RPJaG.js} +1 -1
- package/dist/assets/{SessionsConfig-Du39vDgt.js → SessionsConfig-Doqp5ghH.js} +1 -1
- package/dist/assets/{app-query-client-Dr5d-K8d.js → app-query-client-DniXoIN5.js} +1 -1
- package/dist/assets/{book-open-Da4OEPqB.js → book-open-DocgeQtR.js} +1 -1
- package/dist/assets/chat-page-Bph8M5zo.js +58 -0
- package/dist/assets/chat-session-display-CoN3Wmn-.js +1 -0
- package/dist/assets/{chunk-JZWAC4HX-CoFVxHXV.js → chunk-JZWAC4HX-BvKvh1R8.js} +1 -1
- package/dist/assets/{client-CSk58DcF.js → client-CVqPF5ie.js} +1 -1
- package/dist/assets/{config-D8KzikVB.js → config-Bop2oB18.js} +1 -1
- package/dist/assets/{createLucideIcon-83gaZMtv.js → createLucideIcon-DVv8taGY.js} +1 -1
- package/dist/assets/desktop-update-config-1KBrqLBC.js +1 -0
- package/dist/assets/{dist-toEYs-MZ.js → dist-Da5Gm_pO.js} +1 -1
- package/dist/assets/{dist-aTmhMDVh.js → dist-DmAlInRu.js} +1 -1
- package/dist/assets/{external-link-QQ0TC6X4.js → external-link-DFjw3x1B.js} +1 -1
- package/dist/assets/{hash-DaFBEkmi.js → hash-DJtaCejM.js} +1 -1
- package/dist/assets/i18n-CwHZ-9vt.js +1 -0
- package/dist/assets/{index-CE4N7ItL.css → index-DafCdM4F.css} +1 -1
- package/dist/assets/{index-riX7Sg0_.js → index-DdksE6U3.js} +3 -3
- package/dist/assets/{infiniteQueryBehavior-BmHX_ayZ.js → infiniteQueryBehavior-DHSEQ3OH.js} +1 -1
- package/dist/assets/loader-circle-PsSP0H9n.js +1 -0
- package/dist/assets/{logos-Dzlz30M3.js → logos-DEFUIR12.js} +1 -1
- package/dist/assets/{page-layout-D2eRufRQ.js → page-layout-Da3i3r6G.js} +1 -1
- package/dist/assets/play-DBQbBxTA.js +1 -0
- package/dist/assets/plus-DUOVbsyQ.js +1 -0
- package/dist/assets/{popover-BSXxm5bj.js → popover-C_mWOFzI.js} +1 -1
- package/dist/assets/{refresh-ccw-B3zMtN-_.js → refresh-ccw-D6HkNtfz.js} +1 -1
- package/dist/assets/{refresh-cw-DlZkIHnJ.js → refresh-cw-DRcvRrnc.js} +1 -1
- package/dist/assets/rotate-cw-BmDKfXtH.js +1 -0
- package/dist/assets/{save-Us9fg4Sj.js → save-DHGmi2e9.js} +1 -1
- package/dist/assets/search-MChQRYR1.js +1 -0
- package/dist/assets/{security-config-BGWYwxNr.js → security-config-CbXfPZzr.js} +1 -1
- package/dist/assets/{select-DLYqySQK.js → select-Caud8QvU.js} +1 -1
- package/dist/assets/skeleton-B-4vRq_Z.js +1 -0
- package/dist/assets/{status-dot-DGayudyB.js → status-dot-DurKKSwA.js} +1 -1
- package/dist/assets/{switch-Dz2ScsKx.js → switch-0rmPBRKI.js} +1 -1
- package/dist/assets/{tabs-custom-CdKyjiGk.js → tabs-custom-5JLVL6v8.js} +1 -1
- package/dist/assets/{trash-2-Db-mZOZs.js → trash-2-C6caKPoz.js} +1 -1
- package/dist/assets/{use-infinite-scroll-loader-DBJX5hj0.js → use-infinite-scroll-loader-dwnaa_qi.js} +1 -1
- package/dist/assets/{useConfirmDialog-DL0a-oGC.js → useConfirmDialog-mMeWD_yo.js} +1 -1
- package/dist/assets/{useMutation-BdZm-9PL.js → useMutation-BmxxvCNf.js} +1 -1
- package/dist/assets/x-DuMhMATD.js +1 -0
- package/dist/index.html +20 -20
- package/package.json +6 -6
- package/src/api/runtime-control.ts +34 -0
- package/src/api/runtime-control.types.ts +58 -0
- package/src/api/types.ts +13 -0
- package/src/{App.test.tsx → app.test.tsx} +1 -1
- package/src/{App.tsx → app.tsx} +1 -1
- package/src/components/chat/ChatConversationPanel.test.tsx +78 -16
- package/src/components/chat/ChatSidebar.test.tsx +36 -7
- package/src/components/chat/ChatSidebar.tsx +19 -26
- package/src/components/chat/chat-child-session-panel.tsx +16 -8
- package/src/components/chat/chat-page-runtime.test.ts +1 -1
- package/src/components/chat/{ChatPage.tsx → chat-page.tsx} +1 -1
- package/src/components/chat/managers/chat-session-list.manager.test.ts +82 -31
- package/src/components/chat/managers/chat-session-list.manager.ts +79 -14
- package/src/components/chat/managers/chat-ui.manager.ts +2 -0
- package/src/components/chat/ncp/README.md +1 -1
- package/src/components/chat/ncp/ncp-chat-input.manager.ts +7 -1
- package/src/components/chat/ncp/ncp-chat-page-data.test.ts +1 -1
- package/src/components/chat/ncp/ncp-session-adapter.test.ts +5 -1
- package/src/components/chat/ncp/ncp-session-adapter.ts +12 -0
- package/src/components/chat/ncp/session-conversation/use-ncp-child-session-tabs-view.ts +4 -0
- package/src/components/chat/ncp/tests/ncp-chat-input.manager.test.ts +99 -0
- package/src/components/chat/stores/chat-session-list.store.ts +25 -54
- package/src/components/common/ProviderScopedModelInput.tsx +12 -2
- package/src/components/config/ModelConfig.test.tsx +108 -2
- package/src/components/config/RuntimeConfig.tsx +14 -6
- package/src/components/config/desktop-update-config.test.tsx +85 -0
- package/src/components/config/desktop-update-config.tsx +44 -3
- package/src/components/config/runtime-control-card.test.tsx +255 -0
- package/src/components/config/runtime-control-card.tsx +301 -0
- package/src/components/config/runtime-presence-card.test.tsx +154 -0
- package/src/components/config/runtime-presence-card.tsx +163 -0
- package/src/desktop/desktop-update.types.ts +25 -0
- package/src/desktop/managers/desktop-presence.manager.ts +91 -0
- package/src/desktop/managers/desktop-update.manager.ts +37 -1
- package/src/desktop/stores/desktop-presence.store.ts +18 -0
- package/src/desktop/stores/desktop-update.store.ts +7 -1
- package/src/hooks/use-runtime-control.ts +24 -0
- package/src/lib/desktop-update-labels.utils.ts +28 -2
- package/src/lib/i18n.runtime-control.ts +120 -0
- package/src/lib/i18n.ts +2 -4
- package/src/main.tsx +1 -1
- package/src/runtime-control/runtime-control.manager.ts +118 -0
- package/dist/assets/ChatPage-A45t1Rmf.js +0 -58
- package/dist/assets/DocBrowser-B2MpsnU9.js +0 -1
- package/dist/assets/MarketplacePage-BNZ3Jx5d.js +0 -1
- package/dist/assets/RuntimeConfig-ChdfK4Y_.js +0 -1
- package/dist/assets/chat-session-display-CAlPrnlV.js +0 -1
- package/dist/assets/desktop-update-config-CfoVwf-w.js +0 -1
- package/dist/assets/i18n-C3jb83S6.js +0 -1
- package/dist/assets/loader-circle-BjMg63eu.js +0 -1
- package/dist/assets/plus-CIXME2pD.js +0 -1
- package/dist/assets/search-B_Qr0f6C.js +0 -1
- package/dist/assets/skeleton-CYQJazv6.js +0 -1
- package/dist/assets/x-B8Tho_xC.js +0 -1
- /package/dist/assets/{config-hints-GSUMvmSo.js → config-hints-BZoDjXye.js} +0 -0
- /package/dist/assets/{config-layout-CgBMG7OL.js → config-layout-DmlGaay2.js} +0 -0
- /package/src/components/chat/ncp/{NcpChatPage.tsx → ncp-chat-page.tsx} +0 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { createChatComposerTextNode } from '@nextclaw/agent-chat-ui';
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { NcpChatInputManager } from '@/components/chat/ncp/ncp-chat-input.manager';
|
|
4
|
+
import { useChatInputStore } from '@/components/chat/stores/chat-input.store';
|
|
5
|
+
import { useChatSessionListStore } from '@/components/chat/stores/chat-session-list.store';
|
|
6
|
+
import { useChatThreadStore } from '@/components/chat/stores/chat-thread.store';
|
|
7
|
+
|
|
8
|
+
describe('NcpChatInputManager', () => {
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
useChatInputStore.setState({
|
|
11
|
+
snapshot: {
|
|
12
|
+
...useChatInputStore.getState().snapshot,
|
|
13
|
+
draft: 'hello from current thread',
|
|
14
|
+
composerNodes: [createChatComposerTextNode('hello from current thread')],
|
|
15
|
+
attachments: [],
|
|
16
|
+
selectedSkills: [],
|
|
17
|
+
selectedSessionType: 'native',
|
|
18
|
+
selectedModel: '',
|
|
19
|
+
selectedThinkingLevel: null,
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
useChatSessionListStore.setState({
|
|
23
|
+
optimisticReadAtBySessionKey: {},
|
|
24
|
+
snapshot: {
|
|
25
|
+
...useChatSessionListStore.getState().snapshot,
|
|
26
|
+
selectedSessionKey: 'stale-selected-session',
|
|
27
|
+
draftSessionKey: 'draft-root-session',
|
|
28
|
+
selectedAgentId: 'main',
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
useChatThreadStore.setState({
|
|
32
|
+
snapshot: {
|
|
33
|
+
...useChatThreadStore.getState().snapshot,
|
|
34
|
+
sessionKey: 'current-route-session',
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('sends through the current thread session when selected session state is stale', async () => {
|
|
40
|
+
const streamActionsManager = {
|
|
41
|
+
sendMessage: vi.fn().mockResolvedValue(undefined),
|
|
42
|
+
stopCurrentRun: vi.fn().mockResolvedValue(undefined),
|
|
43
|
+
} as unknown as ConstructorParameters<typeof NcpChatInputManager>[1];
|
|
44
|
+
const sessionListManager = {
|
|
45
|
+
ensureDraftSession: vi.fn(() => 'draft-session'),
|
|
46
|
+
promoteRootDraftSessionRoute: vi.fn(),
|
|
47
|
+
} as unknown as ConstructorParameters<typeof NcpChatInputManager>[2];
|
|
48
|
+
const manager = new NcpChatInputManager(
|
|
49
|
+
{} as ConstructorParameters<typeof NcpChatInputManager>[0],
|
|
50
|
+
streamActionsManager,
|
|
51
|
+
sessionListManager,
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
await manager.send();
|
|
55
|
+
|
|
56
|
+
expect(streamActionsManager.sendMessage).toHaveBeenCalledTimes(1);
|
|
57
|
+
expect(streamActionsManager.sendMessage).toHaveBeenCalledWith(
|
|
58
|
+
expect.objectContaining({
|
|
59
|
+
sessionKey: 'current-route-session',
|
|
60
|
+
message: 'hello from current thread',
|
|
61
|
+
}),
|
|
62
|
+
);
|
|
63
|
+
expect(sessionListManager.ensureDraftSession).not.toHaveBeenCalled();
|
|
64
|
+
expect(sessionListManager.promoteRootDraftSessionRoute).toHaveBeenCalledWith('current-route-session');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('keeps sending through the current root draft session while /chat is still in blank-draft mode', async () => {
|
|
68
|
+
useChatThreadStore.setState({
|
|
69
|
+
snapshot: {
|
|
70
|
+
...useChatThreadStore.getState().snapshot,
|
|
71
|
+
sessionKey: 'draft-root-session',
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
const streamActionsManager = {
|
|
75
|
+
sendMessage: vi.fn().mockResolvedValue(undefined),
|
|
76
|
+
stopCurrentRun: vi.fn().mockResolvedValue(undefined),
|
|
77
|
+
} as unknown as ConstructorParameters<typeof NcpChatInputManager>[1];
|
|
78
|
+
const sessionListManager = {
|
|
79
|
+
ensureDraftSession: vi.fn(() => 'materialized-draft-session'),
|
|
80
|
+
promoteRootDraftSessionRoute: vi.fn(),
|
|
81
|
+
} as unknown as ConstructorParameters<typeof NcpChatInputManager>[2];
|
|
82
|
+
const manager = new NcpChatInputManager(
|
|
83
|
+
{} as ConstructorParameters<typeof NcpChatInputManager>[0],
|
|
84
|
+
streamActionsManager,
|
|
85
|
+
sessionListManager,
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
await manager.send();
|
|
89
|
+
|
|
90
|
+
expect(sessionListManager.ensureDraftSession).not.toHaveBeenCalled();
|
|
91
|
+
expect(streamActionsManager.sendMessage).toHaveBeenCalledWith(
|
|
92
|
+
expect.objectContaining({
|
|
93
|
+
sessionKey: 'draft-root-session',
|
|
94
|
+
message: 'hello from current thread',
|
|
95
|
+
}),
|
|
96
|
+
);
|
|
97
|
+
expect(sessionListManager.promoteRootDraftSessionRoute).toHaveBeenCalledWith('draft-root-session');
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -13,42 +13,39 @@ export type ChatSessionListSnapshot = {
|
|
|
13
13
|
};
|
|
14
14
|
|
|
15
15
|
export function hasUnreadSessionUpdate(
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
lastMessageAt: string | null | undefined,
|
|
17
|
+
readAt: string | undefined,
|
|
18
18
|
): boolean {
|
|
19
|
-
const
|
|
20
|
-
if (!
|
|
19
|
+
const normalizedLastMessageAt = lastMessageAt?.trim();
|
|
20
|
+
if (!normalizedLastMessageAt) {
|
|
21
21
|
return false;
|
|
22
22
|
}
|
|
23
|
-
const
|
|
24
|
-
if (!
|
|
25
|
-
|
|
23
|
+
const normalizedReadAt = readAt?.trim();
|
|
24
|
+
if (!normalizedReadAt) {
|
|
25
|
+
// Until this client establishes a read watermark, avoid guessing unread state.
|
|
26
|
+
return false;
|
|
26
27
|
}
|
|
27
|
-
return
|
|
28
|
+
return normalizedLastMessageAt.localeCompare(normalizedReadAt) > 0;
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
export function shouldShowUnreadSessionIndicator(params: {
|
|
31
32
|
active: boolean;
|
|
32
|
-
|
|
33
|
-
|
|
33
|
+
lastMessageAt: string | null | undefined;
|
|
34
|
+
readAt: string | undefined;
|
|
34
35
|
runStatus?: SessionRunStatus;
|
|
35
36
|
}): boolean {
|
|
36
|
-
const { active,
|
|
37
|
+
const { active, readAt, runStatus, lastMessageAt } = params;
|
|
37
38
|
if (active || runStatus === 'running') {
|
|
38
39
|
return false;
|
|
39
40
|
}
|
|
40
|
-
return hasUnreadSessionUpdate(
|
|
41
|
+
return hasUnreadSessionUpdate(lastMessageAt, readAt);
|
|
41
42
|
}
|
|
42
43
|
|
|
43
44
|
type ChatSessionListStore = {
|
|
44
45
|
snapshot: ChatSessionListSnapshot;
|
|
45
|
-
|
|
46
|
-
hasHydratedReadWatermarks: boolean;
|
|
46
|
+
optimisticReadAtBySessionKey: Record<string, string>;
|
|
47
47
|
setSnapshot: (patch: Partial<ChatSessionListSnapshot>) => void;
|
|
48
|
-
markSessionRead: (sessionKey: string,
|
|
49
|
-
hydrateReadWatermarks: (
|
|
50
|
-
entries: readonly { sessionKey: string; updatedAt: string | null | undefined }[],
|
|
51
|
-
) => void;
|
|
48
|
+
markSessionRead: (sessionKey: string, readAt: string | null | undefined) => void;
|
|
52
49
|
};
|
|
53
50
|
|
|
54
51
|
type ChatSessionListStoreSet = Parameters<StateCreator<ChatSessionListStore>>[0];
|
|
@@ -72,56 +69,30 @@ function createSetSnapshotAction(set: ChatSessionListStoreSet) {
|
|
|
72
69
|
}
|
|
73
70
|
|
|
74
71
|
function createMarkSessionReadAction(set: ChatSessionListStoreSet) {
|
|
75
|
-
return (sessionKey: string,
|
|
72
|
+
return (sessionKey: string, readAt: string | null | undefined) =>
|
|
76
73
|
set((state) => {
|
|
77
74
|
const normalizedSessionKey = sessionKey.trim();
|
|
78
|
-
const
|
|
79
|
-
if (!normalizedSessionKey || !
|
|
75
|
+
const normalizedReadAt = readAt?.trim();
|
|
76
|
+
if (!normalizedSessionKey || !normalizedReadAt) {
|
|
80
77
|
return state;
|
|
81
78
|
}
|
|
82
|
-
|
|
79
|
+
const previousReadAt = state.optimisticReadAtBySessionKey[normalizedSessionKey];
|
|
80
|
+
if (previousReadAt && previousReadAt.localeCompare(normalizedReadAt) >= 0) {
|
|
83
81
|
return state;
|
|
84
82
|
}
|
|
85
83
|
return {
|
|
86
84
|
...state,
|
|
87
|
-
|
|
88
|
-
...state.
|
|
89
|
-
[normalizedSessionKey]:
|
|
90
|
-
}
|
|
91
|
-
};
|
|
92
|
-
});
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
function createHydrateReadWatermarksAction(set: ChatSessionListStoreSet) {
|
|
96
|
-
return (
|
|
97
|
-
entries: readonly { sessionKey: string; updatedAt: string | null | undefined }[],
|
|
98
|
-
) =>
|
|
99
|
-
set((state) => {
|
|
100
|
-
if (state.hasHydratedReadWatermarks) {
|
|
101
|
-
return state;
|
|
102
|
-
}
|
|
103
|
-
const nextReadUpdatedAtBySessionKey = { ...state.readUpdatedAtBySessionKey };
|
|
104
|
-
for (const entry of entries) {
|
|
105
|
-
const normalizedSessionKey = entry.sessionKey.trim();
|
|
106
|
-
const normalizedUpdatedAt = entry.updatedAt?.trim();
|
|
107
|
-
if (!normalizedSessionKey || !normalizedUpdatedAt || nextReadUpdatedAtBySessionKey[normalizedSessionKey]) {
|
|
108
|
-
continue;
|
|
85
|
+
optimisticReadAtBySessionKey: {
|
|
86
|
+
...state.optimisticReadAtBySessionKey,
|
|
87
|
+
[normalizedSessionKey]: normalizedReadAt
|
|
109
88
|
}
|
|
110
|
-
nextReadUpdatedAtBySessionKey[normalizedSessionKey] = normalizedUpdatedAt;
|
|
111
|
-
}
|
|
112
|
-
return {
|
|
113
|
-
...state,
|
|
114
|
-
hasHydratedReadWatermarks: true,
|
|
115
|
-
readUpdatedAtBySessionKey: nextReadUpdatedAtBySessionKey
|
|
116
89
|
};
|
|
117
90
|
});
|
|
118
91
|
}
|
|
119
92
|
|
|
120
93
|
export const useChatSessionListStore = create<ChatSessionListStore>((set) => ({
|
|
121
94
|
snapshot: initialSnapshot,
|
|
122
|
-
|
|
123
|
-
hasHydratedReadWatermarks: false,
|
|
95
|
+
optimisticReadAtBySessionKey: {},
|
|
124
96
|
setSnapshot: createSetSnapshotAction(set),
|
|
125
|
-
markSessionRead: createMarkSessionReadAction(set)
|
|
126
|
-
hydrateReadWatermarks: createHydrateReadWatermarksAction(set)
|
|
97
|
+
markSessionRead: createMarkSessionReadAction(set)
|
|
127
98
|
}));
|
|
@@ -64,6 +64,11 @@ export function ProviderScopedModelInput({
|
|
|
64
64
|
setModelId(currentModel);
|
|
65
65
|
return;
|
|
66
66
|
}
|
|
67
|
+
if (!currentModel) {
|
|
68
|
+
setModelId('');
|
|
69
|
+
setProviderName((currentProvider) => (providerMap.has(currentProvider) ? currentProvider : ''));
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
67
72
|
const matchedProvider = findProviderByModel(currentModel, providerCatalog);
|
|
68
73
|
const effectiveProvider = matchedProvider ?? '';
|
|
69
74
|
const aliases = providerMap.get(effectiveProvider)?.aliases ?? [];
|
|
@@ -72,9 +77,14 @@ export function ProviderScopedModelInput({
|
|
|
72
77
|
}, [hasProviders, providerCatalog, providerMap, value]);
|
|
73
78
|
|
|
74
79
|
const handleProviderChange = (nextProvider: string) => {
|
|
80
|
+
const nextProviderModel = normalizeModelOptions(providerMap.get(nextProvider)?.models ?? [])[0] ?? '';
|
|
75
81
|
setProviderName(nextProvider);
|
|
76
|
-
setModelId(
|
|
77
|
-
|
|
82
|
+
setModelId(nextProviderModel);
|
|
83
|
+
if (!nextProviderModel) {
|
|
84
|
+
onChange('');
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
onChange(composeProviderModel(providerMap.get(nextProvider)?.prefix ?? nextProvider, nextProviderModel));
|
|
78
88
|
};
|
|
79
89
|
|
|
80
90
|
const handleModelChange = (nextModelId: string) => {
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import { render, screen, waitFor } from '@testing-library/react';
|
|
1
|
+
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
2
2
|
import userEvent from '@testing-library/user-event';
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { ProviderScopedModelInput } from '@/components/common/ProviderScopedModelInput';
|
|
3
5
|
import { ModelConfig } from '@/components/config/ModelConfig';
|
|
4
6
|
import { setLanguage } from '@/lib/i18n';
|
|
5
7
|
|
|
@@ -19,7 +21,7 @@ const mocks = vi.hoisted(() => ({
|
|
|
19
21
|
apiKeySet: true,
|
|
20
22
|
models: ['gpt-5.2']
|
|
21
23
|
}
|
|
22
|
-
}
|
|
24
|
+
} as Record<string, { enabled: boolean; apiKeySet: boolean; models: string[] }>
|
|
23
25
|
},
|
|
24
26
|
isLoading: false
|
|
25
27
|
},
|
|
@@ -58,6 +60,9 @@ describe('ModelConfig', () => {
|
|
|
58
60
|
beforeEach(() => {
|
|
59
61
|
mocks.mutate.mockReset();
|
|
60
62
|
setLanguage('en');
|
|
63
|
+
HTMLElement.prototype.hasPointerCapture = vi.fn(() => false);
|
|
64
|
+
HTMLElement.prototype.setPointerCapture = vi.fn();
|
|
65
|
+
HTMLElement.prototype.releasePointerCapture = vi.fn();
|
|
61
66
|
mocks.configQuery.data = {
|
|
62
67
|
agents: {
|
|
63
68
|
defaults: {
|
|
@@ -82,9 +87,42 @@ describe('ModelConfig', () => {
|
|
|
82
87
|
defaultModels: ['openai/gpt-5.2'],
|
|
83
88
|
keywords: [],
|
|
84
89
|
envKey: 'OPENAI_API_KEY'
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
name: 'deepseek',
|
|
93
|
+
displayName: 'DeepSeek',
|
|
94
|
+
modelPrefix: 'deepseek',
|
|
95
|
+
defaultModels: ['deepseek/deepseek-chat', 'deepseek/deepseek-reasoner'],
|
|
96
|
+
keywords: [],
|
|
97
|
+
envKey: 'DEEPSEEK_API_KEY'
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
name: 'customhub',
|
|
101
|
+
displayName: 'CustomHub',
|
|
102
|
+
modelPrefix: 'customhub',
|
|
103
|
+
defaultModels: [],
|
|
104
|
+
keywords: [],
|
|
105
|
+
envKey: 'CUSTOMHUB_API_KEY'
|
|
85
106
|
}
|
|
86
107
|
]
|
|
87
108
|
};
|
|
109
|
+
mocks.configQuery.data.providers = {
|
|
110
|
+
openai: {
|
|
111
|
+
enabled: true,
|
|
112
|
+
apiKeySet: true,
|
|
113
|
+
models: ['gpt-5.2']
|
|
114
|
+
},
|
|
115
|
+
deepseek: {
|
|
116
|
+
enabled: true,
|
|
117
|
+
apiKeySet: true,
|
|
118
|
+
models: ['deepseek-chat', 'deepseek-reasoner']
|
|
119
|
+
},
|
|
120
|
+
customhub: {
|
|
121
|
+
enabled: true,
|
|
122
|
+
apiKeySet: true,
|
|
123
|
+
models: []
|
|
124
|
+
}
|
|
125
|
+
};
|
|
88
126
|
});
|
|
89
127
|
|
|
90
128
|
it('submits the workspace together with the selected model', async () => {
|
|
@@ -136,4 +174,72 @@ describe('ModelConfig', () => {
|
|
|
136
174
|
});
|
|
137
175
|
});
|
|
138
176
|
});
|
|
177
|
+
|
|
178
|
+
it('switches to the new provider without clearing the selection and auto-fills its first model', async () => {
|
|
179
|
+
const user = userEvent.setup();
|
|
180
|
+
|
|
181
|
+
render(<ModelConfig />);
|
|
182
|
+
|
|
183
|
+
const providerTrigger = screen.getByRole('combobox');
|
|
184
|
+
fireEvent.keyDown(providerTrigger, { key: 'ArrowDown' });
|
|
185
|
+
await user.click(screen.getByRole('option', { name: 'DeepSeek' }));
|
|
186
|
+
await user.click(screen.getByRole('button', { name: /save/i }));
|
|
187
|
+
|
|
188
|
+
await waitFor(() => {
|
|
189
|
+
expect(mocks.mutate).toHaveBeenCalledWith({
|
|
190
|
+
model: 'deepseek/deepseek-chat',
|
|
191
|
+
workspace: '~/old-workspace'
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
expect(providerTrigger.textContent).toContain('DeepSeek');
|
|
196
|
+
expect(screen.getByDisplayValue('deepseek-chat')).toBeTruthy();
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('keeps the provider selected when the shared input switches to a provider without preset models', async () => {
|
|
200
|
+
const user = userEvent.setup();
|
|
201
|
+
|
|
202
|
+
function Harness() {
|
|
203
|
+
const [value, setValue] = useState('openai/gpt-5.2');
|
|
204
|
+
|
|
205
|
+
return (
|
|
206
|
+
<ProviderScopedModelInput
|
|
207
|
+
value={value}
|
|
208
|
+
onChange={setValue}
|
|
209
|
+
providerCatalog={[
|
|
210
|
+
{
|
|
211
|
+
name: 'openai',
|
|
212
|
+
displayName: 'OpenAI',
|
|
213
|
+
prefix: 'openai',
|
|
214
|
+
aliases: ['openai'],
|
|
215
|
+
models: ['gpt-5.2'],
|
|
216
|
+
modelThinking: {},
|
|
217
|
+
configured: true
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
name: 'customhub',
|
|
221
|
+
displayName: 'CustomHub',
|
|
222
|
+
prefix: 'customhub',
|
|
223
|
+
aliases: ['customhub'],
|
|
224
|
+
models: [],
|
|
225
|
+
modelThinking: {},
|
|
226
|
+
configured: true
|
|
227
|
+
}
|
|
228
|
+
]}
|
|
229
|
+
/>
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
render(<Harness />);
|
|
234
|
+
|
|
235
|
+
const providerTrigger = screen.getByRole('combobox');
|
|
236
|
+
fireEvent.keyDown(providerTrigger, { key: 'ArrowDown' });
|
|
237
|
+
await user.click(screen.getByRole('option', { name: 'CustomHub' }));
|
|
238
|
+
|
|
239
|
+
const modelInput = screen.getByPlaceholderText('provider/model');
|
|
240
|
+
await user.type(modelInput, 'reasoner-v1');
|
|
241
|
+
|
|
242
|
+
expect(providerTrigger.textContent).toContain('CustomHub');
|
|
243
|
+
expect(screen.getByDisplayValue('reasoner-v1')).toBeTruthy();
|
|
244
|
+
});
|
|
139
245
|
});
|
|
@@ -3,6 +3,8 @@ import { useConfig, useConfigSchema, useUpdateRuntime } from '@/hooks/useConfig'
|
|
|
3
3
|
import type { AgentBindingView, AgentProfileView } from '@/api/types';
|
|
4
4
|
import { Button } from '@/components/ui/button';
|
|
5
5
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
6
|
+
import { RuntimeControlCard } from '@/components/config/runtime-control-card';
|
|
7
|
+
import { RuntimePresenceCard } from '@/components/config/runtime-presence-card';
|
|
6
8
|
import { Input } from '@/components/ui/input';
|
|
7
9
|
import { Switch } from '@/components/ui/switch';
|
|
8
10
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
@@ -30,6 +32,16 @@ const DM_SCOPE_OPTIONS: Array<{ value: DmScope; label: string }> = [
|
|
|
30
32
|
{ value: 'per-account-channel-peer', label: 'per-account-channel-peer' }
|
|
31
33
|
];
|
|
32
34
|
|
|
35
|
+
function RuntimeConfigOverview() {
|
|
36
|
+
return (
|
|
37
|
+
<>
|
|
38
|
+
<PageHeader title={t('runtimePageTitle')} description={t('runtimePageDescription')} />
|
|
39
|
+
<RuntimeControlCard />
|
|
40
|
+
<RuntimePresenceCard />
|
|
41
|
+
</>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
33
45
|
export function RuntimeConfig() {
|
|
34
46
|
const { data: config, isLoading } = useConfig();
|
|
35
47
|
const { data: schema } = useConfigSchema();
|
|
@@ -79,7 +91,6 @@ export function RuntimeConfig() {
|
|
|
79
91
|
const updateBinding = (index: number, next: AgentBindingView) => {
|
|
80
92
|
setBindings((prev) => prev.map((binding, cursor) => (cursor === index ? next : binding)));
|
|
81
93
|
};
|
|
82
|
-
|
|
83
94
|
const handleSave = () => {
|
|
84
95
|
try {
|
|
85
96
|
const normalizedAgents = agents.map((agent, index) => {
|
|
@@ -160,14 +171,11 @@ export function RuntimeConfig() {
|
|
|
160
171
|
}
|
|
161
172
|
};
|
|
162
173
|
|
|
163
|
-
if (isLoading || !config) {
|
|
164
|
-
return <div className="p-8 text-gray-400">{t('runtimeLoading')}</div>;
|
|
165
|
-
}
|
|
174
|
+
if (isLoading || !config) return <div className="p-8 text-gray-400">{t('runtimeLoading')}</div>;
|
|
166
175
|
|
|
167
176
|
return (
|
|
168
177
|
<PageLayout className="space-y-6">
|
|
169
|
-
<
|
|
170
|
-
|
|
178
|
+
<RuntimeConfigOverview />
|
|
171
179
|
<Card>
|
|
172
180
|
<CardHeader>
|
|
173
181
|
<CardTitle>{dmScopeHint?.label ?? t('dmScope')}</CardTitle>
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import userEvent from '@testing-library/user-event';
|
|
3
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { DesktopUpdateConfig } from '@/components/config/desktop-update-config';
|
|
5
|
+
import { useDesktopUpdateStore } from '@/desktop/stores/desktop-update.store';
|
|
6
|
+
import { setLanguage } from '@/lib/i18n';
|
|
7
|
+
|
|
8
|
+
const mocks = vi.hoisted(() => ({
|
|
9
|
+
start: vi.fn(),
|
|
10
|
+
stop: vi.fn(),
|
|
11
|
+
checkForUpdates: vi.fn(),
|
|
12
|
+
downloadUpdate: vi.fn(),
|
|
13
|
+
applyDownloadedUpdate: vi.fn(),
|
|
14
|
+
updatePreferences: vi.fn(),
|
|
15
|
+
updateChannel: vi.fn()
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
vi.mock('@/desktop/managers/desktop-update.manager', () => ({
|
|
19
|
+
desktopUpdateManager: mocks
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
describe('DesktopUpdateConfig', () => {
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
setLanguage('zh');
|
|
25
|
+
mocks.start.mockReset();
|
|
26
|
+
mocks.stop.mockReset();
|
|
27
|
+
mocks.checkForUpdates.mockReset();
|
|
28
|
+
mocks.downloadUpdate.mockReset();
|
|
29
|
+
mocks.applyDownloadedUpdate.mockReset();
|
|
30
|
+
mocks.updatePreferences.mockReset();
|
|
31
|
+
mocks.updateChannel.mockReset();
|
|
32
|
+
|
|
33
|
+
if (!HTMLElement.prototype.hasPointerCapture) {
|
|
34
|
+
HTMLElement.prototype.hasPointerCapture = () => false;
|
|
35
|
+
}
|
|
36
|
+
if (!HTMLElement.prototype.setPointerCapture) {
|
|
37
|
+
HTMLElement.prototype.setPointerCapture = () => {};
|
|
38
|
+
}
|
|
39
|
+
if (!HTMLElement.prototype.releasePointerCapture) {
|
|
40
|
+
HTMLElement.prototype.releasePointerCapture = () => {};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
useDesktopUpdateStore.setState({
|
|
44
|
+
supported: true,
|
|
45
|
+
initialized: true,
|
|
46
|
+
busyAction: null,
|
|
47
|
+
snapshot: {
|
|
48
|
+
status: 'idle',
|
|
49
|
+
channel: 'beta',
|
|
50
|
+
launcherVersion: '0.0.138',
|
|
51
|
+
currentVersion: '0.18.0',
|
|
52
|
+
availableVersion: '0.18.2-beta.1',
|
|
53
|
+
downloadedVersion: null,
|
|
54
|
+
releaseNotesUrl: 'https://example.com/release-notes',
|
|
55
|
+
lastCheckedAt: '2026-04-13T12:00:00.000Z',
|
|
56
|
+
errorMessage: null,
|
|
57
|
+
preferences: {
|
|
58
|
+
automaticChecks: true,
|
|
59
|
+
autoDownload: false
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('renders current channel information and beta guidance', () => {
|
|
66
|
+
render(<DesktopUpdateConfig />);
|
|
67
|
+
|
|
68
|
+
expect(mocks.start).toHaveBeenCalledTimes(1);
|
|
69
|
+
expect(screen.getByText('当前更新通道')).toBeTruthy();
|
|
70
|
+
expect(screen.getAllByText('Beta').length).toBeGreaterThan(0);
|
|
71
|
+
expect(screen.getByText('当前正在跟随 Beta 通道')).toBeTruthy();
|
|
72
|
+
expect(screen.getByText('切回 Stable 后不会立刻强制降级;只有当 Stable 追平或超过当前版本时,才会继续提供 Stable 更新。')).toBeTruthy();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('sends the newly selected release channel to the desktop update manager', async () => {
|
|
76
|
+
const user = userEvent.setup();
|
|
77
|
+
|
|
78
|
+
render(<DesktopUpdateConfig />);
|
|
79
|
+
|
|
80
|
+
await user.click(screen.getByRole('combobox'));
|
|
81
|
+
await user.click(screen.getByRole('option', { name: 'Stable' }));
|
|
82
|
+
|
|
83
|
+
expect(mocks.updateChannel).toHaveBeenCalledWith('stable');
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -2,9 +2,11 @@ import { useEffect } from 'react';
|
|
|
2
2
|
import { Button } from '@/components/ui/button';
|
|
3
3
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
4
4
|
import { Label } from '@/components/ui/label';
|
|
5
|
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
5
6
|
import { Switch } from '@/components/ui/switch';
|
|
6
7
|
import { PageHeader, PageLayout } from '@/components/layout/page-layout';
|
|
7
8
|
import { desktopUpdateManager } from '@/desktop/managers/desktop-update.manager';
|
|
9
|
+
import type { DesktopReleaseChannel } from '@/desktop/desktop-update.types';
|
|
8
10
|
import { useDesktopUpdateStore } from '@/desktop/stores/desktop-update.store';
|
|
9
11
|
import { formatDateTime, t } from '@/lib/i18n';
|
|
10
12
|
import { cn } from '@/lib/utils';
|
|
@@ -18,6 +20,10 @@ function formatLastCheckedAt(value: string | null): string {
|
|
|
18
20
|
return value ? formatDateTime(value) : '-';
|
|
19
21
|
}
|
|
20
22
|
|
|
23
|
+
function getChannelLabel(channel: DesktopReleaseChannel): string {
|
|
24
|
+
return channel === 'beta' ? t('desktopUpdatesChannelBeta') : t('desktopUpdatesChannelStable');
|
|
25
|
+
}
|
|
26
|
+
|
|
21
27
|
function getStatusLabel(status: string): string {
|
|
22
28
|
if (status === 'checking') {
|
|
23
29
|
return t('desktopUpdatesStatusChecking');
|
|
@@ -91,6 +97,7 @@ export function DesktopUpdateConfig() {
|
|
|
91
97
|
const isDownloading = busyAction === 'downloading';
|
|
92
98
|
const isApplying = busyAction === 'applying';
|
|
93
99
|
const isSavingPreferences = busyAction === 'saving-preferences';
|
|
100
|
+
const isSwitchingChannel = busyAction === 'switching-channel';
|
|
94
101
|
const canDownload = snapshot.status === 'update-available' && !isDownloading && !isApplying;
|
|
95
102
|
const canApply = snapshot.status === 'downloaded' && !isApplying;
|
|
96
103
|
|
|
@@ -124,7 +131,7 @@ export function DesktopUpdateConfig() {
|
|
|
124
131
|
</span>
|
|
125
132
|
</div>
|
|
126
133
|
|
|
127
|
-
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-
|
|
134
|
+
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-5">
|
|
128
135
|
<div className="rounded-xl border border-gray-200 bg-gray-50/60 p-4">
|
|
129
136
|
<p className="text-xs font-medium uppercase tracking-[0.08em] text-gray-500">{t('desktopUpdatesLauncherVersion')}</p>
|
|
130
137
|
<p className="mt-2 text-base font-semibold text-gray-900">{formatVersion(snapshot.launcherVersion)}</p>
|
|
@@ -141,8 +148,19 @@ export function DesktopUpdateConfig() {
|
|
|
141
148
|
<p className="text-xs font-medium uppercase tracking-[0.08em] text-gray-500">{t('desktopUpdatesLastCheckedAt')}</p>
|
|
142
149
|
<p className="mt-2 text-base font-semibold text-gray-900">{formatLastCheckedAt(snapshot.lastCheckedAt)}</p>
|
|
143
150
|
</div>
|
|
151
|
+
<div className="rounded-xl border border-gray-200 bg-gray-50/60 p-4">
|
|
152
|
+
<p className="text-xs font-medium uppercase tracking-[0.08em] text-gray-500">{t('desktopUpdatesCurrentChannel')}</p>
|
|
153
|
+
<p className="mt-2 text-base font-semibold text-gray-900">{getChannelLabel(snapshot.channel)}</p>
|
|
154
|
+
</div>
|
|
144
155
|
</div>
|
|
145
156
|
|
|
157
|
+
{snapshot.channel === 'beta' ? (
|
|
158
|
+
<div className="rounded-2xl border border-amber-200 bg-amber-50/70 p-4">
|
|
159
|
+
<p className="text-sm font-semibold text-amber-800">{t('desktopUpdatesBetaBadgeTitle')}</p>
|
|
160
|
+
<p className="mt-1 text-sm text-amber-700">{t('desktopUpdatesBetaBadgeDescription')}</p>
|
|
161
|
+
</div>
|
|
162
|
+
) : null}
|
|
163
|
+
|
|
146
164
|
{snapshot.downloadedVersion ? (
|
|
147
165
|
<div className="rounded-2xl border border-emerald-200 bg-emerald-50/70 p-4">
|
|
148
166
|
<p className="text-sm font-semibold text-emerald-800">{t('desktopUpdatesDownloadedBannerTitle')}</p>
|
|
@@ -166,6 +184,29 @@ export function DesktopUpdateConfig() {
|
|
|
166
184
|
<CardDescription>{t('desktopUpdatesPreferencesDescription')}</CardDescription>
|
|
167
185
|
</CardHeader>
|
|
168
186
|
<CardContent className="space-y-5">
|
|
187
|
+
<div className="rounded-xl border border-gray-200 p-4">
|
|
188
|
+
<div className="space-y-3">
|
|
189
|
+
<div className="space-y-1">
|
|
190
|
+
<Label>{t('desktopUpdatesReleaseChannel')}</Label>
|
|
191
|
+
<p className="text-sm text-gray-500">{t('desktopUpdatesReleaseChannelHelp')}</p>
|
|
192
|
+
</div>
|
|
193
|
+
<Select
|
|
194
|
+
value={snapshot.channel}
|
|
195
|
+
disabled={isSwitchingChannel || isChecking || isDownloading || isApplying}
|
|
196
|
+
onValueChange={(value) => void desktopUpdateManager.updateChannel(value as DesktopReleaseChannel)}
|
|
197
|
+
>
|
|
198
|
+
<SelectTrigger className="w-full max-w-sm">
|
|
199
|
+
<SelectValue placeholder={t('desktopUpdatesReleaseChannel')} />
|
|
200
|
+
</SelectTrigger>
|
|
201
|
+
<SelectContent>
|
|
202
|
+
<SelectItem value="stable">{t('desktopUpdatesChannelStable')}</SelectItem>
|
|
203
|
+
<SelectItem value="beta">{t('desktopUpdatesChannelBeta')}</SelectItem>
|
|
204
|
+
</SelectContent>
|
|
205
|
+
</Select>
|
|
206
|
+
<p className="text-sm text-gray-500">{t('desktopUpdatesReleaseChannelDowngradeHint')}</p>
|
|
207
|
+
</div>
|
|
208
|
+
</div>
|
|
209
|
+
|
|
169
210
|
<div className="flex items-start justify-between gap-4 rounded-xl border border-gray-200 p-4">
|
|
170
211
|
<div className="space-y-1">
|
|
171
212
|
<Label>{t('desktopUpdatesAutomaticChecks')}</Label>
|
|
@@ -173,7 +214,7 @@ export function DesktopUpdateConfig() {
|
|
|
173
214
|
</div>
|
|
174
215
|
<Switch
|
|
175
216
|
checked={snapshot.preferences.automaticChecks}
|
|
176
|
-
disabled={isSavingPreferences}
|
|
217
|
+
disabled={isSavingPreferences || isSwitchingChannel}
|
|
177
218
|
onCheckedChange={(checked) => void desktopUpdateManager.updatePreferences({ automaticChecks: checked })}
|
|
178
219
|
/>
|
|
179
220
|
</div>
|
|
@@ -185,7 +226,7 @@ export function DesktopUpdateConfig() {
|
|
|
185
226
|
</div>
|
|
186
227
|
<Switch
|
|
187
228
|
checked={snapshot.preferences.autoDownload}
|
|
188
|
-
disabled={isSavingPreferences}
|
|
229
|
+
disabled={isSavingPreferences || isSwitchingChannel}
|
|
189
230
|
onCheckedChange={(checked) => void desktopUpdateManager.updatePreferences({ autoDownload: checked })}
|
|
190
231
|
/>
|
|
191
232
|
</div>
|