@nextclaw/ui 0.8.0 → 0.9.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 (72) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/assets/ChannelsList-DhvjpZcs.js +1 -0
  3. package/dist/assets/ChatPage-B8VBaMQm.js +38 -0
  4. package/dist/assets/{DocBrowser-DDX2HMXW.js → DocBrowser-LpzGe8An.js} +1 -1
  5. package/dist/assets/{LogoBadge-J53F_3JA.js → LogoBadge-Be4lktJN.js} +1 -1
  6. package/dist/assets/{MarketplacePage-0BZ4bza0.js → MarketplacePage-Cx9AI3_h.js} +3 -3
  7. package/dist/assets/{ModelConfig-Wzq9wGHV.js → ModelConfig-DuImUHIX.js} +1 -1
  8. package/dist/assets/ProvidersList-Ccleg25k.js +1 -0
  9. package/dist/assets/{RuntimeConfig-N771_AM6.js → RuntimeConfig-C6iqpJR_.js} +1 -1
  10. package/dist/assets/{SearchConfig-DVt5QVa_.js → SearchConfig-Dvp1TAXu.js} +1 -1
  11. package/dist/assets/{SecretsConfig-CkwauPa8.js → SecretsConfig-D5Ymlvt9.js} +1 -1
  12. package/dist/assets/{SessionsConfig-C3mnHzkZ.js → SessionsConfig-CIA_jA1P.js} +2 -2
  13. package/dist/assets/{chat-message-pxr79GDs.js → chat-message-B60Fh9kI.js} +1 -1
  14. package/dist/assets/index-BiPDnzv0.js +8 -0
  15. package/dist/assets/index-C8GsgIUn.css +1 -0
  16. package/dist/assets/{index-GdpEEKnz.js → index-CPDASUXh.js} +1 -1
  17. package/dist/assets/{label-CmksBHgc.js → label-D4fGx6Wb.js} +1 -1
  18. package/dist/assets/{page-layout-Db0GbnhS.js → page-layout-twy8gmBE.js} +1 -1
  19. package/dist/assets/popover-DYbYpt1j.js +1 -0
  20. package/dist/assets/{security-config-CjLFME5Q.js → security-config-BcIZ4rpb.js} +1 -1
  21. package/dist/assets/skeleton-DypBy7jp.js +1 -0
  22. package/dist/assets/{switch-C24d-UJU.js → switch-DqA6r5XR.js} +1 -1
  23. package/dist/assets/tabs-custom-C6enKKs1.js +1 -0
  24. package/dist/assets/{useConfirmDialog-BeP35LcG.js → useConfirmDialog-CHBf5Of7.js} +1 -1
  25. package/dist/assets/{vendor-psXJBy9u.js → vendor-DKBNiC31.js} +1 -1
  26. package/dist/index.html +3 -3
  27. package/package.json +6 -6
  28. package/src/api/config.ts +9 -38
  29. package/src/api/ncp-session.ts +50 -0
  30. package/src/api/types.ts +1 -0
  31. package/src/components/chat/ChatConversationPanel.test.tsx +65 -0
  32. package/src/components/chat/ChatConversationPanel.tsx +21 -12
  33. package/src/components/chat/ChatSidebar.test.tsx +203 -0
  34. package/src/components/chat/ChatSidebar.tsx +97 -7
  35. package/src/components/chat/adapters/chat-message.adapter.test.ts +132 -82
  36. package/src/components/chat/adapters/chat-message.adapter.ts +27 -9
  37. package/src/components/chat/chat-composer-state.ts +53 -0
  38. package/src/components/chat/chat-page-data.ts +30 -1
  39. package/src/components/chat/chat-page-runtime.test.ts +181 -0
  40. package/src/components/chat/chat-page-runtime.ts +101 -15
  41. package/src/components/chat/chat-session-preference-sync.test.ts +62 -0
  42. package/src/components/chat/chat-session-preference-sync.ts +75 -0
  43. package/src/components/chat/chat-stream/types.ts +3 -0
  44. package/src/components/chat/containers/chat-input-bar.container.tsx +12 -63
  45. package/src/components/chat/containers/chat-message-list.container.tsx +31 -27
  46. package/src/components/chat/legacy/LegacyChatPage.tsx +25 -0
  47. package/src/components/chat/managers/chat-input.manager.ts +48 -13
  48. package/src/components/chat/managers/chat-session-list.manager.test.ts +39 -0
  49. package/src/components/chat/managers/chat-session-list.manager.ts +9 -3
  50. package/src/components/chat/ncp/NcpChatPage.tsx +53 -13
  51. package/src/components/chat/ncp/ncp-chat-input.manager.ts +48 -12
  52. package/src/components/chat/ncp/ncp-chat-page-data.ts +34 -2
  53. package/src/components/chat/ncp/ncp-chat-thread.manager.ts +1 -1
  54. package/src/components/chat/ncp/ncp-session-adapter.test.ts +27 -1
  55. package/src/components/chat/ncp/ncp-session-adapter.ts +20 -0
  56. package/src/components/chat/presenter/chat-presenter-context.tsx +2 -0
  57. package/src/components/chat/stores/chat-input.store.ts +4 -0
  58. package/src/components/chat/stores/chat-thread.store.ts +2 -0
  59. package/src/components/chat/useChatSessionTypeState.test.tsx +58 -0
  60. package/src/components/chat/useChatSessionTypeState.ts +25 -8
  61. package/src/hooks/use-ncp-chat-session-types.ts +11 -0
  62. package/src/hooks/useConfig.ts +2 -4
  63. package/src/hooks/useMarketplace.ts +7 -4
  64. package/src/hooks/useWebSocket.ts +23 -2
  65. package/dist/assets/ChannelsList-DBcoVJRW.js +0 -1
  66. package/dist/assets/ChatPage-CD3cxyyM.js +0 -37
  67. package/dist/assets/ProvidersList-kwzRS8_M.js +0 -1
  68. package/dist/assets/index-BIvFMkN4.js +0 -1
  69. package/dist/assets/index-CzkY1reu.js +0 -8
  70. package/dist/assets/index-RZ0kHHRI.css +0 -1
  71. package/dist/assets/skeleton-CkpQeVWN.js +0 -1
  72. package/dist/assets/tabs-custom-D89bh-fc.js +0 -1
