@nextclaw/ui 0.14.0 → 0.14.1-beta.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 (130) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/dist/assets/{api-B50yVdT8.js → api-C6EDHBDR.js} +1 -1
  3. package/dist/assets/channels-list-page-DKj6m1Hc.js +8 -0
  4. package/dist/assets/chat-page-BXngLJY6.js +105 -0
  5. package/dist/assets/config-split-page-CjlmJy8X.js +1 -0
  6. package/dist/assets/{confirm-dialog-DRSelLdI.js → confirm-dialog-B39X0pYp.js} +1 -1
  7. package/dist/assets/desktop-update-config-DfUQ8nly.js +1 -0
  8. package/dist/assets/{dist-NZRV-BxD.js → dist-BIYV2-Ia.js} +1 -1
  9. package/dist/assets/{dist-D19wiQwB.js → dist-CmMro3nV.js} +1 -1
  10. package/dist/assets/doc-browser-B6Wc_TIY.js +1 -0
  11. package/dist/assets/doc-browser-CDtUtcim.js +1 -0
  12. package/dist/assets/{doc-browser-context-C8KPbj-p.js → doc-browser-context-DZbC9zVB.js} +1 -1
  13. package/dist/assets/{ellipsis-BYy8TP0A.js → ellipsis-DDpgTzgx.js} +1 -1
  14. package/dist/assets/{external-link-528k6MDD.js → external-link-D2cBOCqn.js} +1 -1
  15. package/dist/assets/index-DTb_XVIH.js +103 -0
  16. package/dist/assets/index-ayiPYhf1.css +1 -0
  17. package/dist/assets/mcp-marketplace-page-DZ9H0v05.js +1 -0
  18. package/dist/assets/mcp-marketplace-page-cnVWf7QQ.js +40 -0
  19. package/dist/assets/model-config-page-Bcb35AVv.js +1 -0
  20. package/dist/assets/plus-CYE1onU2.js +1 -0
  21. package/dist/assets/provider-scoped-model-input-kdoNp4HG.js +1 -0
  22. package/dist/assets/providers-config-page-CQSibSwO.js +1 -0
  23. package/dist/assets/{react-Dsotw5vM.js → react-DYQXXGbH.js} +1 -1
  24. package/dist/assets/remote-BlJDbXOI.js +1 -0
  25. package/dist/assets/rotate-cw-BWbvFDAX.js +1 -0
  26. package/dist/assets/runtime-config-page-Tn2yLQ0c.js +1 -0
  27. package/dist/assets/{save-CHDnDTok.js → save-BNVTgBVn.js} +1 -1
  28. package/dist/assets/search-CKCzwLId.js +1 -0
  29. package/dist/assets/search-config-page-DG5Q2hF1.js +1 -0
  30. package/dist/assets/{secrets-config-page-95yaH1fa.js → secrets-config-page-BXn5Uuwp.js} +2 -2
  31. package/dist/assets/select-CIRwTNP4.js +41 -0
  32. package/dist/assets/{tag-chip-Cr8GwpRE.js → tag-chip-DKhVFtTE.js} +1 -1
  33. package/dist/assets/{use-config-TzwzTgg0.js → use-config-DgKM9VIU.js} +1 -1
  34. package/dist/assets/x-Cm3JjYut.js +1 -0
  35. package/dist/index.html +14 -27
  36. package/package.json +9 -9
  37. package/src/app/presenters/app.presenter.ts +2 -0
  38. package/src/features/account/components/account-panel.tsx +2 -2
  39. package/src/features/chat/components/conversation/chat-conversation-header-section.tsx +1 -0
  40. package/src/features/chat/features/input/components/chat-input-bar.container.tsx +39 -80
  41. package/src/features/chat/features/input/hooks/use-chat-input-bar-query-state.ts +118 -0
  42. package/src/features/chat/features/input/utils/__tests__/chat-input-bar.utils.test.ts +2 -17
  43. package/src/features/chat/features/input/utils/chat-input-bar.utils.ts +1 -24
  44. package/src/features/chat/features/input/utils/chat-input-toolbar.utils.ts +0 -27
  45. package/src/features/chat/features/message/components/__tests__/chat-inline-panel-app-card.test.tsx +89 -0
  46. package/src/features/chat/features/message/components/__tests__/chat-message-list.container.test.tsx +9 -1
  47. package/src/features/chat/features/message/components/chat-inline-panel-app-card.tsx +115 -0
  48. package/src/features/chat/features/message/components/chat-message-list.container.tsx +4 -0
  49. package/src/features/chat/features/message/utils/__tests__/chat-message-show-content-tool-card.utils.test.ts +119 -1
  50. package/src/features/chat/features/message/utils/chat-inline-panel-app-card.utils.ts +51 -0
  51. package/src/features/chat/features/message/utils/chat-message-show-content-tool-card.utils.ts +28 -5
  52. package/src/features/chat/features/message/utils/chat-message-tool-card.utils.ts +4 -0
  53. package/src/features/chat/features/ncp/hooks/__tests__/use-ui-show-content-event.test.tsx +1 -0
  54. package/src/features/chat/features/ncp/utils/ncp-chat-query-derived.utils.ts +11 -3
  55. package/src/features/chat/features/session/components/session-header/__tests__/chat-session-header-actions.test.tsx +27 -0
  56. package/src/features/chat/features/session/components/session-header/chat-session-header-actions.tsx +19 -1
  57. package/src/features/chat/features/session/components/session-header/chat-session-metadata-dialog.tsx +46 -0
  58. package/src/features/chat/features/session/hooks/__tests__/use-selected-session-context-window-indicator.test.tsx +63 -0
  59. package/src/features/chat/features/session/hooks/use-selected-session-context-window-indicator.ts +7 -10
  60. package/src/features/chat/features/session/utils/__tests__/ncp-session-adapter.utils.test.ts +8 -0
  61. package/src/features/chat/features/session/utils/ncp-session-adapter.utils.ts +2 -0
  62. package/src/features/chat/features/session-type/hooks/use-chat-session-type-state.ts +1 -0
  63. package/src/features/chat/features/session-type/utils/chat-session-type.utils.ts +4 -0
  64. package/src/features/chat/index.ts +1 -0
  65. package/src/features/chat/managers/__tests__/chat-input.manager.test.ts +28 -0
  66. package/src/features/chat/managers/__tests__/chat-run-snapshot.manager.test.ts +129 -0
  67. package/src/features/chat/managers/__tests__/chat-run.manager.test.ts +34 -0
  68. package/src/features/chat/managers/__tests__/chat-session-preference-sync.manager.test.ts +143 -1
  69. package/src/features/chat/managers/__tests__/chat-thread.manager.test.ts +30 -0
  70. package/src/features/chat/managers/__tests__/recent-selection.manager.test.ts +37 -14
  71. package/src/features/chat/managers/chat-draft-intent.manager.ts +46 -0
  72. package/src/features/chat/managers/chat-input.manager.ts +13 -13
  73. package/src/features/chat/managers/chat-run.manager.ts +3 -0
  74. package/src/features/chat/managers/chat-session-preference-sync.manager.ts +19 -2
  75. package/src/features/chat/managers/chat-thread.manager.ts +4 -0
  76. package/src/features/chat/managers/chat-ui.manager.ts +2 -6
  77. package/src/features/chat/managers/recent-selection.manager.ts +21 -67
  78. package/src/features/chat/pages/__tests__/ncp-chat-page.test.ts +47 -1
  79. package/src/features/chat/pages/ncp-chat-page.tsx +18 -0
  80. package/src/features/chat/stores/chat-input.store.ts +7 -1
  81. package/src/features/chat/stores/recent-selection.store.ts +85 -0
  82. package/src/features/chat/types/chat-input-bar.types.ts +0 -1
  83. package/src/features/panel-apps/components/__tests__/panel-apps-list.test.tsx +137 -0
  84. package/src/features/panel-apps/components/panel-apps-list.tsx +119 -5
  85. package/src/features/panel-apps/index.ts +2 -0
  86. package/src/features/panel-apps/utils/panel-app-entry-match.utils.ts +17 -0
  87. package/src/shared/lib/api/chat-session-type.types.ts +7 -0
  88. package/src/shared/lib/api/ncp-session.types.ts +1 -0
  89. package/src/shared/lib/api/types.ts +3 -0
  90. package/src/shared/lib/i18n/locales/en-US/chat.json +4 -0
  91. package/src/shared/lib/i18n/locales/en-US/doc-browser.json +10 -0
  92. package/src/shared/lib/i18n/locales/zh-CN/chat.json +4 -0
  93. package/src/shared/lib/i18n/locales/zh-CN/doc-browser.json +10 -0
  94. package/dist/assets/app-presenter-provider-BI-ewLbO.js +0 -3
  95. package/dist/assets/arrow-left-BOCJyLbQ.js +0 -1
  96. package/dist/assets/book-open-Bd2Xbrrc.js +0 -1
  97. package/dist/assets/channels-list-page-moJkpxtU.js +0 -8
  98. package/dist/assets/chat-page-CO27cl4v.js +0 -105
  99. package/dist/assets/config-split-page-CsQ2rIdq.js +0 -1
  100. package/dist/assets/desktop-update-config-qa-SynOc.js +0 -1
  101. package/dist/assets/doc-browser-2ddUJCA0.js +0 -1
  102. package/dist/assets/doc-browser-BjHUczQb.js +0 -1
  103. package/dist/assets/es2015-BUIIKCNh.js +0 -41
  104. package/dist/assets/host-capabilities-9bB_998Z.js +0 -1
  105. package/dist/assets/index-CMNrRhnG.js +0 -101
  106. package/dist/assets/index-UTmTW1eU.css +0 -1
  107. package/dist/assets/key-round-D3vD9dj7.js +0 -1
  108. package/dist/assets/loader-circle-DV9TSRns.js +0 -1
  109. package/dist/assets/mcp-marketplace-page-CS5Lh3oA.js +0 -1
  110. package/dist/assets/mcp-marketplace-page-DEOMcWb0.js +0 -40
  111. package/dist/assets/model-config-page-BSc4o7pb.js +0 -1
  112. package/dist/assets/notice-card-qqlbsOoR.js +0 -1
  113. package/dist/assets/plus-Bf9qhPDO.js +0 -1
  114. package/dist/assets/popover-BnmPOXe3.js +0 -1
  115. package/dist/assets/provider-scoped-model-input-Ca56s475.js +0 -1
  116. package/dist/assets/providers-config-page-D8Kf4U3u.js +0 -1
  117. package/dist/assets/refresh-cw-Bmx4wc9P.js +0 -1
  118. package/dist/assets/remote-DNMEy_c4.js +0 -1
  119. package/dist/assets/rotate-cw-CL0sVBel.js +0 -1
  120. package/dist/assets/runtime-config-page-CUFtKr0U.js +0 -1
  121. package/dist/assets/search-D7L-TCH-.js +0 -1
  122. package/dist/assets/search-config-page-CA3RVAef.js +0 -1
  123. package/dist/assets/status-dot-Dm4h-bjn.js +0 -1
  124. package/dist/assets/tabs-CJvw5Lqp.js +0 -1
  125. package/dist/assets/tooltip-Bxzi6aDL.js +0 -1
  126. package/dist/assets/trash-2-CSfX-BvU.js +0 -1
  127. package/dist/assets/x-fkIWvi07.js +0 -1
  128. package/src/features/chat/features/input/hooks/__tests__/use-chat-input-bar-controller.test.tsx +0 -152
  129. package/src/features/chat/features/input/hooks/use-chat-input-bar-controller.ts +0 -142
  130. /package/dist/assets/{config-hints-Cl2XAhzV.js → config-hints-CTqBnCDp.js} +0 -0
