@nextclaw/ui 0.15.19 → 0.15.20

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 (88) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/assets/{api-O5yEgzBt.js → api-tWUa6l9J.js} +3 -3
  3. package/dist/assets/app-presenter-provider-s-FrOinZ.js +106 -0
  4. package/dist/assets/appearance-settings-page-dMRBD7ni.js +1 -0
  5. package/dist/assets/{book-open-HtUSPwoS.js → book-open-BQ29ZmDE.js} +1 -1
  6. package/dist/assets/card-CtKueklV.js +1 -0
  7. package/dist/assets/{channels-list-page-Bspr1Jp4.js → channels-list-page-C8y3rqYt.js} +2 -2
  8. package/dist/assets/{chat-page-BKQ89f4s.js → chat-page-MXBltS3G.js} +1 -1
  9. package/dist/assets/config-split-page-uC6d_r9c.js +1 -0
  10. package/dist/assets/confirm-dialog-C1pbqHZX.js +5 -0
  11. package/dist/assets/desktop-update-config-DssP8LCk.js +1 -0
  12. package/dist/assets/{dist-CZx9Q6jc.js → dist-rYHnBo8t.js} +1 -1
  13. package/dist/assets/doc-browser-AXF1zRsM.js +1 -0
  14. package/dist/assets/{doc-browser-B4l0Xu7u.js → doc-browser-D8ng4EUc.js} +1 -1
  15. package/dist/assets/{doc-browser-context-Dy7EhLI8.js → doc-browser-context-CPHNzQ8m.js} +1 -1
  16. package/dist/assets/{eye-DU7p1-7X.js → eye-CiJz3ODY.js} +1 -1
  17. package/dist/assets/form-actions-BnZVzLZf.js +1 -0
  18. package/dist/assets/{index-6pHX0DZH.js → index-CocHWEsI.js} +2 -2
  19. package/dist/assets/loader-circle-AOpK5kkW.js +1 -0
  20. package/dist/assets/logo-badge-BXI-Kdhd.js +1 -0
  21. package/dist/assets/mcp-marketplace-page-5ogeBHnl.js +1 -0
  22. package/dist/assets/{mcp-marketplace-page-Dg03SZX1.js → mcp-marketplace-page-CxNe-Q9Q.js} +3 -3
  23. package/dist/assets/{model-config-page-DOBqLZoQ.js → model-config-page-CR_mgEVp.js} +1 -1
  24. package/dist/assets/{navigation-link-Ct6xtnVM.js → navigation-link-C1cvB7y4.js} +1 -1
  25. package/dist/assets/plus-DI37QuHc.js +1 -0
  26. package/dist/assets/{popover-C2oIpGBF.js → popover-ZoDwVwwd.js} +1 -1
  27. package/dist/assets/provider-scoped-model-input-DIL6Ex_d.js +1 -0
  28. package/dist/assets/{providers-config-page-BAVPJKpx.js → providers-config-page-D2NXVctW.js} +1 -1
  29. package/dist/assets/react-39CDNZlM.js +8 -0
  30. package/dist/assets/{refresh-cw-BZ8giusN.js → refresh-cw-DKewcdAl.js} +1 -1
  31. package/dist/assets/remote-DYOOO1YG.js +1 -0
  32. package/dist/assets/rotate-cw-PmqA8LQT.js +1 -0
  33. package/dist/assets/{runtime-config-page-D0244uxJ.js → runtime-config-page-0lb5NyKc.js} +1 -1
  34. package/dist/assets/{save-CSzFGRse.js → save-BrckuAXY.js} +1 -1
  35. package/dist/assets/{search-BmSWsiG2.js → search-BhXQB2TO.js} +1 -1
  36. package/dist/assets/{search-config-page-CrL1pUzy.js → search-config-page-DFOQ6SDJ.js} +1 -1
  37. package/dist/assets/{secrets-config-page-BlgsD0-L.js → secrets-config-page-BqqeClqE.js} +1 -1
  38. package/dist/assets/security-config-13AtPIlm.js +1 -0
  39. package/dist/assets/{select-CklWb6Rs.js → select-CVzGGT38.js} +1 -1
  40. package/dist/assets/{settings-2-BSZf8327.js → settings-2-BP6L2enG.js} +1 -1
  41. package/dist/assets/skeleton-OwCNQJ7M.js +1 -0
  42. package/dist/assets/{tabs-Cah5klko.js → tabs-B-QWjKCL.js} +1 -1
  43. package/dist/assets/{tag-chip-DcrirDgN.js → tag-chip-C88mxjwx.js} +1 -1
  44. package/dist/assets/{trash-2-BLQOULWD.js → trash-2-ck3t33I3.js} +1 -1
  45. package/dist/assets/{use-config-CuFIsUVG.js → use-config-BAr5bH2c.js} +1 -1
  46. package/dist/assets/use-confirm-dialog-C4e0nG_4.js +1 -0
  47. package/dist/assets/{use-viewport-layout-DU29bUJS.js → use-viewport-layout-k1isXfNm.js} +1 -1
  48. package/dist/assets/x-C1ijNWcE.js +1 -0
  49. package/dist/index.html +27 -27
  50. package/package.json +9 -9
  51. package/src/features/chat/features/conversation/hooks/__tests__/use-session-run-queue.test.tsx +28 -0
  52. package/src/features/chat/features/conversation/hooks/use-session-run-queue.ts +4 -5
  53. package/src/features/chat/features/input/hooks/__tests__/use-chat-input-surface-state.test.tsx +13 -0
  54. package/src/features/chat/features/input/hooks/use-chat-input-surface-state.ts +31 -8
  55. package/src/features/chat/features/input/input-surface-plugins/__tests__/context-reference-plugin.test.ts +79 -9
  56. package/src/features/chat/features/input/input-surface-plugins/__tests__/slash-command-plugin.test.ts +16 -4
  57. package/src/features/chat/features/input/input-surface-plugins/chat-input-product-plugin-adapters.types.ts +10 -2
  58. package/src/features/chat/features/input/input-surface-plugins/context-reference-items.utils.ts +240 -0
  59. package/src/features/chat/features/input/input-surface-plugins/context-reference-plugin.utils.ts +62 -173
  60. package/src/features/chat/features/input/utils/__tests__/chat-inline-token.utils.test.ts +29 -0
  61. package/src/features/chat/features/input/utils/chat-composer-token-protocol.utils.ts +5 -0
  62. package/src/features/chat/features/input/utils/chat-inline-token.utils.ts +26 -5
  63. package/src/features/chat/features/message/components/__tests__/chat-message-list.container.test.tsx +12 -1
  64. package/src/features/chat/features/message/components/chat-message-list.container.tsx +9 -5
  65. package/src/features/chat/pages/__tests__/ncp-chat-page.test.ts +26 -0
  66. package/src/shared/hooks/use-projects.ts +2 -1
  67. package/src/shared/lib/i18n/locales/en-US/chat.json +13 -4
  68. package/src/shared/lib/i18n/locales/zh-CN/chat.json +13 -4
  69. package/dist/assets/app-presenter-provider-DXTYtH78.js +0 -106
  70. package/dist/assets/appearance-settings-page-BuGxSvlJ.js +0 -1
  71. package/dist/assets/card-GkBc-UMw.js +0 -1
  72. package/dist/assets/config-split-page-CvLXSFSC.js +0 -1
  73. package/dist/assets/confirm-dialog-BVH7haN0.js +0 -5
  74. package/dist/assets/desktop-update-config-C9ZUI8UW.js +0 -1
  75. package/dist/assets/doc-browser-DsVF1DrS.js +0 -1
  76. package/dist/assets/form-actions-aM9_jSJC.js +0 -1
  77. package/dist/assets/loader-circle-Dd6jw700.js +0 -1
  78. package/dist/assets/logo-badge-2-iV1pkr.js +0 -1
  79. package/dist/assets/mcp-marketplace-page-C-d0RMfg.js +0 -1
  80. package/dist/assets/plus-CsgeRId8.js +0 -1
  81. package/dist/assets/provider-scoped-model-input-DDqCdnTU.js +0 -1
  82. package/dist/assets/react-CeMr9V52.js +0 -8
  83. package/dist/assets/remote-C-r0SHcz.js +0 -1
  84. package/dist/assets/rotate-cw-DVdRfMSO.js +0 -1
  85. package/dist/assets/security-config-cDrdpMJW.js +0 -1
  86. package/dist/assets/skeleton-DG9u1BmA.js +0 -1
  87. package/dist/assets/use-confirm-dialog-CcySmkWp.js +0 -1
  88. package/dist/assets/x-BVoeXny1.js +0 -1