@@ -0,0 +1,181 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { SessionEntryView } from '@/api/types';
3
+ import { resolveRecentSessionPreferredModel, resolveSelectedModelValue } from '@/components/chat/chat-page-runtime';
4
+
5
+ const modelOptions = [
6
+ {
7
+ value: 'anthropic/claude-sonnet-4',
8
+ modelLabel: 'claude-sonnet-4',
9
+ providerLabel: 'Anthropic',
10
+ thinkingCapability: null
11
+ },
12
+ {
13
+ value: 'openai/gpt-5',
14
+ modelLabel: 'gpt-5',
15
+ providerLabel: 'OpenAI',
16
+ thinkingCapability: null
17
+ }
18
+ ];
19
+
20
+ function createSession(overrides: Partial<SessionEntryView> & Pick<SessionEntryView, 'key'>): SessionEntryView {
21
+ return {
22
+ key: overrides.key,
23
+ createdAt: overrides.createdAt ?? '2026-03-19T00:00:00.000Z',
24
+ updatedAt: overrides.updatedAt ?? '2026-03-19T00:00:00.000Z',
25
+ sessionType: overrides.sessionType ?? 'native',
26
+ sessionTypeMutable: overrides.sessionTypeMutable ?? false,
27
+ messageCount: overrides.messageCount ?? 0,
28
+ ...(overrides.label ? { label: overrides.label } : {}),
29
+ ...(overrides.preferredModel ? { preferredModel: overrides.preferredModel } : {}),
30
+ ...(overrides.lastRole ? { lastRole: overrides.lastRole } : {}),
31
+ ...(overrides.lastTimestamp ? { lastTimestamp: overrides.lastTimestamp } : {})
32
+ };
33
+ }
34
+
35
+ describe('resolveSelectedModelValue', () => {
36
+ it('keeps the current selected model when it is still available', () => {
37
+ expect(
38
+ resolveSelectedModelValue({
39
+ currentSelectedModel: 'openai/gpt-5',
40
+ modelOptions,
41
+ selectedSessionPreferredModel: 'anthropic/claude-sonnet-4',
42
+ fallbackPreferredModel: 'anthropic/claude-sonnet-4',
43
+ defaultModel: 'anthropic/claude-sonnet-4'
44
+ })
45
+ ).toBe('openai/gpt-5');
46
+ });
47
+
48
+ it('prefers the current session preferred model over runtime fallback and global default', () => {
49
+ expect(
50
+ resolveSelectedModelValue({
51
+ currentSelectedModel: 'missing/model',
52
+ modelOptions,
53
+ selectedSessionPreferredModel: 'openai/gpt-5',
54
+ fallbackPreferredModel: 'anthropic/claude-sonnet-4',
55
+ defaultModel: 'anthropic/claude-sonnet-4'
56
+ })
57
+ ).toBe('openai/gpt-5');
58
+ });
59
+
60
+ it('prefers the current session preferred model over a stale in-memory selection after switching sessions', () => {
61
+ expect(
62
+ resolveSelectedModelValue({
63
+ currentSelectedModel: 'anthropic/claude-sonnet-4',
64
+ modelOptions,
65
+ selectedSessionPreferredModel: 'openai/gpt-5',
66
+ fallbackPreferredModel: 'anthropic/claude-sonnet-4',
67
+ defaultModel: 'anthropic/claude-sonnet-4',
68
+ preferSessionPreferredModel: true
69
+ })
70
+ ).toBe('openai/gpt-5');
71
+ });
72
+
73
+ it('ignores the stale in-memory selection when a switched session has no explicit preferred model', () => {
74
+ expect(
75
+ resolveSelectedModelValue({
76
+ currentSelectedModel: 'anthropic/claude-sonnet-4',
77
+ modelOptions,
78
+ fallbackPreferredModel: 'openai/gpt-5',
79
+ defaultModel: 'anthropic/claude-sonnet-4',
80
+ preferSessionPreferredModel: true
81
+ })
82
+ ).toBe('openai/gpt-5');
83
+ });
84
+
85
+ it('uses the recent same-runtime model when the current session has no valid preferred model', () => {
86
+ expect(
87
+ resolveSelectedModelValue({
88
+ currentSelectedModel: 'missing/model',
89
+ modelOptions,
90
+ fallbackPreferredModel: 'openai/gpt-5',
91
+ defaultModel: 'anthropic/claude-sonnet-4'
92
+ })
93
+ ).toBe('openai/gpt-5');
94
+ });
95
+
96
+ it('falls back to the global default model when the recent same-runtime model is unavailable', () => {
97
+ expect(
98
+ resolveSelectedModelValue({
99
+ currentSelectedModel: 'missing/model',
100
+ modelOptions,
101
+ fallbackPreferredModel: 'missing/model',
102
+ defaultModel: 'anthropic/claude-sonnet-4'
103
+ })
104
+ ).toBe('anthropic/claude-sonnet-4');
105
+ });
106
+
107
+ it('falls back to the first available model when no candidate is valid', () => {
108
+ expect(
109
+ resolveSelectedModelValue({
110
+ currentSelectedModel: 'missing/model',
111
+ modelOptions,
112
+ selectedSessionPreferredModel: 'missing/model',
113
+ fallbackPreferredModel: 'missing/model',
114
+ defaultModel: 'missing/model'
115
+ })
116
+ ).toBe('anthropic/claude-sonnet-4');
117
+ });
118
+ });
119
+
120
+ describe('resolveRecentSessionPreferredModel', () => {
121
+ it('returns the most recent preferred model from the same runtime', () => {
122
+ const sessions = [
123
+ createSession({
124
+ key: 'native-1',
125
+ sessionType: 'native',
126
+ preferredModel: 'anthropic/claude-sonnet-4',
127
+ updatedAt: '2026-03-18T01:00:00.000Z'
128
+ }),
129
+ createSession({
130
+ key: 'codex-1',
131
+ sessionType: 'codex',
132
+ preferredModel: 'openai/gpt-5',
133
+ updatedAt: '2026-03-18T03:00:00.000Z'
134
+ }),
135
+ createSession({
136
+ key: 'codex-2',
137
+ sessionType: 'codex',
138
+ preferredModel: 'anthropic/claude-sonnet-4',
139
+ updatedAt: '2026-03-18T02:00:00.000Z'
140
+ })
141
+ ];
142
+
143
+ expect(
144
+ resolveRecentSessionPreferredModel({
145
+ sessions,
146
+ selectedSessionKey: 'draft',
147
+ sessionType: 'codex'
148
+ })
149
+ ).toBe('openai/gpt-5');
150
+ });
151
+
152
+ it('ignores the currently selected session and sessions without preferred models', () => {
153
+ const sessions = [
154
+ createSession({
155
+ key: 'codex-current',
156
+ sessionType: 'codex',
157
+ preferredModel: 'openai/gpt-5',
158
+ updatedAt: '2026-03-18T03:00:00.000Z'
159
+ }),
160
+ createSession({
161
+ key: 'codex-empty',
162
+ sessionType: 'codex',
163
+ updatedAt: '2026-03-18T04:00:00.000Z'
164
+ }),
165
+ createSession({
166
+ key: 'codex-fallback',
167
+ sessionType: 'codex',
168
+ preferredModel: 'anthropic/claude-sonnet-4',
169
+ updatedAt: '2026-03-18T02:00:00.000Z'
170
+ })
171
+ ];
172
+
173
+ expect(
174
+ resolveRecentSessionPreferredModel({
175
+ sessions,
176
+ selectedSessionKey: 'codex-current',
177
+ sessionType: 'codex'
178
+ })
179
+ ).toBe('anthropic/claude-sonnet-4');
180
+ });
181
+ });
@@ -1,39 +1,125 @@
1
1
  import { useEffect, useMemo, useRef, useState } from 'react';
