@nextclaw/ui 0.17.2 → 0.18.1

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 (67) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/assets/{api-CuPWhNDM.js → api-BJioGtWS.js} +1 -1
  3. package/dist/assets/{appearance-settings-page-DRjg6UP3.js → appearance-settings-page-CR2LJW8I.js} +1 -1
  4. package/dist/assets/{book-open-B4ovWX14.js → book-open-aDv6R1av.js} +1 -1
  5. package/dist/assets/{channels-list-page-B26GXq63.js → channels-list-page-BXyx_gxw.js} +1 -1
  6. package/dist/assets/{chat-page-QA0dpNyU.js → chat-page-OP5V5mAm.js} +1 -1
  7. package/dist/assets/{config-split-page-Box_-aBK.js → config-split-page-Bs1wArsX.js} +1 -1
  8. package/dist/assets/{confirm-dialog-uqcCxomz.js → confirm-dialog-BFatCZUq.js} +1 -1
  9. package/dist/assets/{desktop-update-config-DkDor159.js → desktop-update-config-eb7REelt.js} +1 -1
  10. package/dist/assets/{dist-CZLIZntJ.js → dist-CV9wIyME.js} +1 -1
  11. package/dist/assets/{dist-Co2G-YjN.js → dist-D1I3blO_.js} +1 -1
  12. package/dist/assets/{doc-browser-CbCBDdap.js → doc-browser-BFfstBoh.js} +1 -1
  13. package/dist/assets/doc-browser-DEdoMb3X.js +1 -0
  14. package/dist/assets/{doc-browser-context-aeZu0FHR.js → doc-browser-context-BcwcVwX_.js} +1 -1
  15. package/dist/assets/{form-actions-C3Pa1HQb.js → form-actions-YSnn7q_U.js} +1 -1
  16. package/dist/assets/index-DotCWGD1.js +109 -0
  17. package/dist/assets/loader-circle-CsNiT6VM.js +1 -0
  18. package/dist/assets/mcp-marketplace-page-DfUCF-L5.js +1 -0
  19. package/dist/assets/{mcp-marketplace-page-BmqbIQQQ.js → mcp-marketplace-page-ZTHpQTvi.js} +1 -1
  20. package/dist/assets/{model-config-page-BXUoZEEZ.js → model-config-page-BOhaenKN.js} +1 -1
  21. package/dist/assets/{navigation-link-DngetDmN.js → navigation-link-7oSjAC9R.js} +1 -1
  22. package/dist/assets/plus-D6UN8XN5.js +1 -0
  23. package/dist/assets/{provider-scoped-model-input-C4a2TuV7.js → provider-scoped-model-input-DlohNUbq.js} +1 -1
  24. package/dist/assets/{providers-config-page-CHSka1kQ.js → providers-config-page-eYaDPENc.js} +1 -1
  25. package/dist/assets/{react-CPH0zySO.js → react-7zG0fz1I.js} +1 -1
  26. package/dist/assets/{refresh-cw-vTYZqC0b.js → refresh-cw-BUg7hI3u.js} +1 -1
  27. package/dist/assets/remote-Cx_8UTi5.js +1 -0
  28. package/dist/assets/rotate-cw-B3qmXtfJ.js +1 -0
  29. package/dist/assets/{runtime-config-page-V75_zqXa.js → runtime-config-page-D5lV05VE.js} +1 -1
  30. package/dist/assets/{save-lWb6TI69.js → save-CTCYuZqu.js} +1 -1
  31. package/dist/assets/{search-BuUX_0KY.js → search-CfFUpaf7.js} +1 -1
  32. package/dist/assets/{search-config-page-rlkRJZCF.js → search-config-page-DAWe6n7E.js} +1 -1
  33. package/dist/assets/{secrets-config-page-BxCEc8r5.js → secrets-config-page-DyFeZDAi.js} +1 -1
  34. package/dist/assets/security-config-DoVoel5I.js +1 -0
  35. package/dist/assets/{select-oalsltZV.js → select-MSvkWmPP.js} +1 -1
  36. package/dist/assets/{settings-2-Bd9xiNAY.js → settings-2-DXUfAWgg.js} +1 -1
  37. package/dist/assets/skeleton-DDFivi9Y.js +1 -0
  38. package/dist/assets/{tag-chip-B324d_Xk.js → tag-chip-u8g0b0Nf.js} +1 -1
  39. package/dist/assets/x-DdOEUmR4.js +1 -0
  40. package/dist/index.html +19 -19
  41. package/package.json +6 -6
  42. package/src/features/chat/features/conversation/components/__tests__/session-conversation-input.streaming.test.tsx +102 -1
  43. package/src/features/chat/features/conversation/components/session-conversation-area.tsx +2 -0
  44. package/src/features/chat/features/conversation/components/session-conversation-input.tsx +19 -17
  45. package/src/features/chat/features/conversation/hooks/__tests__/use-session-conversation-controller.test.tsx +46 -0
  46. package/src/features/chat/features/conversation/hooks/__tests__/use-session-conversation-input-state.test.tsx +31 -0
  47. package/src/features/chat/features/conversation/hooks/__tests__/use-session-conversation-preference-persistence.test.tsx +110 -0
  48. package/src/features/chat/features/conversation/hooks/__tests__/use-session-conversation-slash-commands.test.tsx +20 -0
  49. package/src/features/chat/features/conversation/hooks/use-session-conversation-controller.ts +138 -54
  50. package/src/features/chat/features/conversation/hooks/use-session-conversation-input-state.ts +3 -2
  51. package/src/features/chat/features/conversation/hooks/use-session-conversation-preference-actions.ts +30 -3
  52. package/src/features/chat/features/conversation/hooks/use-session-conversation-preference-persistence.ts +88 -0
  53. package/src/features/chat/features/conversation/hooks/use-session-conversation-slash-commands.ts +25 -2
  54. package/src/features/chat/features/session/hooks/__tests__/use-chat-session-update.test.tsx +27 -0
  55. package/src/features/chat/features/session/hooks/use-chat-session-update.ts +17 -6
  56. package/src/shared/lib/i18n/locales/en-US/chat.json +4 -0
  57. package/src/shared/lib/i18n/locales/zh-CN/chat.json +4 -0
  58. package/dist/assets/doc-browser-DUqckL2E.js +0 -1
  59. package/dist/assets/index-CCWgeWoR.js +0 -109
  60. package/dist/assets/loader-circle-DfbWUPdI.js +0 -1
  61. package/dist/assets/mcp-marketplace-page-B2D7ciJL.js +0 -1
  62. package/dist/assets/plus-wNpOxBfP.js +0 -1
  63. package/dist/assets/remote-DgfRmokf.js +0 -1
  64. package/dist/assets/rotate-cw-L_seGUwq.js +0 -1
  65. package/dist/assets/security-config-Cqxe13za.js +0 -1
  66. package/dist/assets/skeleton-D72EdBNa.js +0 -1
  67. package/dist/assets/x-Cps-je1K.js +0 -1
