@nextclaw/ui 0.9.0 → 0.9.2

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 (44) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/assets/{ChannelsList-C7F_As4r.js → ChannelsList-DKD6Llid.js} +1 -1
  3. package/dist/assets/ChatPage-BK9X4Tin.js +38 -0
  4. package/dist/assets/{DocBrowser-Dsd8Dlq8.js → DocBrowser-CVwUDJMO.js} +1 -1
  5. package/dist/assets/{LogoBadge-2ChEc_oz.js → LogoBadge-CYQ_b7jk.js} +1 -1
  6. package/dist/assets/{MarketplacePage-BXck6-X3.js → MarketplacePage-B_2z3ii_.js} +1 -1
  7. package/dist/assets/{ModelConfig-CgHRSD0b.js → ModelConfig-CsX-_fyy.js} +1 -1
  8. package/dist/assets/{ProvidersList-PPfZucvS.js → ProvidersList-CZstsyv7.js} +1 -1
  9. package/dist/assets/{RuntimeConfig-ClLEKNTN.js → RuntimeConfig-CX2TGEG1.js} +1 -1
  10. package/dist/assets/{SearchConfig-CuXVCbrf.js → SearchConfig-C-WBTcWi.js} +1 -1
  11. package/dist/assets/{SecretsConfig-udJz6Ake.js → SecretsConfig-9kbR0ZCB.js} +1 -1
  12. package/dist/assets/{SessionsConfig-C1XnFfiC.js → SessionsConfig-Bohn3P1q.js} +1 -1
  13. package/dist/assets/{chat-message-BETwXLD4.js → chat-message-AWIcksDK.js} +1 -1
  14. package/dist/assets/{index-CsvP4CER.js → index-BEgClaDH.js} +3 -3
  15. package/dist/assets/index-C8GsgIUn.css +1 -0
  16. package/dist/assets/{index-COJdlL0e.js → index-CPDASUXh.js} +1 -1
  17. package/dist/assets/{label-BGL-ztxh.js → label-DD61y-4v.js} +1 -1
  18. package/dist/assets/{page-layout-aw88k7tG.js → page-layout-CfnoVycc.js} +1 -1
  19. package/dist/assets/{popover-DyEvzhmV.js → popover-DsugZ6rp.js} +1 -1
  20. package/dist/assets/{security-config-BuPAQn82.js → security-config-DIrf2Z0O.js} +1 -1
  21. package/dist/assets/skeleton-DJ-Wen2o.js +1 -0
  22. package/dist/assets/{switch-BK8jIzto.js → switch-NX5OmUXQ.js} +1 -1
  23. package/dist/assets/{tabs-custom-Da3cEOji.js → tabs-custom-9ihB5Jem.js} +1 -1
  24. package/dist/assets/{useConfirmDialog-z0CE92iS.js → useConfirmDialog-BuQnVTeR.js} +1 -1
  25. package/dist/assets/{vendor-CkJHmX1g.js → vendor-DKBNiC31.js} +1 -1
  26. package/dist/index.html +3 -3
  27. package/package.json +6 -6
  28. package/src/api/types.ts +63 -3
  29. package/src/components/chat/chat-composer-state.ts +53 -0
  30. package/src/components/chat/chat-page-data.ts +1 -15
  31. package/src/components/chat/chat-page-runtime.test.ts +26 -0
  32. package/src/components/chat/chat-page-runtime.ts +21 -4
  33. package/src/components/chat/chat-stream/types.ts +3 -0
  34. package/src/components/chat/containers/chat-input-bar.container.tsx +12 -41
  35. package/src/components/chat/legacy/LegacyChatPage.tsx +1 -15
  36. package/src/components/chat/managers/chat-input.manager.ts +43 -13
  37. package/src/components/chat/ncp/NcpChatPage.tsx +11 -18
  38. package/src/components/chat/ncp/ncp-chat-input.manager.ts +42 -12
  39. package/src/components/chat/ncp/ncp-chat-page-data.ts +1 -15
  40. package/src/components/chat/presenter/chat-presenter-context.tsx +2 -0
  41. package/src/components/chat/stores/chat-input.store.ts +4 -0
  42. package/dist/assets/ChatPage-Oo7-OUsx.js +0 -37
  43. package/dist/assets/index-D-bXl7qL.css +0 -1
  44. package/dist/assets/skeleton-drzO_tdU.js +0 -1
