@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.
Files changed (115) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/dist/assets/{ChannelsList-D8p4OlM6.js → ChannelsList-KIQIxluX.js} +1 -1
  3. package/dist/assets/{DocBrowser-Cse_F8Nn.js → DocBrowser-BMxf9CIK.js} +1 -1
  4. package/dist/assets/DocBrowser-CyDgAtO9.js +1 -0
  5. package/dist/assets/{DocBrowserContext-Bai1WU2H.js → DocBrowserContext-Ce28gRXt.js} +1 -1
  6. package/dist/assets/{LogoBadge-BdxMPc9v.js → LogoBadge-o92MOA2L.js} +1 -1
  7. package/dist/assets/{MarketplacePage-BbpAkllU.js → MarketplacePage-BySqkYDh.js} +1 -1
  8. package/dist/assets/MarketplacePage-C0olZaek.js +1 -0
  9. package/dist/assets/{McpMarketplacePage-CxPFOgxv.js → McpMarketplacePage-DqKaiXO9.js} +1 -1
  10. package/dist/assets/{ModelConfig-3GLqQ5GY.js → ModelConfig-IrmzoslW.js} +1 -1
  11. package/dist/assets/{ProviderScopedModelInput-BYNouw-i.js → ProviderScopedModelInput-CmTIzgI7.js} +1 -1
  12. package/dist/assets/{ProvidersList-BR1gJ4Dm.js → ProvidersList-8_Kalfwl.js} +1 -1
  13. package/dist/assets/{RemoteAccessPage-DyYVWsyK.js → RemoteAccessPage-CyQlSjPf.js} +1 -1
  14. package/dist/assets/RuntimeConfig-Bk0uYBhf.js +1 -0
  15. package/dist/assets/{SearchConfig-DTeJvp8m.js → SearchConfig-DNBR-UbE.js} +1 -1
  16. package/dist/assets/{SecretsConfig-CCYO6NcV.js → SecretsConfig-Ba1RPJaG.js} +1 -1
  17. package/dist/assets/{SessionsConfig-Du39vDgt.js → SessionsConfig-Doqp5ghH.js} +1 -1
  18. package/dist/assets/{app-query-client-Dr5d-K8d.js → app-query-client-DniXoIN5.js} +1 -1
  19. package/dist/assets/{book-open-Da4OEPqB.js → book-open-DocgeQtR.js} +1 -1
  20. package/dist/assets/chat-page-Bph8M5zo.js +58 -0
  21. package/dist/assets/chat-session-display-CoN3Wmn-.js +1 -0
  22. package/dist/assets/{chunk-JZWAC4HX-CoFVxHXV.js → chunk-JZWAC4HX-BvKvh1R8.js} +1 -1
  23. package/dist/assets/{client-CSk58DcF.js → client-CVqPF5ie.js} +1 -1
  24. package/dist/assets/{config-D8KzikVB.js → config-Bop2oB18.js} +1 -1
  25. package/dist/assets/{createLucideIcon-83gaZMtv.js → createLucideIcon-DVv8taGY.js} +1 -1
  26. package/dist/assets/desktop-update-config-1KBrqLBC.js +1 -0
  27. package/dist/assets/{dist-toEYs-MZ.js → dist-Da5Gm_pO.js} +1 -1
  28. package/dist/assets/{dist-aTmhMDVh.js → dist-DmAlInRu.js} +1 -1
  29. package/dist/assets/{external-link-QQ0TC6X4.js → external-link-DFjw3x1B.js} +1 -1
  30. package/dist/assets/{hash-DaFBEkmi.js → hash-DJtaCejM.js} +1 -1
  31. package/dist/assets/i18n-CwHZ-9vt.js +1 -0
  32. package/dist/assets/{index-CE4N7ItL.css → index-DafCdM4F.css} +1 -1
  33. package/dist/assets/{index-riX7Sg0_.js → index-DdksE6U3.js} +3 -3
  34. package/dist/assets/{infiniteQueryBehavior-BmHX_ayZ.js → infiniteQueryBehavior-DHSEQ3OH.js} +1 -1
  35. package/dist/assets/loader-circle-PsSP0H9n.js +1 -0
  36. package/dist/assets/{logos-Dzlz30M3.js → logos-DEFUIR12.js} +1 -1
  37. package/dist/assets/{page-layout-D2eRufRQ.js → page-layout-Da3i3r6G.js} +1 -1
  38. package/dist/assets/play-DBQbBxTA.js +1 -0
  39. package/dist/assets/plus-DUOVbsyQ.js +1 -0
  40. package/dist/assets/{popover-BSXxm5bj.js → popover-C_mWOFzI.js} +1 -1
  41. package/dist/assets/{refresh-ccw-B3zMtN-_.js → refresh-ccw-D6HkNtfz.js} +1 -1
  42. package/dist/assets/{refresh-cw-DlZkIHnJ.js → refresh-cw-DRcvRrnc.js} +1 -1
  43. package/dist/assets/rotate-cw-BmDKfXtH.js +1 -0
  44. package/dist/assets/{save-Us9fg4Sj.js → save-DHGmi2e9.js} +1 -1
  45. package/dist/assets/search-MChQRYR1.js +1 -0
  46. package/dist/assets/{security-config-BGWYwxNr.js → security-config-CbXfPZzr.js} +1 -1
  47. package/dist/assets/{select-DLYqySQK.js → select-Caud8QvU.js} +1 -1
  48. package/dist/assets/skeleton-B-4vRq_Z.js +1 -0
  49. package/dist/assets/{status-dot-DGayudyB.js → status-dot-DurKKSwA.js} +1 -1
  50. package/dist/assets/{switch-Dz2ScsKx.js → switch-0rmPBRKI.js} +1 -1
  51. package/dist/assets/{tabs-custom-CdKyjiGk.js → tabs-custom-5JLVL6v8.js} +1 -1
  52. package/dist/assets/{trash-2-Db-mZOZs.js → trash-2-C6caKPoz.js} +1 -1
  53. package/dist/assets/{use-infinite-scroll-loader-DBJX5hj0.js → use-infinite-scroll-loader-dwnaa_qi.js} +1 -1
  54. package/dist/assets/{useConfirmDialog-DL0a-oGC.js → useConfirmDialog-mMeWD_yo.js} +1 -1
  55. package/dist/assets/{useMutation-BdZm-9PL.js → useMutation-BmxxvCNf.js} +1 -1
  56. package/dist/assets/x-DuMhMATD.js +1 -0
  57. package/dist/index.html +20 -20
  58. package/package.json +6 -6
  59. package/src/api/runtime-control.ts +34 -0
  60. package/src/api/runtime-control.types.ts +58 -0
  61. package/src/api/types.ts +13 -0
  62. package/src/{App.test.tsx → app.test.tsx} +1 -1
  63. package/src/{App.tsx → app.tsx} +1 -1
  64. package/src/components/chat/ChatConversationPanel.test.tsx +78 -16
  65. package/src/components/chat/ChatSidebar.test.tsx +36 -7
  66. package/src/components/chat/ChatSidebar.tsx +19 -26
  67. package/src/components/chat/chat-child-session-panel.tsx +16 -8
  68. package/src/components/chat/chat-page-runtime.test.ts +1 -1
  69. package/src/components/chat/{ChatPage.tsx → chat-page.tsx} +1 -1
  70. package/src/components/chat/managers/chat-session-list.manager.test.ts +82 -31
  71. package/src/components/chat/managers/chat-session-list.manager.ts +79 -14
  72. package/src/components/chat/managers/chat-ui.manager.ts +2 -0
  73. package/src/components/chat/ncp/README.md +1 -1
  74. package/src/components/chat/ncp/ncp-chat-input.manager.ts +7 -1
  75. package/src/components/chat/ncp/ncp-chat-page-data.test.ts +1 -1
  76. package/src/components/chat/ncp/ncp-session-adapter.test.ts +5 -1
  77. package/src/components/chat/ncp/ncp-session-adapter.ts +12 -0
  78. package/src/components/chat/ncp/session-conversation/use-ncp-child-session-tabs-view.ts +4 -0
  79. package/src/components/chat/ncp/tests/ncp-chat-input.manager.test.ts +99 -0
  80. package/src/components/chat/stores/chat-session-list.store.ts +25 -54
  81. package/src/components/common/ProviderScopedModelInput.tsx +12 -2
  82. package/src/components/config/ModelConfig.test.tsx +108 -2
  83. package/src/components/config/RuntimeConfig.tsx +14 -6
  84. package/src/components/config/desktop-update-config.test.tsx +85 -0
  85. package/src/components/config/desktop-update-config.tsx +44 -3
  86. package/src/components/config/runtime-control-card.test.tsx +255 -0
  87. package/src/components/config/runtime-control-card.tsx +301 -0
  88. package/src/components/config/runtime-presence-card.test.tsx +154 -0
  89. package/src/components/config/runtime-presence-card.tsx +163 -0
  90. package/src/desktop/desktop-update.types.ts +25 -0
  91. package/src/desktop/managers/desktop-presence.manager.ts +91 -0
  92. package/src/desktop/managers/desktop-update.manager.ts +37 -1
  93. package/src/desktop/stores/desktop-presence.store.ts +18 -0
  94. package/src/desktop/stores/desktop-update.store.ts +7 -1
  95. package/src/hooks/use-runtime-control.ts +24 -0
  96. package/src/lib/desktop-update-labels.utils.ts +28 -2
  97. package/src/lib/i18n.runtime-control.ts +120 -0
  98. package/src/lib/i18n.ts +2 -4
  99. package/src/main.tsx +1 -1
  100. package/src/runtime-control/runtime-control.manager.ts +118 -0
  101. package/dist/assets/ChatPage-A45t1Rmf.js +0 -58
  102. package/dist/assets/DocBrowser-B2MpsnU9.js +0 -1
  103. package/dist/assets/MarketplacePage-BNZ3Jx5d.js +0 -1
  104. package/dist/assets/RuntimeConfig-ChdfK4Y_.js +0 -1
  105. package/dist/assets/chat-session-display-CAlPrnlV.js +0 -1
  106. package/dist/assets/desktop-update-config-CfoVwf-w.js +0 -1
  107. package/dist/assets/i18n-C3jb83S6.js +0 -1
  108. package/dist/assets/loader-circle-BjMg63eu.js +0 -1
  109. package/dist/assets/plus-CIXME2pD.js +0 -1
  110. package/dist/assets/search-B_Qr0f6C.js +0 -1
  111. package/dist/assets/skeleton-CYQJazv6.js +0 -1
  112. package/dist/assets/x-B8Tho_xC.js +0 -1
  113. /package/dist/assets/{config-hints-GSUMvmSo.js → config-hints-BZoDjXye.js} +0 -0
  114. /package/dist/assets/{config-layout-CgBMG7OL.js → config-layout-DmlGaay2.js} +0 -0
  115. /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