@@ -1,4 +1,5 @@
1
1
  import type { NcpMessage } from '@nextclaw/ncp';
2
+ import { RUNTIME_DEFAULT_MODEL_VALUE } from '@nextclaw/shared';
2
3
  import { beforeEach, describe, expect, it, vi } from 'vitest';
3
4
  import { ChatRunManager } from '@/features/chat/managers/chat-run.manager';
4
5
  import { useChatInputStore } from '@/features/chat/stores/chat-input.store';
@@ -75,6 +76,39 @@ describe('ChatRunManager', () => {
75
76
  expect(uiManager.goToSession).toHaveBeenCalledWith('materialized-session', { replace: true });
76
77
  });
77
78
 
79
+ it('keeps the runtime-default sentinel in request metadata to override stale session model metadata', async () => {
80
+ const { manager } = createChatRunManager();
81
+ const sendEnvelope = vi.fn(async () => ({
82
+ sessionId: 'session-1',
83
+ userMessageId: 'user-message-1',
84
+ assistantMessageId: null,
85
+ runId: 'run-1',
86
+ }));
87
+ manager.setActiveRuntime({
88
+ sessionKey: 'session-1',
89
+ sendEnvelope,
90
+ abortCurrentRun: vi.fn(),
91
+ resumeCurrentSessionRun: vi.fn(),
92
+ });
93
+
94
+ await manager.sendMessage({
95
+ message: 'hello',
96
+ sessionKey: 'session-1',
97
+ agentId: 'main',
98
+ sessionType: 'codex',
99
+ model: RUNTIME_DEFAULT_MODEL_VALUE,
100
+ });
101
+
102
+ expect(sendEnvelope).toHaveBeenCalledWith(
103
+ expect.objectContaining({
104
+ metadata: expect.objectContaining({
105
+ model: RUNTIME_DEFAULT_MODEL_VALUE,
106
+ preferred_model: RUNTIME_DEFAULT_MODEL_VALUE,
107
+ }),
108
+ }),
109
+ );
110
+ });
111
+
78
112
  it('does not send through a runtime for another session', async () => {
79
113
  const { manager } = createChatRunManager();
80
114
  const sendEnvelope = vi.fn();
@@ -1,6 +1,7 @@
1
1
  import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { RUNTIME_DEFAULT_MODEL_VALUE } from '@nextclaw/shared';
2
3
  import { updateNcpSession } from '@/shared/lib/api';
3
- import { ChatSessionPreferenceSync } from '../chat-session-preference-sync.manager';
4
+ import { ChatSessionPreferenceSync } from '@/features/chat/managers/chat-session-preference-sync.manager';
4
5
  import { useChatInputStore } from '@/features/chat/stores/chat-input.store';
5
6
  import { useChatSessionListStore } from '@/features/chat/stores/chat-session-list.store';
6
7
  import { useChatThreadStore } from '@/features/chat/stores/chat-thread.store';
@@ -25,6 +26,7 @@ describe('ChatSessionPreferenceSync', () => {
25
26
  useChatInputStore.setState((state) => ({
26
27
  snapshot: {
27
28
  ...state.snapshot,
29
+ modelOptions: [],
28
30
  selectedModel: '',
29
31
  selectedThinkingLevel: null
30
32
  }
@@ -74,4 +76,144 @@ describe('ChatSessionPreferenceSync', () => {
74
76
  });
75
77
  });
76
78
  });
79
+
80
+ it('clears the persisted model preference when runtime default is selected', async () => {
81
+ useChatInputStore.setState((state) => ({
82
+ snapshot: {
83
+ ...state.snapshot,
84
+ selectedModel: RUNTIME_DEFAULT_MODEL_VALUE,
85
+ selectedThinkingLevel: null
86
+ }
87
+ }));
88
+ useChatSessionListStore.setState((state) => ({
89
+ snapshot: {
90
+ ...state.snapshot,
91
+ selectedSessionKey: 'session-1'
92
+ }
93
+ }));
94
+ useChatThreadStore.setState((state) => ({
95
+ snapshot: {
96
+ ...state.snapshot,
97
+ canDeleteSession: true
98
+ }
99
+ }));
100
+
101
+ const sync = new ChatSessionPreferenceSync(updateNcpSession);
102
+ sync.syncSelectedSessionPreferences();
103
+ await vi.waitFor(() => {
104
+ expect(updateNcpSession).toHaveBeenCalledWith('session-1', {
105
+ preferredModel: null,
106
+ preferredThinking: null
107
+ });
108
+ });
109
+ });
110
+
111
+ it('uses the runtime-default thinking capability default when no session thinking is persisted', () => {
112
+ useChatInputStore.setState((state) => ({
113
+ snapshot: {
114
+ ...state.snapshot,
115
+ modelOptions: [
116
+ {
117
+ value: RUNTIME_DEFAULT_MODEL_VALUE,
118
+ modelLabel: 'Runtime default',
119
+ providerLabel: '',
120
+ isRuntimeDefault: true,
121
+ thinkingCapability: {
122
+ supported: ['off', 'minimal', 'low', 'medium', 'high', 'xhigh'],
123
+ default: 'high',
124
+ },
125
+ },
126
+ ],
127
+ selectedModel: '',
128
+ selectedThinkingLevel: null,
129
+ }
130
+ }));
131
+
132
+ const sync = new ChatSessionPreferenceSync(updateNcpSession);
133
+ sync.syncInputSelection({
134
+ selectedSessionExists: true,
135
+ defaultModel: RUNTIME_DEFAULT_MODEL_VALUE,
136
+ selectedSessionPreferredThinking: null,
137
+ fallbackPreferredThinking: null,
138
+ });
139
+
140
+ expect(useChatInputStore.getState().snapshot).toMatchObject({
141
+ selectedModel: RUNTIME_DEFAULT_MODEL_VALUE,
142
+ selectedThinkingLevel: 'high',
143
+ });
144
+ });
145
+
146
+ it('does not rewrite the input snapshot when the resolved selection is unchanged', () => {
147
+ useChatInputStore.setState((state) => ({
148
+ snapshot: {
149
+ ...state.snapshot,
150
+ modelOptions: [
151
+ {
152
+ value: 'openai/gpt-5',
153
+ modelLabel: 'GPT-5',
154
+ providerLabel: 'OpenAI',
155
+ thinkingCapability: null,
156
+ },
157
+ ],
158
+ selectedModel: 'openai/gpt-5',
159
+ selectedThinkingLevel: null,
160
+ }
161
+ }));
162
+ const sync = new ChatSessionPreferenceSync(updateNcpSession);
163
+ const listener = vi.fn();
164
+ const unsubscribe = useChatInputStore.subscribe(listener);
165
+
166
+ sync.syncInputSelection({
167
+ selectedSessionExists: true,
168
+ selectedSessionPreferredModel: 'openai/gpt-5',
169
+ selectedSessionPreferredThinking: null,
170
+ });
171
+
172
+ unsubscribe();
173
+ expect(listener).not.toHaveBeenCalled();
174
+ });
175
+
176
+ it('applies a historical session preferred model after its summary arrives', () => {
177
+ useChatInputStore.setState((state) => ({
178
+ snapshot: {
179
+ ...state.snapshot,
180
+ modelOptions: [
181
+ {
182
+ value: 'anthropic/claude-sonnet-4',
183
+ modelLabel: 'Claude Sonnet 4',
184
+ providerLabel: 'Anthropic',
185
+ thinkingCapability: null,
186
+ },
187
+ {
188
+ value: 'openai/gpt-5',
189
+ modelLabel: 'GPT-5',
190
+ providerLabel: 'OpenAI',
191
+ thinkingCapability: null,
192
+ },
193
+ ],
194
+ selectedModel: 'anthropic/claude-sonnet-4',
195
+ selectedThinkingLevel: null,
196
+ }
197
+ }));
198
+ const sync = new ChatSessionPreferenceSync(updateNcpSession);
199
+
200
+ sync.syncInputSelection({
201
+ selectedSessionKey: 'session-2',
202
+ selectedSessionExists: false,
203
+ fallbackPreferredModel: 'anthropic/claude-sonnet-4',
204
+ });
205
+ expect(useChatInputStore.getState().snapshot.selectedModel).toBe(
206
+ 'anthropic/claude-sonnet-4',
207
+ );
208
+
209
+ sync.syncInputSelection({
210
+ selectedSessionKey: 'session-2',
211
+ selectedSessionExists: true,
212
+ selectedSessionPreferredModel: 'openai/gpt-5',
213
+ selectedSessionPreferredThinking: null,
214
+ fallbackPreferredModel: 'anthropic/claude-sonnet-4',
215
+ });
216
+
217
+ expect(useChatInputStore.getState().snapshot.selectedModel).toBe('openai/gpt-5');
218
+ });
77
219
  });
@@ -483,6 +483,7 @@ describe('ChatThreadManager showContent', () => {
483
483
  },
484
484
  title: 'Example URL',
485
485
  purpose: 'read',
486
+ placement: undefined,
486
487
  });