@@ -0,0 +1,53 @@
1
+ import type { ChatComposerNode } from '@nextclaw/agent-chat-ui';
2
+ import {
3
+ createChatComposerTokenNode,
4
+ createChatComposerNodesFromText,
5
+ createEmptyChatComposerNodes,
6
+ extractChatComposerTokenKeys,
7
+ normalizeChatComposerNodes,
8
+ removeChatComposerTokenNodes,
9
+ serializeChatComposerPlainText
10
+ } from '@nextclaw/agent-chat-ui';
11
+
12
+ export function createInitialChatComposerNodes(): ChatComposerNode[] {
13
+ return createEmptyChatComposerNodes();
14
+ }
15
+
16
+ export function createChatComposerNodesFromDraft(text: string): ChatComposerNode[] {
17
+ return createChatComposerNodesFromText(text);
18
+ }
19
+
20
+ export function deriveChatComposerDraft(nodes: ChatComposerNode[]): string {
21
+ return serializeChatComposerPlainText(nodes);
22
+ }
23
+
24
+ export function deriveSelectedSkillsFromComposer(nodes: ChatComposerNode[]): string[] {
25
+ return extractChatComposerTokenKeys(nodes, 'skill');
26
+ }
27
+
28
+ export function syncComposerSkills(
29
+ nodes: ChatComposerNode[],
30
+ nextSkills: string[],
31
+ skillRecords: Array<{ spec: string; label?: string }>
32
+ ): ChatComposerNode[] {
33
+ const nextSkillSet = new Set(nextSkills);
34
+ const prunedNodes = removeChatComposerTokenNodes(
35
+ nodes,
36
+ (node) => node.tokenKind === 'skill' && !nextSkillSet.has(node.tokenKey)
37
+ );
38
+ const existingSkills = extractChatComposerTokenKeys(prunedNodes, 'skill');
39
+ const recordMap = new Map(skillRecords.map((record) => [record.spec, record]));
40
+ const appendedNodes = nextSkills
41
+ .filter((skill) => !existingSkills.includes(skill))
42
+ .map((skill) =>
43
+ createChatComposerTokenNode({
44
+ tokenKind: 'skill',
45
+ tokenKey: skill,
46
+ label: recordMap.get(skill)?.label || skill
47
+ })
48
+ );
49
+
50
+ return appendedNodes.length === 0
51
+ ? prunedNodes
52
+ : normalizeChatComposerNodes([...prunedNodes, ...appendedNodes]);
53
+ }
@@ -4,7 +4,6 @@ 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
6
  import {
7
- resolveSelectedModelValue,
8
7
  resolveRecentSessionPreferredModel,
9
8
  useSyncSelectedModel
10
9
  } from '@/components/chat/chat-page-runtime';