- updatedAt: string | null | undefined,
17
- readUpdatedAt: string | undefined,
16
+ lastMessageAt: string | null | undefined,
17
+ readAt: string | undefined,
18
18
  ): boolean {
19
- const normalizedUpdatedAt = updatedAt?.trim();
20
- if (!normalizedUpdatedAt) {
19
+ const normalizedLastMessageAt = lastMessageAt?.trim();
20
+ if (!normalizedLastMessageAt) {
21
21
  return false;
22
22
  }
23
- const normalizedReadUpdatedAt = readUpdatedAt?.trim();
24
- if (!normalizedReadUpdatedAt) {
25
- return true;
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 normalizedUpdatedAt.localeCompare(normalizedReadUpdatedAt) > 0;
28
+ return normalizedLastMessageAt.localeCompare(normalizedReadAt) > 0;
28
29
  }
29
30
 
30
31
  export function shouldShowUnreadSessionIndicator(params: {
31
32
  active: boolean;
32
- updatedAt: string | null | undefined;
33
- readUpdatedAt: string | undefined;
33
+ lastMessageAt: string | null | undefined;
34
+ readAt: string | undefined;
34
35
  runStatus?: SessionRunStatus;
35
36
  }): boolean {
36
- const { active, readUpdatedAt, runStatus, updatedAt } = params;
37
+ const { active, readAt, runStatus, lastMessageAt } = params;
37
38
  if (active || runStatus === 'running') {
38
39
  return false;
39
40
  }
40
- return hasUnreadSessionUpdate(updatedAt, readUpdatedAt);
41
+ return hasUnreadSessionUpdate(lastMessageAt, readAt);
41
42
  }
42
43
 
43
44
  type ChatSessionListStore = {
44
45
  snapshot: ChatSessionListSnapshot;
45
- readUpdatedAtBySessionKey: Record<string, string>;
46
- hasHydratedReadWatermarks: boolean;
46
+ optimisticReadAtBySessionKey: Record<string, string>;
47
47
  setSnapshot: (patch: Partial<ChatSessionListSnapshot>) => void;
48
- markSessionRead: (sessionKey: string, updatedAt: string | null | undefined) => void;
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, updatedAt: string | null | undefined) =>
72
+ return (sessionKey: string, readAt: string | null | undefined) =>
76
73
  set((state) => {
77
74
  const normalizedSessionKey = sessionKey.trim();
78
- const normalizedUpdatedAt = updatedAt?.trim();
79
- if (!normalizedSessionKey || !normalizedUpdatedAt) {
75
+ const normalizedReadAt = readAt?.trim();
76
+ if (!normalizedSessionKey || !normalizedReadAt) {
80
77
  return state;
81
78
  }
82
- if (state.readUpdatedAtBySessionKey[normalizedSessionKey] === normalizedUpdatedAt) {
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
- readUpdatedAtBySessionKey: {
88
- ...state.readUpdatedAtBySessionKey,
89
- [normalizedSessionKey]: normalizedUpdatedAt
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
- readUpdatedAtBySessionKey: {},
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
- onChange('');
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
- <PageHeader title={t('runtimePageTitle')} description={t('runtimePageDescription')} />
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-4">
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>