487
488
  await manager.handleUiShowContentEvent({
488
489
  id: 'tool:call-show-content-1:show-content',
@@ -495,6 +496,7 @@ describe('ChatThreadManager showContent', () => {
495
496
  },
496
497
  title: 'Example URL',
497
498
  purpose: 'read',
499
+ placement: undefined,
498
500
  });
499
501
 
500
502
  expect(uiManager.showContent).toHaveBeenCalledTimes(1);
@@ -541,6 +543,34 @@ describe('ChatThreadManager showContent', () => {
541
543
  title: 'Reader',
542
544
  });
543
545
  });
546
+
547
+ });
548
+
549
+ describe('ChatThreadManager inline showContent', () => {
550
+ it('does not auto-open inline panel app content outside the message card', async () => {
551
+ const uiManager = createUiManager();
552
+ const manager = new ChatThreadManager(
553
+ uiManager,
554
+ {} as ConstructorParameters<typeof ChatThreadManager>[1],
555
+ {} as ConstructorParameters<typeof ChatThreadManager>[2],
556
+ );
557
+
558
+ await manager.handleUiShowContentEvent({
559
+ id: 'tool:call-show-content-inline:show-content',
560
+ toolCallId: 'call-show-content-inline',
561
+ target: {
562
+ type: 'panel_app',
563
+ payload: {
564
+ appId: 'reader',
565
+ },
566
+ },
567
+ title: 'Reader',
568
+ purpose: 'interact',
569
+ placement: 'inline',
570
+ });
571
+
572
+ expect(uiManager.showContent).not.toHaveBeenCalled();
573
+ });
544
574
  });