@@ -115,25 +114,13 @@ export function useChatPageData(params: UseChatPageDataParams) {
115
114
  useSyncSelectedModel({
116
115
  modelOptions,
117
116
  selectedSessionKey: params.selectedSessionKey,
117
+ selectedSessionExists: Boolean(selectedSession),
118
118
  selectedSessionPreferredModel: selectedSession?.preferredModel,
119
119
  fallbackPreferredModel: recentSessionPreferredModel,
120
120
  defaultModel: configQuery.data?.agents.defaults.model,
121
121
  setSelectedModel: params.setSelectedModel
122
122
  });
123
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
-
137
124
  const historyMessages = useMemo(() => historyQuery.data?.messages ?? [], [historyQuery.data?.messages]);
138
125
  const selectedSessionThinkingLevel = useMemo(() => {
139
126
  if (!params.selectedSessionKey) {
@@ -171,7 +158,6 @@ export function useChatPageData(params: UseChatPageDataParams) {
171
158
  sessions,
172
159
  skillRecords,
173
160
  selectedSession,
174
- hydratedSessionModel,
175
161
  historyMessages,
176
162
  selectedSessionThinkingLevel,
177
163
  ...sessionTypeState
@@ -82,6 +82,32 @@ describe('resolveSelectedModelValue', () => {
82
82
  ).toBe('openai/gpt-5');
83
83
  });
84
84
 
85
+ it('preserves the current valid model when a draft session materializes before the new session metadata exists', () => {
86
+ expect(
87
+ resolveSelectedModelValue({
88
+ currentSelectedModel: 'openai/gpt-5',
89
+ modelOptions,
90
+ fallbackPreferredModel: 'anthropic/claude-sonnet-4',
91
+ defaultModel: 'anthropic/claude-sonnet-4',
92
+ preferSessionPreferredModel: true,
93
+ preserveCurrentSelectedModelOnSessionChange: true
94
+ })
95
+ ).toBe('openai/gpt-5');
96
+ });
97
+
98
+ it('still falls back when the current model is no longer valid during draft session materialization', () => {
99
+ expect(
100
+ resolveSelectedModelValue({
101
+ currentSelectedModel: 'missing/model',
102
+ modelOptions,
103
+ fallbackPreferredModel: 'openai/gpt-5',
104
+ defaultModel: 'anthropic/claude-sonnet-4',
105
+ preferSessionPreferredModel: true,
106
+ preserveCurrentSelectedModelOnSessionChange: true
107
+ })
108
+ ).toBe('openai/gpt-5');
109
+ });
110
+
85
111
  it('uses the recent same-runtime model when the current session has no valid preferred model', () => {
86
112
  expect(
87
113
  resolveSelectedModelValue({
@@ -27,6 +27,7 @@ export function resolveSelectedModelValue(params: {
27
27
  fallbackPreferredModel?: string;
28
28
  defaultModel?: string;
29
29
  preferSessionPreferredModel?: boolean;
30
+ preserveCurrentSelectedModelOnSessionChange?: boolean;
30
31
  }): string {
31
32
  const {
32
33
  currentSelectedModel,
@@ -34,12 +35,16 @@ export function resolveSelectedModelValue(params: {
34
35
  selectedSessionPreferredModel,
35
36
  fallbackPreferredModel,
36
37
  defaultModel,
37
- preferSessionPreferredModel = false
38
+ preferSessionPreferredModel = false,
39
+ preserveCurrentSelectedModelOnSessionChange = false
38
40
  } = params;
39
41
  if (modelOptions.length === 0) {
40
42
  return '';
41
43
  }
42
- if (!preferSessionPreferredModel && hasModelOption(modelOptions, currentSelectedModel)) {
44
+ if (
45
+ hasModelOption(modelOptions, currentSelectedModel) &&
46
+ (!preferSessionPreferredModel || preserveCurrentSelectedModelOnSessionChange)
47
+ ) {
43
48
  return currentSelectedModel.trim();
44
49
  }
45
50
  if (hasModelOption(modelOptions, selectedSessionPreferredModel)) {
@@ -86,6 +91,7 @@ export function resolveRecentSessionPreferredModel(params: {
86
91
  export function useSyncSelectedModel(params: {
87
92
  modelOptions: ChatModelOption[];
88
93
  selectedSessionKey?: string | null;
94
+ selectedSessionExists?: boolean;
89
95
  selectedSessionPreferredModel?: string;
90
96
  fallbackPreferredModel?: string;
91
97
  defaultModel?: string;
@@ -94,6 +100,7 @@ export function useSyncSelectedModel(params: {
94
100
  const {
95
101
  modelOptions,
96
102
  selectedSessionKey,
103
+ selectedSessionExists = false,
97
104
  selectedSessionPreferredModel,
98
105
  fallbackPreferredModel,
99
106
  defaultModel,
@@ -115,11 +122,21 @@ export function useSyncSelectedModel(params: {
115
122
  selectedSessionPreferredModel,
116
123
  fallbackPreferredModel,
117
124
  defaultModel,
118
- preferSessionPreferredModel: sessionChanged
125
+ preferSessionPreferredModel: sessionChanged,
126
+ preserveCurrentSelectedModelOnSessionChange:
127
+ sessionChanged && Boolean(selectedSessionKey) && !selectedSessionExists
119
128
  });
120
129
  });
121
130
  previousSessionKeyRef.current = selectedSessionKey;
122
- }, [defaultModel, fallbackPreferredModel, modelOptions, selectedSessionKey, selectedSessionPreferredModel, setSelectedModel]);
131
+ }, [
132
+ defaultModel,
133
+ fallbackPreferredModel,
134
+ modelOptions,
135
+ selectedSessionExists,
136
+ selectedSessionKey,
137
+ selectedSessionPreferredModel,
138
+ setSelectedModel
139
+ ]);
123
140
  }
124
141
 
125
142
  export function useSessionRunStatus(params: {
@@ -1,3 +1,4 @@
1
+ import type { ChatComposerNode } from '@nextclaw/agent-chat-ui';
1
2
  import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
2
3
  import type {
3
4
  ChatRunView,
@@ -20,6 +21,7 @@ export type SendMessageParams = {
20
21
  stopSupported?: boolean;
21
22
  stopReason?: string;
22
23
  restoreDraftOnError?: boolean;
24
+ composerNodes?: ChatComposerNode[];
23
25
  };
24
26
 
25
27
  export type ActiveRunState = {
@@ -65,6 +67,7 @@ export type UseChatStreamControllerParams = {
65
67
  selectedSessionKeyRef: MutableRefObject<string | null>;
66
68
  setSelectedSessionKey: Dispatch<SetStateAction<string | null>>;
67
69
  setDraft: Dispatch<SetStateAction<string>>;
70
+ setComposerNodes: Dispatch<SetStateAction<ChatComposerNode[]>>;
68
71
  refetchSessions: () => Promise<unknown>;
69
72
  refetchHistory: () => Promise<unknown>;
70
73
  };
@@ -1,18 +1,15 @@
1
- import { useMemo } from 'react';
1
+ import { useMemo, useState } from 'react';
2
2
  import { ChatInputBar } from '@nextclaw/agent-chat-ui';
3
3
  import {
4
4
  buildChatSlashItems,
5
5
  buildModelStateHint,
6
6
  buildModelToolbarSelect,
7
- buildSelectedSkillItems,
8
7
  buildSkillPickerModel,
9
8
  buildThinkingToolbarSelect,
10
- resolveSlashQuery,
11
9
  type ChatModelRecord,
12
10
  type ChatSkillRecord,
13
11
  type ChatThinkingLevel
14
12
  } from '@/components/chat/adapters/chat-input-bar.adapter';
15
- import { useChatInputBarController } from '@/components/chat/chat-input/chat-input-bar.controller';
16
13
  import { usePresenter } from '@/components/chat/presenter/chat-presenter-context';
17
14
  import { useI18n } from '@/components/providers/I18nProvider';
18
15
  import { useChatInputStore } from '@/components/chat/stores/chat-input.store';
@@ -72,6 +69,7 @@ export function ChatInputBarContainer() {
72
69
  const presenter = usePresenter();
73
70
  const { language } = useI18n();
74
71
  const snapshot = useChatInputStore((state) => state.snapshot);
72
+ const [slashQuery, setSlashQuery] = useState<string | null>(null);
75
73
 
76
74
  const officialSkillBadgeLabel = useMemo(() => {
77
75
  // Keep memo reactive to locale switches even though `t` is imported as a stable function.
@@ -110,31 +108,11 @@ export function ChatInputBarContainer() {
110
108
  ? t('chatInputPlaceholder')
111
109
  : t('chatModelNoOptions');
112
110
 
113
- const slashQuery = resolveSlashQuery(snapshot.draft);
114
111
  const slashItems = useMemo(
115
112
  () => buildChatSlashItems(skillRecords, slashQuery ?? '', slashTexts),
116
113
  [slashQuery, skillRecords, slashTexts]
117
114
  );
118
115
 
119
- const controller = useChatInputBarController({
120
- isSlashMode: slashQuery !== null,
121
- slashItems,
122
- isSlashLoading: snapshot.isSkillsLoading,
123
- onSelectSlashItem: (item) => {
124
- if (!item.value) {
125
- return;
126
- }
127
- if (!snapshot.selectedSkills.includes(item.value)) {
128
- presenter.chatInputManager.selectSkills([...snapshot.selectedSkills, item.value]);
129
- }
130
- presenter.chatInputManager.setDraft('');
131
- },
132
- onSend: presenter.chatInputManager.send,
133
- onStop: presenter.chatInputManager.stop,
134
- isSending: snapshot.isSending,
135
- canStopGeneration: snapshot.canStopGeneration
136
- });
137
-
138
116
  const selectedModelOption = modelRecords.find((option) => option.value === snapshot.selectedModel);
139
117
  const selectedModelThinkingCapability = selectedModelOption?.thinkingCapability;
140
118
  const thinkingSupportedLevels = selectedModelThinkingCapability?.supported ?? [];
@@ -183,27 +161,23 @@ export function ChatInputBarContainer() {
183
161
 
184
162
  return (
185
163
  <ChatInputBar
186
- value={snapshot.draft}
187
- placeholder={textareaPlaceholder}
188
- disabled={inputDisabled}
189
- onValueChange={presenter.chatInputManager.setDraft}
190
- onKeyDown={controller.onTextareaKeyDown}
164
+ composer={{
165
+ nodes: snapshot.composerNodes,
166
+ placeholder: textareaPlaceholder,
167
+ disabled: inputDisabled,
168
+ onNodesChange: presenter.chatInputManager.setComposerNodes,
169
+ onSlashQueryChange: setSlashQuery
170
+ }}
191
171
  slashMenu={{
192
- isOpen: controller.isSlashPanelOpen,
193
172
  isLoading: snapshot.isSkillsLoading,
194
173
  items: slashItems,
195
- activeIndex: controller.activeSlashIndex,
196
- activeItem: controller.activeSlashItem,
197
174
  texts: {
198
175
  slashLoadingLabel: t('chatSlashLoading'),
199
176
  slashSectionLabel: t('chatSlashSectionSkills'),
200
177
  slashEmptyLabel: t('chatSlashNoResult'),
201
178
  slashHintLabel: t('chatSlashHint'),
202
179
  slashSkillHintLabel: t('chatSlashSkillHint')
203
- },
204
- onSelectItem: controller.onSelectSlashItem,
205
- onOpenChange: controller.onSlashPanelOpenChange,
206
- onSetActiveIndex: controller.onSetActiveSlashIndex
180
+ }
207
181
  }}
208
182
  hint={buildModelStateHint({
209
183
  isModelOptionsLoading,
@@ -214,17 +188,14 @@ export function ChatInputBarContainer() {
214
188
  configureProviderLabel: t('chatGoConfigureProvider')
215
189
  }
216
190
  })}
217
- selectedItems={{
218
- items: buildSelectedSkillItems(snapshot.selectedSkills, skillRecords),
219
- onRemove: (key) => presenter.chatInputManager.selectSkills(snapshot.selectedSkills.filter((skill) => skill !== key))
220
- }}
221
191
  toolbar={{
222
192
  selects: toolbarSelects,
223
193
  accessories: [
224
194
  {
225
195
  key: 'attach',
226
- label: t('chatInputAttachComingSoon'),
196
+ label: t('chatInputAttach'),
227
197
  icon: 'paperclip',
198
+ iconOnly: true,
228
199
  disabled: true,
229
200
  tooltip: t('chatInputAttachComingSoon')
230
201
  }
@@ -24,7 +24,6 @@ export function LegacyChatPage({ view }: ChatPageProps) {
24
24
  const { sessionId: routeSessionIdParam } = useParams<{ sessionId?: string }>();
25
25
  const threadRef = useRef<HTMLDivElement | null>(null);
26
26
  const selectedSessionKeyRef = useRef<string | null>(selectedSessionKey);
27
- const modelHydratedSessionKeyRef = useRef<string | null>(null);
28
27
  const thinkingHydratedSessionKeyRef = useRef<string | null>(null);
29
28
  const routeSessionKey = useMemo(
30
29
  () => parseSessionKeyFromRoute(routeSessionIdParam),
@@ -40,7 +39,6 @@ export function LegacyChatPage({ view }: ChatPageProps) {
40
39
  sessions,
41
40
  skillRecords,
42
41
  selectedSession,
43
- hydratedSessionModel,
44
42
  historyMessages,
45
43
  selectedSessionThinkingLevel,
46
44
  sessionTypeOptions,
@@ -75,6 +73,7 @@ export function LegacyChatPage({ view }: ChatPageProps) {
75
73
  selectedSessionKeyRef,
76
74
  setSelectedSessionKey: presenter.chatSessionListManager.setSelectedSessionKey,
77
75
  setDraft: presenter.chatInputManager.setDraft,
76
+ setComposerNodes: presenter.chatInputManager.setComposerNodes,
78
77
  refetchSessions: sessionsQuery.refetch,
79
78
  refetchHistory: historyQuery.refetch
80
79
  },
@@ -139,13 +138,6 @@ export function LegacyChatPage({ view }: ChatPageProps) {
139
138
  }, [presenter, sessionsQuery.refetch]);
140
139
 
141
140
  useEffect(() => {
142
- const shouldHydrateModelFromSession =
143
- !isSending &&
144
- !isAwaitingAssistantOutput &&
145
- !sessionsQuery.isLoading &&
146
- isProviderStateResolved &&
147
- modelOptions.length > 0 &&
148
- selectedSessionKey !== modelHydratedSessionKeyRef.current;
149
141
  const shouldHydrateThinkingFromHistory =
150
142
  !isSending &&
151
143
  !isAwaitingAssistantOutput &&
@@ -164,7 +156,6 @@ export function LegacyChatPage({ view }: ChatPageProps) {
164
156
  sendError: lastSendError,
165
157
  isSending,
166
158
  modelOptions,
167
- ...(shouldHydrateModelFromSession ? { selectedModel: hydratedSessionModel } : {}),
168
159
  sessionTypeOptions,
169
160
  selectedSessionType,
170
161
  ...(shouldHydrateThinkingFromHistory ? { selectedThinkingLevel: selectedSessionThinkingLevel } : {}),
@@ -173,14 +164,10 @@ export function LegacyChatPage({ view }: ChatPageProps) {
173
164
  skillRecords,
174
165
  isSkillsLoading: installedSkillsQuery.isLoading
175
166
  });
176
- if (shouldHydrateModelFromSession) {
177
- modelHydratedSessionKeyRef.current = selectedSessionKey;
178
- }
179
167
  if (shouldHydrateThinkingFromHistory) {
180
168
  thinkingHydratedSessionKeyRef.current = selectedSessionKey;
181
169
  }
182
170
  if (!selectedSessionKey) {
183
- modelHydratedSessionKeyRef.current = null;
184
171
  thinkingHydratedSessionKeyRef.current = null;
185
172
  }
186
173
  presenter.chatSessionListManager.syncSnapshot({
@@ -220,7 +207,6 @@ export function LegacyChatPage({ view }: ChatPageProps) {
220
207
  historyQuery.isLoading,
221
208
  installedSkillsQuery.isLoading,
222
209
  isAwaitingAssistantOutput,
223
- hydratedSessionModel,
224
210
  isProviderStateResolved,
225
211
  isSending,
226
212
  lastSendError,
@@ -1,3 +1,11 @@
1
+ import type { ChatComposerNode } from '@nextclaw/agent-chat-ui';
2
+ import {
3
+ createInitialChatComposerNodes,
4
+ createChatComposerNodesFromDraft,
5
+ deriveChatComposerDraft,
6
+ deriveSelectedSkillsFromComposer,
7
+ syncComposerSkills
8
+ } from '@/components/chat/chat-composer-state';
1
9
  import { updateSession } from '@/api/config';
2
10
  import { useChatInputStore } from '@/components/chat/stores/chat-input.store';
3
11
  import { buildNewSessionKey } from '@/components/chat/chat-session-route';
@@ -36,6 +44,17 @@ export class ChatInputManager {
36
44
  return next;
37
45
  };
38
46
 
47
+ private isSameStringArray = (left: string[], right: string[]): boolean =>
48
+ left.length === right.length && left.every((value, index) => value === right[index]);
49
+
50
+ private syncComposerSnapshot = (nodes: ChatComposerNode[]) => {
51
+ useChatInputStore.getState().setSnapshot({
52
+ composerNodes: nodes,
53
+ draft: deriveChatComposerDraft(nodes),
54
+ selectedSkills: deriveSelectedSkillsFromComposer(nodes)
55
+ });
56
+ };
57
+
39
58
  syncSnapshot = (patch: Partial<ChatInputSnapshot>) => {
40
59
  if (!this.hasSnapshotChanges(patch)) {
41
60
  return;
@@ -46,8 +65,8 @@ export class ChatInputManager {
46
65
  Object.prototype.hasOwnProperty.call(patch, 'selectedModel') ||
47
66
  Object.prototype.hasOwnProperty.call(patch, 'selectedThinkingLevel')
48
67
  ) {
49
- const snapshot = useChatInputStore.getState().snapshot;
50
- this.reconcileThinkingForModel(snapshot.selectedModel);
68
+ const { selectedModel } = useChatInputStore.getState().snapshot;
69
+ this.reconcileThinkingForModel(selectedModel);
51
70
  }
52
71
  };
53
72
 
@@ -57,7 +76,16 @@ export class ChatInputManager {
57
76
  if (value === prev) {
58
77
  return;
59
78
  }
60
- useChatInputStore.getState().setSnapshot({ draft: value });
79
+ this.syncComposerSnapshot(createChatComposerNodesFromDraft(value));
80
+ };
81
+
82
+ setComposerNodes = (next: SetStateAction<ChatComposerNode[]>) => {
83
+ const prev = useChatInputStore.getState().snapshot.composerNodes;
84
+ const value = this.resolveUpdateValue(prev, next);
85
+ if (Object.is(value, prev)) {
86
+ return;
87
+ }
88
+ this.syncComposerSnapshot(value);
61
89
  };
62
90
 
63
91
  setPendingSessionType = (next: SetStateAction<string>) => {
@@ -76,14 +104,13 @@ export class ChatInputManager {
76
104
  if (!message) {
77
105
  return;
78
106
  }
79
- const requestedSkills = inputSnapshot.selectedSkills;
107
+ const { selectedSkills: requestedSkills, composerNodes } = inputSnapshot;
80
108
  const hasSelectedSession = Boolean(sessionSnapshot.selectedSessionKey);
81
109
  const sessionKey = sessionSnapshot.selectedSessionKey ?? buildNewSessionKey(sessionSnapshot.selectedAgentId);
82
110
  if (!hasSelectedSession) {
83
111
  this.uiManager.goToSession(sessionKey, { replace: true });
84
112
  }
85
- this.setDraft('');
86
- this.setSelectedSkills([]);
113
+ this.setComposerNodes(createInitialChatComposerNodes());
87
114
  await this.streamActionsManager.sendMessage({
88
115
  message,
89
116
  sessionKey,
@@ -94,7 +121,8 @@ export class ChatInputManager {
94
121
  stopSupported: inputSnapshot.stopSupported,
95
122
  stopReason: inputSnapshot.stopReason,
96
123
  requestedSkills,
97
- restoreDraftOnError: true
124
+ restoreDraftOnError: true,
125
+ composerNodes
98
126
  });
99
127
  };
100
128
 
@@ -132,12 +160,13 @@ export class ChatInputManager {
132
160
  };
133
161
 
134
162
  setSelectedSkills = (next: SetStateAction<string[]>) => {
135
- const prev = useChatInputStore.getState().snapshot.selectedSkills;
163
+ const snapshot = useChatInputStore.getState().snapshot;
164
+ const { selectedSkills: prev } = snapshot;
136
165
  const value = this.resolveUpdateValue(prev, next);
137
- if (Object.is(value, prev)) {
166
+ if (this.isSameStringArray(value, prev)) {
138
167
  return;
139
168
  }
140
- useChatInputStore.getState().setSnapshot({ selectedSkills: value });
169
+ this.syncComposerSnapshot(syncComposerSkills(snapshot.composerNodes, value, snapshot.skillRecords));
141
170
  };
142
171
 
143
172
  selectModel = (value: string) => {
@@ -174,15 +203,16 @@ export class ChatInputManager {
174
203
  private reconcileThinkingForModel(model: string): void {
175
204
  const snapshot = useChatInputStore.getState().snapshot;
176
205
  const modelOption = snapshot.modelOptions.find((option) => option.value === model);
177
- const nextThinking = this.resolveThinkingForModel(modelOption, snapshot.selectedThinkingLevel);
178
- if (nextThinking !== snapshot.selectedThinkingLevel) {
206
+ const { selectedThinkingLevel } = snapshot;
207
+ const nextThinking = this.resolveThinkingForModel(modelOption, selectedThinkingLevel);
208
+ if (nextThinking !== selectedThinkingLevel) {
179
209
  useChatInputStore.getState().setSnapshot({ selectedThinkingLevel: nextThinking });
180
210
  }
181
211
  }
182
212
 
183
213
  private syncRemoteSessionType = async (normalizedType: string) => {
184
214
  const sessionSnapshot = useChatSessionListStore.getState().snapshot;
185
- const selectedSessionKey = sessionSnapshot.selectedSessionKey;
215
+ const { selectedSessionKey } = sessionSnapshot;
186
216
  if (!selectedSessionKey) {
187
217
  return;
188
218
  }
@@ -71,7 +71,6 @@ export function NcpChatPage({ view }: ChatPageProps) {
71
71
  const { sessionId: routeSessionIdParam } = useParams<{ sessionId?: string }>();
72
72
  const threadRef = useRef<HTMLDivElement | null>(null);
73
73
  const selectedSessionKeyRef = useRef<string | null>(selectedSessionKey);
74
- const modelHydratedSessionKeyRef = useRef<string | null>(null);
75
74
  const thinkingHydratedSessionKeyRef = useRef<string | null>(null);
76
75
  const routeSessionKey = useMemo(
77
76
  () => parseSessionKeyFromRoute(routeSessionIdParam),
@@ -86,7 +85,6 @@ export function NcpChatPage({ view }: ChatPageProps) {
86
85
  sessions,
87
86
  skillRecords,
88
87
  selectedSession,
89
- hydratedSessionModel,
90
88
  selectedSessionThinkingLevel,
91
89
  sessionTypeOptions,
92
90
  defaultSessionType,
@@ -212,9 +210,17 @@ export function NcpChatPage({ view }: ChatPageProps) {
212
210
  await sessionsQuery.refetch();
213
211
  } catch (error) {
214
212
  if (payload.restoreDraftOnError) {
215
- presenter.chatInputManager.setDraft((currentDraft) =>
216
- currentDraft.trim().length === 0 ? payload.message : currentDraft
217
- );
213
+ if (payload.composerNodes && payload.composerNodes.length > 0) {
214
+ presenter.chatInputManager.setComposerNodes((currentNodes) =>
215
+ currentNodes.length === 1 && currentNodes[0]?.type === 'text' && currentNodes[0].text.length === 0
216
+ ? payload.composerNodes ?? currentNodes
217
+ : currentNodes
218
+ );
219
+ } else {
220
+ presenter.chatInputManager.setDraft((currentDraft) =>
221
+ currentDraft.trim().length === 0 ? payload.message : currentDraft
222
+ );
223
+ }
218
224
  }
219
225
  throw error;
220
226
  }
@@ -270,13 +276,6 @@ export function NcpChatPage({ view }: ChatPageProps) {
270
276
  }, [presenter, sessionsQuery.refetch]);
271
277
 
272
278
  useEffect(() => {
273
- const shouldHydrateModelFromSession =
274
- !isSending &&
275
- !isAwaitingAssistantOutput &&
276
- !sessionsQuery.isLoading &&
277
- isProviderStateResolved &&
278
- modelOptions.length > 0 &&
279
- selectedSessionKey !== modelHydratedSessionKeyRef.current;
280
279
  const shouldHydrateThinkingFromSession =
281
280
  !isSending &&
282
281
  !isAwaitingAssistantOutput &&
@@ -295,7 +294,6 @@ export function NcpChatPage({ view }: ChatPageProps) {
295
294
  sendError: lastSendError,
296
295
  isSending,
297
296
  modelOptions,
298
- ...(shouldHydrateModelFromSession ? { selectedModel: hydratedSessionModel } : {}),
299
297
  sessionTypeOptions,
300
298
  selectedSessionType,
301
299
  ...(shouldHydrateThinkingFromSession ? { selectedThinkingLevel: selectedSessionThinkingLevel } : {}),
@@ -304,14 +302,10 @@ export function NcpChatPage({ view }: ChatPageProps) {
304
302
  skillRecords,
305
303
  isSkillsLoading: installedSkillsQuery.isLoading
306
304
  });
307
- if (shouldHydrateModelFromSession) {
308
- modelHydratedSessionKeyRef.current = selectedSessionKey;
309
- }
310
305
  if (shouldHydrateThinkingFromSession) {
311
306
  thinkingHydratedSessionKeyRef.current = selectedSessionKey;
312
307
  }
313
308
  if (!selectedSessionKey) {
314
- modelHydratedSessionKeyRef.current = null;
315
309
  thinkingHydratedSessionKeyRef.current = null;
316
310
  }
317
311
  presenter.chatSessionListManager.syncSnapshot({
@@ -349,7 +343,6 @@ export function NcpChatPage({ view }: ChatPageProps) {
349
343
  defaultSessionType,
350
344
  installedSkillsQuery.isLoading,
351
345
  isAwaitingAssistantOutput,
352
- hydratedSessionModel,
353
346
  isProviderStateResolved,
354
347
  isSending,
355
348
  lastSendError,