@nextclaw/ui 0.15.26 → 0.15.27

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 +16 -0
  2. package/dist/assets/{api-CK3Fro7z.js → api-a_GaOFc7.js} +4 -4
  3. package/dist/assets/{appearance-settings-page-zqwWO8Dy.js → appearance-settings-page-DlGXLgff.js} +1 -1
  4. package/dist/assets/{channels-list-page-CVCZBlCL.js → channels-list-page-BEEk4Uc9.js} +2 -2
  5. package/dist/assets/{chat-page-D4wehRTz.js → chat-page-DUQXXqQm.js} +1 -1
  6. package/dist/assets/{config-split-page-CceNFvlY.js → config-split-page-ps1Aod3g.js} +1 -1
  7. package/dist/assets/{confirm-dialog-CWeNYWZY.js → confirm-dialog-DJSUNG33.js} +2 -2
  8. package/dist/assets/desktop-update-config--zagjHic.js +1 -0
  9. package/dist/assets/{dist-D9xtGgdL.js → dist-BkTYNzm_.js} +1 -1
  10. package/dist/assets/{dist-AYxkFx2B.js → dist-DkR7_Dpl.js} +1 -1
  11. package/dist/assets/doc-browser-DahzJEWj.js +1 -0
  12. package/dist/assets/doc-browser-Q67TOGVS.js +1 -0
  13. package/dist/assets/{doc-browser-context-Cb7Z0j90.js → doc-browser-context-BQim8SqD.js} +1 -1
  14. package/dist/assets/form-actions-B2-bpFie.js +1 -0
  15. package/dist/assets/index-Ba_o5fu9.js +109 -0
  16. package/dist/assets/index-DSLK7zgR.css +1 -0
  17. package/dist/assets/loader-circle-DyXZU92l.js +1 -0
  18. package/dist/assets/mcp-marketplace-page-B0dBE5CV.js +1 -0
  19. package/dist/assets/mcp-marketplace-page-w8panjZG.js +9 -0
  20. package/dist/assets/{model-config-page-DfOLyQqR.js → model-config-page-rrhFaque.js} +1 -1
  21. package/dist/assets/{navigation-link-MiiyoIjH.js → navigation-link-mpHFXwRr.js} +2 -2
  22. package/dist/assets/plus-Dhb4KuMH.js +1 -0
  23. package/dist/assets/{provider-scoped-model-input-DXzjr5l0.js → provider-scoped-model-input-CFsfWEt9.js} +1 -1
  24. package/dist/assets/{providers-config-page-CaO8WfZH.js → providers-config-page-D-TYFZsF.js} +1 -1
  25. package/dist/assets/react-BlXwvOLo.js +8 -0
  26. package/dist/assets/{refresh-cw-a2VVeeAF.js → refresh-cw-JFuyDj_P.js} +1 -1
  27. package/dist/assets/remote-BwVv_Qq-.js +1 -0
  28. package/dist/assets/rotate-cw-DjbbiIW_.js +1 -0
  29. package/dist/assets/{runtime-config-page-BuA4A02z.js → runtime-config-page-Br9j9Vxe.js} +1 -1
  30. package/dist/assets/{save-CGm23Yww.js → save-BL8P8CLX.js} +1 -1
  31. package/dist/assets/{search-CH2t--sH.js → search-bZL75Pgc.js} +1 -1
  32. package/dist/assets/{search-config-page-BAmYqoAz.js → search-config-page-B3fvzMd1.js} +1 -1
  33. package/dist/assets/{secrets-config-page-BlPOuql_.js → secrets-config-page-ClwTxtXd.js} +2 -2
  34. package/dist/assets/security-config-DmJw9QZA.js +1 -0
  35. package/dist/assets/{select-DnxxDpcV.js → select-rUZ8nIZu.js} +2 -2
  36. package/dist/assets/{settings-2-agnMYc8a.js → settings-2-BI4SCkxQ.js} +1 -1
  37. package/dist/assets/skeleton-CVmEPG43.js +1 -0
  38. package/dist/assets/{tag-chip-CPOc8b2N.js → tag-chip-BdI_yIOA.js} +1 -1
  39. package/dist/assets/x-BcEayhV6.js +1 -0
  40. package/dist/index.html +19 -19
  41. package/package.json +7 -7
  42. package/src/app/components/layout/app-layout.tsx +38 -2
  43. package/src/app/hooks/__tests__/use-app-event-consumers.test.ts +20 -0
  44. package/src/app/hooks/use-app-event-consumers.ts +21 -0
  45. package/src/app/presenters/app.presenter.ts +6 -0
  46. package/src/features/apps/components/__tests__/app-packages-panel.test.tsx +92 -14
  47. package/src/features/apps/components/__tests__/apps-panel.test.tsx +58 -0
  48. package/src/features/apps/components/app-artwork.tsx +49 -0
  49. package/src/features/apps/components/app-marketplace-catalog.tsx +332 -0
  50. package/src/features/apps/components/app-marketplace-cover.tsx +64 -0
  51. package/src/features/apps/components/app-marketplace-detail.tsx +161 -0
  52. package/src/features/apps/components/app-marketplace-dialog.tsx +107 -217
  53. package/src/features/apps/components/app-marketplace-operation.tsx +84 -0
  54. package/src/features/apps/components/app-package-card.tsx +201 -52
  55. package/src/features/apps/components/app-packages-panel.tsx +98 -26
  56. package/src/features/apps/components/apps-panel.tsx +28 -27
  57. package/src/features/apps/hooks/use-app-marketplace.test.ts +144 -0
  58. package/src/features/apps/hooks/use-app-marketplace.ts +55 -85
  59. package/src/features/apps/hooks/use-app-packages.ts +59 -4
  60. package/src/features/apps/index.ts +1 -0
  61. package/src/features/apps/managers/__tests__/app-package-operation-settlement.manager.test.ts +81 -0
  62. package/src/features/apps/managers/app-package-operation-settlement.manager.ts +80 -0
  63. package/src/features/apps/services/app-marketplace.service.ts +223 -0
  64. package/src/features/apps/types/app-marketplace.types.ts +75 -0
  65. package/src/features/chat/features/conversation/components/__tests__/session-conversation-input.streaming.test.tsx +64 -3
  66. package/src/features/chat/features/conversation/components/session-queued-input-rows.tsx +24 -14
  67. package/src/features/chat/features/conversation/hooks/__tests__/use-session-conversation-controller.test.tsx +53 -0
  68. package/src/features/chat/features/conversation/hooks/__tests__/use-session-run-queue.test.tsx +18 -0
  69. package/src/features/chat/features/conversation/hooks/use-chat-composer-reference-intent.ts +4 -1
  70. package/src/features/chat/features/conversation/hooks/use-session-conversation-controller.ts +126 -32
  71. package/src/features/chat/features/conversation/hooks/use-session-run-queue.ts +12 -2
  72. package/src/features/chat/features/input/utils/__tests__/chat-inline-token.utils.test.ts +31 -0
  73. package/src/features/chat/features/input/utils/chat-composer-token-protocol.utils.ts +5 -0
  74. package/src/features/chat/features/input/utils/chat-inline-token-composer.utils.ts +14 -0
  75. package/src/features/chat/features/input/utils/chat-inline-token.utils.ts +25 -0
  76. package/src/features/chat/features/message/hooks/use-chat-inline-token-actions.ts +8 -1
  77. package/src/features/chat/features/workspace/components/__tests__/chat-session-workspace-panel-content.test.tsx +77 -20
  78. package/src/features/chat/features/workspace/components/chat-session-workspace-directory-browser.tsx +56 -50
  79. package/src/features/chat/features/workspace/components/project-files/workspace-directory-tree.tsx +33 -11
  80. package/src/features/chat/features/workspace/components/project-files/workspace-project-tree-view.tsx +93 -0
  81. package/src/features/chat/features/workspace/stores/__tests__/workspace-project-tree.store.test.ts +38 -0
  82. package/src/features/chat/features/workspace/stores/workspace-project-tree.store.ts +160 -0
  83. package/src/features/chat/index.ts +1 -0
  84. package/src/features/chat/managers/__tests__/chat-composer-intent.manager.test.ts +29 -0
  85. package/src/features/chat/managers/chat-composer-intent.manager.ts +29 -2
  86. package/src/features/chat/presenters/chat.presenter.ts +2 -2
  87. package/src/features/panel-apps/components/__tests__/panel-apps-list.test.tsx +1 -1
  88. package/src/features/panel-apps/components/panel-apps-list.tsx +1 -6
  89. package/src/features/right-panel-resources/utils/__tests__/right-panel-resource-uri.utils.test.ts +34 -0
  90. package/src/features/right-panel-resources/utils/right-panel-resource-uri.utils.ts +29 -0
  91. package/src/features/service-apps/components/service-apps-panel.tsx +3 -17
  92. package/src/platforms/desktop/components/desktop-app-shell.tsx +5 -0
  93. package/src/platforms/mobile/components/mobile-app-shell.tsx +9 -1
  94. package/src/shared/components/actions/__tests__/navigation-link.test.tsx +12 -0
  95. package/src/shared/components/actions/navigation-link.tsx +6 -3
  96. package/src/shared/components/doc-browser/__tests__/doc-browser.test.tsx +34 -0
  97. package/src/shared/components/doc-browser/doc-browser-tab-strip.tsx +5 -0
  98. package/src/shared/components/doc-browser/doc-browser.tsx +11 -1
  99. package/src/shared/components/doc-browser/managers/doc-browser.manager.ts +13 -0
  100. package/src/shared/components/ui/tabs.tsx +24 -1
  101. package/src/shared/hooks/use-server-path-watch.ts +42 -0
  102. package/src/shared/lib/api/server-path/server-path.types.ts +10 -0
  103. package/src/shared/lib/api/types.ts +3 -0
  104. package/src/shared/lib/api/utils/server-path.utils.ts +10 -0
  105. package/src/shared/lib/i18n/locales/en-US/chat.json +1 -0
  106. package/src/shared/lib/i18n/locales/en-US/doc-browser.json +49 -7
  107. package/src/shared/lib/i18n/locales/zh-CN/chat.json +1 -0
  108. package/src/shared/lib/i18n/locales/zh-CN/doc-browser.json +49 -7
  109. package/vite.config.ts +32 -1
  110. package/dist/assets/desktop-update-config-Dirc3bpt.js +0 -1
  111. package/dist/assets/doc-browser-4Sud6dWw.js +0 -1
  112. package/dist/assets/doc-browser-D4x_yH5F.js +0 -1
  113. package/dist/assets/form-actions-B2q4jgjE.js +0 -1
  114. package/dist/assets/index-CdyTsSBu.css +0 -1
  115. package/dist/assets/index-Das_bw2h.js +0 -107
  116. package/dist/assets/loader-circle-CpmBdaon.js +0 -1
  117. package/dist/assets/mcp-marketplace-page-CdOY3Sop.js +0 -1
  118. package/dist/assets/mcp-marketplace-page-DvjrfqEt.js +0 -9
  119. package/dist/assets/plus-C8FkjpSZ.js +0 -1
  120. package/dist/assets/react-C8XFFvuz.js +0 -8
  121. package/dist/assets/remote-BJHXa4mB.js +0 -1
  122. package/dist/assets/rotate-cw-DJ7UF3F2.js +0 -1
  123. package/dist/assets/security-config-CbDiY2xr.js +0 -1
  124. package/dist/assets/skeleton-XpqeWL62.js +0 -1
  125. package/dist/assets/x-odTHAtLZ.js +0 -1
