@nextclaw/ui 0.11.20 → 0.11.22

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 (125) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/dist/assets/{ChannelsList-DAx7wv0_.js → ChannelsList-Zeys_w43.js} +6 -6
  3. package/dist/assets/ChatPage-DWOU_8P6.js +43 -0
  4. package/dist/assets/DocBrowser-B9OaZjmg.js +1 -0
  5. package/dist/assets/{DocBrowser-DKkE3Y4I.js → DocBrowser-BmtBLFU0.js} +1 -1
  6. package/dist/assets/{DocBrowserContext-BcZRBsCg.js → DocBrowserContext-YIKkPb76.js} +1 -1
  7. package/dist/assets/{LogoBadge-BIPDLEwK.js → LogoBadge-F7ZWdxLT.js} +1 -1
  8. package/dist/assets/MarketplacePage-BfaTTqN6.js +1 -0
  9. package/dist/assets/{MarketplacePage-Dlp5BgCh.js → MarketplacePage-Cd4faegU.js} +2 -2
  10. package/dist/assets/{McpMarketplacePage-CwKtAil8.js → McpMarketplacePage-C09Ngs7O.js} +2 -2
  11. package/dist/assets/ModelConfig-DJgdcgvQ.js +1 -0
  12. package/dist/assets/ProvidersList-w0rVFIBf.js +1 -0
  13. package/dist/assets/RemoteAccessPage-BJ_ckkOV.js +1 -0
  14. package/dist/assets/RuntimeConfig-Cmn2xPQO.js +1 -0
  15. package/dist/assets/{SearchConfig-v46R5a2U.js → SearchConfig-BT13qpR_.js} +1 -1
  16. package/dist/assets/{SecretsConfig-CXvUpbB_.js → SecretsConfig-CvqEVn0B.js} +2 -2
  17. package/dist/assets/{SessionsConfig-7vUHMtOh.js → SessionsConfig-DHHcYznk.js} +2 -2
  18. package/dist/assets/{book-open-DzSduAaw.js → book-open-CXoF5nQC.js} +1 -1
  19. package/dist/assets/chat-session-display-VW6ZMvZP.js +1 -0
  20. package/dist/assets/{chunk-JZWAC4HX-C1vpvW4r.js → chunk-JZWAC4HX-CvRWvTy5.js} +1 -1
  21. package/dist/assets/{config-Df97LeLR.js → config-DJswxxE8.js} +1 -1
  22. package/dist/assets/{createLucideIcon-CcR5wVoU.js → createLucideIcon-CjGHOWb6.js} +1 -1
  23. package/dist/assets/{dist-Dii9v3X9.js → dist-Cl2QB-2y.js} +1 -1
  24. package/dist/assets/{dist-BMlnBah3.js → dist-nqTTbVdA.js} +1 -1
  25. package/dist/assets/{external-link-CnSDrvJE.js → external-link-tIO7zING.js} +1 -1
  26. package/dist/assets/{hash-CAnX6PNt.js → hash-JWUyl1pT.js} +1 -1
  27. package/dist/assets/i18n-CDHMXlRZ.js +1 -0
  28. package/dist/assets/index-BlH4-cBw.css +1 -0
  29. package/dist/assets/{index-B0DzQqwv.js → index-C6d0xmtm.js} +3 -3
  30. package/dist/assets/{label-CtIFj7_6.js → label-BIpeNu4r.js} +1 -1
  31. package/dist/assets/loader-circle-Cs8XVFTw.js +1 -0
  32. package/dist/assets/{logos-3KFNiOej.js → logos-DThdM9lk.js} +1 -1
  33. package/dist/assets/{page-layout-BMwpn87D.js → page-layout-D3Xo605Z.js} +1 -1
  34. package/dist/assets/plus-PHf8q-Ct.js +1 -0
  35. package/dist/assets/{popover-BIzq25oH.js → popover-BJRUGA_H.js} +1 -1
  36. package/dist/assets/provider-models-bz5y28rq.js +1 -0
  37. package/dist/assets/{react-ji6GGP_j.js → react-7ZHqQtEV.js} +1 -1
  38. package/dist/assets/refresh-ccw-CC6-_QuL.js +1 -0
  39. package/dist/assets/{save-CMgYkJ-y.js → save-DJM5RRWW.js} +1 -1
  40. package/dist/assets/search-C91yH_6y.js +1 -0
  41. package/dist/assets/{security-config-Xi5DYW7j.js → security-config-T5zpg16O.js} +1 -1
  42. package/dist/assets/{select-Cz82gl01.js → select-DSkTc61S.js} +1 -1
  43. package/dist/assets/skeleton-Dzg-HOiN.js +1 -0
  44. package/dist/assets/{status-dot-C7q1HvLH.js → status-dot-LNBlDu3q.js} +1 -1
  45. package/dist/assets/{switch-DYswvkYj.js → switch-Bo-Y46HZ.js} +1 -1
  46. package/dist/assets/tabs-custom-DXv507_2.js +1 -0
  47. package/dist/assets/{trash-2-DfXI7-ap.js → trash-2-DFZmW6Gg.js} +1 -1
  48. package/dist/assets/useConfirmDialog-Bs5Ll17m.js +1 -0
  49. package/dist/assets/{useMutation-s2sn2yzh.js → useMutation-DrZrOgVL.js} +1 -1
  50. package/dist/assets/x-D7Q1yqSF.js +1 -0
  51. package/dist/index.html +18 -18
  52. package/package.json +6 -6
  53. package/src/api/ncp-session.test.ts +37 -0
  54. package/src/api/ncp-session.ts +29 -1
  55. package/src/api/server-path.ts +23 -0
  56. package/src/api/types.ts +41 -0
  57. package/src/components/chat/ChatConversationPanel.test.tsx +43 -7
  58. package/src/components/chat/ChatConversationPanel.tsx +23 -17
  59. package/src/components/chat/ChatSidebar.test.tsx +2 -2
  60. package/src/components/chat/ChatSidebar.tsx +2 -2
  61. package/src/components/chat/adapters/chat-input-bar.adapter.test.ts +1 -0
  62. package/src/components/chat/adapters/chat-input-bar.adapter.ts +7 -2
  63. package/src/components/chat/adapters/chat-message-part.adapter.ts +81 -6
  64. package/src/components/chat/adapters/chat-message.adapter.test.ts +393 -3
  65. package/src/components/chat/adapters/chat-message.partial-json.ts +89 -0
  66. package/src/components/chat/adapters/file-operation/card.ts +330 -0
  67. package/src/components/chat/adapters/file-operation/diff.ts +398 -0
  68. package/src/components/chat/adapters/file-operation/line-builder.ts +249 -0
  69. package/src/components/chat/adapters/file-operation/record-readers.ts +233 -0
  70. package/src/components/chat/chat-composer-state.ts +3 -3
  71. package/src/components/chat/chat-session-display.test.ts +21 -0
  72. package/src/components/chat/chat-session-display.ts +6 -1
  73. package/src/components/chat/containers/chat-input-bar.container.tsx +29 -32
  74. package/src/components/chat/containers/chat-message-list.container.tsx +1 -0
  75. package/src/components/chat/hooks/use-chat-session-label.ts +19 -0
  76. package/src/components/chat/hooks/use-chat-session-project.test.tsx +117 -0
  77. package/src/components/chat/hooks/use-chat-session-project.ts +40 -0
  78. package/src/components/chat/{chat-session-label.service.ts → hooks/use-chat-session-update.ts} +11 -7
  79. package/src/components/chat/managers/chat-session-list.manager.ts +5 -1
  80. package/src/components/chat/ncp/NcpChatPage.tsx +55 -17
  81. package/src/components/chat/ncp/ncp-chat-page-data.test.ts +33 -0
  82. package/src/components/chat/ncp/ncp-chat-page-data.ts +21 -15
  83. package/src/components/chat/ncp/ncp-session-adapter.test.ts +176 -0
  84. package/src/components/chat/ncp/ncp-session-adapter.ts +16 -0
  85. package/src/components/chat/session-header/chat-session-header-actions.test.tsx +63 -0
  86. package/src/components/chat/session-header/chat-session-header-actions.tsx +95 -0
  87. package/src/components/chat/session-header/chat-session-header-menu-item.tsx +35 -0
  88. package/src/components/chat/session-header/chat-session-project-badge.test.tsx +66 -0
  89. package/src/components/chat/session-header/chat-session-project-badge.tsx +102 -0
  90. package/src/components/chat/session-header/chat-session-project-dialog.tsx +34 -0
  91. package/src/components/chat/stores/chat-input.store.ts +6 -3
  92. package/src/components/chat/stores/chat-thread.store.ts +6 -2
  93. package/src/components/chat/useNcpAgentRuntime.test.tsx +90 -0
  94. package/src/components/path-picker/server-path-picker-dialog.test.tsx +92 -0
  95. package/src/components/path-picker/server-path-picker-dialog.tsx +282 -0
  96. package/src/hooks/server-path/use-server-path-browse.ts +19 -0
  97. package/src/hooks/useConfig.ts +26 -1
  98. package/src/lib/i18n/i18n-language-owner.ts +94 -0
  99. package/src/lib/i18n/i18n.path-picker.ts +12 -0
  100. package/src/lib/i18n.chat.ts +25 -1
  101. package/src/lib/i18n.ts +21 -84
  102. package/src/lib/session-project/session-project.utils.ts +30 -0
  103. package/src/remote/remote-access-feedback.service.test.ts +18 -0
  104. package/src/remote/remote-access-feedback.service.ts +10 -1
  105. package/dist/assets/ChatPage-l2PYwCeB.js +0 -38
  106. package/dist/assets/DocBrowser-CIHLqoIm.js +0 -1
  107. package/dist/assets/MarketplacePage-TVeyVOuO.js +0 -1
  108. package/dist/assets/ModelConfig-Dg6F3Ldb.js +0 -1
  109. package/dist/assets/ProvidersList-f7bQdRxA.js +0 -1
  110. package/dist/assets/RemoteAccessPage-w_dY7P4T.js +0 -1
  111. package/dist/assets/RuntimeConfig-M4OKjmgU.js +0 -1
  112. package/dist/assets/chat-session-display-CGfXhJoT.js +0 -1
  113. package/dist/assets/i18n-CXBpwAwA.js +0 -1
  114. package/dist/assets/index-BahpXJg8.css +0 -1
  115. package/dist/assets/loader-circle-qgU4zQDw.js +0 -1
  116. package/dist/assets/plus-C9cYVbL-.js +0 -1
  117. package/dist/assets/provider-models-C8JQUd1E.js +0 -1
  118. package/dist/assets/search-sl1OeJFl.js +0 -1
  119. package/dist/assets/skeleton-rgIt7a5q.js +0 -1
  120. package/dist/assets/tabs-custom-DKYQxrx1.js +0 -1
  121. package/dist/assets/useConfirmDialog-CXDAxtRL.js +0 -1
  122. package/dist/assets/x-MIimOGs6.js +0 -1
  123. /package/dist/assets/{config-hints-fGnUjDe9.js → config-hints-WtpHP_DW.js} +0 -0
  124. /package/dist/assets/{config-layout-B-7erZRN.js → config-layout-LQ10ozRC.js} +0 -0
  125. /package/dist/assets/{marketplace-localization-CXeGRf6E.js → marketplace-localization-CxSTG9wr.js} +0 -0