2
2
  import type { Dispatch, SetStateAction } from 'react';
3
- import type { ChatRunView } from '@/api/types';
3
+ import type { ChatRunView, SessionEntryView } from '@/api/types';
4
4
  import type { ChatModelOption } from '@/components/chat/chat-input.types';
5
5
  import { useChatRuns } from '@/hooks/useConfig';
6
6
  import { buildActiveRunBySessionKey, buildSessionRunStatusByKey } from '@/lib/session-run-status';
7
7
 
8
8
  export type ChatMainPanelView = 'chat' | 'cron' | 'skills';
9
9
 
10
+ function normalizeSessionType(value: string | null | undefined): string {
11
+ const normalized = value?.trim().toLowerCase();
12
+ return normalized || 'native';
13
+ }
14
+
15
+ function hasModelOption(modelOptions: ChatModelOption[], value: string | null | undefined): value is string {
16
+ const normalized = value?.trim();
17
+ if (!normalized) {
18
+ return false;
19
+ }
20
+ return modelOptions.some((option) => option.value === normalized);
21
+ }
22
+
23
+ export function resolveSelectedModelValue(params: {
24
+ currentSelectedModel?: string;
25
+ modelOptions: ChatModelOption[];
26
+ selectedSessionPreferredModel?: string;
27
+ fallbackPreferredModel?: string;
28
+ defaultModel?: string;
29
+ preferSessionPreferredModel?: boolean;
30
+ }): string {
31
+ const {
32
+ currentSelectedModel,
33
+ modelOptions,
34
+ selectedSessionPreferredModel,
35
+ fallbackPreferredModel,
36
+ defaultModel,
37
+ preferSessionPreferredModel = false
38
+ } = params;
39
+ if (modelOptions.length === 0) {
40
+ return '';
41
+ }
42
+ if (!preferSessionPreferredModel && hasModelOption(modelOptions, currentSelectedModel)) {
43
+ return currentSelectedModel.trim();
44
+ }
45
+ if (hasModelOption(modelOptions, selectedSessionPreferredModel)) {
46
+ return selectedSessionPreferredModel.trim();
47
+ }
48
+ if (hasModelOption(modelOptions, fallbackPreferredModel)) {
49
+ return fallbackPreferredModel.trim();
50
+ }
51
+ if (hasModelOption(modelOptions, defaultModel)) {
52
+ return defaultModel.trim();
53
+ }
54
+ return modelOptions[0]?.value ?? '';
55
+ }
56
+
57
+ export function resolveRecentSessionPreferredModel(params: {
58
+ sessions: readonly SessionEntryView[];
59
+ selectedSessionKey?: string | null;
60
+ sessionType?: string | null;
61
+ }): string | undefined {
62
+ const targetSessionType = normalizeSessionType(params.sessionType);
63
+ let bestSession: SessionEntryView | null = null;
64
+ let bestTimestamp = Number.NEGATIVE_INFINITY;
65
+ for (const session of params.sessions) {
66
+ if (session.key === params.selectedSessionKey) {
67
+ continue;
68
+ }
69
+ if (normalizeSessionType(session.sessionType) !== targetSessionType) {
70
+ continue;
71
+ }
72
+ const preferredModel = session.preferredModel?.trim();
73
+ if (!preferredModel) {
74
+ continue;
75
+ }
76
+ const updatedAtTimestamp = Date.parse(session.updatedAt);
77
+ const comparableTimestamp = Number.isFinite(updatedAtTimestamp) ? updatedAtTimestamp : Number.NEGATIVE_INFINITY;
78
+ if (!bestSession || comparableTimestamp > bestTimestamp) {
79
+ bestSession = session;
80
+ bestTimestamp = comparableTimestamp;
81
+ }
82
+ }
83
+ return bestSession?.preferredModel?.trim();
84
+ }
85
+
10
86
  export function useSyncSelectedModel(params: {
11
87
  modelOptions: ChatModelOption[];
88
+ selectedSessionKey?: string | null;
12
89
  selectedSessionPreferredModel?: string;
90
+ fallbackPreferredModel?: string;
13
91
  defaultModel?: string;
14
92
  setSelectedModel: Dispatch<SetStateAction<string>>;
15
93
  }) {
16
- const { modelOptions, selectedSessionPreferredModel, defaultModel, setSelectedModel } = params;
94
+ const {
95
+ modelOptions,
96
+ selectedSessionKey,
97
+ selectedSessionPreferredModel,
98
+ fallbackPreferredModel,
99
+ defaultModel,
100
+ setSelectedModel
101
+ } = params;
102
+ const previousSessionKeyRef = useRef<string | null | undefined>(undefined);
103
+
17
104
  useEffect(() => {
105
+ const sessionChanged = previousSessionKeyRef.current !== selectedSessionKey;
18
106
  if (modelOptions.length === 0) {
19
107
  setSelectedModel('');
108
+ previousSessionKeyRef.current = selectedSessionKey;
20
109
  return;
21
110
  }
22
111
  setSelectedModel((prev) => {
23
- if (modelOptions.some((option) => option.value === prev)) {
24
- return prev;
25
- }
26
- const sessionPreferred = selectedSessionPreferredModel?.trim();
27
- if (sessionPreferred && modelOptions.some((option) => option.value === sessionPreferred)) {
28
- return sessionPreferred;
29
- }
30
- const fallback = defaultModel?.trim();
31
- if (fallback && modelOptions.some((option) => option.value === fallback)) {
32
- return fallback;
33
- }
34
- return modelOptions[0]?.value ?? '';
112
+ return resolveSelectedModelValue({
113
+ currentSelectedModel: prev,
114
+ modelOptions,
115
+ selectedSessionPreferredModel,
116
+ fallbackPreferredModel,
117
+ defaultModel,
118
+ preferSessionPreferredModel: sessionChanged
119
+ });
35
120
  });
36
- }, [defaultModel, modelOptions, selectedSessionPreferredModel, setSelectedModel]);
121
+ previousSessionKeyRef.current = selectedSessionKey;
122
+ }, [defaultModel, fallbackPreferredModel, modelOptions, selectedSessionKey, selectedSessionPreferredModel, setSelectedModel]);
37
123
  }
