@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.
Files changed (80) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/dist/assets/ChannelsList-C7F_As4r.js +1 -0
  3. package/dist/assets/ChatPage-Oo7-OUsx.js +37 -0
  4. package/dist/assets/{DocBrowser-B9ws5JL7.js → DocBrowser-Dsd8Dlq8.js} +1 -1
  5. package/dist/assets/{LogoBadge-DvGAzkZ3.js → LogoBadge-2ChEc_oz.js} +1 -1
  6. package/dist/assets/MarketplacePage-BXck6-X3.js +49 -0
  7. package/dist/assets/{ModelConfig-BL_HsOsm.js → ModelConfig-CgHRSD0b.js} +1 -1
  8. package/dist/assets/ProvidersList-PPfZucvS.js +1 -0
  9. package/dist/assets/RuntimeConfig-ClLEKNTN.js +1 -0
  10. package/dist/assets/{SearchConfig-BhaI0fUf.js → SearchConfig-CuXVCbrf.js} +1 -1
  11. package/dist/assets/{SecretsConfig-CFoimOh9.js → SecretsConfig-udJz6Ake.js} +2 -2
  12. package/dist/assets/SessionsConfig-C1XnFfiC.js +2 -0
  13. package/dist/assets/{session-run-status-TkIuGbVw.js → chat-message-BETwXLD4.js} +3 -3
  14. package/dist/assets/{index-uMsNsQX6.js → index-COJdlL0e.js} +1 -1
  15. package/dist/assets/index-CsvP4CER.js +8 -0
  16. package/dist/assets/index-D-bXl7qL.css +1 -0
  17. package/dist/assets/{label-D8ly4a2P.js → label-BGL-ztxh.js} +1 -1
  18. package/dist/assets/{page-layout-BSYfvwbp.js → page-layout-aw88k7tG.js} +1 -1
  19. package/dist/assets/popover-DyEvzhmV.js +1 -0
  20. package/dist/assets/security-config-BuPAQn82.js +1 -0
  21. package/dist/assets/skeleton-drzO_tdU.js +1 -0
  22. package/dist/assets/{switch-Ce_g9lpN.js → switch-BK8jIzto.js} +1 -1
  23. package/dist/assets/{tabs-custom-Cf5azvT5.js → tabs-custom-Da3cEOji.js} +1 -1
  24. package/dist/assets/{useConfirmDialog-A8Ek8Wu7.js → useConfirmDialog-z0CE92iS.js} +2 -2
  25. package/dist/assets/{vendor-B7ozqnFC.js → vendor-CkJHmX1g.js} +65 -70
  26. package/dist/index.html +3 -3
  27. package/package.json +5 -2
  28. package/src/api/config.ts +9 -0
  29. package/src/api/ncp-session.ts +50 -0
  30. package/src/api/types.ts +20 -0
  31. package/src/components/chat/ChatConversationPanel.test.tsx +65 -0
  32. package/src/components/chat/ChatConversationPanel.tsx +21 -12
  33. package/src/components/chat/ChatPage.tsx +10 -324
  34. package/src/components/chat/ChatSidebar.test.tsx +203 -0
  35. package/src/components/chat/ChatSidebar.tsx +97 -7
  36. package/src/components/chat/adapters/chat-message.adapter.test.ts +132 -81
  37. package/src/components/chat/adapters/chat-message.adapter.ts +27 -9
  38. package/src/components/chat/chat-chain.test.ts +22 -0
  39. package/src/components/chat/chat-chain.ts +23 -0
  40. package/src/components/chat/chat-page-data.ts +30 -1
  41. package/src/components/chat/chat-page-runtime.test.ts +181 -0
  42. package/src/components/chat/chat-page-runtime.ts +101 -15
  43. package/src/components/chat/chat-page-shell.tsx +103 -0
  44. package/src/components/chat/chat-session-preference-sync.test.ts +62 -0
  45. package/src/components/chat/chat-session-preference-sync.ts +75 -0
  46. package/src/components/chat/containers/chat-input-bar.container.tsx +0 -22
  47. package/src/components/chat/containers/chat-message-list.container.tsx +34 -26
  48. package/src/components/chat/legacy/LegacyChatPage.tsx +252 -0
  49. package/src/components/chat/managers/chat-input.manager.ts +5 -0
  50. package/src/components/chat/managers/chat-session-list.manager.test.ts +39 -0
  51. package/src/components/chat/managers/chat-session-list.manager.ts +9 -3
  52. package/src/components/chat/ncp/NcpChatPage.tsx +381 -0
  53. package/src/components/chat/ncp/ncp-chat-input.manager.ts +179 -0
  54. package/src/components/chat/ncp/ncp-chat-page-data.ts +166 -0
  55. package/src/components/chat/ncp/ncp-chat-thread.manager.ts +89 -0
  56. package/src/components/chat/ncp/ncp-chat.presenter.ts +33 -0
  57. package/src/components/chat/ncp/ncp-session-adapter.test.ts +75 -0
  58. package/src/components/chat/ncp/ncp-session-adapter.ts +214 -0
  59. package/src/components/chat/presenter/chat-presenter-context.tsx +43 -4
  60. package/src/components/chat/stores/chat-thread.store.ts +2 -0
  61. package/src/components/chat/useChatSessionTypeState.test.tsx +58 -0
  62. package/src/components/chat/useChatSessionTypeState.ts +25 -8
  63. package/src/hooks/use-ncp-chat-session-types.ts +11 -0
  64. package/src/hooks/useConfig.ts +41 -1
  65. package/src/hooks/useMarketplace.ts +7 -4
  66. package/src/hooks/useWebSocket.ts +23 -2
  67. package/src/lib/i18n.ts +1 -1
  68. package/tailwind.config.js +8 -3
  69. package/tsconfig.json +4 -1
  70. package/dist/assets/ChannelsList-DF2U-LY1.js +0 -1
  71. package/dist/assets/ChatPage-BX39y0U5.js +0 -36
  72. package/dist/assets/MarketplacePage-DG5mHWJ8.js +0 -49
  73. package/dist/assets/ProvidersList-CH5z00YT.js +0 -1
  74. package/dist/assets/RuntimeConfig-BplBgkwo.js +0 -1
  75. package/dist/assets/SessionsConfig-BHTAYn9T.js +0 -2
  76. package/dist/assets/index-BLeJkJ0o.css +0 -1
  77. package/dist/assets/index-DK4TS5ev.js +0 -8
  78. package/dist/assets/index-X5J6Mm--.js +0 -1
  79. package/dist/assets/security-config-DlKEYHNN.js +0 -1
  80. 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(role: string, texts: ChatMessageAdapterTexts['roleLabels']): string {
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(toolCard: ToolCard, texts: ChatMessageAdapterTexts): ChatToolPartViewModel {
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.trim();
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.trim();
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 = typeof invocation.error === 'string' && invocation.error.trim()
172
- ? invocation.error.trim()
173
- : invocation.result != null
174
- ? stringifyUnknown(invocation.result).trim()
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 { useSyncSelectedModel } from '@/components/chat/chat-page-runtime';
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 { modelOptions, selectedSessionPreferredModel, defaultModel, setSelectedModel } = params;
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
- if (modelOptions.some((option) => option.value === prev)) {
24
- return prev;
25
- }
26
- const sessionPreferred = selectedSessionPreferredModel?.trim();
27
- if (sessionPreferred && modelOptions.some((option) => option.value === sessionPreferred)) {
28
- return sessionPreferred;
29
- }
30
- const fallback = defaultModel?.trim();
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
- }, [defaultModel, modelOptions, selectedSessionPreferredModel, setSelectedModel]);
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
+ });