@@ -0,0 +1,233 @@
1
+ import { readPartialJsonStringField } from "@/components/chat/adapters/chat-message.partial-json";
2
+
3
+ export function isRecord(value: unknown): value is Record<string, unknown> {
4
+ return typeof value === "object" && value !== null && !Array.isArray(value);
5
+ }
6
+
7
+ export function readNonEmptyString(value: unknown): string | null {
8
+ if (typeof value !== "string") {
9
+ return null;
10
+ }
11
+ const trimmed = value.trim();
12
+ return trimmed.length > 0 ? trimmed : null;
13
+ }
14
+
15
+ function normalizePath(value: unknown): string | null {
16
+ if (typeof value === "string" && value.trim()) {
17
+ return value.trim();
18
+ }
19
+ return null;
20
+ }
21
+
22
+ function readPositiveInteger(value: unknown): number | null {
23
+ if (typeof value === "number" && Number.isInteger(value) && value > 0) {
24
+ return value;
25
+ }
26
+ if (typeof value === "string" && /^\d+$/.test(value.trim())) {
27
+ return Number(value.trim());
28
+ }
29
+ return null;
30
+ }
31
+
32
+ export function readRecordPayload(
33
+ value: unknown,
34
+ ): Record<string, unknown> | null {
35
+ if (isRecord(value)) {
36
+ return value;
37
+ }
38
+ if (typeof value !== "string") {
39
+ return null;
40
+ }
41
+ const trimmed = value.trim();
42
+ if (!trimmed.startsWith("{")) {
43
+ return null;
44
+ }
45
+ try {
46
+ const parsed = JSON.parse(trimmed) as unknown;
47
+ return isRecord(parsed) ? parsed : null;
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
52
+
53
+ export function readPartialRecordPayload(
54
+ value: unknown,
55
+ ): Record<string, unknown> | null {
56
+ if (isRecord(value)) {
57
+ return value;
58
+ }
59
+ if (typeof value !== "string") {
60
+ return null;
61
+ }
62
+ const trimmed = value.trim();
63
+ if (!trimmed.startsWith("{")) {
64
+ return null;
65
+ }
66
+ const path =
67
+ readPartialJsonStringField(trimmed, [
68
+ "path",
69
+ "filePath",
70
+ "file_path",
71
+ "targetPath",
72
+ "target_path",
73
+ "filename",
74
+ "name",
75
+ ])?.value ?? null;
76
+ const content =
77
+ readPartialJsonStringField(trimmed, [
78
+ "content",
79
+ "text",
80
+ "afterText",
81
+ "after_text",
82
+ ])?.value ?? null;
83
+ const oldText =
84
+ readPartialJsonStringField(trimmed, [
85
+ "oldText",
86
+ "beforeText",
87
+ "before_text",
88
+ ])?.value ?? null;
89
+ const newText =
90
+ readPartialJsonStringField(trimmed, ["newText", "afterText", "after_text"])
91
+ ?.value ?? null;
92
+ const patch =
93
+ readPartialJsonStringField(trimmed, [
94
+ "patch",
95
+ "diff",
96
+ "unifiedDiff",
97
+ "unified_diff",
98
+ ])?.value ?? null;
99
+
100
+ const partialRecord: Record<string, unknown> = {};
101
+ if (path) {
102
+ partialRecord.path = path;
103
+ }
104
+ if (content) {
105
+ partialRecord.content = content;
106
+ }
107
+ if (oldText) {
108
+ partialRecord.oldText = oldText;
109
+ }
110
+ if (newText) {
111
+ partialRecord.newText = newText;
112
+ }
113
+ if (patch) {
114
+ partialRecord.patch = patch;
115
+ }
116
+
117
+ return Object.keys(partialRecord).length > 0 ? partialRecord : null;
118
+ }
119
+
120
+ export function readPath(record: Record<string, unknown>): string | null {
121
+ return (
122
+ normalizePath(record.path) ??
123
+ normalizePath(record.filePath) ??
124
+ normalizePath(record.file_path) ??
125
+ normalizePath(record.targetPath) ??
126
+ normalizePath(record.target_path) ??
127
+ normalizePath(record.filename) ??
128
+ normalizePath(record.name)
129
+ );
130
+ }
131
+
132
+ export function readOperation(record: Record<string, unknown>): string | null {
133
+ return (
134
+ readNonEmptyString(record.operation) ??
135
+ readNonEmptyString(record.op) ??
136
+ readNonEmptyString(record.action) ??
137
+ readNonEmptyString(record.kind) ??
138
+ readNonEmptyString(record.type) ??
139
+ readNonEmptyString(record.status)
140
+ );
141
+ }
142
+
143
+ function readLineStart(
144
+ record: Record<string, unknown>,
145
+ keys: string[],
146
+ ): number | null {
147
+ for (const key of keys) {
148
+ const value = readPositiveInteger(record[key]);
149
+ if (value !== null) {
150
+ return value;
151
+ }
152
+ }
153
+ return null;
154
+ }
155
+
156
+ export function readOldStartLine(
157
+ record: Record<string, unknown>,
158
+ ): number | null {
159
+ return readLineStart(record, [
160
+ "oldStartLine",
161
+ "old_start_line",
162
+ "startOldLine",
163
+ "start_old_line",
164
+ "oldLineStart",
165
+ "old_line_start",
166
+ "oldLineNumber",
167
+ "old_line_number",
168
+ "lineStart",
169
+ "line_start",
170
+ "startLine",
171
+ "start_line",
172
+ "lineNumber",
173
+ "line_number",
174
+ ]);
175
+ }
176
+
177
+ export function readNewStartLine(
178
+ record: Record<string, unknown>,
179
+ ): number | null {
180
+ return readLineStart(record, [
181
+ "newStartLine",
182
+ "new_start_line",
183
+ "startNewLine",
184
+ "start_new_line",
185
+ "newLineStart",
186
+ "new_line_start",
187
+ "newLineNumber",
188
+ "new_line_number",
189
+ "lineStart",
190
+ "line_start",
191
+ "startLine",
192
+ "start_line",
193
+ "lineNumber",
194
+ "line_number",
195
+ ]);
196
+ }
197
+
198
+ export function readPatchText(record: Record<string, unknown>): string | null {
199
+ return (
200
+ readNonEmptyString(record.patch) ??
201
+ readNonEmptyString(record.diff) ??
202
+ readNonEmptyString(record.unifiedDiff) ??
203
+ readNonEmptyString(record.unified_diff)
204
+ );
205
+ }
206
+
207
+ export function readBeforeText(record: Record<string, unknown>): string | null {
208
+ return (
209
+ readNonEmptyString(record.beforeText) ??
210
+ readNonEmptyString(record.before_text) ??
211
+ readNonEmptyString(record.oldText) ??
212
+ readNonEmptyString(record.old_text) ??
213
+ readNonEmptyString(record.oldContent) ??
214
+ readNonEmptyString(record.old_content) ??
215
+ readNonEmptyString(record.before) ??
216
+ readNonEmptyString(record.previous)
217
+ );
218
+ }
219
+
220
+ export function readAfterText(record: Record<string, unknown>): string | null {
221
+ return (
222
+ readNonEmptyString(record.afterText) ??
223
+ readNonEmptyString(record.after_text) ??
224
+ readNonEmptyString(record.newText) ??
225
+ readNonEmptyString(record.new_text) ??
226
+ readNonEmptyString(record.newContent) ??
227
+ readNonEmptyString(record.new_content) ??
228
+ readNonEmptyString(record.content) ??
229
+ readNonEmptyString(record.text) ??
230
+ readNonEmptyString(record.after) ??
231
+ readNonEmptyString(record.updated)
232
+ );
233
+ }
@@ -55,7 +55,7 @@ export function deriveSelectedAttachmentIdsFromComposer(nodes: ChatComposerNode[
55
55
  export function syncComposerSkills(
56
56
  nodes: ChatComposerNode[],
57
57
  nextSkills: string[],
58
- skillRecords: Array<{ spec: string; label?: string }>
58
+ skillRecords: Array<{ ref: string; name: string }>
59
59
  ): ChatComposerNode[] {
60
60
  const nextSkillSet = new Set(nextSkills);
61
61
  const prunedNodes = removeChatComposerTokenNodes(
@@ -63,14 +63,14 @@ export function syncComposerSkills(
63
63
  (node) => node.tokenKind === 'skill' && !nextSkillSet.has(node.tokenKey)
64
64
  );
65
65
  const existingSkills = extractChatComposerTokenKeys(prunedNodes, 'skill');
66
- const recordMap = new Map(skillRecords.map((record) => [record.spec, record]));
66
+ const recordMap = new Map(skillRecords.map((record) => [record.ref, record]));
67
67
  const appendedNodes = nextSkills
68
68
  .filter((skill) => !existingSkills.includes(skill))
69
69
  .map((skill) =>
70
70
  createChatComposerTokenNode({
71
71
  tokenKind: 'skill',
72
72
  tokenKey: skill,
73
- label: recordMap.get(skill)?.label || skill
73
+ label: recordMap.get(skill)?.name || skill
74
74
  })
75
75
  );
76
76
 
@@ -27,6 +27,27 @@ describe('chat-session-display', () => {
27
27
  expect(sessionMatchesQuery(createSession({ label: 'VIP Alpha Thread' }), 'alpha')).toBe(true);
28
28
  });
29
29
 
30
+ it('matches the search query against the project name and path', () => {
31
+ expect(
32
+ sessionMatchesQuery(
33
+ createSession({
34
+ projectRoot: '/Users/demo/workspace/project-apollo',
35
+ projectName: 'project-apollo'
36
+ }),
37
+ 'apollo'
38
+ )
39
+ ).toBe(true);
40
+ expect(
41
+ sessionMatchesQuery(
42
+ createSession({
43
+ projectRoot: '/Users/demo/workspace/project-apollo',
44
+ projectName: 'project-apollo'
45
+ }),
46
+ 'workspace/project'
47
+ )
48
+ ).toBe(true);
49
+ });
50
+
30
51
  it('treats an empty query as a match', () => {
31
52
  expect(sessionMatchesQuery(createSession({ label: 'Anything' }), ' ')).toBe(true);
32
53
  });
@@ -18,7 +18,12 @@ export function sessionMatchesQuery(session: SessionEntryView, query: string): b
18
18
  return true;
19
19
  }
20
20
 
21
- return [session.key, sessionDisplayName(session)]
21
+ return [
22
+ session.key,
23
+ sessionDisplayName(session),
24
+ session.projectRoot ?? '',
25
+ session.projectName ?? '',
26
+ ]
22
27
  .map(normalizeSessionSearchValue)
23
28
  .some((value) => value.includes(normalizedQuery));
24
29
  }
@@ -15,6 +15,7 @@ import {
15
15
  type ChatSkillRecord,
16
16
  type ChatThinkingLevel
17
17
  } from '@/components/chat/adapters/chat-input-bar.adapter';
18
+ import { deriveSelectedSkillsFromComposer } from '@/components/chat/chat-composer-state';
18
19
  import { usePresenter } from '@/components/chat/presenter/chat-presenter-context';
19
20
  import {
20
21
  CHAT_RECENT_MODELS_MIN_OPTIONS,
@@ -26,6 +27,7 @@ import {
26
27
  } from '@/components/chat/chat-recent-skills.manager';
27
28
  import { useI18n } from '@/components/providers/I18nProvider';
28
29
  import { useChatInputStore } from '@/components/chat/stores/chat-input.store';
30
+ import type { SessionSkillEntryView } from '@/api/types';
29
31
  import { t } from '@/lib/i18n';
30
32
  import { toast } from 'sonner';
31
33
 
@@ -41,19 +43,17 @@ function buildThinkingLabels(): Record<ChatThinkingLevel, string> {
41
43
  };
42
44
  }
43
45
 
44
- function toSkillRecords(snapshotRecords: Array<{
45
- spec: string;
46
- label?: string;
47
- description?: string;
48
- descriptionZh?: string;
49
- origin?: string;
50
- }>, officialBadgeLabel: string): ChatSkillRecord[] {
46
+ function toSkillRecords(
47
+ snapshotRecords: SessionSkillEntryView[],
48
+ scopeLabels: Record<SessionSkillEntryView['scope'], string>
49
+ ): ChatSkillRecord[] {
51
50
  return snapshotRecords.map((record) => ({
52
- key: record.spec,
53
- label: record.label || record.spec,
51
+ key: record.ref,
52
+ label: record.name,
53
+ scopeLabel: scopeLabels[record.scope],
54
54
  description: record.description,
55
55
  descriptionZh: record.descriptionZh,
56
- badgeLabel: record.origin === 'builtin' ? officialBadgeLabel : undefined
56
+ badgeLabel: scopeLabels[record.scope]
57
57
  }));
58
58
  }
59
59
 
@@ -87,20 +87,18 @@ export function ChatInputBarContainer() {
87
87
  const inputBarRef = useRef<ChatInputBarHandle | null>(null);
88
88
  const fileInputRef = useRef<HTMLInputElement | null>(null);
89
89
 
90
- const officialSkillBadgeLabel = useMemo(() => {
91
- // Keep memo reactive to locale switches even though `t` is imported as a stable function.
92
- const locale = language;
93
- void locale;
94
- return t('chatSkillsPickerOfficial');
90
+ const skillScopeLabels = useMemo<Record<'project' | 'workspace', string>>(() => {
91
+ return {
92
+ project: t('chatSkillScopeProject'),
93
+ workspace: t('chatSkillScopeWorkspace'),
94
+ };
95
95
  }, [language]);
96
96
  const slashTexts = useMemo(
97
97
  () => {
98
- // Keep memo reactive to locale switches even though `t` is imported as a stable function.
99
- const locale = language;
100
- void locale;
101
98
  return {
102
99
  slashSkillSubtitle: t('chatSlashTypeSkill'),
103
100
  slashSkillSpecLabel: t('chatSlashSkillSpec'),
101
+ slashSkillScopeLabel: t('chatSlashSkillScope'),
104
102
  noSkillDescription: t('chatSkillsPickerNoDescription')
105
103
  };
106
104
  },
@@ -108,8 +106,8 @@ export function ChatInputBarContainer() {
108
106
  );
109
107
 
110
108
  const skillRecords = useMemo(
111
- () => toSkillRecords(snapshot.skillRecords, officialSkillBadgeLabel),
112
- [snapshot.skillRecords, officialSkillBadgeLabel]
109
+ () => toSkillRecords(snapshot.skillRecords, skillScopeLabels),
110
+ [snapshot.skillRecords, skillScopeLabels]
113
111
  );
114
112
  const modelRecords = useMemo(() => toModelRecords(snapshot.modelOptions), [snapshot.modelOptions]);
115
113
  const recentModelValues = chatRecentModelsManager.resolveVisible({
@@ -136,10 +134,10 @@ export function ChatInputBarContainer() {
136
134
  : hasModelOptions
137
135
  ? t('chatInputPlaceholder')
138
136
  : t('chatModelNoOptions');
139
- const recentModelsLabel = language === 'zh' ? '最近选择' : 'Recent';
140
- const allModelsLabel = language === 'zh' ? '全部模型' : 'All models';
141
- const recentSkillsLabel = language === 'zh' ? '最近使用' : 'Recent';
142
- const allSkillsLabel = language === 'zh' ? '全部技能' : 'All skills';
137
+ const recentModelsLabel = t('chatPickerRecentModels');
138
+ const allModelsLabel = t('chatPickerAllModels');
139
+ const recentSkillsLabel = t('chatPickerRecent');
140
+ const allSkillsLabel = t('chatPickerAllSkills');
143
141
 
144
142
  const slashItems = useMemo(
145
143
  () => buildChatSlashItems(skillRecords, slashQuery ?? '', slashTexts, recentSkillValues),
@@ -236,6 +234,12 @@ export function ChatInputBarContainer() {
236
234
  }
237
235
  });
238
236
 
237
+ const composerSelectedSkillCount = deriveSelectedSkillsFromComposer(snapshot.composerNodes).length;
238
+ const hasSendableDraft =
239
+ snapshot.draft.trim().length > 0 ||
240
+ snapshot.attachments.length > 0 ||
241
+ composerSelectedSkillCount > 0;
242
+
239
243
  return (
240
244
  <>
241
245
  <ChatInputBar
@@ -296,14 +300,7 @@ export function ChatInputBarContainer() {
296
300
  sendError: snapshot.sendError,
297
301
  isSending: snapshot.isSending,
298
302
  canStopGeneration: snapshot.canStopGeneration,
299
- sendDisabled:
300
- (
301
- snapshot.draft.trim().length === 0 &&
302
- snapshot.attachments.length === 0 &&
303
- snapshot.selectedSkills.length === 0
304
- ) ||
305
- !hasModelOptions ||
306
- snapshot.sessionTypeUnavailable,
303
+ sendDisabled: !hasSendableDraft || !hasModelOptions || snapshot.sessionTypeUnavailable,
307
304
  stopDisabled: !snapshot.canStopGeneration,
308
305
  stopHint: resolvedStopHint,
309
306
  sendButtonLabel: t('chatSend'),
@@ -40,6 +40,7 @@ function buildChatMessageAdapterTexts(
40
40
  reasoningLabel: t("chatReasoning"),
41
41
  toolCallLabel: t("chatToolCall"),
42
42
  toolResultLabel: t("chatToolResult"),
43
+ toolInputLabel: t("chatToolInput"),
43
44
  toolNoOutputLabel: t("chatToolNoOutput"),
44
45
  toolOutputLabel: t("chatToolOutput"),
45
46
  toolStatusPreparingLabel: t("chatToolStatusPreparing"),
@@ -0,0 +1,19 @@
1
+ import { t } from '@/lib/i18n';
2
+ import { useChatSessionUpdate } from '@/components/chat/hooks/use-chat-session-update';
3
+
4
+ type UpdateChatSessionLabelParams = {
5
+ sessionKey: string;
6
+ label: string | null;
7
+ };
8
+
9
+ export function useChatSessionLabel() {
10
+ const updateSession = useChatSessionUpdate();
11
+
12
+ return async (params: UpdateChatSessionLabelParams): Promise<void> => {
13
+ await updateSession({
14
+ sessionKey: params.sessionKey,
15
+ patch: { label: params.label },
16
+ successMessage: t('configSavedApplied'),
17
+ });
18
+ };
19
+ }
@@ -0,0 +1,117 @@
1
+ import { act, renderHook } from '@testing-library/react';
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
+ import { toast } from 'sonner';
4
+ import { useChatSessionProject } from '@/components/chat/hooks/use-chat-session-project';
5
+ import { useChatInputStore } from '@/components/chat/stores/chat-input.store';
6
+
7
+ const mocks = vi.hoisted(() => ({
8
+ updateSession: vi.fn(),
9
+ }));
10
+
11
+ vi.mock('sonner', () => ({
12
+ toast: {
13
+ success: vi.fn(),
14
+ },
15
+ }));
16
+
17
+ vi.mock('@/components/chat/hooks/use-chat-session-update', () => ({
18
+ useChatSessionUpdate: () => mocks.updateSession,
19
+ }));
20
+
21
+ describe('useChatSessionProject', () => {
22
+ beforeEach(() => {
23
+ useChatInputStore.setState((state) => ({
24
+ snapshot: {
25
+ ...state.snapshot,
26
+ pendingProjectRoot: null,
27
+ pendingProjectRootSessionKey: null,
28
+ },
29
+ }));
30
+ });
31
+
32
+ afterEach(() => {
33
+ vi.clearAllMocks();
34
+ });
35
+
36
+ it('stores the draft project root locally when the session does not exist yet', async () => {
37
+ const { result } = renderHook(() => useChatSessionProject());
38
+
39
+ await act(async () => {
40
+ await result.current({
41
+ sessionKey: 'draft-session-1',
42
+ projectRoot: '/tmp/project-alpha',
43
+ persistToServer: false,
44
+ });
45
+ });
46
+
47
+ expect(mocks.updateSession).not.toHaveBeenCalled();
48
+ expect(useChatInputStore.getState().snapshot).toMatchObject({
49
+ pendingProjectRoot: '/tmp/project-alpha',
50
+ pendingProjectRootSessionKey: 'draft-session-1',
51
+ });
52
+ expect(toast.success).toHaveBeenCalledTimes(1);
53
+ });
54
+
55
+ it('keeps an explicit draft override when clearing the project root locally', async () => {
56
+ const { result } = renderHook(() => useChatSessionProject());
57
+
58
+ await act(async () => {
59
+ await result.current({
60
+ sessionKey: 'draft-session-1',
61
+ projectRoot: null,
62
+ persistToServer: false,
63
+ });
64
+ });
65
+
66
+ expect(mocks.updateSession).not.toHaveBeenCalled();
67
+ expect(useChatInputStore.getState().snapshot).toMatchObject({
68
+ pendingProjectRoot: null,
69
+ pendingProjectRootSessionKey: 'draft-session-1',
70
+ });
71
+ expect(toast.success).toHaveBeenCalledTimes(1);
72
+ });
73
+
74
+ it('persists to the server and mirrors the updated project override locally for an existing session', async () => {
75
+ const { result } = renderHook(() => useChatSessionProject());
76
+
77
+ await act(async () => {
78
+ await result.current({
79
+ sessionKey: 'session-1',
80
+ projectRoot: '/tmp/project-beta',
81
+ persistToServer: true,
82
+ });
83
+ });
84
+
85
+ expect(mocks.updateSession).toHaveBeenCalledWith({
86
+ sessionKey: 'session-1',
87
+ patch: { projectRoot: '/tmp/project-beta' },
88
+ successMessage: 'Project directory updated',
89
+ });
90
+ expect(useChatInputStore.getState().snapshot).toMatchObject({
91
+ pendingProjectRoot: '/tmp/project-beta',
92
+ pendingProjectRootSessionKey: 'session-1',
93
+ });
94
+ });
95
+
96
+ it('persists clearing to the server and keeps the cleared override until session state catches up', async () => {
97
+ const { result } = renderHook(() => useChatSessionProject());
98
+
99
+ await act(async () => {
100
+ await result.current({
101
+ sessionKey: 'session-1',
102
+ projectRoot: null,
103
+ persistToServer: true,
104
+ });
105
+ });
106
+
107
+ expect(mocks.updateSession).toHaveBeenCalledWith({
108
+ sessionKey: 'session-1',
109
+ patch: { projectRoot: null },
110
+ successMessage: 'Project directory cleared',
111
+ });
112
+ expect(useChatInputStore.getState().snapshot).toMatchObject({
113
+ pendingProjectRoot: null,
114
+ pendingProjectRootSessionKey: 'session-1',
115
+ });
116
+ });
117
+ });
@@ -0,0 +1,40 @@
1
+ import { toast } from 'sonner';
2
+ import { t } from '@/lib/i18n';
3
+ import { useChatSessionUpdate } from '@/components/chat/hooks/use-chat-session-update';
4
+ import { useChatInputStore } from '@/components/chat/stores/chat-input.store';
5
+
6
+ type UpdateChatSessionProjectParams = {
7
+ sessionKey: string;
8
+ projectRoot: string | null;
9
+ persistToServer: boolean;
10
+ };
11
+
12
+ export function useChatSessionProject() {
13
+ const updateSession = useChatSessionUpdate();
14
+
15
+ return async (params: UpdateChatSessionProjectParams): Promise<void> => {
16
+ const successMessage = params.projectRoot
17
+ ? t('chatSessionProjectUpdated')
18
+ : t('chatSessionProjectCleared');
19
+
20
+ if (!params.persistToServer) {
21
+ useChatInputStore.getState().setSnapshot({
22
+ pendingProjectRoot: params.projectRoot,
23
+ pendingProjectRootSessionKey: params.sessionKey
24
+ });
25
+ toast.success(successMessage);
26
+ return;
27
+ }
28
+
29
+ await updateSession({
30
+ sessionKey: params.sessionKey,
31
+ patch: { projectRoot: params.projectRoot },
32
+ successMessage,
33
+ });
34
+
35
+ useChatInputStore.getState().setSnapshot({
36
+ pendingProjectRoot: params.projectRoot,
37
+ pendingProjectRootSessionKey: params.sessionKey,
38
+ });
39
+ };
40
+ }
@@ -1,24 +1,28 @@
1
1
  import { useQueryClient } from '@tanstack/react-query';
2
2
  import { toast } from 'sonner';
3
+ import type { SessionPatchUpdate } from '@/api/types';
3
4
  import { upsertNcpSessionSummaryInQueryClient } from '@/api/ncp-session-query-cache';
4
5
  import { updateNcpSession } from '@/api/ncp-session';
5
6
  import { t } from '@/lib/i18n';
6
7
 
7
- type UpdateChatSessionLabelParams = {
8
+ type UpdateChatSessionParams = {
8
9
  sessionKey: string;
9
- label: string | null;
10
+ patch: SessionPatchUpdate;
11
+ successMessage?: string;
10
12
  };
11
13
 
12
- export function useChatSessionLabelService() {
14
+ export function useChatSessionUpdate() {
13
15
  const queryClient = useQueryClient();
14
16
 
15
- return async (params: UpdateChatSessionLabelParams): Promise<void> => {
17
+ return async (params: UpdateChatSessionParams): Promise<void> => {
16
18
  try {
17
- const updated = await updateNcpSession(params.sessionKey, { label: params.label });
19
+ const updated = await updateNcpSession(params.sessionKey, params.patch);
18
20
  upsertNcpSessionSummaryInQueryClient(queryClient, updated);
19
- toast.success(t('configSavedApplied'));
21
+ toast.success(params.successMessage ?? t('configSavedApplied'));
20
22
  } catch (error) {
21
- toast.error(t('configSaveFailed') + ': ' + (error instanceof Error ? error.message : String(error)));
23
+ toast.error(
24
+ t('configSaveFailed') + ': ' + (error instanceof Error ? error.message : String(error)),
25
+ );
22
26
  throw error;
23
27
  }
24
28
  };
@@ -44,7 +44,11 @@ export class ChatSessionListManager {
44
44
  ? sessionType.trim()
45
45
  : defaultSessionType;
46
46
  this.streamActionsManager.resetStreamState();
47
- useChatInputStore.getState().setSnapshot({ pendingSessionType: nextSessionType });
47
+ useChatInputStore.getState().setSnapshot({
48
+ pendingSessionType: nextSessionType,
49
+ pendingProjectRoot: null,
50
+ pendingProjectRootSessionKey: null
51
+ });
48
52
  this.uiManager.goToChatRoot();
49
53
  };
50
54