@@ -5,20 +5,28 @@ import {
5
5
  type ChatInputSurfacePlugin,
6
6
  type ChatInputSurfaceTriggerSpec,
7
7
  } from '@nextclaw/agent-chat-ui';
8
+ import type { PanelAppEntryView } from '@/shared/lib/api';
9
+ import type {
10
+ ChatInputProductPluginData,
11
+ ContextReferenceLocation,
12
+ } from './chat-input-product-plugin-adapters.types';
8
13
  import {
9
- CHAT_WORKSPACE_DIRECTORY_TOKEN_KIND,
10
- CHAT_WORKSPACE_FILE_TOKEN_KIND,
11
- } from '@nextclaw/shared';
12
- import type { PanelAppEntryView, ServerPathSearchEntryView } from '@/shared/lib/api';
13
- import type { ChatInputProductPluginData } from './chat-input-product-plugin-adapters.types';
14
+ buildBackNavigationItem,
15
+ buildCurrentDirectoryReferenceItem,
16
+ buildFilesNavigationItem,
17
+ buildProjectReferenceItems,
18
+ buildProjectsNavigationItem,
19
+ buildWorkspaceReferenceItems,
20
+ FILES_NAVIGATION_ITEM_KEY,
21
+ PROJECTS_NAVIGATION_ITEM_KEY,
22
+ ROOT_NAVIGATION_ITEM_KEY,
23
+ type ContextReferenceInputSurfaceTexts,
24
+ } from './context-reference-items.utils';
14
25
  import {
15
26
  scoreInputSurfaceSearchCandidate,
16
27
  resolveInputSurfaceMatchTier,
17
28
  } from './input-surface-search.utils';
18
29
 
19
- const FILES_NAVIGATION_ITEM_KEY = 'context-reference:navigate:files';
20
- const ROOT_NAVIGATION_ITEM_KEY = 'context-reference:navigate:root';
21
- const WORKSPACE_SECTION_KEY = 'workspace';
22
30
  const PANEL_APP_SECTION_KEY = 'panel-apps';
23
31
 