545
575
 
546
576
  describe('ChatThreadManager visible workspace selection', () => {
@@ -1,22 +1,16 @@
1
- import { RecentSelectionManager } from '../recent-selection.manager';
1
+ import { RecentSelectionManager } from '@/features/chat/managers/recent-selection.manager';
2
+ import { useRecentSelectionStore } from '@/features/chat/stores/recent-selection.store';
2
3
 
3
4
  describe('RecentSelectionManager', () => {
4
5
  const storageKey = 'test.recent-selection-manager';
5
- let storageState: Record<string, string>;
6
- let storage: Pick<Storage, 'getItem' | 'setItem'>;
7
6
 
8
7
  beforeEach(() => {
9
- storageState = {};
10
- storage = {
11
- getItem: (key) => storageState[key] ?? null,
12
- setItem: (key, value) => {
13
- storageState[key] = value;
14
- }
15
- };
8
+ window.localStorage.clear();
9
+ useRecentSelectionStore.setState({ entriesByKey: {} });
16
10
  });
17
11
 
18
12
  it('stores recent values in LRU order and respects the size limit', () => {
19
- const manager = new RecentSelectionManager({ storageKey, limit: 3, storage });
13
+ const manager = new RecentSelectionManager({ storageKey, limit: 3 });
20
14
 
21
15
  manager.remember('openai/gpt-5');
22
16
  manager.remember('anthropic/claude-sonnet-4');
@@ -31,7 +25,7 @@ describe('RecentSelectionManager', () => {
31
25
  });
32
26
 
33
27
  it('filters recent values by the currently available list and threshold', () => {
34
- const manager = new RecentSelectionManager({ storageKey, limit: 4, storage });
28
+ const manager = new RecentSelectionManager({ storageKey, limit: 4 });
35
29
  manager.remember('openai/gpt-5');
36
30
  manager.remember('anthropic/claude-sonnet-4');
37
31
  manager.remember('missing/model');
@@ -60,9 +54,38 @@ describe('RecentSelectionManager', () => {
60
54
  });
61
55
 
62
56
  it('returns an empty list when storage content is malformed', () => {
63
- storageState[storageKey] = '{broken-json';
64
- const manager = new RecentSelectionManager({ storageKey, limit: 3, storage });
57
+ window.localStorage.setItem(storageKey, '{broken-json');
58
+ const manager = new RecentSelectionManager({ storageKey, limit: 3 });
65
59
 
66
60
  expect(manager.read()).toEqual([]);
67
61
  });
62
+
63
+ it('does not mutate an empty store while reading recent values', () => {
64
+ const manager = new RecentSelectionManager({ storageKey, limit: 3 });
65
+
66
+ expect(manager.read()).toEqual([]);
67
+ expect(useRecentSelectionStore.getState().entriesByKey).toEqual({});
68
+ });
69
+
70
+ it('keeps namespaced recent values separate from the global list', () => {
71
+ const manager = new RecentSelectionManager({ storageKey, limit: 3 });
72
+
73
+ manager.remember('openai/gpt-5');
74
+ manager.remember('anthropic/claude-sonnet-4', { namespace: 'codex' });
75
+ manager.remember('minimax/MiniMax-M2.7', { namespace: 'hermes' });
76
+ manager.remember('openai/gpt-5.1', { namespace: 'codex' });
77
+
78
+ expect(manager.read()).toEqual(['openai/gpt-5']);
79
+ expect(manager.read({ namespace: 'codex' })).toEqual([
80
+ 'openai/gpt-5.1',
81
+ 'anthropic/claude-sonnet-4'
82
+ ]);
83
+ expect(
84
+ manager.resolveVisible({
85
+ namespace: 'hermes',
86
+ availableValues: ['minimax/MiniMax-M2.7', 'openai/gpt-5'],
87
+ minAvailableCount: 0
88
+ })
89
+ ).toEqual(['minimax/MiniMax-M2.7']);
90
+ });
68
91
  });
@@ -0,0 +1,46 @@
1
+ export type ChatDraftIntent = {
2
+ id: number;
3
+ prompt: string;
4
+ };
5
+
6
+ type ChatDraftIntentListener = (intent: ChatDraftIntent) => void;
7
+
8
+ export class ChatDraftIntentManager {
9
+ private nextId = 0;
10
+ private pendingIntent: ChatDraftIntent | null = null;
11
+ private readonly listeners = new Set<ChatDraftIntentListener>();
12
+
13
+ requestDraft = (prompt: string) => {
14
+ const normalizedPrompt = prompt.trim();
15
+ if (!normalizedPrompt) {
16
+ return;
17
+ }
18
+ const intent: ChatDraftIntent = {
19
+ id: this.nextId + 1,
20
+ prompt: normalizedPrompt,
21
+ };
22
+ this.nextId = intent.id;
23
+ this.pendingIntent = intent;
24
+ this.listeners.forEach((listener) => listener(intent));
25
+ };
26
+
27
+ consumePending = (): ChatDraftIntent | null => {
28
+ const intent = this.pendingIntent;
29
+ this.pendingIntent = null;
30
+ return intent;
31
+ };
32
+
33
+ markConsumed = (intentId: number) => {
34
+ if (this.pendingIntent?.id !== intentId) {
35
+ return;
36
+ }
37
+ this.pendingIntent = null;
38
+ };
39
+
40
+ subscribe = (listener: ChatDraftIntentListener) => {
41
+ this.listeners.add(listener);
42
+ return () => {
43
+ this.listeners.delete(listener);
44
+ };
45
+ };
46
+ }
@@ -31,9 +31,6 @@ import { systemStatusManager } from '@/features/system-status';
31
31
  import { shouldClearPendingProjectRootOverride } from '@/features/chat/features/session/utils/chat-run-metadata.utils';
32
32
 
33
33
  function resolveModelForSend(value: string): string | undefined {
34
- if (isRuntimeDefaultModelValue(value)) {
35
- return undefined;
36
- }
37
34
  return value || undefined;
38
35
  }
39
36
 
@@ -55,16 +52,6 @@ export class ChatInputManager {
55
52
  private sessionListManager: ChatSessionListManager
56
53
  ) {}
57
54
 
58
- private hasSnapshotChanges = (patch: Partial<ChatInputSnapshot>): boolean => {
59
- const current = useChatInputStore.getState().snapshot;
60
- for (const [key, value] of Object.entries(patch) as Array<[keyof ChatInputSnapshot, ChatInputSnapshot[keyof ChatInputSnapshot]]>) {
61
- if (!Object.is(current[key], value)) {
62
- return true;
63
- }
64
- }
65
- return false;
66
- };
67
-
68
55
  private resolveUpdateValue = <T>(prev: T, next: SetStateAction<T>): T => {
69
56
  if (typeof next === 'function') {
70
57
  return (next as (value: T) => T)(prev);
@@ -75,6 +62,16 @@ export class ChatInputManager {
75
62
  private isSameStringArray = (left: string[], right: string[]): boolean =>
76
63
  left.length === right.length && left.every((value, index) => value === right[index]);
77
64
 
65
+ private hasSnapshotChanges = (patch: Partial<ChatInputSnapshot>): boolean => {
66
+ const current = useChatInputStore.getState().snapshot;
67
+ for (const [key, value] of Object.entries(patch) as Array<[keyof ChatInputSnapshot, ChatInputSnapshot[keyof ChatInputSnapshot]]>) {
68
+ if (!Object.is(current[key], value)) {
69
+ return true;
70
+ }
71
+ }
72
+ return false;
73
+ };
74
+
78
75
  private isRuntimeBlockedForSend = (): boolean =>
79
76
  isNcpChatRuntimeBlocked(systemStatusManager.getStatusView());
80
77
 
@@ -351,6 +348,9 @@ export class ChatInputManager {
351
348
 
352
349
  selectModel = (value: string) => {
353
350
  this.setSelectedModel(value);
351
+ chatRecentModelsManager.remember(value, {
352
+ namespace: useChatInputStore.getState().snapshot.selectedSessionType,
353
+ });
354
354
  if (!isRuntimeDefaultModelValue(value)) {
355
355
  chatRecentModelsManager.remember(value);
356
356
  }
@@ -61,6 +61,8 @@ export class ChatRunManager {
61
61
 
62
62
  applyRunSnapshot = (snapshot: ChatRunSnapshot) => {
63
63
  const isSending = snapshot.isSending || snapshot.isRunning;
64
+ const sessionKey =
65
+ snapshot.routeSessionKey ?? snapshot.materializedSessionKey ?? null;
64
66
  useChatInputStore.getState().setSnapshot({
65
67
  canStopGeneration: snapshot.isRunning,
66
68
  stopDisabledReason: snapshot.isRunning ? null : '__preparing__',
@@ -70,6 +72,7 @@ export class ChatRunManager {
70
72
  isSending,
71
73
  });
72
74
  useChatThreadStore.getState().setSnapshot({
75
+ sessionKey,
73
76
  isHistoryLoading: snapshot.isHydrating,
74
77
  messages: snapshot.visibleMessages,
75
78
  isSending,
@@ -45,6 +45,7 @@ export class ChatSessionPreferenceSync {
45
45
  };
46
46
 
47
47
  syncInputSelection = (params: {
48
+ selectedSessionKey?: string | null;
48
49
  selectedSessionExists: boolean;
49
50
  selectedSessionPreferredModel?: string;
50
51
  fallbackPreferredModel?: string;
@@ -56,15 +57,14 @@ export class ChatSessionPreferenceSync {
56
57
  defaultModel,
57
58
  fallbackPreferredModel,
58
59
  fallbackPreferredThinking,
60
+ selectedSessionKey = null,
59
61
  selectedSessionExists,
60
62
  selectedSessionPreferredModel,
61
63
  selectedSessionPreferredThinking,
62
64
  } = params;
63
65
  const { snapshot } = useChatInputStore.getState();
64
66
  const { modelOptions } = snapshot;
65
- const selectedSessionKey = useChatThreadStore.getState().snapshot.sessionKey;
66
67
  const sessionChanged = this.previousPreferenceSessionKey !== selectedSessionKey;
67
- this.previousPreferenceSessionKey = selectedSessionKey;
68
68
  const preserveCurrentPreference = sessionChanged && Boolean(selectedSessionKey) && !selectedSessionExists;
69
69
  const selectedModel = resolveSelectedModelValue({
70
70
  currentSelectedModel: snapshot.selectedModel,
@@ -88,10 +88,27 @@ export class ChatSessionPreferenceSync {
88
88
  preferSessionPreferredThinking: sessionChanged,
89
89
  preserveCurrentSelectedThinkingOnSessionChange: preserveCurrentPreference,
90
90
  });
91
+ if (
92
+ snapshot.selectedModel === selectedModel &&
93
+ snapshot.selectedThinkingLevel === selectedThinking
94
+ ) {
95
+ this.recordSyncedPreferenceSessionKey(selectedSessionKey, selectedSessionExists);
96
+ return;
97
+ }
91
98
  useChatInputStore.getState().setSnapshot({
92
99
  selectedModel,
93
100
  selectedThinkingLevel: selectedThinking,
94
101
  });
102
+ this.recordSyncedPreferenceSessionKey(selectedSessionKey, selectedSessionExists);
103
+ };
104
+
105
+ private recordSyncedPreferenceSessionKey = (
106
+ selectedSessionKey: string | null,
107
+ selectedSessionExists: boolean,
108
+ ): void => {
109
+ if (!selectedSessionKey || selectedSessionExists) {
110
+ this.previousPreferenceSessionKey = selectedSessionKey;
111
+ }
95
112
  };
96
113
 
97
114
  private enqueue = (next: QueuedSessionPreferenceSync): void => {
@@ -296,10 +296,14 @@ export class ChatThreadManager {
296
296
  target: payload.target,
297
297
  title: payload.title,
298
298
  purpose: payload.purpose,
299
+ placement: payload.placement,
299
300
  });
300
301
  };
301
302
 
302
303
  private showContent = async (request: ChatUiShowContentRequest): Promise<void> => {
304
+ if (request.placement === 'inline') {
305
+ return;
306
+ }
303
307
  if (request.target.type === 'file') {
304
308
  this.openFilePreview({
305
309
  path: request.target.payload.path,
@@ -6,6 +6,7 @@ import {
6
6
  createPanelAppResourceUri,
7
7
  createPanelAppRightPanelResourceTarget,
8
8
  } from '@/features/right-panel-resources';
9
+ import { findPanelAppEntryByDisplayId } from '@/features/panel-apps';
9
10
  import type { DocBrowserManager } from '@/shared/components/doc-browser/managers/doc-browser.manager';
10
11
  import { nextclawClient, type PanelAppEntryView } from '@/shared/lib/api';
11
12
 
@@ -26,11 +27,6 @@ type ChatUiDisplayContentTarget = Extract<ChatUiShowContentRequest['target'], {
26
27
 
27
28
  const noopConfirm: ChatUiActions['confirm'] = async () => false;
28
29
 
29
- function isPanelAppEntryMatch(entry: PanelAppEntryView, value: string): boolean {
30
- const normalizedValue = value.trim();
31
- return [entry.id, entry.appId, entry.fileName, entry.title].some((candidate) => candidate.trim() === normalizedValue);
32
- }
33
-
34
30
  export class ChatUiManager {
35
31
  private state: ChatUiState = {
36
32
  pathname: ''
@@ -84,7 +80,7 @@ export class ChatUiManager {
84
80
  return null;
85
81
  }
86
82
  try {
87
- return (await nextclawClient.panelApps.listPanelApps()).entries.find((entry) => isPanelAppEntryMatch(entry, normalizedValue)) ?? null;
83
+ return findPanelAppEntryByDisplayId((await nextclawClient.panelApps.listPanelApps()).entries, normalizedValue);
88
84
  } catch {
89
85
  return null;
90
86
  }
@@ -1,49 +1,51 @@
1
+ import {
2
+ useRecentSelectionStore,
3
+ normalizeRecentSelectionList,
4
+ normalizeRecentSelectionValue,
5
+ resolveRecentSelectionKey,
6
+ type RecentSelectionScope,
7
+ } from '@/features/chat/stores/recent-selection.store';
8
+
1
9
  type RecentSelectionManagerOptions = {
2
10
  storageKey: string;
3
11
  limit: number;
4
- storage?: Pick<Storage, 'getItem' | 'setItem'> | null;
5
12
  };
6
13
 
7
14
  type VisibleRecentSelectionParams = {
8
15
  availableValues: string[];
9
16
  minAvailableCount: number;
10
17
  limit?: number;
11
- };
18
+ } & RecentSelectionScope;
12
19
 
13
20
  export class RecentSelectionManager {
14
21
  constructor(private readonly options: RecentSelectionManagerOptions) {}
15
22
 
16
- read = (): string[] => {
17
- const storage = this.getStorage();
18
- if (!storage) {
19
- return [];
20
- }
21
- try {
22
- return this.normalizeList(JSON.parse(storage.getItem(this.options.storageKey) ?? '[]'));
23
- } catch {
24
- return [];
25
- }
23
+ read = (scope: RecentSelectionScope = {}): string[] => {
24
+ const key = resolveRecentSelectionKey(this.options, scope);
25
+ return normalizeRecentSelectionList(useRecentSelectionStore.getState().entriesByKey[key], this.options.limit);
26
26
  };
27
27
 
28
- remember = (value: string): string[] => {
29
- const normalizedValue = this.normalizeValue(value);
28
+ remember = (value: string, scope: RecentSelectionScope = {}): string[] => {
29
+ const normalizedValue = normalizeRecentSelectionValue(value);
30
30
  if (!normalizedValue) {
31
- return this.read();
31
+ return this.read(scope);
32
32
  }
33
- const next = [normalizedValue, ...this.read().filter((item) => item !== normalizedValue)].slice(0, this.options.limit);
34
- this.write(next);
33
+ const key = resolveRecentSelectionKey(this.options, scope);
34
+ const next = [normalizedValue, ...this.read(scope).filter((item) => item !== normalizedValue)]
35
+ .slice(0, this.options.limit);
36
+ useRecentSelectionStore.getState().setEntries(key, next);
35
37
  return next;
36
38
  };
37
39
 
38
40
  resolveVisible = (params: VisibleRecentSelectionParams): string[] => {
39
- const availableValues = this.normalizeList(params.availableValues, Number.POSITIVE_INFINITY);
41
+ const availableValues = normalizeRecentSelectionList(params.availableValues, Number.POSITIVE_INFINITY);
40
42
  if (availableValues.length <= params.minAvailableCount) {
41
43
  return [];
42
44
  }
43
45
  const availableSet = new Set(availableValues);
44
46
  const visible: string[] = [];
45
47
  const maxVisibleItems = Math.max(1, params.limit ?? this.options.limit);
46
- for (const value of this.read()) {
48
+ for (const value of this.read(params)) {
47
49
  if (!availableSet.has(value) || visible.includes(value)) {
48
50
  continue;
49
51
  }
@@ -54,52 +56,4 @@ export class RecentSelectionManager {
54
56
  }
55
57
  return visible;
56
58
  };
57
-
58
- private write = (values: string[]): void => {
59
- const storage = this.getStorage();
60
- if (!storage) {
61
- return;
62
- }
63
- try {
64
- storage.setItem(this.options.storageKey, JSON.stringify(this.normalizeList(values)));
65
- } catch {
66
- // Ignore storage write failures and keep the runtime behavior deterministic.
67
- }
68
- };
69
-
70
- private getStorage = (): Storage | null => {
71
- if (Object.prototype.hasOwnProperty.call(this.options, 'storage')) {
72
- return (this.options.storage as Storage | null | undefined) ?? null;
73
- }
74
- if (typeof window === 'undefined') {
75
- return null;
76
- }
77
- return window.localStorage;
78
- };
79
-
80
- private normalizeList = (values: unknown, limit = this.options.limit): string[] => {
81
- if (!Array.isArray(values)) {
82
- return [];
83
- }
84
- const deduped: string[] = [];
85
- for (const value of values) {
86
- const normalized = this.normalizeValue(value);
87
- if (!normalized || deduped.includes(normalized)) {
88
- continue;
89
- }
90
- deduped.push(normalized);
91
- if (deduped.length >= limit) {
92
- break;
93
- }
94
- }
95
- return deduped;
96
- };
97
-
98
- private normalizeValue = (value: unknown): string | null => {
99
- if (typeof value !== 'string') {
100
- return null;
101
- }
102
- const normalized = value.trim();
103
- return normalized.length > 0 ? normalized : null;
104
- };
105
59
  }