@@ -1,4 +1,4 @@
1
- import { useCallback, useMemo } from 'react';
1
+ import { useCallback, useMemo, useState } from 'react';
2
2
  import { buildNcpRequestEnvelope } from '@nextclaw/ncp-react';
3
3
  import type { UiNcpSessionQueuedInputView } from '@nextclaw/client-sdk';
4
4
  import type { NcpAgentSendEnvelope, NcpRunHandle } from '@nextclaw/ncp';
@@ -39,11 +39,13 @@ type SessionConversationAgent = SessionConversationRecoveryAgent & {
39
39
 
40
40
  export type SessionConversationQueuedInput = {
41
41
  readonly id: string;
42
+ readonly isSubmitting?: boolean;
42
43
  readonly preview: string;
43
44
  };
44
45
 
45
46
  type SessionRunQueue = {
46
47
  readonly inputs: readonly UiNcpSessionQueuedInputView[];
48
+ readonly refreshQueuedInputs: () => Promise<readonly UiNcpSessionQueuedInputView[]>;
47
49
  readonly removeQueuedInput: (
48
50
  queuedInputId: string,
49
51
  ) => Promise<UiNcpSessionQueuedInputView | null>;
@@ -85,6 +87,13 @@ type SubmissionDraft = {
85
87
  readonly metadata: Record<string, unknown>;
86
88
  };
87
89
 
90
+ type SubmittingQueuedInput = {
91
+ readonly id: string;
92
+ readonly preview: string;
93
+ readonly sessionId: string;
94
+ readonly userMessageId: string;
95
+ };
96
+
88
97
  const hasSendableMessagePart = (parts: ReturnType<typeof deriveNcpMessagePartsFromComposer>): boolean =>
89
98
  parts.some((part) => part.type !== 'text' || part.text.trim().length > 0);
90
99
 
@@ -188,6 +197,97 @@ function buildSubmissionEnvelope(
188
197
  });
189
198
  }