24
32
  export const CONTEXT_REFERENCE_TRIGGER_SPEC: ChatInputSurfaceTriggerSpec = {
@@ -33,34 +41,10 @@ export type PanelAppInputSurfaceItemTexts = {
33
41
  subtitle: string;
34
42
  };
35
43
 
36
- export type ContextReferenceInputSurfaceTexts = {
37
- backLabel: string;
38
- backDescription: string;
39
- backHintLabel: string;
40
- currentDirectoryLabel: string;
41
- directoryDescription: string;
42
- fileDescription: string;
43
- filesDescription: string;
44
- filesHintLabel: string;
45
- filesLabel: string;
46
- filesSubtitle: string;
47
- panelAppSectionLabel: string;
48
- parentLabel: string;
49
- parentDescription: string;
50
- parentHintLabel: string;
51
- projectRootLabel: string;
52
- searchFailedLabel: string;
53
- workspaceSectionLabel: string;
54
- };
55
-
56
44
  function getPanelAppActivityTime(entry: PanelAppEntryView): number {
57
45
  return Date.parse(entry.lastOpenedAt ?? entry.updatedAt) || 0;
58
46
  }
59
47
 
60
- function resolveProjectLabel(projectRoot: string): string {
61
- return projectRoot.split(/[\\/]+/).filter(Boolean).at(-1) ?? projectRoot;
62
- }
63
-
64
48
  function resolvePanelAppInputSurfaceEntries(params: {
65
49
  entries: readonly PanelAppEntryView[];
66
50
  query: string;
@@ -155,171 +139,64 @@ function buildPanelAppReferenceItems(params: {
155
139
  }));
156
140
  }
157
141
 
158
- function buildPathPreview(params: {
159
- kind: 'directory' | 'file';
160
- projectRoot: string;
161
- relativePath: string;
162
- }) {
163
- const { kind, projectRoot, relativePath } = params;
164
- const pathSegments = relativePath.split('/').filter(Boolean);
165
- return {
166
- rootLabel: resolveProjectLabel(projectRoot),
167
- segments: pathSegments.map((label, index) => ({
168
- label,
169
- kind: index === pathSegments.length - 1 ? kind : 'directory' as const,
170
- })),
171
- };
172
- }
173
-
174
- function buildWorkspaceReferenceItems(params: {
175
- entries: readonly ServerPathSearchEntryView[];
176
- navigateDirectories: boolean;
177
- projectRoot: string;
178
- texts: ContextReferenceInputSurfaceTexts;
179
- }): ChatInputSurfaceItem[] {
180
- const projectLabel = resolveProjectLabel(params.projectRoot);
181
- return params.entries.map((entry) => {
182
- const navigates = params.navigateDirectories && entry.kind === 'directory';
183
- return {
184
- key: `workspace:${entry.kind}:${entry.relativePath}`,
185
- icon: entry.kind === 'directory' ? 'folder' : 'file',
186
- title: entry.name,
187
- subtitle: entry.parentRelativePath || projectLabel,
188
- description: entry.kind === 'directory'
189
- ? params.texts.directoryDescription
190
- : params.texts.fileDescription,
191
- detailLines: [
192
- `${params.texts.projectRootLabel}: ${params.projectRoot}`,
193
- entry.relativePath,
194
- ],
195
- sectionKey: WORKSPACE_SECTION_KEY,
196
- sectionLabel: params.texts.workspaceSectionLabel,
197
- value: entry.relativePath,
198
- tokenKind: navigates
199
- ? undefined
200
- : entry.kind === 'directory'
201
- ? CHAT_WORKSPACE_DIRECTORY_TOKEN_KIND
202
- : CHAT_WORKSPACE_FILE_TOKEN_KIND,
203
- tokenKey: navigates ? undefined : entry.relativePath,
204
- selectionBehavior: navigates ? 'navigate' : undefined,
205
- pathPreview: buildPathPreview({
206
- kind: entry.kind,
207
- projectRoot: params.projectRoot,
208
- relativePath: entry.relativePath,
209
- }),
210
- } satisfies ChatInputSurfaceItem;
211
- });
212
- }
213
-
214
- function resolveParentReferencePath(referencePath: string): string | null {
215
- const segments = referencePath.split('/').filter(Boolean);
216
- if (segments.length === 0) {
217
- return null;
218
- }
219
- return segments.slice(0, -1).join('/');
220
- }
221
-
222
- function buildCurrentDirectoryReferenceItem(params: {
223
- projectRoot: string;
224
- referencePath: string;
225
- texts: ContextReferenceInputSurfaceTexts;
226
- }): ChatInputSurfaceItem {
227
- const { projectRoot, referencePath, texts } = params;
228
- const title = referencePath.split('/').filter(Boolean).at(-1) ?? referencePath;
229
- return {
230
- key: `context-reference:current-directory:${referencePath}`,
231
- icon: 'folder',
232
- title: texts.currentDirectoryLabel,
233
- subtitle: title,
234
- description: texts.directoryDescription,
235
- detailLines: [
236
- `${texts.projectRootLabel}: ${projectRoot}`,
237
- referencePath,
238
- ],
239
- sectionKey: WORKSPACE_SECTION_KEY,
240
- sectionLabel: texts.workspaceSectionLabel,
241
- value: referencePath,
242
- tokenKind: CHAT_WORKSPACE_DIRECTORY_TOKEN_KIND,
243
- tokenKey: referencePath,
244
- pathPreview: buildPathPreview({
245
- kind: 'directory',
246
- projectRoot,
247
- relativePath: referencePath,
248
- }),
249
- };
250
- }
251
-
252
- function buildFilesNavigationItem(texts: ContextReferenceInputSurfaceTexts): ChatInputSurfaceItem {
253
- return {
254
- key: FILES_NAVIGATION_ITEM_KEY,
255
- icon: 'files',
256
- title: texts.filesLabel,
257
- subtitle: texts.filesSubtitle,
258
- description: texts.filesDescription,
259
- detailLines: [],
260
- hintLabel: texts.filesHintLabel,
261
- selectionBehavior: 'navigate',
262
- };
263
- }
264
-
265
- function buildBackNavigationItem(params: {
266
- referencePath: string;
267
- texts: ContextReferenceInputSurfaceTexts;
268
- }): ChatInputSurfaceItem {
269
- const { referencePath, texts } = params;
270
- const isProjectRoot = !referencePath;
271
- return {
272
- key: ROOT_NAVIGATION_ITEM_KEY,
273
- icon: 'back',
274
- title: isProjectRoot ? texts.backLabel : texts.parentLabel,
275
- subtitle: '',
276
- description: isProjectRoot ? texts.backDescription : texts.parentDescription,
277
- detailLines: [],
278
- hintLabel: isProjectRoot ? texts.backHintLabel : texts.parentHintLabel,
279
- value: resolveParentReferencePath(referencePath) ?? '',
280
- selectionBehavior: 'navigate',
281
- };
282
- }
283
-
284
142
  export function createContextReferenceInputSurfacePlugin(params: {
285
143
  itemTexts: {
286
144
  context: ContextReferenceInputSurfaceTexts;
287
145
  panelApp: PanelAppInputSurfaceItemTexts;
288
146
  };
289
147
  menuTexts: ChatInputSurfaceMenuTexts;
290
- onNavigate: (path: string | null) => void;
148
+ onNavigate: (location: ContextReferenceLocation) => void;
291
149
  }): ChatInputSurfacePlugin<ChatInputProductPluginData> {
292
150
  return createInputSurfaceTriggeredPanelPlugin({
293
151
  key: 'context-reference',
294
152
  trigger: CONTEXT_REFERENCE_TRIGGER_SPEC,
295
153
  resolvePanel: ({ data, trigger }) => {
296
- const isFilesMode = data.referencePath !== null;
154
+ const isFilesMode = data.referenceLocation.view === 'files';
155
+ const isProjectsMode = data.referenceLocation.view === 'projects';
297
156
  const isBrowsing = isFilesMode && !trigger.query.trim();
157
+ const referencePath = data.referenceLocation.view === 'files'
158
+ ? data.referenceLocation.path
159
+ : '';
298
160
  const workspaceItems = buildWorkspaceReferenceItems({
299
161
  entries: data.serverPathEntries,
300
162
  navigateDirectories: isBrowsing,
301
163
  projectRoot: data.projectRoot,
302
164
  texts: params.itemTexts.context,
303
165
  });
304
- const currentDirectoryItem = isBrowsing && data.referencePath
166
+ const projectItems = buildProjectReferenceItems({
167
+ projects: data.projects,
168
+ query: trigger.query,
169
+ texts: params.itemTexts.context,
170
+ });
171
+ const currentDirectoryItem = isBrowsing && referencePath
305
172
  ? buildCurrentDirectoryReferenceItem({
306
173
  projectRoot: data.projectRoot,
307
- referencePath: data.referencePath,
174
+ referencePath,
308
175
  texts: params.itemTexts.context,
309
176
  })
310
177
  : null;
311
178
  const items = isFilesMode
312
179
  ? [
313
180
  buildBackNavigationItem({
314
- referencePath: data.referencePath ?? '',
181
+ location: data.referenceLocation,
315
182
  texts: params.itemTexts.context,
316
183
  }),
317
184
  ...(currentDirectoryItem ? [currentDirectoryItem] : []),
318
185
  ...workspaceItems,
319
186
  ]
320
- : [
187
+ : isProjectsMode
188
+ ? [
189
+ buildBackNavigationItem({
190
+ location: data.referenceLocation,
191
+ texts: params.itemTexts.context,
192
+ }),
193
+ ...projectItems,
194
+ ]
195
+ : [
321
196
  buildFilesNavigationItem(params.itemTexts.context),
197
+ buildProjectsNavigationItem(params.itemTexts.context),
322
198
  ...(trigger.query ? workspaceItems : []),
199
+ ...(trigger.query ? projectItems : []),
323
200
  ...buildPanelAppReferenceItems({
324
201
  entries: data.panelApps,
325
202
  query: trigger.query,
@@ -329,25 +206,37 @@ export function createContextReferenceInputSurfacePlugin(params: {
329
206
  ];
330
207
  const relevantLoading = isFilesMode
331
208
  ? data.isServerPathSearchLoading
332
- : data.isPanelAppsLoading || (Boolean(trigger.query) && data.isServerPathSearchLoading);
209
+ : isProjectsMode
210
+ ? data.isProjectsLoading
211
+ : data.isPanelAppsLoading ||
212
+ (Boolean(trigger.query) && (data.isProjectsLoading || data.isServerPathSearchLoading));
213
+ const errorMessage = isProjectsMode && data.projectsError
214
+ ? `${params.itemTexts.context.projectsLoadFailedLabel}: ${data.projectsError}`
215
+ : data.serverPathSearchError
216
+ ? `${params.itemTexts.context.searchFailedLabel}: ${data.serverPathSearchError}`
217
+ : null;
333
218
  return {
334
- isLoading: isFilesMode
335
- ? relevantLoading
336
- : relevantLoading && items.length === 0,
219
+ isLoading: isProjectsMode ? relevantLoading : relevantLoading && items.length === 0,
337
220
  items,
338
- notice: data.serverPathSearchError
221
+ notice: errorMessage
339
222
  ? {
340
- message: `${params.itemTexts.context.searchFailedLabel}: ${data.serverPathSearchError}`,
223
+ message: errorMessage,
341
224
  tone: 'error',
342
225
  }
343
226
  : undefined,
344
227
  onSelectItem: (item) => {
345
228
  if (item.key === FILES_NAVIGATION_ITEM_KEY) {
346
- params.onNavigate('');
229
+ params.onNavigate({ view: 'files', path: '' });
230
+ } else if (item.key === PROJECTS_NAVIGATION_ITEM_KEY) {
231
+ params.onNavigate({ view: 'projects' });
347
232
  } else if (item.key === ROOT_NAVIGATION_ITEM_KEY) {
348
- params.onNavigate(data.referencePath ? item.value ?? '' : null);
233
+ params.onNavigate(
234
+ isFilesMode && referencePath
235
+ ? { view: 'files', path: item.value ?? '' }
236
+ : { view: 'root' },
237
+ );
349
238
  } else if (item.selectionBehavior === 'navigate' && item.value) {
350
- params.onNavigate(item.value);
239
+ params.onNavigate({ view: 'files', path: item.value });
351
240
  }
352
241
  },
353
242
  texts: params.menuTexts,
@@ -226,6 +226,35 @@ describe('chat inline token workspace references', () => {
226
226
  ]);
227
227
  });
228
228
 
229
+ it('serializes project references with their registered path and display name', () => {
230
+ expect(
231
+ buildInlineTokensFromComposer([
232
+ createChatComposerTokenNode({
233
+ tokenKind: 'project',
234
+ tokenKey: '/tmp/NextClaw Project',
235
+ label: 'NextClaw',
236
+ }),
237
+ ]),
238
+ ).toEqual([
239
+ {
240
+ kind: 'project',
241
+ key: '/tmp/NextClaw Project',
242
+ label: 'NextClaw',
243
+ rawText: '@project:%2Ftmp%2FNextClaw%20Project',
244
+ },
245
+ ]);
246
+ expect(
247
+ buildInlineTokensFromTextProtocol('review @project:%2Ftmp%2FNextClaw%20Project'),
248
+ ).toEqual([
249
+ {
250
+ kind: 'project',
251
+ key: '/tmp/NextClaw Project',
252
+ label: 'NextClaw Project',
253
+ rawText: '@project:%2Ftmp%2FNextClaw%20Project',
254
+ },
255
+ ]);
256
+ });
257
+
229
258
  it('resolves workspace token paths inside POSIX and Windows project roots', () => {
230
259
  expect(resolveWorkspaceReferencePath({
231
260
  projectRoot: '/tmp/project/',
@@ -1,10 +1,12 @@
1
1
  import {
2
+ CHAT_PROJECT_TOKEN_KIND,
2
3
  CHAT_WORKSPACE_DIRECTORY_TOKEN_KIND,
3
4
  CHAT_WORKSPACE_FILE_TOKEN_KIND,
4
5
  } from '@nextclaw/shared';
5
6
 
6
7
  const CHAT_SKILL_TOKEN_PREFIX = '$';
7
8
  const CHAT_PANEL_APP_TOKEN_PREFIX = '@panel-app:';
9
+ const CHAT_PROJECT_TOKEN_PREFIX = '@project:';
8
10
  const CHAT_WORKSPACE_FILE_TOKEN_PREFIX = '@file:';
9
11
  const CHAT_WORKSPACE_DIRECTORY_TOKEN_PREFIX = '@folder:';
10
12
 
@@ -20,6 +22,9 @@ export function serializeChatComposerTokenText(params: {
20
22
  if (tokenKind === 'panel_app') {
21
23
  return `${CHAT_PANEL_APP_TOKEN_PREFIX}${tokenKey}`;
22
24
  }
25
+ if (tokenKind === CHAT_PROJECT_TOKEN_KIND) {
26
+ return `${CHAT_PROJECT_TOKEN_PREFIX}${encodeURIComponent(tokenKey)}`;
27
+ }
23
28
  if (tokenKind === CHAT_WORKSPACE_FILE_TOKEN_KIND) {
24
29
  return `${CHAT_WORKSPACE_FILE_TOKEN_PREFIX}${encodeURIComponent(tokenKey)}`;
25
30
  }
@@ -2,6 +2,7 @@ import type { ChatComposerNode } from '@nextclaw/agent-chat-ui';
2
2
  import {
3
3
  CHAT_INLINE_TOKENS_METADATA_KEY,
4
4
  CHAT_INLINE_TOKENS_SCHEMA_VERSION,
5
+ CHAT_PROJECT_TOKEN_KIND,
5
6
  CHAT_WORKSPACE_DIRECTORY_TOKEN_KIND,
6
7
  CHAT_WORKSPACE_FILE_TOKEN_KIND,
7
8
  type ChatInlineTokenMetadata,
@@ -13,6 +14,7 @@ import { serializeChatComposerTokenText } from './chat-composer-token-protocol.u
13
14
  export { CHAT_INLINE_TOKENS_METADATA_KEY };
14
15
  const CHAT_PANEL_APP_TOKEN_PREFIX = '@panel-app:';
15
16
  const CHAT_PANEL_APP_TOKEN_PATTERN = /@panel-app:([A-Za-z0-9_-]+)/g;
17
+ const CHAT_PROJECT_TOKEN_PATTERN = /@project:([^\s]+)/g;
16
18
  const CHAT_WORKSPACE_FILE_TOKEN_PATTERN = /@file:([^\s]+)/g;
17
19
  const CHAT_WORKSPACE_DIRECTORY_TOKEN_PATTERN = /@folder:([^\s]+)/g;
18
20
 
@@ -96,6 +98,7 @@ export function buildInlineTokensFromComposer(
96
98
  }
97
99
  if (
98
100
  node.tokenKind !== 'skill' &&
101
+ node.tokenKind !== CHAT_PROJECT_TOKEN_KIND &&
99
102
  node.tokenKind !== CHAT_WORKSPACE_FILE_TOKEN_KIND &&
100
103
  node.tokenKind !== CHAT_WORKSPACE_DIRECTORY_TOKEN_KIND
101
104
  ) {
@@ -121,6 +124,15 @@ export function buildInlineTokensFromComposer(
121
124
  });
122
125
  continue;
123
126
  }
127
+ if (node.tokenKind === CHAT_PROJECT_TOKEN_KIND) {
128
+ tokens.push({
129
+ kind: CHAT_PROJECT_TOKEN_KIND,
130
+ key: node.tokenKey,
131
+ label: node.label,
132
+ rawText,
133
+ });
134
+ continue;
135
+ }
124
136
  const workspaceKind = node.tokenKind === CHAT_WORKSPACE_FILE_TOKEN_KIND
125
137
  ? CHAT_WORKSPACE_FILE_TOKEN_KIND
126
138
  : CHAT_WORKSPACE_DIRECTORY_TOKEN_KIND;
@@ -134,8 +146,11 @@ export function buildInlineTokensFromComposer(
134
146
  return dedupeInlineTokens(tokens);
135
147
  }
136
148
 
137
- function appendWorkspaceTokens(params: {
138
- kind: typeof CHAT_WORKSPACE_FILE_TOKEN_KIND | typeof CHAT_WORKSPACE_DIRECTORY_TOKEN_KIND;
149
+ function appendEncodedKeyTokens(params: {
150
+ kind:
151
+ | typeof CHAT_PROJECT_TOKEN_KIND
152
+ | typeof CHAT_WORKSPACE_FILE_TOKEN_KIND
153
+ | typeof CHAT_WORKSPACE_DIRECTORY_TOKEN_KIND;
139
154
  pattern: RegExp;
140
155
  text: string;
141
156
  tokens: ChatInlineTokenSource[];
@@ -155,7 +170,7 @@ function appendWorkspaceTokens(params: {
155
170
  tokens.push({
156
171
  kind,
157
172
  key,
158
- label: key.split('/').filter(Boolean).at(-1) ?? key,
173
+ label: key.split(/[\\/]+/).filter(Boolean).at(-1) ?? key,
159
174
  rawText: match[0],
160
175
  });
161
176
  }
@@ -175,13 +190,19 @@ export function buildInlineTokensFromTextProtocol(text: string): ChatInlineToken
175
190
  rawText: `${CHAT_PANEL_APP_TOKEN_PREFIX}${key}`
176
191
  });
177
192
  }
178
- appendWorkspaceTokens({
193
+ appendEncodedKeyTokens({
194
+ kind: CHAT_PROJECT_TOKEN_KIND,
195
+ pattern: CHAT_PROJECT_TOKEN_PATTERN,
196
+ text,
197
+ tokens,
198
+ });
199
+ appendEncodedKeyTokens({
179
200
  kind: CHAT_WORKSPACE_FILE_TOKEN_KIND,
180
201
  pattern: CHAT_WORKSPACE_FILE_TOKEN_PATTERN,
181
202
  text,
182
203
  tokens,
183
204
  });
184
- appendWorkspaceTokens({
205
+ appendEncodedKeyTokens({
185
206
  kind: CHAT_WORKSPACE_DIRECTORY_TOKEN_KIND,
186
207
  pattern: CHAT_WORKSPACE_DIRECTORY_TOKEN_PATTERN,
187
208
  text,
@@ -453,7 +453,7 @@ it("wires markdown file link actions to the workspace file preview manager", ()
453
453
  expect(captures.openFilePreview).toHaveBeenCalledWith(action);
454
454
  });
455
455
 
456
- it("opens workspace file and directory tokens from the message", () => {
456
+ it("opens workspace and project reference tokens from the message", () => {
457
457
  captures.selectedSession = {
458
458
  projectRoot: "/tmp/project",
459
459
  workingDir: "/tmp/project/packages/ui",
@@ -474,6 +474,12 @@ it("opens workspace file and directory tokens from the message", () => {
474
474
  label: "server",
475
475
  rawText: "@folder:packages%2Fserver",
476
476
  });
477
+ onInlineTokenClick?.({
478
+ kind: "project",
479
+ key: "/tmp/nextclaw",
480
+ label: "NextClaw",
481
+ rawText: "@project:%2Ftmp%2Fnextclaw",
482
+ });
477
483
 
478
484
  expect(captures.openFilePreview).toHaveBeenNthCalledWith(1, {
479
485
  path: "/tmp/project/docs/guide.md",
@@ -485,6 +491,11 @@ it("opens workspace file and directory tokens from the message", () => {
485
491
  label: "server",
486
492
  viewMode: "preview",
487
493
  });
494
+ expect(captures.openFilePreview).toHaveBeenNthCalledWith(3, {
495
+ path: "/tmp/nextclaw",
496
+ label: "NextClaw",
497
+ viewMode: "preview",
498
+ });
488
499
  });
489
500
 
490
501
  it("opens panel app tokens through the existing content owner", () => {
@@ -6,6 +6,7 @@ import {
6
6
  type RefObject,
7
7
  } from "react";
8
8
  import type { NcpMessage } from "@nextclaw/ncp";
9
+ import { CHAT_PROJECT_TOKEN_KIND } from "@nextclaw/shared";
9
10
  import { toast } from "sonner";
10
11
  import {
11
12
  type ChatInlineDisplayViewModel,
@@ -346,13 +347,16 @@ export function ChatMessageListContainer({
346
347
  }
347
348
  if (
348
349
  "key" in token &&
349
- (token.kind === "workspace_file" ||
350
+ (token.kind === CHAT_PROJECT_TOKEN_KIND ||
351
+ token.kind === "workspace_file" ||
350
352
  token.kind === "workspace_directory")
351
353
  ) {
352
- const path = resolveWorkspaceReferencePath({
353
- projectRoot: selectedSession?.projectRoot,
354
- relativePath: token.key,
355
- });
354
+ const path = token.kind === CHAT_PROJECT_TOKEN_KIND
355
+ ? token.key
356
+ : resolveWorkspaceReferencePath({
357
+ projectRoot: selectedSession?.projectRoot,
358
+ relativePath: token.key,
359
+ });
356
360
  if (path) {
357
361
  presenter.chatThreadManager.openFilePreview({
358
362
  path,
@@ -272,6 +272,32 @@ describe('buildChatRunMetadata', () => {
272
272
  });
273
273
  });
274
274
 
275
+ it('adds structured project references to run metadata', () => {
276
+ expect(
277
+ buildChatRunMetadata({
278
+ composerNodes: [
279
+ createChatComposerTokenNode({
280
+ tokenKind: 'project',
281
+ tokenKey: '/tmp/nextclaw',
282
+ label: 'NextClaw',
283
+ }),
284
+ ],
285
+ }),
286
+ ).toMatchObject({
287
+ ui_inline_tokens: {
288
+ schemaVersion: 2,
289
+ items: [
290
+ {
291
+ kind: 'project',
292
+ key: '/tmp/nextclaw',
293
+ label: 'NextClaw',
294
+ rawText: '@project:%2Ftmp%2Fnextclaw',
295
+ },
296
+ ],
297
+ },
298
+ });
299
+ });
300
+
275
301
  it('serializes child session materialization metadata for side chat drafts', () => {
276
302
  expect(
277
303
  buildChatRunMetadata({
@@ -5,10 +5,11 @@ import { t } from "@/shared/lib/i18n";
5
5
 
6
6
  const PROJECTS_QUERY_KEY = ["projects"] as const;
7
7
 
8
- export function useProjects() {
8
+ export function useProjects(options: { enabled?: boolean } = {}) {
9
9
  return useQuery({
10
10
  queryKey: PROJECTS_QUERY_KEY,
11
11
  queryFn: fetchProjects,
12
+ enabled: options.enabled ?? true,
12
13
  staleTime: 30_000,
13
14
  });
14
15
  }
@@ -200,7 +200,7 @@
200
200
  "chatProcessSummaryCancelled": "cancelled",
201
201
  "chatRuntimeInitializing": "Chat is still initializing. You can keep typing and send once startup finishes.",
202
202
  "chatRuntimeInitializationFailed": "Chat startup failed. Please retry in a moment or inspect the service logs.",
203
- "chatInputPlaceholder": "Type a message, / for commands, apps or skills, @ for files, folders or panel apps, Enter to send, Shift + Enter for newline",
203
+ "chatInputPlaceholder": "Type a message, / for commands, apps or skills, @ for files, folders, projects or panel apps, Enter to send, Shift + Enter for newline",
204
204
  "chatInputPlaceholderCompact": "Message NextClaw...",
205
205
  "chatInputHint": "Multi-turn context is preserved in the current session.",
206
206
  "chatSlashSection": "Slash menu",
@@ -239,15 +239,15 @@
239
239
  "chatPanelAppReferenceNoDescription": "No panel app description",
240
240
  "chatContextReferenceLoading": "Searching references...",
241
241
  "chatContextReferenceSection": "References",
242
- "chatContextReferenceNoResult": "No matching files, folders, or panel apps",
243
- "chatContextReferenceHint": "Type @ to reference a file, folder, or panel app",
242
+ "chatContextReferenceNoResult": "No matching files, folders, projects, or panel apps",
243
+ "chatContextReferenceHint": "Type @ to reference a file, folder, project, or panel app",
244
244
  "chatContextReferenceItemHint": "Press Enter to add this reference.",
245
245
  "chatContextReferenceFilesLabel": "Files & Folders",
246
246
  "chatContextReferenceFilesType": "Project Context",
247
247
  "chatContextReferenceFilesDescription": "Search and reference files or folders in the current project.",
248
248
  "chatContextReferenceFilesHint": "Press Enter to browse files and folders.",
249
249
  "chatContextReferenceBack": "Back to all references",
250
- "chatContextReferenceBackDescription": "Return to the unified file, folder, and panel app reference menu.",
250
+ "chatContextReferenceBackDescription": "Return to the unified file, folder, project, and panel app reference menu.",
251
251
  "chatContextReferenceBackHint": "Press Enter to go back.",
252
252
  "chatContextReferenceParent": "Up one folder",
253
253
  "chatContextReferenceParentDescription": "Browse the parent folder without closing the reference menu.",
@@ -255,6 +255,15 @@
255
255
  "chatContextReferenceCurrentDirectory": "Reference current folder",
256
256
  "chatContextReferenceFileDescription": "Use this file as explicit context for the current message.",
257
257
  "chatContextReferenceDirectoryDescription": "Use this folder as a context scope for the current message.",
258
+ "chatContextReferenceProjectsLabel": "Projects",
259
+ "chatContextReferenceProjectsType": "Project Context",
260
+ "chatContextReferenceProjectsDescription": "Choose a registered project as context for the current message.",
261
+ "chatContextReferenceProjectsHint": "Press Enter to browse projects.",
262
+ "chatContextReferenceProjectType": "Project",
263
+ "chatContextReferenceProjectDescription": "Use this project's name, path, and directory outline as explicit context for the current message.",
264
+ "chatContextReferenceProjectPath": "Project path",
265
+ "chatContextReferenceProjectSection": "Projects",
266
+ "chatContextReferenceProjectsLoadFailed": "Failed to load projects",
258
267
  "chatContextReferenceProjectRoot": "Project directory",
259
268
  "chatContextReferenceWorkspaceSection": "Files & Folders",
260
269
  "chatContextReferenceSearchFailed": "File search failed",
@@ -200,7 +200,7 @@
200
200
  "chatProcessSummaryCancelled": "已取消",
201
201
  "chatRuntimeInitializing": "聊天能力正在初始化。你可以先输入内容,完成后即可发送。",
202
202
  "chatRuntimeInitializationFailed": "聊天能力启动失败,请稍后重试或检查服务日志。",
203
- "chatInputPlaceholder": "输入消息,/ 选命令、应用或技能,@ 引用文件、目录或面板应用,Enter 发送,Shift + Enter 换行",
203
+ "chatInputPlaceholder": "输入消息,/ 选命令、应用或技能,@ 引用文件、目录、项目或面板应用,Enter 发送,Shift + Enter 换行",
204
204
  "chatInputPlaceholderCompact": "发消息...",
205
205
  "chatInputHint": "支持多轮上下文,默认走当前会话。",
206
206
  "chatSlashSection": "斜杠菜单",
@@ -239,15 +239,15 @@
239
239
  "chatPanelAppReferenceNoDescription": "暂无面板应用描述",
240
240
  "chatContextReferenceLoading": "搜索引用中…",
241
241
  "chatContextReferenceSection": "引用",
242
- "chatContextReferenceNoResult": "没有匹配的文件、目录或面板应用",
243
- "chatContextReferenceHint": "输入 @ 引用文件、目录或面板应用",
242
+ "chatContextReferenceNoResult": "没有匹配的文件、目录、项目或面板应用",
243
+ "chatContextReferenceHint": "输入 @ 引用文件、目录、项目或面板应用",
244
244
  "chatContextReferenceItemHint": "回车添加这个引用。",
245
245
  "chatContextReferenceFilesLabel": "文件与文件夹",
246
246
  "chatContextReferenceFilesType": "项目上下文",
247
247
  "chatContextReferenceFilesDescription": "搜索并引用当前项目中的文件或目录。",
248
248
  "chatContextReferenceFilesHint": "回车进入文件与文件夹。",
249
249
  "chatContextReferenceBack": "返回全部引用",
250
- "chatContextReferenceBackDescription": "返回文件、目录和面板应用的统一引用入口。",
250
+ "chatContextReferenceBackDescription": "返回文件、目录、项目和面板应用的统一引用入口。",
251
251
  "chatContextReferenceBackHint": "回车返回。",
252
252
  "chatContextReferenceParent": "返回上一级",
253
253
  "chatContextReferenceParentDescription": "在不关闭引用菜单的情况下浏览上一级文件夹。",
@@ -255,6 +255,15 @@
255
255
  "chatContextReferenceCurrentDirectory": "引用当前文件夹",
256
256
  "chatContextReferenceFileDescription": "把这个文件作为本次消息的显式上下文。",
257
257
  "chatContextReferenceDirectoryDescription": "把这个目录作为本次消息的上下文范围。",
258
+ "chatContextReferenceProjectsLabel": "项目",
259
+ "chatContextReferenceProjectsType": "项目上下文",
260
+ "chatContextReferenceProjectsDescription": "从已登记项目中选择一个,作为本次消息的上下文。",
261
+ "chatContextReferenceProjectsHint": "回车进入项目列表。",
262
+ "chatContextReferenceProjectType": "项目",
263
+ "chatContextReferenceProjectDescription": "把这个项目的名称、路径和目录概览作为本次消息的显式上下文。",
264
+ "chatContextReferenceProjectPath": "项目路径",
265
+ "chatContextReferenceProjectSection": "项目",
266
+ "chatContextReferenceProjectsLoadFailed": "项目加载失败",
258
267
  "chatContextReferenceProjectRoot": "项目目录",
259
268
  "chatContextReferenceWorkspaceSection": "文件与文件夹",
260
269
  "chatContextReferenceSearchFailed": "文件搜索失败",