@@ -180,6 +180,52 @@ describe('useSessionConversationController backend run queue', () => {
180
180
  expect(params.resetComposer).toHaveBeenCalledTimes(1);
181
181
  });
182
182
 
183
+ it('sends a preset message without clearing the existing composer draft', async () => {
184
+ const send = createSendMock();
185
+ const params = createControllerParams({ isRunning: false, send });
186
+ const paramsWithSelectedSkill = {
187
+ ...params,
188
+ inputSnapshot: {
189
+ ...params.inputSnapshot,
190
+ selectedSkills: ['skill-ref'],
191
+ skillRecords: [{ ref: 'skill-ref', name: 'Selected skill' }],
192
+ },
193
+ } as unknown as Parameters<typeof useSessionConversationController>[0];
194
+ const { result } = renderHook(() => useSessionConversationController(paramsWithSelectedSkill));
195
+
196
+ await act(async () => {
197
+ await result.current.sendPresetMessage('Update this session title');
198
+ });
199
+
200
+ const envelope = send.mock.calls[0]?.[0];
201
+ expect(envelope).toMatchObject({
202
+ sessionId: 'session-1',
203
+ message: expect.objectContaining({
204
+ parts: [{ type: 'text', text: 'Update this session title' }],
205
+ }),
206
+ });
207
+ expect(envelope?.metadata).not.toHaveProperty('requested_skill_refs');
208
+ expect(params.resetComposer).not.toHaveBeenCalled();
209
+ expect(params.restoreComposer).not.toHaveBeenCalled();
210
+ });
211
+
212
+ it('keeps the existing composer draft untouched when a preset message fails', async () => {
213
+ const sendError = new Error('preset submission failed');
214
+ const send = vi.fn<TestAgentSend>(async () => {
215
+ throw sendError;
216
+ });
217
+ const params = createControllerParams({ isRunning: false, send });
218
+ const { result } = renderHook(() => useSessionConversationController(params));
219
+
220
+ await act(async () => {
221
+ await expect(result.current.sendPresetMessage('Update title')).rejects.toThrow(sendError);
222
+ });
223
+
224
+ expect(params.resetComposer).not.toHaveBeenCalled();
225
+ expect(params.restoreComposer).not.toHaveBeenCalled();
226
+ expect(params.setSendError).toHaveBeenLastCalledWith(sendError.message);
227
+ });
228
+
183
229
  it('projects a submitting queue row before the backend acknowledges the message', async () => {
184
230
  let resolveSend!: (handle: NcpRunHandle) => void;
185
231
  const send = vi.fn<TestAgentSend>(() => new Promise((resolve) => {
@@ -21,6 +21,18 @@ const MODEL_OPTIONS: ChatModelOption[] = [
21
21
  },
22
22
  ];
23
23
 
24
+ const THINKING_MODEL_OPTIONS: ChatModelOption[] = [
25
+ {
26
+ value: 'openai/gpt-5.6',
27
+ modelLabel: 'GPT-5.6',
28
+ providerLabel: 'OpenAI',
29
+ thinkingCapability: {
30
+ supported: ['off', 'low', 'medium', 'high'],
31
+ default: 'high',
32
+ },
33
+ },
34
+ ];
35
+
24
36
  describe('useSessionConversationInputState session preferences', () => {
25
37
  beforeEach(() => {
26
38
  window.localStorage.clear();
@@ -36,6 +48,25 @@ describe('useSessionConversationInputState session preferences', () => {
36
48
  expect(result.current.inputSnapshot.composerFocusRequestId).toBe(1);
37
49
  });
38
50
 
51
+ it('does not let first preference hydration overwrite an explicit off selection', () => {
52
+ const { result } = renderHook(() => useSessionConversationInputState());
53
+
54
+ act(() => {
55
+ result.current.inputActions.setSelectedThinkingLevel('off');
56
+ });
57
+ act(() => {
58
+ result.current.inputActions.syncSessionPreferences({
59
+ fallbackPreferredThinking: 'high',
60
+ modelOptions: THINKING_MODEL_OPTIONS,
61
+ selectedSessionExists: false,
62
+ selectedSessionKey: null,
63
+ selectedSessionType: 'native',
64
+ });
65
+ });
66
+
67
+ expect(result.current.inputSnapshot.selectedThinkingLevel).toBe('off');
68
+ });
69
+
39
70
  it('restores the switched session model after its metadata becomes available', () => {
40
71
  const { result } = renderHook(() => useSessionConversationInputState());
41
72
 
@@ -0,0 +1,110 @@
1
+ import { act, renderHook, waitFor } from '@testing-library/react';
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
3
+
4
+ import { useSessionConversationPreferencePersistence } from '@/features/chat/features/conversation/hooks/use-session-conversation-preference-persistence';
5
+ import type { SessionConversationInputActions } from '@/features/chat/features/conversation/hooks/use-session-conversation-input-state';
6
+
7
+ const mocks = vi.hoisted(() => ({
8
+ updateSession: vi.fn(),
9
+ }));
10
+
11
+ vi.mock('@/features/chat/features/session/hooks/use-chat-session-update', () => ({
12
+ useChatSessionUpdate: () => mocks.updateSession,
13
+ }));
14
+
15
+ function createInputActions() {
16
+ return {
17
+ update: vi.fn(),
18
+ } as unknown as SessionConversationInputActions;
19
+ }
20
+
21
+ describe('useSessionConversationPreferencePersistence', () => {
22
+ beforeEach(() => {
23
+ vi.clearAllMocks();
24
+ mocks.updateSession.mockResolvedValue(undefined);
25
+ });
26
+
27
+ it('persists explicit off for a selected session without a success toast', async () => {
28
+ const inputActions = createInputActions();
29
+ const { result } = renderHook(() => useSessionConversationPreferencePersistence({
30
+ inputActions,
31
+ selectedSessionKey: 'session-1',
32
+ }));
33
+
34
+ act(() => result.current(
35
+ { preferredThinking: 'off' },
36
+ { selectedThinkingLevel: 'off' },
37
+ { selectedThinkingLevel: 'high' },
38
+ ));
39
+
40
+ await waitFor(() => expect(mocks.updateSession).toHaveBeenCalledWith({
41
+ invalidateSessionSkills: false,
42
+ sessionKey: 'session-1',
43
+ patch: { preferredThinking: 'off' },
44
+ successMessage: null,
45
+ }));
46
+ });
47
+
48
+ it('rolls back the latest failed thinking mutation only when it is still visible', async () => {
49
+ mocks.updateSession.mockRejectedValueOnce(new Error('save failed'));
50
+ const inputActions = createInputActions();
51
+ const { result } = renderHook(() => useSessionConversationPreferencePersistence({
52
+ inputActions,
53
+ selectedSessionKey: 'session-1',
54
+ }));
55
+
56
+ act(() => result.current(
57
+ { preferredThinking: 'off' },
58
+ { selectedThinkingLevel: 'off' },
59
+ { selectedThinkingLevel: 'high' },
60
+ ));
61
+
62
+ await waitFor(() => expect(inputActions.update).toHaveBeenCalledOnce());
63
+ const rollback = vi.mocked(inputActions.update).mock.calls[0]?.[0];
64
+ expect(typeof rollback).toBe('function');
65
+ expect((rollback as (current: never) => unknown)({
66
+ selectedModel: 'openai/gpt-5.6',
67
+ selectedThinkingLevel: 'off',
68
+ } as never)).toEqual({ selectedThinkingLevel: 'high' });
69
+ });
70
+
71
+ it('rolls back a failed runtime-default model selection using its UI value', async () => {
72
+ mocks.updateSession.mockRejectedValueOnce(new Error('save failed'));
73
+ const inputActions = createInputActions();
74
+ const { result } = renderHook(() => useSessionConversationPreferencePersistence({
75
+ inputActions,
76
+ selectedSessionKey: 'session-1',
77
+ }));
78
+
79
+ act(() => result.current(
80
+ { preferredModel: null },
81
+ { selectedModel: '__runtime_default__' },
82
+ { selectedModel: 'openai/gpt-5.6' },
83
+ ));
84
+
85
+ await waitFor(() => expect(inputActions.update).toHaveBeenCalledOnce());
86
+ const rollback = vi.mocked(inputActions.update).mock.calls[0]?.[0];
87
+ expect((rollback as (current: never) => unknown)({
88
+ selectedModel: '__runtime_default__',
89
+ selectedThinkingLevel: 'high',
90
+ } as never)).toEqual({ selectedModel: 'openai/gpt-5.6' });
91
+ });
92
+
93
+ it('does not write a draft preference to a remote session', async () => {
94
+ const inputActions = createInputActions();
95
+ const { result } = renderHook(() => useSessionConversationPreferencePersistence({
96
+ inputActions,
97
+ selectedSessionKey: null,
98
+ }));
99
+
100
+ act(() => result.current(
101
+ { preferredThinking: 'off' },
102
+ { selectedThinkingLevel: 'off' },
103
+ { selectedThinkingLevel: 'high' },
104
+ ));
105
+ await Promise.resolve();
106
+
107
+ expect(mocks.updateSession).not.toHaveBeenCalled();
108
+ expect(inputActions.update).not.toHaveBeenCalled();
109
+ });
110
+ });
@@ -7,6 +7,7 @@ import { useSessionConversationSlashCommands } from '@/features/chat/features/co
7
7
  const mocks = vi.hoisted(() => ({
8
8
  compactNcpSessionContext: vi.fn(),
9
9
  openSideChatDraft: vi.fn(),
10
+ sendPresetMessage: vi.fn(),
10
11
  }));
11
12
 
12
13
  vi.mock('sonner', () => ({
@@ -35,12 +36,14 @@ describe('useSessionConversationSlashCommands', () => {
35
36
  it('exposes the runtime compaction command for the selected session', async () => {
36
37
  const { result } = renderHook(() => useSessionConversationSlashCommands({
37
38
  language: 'en',
39
+ onSendPresetMessage: mocks.sendPresetMessage,
38
40
  selectedSessionKey: ' session-1 ',
39
41
  }));
40
42
  const command = result.current.find((entry) => entry.key === 'compact-context');
41
43
 
42
44
  expect(result.current.map(({ key, icon }) => ({ key, icon }))).toEqual([
43
45
  { key: 'side-chat', icon: 'message-square-plus' },
46
+ { key: 'update-session-title', icon: 'command' },
44
47
  { key: 'compact-context', icon: 'list-collapse' },
45
48
  ]);
46
49
  act(() => command?.onSelect());
@@ -56,6 +59,7 @@ describe('useSessionConversationSlashCommands', () => {
56
59
  }));
57
60
  const { result } = renderHook(() => useSessionConversationSlashCommands({
58
61
  language: 'en',
62
+ onSendPresetMessage: mocks.sendPresetMessage,
59
63
  selectedSessionKey: 'session-1',
60
64
  }));
61
65
  const command = result.current.find((entry) => entry.key === 'compact-context');
@@ -83,6 +87,7 @@ describe('useSessionConversationSlashCommands', () => {
83
87
  const onContextCompactingChange = vi.fn();
84
88
  const { result } = renderHook(() => useSessionConversationSlashCommands({
85
89
  language: 'en',
90
+ onSendPresetMessage: mocks.sendPresetMessage,
86
91
  selectedSessionKey: 'session-1',
87
92
  onContextCompactingChange,
88
93
  }));
@@ -100,9 +105,24 @@ describe('useSessionConversationSlashCommands', () => {
100
105
  ));
101
106
  });
102
107
 
108
+ it('sends the localized title update prompt through the conversation callback', () => {
109
+ const { result } = renderHook(() => useSessionConversationSlashCommands({
110
+ language: 'en',
111
+ onSendPresetMessage: mocks.sendPresetMessage,
112
+ selectedSessionKey: 'session-1',
113
+ }));
114
+
115
+ act(() => result.current.find((entry) => entry.key === 'update-session-title')?.onSelect());
116
+
117
+ expect(mocks.sendPresetMessage).toHaveBeenCalledWith(
118
+ expect.stringContaining('sessions_update'),
119
+ );
120
+ });
121
+
103
122
  it('does not expose session commands before a session exists', () => {
104
123
  const { result } = renderHook(() => useSessionConversationSlashCommands({
105
124
  language: 'en',
125
+ onSendPresetMessage: mocks.sendPresetMessage,
106
126
  selectedSessionKey: null,
107
127
  }));
108
128
 
@@ -3,7 +3,10 @@ import { buildNcpRequestEnvelope } from '@nextclaw/ncp-react';
3
3
  import type { UiNcpSessionQueuedInputView } from '@nextclaw/client-sdk';
4
4
  import type { NcpAgentSendEnvelope, NcpRunHandle } from '@nextclaw/ncp';
5
5
 
6
- import { deriveNcpMessagePartsFromComposer } from '@/features/chat/features/input/utils/chat-composer-state.utils';
6
+ import {
7
+ createChatComposerNodesFromDraft,
8
+ deriveNcpMessagePartsFromComposer,
9
+ } from '@/features/chat/features/input/utils/chat-composer-state.utils';
7
10
  import { isNcpChatSendDisabled } from '@/features/chat/features/input/utils/ncp-chat-input-availability.utils';
8
11
  import { buildChatRunMetadata } from '@/features/chat/features/session/utils/chat-run-metadata.utils';
9
12
  import { createNcpSessionId } from '@/features/chat/features/session/utils/ncp-session-adapter.utils';
@@ -74,6 +77,7 @@ type UseSessionConversationControllerParams = {
74
77
 
75
78
  type BuildSubmissionDraftParams = {
76
79
  readonly agentIsSending: boolean;
80
+ readonly composerSnapshot?: ComposerDraftSnapshot;
77
81
  readonly inputSnapshot: SessionConversationInputSnapshot;
78
82
  readonly inputQuery: SessionConversationInputQuery;
79
83
  readonly isRuntimeBlocked: boolean;
@@ -87,6 +91,8 @@ type SubmissionDraft = {
87
91
  readonly metadata: Record<string, unknown>;
88
92
  };
89
93
 
94
+ type SubmissionComposerBehavior = 'preserve' | 'reset-and-restore';
95
+
90
96
  type SubmittingQueuedInput = {
91
97
  readonly id: string;
92
98
  readonly preview: string;
@@ -113,6 +119,7 @@ function buildInputAvailabilitySnapshot(inputQuery: SessionConversationInputQuer
113
119
  function buildSubmissionDraft(params: BuildSubmissionDraftParams): SubmissionDraft | null {
114
120
  const {
115
121
  agentIsSending,
122
+ composerSnapshot: requestedComposerSnapshot,
116
123
  inputQuery,
117
124
  inputSnapshot,
118
125
  isRuntimeBlocked,
@@ -120,9 +127,16 @@ function buildSubmissionDraft(params: BuildSubmissionDraftParams): SubmissionDra
120
127
  selectedAgentId,
121
128
  sessionKey,
122
129
  } = params;
130
+ const composerSnapshot: ComposerDraftSnapshot = requestedComposerSnapshot ?? {
131
+ text: inputSnapshot.text,
132
+ nodes: [...inputSnapshot.nodes],
133
+ selectedSkills: [...inputSnapshot.selectedSkills],
134
+ skillRecords: [...inputSnapshot.skillRecords],
135
+ attachments: [...inputSnapshot.attachments],
136
+ };
123
137
  const currentParts = deriveNcpMessagePartsFromComposer(
124
- [...inputSnapshot.nodes],
125
- inputSnapshot.attachments,
138
+ [...composerSnapshot.nodes],
139
+ composerSnapshot.attachments,
126
140
  );
127
141
  if (
128
142
  isNcpChatSendDisabled({
@@ -134,13 +148,6 @@ function buildSubmissionDraft(params: BuildSubmissionDraftParams): SubmissionDra
134
148
  ) {
135
149
  return null;
136
150
  }
137
- const composerSnapshot: ComposerDraftSnapshot = {
138
- text: inputSnapshot.text,
139
- nodes: [...inputSnapshot.nodes],
140
- selectedSkills: [...inputSnapshot.selectedSkills],
141
- skillRecords: [...inputSnapshot.skillRecords],
142
- attachments: [...inputSnapshot.attachments],
143
- };
144
151
  return {
145
152
  composerSnapshot,
146
153
  metadata: buildChatRunMetadata({
@@ -165,11 +172,11 @@ function buildSubmissionDraft(params: BuildSubmissionDraftParams): SubmissionDra
165
172
  : sessionKey
166
173
  ? inputQuery.selectedSession?.projectRoot ?? null
167
174
  : inputSnapshot.pendingProjectRoot,
168
- requestedSkills: [...inputSnapshot.selectedSkills],
175
+ requestedSkills: [...composerSnapshot.selectedSkills],
169
176
  skillRecords: inputQuery.skillRecords.filter((record) =>
170
- inputSnapshot.selectedSkills.includes(record.ref)
177
+ composerSnapshot.selectedSkills.includes(record.ref)
171
178
  ),
172
- composerNodes: [...inputSnapshot.nodes],
179
+ composerNodes: [...composerSnapshot.nodes],
173
180
  sessionMaterialization: materializationContext
174
181
  ? {
175
182
  kind: materializationContext.kind,
@@ -288,6 +295,72 @@ function useQueuedInputActions(params: {
288
295
  return { deleteQueuedInput, editQueuedInput };
289
296
  }
290
297
 
298
+ function useSubmissionDraftRunner(params: {
299
+ readonly agent: SessionConversationAgent;
300
+ readonly clearSubmittingInput: (input: SubmittingQueuedInput) => void;
301
+ readonly materializationContext?: SessionConversationMaterializationContext | null;
302
+ readonly onSessionMaterialized?: (sessionKey: string) => void;
303
+ readonly resetComposer: () => void;
304
+ readonly restoreComposer: (snapshot: ComposerDraftSnapshot) => void;
305
+ readonly runQueue: SessionRunQueue;
306
+ readonly sessionKey: string | null;
307
+ readonly setSendError: (message: string | null) => void;
308
+ readonly stageSubmittingInput: (envelope: NcpAgentSendEnvelope) => SubmittingQueuedInput | null;
309
+ }) {
310
+ const {
311
+ agent,
312
+ clearSubmittingInput,
313
+ materializationContext,
314
+ onSessionMaterialized,
315
+ resetComposer,
316
+ restoreComposer,
317
+ runQueue,
318
+ sessionKey,
319
+ setSendError,
320
+ stageSubmittingInput,
321
+ } = params;
322
+ return useCallback(async (
323
+ draft: SubmissionDraft,
324
+ composerBehavior: SubmissionComposerBehavior,
325
+ ) => {
326
+ const submissionSessionKey =
327
+ sessionKey ?? (materializationContext ? null : createNcpSessionId());
328
+ const envelope = buildSubmissionEnvelope(draft, submissionSessionKey);
329
+ if (!envelope) return;
330
+ const queuedSubmission = agent.isRunning ? stageSubmittingInput(envelope) : null;
331
+ if (composerBehavior === 'reset-and-restore') resetComposer();
332
+ setSendError(null);
333
+ try {
334
+ const handle = await agent.send(envelope);
335
+ if (queuedSubmission && handle?.runId === null) {
336
+ await runQueue.refreshQueuedInputs().catch(() => undefined);
337
+ }
338
+ if (queuedSubmission) clearSubmittingInput(queuedSubmission);
339
+ const materializedSessionKey =
340
+ handle?.sessionId?.trim() || agent.snapshot.activeRun?.sessionId?.trim() || null;
341
+ if (!sessionKey && materializedSessionKey) {
342
+ onSessionMaterialized?.(materializedSessionKey);
343
+ }
344
+ } catch (error) {
345
+ if (queuedSubmission) clearSubmittingInput(queuedSubmission);
346
+ if (composerBehavior === 'reset-and-restore') restoreComposer(draft.composerSnapshot);
347
+ setSendError(error instanceof Error ? error.message : String(error));
348
+ throw error;
349
+ }
350
+ }, [
351
+ agent,
352
+ clearSubmittingInput,
353
+ materializationContext,
354
+ onSessionMaterialized,
355
+ resetComposer,
356
+ restoreComposer,
357
+ runQueue,
358
+ sessionKey,
359
+ setSendError,
360
+ stageSubmittingInput,
361
+ ]);
362
+ }
363
+
291
364
  export function useSessionConversationController(params: UseSessionConversationControllerParams) {
292
365
  const {
293
366
  agent,
@@ -343,6 +416,19 @@ export function useSessionConversationController(params: UseSessionConversationC
343
416
  setSendError,
344
417
  });
345
418
 
419
+ const submitDraft = useSubmissionDraftRunner({
420
+ agent,
421
+ clearSubmittingInput,
422
+ materializationContext,
423
+ onSessionMaterialized,
424
+ resetComposer,
425
+ restoreComposer,
426
+ runQueue,
427
+ sessionKey,
428
+ setSendError,
429
+ stageSubmittingInput,
430
+ });
431
+
346
432
  const send = useCallback(async () => {
347
433
  if (primaryAction === 'continue') {
348
434
  await continueRun();
@@ -357,58 +443,55 @@ export function useSessionConversationController(params: UseSessionConversationC
357
443
  selectedAgentId,
358
444
  sessionKey,
359
445
  });
360
- if (!draft) {
361
- return;
446
+ if (draft) {
447
+ await submitDraft(draft, 'reset-and-restore');
362
448
  }
363
- const submissionSessionKey =
364
- sessionKey ?? (materializationContext ? null : createNcpSessionId());
365
- const envelope = buildSubmissionEnvelope(draft, submissionSessionKey);
366
- if (!envelope) {
449
+ }, [
450
+ agent.isSending,
451
+ continueRun,
452
+ inputQuery,
453
+ inputSnapshot,
454
+ isRuntimeBlocked,
455
+ materializationContext,
456
+ primaryAction,
457
+ selectedAgentId,
458
+ sessionKey,
459
+ submitDraft,
460
+ ]);
461
+
462
+ const sendPresetMessage = useCallback(async (message: string) => {
463
+ const text = message.trim();
464
+ if (!text) {
367
465
  return;
368
466
  }
369
- const queuedSubmission = agent.isRunning ? stageSubmittingInput(envelope) : null;
370
- resetComposer();
371
- setSendError(null);
372
- try {
373
- const handle = await agent.send(envelope);
374
- if (queuedSubmission && handle?.runId === null) {
375
- await runQueue.refreshQueuedInputs().catch(() => undefined);
376
- }
377
- if (queuedSubmission) {
378
- clearSubmittingInput(queuedSubmission);
379
- }
380
- const materializedSessionKey =
381
- handle?.sessionId?.trim() ||
382
- agent.snapshot.activeRun?.sessionId?.trim() ||
383
- null;
384
- if (!sessionKey && materializedSessionKey) {
385
- onSessionMaterialized?.(materializedSessionKey);
386
- }
387
- } catch (error) {
388
- if (queuedSubmission) {
389
- clearSubmittingInput(queuedSubmission);
390
- }
391
- restoreComposer(draft.composerSnapshot);
392
- setSendError(error instanceof Error ? error.message : String(error));
393
- throw error;
467
+ const draft = buildSubmissionDraft({
468
+ agentIsSending: agent.isSending,
469
+ composerSnapshot: {
470
+ text,
471
+ nodes: createChatComposerNodesFromDraft(text),
472
+ selectedSkills: [],
473
+ skillRecords: [],
474
+ attachments: [],
475
+ },
476
+ inputSnapshot,
477
+ inputQuery,
478
+ isRuntimeBlocked,
479
+ materializationContext,
480
+ selectedAgentId,
481
+ sessionKey,
482
+ });
483
+ if (draft) {
484
+ await submitDraft(draft, 'preserve');
394
485
  }
395
486
  }, [
396
- agent,
397
- clearSubmittingInput,
398
- continueRun,
487
+ agent.isSending,
399
488
  inputQuery,
400
489
  inputSnapshot,
401
490
  isRuntimeBlocked,
402
491
  materializationContext,
403
- onSessionMaterialized,
404
- resetComposer,
405
- restoreComposer,
406
492
  selectedAgentId,
407
493
  sessionKey,
408
- setSendError,
409
- stageSubmittingInput,
410
- primaryAction,
411
- runQueue,
494
+ submitDraft,
412
495
  ]);
413
496
 
414
497
  const stop = useCallback(async () => {
@@ -426,6 +509,7 @@ export function useSessionConversationController(params: UseSessionConversationC
426
509
  isSending,
427
510
  queuedInputs,
428
511
  send,
512
+ sendPresetMessage,
429
513
  sendDisabled,
430
514
  primaryAction,
431
515
  stop,
@@ -196,7 +196,7 @@ function usePersistedConversationInputSnapshot(
196
196
  });
197
197
  }, [draftKey, initialSnapshot]);
198
198
 
199
- return { snapshot, update };
199
+ return { draftKey, snapshot, update };
200
200
  }
201
201
 
202
202
  export const useSessionConversationInputState = (
@@ -204,7 +204,7 @@ export const useSessionConversationInputState = (
204
204
  sessionKey: string | null = null,
205
205
  ) => {
206
206
  const pendingProjectRoot = useChatThreadStore((state) => state.snapshot.draftProjectRoot);
207
- const { snapshot, update } = usePersistedConversationInputSnapshot(
207
+ const { draftKey, snapshot, update } = usePersistedConversationInputSnapshot(
208
208
  initialPrompt,
209
209
  sessionKey,
210
210
  );
@@ -268,6 +268,7 @@ export const useSessionConversationInputState = (
268
268
  });
269
269
 
270
270
  const preferenceActions = useSessionConversationPreferenceActions({
271
+ preferenceOwnerKey: draftKey,
271
272
  selectedModel: snapshot.selectedModel,
272
273
  selectedThinkingLevel: snapshot.selectedThinkingLevel,
273
274
  updatePreferences: update,
@@ -22,6 +22,7 @@ export type SessionConversationPreferenceSyncParams = {
22
22
  };
23
23
 
24
24
  type SessionConversationPreferenceActionsParams = {
25
+ readonly preferenceOwnerKey: string;
25
26
  readonly selectedModel: ConversationPreferenceValue;
26
27
  readonly selectedThinkingLevel: ThinkingLevel | null;
27
28
  readonly updatePreferences: (patch: {
@@ -31,14 +32,23 @@ type SessionConversationPreferenceActionsParams = {
31
32
  };
32
33
 
33
34
  export const useSessionConversationPreferenceActions = ({
35
+ preferenceOwnerKey,
34
36
  selectedModel: currentSelectedModel,
35
37
  selectedThinkingLevel: currentSelectedThinkingLevel,
36
38
  updatePreferences,
37
39
  }: SessionConversationPreferenceActionsParams) => {
38
40
  const previousPreferenceContextRef = useRef<string | undefined>(undefined);
41
+ const localThinkingSelectionRef = useRef<{
42
+ readonly ownerKey: string;
43
+ readonly value: ThinkingLevel | null;
44
+ } | null>(null);
39
45
  const setSelectedThinkingLevel = useCallback((selectedThinkingLevel: ThinkingLevel | null) => {
46
+ localThinkingSelectionRef.current = {
47
+ ownerKey: preferenceOwnerKey,
48
+ value: selectedThinkingLevel,
49
+ };
40
50
  updatePreferences({ selectedThinkingLevel });
41
- }, [updatePreferences]);
51
+ }, [preferenceOwnerKey, updatePreferences]);
42
52
  const syncSessionPreferences = useCallback(({
43
53
  defaultModel,
44
54
  fallbackPreferredModel,
@@ -54,6 +64,17 @@ export const useSessionConversationPreferenceActions = ({
54
64
  const preferenceContextKey = JSON.stringify([selectedSessionType, selectedSessionKey]);
55
65
  const preferenceContextChanged = previousPreferenceContextRef.current !== preferenceContextKey;
56
66
  const preserveCurrentPreference = preferenceContextChanged && Boolean(selectedSessionKey) && !selectedSessionExists;
67
+ const localThinkingSelection = localThinkingSelectionRef.current;
68
+ const localThinkingSelectionAcknowledged =
69
+ localThinkingSelection?.ownerKey === preferenceOwnerKey &&
70
+ selectedSessionExists &&
71
+ selectedSessionPreferredThinking === localThinkingSelection.value;
72
+ if (localThinkingSelectionAcknowledged) {
73
+ localThinkingSelectionRef.current = null;
74
+ }
75
+ const preserveLocalThinkingSelection =
76
+ localThinkingSelection?.ownerKey === preferenceOwnerKey &&
77
+ !localThinkingSelectionAcknowledged;
57
78
  const selectedModel = resolveSelectedModelValue({
58
79
  currentSelectedModel: currentSelectedModel ?? undefined,
59
80
  modelOptions,
@@ -73,7 +94,8 @@ export const useSessionConversationPreferenceActions = ({
73
94
  defaultThinkingLevel:
74
95
  (modelOption?.thinkingCapability?.default as ThinkingLevel | null | undefined) ?? null,
75
96
  preferSessionPreferredThinking: preferenceContextChanged,
76
- preserveCurrentSelectedThinkingOnSessionChange: preserveCurrentPreference,
97
+ preserveCurrentSelectedThinkingOnSessionChange:
98
+ preserveCurrentPreference || preserveLocalThinkingSelection,
77
99
  });
78
100
  if (
79
101
  currentSelectedModel !== selectedModel ||
@@ -84,7 +106,12 @@ export const useSessionConversationPreferenceActions = ({
84
106
  if (!selectedSessionKey || selectedSessionExists) {
85
107
  previousPreferenceContextRef.current = preferenceContextKey;
86
108
  }
87
- }, [currentSelectedModel, currentSelectedThinkingLevel, updatePreferences]);
109
+ }, [
110
+ currentSelectedModel,
111
+ currentSelectedThinkingLevel,
112
+ preferenceOwnerKey,
113
+ updatePreferences,
114
+ ]);
88
115
 
89
116
  return useMemo(() => ({
90
117
  setSelectedThinkingLevel,