190
199
 
200
+ function buildSubmittingQueuedInput(
201
+ envelope: NcpAgentSendEnvelope,
202
+ ): SubmittingQueuedInput | null {
203
+ const sessionId = envelope.sessionId?.trim();
204
+ if (!sessionId) {
205
+ return null;
206
+ }
207
+ const message = {
208
+ ...envelope.message,
209
+ sessionId,
210
+ };
211
+ const enqueuedAt = new Date().toISOString();
212
+ const input: UiNcpSessionQueuedInputView = {
213
+ id: `submitting-${message.id}`,
214
+ sessionId,
215
+ enqueuedAt,
216
+ message,
217
+ metadata: structuredClone(envelope.metadata ?? {}),
218
+ };
219
+ return {
220
+ id: input.id,
221
+ preview: buildSessionQueuedInputPreview(input),
222
+ sessionId,
223
+ userMessageId: message.id,
224
+ };
225
+ }
226
+
227
+ function useSubmittingQueuedInputProjection(
228
+ serverInputs: readonly UiNcpSessionQueuedInputView[],
229
+ sessionKey: string | null,
230
+ ) {
231
+ const [submittingInput, setSubmittingInput] =
232
+ useState<SubmittingQueuedInput | null>(null);
233
+ const stageSubmittingInput = useCallback((envelope: NcpAgentSendEnvelope) => {
234
+ const input = buildSubmittingQueuedInput(envelope);
235
+ if (input) {
236
+ setSubmittingInput(input);
237
+ }
238
+ return input;
239
+ }, []);
240
+ const clearSubmittingInput = useCallback((input: SubmittingQueuedInput) => {
241
+ setSubmittingInput((current) =>
242
+ current?.userMessageId === input.userMessageId ? null : current
243
+ );
244
+ }, []);
245
+ const queuedInputs = useMemo<SessionConversationQueuedInput[]>(() => {
246
+ const projected: SessionConversationQueuedInput[] = serverInputs.map((input) => ({
247
+ id: input.id,
248
+ preview: buildSessionQueuedInputPreview(input),
249
+ }));
250
+ if (
251
+ submittingInput?.sessionId === sessionKey &&
252
+ !serverInputs.some((input) => input.message.id === submittingInput.userMessageId)
253
+ ) {
254
+ projected.push({
255
+ id: submittingInput.id,
256
+ isSubmitting: true,
257
+ preview: submittingInput.preview,
258
+ });
259
+ }
260
+ return projected;
261
+ }, [serverInputs, sessionKey, submittingInput]);
262
+ return { clearSubmittingInput, queuedInputs, stageSubmittingInput };
263
+ }
264
+
265
+ function useQueuedInputActions(params: {
266
+ readonly availableSkills: readonly { ref: string; name: string }[];
267
+ readonly restoreComposer: (snapshot: ComposerDraftSnapshot) => void;
268
+ readonly runQueue: SessionRunQueue;
269
+ readonly setSendError: (message: string | null) => void;
270
+ }) {
271
+ const { availableSkills, restoreComposer, runQueue, setSendError } = params;
272
+ const editQueuedInput = useCallback((id: string) => {
273
+ const input = runQueue.inputs.find((item) => item.id === id);
274
+ if (!input) return;
275
+ void runQueue.removeQueuedInput(id).then((removed) => {
276
+ if (!removed) return;
277
+ restoreComposer(buildSessionQueuedInputComposerSnapshot(removed, availableSkills));
278
+ setSendError(null);
279
+ }).catch((error) => {
280
+ setSendError(error instanceof Error ? error.message : String(error));
281
+ });
282
+ }, [availableSkills, restoreComposer, runQueue, setSendError]);
283
+ const deleteQueuedInput = useCallback((id: string) => {
284
+ void runQueue.removeQueuedInput(id).then(() => setSendError(null)).catch((error) => {
285
+ setSendError(error instanceof Error ? error.message : String(error));
286
+ });
287
+ }, [runQueue, setSendError]);
288
+ return { deleteQueuedInput, editQueuedInput };
289
+ }
290
+
191
291
  export function useSessionConversationController(params: UseSessionConversationControllerParams) {
192
292
  const {
193
293
  agent,
@@ -203,6 +303,17 @@ export function useSessionConversationController(params: UseSessionConversationC
203
303
  restoreComposer,
204
304
  setSendError,
205
305
  } = params;
306
+ const {
307
+ clearSubmittingInput,
308
+ queuedInputs,
309
+ stageSubmittingInput,
310
+ } = useSubmittingQueuedInputProjection(runQueue.inputs, sessionKey);
311
+ const { deleteQueuedInput, editQueuedInput } = useQueuedInputActions({
312
+ availableSkills: inputQuery.skillRecords,
313
+ restoreComposer,
314
+ runQueue,
315
+ setSendError,
316
+ });
206
317
  const parts = useMemo(
207
318
  () => deriveNcpMessagePartsFromComposer([...inputSnapshot.nodes], inputSnapshot.attachments),
208
319
  [inputSnapshot.attachments, inputSnapshot.nodes],
@@ -255,10 +366,17 @@ export function useSessionConversationController(params: UseSessionConversationC
255
366
  if (!envelope) {
256
367
  return;
257
368
  }
369
+ const queuedSubmission = agent.isRunning ? stageSubmittingInput(envelope) : null;
258
370
  resetComposer();
259
371
  setSendError(null);
260
372
  try {
261
373
  const handle = await agent.send(envelope);
374
+ if (queuedSubmission && handle?.runId === null) {
375
+ await runQueue.refreshQueuedInputs().catch(() => undefined);
376
+ }
377
+ if (queuedSubmission) {
378
+ clearSubmittingInput(queuedSubmission);
379
+ }
262
380
  const materializedSessionKey =
263
381
  handle?.sessionId?.trim() ||
264
382
  agent.snapshot.activeRun?.sessionId?.trim() ||
@@ -267,12 +385,16 @@ export function useSessionConversationController(params: UseSessionConversationC
267
385
  onSessionMaterialized?.(materializedSessionKey);
268
386
  }
269
387
  } catch (error) {
388
+ if (queuedSubmission) {
389
+ clearSubmittingInput(queuedSubmission);
390
+ }
270
391
  restoreComposer(draft.composerSnapshot);
271
392
  setSendError(error instanceof Error ? error.message : String(error));
272
393
  throw error;
273
394
  }
274
395
  }, [
275
396
  agent,
397
+ clearSubmittingInput,
276
398
  continueRun,
277
399
  inputQuery,
278
400
  inputSnapshot,
@@ -284,36 +406,11 @@ export function useSessionConversationController(params: UseSessionConversationC
284
406
  selectedAgentId,
285
407
  sessionKey,
286
408
  setSendError,
409
+ stageSubmittingInput,
287
410
  primaryAction,
411
+ runQueue,
288
412
  ]);
289
413
 
290
- const editQueuedInput = useCallback((id: string) => {
291
- const input = runQueue.inputs.find((item) => item.id === id);
292
- if (!input) {
293
- return;
294
- }
295
- void runQueue.removeQueuedInput(id).then((removed) => {
296
- if (!removed) {
297
- return;
298
- }
299
- restoreComposer(buildSessionQueuedInputComposerSnapshot(
300
- removed,
301
- inputQuery.skillRecords,
302
- ));
303
- setSendError(null);
304
- }).catch((error) => {
305
- setSendError(error instanceof Error ? error.message : String(error));
306
- });
307
- }, [inputQuery.skillRecords, restoreComposer, runQueue, setSendError]);
308
-
309
- const deleteQueuedInput = useCallback((id: string) => {
310
- void runQueue.removeQueuedInput(id).then(() => {
311
- setSendError(null);
312
- }).catch((error) => {
313
- setSendError(error instanceof Error ? error.message : String(error));
314
- });
315
- }, [runQueue, setSendError]);
316
-
317
414
  const stop = useCallback(async () => {
318
415
  await agent.abort();
319
416
  }, [agent]);
@@ -327,10 +424,7 @@ export function useSessionConversationController(params: UseSessionConversationC
327
424
  editQueuedInput,
328
425
  hasSendableDraft,
329
426
  isSending,
330
- queuedInputs: runQueue.inputs.map((input) => ({
331
- id: input.id,
332
- preview: buildSessionQueuedInputPreview(input),
333
- })),
427
+ queuedInputs,
334
428
  send,
335
429
  sendDisabled,
336
430
  primaryAction,
@@ -16,6 +16,7 @@ export function useSessionRunQueue(sessionKey: string | null) {
16
16
  retry: false,
17
17
  staleTime: 5_000,
18
18
  });
19
+ const { data, isLoading, refetch } = query;
19
20
 
20
21
  useEffect(() => {
21
22
  if (!normalizedSessionKey) {
@@ -43,9 +44,18 @@ export function useSessionRunQueue(sessionKey: string | null) {
43
44
  );
44
45
  }, [normalizedSessionKey]);
45
46
 
47
+ const refreshQueuedInputs = useCallback(async (): Promise<readonly UiNcpSessionQueuedInputView[]> => {
48
+ if (!normalizedSessionKey) {
49
+ return [];
50
+ }
51
+ const result = await refetch();
52
+ return result.data?.inputs ?? [];
53
+ }, [normalizedSessionKey, refetch]);
54
+
46
55
  return {
47
- inputs: query.data?.inputs ?? [],
48
- isLoading: query.isLoading,
56
+ inputs: data?.inputs ?? [],
57
+ isLoading,
58
+ refreshQueuedInputs,
49
59
  removeQueuedInput,
50
60
  };
51
61
  }
@@ -374,3 +374,34 @@ describe('chat inline token workspace references', () => {
374
374
  })).toBeNull();
375
375
  });