38
124
 
39
125
  export function useSessionRunStatus(params: {
@@ -0,0 +1,62 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { updateSession } from '@/api/config';
3
+ import { ChatSessionPreferenceSync } from '@/components/chat/chat-session-preference-sync';
4
+ import { useChatInputStore } from '@/components/chat/stores/chat-input.store';
5
+ import { useChatSessionListStore } from '@/components/chat/stores/chat-session-list.store';
6
+
7
+ vi.mock('@/api/config', () => ({
8
+ updateSession: vi.fn(async () => ({
9
+ key: 'session-1',
10
+ totalMessages: 0,
11
+ totalEvents: 0,
12
+ sessionType: 'native',
13
+ sessionTypeMutable: false,
14
+ metadata: {},
15
+ messages: [],
16
+ events: []
17
+ }))
18
+ }));
19
+
20
+ describe('ChatSessionPreferenceSync', () => {
21
+ afterEach(() => {
22
+ useChatInputStore.setState((state) => ({
23
+ snapshot: {
24
+ ...state.snapshot,
25
+ selectedModel: '',
26
+ selectedThinkingLevel: null
27
+ }
28
+ }));
29
+ useChatSessionListStore.setState((state) => ({
30
+ snapshot: {
31
+ ...state.snapshot,
32
+ selectedSessionKey: null
33
+ }
34
+ }));
35
+ vi.clearAllMocks();
36
+ });
37
+
38
+ it('persists the selected model and thinking to the current session metadata', async () => {
39
+ useChatInputStore.setState((state) => ({
40
+ snapshot: {
41
+ ...state.snapshot,
42
+ selectedModel: 'openai/gpt-5',
43
+ selectedThinkingLevel: 'high'
44
+ }
45
+ }));
46
+ useChatSessionListStore.setState((state) => ({
47
+ snapshot: {
48
+ ...state.snapshot,
49
+ selectedSessionKey: 'session-1'
50
+ }
51
+ }));
52
+
53
+ const sync = new ChatSessionPreferenceSync(updateSession);
54
+ sync.syncSelectedSessionPreferences();
55
+ await vi.waitFor(() => {
56
+ expect(updateSession).toHaveBeenCalledWith('session-1', {
57
+ preferredModel: 'openai/gpt-5',
58
+ preferredThinking: 'high'
59
+ });
60
+ });
61
+ });
62
+ });
@@ -0,0 +1,75 @@
1
+ import type { SessionPatchUpdate, ThinkingLevel } from '@/api/types';
2
+ import { useChatInputStore } from '@/components/chat/stores/chat-input.store';
3
+ import { useChatSessionListStore } from '@/components/chat/stores/chat-session-list.store';
4
+
5
+ type QueuedSessionPreferenceSync = {
6
+ sessionKey: string;
7
+ patch: SessionPatchUpdate;
8
+ };
9
+
10
+ function normalizeOptionalModel(value: string): string | null {
11
+ const normalized = value.trim();
12
+ return normalized.length > 0 ? normalized : null;
13
+ }
14
+
15
+ function normalizeOptionalThinking(value: ThinkingLevel | null): ThinkingLevel | null {
16
+ return value ?? null;
17
+ }
18
+
19
+ export class ChatSessionPreferenceSync {
20
+ private inFlight: Promise<void> | null = null;
21
+ private queued: QueuedSessionPreferenceSync | null = null;
22
+
23
+ constructor(
24
+ private readonly updateSession: (
25
+ sessionKey: string,
26
+ patch: SessionPatchUpdate
27
+ ) => Promise<unknown>
28
+ ) {}
29
+
30
+ syncSelectedSessionPreferences = (): void => {
31
+ const inputSnapshot = useChatInputStore.getState().snapshot;
32
+ const sessionSnapshot = useChatSessionListStore.getState().snapshot;
33
+ const sessionKey = sessionSnapshot.selectedSessionKey;
34
+ if (!sessionKey) {
35
+ return;
36
+ }
37
+
38
+ this.enqueue({
39
+ sessionKey,
40
+ patch: {
41
+ preferredModel: normalizeOptionalModel(inputSnapshot.selectedModel),
42
+ preferredThinking: normalizeOptionalThinking(inputSnapshot.selectedThinkingLevel)
43
+ }
44
+ });
45
+ };
46
+
47
+ private enqueue(next: QueuedSessionPreferenceSync): void {
48
+ this.queued = next;
49
+ if (this.inFlight) {
50
+ return;
51
+ }
52
+ this.startFlush();
53
+ }
54
+
55
+ private startFlush(): void {
56
+ this.inFlight = this.flush()
57
+ .catch((error) => {
58
+ console.error(`Failed to sync chat session preferences: ${String(error)}`);
59
+ })
60
+ .finally(() => {
61
+ this.inFlight = null;
62
+ if (this.queued) {
63
+ this.startFlush();
64
+ }
65
+ });
66
+ }
67
+
68
+ private async flush(): Promise<void> {
69
+ while (this.queued) {
70
+ const current = this.queued;
71
+ this.queued = null;
72
+ await this.updateSession(current.sessionKey, current.patch);
73
+ }
74
+ }
75
+ }
@@ -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,19 +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
- buildSessionTypeToolbarSelect,
9
7
  buildSkillPickerModel,
10
8
  buildThinkingToolbarSelect,
11
- resolveSlashQuery,
12
9
  type ChatModelRecord,
13
10
  type ChatSkillRecord,
14
11
  type ChatThinkingLevel
15
12
  } from '@/components/chat/adapters/chat-input-bar.adapter';
16
- import { useChatInputBarController } from '@/components/chat/chat-input/chat-input-bar.controller';
17
13
  import { usePresenter } from '@/components/chat/presenter/chat-presenter-context';
18
14
  import { useI18n } from '@/components/providers/I18nProvider';
19
15
  import { useChatInputStore } from '@/components/chat/stores/chat-input.store';
@@ -73,6 +69,7 @@ export function ChatInputBarContainer() {
73
69
  const presenter = usePresenter();
74
70
  const { language } = useI18n();
75
71
  const snapshot = useChatInputStore((state) => state.snapshot);
72
+ const [slashQuery, setSlashQuery] = useState<string | null>(null);
76
73
 
77
74
  const officialSkillBadgeLabel = useMemo(() => {
78
75
  // Keep memo reactive to locale switches even though `t` is imported as a stable function.
@@ -111,41 +108,11 @@ export function ChatInputBarContainer() {
111
108
  ? t('chatInputPlaceholder')
112
109
  : t('chatModelNoOptions');
113
110
 
114
- const slashQuery = resolveSlashQuery(snapshot.draft);
115
111
  const slashItems = useMemo(
116
112
  () => buildChatSlashItems(skillRecords, slashQuery ?? '', slashTexts),
117
113
  [slashQuery, skillRecords, slashTexts]
118
114
  );
119
115
 
120
- const controller = useChatInputBarController({
121
- isSlashMode: slashQuery !== null,
122
- slashItems,
123
- isSlashLoading: snapshot.isSkillsLoading,
124
- onSelectSlashItem: (item) => {
125
- if (!item.value) {
126
- return;
127
- }
128
- if (!snapshot.selectedSkills.includes(item.value)) {
129
- presenter.chatInputManager.selectSkills([...snapshot.selectedSkills, item.value]);
130
- }
131
- presenter.chatInputManager.setDraft('');
132
- },
133
- onSend: presenter.chatInputManager.send,
134
- onStop: presenter.chatInputManager.stop,
135
- isSending: snapshot.isSending,
136
- canStopGeneration: snapshot.canStopGeneration
137
- });
138
-
139
- const selectedSessionTypeOption =
140
- snapshot.sessionTypeOptions.find((option) => option.value === snapshot.selectedSessionType) ??
141
- (snapshot.selectedSessionType
142
- ? { value: snapshot.selectedSessionType, label: snapshot.selectedSessionType }
143
- : null);
144
- const shouldShowSessionTypeSelector =
145
- snapshot.canEditSessionType &&
146
- (snapshot.sessionTypeOptions.length > 1 ||
147
- Boolean(snapshot.selectedSessionType && snapshot.selectedSessionType !== 'native'));
148
-
149
116
  const selectedModelOption = modelRecords.find((option) => option.value === snapshot.selectedModel);
150
117
  const selectedModelThinkingCapability = selectedModelOption?.thinkingCapability;
151
118
  const thinkingSupportedLevels = selectedModelThinkingCapability?.supported ?? [];
@@ -156,17 +123,6 @@ export function ChatInputBarContainer() {
156
123
  : snapshot.stopDisabledReason?.trim() || t('chatStopUnavailable');
157
124
 
158
125
  const toolbarSelects = [
159
- buildSessionTypeToolbarSelect({
160
- selectedSessionType: snapshot.selectedSessionType,
161
- selectedSessionTypeOption,
162
- sessionTypeOptions: snapshot.sessionTypeOptions,
163
- onValueChange: presenter.chatInputManager.selectSessionType,
164
- canEditSessionType: snapshot.canEditSessionType,
165
- shouldShow: shouldShowSessionTypeSelector,
166
- texts: {
167
- sessionTypePlaceholder: t('chatSessionTypeLabel')
168
- }
169
- }),
170
126
  buildModelToolbarSelect({
171
127
  modelOptions: modelRecords,
172
128
  selectedModel: snapshot.selectedModel,
@@ -205,27 +161,23 @@ export function ChatInputBarContainer() {
205
161
 
206
162
  return (
207
163
  <ChatInputBar
208
- value={snapshot.draft}
209
- placeholder={textareaPlaceholder}
210
- disabled={inputDisabled}
211
- onValueChange={presenter.chatInputManager.setDraft}
212
- onKeyDown={controller.onTextareaKeyDown}
164
+ composer={{
165
+ nodes: snapshot.composerNodes,
166
+ placeholder: textareaPlaceholder,
167
+ disabled: inputDisabled,
168
+ onNodesChange: presenter.chatInputManager.setComposerNodes,
169
+ onSlashQueryChange: setSlashQuery
170
+ }}
213
171
  slashMenu={{
214
- isOpen: controller.isSlashPanelOpen,
215
172
  isLoading: snapshot.isSkillsLoading,
216
173
  items: slashItems,
217
- activeIndex: controller.activeSlashIndex,
218
- activeItem: controller.activeSlashItem,
219
174
  texts: {
220
175
  slashLoadingLabel: t('chatSlashLoading'),
221
176
  slashSectionLabel: t('chatSlashSectionSkills'),
222
177
  slashEmptyLabel: t('chatSlashNoResult'),
223
178
  slashHintLabel: t('chatSlashHint'),
224
179
  slashSkillHintLabel: t('chatSlashSkillHint')
225
- },
226
- onSelectItem: controller.onSelectSlashItem,
227
- onOpenChange: controller.onSlashPanelOpenChange,
228
- onSetActiveIndex: controller.onSetActiveSlashIndex
180
+ }
229
181
  }}
230
182
  hint={buildModelStateHint({
231
183
  isModelOptionsLoading,
@@ -236,17 +188,14 @@ export function ChatInputBarContainer() {
236
188
  configureProviderLabel: t('chatGoConfigureProvider')
237
189
  }
238
190
  })}
239
- selectedItems={{
240
- items: buildSelectedSkillItems(snapshot.selectedSkills, skillRecords),
241
- onRemove: (key) => presenter.chatInputManager.selectSkills(snapshot.selectedSkills.filter((skill) => skill !== key))
242
- }}
243
191
  toolbar={{
244
192
  selects: toolbarSelects,
245
193
  accessories: [
246
194
  {
247
195
  key: 'attach',
248
- label: t('chatInputAttachComingSoon'),
196
+ label: t('chatInputAttach'),
249
197
  icon: 'paperclip',
198
+ iconOnly: true,
250
199
  disabled: true,
251
200
  tooltip: t('chatInputAttachComingSoon')
252
201
  }