376
376
  });
377
+
378
+ describe('UI resource inline tokens', () => {
379
+ it('round-trips the visible token with its selection-time snapshot', () => {
380
+ const reference = {
381
+ uri: 'nextclaw://apps?tab=panel-apps',
382
+ resourceKind: 'apps',
383
+ title: 'Panel Apps',
384
+ currentUrl: 'nextclaw://apps?tab=panel-apps',
385
+ contentParams: { filter: 'installed' },
386
+ };
387
+ const tokens = buildInlineTokensFromComposer([
388
+ createChatComposerTokenNode({
389
+ tokenKind: 'ui_resource',
390
+ tokenKey: reference.uri,
391
+ label: reference.title,
392
+ data: { reference },
393
+ }),
394
+ ]);
395
+
396
+ expect(tokens).toEqual([{
397
+ kind: 'ui_resource',
398
+ key: reference.uri,
399
+ label: reference.title,
400
+ rawText: `@resource:${encodeURIComponent(reference.uri)}`,
401
+ reference,
402
+ }]);
403
+ expect(readInlineTokensFromMetadata({
404
+ ui_inline_tokens: createInlineTokensMetadata(tokens),
405
+ })).toEqual(tokens);
406
+ });
407
+ });
@@ -2,6 +2,7 @@ import {
2
2
  CHAT_CONVERSATION_EXCERPT_TOKEN_KIND,
3
3
  CHAT_PROJECT_TOKEN_KIND,
4
4
  CHAT_SYSTEM_OBJECT_TOKEN_KIND,
5
+ CHAT_UI_RESOURCE_TOKEN_KIND,
5
6
  CHAT_WORKSPACE_DIRECTORY_TOKEN_KIND,
6
7
  CHAT_WORKSPACE_EXCERPT_TOKEN_KIND,
7
8
  CHAT_WORKSPACE_FILE_TOKEN_KIND,
@@ -15,6 +16,7 @@ const CHAT_WORKSPACE_DIRECTORY_TOKEN_PREFIX = '@folder:';
15
16
  const CHAT_WORKSPACE_EXCERPT_TOKEN_PREFIX = '@excerpt:';
16
17
  const CHAT_CONVERSATION_EXCERPT_TOKEN_PREFIX = '@message-excerpt:';
17
18
  const CHAT_SYSTEM_OBJECT_TOKEN_PREFIX = '@object:';
19
+ const CHAT_UI_RESOURCE_TOKEN_PREFIX = '@resource:';
18
20
 
19
21
  export function serializeChatComposerTokenText(params: {
20
22
  label?: string;
@@ -46,5 +48,8 @@ export function serializeChatComposerTokenText(params: {
46
48
  if (tokenKind === CHAT_SYSTEM_OBJECT_TOKEN_KIND) {
47
49
  return `${CHAT_SYSTEM_OBJECT_TOKEN_PREFIX}${encodeURIComponent(tokenKey)}`;
48
50
  }
51
+ if (tokenKind === CHAT_UI_RESOURCE_TOKEN_KIND) {
52
+ return `${CHAT_UI_RESOURCE_TOKEN_PREFIX}${encodeURIComponent(tokenKey)}`;
53
+ }
49
54
  return null;
50
55
  }
@@ -3,11 +3,13 @@ import {
3
3
  CHAT_CONVERSATION_EXCERPT_TOKEN_KIND,
4
4
  CHAT_PROJECT_TOKEN_KIND,
5
5
  CHAT_SYSTEM_OBJECT_TOKEN_KIND,
6
+ CHAT_UI_RESOURCE_TOKEN_KIND,
6
7
  CHAT_WORKSPACE_DIRECTORY_TOKEN_KIND,
7
8
  CHAT_WORKSPACE_EXCERPT_TOKEN_KIND,
8
9
  CHAT_WORKSPACE_FILE_TOKEN_KIND,
9
10
  type ChatInlineTokenMetadata,
10
11
  type ChatSkillSource,
12
+ readChatUiResourceReference,
11
13
  } from '@nextclaw/shared';
12
14
 
13
15
  import { serializeChatComposerTokenText } from './chat-composer-token-protocol.utils';
@@ -83,6 +85,18 @@ function buildStructuredReferenceToken(
83
85
  }
84
86
  : null;
85
87
  }
88
+ if (node.tokenKind === CHAT_UI_RESOURCE_TOKEN_KIND) {
89
+ const reference = readChatUiResourceReference(node.data?.reference);
90
+ return reference?.uri === node.tokenKey
91
+ ? {
92
+ kind: CHAT_UI_RESOURCE_TOKEN_KIND,
93
+ key: node.tokenKey,
94
+ label: node.label,
95
+ rawText,
96
+ reference,
97
+ }
98
+ : null;
99
+ }
86
100
  return undefined;
87
101
  }
88
102
 
@@ -4,6 +4,7 @@ import {
4
4
  CHAT_INLINE_TOKENS_SCHEMA_VERSION,
5
5
  CHAT_PROJECT_TOKEN_KIND,
6
6
  CHAT_SYSTEM_OBJECT_TOKEN_KIND,
7
+ CHAT_UI_RESOURCE_TOKEN_KIND,
7
8
  CHAT_WORKSPACE_DIRECTORY_TOKEN_KIND,
8
9
  CHAT_WORKSPACE_EXCERPT_TOKEN_KIND,
9
10
  CHAT_WORKSPACE_FILE_TOKEN_KIND,
@@ -11,6 +12,8 @@ import {
11
12
  type ChatInlineTokensMetadata,
12
13
  type ChatSkillSource,
13
14
  type SystemObjectResolvedReference,
15
+ type ChatUiResourceReference,
16
+ readChatUiResourceReference,
14
17
  } from '@nextclaw/shared';
15
18
  import { readConversationExcerptInlineToken } from './chat-conversation-excerpt-token.utils';
16
19
  import { readSystemObjectResolvedReference } from './chat-system-object-reference.utils';
@@ -26,6 +29,7 @@ const CHAT_PROJECT_TOKEN_PATTERN = /@project:([^\s]+)/g;
26
29
  const CHAT_WORKSPACE_FILE_TOKEN_PATTERN = /@file:([^\s]+)/g;
27
30
  const CHAT_WORKSPACE_DIRECTORY_TOKEN_PATTERN = /@folder:([^\s]+)/g;
28
31
  const CHAT_SYSTEM_OBJECT_TOKEN_PATTERN = /@object:([^\s]+)/g;
32
+ const CHAT_UI_RESOURCE_TOKEN_PATTERN = /@resource:([^\s]+)/g;
29
33
 
30
34
  export type ChatInlineTokenSource =
31
35
  | {
@@ -68,6 +72,13 @@ export type ChatInlineTokenSource =
68
72
  label: string;
69
73
  rawText: string;
70
74
  reference: SystemObjectResolvedReference;
75
+ }
76
+ | {
77
+ kind: typeof CHAT_UI_RESOURCE_TOKEN_KIND;
78
+ key: string;
79
+ label: string;
80
+ rawText: string;
81
+ reference: ChatUiResourceReference;
71
82
  };
72
83
 
73
84
  export function resolveWorkspaceReferencePath(params: {
@@ -124,6 +135,7 @@ function appendEncodedKeyTokens(params: {
124
135
  kind:
125
136
  | typeof CHAT_PROJECT_TOKEN_KIND
126
137
  | typeof CHAT_SYSTEM_OBJECT_TOKEN_KIND
138
+ | typeof CHAT_UI_RESOURCE_TOKEN_KIND
127
139
  | typeof CHAT_WORKSPACE_FILE_TOKEN_KIND
128
140
  | typeof CHAT_WORKSPACE_DIRECTORY_TOKEN_KIND;
129
141
  pattern: RegExp;
@@ -177,6 +189,12 @@ export function buildInlineTokensFromTextProtocol(text: string): ChatInlineToken
177
189
  text,
178
190
  tokens,
179
191
  });
192
+ appendEncodedKeyTokens({
193
+ kind: CHAT_UI_RESOURCE_TOKEN_KIND,
194
+ pattern: CHAT_UI_RESOURCE_TOKEN_PATTERN,
195
+ text,
196
+ tokens,
197
+ });
180
198
  appendEncodedKeyTokens({
181
199
  kind: CHAT_WORKSPACE_FILE_TOKEN_KIND,
182
200
  pattern: CHAT_WORKSPACE_FILE_TOKEN_PATTERN,
@@ -269,6 +287,13 @@ function readInlineTokenEntry(entry: unknown): ChatInlineTokenSource | null {
269
287
  ? { kind, key, label, rawText, reference }
270
288
  : null;
271
289
  }
290
+ if (kind === CHAT_UI_RESOURCE_TOKEN_KIND) {
291
+ const key = readOptionalString(entry.key);
292
+ const reference = readChatUiResourceReference(entry.reference);
293
+ return key && reference && reference.uri === key
294
+ ? { kind, key, label, rawText, reference }
295
+ : null;
296
+ }
272
297
  const key = readOptionalString(entry.key);
273
298
  return key ? { kind, key, rawText, label } : null;
274
299
  }
@@ -2,10 +2,12 @@ import { useCallback } from "react";
2
2
  import type { ChatInlineTokenViewModel } from "@nextclaw/agent-chat-ui";
3
3
  import {
4
4
  CHAT_PROJECT_TOKEN_KIND,
5
+ CHAT_UI_RESOURCE_TOKEN_KIND,
5
6
  CHAT_WORKSPACE_EXCERPT_TOKEN_KIND,
6
7
  } from "@nextclaw/shared";
7
8
  import { toast } from "sonner";
8
9
  import { usePresenter } from "@/features/chat/components/providers/chat-presenter.provider";
10
+ import { useAppPresenter } from "@/app/components/app-presenter-provider";
9
11
  import { resolveWorkspaceReferencePath } from "@/features/chat/features/input/utils/chat-inline-token.utils";
10
12
  import { fetchNcpSessionSkills } from "@/shared/lib/api";
11
13
  import { t } from "@/shared/lib/i18n";
@@ -21,9 +23,14 @@ export function useChatInlineTokenActions(params: {
21
23
  }) {
22
24
  const { selectedSession, sessionKey } = params;
23
25
  const presenter = usePresenter();
26
+ const appPresenter = useAppPresenter();
24
27
  const projectRoot = selectedSession?.projectRoot ?? selectedSession?.workingDir;
25
28
 
26
29
  const handleInlineTokenClick = useCallback((token: ChatInlineTokenViewModel) => {
30
+ if (token.kind === CHAT_UI_RESOURCE_TOKEN_KIND && "key" in token) {
31
+ appPresenter.docBrowserManager.open(token.key);
32
+ return;
33
+ }
27
34
  if (token.kind === "panel_app" && "key" in token) {
28
35
  void presenter.chatUiManager.showContent({
29
36
  target: { type: "panel_app", payload: { appId: token.key } },
@@ -98,7 +105,7 @@ export function useChatInlineTokenActions(params: {
98
105
  previewViewer: "rendered",
99
106
  });
100
107
  }).catch(() => toast.error(t("chatSkillPreviewUnavailable")));
101
- }, [presenter, projectRoot, selectedSession?.projectRoot, sessionKey]);
108
+ }, [appPresenter.docBrowserManager, presenter, projectRoot, selectedSession?.projectRoot, sessionKey]);
102
109
 
103
110
  const handleAttachmentOpen = useCallback((file: {
104
111
  label: string;
@@ -5,6 +5,7 @@ import { ChatSessionWorkspacePanelContent } from '@/features/chat/features/works
5
5
  import type { ResolvedChildSessionTab } from '@/features/chat/features/ncp/hooks/use-ncp-child-session-tabs-view';
6
6
  import { useChatThreadStore } from '@/features/chat/stores/chat-thread.store';
7
7
  import { CHAT_WORKSPACE_EXPLORER_DEFAULT_WIDTH } from '@/features/chat/features/workspace/utils/chat-workspace-panel-layout.utils';
8
+ import { useWorkspaceProjectTreeStore } from '@/features/chat/features/workspace/stores/workspace-project-tree.store';
8
9
 
9
10
  const mocks = vi.hoisted(() => ({
10
11
  browseEntries: [
@@ -27,7 +28,9 @@ const mocks = vi.hoisted(() => ({
27
28
  deleteServerPathEntry: vi.fn(),
28
29
  renameServerPathEntry: vi.fn(),
29
30
  uploadServerPathFiles: vi.fn(),
31
+ watchDirectories: vi.fn(),
30
32
  refetchBrowse: vi.fn(),
33
+ refetchQueries: vi.fn(),
31
34
  requestDirectoryReference: vi.fn(),
32
35
  requestFileReference: vi.fn(),
33
36
  openChildSessions: vi.fn(),
@@ -41,6 +44,15 @@ const mocks = vi.hoisted(() => ({
41
44
  scrollIntoView: vi.fn(),
42
45
  }));
43
46
 
47
+ vi.mock('@/shared/hooks/use-server-path-watch', () => ({
48
+ useServerPathWatch: mocks.watchDirectories,
49
+ }));
50
+
51
+ vi.mock('@tanstack/react-query', async (importOriginal) => ({
52
+ ...(await importOriginal()),
53
+ useQueryClient: () => ({ refetchQueries: mocks.refetchQueries }),
54
+ }));
55
+
44
56
  function firePointerEvent(
45
57
  target: Window | Document | Node | Element,
46
58
  type: string,
@@ -168,6 +180,8 @@ beforeEach(() => {
168
180
  overwritten: false,
169
181
  });
170
182
  mocks.refetchBrowse.mockResolvedValue(undefined);
183
+ mocks.refetchQueries.mockResolvedValue(undefined);
184
+ useWorkspaceProjectTreeStore.setState({ trees: {} });
171
185
  useChatThreadStore.getState().setSnapshot({
172
186
  workspaceExplorerOpen: false,
173
187
  workspaceExplorerWidth: CHAT_WORKSPACE_EXPLORER_DEFAULT_WIDTH,
@@ -475,7 +489,7 @@ it('creates a project folder from the root toolbar and refreshes the tree', asyn
475
489
  parentPath: '/Users/peiwang/Projects/nextbot',
476
490
  name: 'research',
477
491
  });
478
- expect(mocks.refetchBrowse).toHaveBeenCalled();
492
+ expect(mocks.refetchQueries).toHaveBeenCalled();
479
493
  expect(document.activeElement).toBe(screen.getByRole('button', { name: 'research' }));
480
494
  expect(mocks.scrollIntoView).toHaveBeenCalledTimes(2);
481
495
  });
@@ -564,7 +578,7 @@ it('uploads multiple project files from the root actions menu', async () => {
564
578
  files: [file],
565
579
  overwrite: undefined,
566
580
  });
567
- expect(mocks.refetchBrowse).toHaveBeenCalled();
581
+ expect(mocks.refetchQueries).toHaveBeenCalled();
568
582
  });
569
583
  });
570
584
 
@@ -594,13 +608,62 @@ it('requires destructive confirmation before deleting a project file', async ()
594
608
  path: '/private/resolved-worktree/package.json',
595
609
  });
596
610
  expect(mocks.removeWorkspacePath).toHaveBeenCalledWith('/private/resolved-worktree/package.json');
597
- expect(mocks.refetchBrowse).toHaveBeenCalled();
611
+ expect(mocks.refetchQueries).toHaveBeenCalled();
598
612
  });
599
613
  });
600
614
 
601
- it('preserves project tree expansion and scroll while visiting another workspace tab', async () => {
615
+ it('refreshes the root and every active expanded directory query', async () => {
602
616
  const user = userEvent.setup();
603
- const projectFilesProps = {
617
+ render(
618
+ <ChatSessionWorkspacePanelContent
619
+ activeSelection={{ kind: 'project-files' }}
620
+ childSessionTabs={[]}
621
+ filePreviewRefreshVersion={0}
622
+ sessionKey="parent-1"
623
+ sessionCronJobs={[]}
624
+ sessionProjectRoot="/Users/peiwang/Projects/nextbot"
625
+ sessionWorkingDir="/Users/peiwang/Projects/nextbot"
626
+ />,
627
+ );
628
+
629
+ await user.click(screen.getByRole('button', { name: 'src' }));
630
+ await user.click(screen.getByRole('button', { name: 'Refresh project files' }));
631
+
632
+ expect(mocks.refetchQueries).toHaveBeenCalledTimes(1);
633
+ const request = mocks.refetchQueries.mock.calls[0]?.[0] as {
634
+ predicate: (query: { queryKey: readonly unknown[] }) => boolean;
635
+ type: string;
636
+ };
637
+ expect(request.type).toBe('active');
638
+ expect(request.predicate({ queryKey: ['server-path-browse', '/Users/peiwang/Projects/nextbot', '', true] })).toBe(true);
639
+ expect(request.predicate({ queryKey: ['server-path-browse', '/Users/peiwang/Projects/nextbot/src', '', true] })).toBe(true);
640
+ expect(request.predicate({ queryKey: ['server-path-browse', '/tmp/another-project', '', true] })).toBe(false);
641
+ });
642
+
643
+ it('collapses all persisted directories in one action', async () => {
644
+ const user = userEvent.setup();
645
+ render(
646
+ <ChatSessionWorkspacePanelContent
647
+ activeSelection={{ kind: 'project-files' }}
648
+ childSessionTabs={[]}
649
+ filePreviewRefreshVersion={0}
650
+ sessionKey="parent-1"
651
+ sessionCronJobs={[]}
652
+ sessionProjectRoot="/Users/peiwang/Projects/nextbot"
653
+ sessionWorkingDir="/Users/peiwang/Projects/nextbot"
654
+ />,
655
+ );
656
+
657
+ const sourceDirectory = screen.getByRole('treeitem', { name: 'Open directory: src' });
658
+ await user.click(screen.getByRole('button', { name: 'src' }));
659
+ expect(sourceDirectory.getAttribute('aria-expanded')).toBe('true');
660
+ await user.click(screen.getByRole('button', { name: 'Collapse all' }));
661
+ expect(sourceDirectory.getAttribute('aria-expanded')).toBe('false');
662
+ });
663
+
664
+ it('restores expansion and scroll from project view state after remounting', async () => {
665
+ const user = userEvent.setup();
666
+ const props = {
604
667
  activeSelection: { kind: 'project-files' as const },
605
668
  childSessionTabs: [],
606
669
  filePreviewRefreshVersion: 0,
@@ -609,22 +672,16 @@ it('preserves project tree expansion and scroll while visiting another workspace
609
672
  sessionProjectRoot: '/Users/peiwang/Projects/nextbot',
610
673
  sessionWorkingDir: '/Users/peiwang/Projects/nextbot',
611
674
  };
612
- const { rerender } = render(<ChatSessionWorkspacePanelContent {...projectFilesProps} />);
613
- const tree = screen.getByRole('tree', { name: 'Project files' });
614
- const sourceDirectory = screen.getByRole('treeitem', {
615
- name: 'Open directory: src',
616
- });
675
+ const first = render(<ChatSessionWorkspacePanelContent {...props} />);
617
676
  await user.click(screen.getByRole('button', { name: 'src' }));
618
- tree.scrollTop = 128;
619
- expect(sourceDirectory.getAttribute('aria-expanded')).toBe('true');
620
-
621
- rerender(<ChatSessionWorkspacePanelContent {...projectFilesProps} activeSelection={{ kind: 'overview' }} />);
622
- rerender(<ChatSessionWorkspacePanelContent {...projectFilesProps} />);
623
-
624
- expect(screen.getByRole('tree', { name: 'Project files' })).toBe(tree);
625
- expect(sourceDirectory.isConnected).toBe(true);
626
- expect(sourceDirectory.getAttribute('aria-expanded')).toBe('true');
627
- expect(tree.scrollTop).toBe(128);
677
+ const firstTree = screen.getByRole('tree', { name: 'Project files' });
678
+ firstTree.scrollTop = 144;
679
+ fireEvent.scroll(firstTree);
680
+ first.unmount();
681
+
682
+ render(<ChatSessionWorkspacePanelContent {...props} />);
683
+ expect(screen.getAllByRole('treeitem', { name: 'Open directory: src' })[0]?.getAttribute('aria-expanded')).toBe('true');
684
+ expect(screen.getByRole('tree', { name: 'Project files' }).scrollTop).toBe(144);
628
685
  });
629
686
 
630
687
  it('selects and reveals the active file when it is opened outside the Explorer', async () => {