@nextclaw/ui 0.15.23 → 0.15.24

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 (83) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/assets/{api-Glw2OGXK.js → api-BxPNDp30.js} +3 -3
  3. package/dist/assets/appearance-settings-page-DpVJjvkB.js +1 -0
  4. package/dist/assets/{channels-list-page-C7E4Sxwd.js → channels-list-page-DRaNWAxC.js} +2 -2
  5. package/dist/assets/chat-page-BuFDmG5_.js +1 -0
  6. package/dist/assets/config-split-page-BVWVDbvG.js +1 -0
  7. package/dist/assets/confirm-dialog-mA2iVzxh.js +5 -0
  8. package/dist/assets/{desktop-update-config-BwuyBPJb.js → desktop-update-config-DyQb1LXr.js} +1 -1
  9. package/dist/assets/{dist-dv_SUR54.js → dist-B4Pf5X3Q.js} +1 -1
  10. package/dist/assets/{dist-CtLyEJeO.js → dist-DHEWkGWX.js} +1 -1
  11. package/dist/assets/{doc-browser-DlOOhBxI.js → doc-browser-CtO_HtRy.js} +1 -1
  12. package/dist/assets/{doc-browser-context-BIBjkwfV.js → doc-browser-context-CuGI6PQc.js} +1 -1
  13. package/dist/assets/doc-browser-eMUagyBV.js +1 -0
  14. package/dist/assets/form-actions-BOfIyG3O.js +1 -0
  15. package/dist/assets/index-BjBdWHV1.js +107 -0
  16. package/dist/assets/index-DmpmjAGQ.css +1 -0
  17. package/dist/assets/loader-circle-Df8wF2Pr.js +1 -0
  18. package/dist/assets/mcp-marketplace-page-DI49IKxp.js +1 -0
  19. package/dist/assets/{mcp-marketplace-page-ByhvQQj5.js → mcp-marketplace-page-ofDqbOIF.js} +3 -3
  20. package/dist/assets/{model-config-page-bgEx3MjQ.js → model-config-page-D73Tu3jW.js} +1 -1
  21. package/dist/assets/{navigation-link-CB0OQbnY.js → navigation-link-CAFOcXNg.js} +2 -2
  22. package/dist/assets/plus-DVU5RLCS.js +1 -0
  23. package/dist/assets/provider-scoped-model-input-BaJJJYMX.js +1 -0
  24. package/dist/assets/providers-config-page-BL6IRVBm.js +1 -0
  25. package/dist/assets/react-BC_orpAj.js +8 -0
  26. package/dist/assets/{refresh-cw-Ba0LZou4.js → refresh-cw-CWDp_JnN.js} +1 -1
  27. package/dist/assets/remote-T24O58lH.js +1 -0
  28. package/dist/assets/rotate-cw-BqhwSV5p.js +1 -0
  29. package/dist/assets/{runtime-config-page-C0ByyUVr.js → runtime-config-page-CXUx017t.js} +1 -1
  30. package/dist/assets/{save-DTOAvqzt.js → save-Cnr71JON.js} +1 -1
  31. package/dist/assets/{search-D4X5DNMe.js → search-CCX11X2m.js} +1 -1
  32. package/dist/assets/{search-config-page-Cr6254K5.js → search-config-page-BB6d5gT7.js} +1 -1
  33. package/dist/assets/{secrets-config-page-W2Hx332d.js → secrets-config-page-DYsDeT9K.js} +2 -2
  34. package/dist/assets/security-config-CVPgqL7G.js +1 -0
  35. package/dist/assets/{select-CDv0g2Pj.js → select-DvwBu99K.js} +2 -2
  36. package/dist/assets/{settings-2-CoxzNABp.js → settings-2-Dmv6zMne.js} +1 -1
  37. package/dist/assets/skeleton-C5tWata3.js +1 -0
  38. package/dist/assets/{tag-chip-CjkZEoS2.js → tag-chip-gMPvx7n_.js} +1 -1
  39. package/dist/assets/use-confirm-dialog-D_3-QpZU.js +1 -0
  40. package/dist/assets/x-BEWSqeux.js +1 -0
  41. package/dist/index.html +20 -20
  42. package/package.json +7 -7
  43. package/src/features/chat/features/conversation/hooks/__tests__/use-session-provider-model-catalog.test.tsx +54 -2
  44. package/src/features/chat/features/conversation/hooks/use-chat-composer-reference-intent.ts +10 -1
  45. package/src/features/chat/features/conversation/hooks/use-session-provider-model-catalog.ts +76 -1
  46. package/src/features/chat/features/conversation/utils/session-conversation-input-toolbar.utils.ts +6 -1
  47. package/src/features/chat/features/input/utils/__tests__/chat-inline-token.utils.test.ts +29 -0
  48. package/src/features/chat/features/input/utils/__tests__/chat-input-bar.utils.test.ts +12 -3
  49. package/src/features/chat/features/input/utils/chat-composer-token-protocol.utils.ts +5 -0
  50. package/src/features/chat/features/input/utils/chat-conversation-excerpt-token.utils.ts +59 -0
  51. package/src/features/chat/features/input/utils/chat-inline-token.utils.ts +42 -16
  52. package/src/features/chat/features/input/utils/chat-input-toolbar.utils.ts +50 -14
  53. package/src/features/chat/features/message/components/chat-message-list.container.tsx +40 -0
  54. package/src/features/chat/features/message/utils/chat-message-texts.utils.ts +5 -0
  55. package/src/features/chat/features/workspace/components/__tests__/workspace-text-selection-menu.test.tsx +11 -4
  56. package/src/features/chat/features/workspace/components/workspace-text-selection-menu.tsx +17 -195
  57. package/src/features/chat/managers/__tests__/chat-composer-intent.manager.test.ts +23 -0
  58. package/src/features/chat/managers/chat-composer-intent.manager.ts +54 -9
  59. package/src/features/chat/types/chat-input-bar.types.ts +3 -0
  60. package/src/features/side-dock/components/__tests__/side-dock.test.tsx +1 -0
  61. package/src/features/side-dock/configs/side-dock-built-in-items.config.ts +0 -9
  62. package/src/shared/lib/i18n/locales/en-US/chat.json +7 -1
  63. package/src/shared/lib/i18n/locales/zh-CN/chat.json +7 -1
  64. package/dist/assets/appearance-settings-page-Bl_W_9m7.js +0 -1
  65. package/dist/assets/chat-page-BT6dNI-4.js +0 -1
  66. package/dist/assets/config-split-page-D5BgtKrm.js +0 -1
  67. package/dist/assets/confirm-dialog-Bf907Azu.js +0 -5
  68. package/dist/assets/doc-browser-C23NDGh5.js +0 -1
  69. package/dist/assets/form-actions-DAzi68Cn.js +0 -1
  70. package/dist/assets/index-BLNg2yg0.css +0 -1
  71. package/dist/assets/index-D3EKjEyK.js +0 -107
  72. package/dist/assets/loader-circle-BSg5u0r4.js +0 -1
  73. package/dist/assets/mcp-marketplace-page-CytXwnUs.js +0 -1
  74. package/dist/assets/plus-CNjWdLhZ.js +0 -1
  75. package/dist/assets/provider-scoped-model-input-8F6JMd2n.js +0 -1
  76. package/dist/assets/providers-config-page-CxpTjR81.js +0 -1
  77. package/dist/assets/react-Bk2ydNTe.js +0 -8
  78. package/dist/assets/remote-Dv38PuMV.js +0 -1
  79. package/dist/assets/rotate-cw-IkYVk9JX.js +0 -1
  80. package/dist/assets/security-config-1E8OF4kR.js +0 -1
  81. package/dist/assets/skeleton-DW018fJv.js +0 -1
  82. package/dist/assets/use-confirm-dialog-CavtY1vJ.js +0 -1
  83. package/dist/assets/x-PTXYTEwz.js +0 -1
@@ -1,6 +1,9 @@
1
1
  import { useEffect, type RefObject } from 'react';
2
2
  import { type ChatInputBarHandle } from '@nextclaw/agent-chat-ui';
3
- import { CHAT_WORKSPACE_EXCERPT_TOKEN_KIND } from '@nextclaw/shared';
3
+ import {
4
+ CHAT_CONVERSATION_EXCERPT_TOKEN_KIND,
5
+ CHAT_WORKSPACE_EXCERPT_TOKEN_KIND,
6
+ } from '@nextclaw/shared';
4
7
 
5
8
  import type {
6
9
  ChatComposerIntentManager,
@@ -32,6 +35,12 @@ export function useChatComposerReferenceIntent(params: {
32
35
  startLine: intent.startLine,
33
36
  endLine: intent.endLine,
34
37
  }
38
+ : intent.kind === CHAT_CONVERSATION_EXCERPT_TOKEN_KIND
39
+ ? {
40
+ messageId: intent.messageId,
41
+ role: intent.role,
42
+ excerpt: intent.excerpt,
43
+ }
35
44
  : undefined,
36
45
  });
37
46
  intentManager.markConsumed(intent.id);
@@ -4,12 +4,69 @@ import type { RuntimeModelSelectionMode } from '@nextclaw/shared';
4
4
  import type { ConfigView, ProvidersView, ProviderTemplatesView } from '@/shared/lib/api';
5
5
  import { useProviderModelCatalog, useUpdateProvider } from '@/shared/hooks/use-config';
6
6
  import { normalizeStringList } from '@/shared/lib/provider-models';
7
+ import type { DiscoveredChatModelOption } from '@/features/chat/types/chat-input.types';
7
8
  import {
8
9
  buildNcpChatDiscoveredModelOptions,
9
10
  filterNcpChatDiscoveredModelOptionsBySessionType,
10
11
  } from '@/features/chat/features/ncp/utils/ncp-chat-query-derived.utils';
11
12
  import { providerModelCatalogNoticeManager } from '@/features/chat/managers/provider-model-catalog-notice.manager';
12
13
 
14
+ const MODEL_DISCOVERY_PREVIEW_PROVIDER_PREFIX = '__nextclaw-preview__';
15
+ const MODEL_DISCOVERY_PREVIEW_GROUPS = [
16
+ {
17
+ providerId: 'opencode',
18
+ providerLabel: 'OpenCode Zen Free Trial',
19
+ models: [
20
+ 'deepseek-v4-flash-free',
21
+ 'laguna-s-2.1-free',
22
+ 'longcat-2.0-free',
23
+ 'mimo-v2.5-free',
24
+ 'nemotron-3-ultra-free',
25
+ 'north-mini-code-free',
26
+ 'trinity-large-preview-free',
27
+ ],
28
+ },
29
+ {
30
+ providerId: 'openrouter',
31
+ providerLabel: 'OpenRouter',
32
+ models: [
33
+ 'inclusionai/ling-3.0-tiny:free',
34
+ 'qwen/qwen3-coder:free',
35
+ 'deepseek/deepseek-r1:free',
36
+ 'mistralai/mistral-small:free',
37
+ 'google/gemma-3-27b-it:free',
38
+ ],
39
+ },
40
+ {
41
+ providerId: 'kimi',
42
+ providerLabel: 'Kimi',
43
+ models: ['kimi-k2.5-preview', 'kimi-dev-72b', 'moonshot-v1-128k-preview'],
44
+ },
45
+ ] as const;
46
+
47
+ export function buildProviderModelDiscoveryPreviewOptions(params: {
48
+ enabled: boolean;
49
+ search: string;
50
+ }): DiscoveredChatModelOption[] {
51
+ if (!params.enabled || new URLSearchParams(params.search).get('preview') !== 'model-discovery') {
52
+ return [];
53
+ }
54
+ return MODEL_DISCOVERY_PREVIEW_GROUPS.flatMap((group) =>
55
+ group.models.map((model) => ({
56
+ value: `${MODEL_DISCOVERY_PREVIEW_PROVIDER_PREFIX}${group.providerId}/${model}`,
57
+ providerId: `${MODEL_DISCOVERY_PREVIEW_PROVIDER_PREFIX}${group.providerId}`,
58
+ providerModel: model,
59
+ modelLabel: model,
60
+ providerLabel: group.providerLabel,
61
+ thinkingCapability: null,
62
+ })),
63
+ );
64
+ }
65
+
66
+ function isProviderModelDiscoveryPreviewOption(option: DiscoveredChatModelOption): boolean {
67
+ return option.providerId.startsWith(MODEL_DISCOVERY_PREVIEW_PROVIDER_PREFIX);
68
+ }
69
+
13
70
  type UseSessionProviderModelCatalogParams = {
14
71
  readonly config: ConfigView | null;
15
72
  readonly providersView: ProvidersView | null;
@@ -29,6 +86,14 @@ export function useSessionProviderModelCatalog(params: UseSessionProviderModelCa
29
86
  const catalogQuery = useProviderModelCatalog();
30
87
  const { mutateAsync: updateProvider } = useUpdateProvider();
31
88
  const [noticeRevision, setNoticeRevision] = useState(0);
89
+ const [previewDismissed, setPreviewDismissed] = useState(false);
90
+ const previewModelOptions = useMemo(
91
+ () => buildProviderModelDiscoveryPreviewOptions({
92
+ enabled: import.meta.env.DEV && !import.meta.env.VITEST,
93
+ search: typeof window === 'undefined' ? '' : window.location.search,
94
+ }),
95
+ [],
96
+ );
32
97
  const catalogModelOptions = useMemo(
33
98
  () => buildNcpChatDiscoveredModelOptions({
34
99
  catalogView: catalogQuery.data ?? null,
@@ -44,16 +109,22 @@ export function useSessionProviderModelCatalog(params: UseSessionProviderModelCa
44
109
  const discoveredModelOptions = useMemo(
45
110
  () => {
46
111
  void noticeRevision;
112
+ if (previewModelOptions.length > 0 && !previewDismissed) {
113
+ return previewModelOptions;
114
+ }
47
115
  return filterNcpChatDiscoveredModelOptionsBySessionType({
48
116
  modelOptions: providerModelCatalogNoticeManager.filterUnseen(catalogModelOptions),
49
117
  modelSelectionMode,
50
118
  supportedModels,
51
119
  });
52
120
  },
53
- [catalogModelOptions, modelSelectionMode, noticeRevision, supportedModels],
121
+ [catalogModelOptions, modelSelectionMode, noticeRevision, previewDismissed, previewModelOptions, supportedModels],
54
122
  );
55
123
  const addDiscoveredModel = useCallback(async (value: string) => {
56
124
  const option = discoveredModelOptions.find((candidate) => candidate.value === value);
125
+ if (option && isProviderModelDiscoveryPreviewOption(option)) {
126
+ return option;
127
+ }
57
128
  const provider = option ? providersView?.providers[option.providerId] : undefined;
58
129
  if (!option || !provider) {
59
130
  return null;
@@ -70,6 +141,10 @@ export function useSessionProviderModelCatalog(params: UseSessionProviderModelCa
70
141
  return option;
71
142
  }, [discoveredModelOptions, providersView, updateProvider]);
72
143
  const dismissDiscoveredModels = useCallback(() => {
144
+ if (discoveredModelOptions.some(isProviderModelDiscoveryPreviewOption)) {
145
+ setPreviewDismissed(true);
146
+ return;
147
+ }
73
148
  if (providerModelCatalogNoticeManager.acknowledge(discoveredModelOptions)) {
74
149
  setNoticeRevision((current) => current + 1);
75
150
  }
@@ -46,7 +46,9 @@ type ToolbarSelectBuildParams = {
46
46
  readonly unfavoriteModelLabel: string;
47
47
  };
48
48
 
49
- export function buildSessionConversationToolbarSelects(params: ToolbarSelectBuildParams) {
49
+ export function buildSessionConversationToolbarSelects(
50
+ params: ToolbarSelectBuildParams,
51
+ ) {
50
52
  const {
51
53
  allModelsLabel,
52
54
  favoriteModelLabel,
@@ -99,7 +101,10 @@ export function buildSessionConversationToolbarSelects(params: ToolbarSelectBuil
99
101
  discoveredModelsViewLabel: t('chatDiscoveredModelsView'),
100
102
  discoveredModelsGroupLabel: t('chatDiscoveredModelsGroup'),
101
103
  discoveredModelAddLabel: t('chatDiscoveredModelAdd'),
104
+ discoveredModelAddedLabel: t('chatDiscoveredModelAddedShort'),
102
105
  discoveredModelsDismissLabel: t('chatDiscoveredModelsDismiss'),
106
+ discoveredModelsDoneLabel: t('chatDiscoveredModelsDone'),
107
+ discoveredModelsCloseLabel: t('chatDiscoveredModelsClose'),
103
108
  recentModelsLabel,
104
109
  allModelsLabel,
105
110
  },
@@ -3,6 +3,7 @@ import {
3
3
  buildInlineTokensFromTextProtocol,
4
4
  buildInlineTokensFromComposer,
5
5
  CHAT_INLINE_TOKENS_METADATA_KEY,
6
+ createInlineTokensMetadata,
6
7
  readInlineTokensFromMetadata,
7
8
  resolveInlineTokensForText,
8
9
  resolveWorkspaceReferencePath,
@@ -278,6 +279,34 @@ describe('chat inline token workspace references', () => {
278
279
  ]);
279
280
  });
280
281
 
282
+ it('round-trips conversation excerpts with their immutable snapshot', () => {
283
+ const tokens = buildInlineTokensFromComposer([
284
+ createChatComposerTokenNode({
285
+ tokenKind: 'conversation_excerpt',
286
+ tokenKey: 'assistant-1#excerpt-demo',
287
+ label: 'AI reply',
288
+ data: {
289
+ messageId: 'assistant-1',
290
+ role: 'assistant',
291
+ excerpt: 'Keep the visible tag concise.',
292
+ },
293
+ }),
294
+ ]);
295
+
296
+ expect(tokens).toEqual([{
297
+ kind: 'conversation_excerpt',
298
+ key: 'assistant-1#excerpt-demo',
299
+ messageId: 'assistant-1',
300
+ role: 'assistant',
301
+ label: 'AI reply',
302
+ excerpt: 'Keep the visible tag concise.',
303
+ rawText: '@message-excerpt:assistant-1%23excerpt-demo',
304
+ }]);
305
+ expect(readInlineTokensFromMetadata({
306
+ ui_inline_tokens: createInlineTokensMetadata(tokens),
307
+ })).toEqual(tokens);
308
+ });
309
+
281
310
  it('resolves workspace token paths inside POSIX and Windows project roots', () => {
282
311
  expect(resolveWorkspaceReferencePath({
283
312
  projectRoot: '/tmp/project/',
@@ -28,7 +28,10 @@ function createModelTexts() {
28
28
  discoveredModelsViewLabel: 'View',
29
29
  discoveredModelsGroupLabel: 'New models',
30
30
  discoveredModelAddLabel: 'Add',
31
- discoveredModelsDismissLabel: "Don't remind me about these",
31
+ discoveredModelAddedLabel: 'Added',
32
+ discoveredModelsDismissLabel: 'Dismiss this batch',
33
+ discoveredModelsDoneLabel: 'Done',
34
+ discoveredModelsCloseLabel: 'Close new models',
32
35
  recentModelsLabel: 'Recent',
33
36
  allModelsLabel: 'All models'
34
37
  };
@@ -64,7 +67,6 @@ describe('buildModelStateHint', () => {
64
67
  });
65
68
  });
66
69
  });
67
-
68
70
  describe('buildChatSlashItems', () => {
69
71
  const texts = {
70
72
  slashSkillSubtitle: 'Skill',
@@ -316,7 +318,12 @@ describe('buildModelToolbarSelect', () => {
316
318
  groupLabel: 'New models',
317
319
  allGroupLabel: 'All models',
318
320
  actionLabel: 'Add',
319
- dismissLabel: "Don't remind me about these",
321
+ addedLabel: 'Added',
322
+ dismissLabel: 'Dismiss this batch',
323
+ doneLabel: 'Done',
324
+ closeLabel: 'Close new models',
325
+ searchPlaceholder: 'Search models',
326
+ searchEmptyLabel: 'No matching models',
320
327
  groups: [
321
328
  {
322
329
  key: 'opencode',
@@ -340,7 +347,9 @@ describe('buildModelToolbarSelect', () => {
340
347
  });
341
348
  expect(select.onOpen).toBe(onOpen);
342
349
  });
350
+ });
343
351
 
352
+ describe('buildModelToolbarSelect selection', () => {
344
353
  it('falls back to the first available option when the selected model is missing', () => {
345
354
  const onValueChange = vi.fn();
346
355
  const select = buildModelToolbarSelect({
@@ -1,4 +1,5 @@
1
1
  import {
2
+ CHAT_CONVERSATION_EXCERPT_TOKEN_KIND,
2
3
  CHAT_PROJECT_TOKEN_KIND,
3
4
  CHAT_WORKSPACE_DIRECTORY_TOKEN_KIND,
4
5
  CHAT_WORKSPACE_EXCERPT_TOKEN_KIND,
@@ -11,6 +12,7 @@ const CHAT_PROJECT_TOKEN_PREFIX = '@project:';
11
12
  const CHAT_WORKSPACE_FILE_TOKEN_PREFIX = '@file:';
12
13
  const CHAT_WORKSPACE_DIRECTORY_TOKEN_PREFIX = '@folder:';
13
14
  const CHAT_WORKSPACE_EXCERPT_TOKEN_PREFIX = '@excerpt:';
15
+ const CHAT_CONVERSATION_EXCERPT_TOKEN_PREFIX = '@message-excerpt:';
14
16
 
15
17
  export function serializeChatComposerTokenText(params: {
16
18
  label?: string;
@@ -36,5 +38,8 @@ export function serializeChatComposerTokenText(params: {
36
38
  if (tokenKind === CHAT_WORKSPACE_EXCERPT_TOKEN_KIND) {
37
39
  return `${CHAT_WORKSPACE_EXCERPT_TOKEN_PREFIX}${encodeURIComponent(tokenKey)}`;
38
40
  }
41
+ if (tokenKind === CHAT_CONVERSATION_EXCERPT_TOKEN_KIND) {
42
+ return `${CHAT_CONVERSATION_EXCERPT_TOKEN_PREFIX}${encodeURIComponent(tokenKey)}`;
43
+ }
39
44
  return null;
40
45
  }
@@ -0,0 +1,59 @@
1
+ import type { ChatComposerNode } from '@nextclaw/agent-chat-ui';
2
+ import {
3
+ CHAT_CONVERSATION_EXCERPT_TOKEN_KIND,
4
+ type ChatConversationExcerptInlineTokenMetadata,
5
+ } from '@nextclaw/shared';
6
+
7
+ type ComposerTokenNode = Extract<ChatComposerNode, { type: 'token' }>;
8
+
9
+ function readRequiredString(value: unknown): string | null {
10
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
11
+ }
12
+
13
+ function readConversationExcerptFields(source: Record<string, unknown>): {
14
+ messageId: string;
15
+ role: 'assistant' | 'user';
16
+ excerpt: string;
17
+ } | null {
18
+ const messageId = readRequiredString(source.messageId);
19
+ const excerpt = readRequiredString(source.excerpt);
20
+ const { role } = source;
21
+ return messageId && excerpt && (role === 'assistant' || role === 'user')
22
+ ? { messageId, role, excerpt }
23
+ : null;
24
+ }
25
+
26
+ export function buildConversationExcerptInlineToken(
27
+ node: ComposerTokenNode,
28
+ rawText: string,
29
+ ): ChatConversationExcerptInlineTokenMetadata | null {
30
+ const fields = readConversationExcerptFields(node.data ?? {});
31
+ return fields
32
+ ? {
33
+ kind: CHAT_CONVERSATION_EXCERPT_TOKEN_KIND,
34
+ key: node.tokenKey,
35
+ label: node.label,
36
+ rawText,
37
+ ...fields,
38
+ }
39
+ : null;
40
+ }
41
+
42
+ export function readConversationExcerptInlineToken(params: {
43
+ entry: Record<string, unknown>;
44
+ label: string;
45
+ rawText: string;
46
+ }): ChatConversationExcerptInlineTokenMetadata | null {
47
+ const { entry, label, rawText } = params;
48
+ const key = readRequiredString(entry.key);
49
+ const fields = readConversationExcerptFields(entry);
50
+ return key && fields
51
+ ? {
52
+ kind: CHAT_CONVERSATION_EXCERPT_TOKEN_KIND,
53
+ key,
54
+ label,
55
+ rawText,
56
+ ...fields,
57
+ }
58
+ : null;
59
+ }
@@ -1,5 +1,6 @@
1
1
  import type { ChatComposerNode } from '@nextclaw/agent-chat-ui';
2
2
  import {
3
+ CHAT_CONVERSATION_EXCERPT_TOKEN_KIND,
3
4
  CHAT_INLINE_TOKENS_METADATA_KEY,
4
5
  CHAT_INLINE_TOKENS_SCHEMA_VERSION,
5
6
  CHAT_PROJECT_TOKEN_KIND,
@@ -11,6 +12,7 @@ import {
11
12
  type ChatSkillSource,
12
13
  } from '@nextclaw/shared';
13
14
  import { serializeChatComposerTokenText } from './chat-composer-token-protocol.utils';
15
+ import { buildConversationExcerptInlineToken, readConversationExcerptInlineToken } from './chat-conversation-excerpt-token.utils';
14
16
 
15
17
  export { CHAT_INLINE_TOKENS_METADATA_KEY };
16
18
  const CHAT_PANEL_APP_TOKEN_PREFIX = '@panel-app:';
@@ -36,6 +38,15 @@ export type ChatInlineTokenSource =
36
38
  label: string;
37
39
  rawText: string;
38
40
  }
41
+ | {
42
+ kind: typeof CHAT_CONVERSATION_EXCERPT_TOKEN_KIND;
43
+ key: string;
44
+ messageId: string;
45
+ role: 'assistant' | 'user';
46
+ label: string;
47
+ excerpt: string;
48
+ rawText: string;
49
+ }
39
50
  | {
40
51
  kind: typeof CHAT_WORKSPACE_EXCERPT_TOKEN_KIND;
41
52
  key: string;
@@ -103,6 +114,26 @@ function dedupeInlineTokens<T extends ChatInlineTokenSource>(tokens: readonly T[
103
114
  return output;
104
115
  }
105
116
 
117
+ function appendWorkspaceExcerptToken(
118
+ tokens: ChatInlineTokenMetadata[],
119
+ node: Extract<ChatComposerNode, { type: 'token' }>,
120
+ rawText: string,
121
+ ): void {
122
+ const path = readOptionalString(node.data?.path);
123
+ const excerpt = readOptionalString(node.data?.excerpt);
124
+ if (!path || !excerpt) return;
125
+ tokens.push({
126
+ kind: CHAT_WORKSPACE_EXCERPT_TOKEN_KIND,
127
+ key: node.tokenKey,
128
+ path,
129
+ label: node.label,
130
+ excerpt,
131
+ startLine: readOptionalLine(node.data?.startLine),
132
+ endLine: readOptionalLine(node.data?.endLine),
133
+ rawText,
134
+ });
135
+ }
136
+
106
137
  export function buildInlineTokensFromComposer(
107
138
  nodes: readonly ChatComposerNode[],
108
139
  skillRecords: readonly ChatSkillReferenceSnapshot[] = [],
@@ -118,7 +149,8 @@ export function buildInlineTokensFromComposer(
118
149
  node.tokenKind !== CHAT_PROJECT_TOKEN_KIND &&
119
150
  node.tokenKind !== CHAT_WORKSPACE_FILE_TOKEN_KIND &&
120
151
  node.tokenKind !== CHAT_WORKSPACE_DIRECTORY_TOKEN_KIND &&
121
- node.tokenKind !== CHAT_WORKSPACE_EXCERPT_TOKEN_KIND
152
+ node.tokenKind !== CHAT_WORKSPACE_EXCERPT_TOKEN_KIND &&
153
+ node.tokenKind !== CHAT_CONVERSATION_EXCERPT_TOKEN_KIND
122
154
  ) {
123
155
  continue;
124
156
  }
@@ -152,21 +184,12 @@ export function buildInlineTokensFromComposer(
152
184
  continue;
153
185
  }
154
186
  if (node.tokenKind === CHAT_WORKSPACE_EXCERPT_TOKEN_KIND) {
155
- const path = readOptionalString(node.data?.path);
156
- const excerpt = readOptionalString(node.data?.excerpt);
157
- if (!path || !excerpt) {
158
- continue;
159
- }
160
- tokens.push({
161
- kind: CHAT_WORKSPACE_EXCERPT_TOKEN_KIND,
162
- key: node.tokenKey,
163
- path,
164
- label: node.label,
165
- excerpt,
166
- startLine: readOptionalLine(node.data?.startLine),
167
- endLine: readOptionalLine(node.data?.endLine),
168
- rawText,
169
- });
187
+ appendWorkspaceExcerptToken(tokens, node, rawText);
188
+ continue;
189
+ }
190
+ if (node.tokenKind === CHAT_CONVERSATION_EXCERPT_TOKEN_KIND) {
191
+ const token = buildConversationExcerptInlineToken(node, rawText);
192
+ if (token) tokens.push(token);
170
193
  continue;
171
194
  }
172
195
  const workspaceKind = node.tokenKind === CHAT_WORKSPACE_FILE_TOKEN_KIND
@@ -314,6 +337,9 @@ function readInlineTokenEntry(entry: unknown): ChatInlineTokenSource | null {
314
337
  if (kind === CHAT_WORKSPACE_EXCERPT_TOKEN_KIND) {
315
338
  return readWorkspaceExcerptInlineToken({ entry, label, rawText });
316
339
  }
340
+ if (kind === CHAT_CONVERSATION_EXCERPT_TOKEN_KIND) {
341
+ return readConversationExcerptInlineToken({ entry, label, rawText });
342
+ }
317
343
  const key = readOptionalString(entry.key);
318
344
  return key ? { kind, key, rawText, label } : null;
319
345
  }
@@ -14,7 +14,6 @@ function formatModelOptionLabel(option: ChatModelRecord): string {
14
14
  const providerLabel = option.providerLabel.trim();
15
15
  return providerLabel ? `${providerLabel}/${modelLabel}` : modelLabel;
16
16
  }
17
-
18
17
  function buildDiscoveredModelGroups(options: ChatModelRecord[]) {
19
18
  const groups = new Map<string, { key: string; label: string; options: Array<{ value: string; label: string }> }>();
20
19
  for (const option of options) {
@@ -81,6 +80,47 @@ export function buildModelStateHint(params: {
81
80
  };
82
81
  }
83
82
 
83
+ function buildModelDiscovery(params: {
84
+ options: ChatModelRecord[];
85
+ onDismiss?: () => void;
86
+ onSelect?: (value: string) => Promise<void> | void;
87
+ texts: Pick<
88
+ ChatInputBarAdapterTexts,
89
+ | "allModelsLabel"
90
+ | "discoveredModelAddLabel"
91
+ | "discoveredModelAddedLabel"
92
+ | "discoveredModelsCloseLabel"
93
+ | "discoveredModelsDismissLabel"
94
+ | "discoveredModelsDoneLabel"
95
+ | "discoveredModelsGroupLabel"
96
+ | "discoveredModelsSummaryLabel"
97
+ | "discoveredModelsViewLabel"
98
+ | "modelSearchEmptyLabel"
99
+ | "modelSearchPlaceholder"
100
+ >;
101
+ }): ChatToolbarSelect["discovery"] {
102
+ const { onDismiss, onSelect, options, texts } = params;
103
+ if (!onDismiss || !onSelect || options.length === 0) {
104
+ return undefined;
105
+ }
106
+ return {
107
+ summaryLabel: texts.discoveredModelsSummaryLabel.replace('{count}', String(options.length)),
108
+ viewLabel: texts.discoveredModelsViewLabel,
109
+ groupLabel: texts.discoveredModelsGroupLabel,
110
+ allGroupLabel: texts.allModelsLabel,
111
+ actionLabel: texts.discoveredModelAddLabel,
112
+ addedLabel: texts.discoveredModelAddedLabel,
113
+ dismissLabel: texts.discoveredModelsDismissLabel,
114
+ doneLabel: texts.discoveredModelsDoneLabel,
115
+ closeLabel: texts.discoveredModelsCloseLabel,
116
+ searchPlaceholder: texts.modelSearchPlaceholder,
117
+ searchEmptyLabel: texts.modelSearchEmptyLabel,
118
+ groups: buildDiscoveredModelGroups(options),
119
+ onDismiss,
120
+ onSelect,
121
+ };
122
+ }
123
+
84
124
  export function buildModelToolbarSelect({
85
125
  modelOptions,
86
126
  discoveredModelOptions,
@@ -122,7 +162,10 @@ export function buildModelToolbarSelect({
122
162
  | "discoveredModelsViewLabel"
123
163
  | "discoveredModelsGroupLabel"
124
164
  | "discoveredModelAddLabel"
165
+ | "discoveredModelAddedLabel"
125
166
  | "discoveredModelsDismissLabel"
167
+ | "discoveredModelsDoneLabel"
168
+ | "discoveredModelsCloseLabel"
126
169
  | "recentModelsLabel"
127
170
  | "allModelsLabel"
128
171
  >;
@@ -210,19 +253,12 @@ export function buildModelToolbarSelect({
210
253
  onToggle: onFavoriteToggle,
211
254
  }
212
255
  : undefined,
213
- discovery: onDiscoveredModelSelect && onDiscoveredModelsDismiss && discoveryOptions.length > 0
214
- ? {
215
- summaryLabel: texts.discoveredModelsSummaryLabel.replace('{count}', String(discoveryOptions.length)),
216
- viewLabel: texts.discoveredModelsViewLabel,
217
- groupLabel: texts.discoveredModelsGroupLabel,
218
- allGroupLabel: texts.allModelsLabel,
219
- actionLabel: texts.discoveredModelAddLabel,
220
- dismissLabel: texts.discoveredModelsDismissLabel,
221
- groups: buildDiscoveredModelGroups(discoveryOptions),
222
- onDismiss: onDiscoveredModelsDismiss,
223
- onSelect: onDiscoveredModelSelect,
224
- }
225
- : undefined,
256
+ discovery: buildModelDiscovery({
257
+ options: discoveryOptions,
258
+ onDismiss: onDiscoveredModelsDismiss,
259
+ onSelect: onDiscoveredModelSelect,
260
+ texts,
261
+ }),
226
262
  manageLabel: texts.manageModelsLabel,
227
263
  manageHref: "/providers",
228
264
  onOpen,
@@ -7,6 +7,7 @@ import {
7
7
  } from "react";
8
8
  import type { NcpMessage } from "@nextclaw/ncp";
9
9
  import {
10
+ ChatTextSelectionAction,
10
11
  type ChatInlineDisplayViewModel,
11
12
  type ChatMessageViewModel,
12
13
  type ChatPanelAppCardViewModel,
@@ -176,6 +177,16 @@ const renderChatPanelAppCard = (panelApp: ChatPanelAppCardViewModel) => (
176
177
  <ChatInlinePanelAppCard panelApp={panelApp} />
177
178
  );
178
179
 
180
+ const CONVERSATION_EXCERPT_MAX_CHARACTERS = 8_000;
181
+
182
+ function findSelectableMessageElement(range: Range): HTMLElement | null {
183
+ const commonAncestor = range.commonAncestorContainer;
184
+ const element = commonAncestor instanceof Element
185
+ ? commonAncestor
186
+ : commonAncestor.parentElement;
187
+ return element?.closest<HTMLElement>('[data-chat-message-selectable="true"]') ?? null;
188
+ }
189
+
179
190
  export { ChatContextCompactionDivider } from "@/features/chat/features/message/components/chat-message-timeline-dividers";
180
191
 
181
192
  function isAwaitingAssistantOutputRow(
@@ -299,6 +310,27 @@ export function ChatMessageListContainer({
299
310
  () => buildChatMessageTexts(language),
300
311
  [language],
301
312
  );
313
+ const isConversationSelectionAllowed = useCallback(
314
+ ({ range }: { range: Range }) => Boolean(findSelectableMessageElement(range)),
315
+ [],
316
+ );
317
+ const handleConversationSelectionAdd = useCallback(
318
+ ({ range, text }: { range: Range; text: string }) => {
319
+ const messageElement = findSelectableMessageElement(range);
320
+ const messageId = messageElement?.dataset.chatMessageId?.trim();
321
+ const role = messageElement?.dataset.chatMessageRole;
322
+ const label = messageElement?.dataset.chatMessageRoleLabel?.trim();
323
+ if (!messageId || !label || (role !== "assistant" && role !== "user")) return;
324
+ presenter.chatComposerIntentManager.requestConversationExcerptReference({
325
+ targetSessionKey: sessionKey,
326
+ messageId,
327
+ role,
328
+ label,
329
+ excerpt: text,
330
+ });
331
+ },
332
+ [presenter.chatComposerIntentManager, sessionKey],
333
+ );
302
334
  const timelineItems = useMemo(
303
335
  () => buildChatMessageTimelineItems({ rawMessages, messages }),
304
336
  [messages, rawMessages],
@@ -341,6 +373,13 @@ export function ChatMessageListContainer({
341
373
  [],
342
374
  );
343
375
  return (
376
+ <ChatTextSelectionAction
377
+ actionLabel={messageTexts.addSelectionToChatLabel ?? t("chatWorkspaceAddToChat")}
378
+ isSelectionAllowed={isConversationSelectionAllowed}
379
+ maxCharacters={CONVERSATION_EXCERPT_MAX_CHARACTERS}
380
+ onAddToChat={handleConversationSelectionAdd}
381
+ selectionTooLongLabel={messageTexts.selectionTooLongLabel ?? t("chatWorkspaceExcerptSelectionTooLong")}
382
+ >
344
383
  <div className={cn("relative", className)} ref={containerRef}>
345
384
  {virtualizer.getVirtualItems().map((virtualRow) => {
346
385
  const item = virtualRows[virtualRow.index];
@@ -388,5 +427,6 @@ export function ChatMessageListContainer({
388
427
  );
389
428
  })}
390
429
  </div>
430
+ </ChatTextSelectionAction>
391
431
  );
392
432
  }
@@ -145,6 +145,8 @@ export function buildChatMessageTexts(
145
145
  ): ChatMessageTexts {
146
146
  void language;
147
147
  return {
148
+ addSelectionToChatLabel: t("chatWorkspaceAddToChat"),
149
+ selectionTooLongLabel: t("chatWorkspaceExcerptSelectionTooLong"),
148
150
  copyCodeLabel: t("chatCodeCopy"),
149
151
  copiedCodeLabel: t("chatCodeCopied"),
150
152
  copyMessageLabel: t("chatMessageCopy"),
@@ -154,6 +156,9 @@ export function buildChatMessageTexts(
154
156
  mermaidExpandLabel: t("chatMermaidExpand"),
155
157
  mermaidLoadingLabel: t("chatMermaidLoading"),
156
158
  mermaidRenderErrorLabel: t("chatMermaidRenderError"),
159
+ previewZoomInLabel: t("chatPreviewZoomIn"),
160
+ previewZoomOutLabel: t("chatPreviewZoomOut"),
161
+ previewResetZoomLabel: t("chatPreviewResetZoom"),
157
162
  typingLabel: t("chatTyping"),
158
163
  reasoningCharacterCountTemplates: {
159
164
  inProgress: t("chatReasoningInProgressCharacterCount"),
@@ -17,7 +17,9 @@ describe("WorkspaceTextSelectionMenu", () => {
17
17
  </WorkspaceTextSelectionMenu>,
18
18
  );
19
19
 
20
- const textNode = screen.getByText("selected text").firstChild;
20
+ const selectedText = screen.getByText("selected text");
21
+ fireEvent.pointerDown(selectedText);
22
+ const textNode = selectedText.firstChild;
21
23
  expect(textNode).toBeTruthy();
22
24
  const range = document.createRange();
23
25
  range.selectNodeContents(textNode!);
@@ -34,9 +36,14 @@ describe("WorkspaceTextSelectionMenu", () => {
34
36
  });
35
37
  window.getSelection()?.removeAllRanges();
36
38
  window.getSelection()?.addRange(range);
39
+ document.dispatchEvent(new Event("selectionchange"));
40
+ expect(screen.queryByRole("button", { name: /Add to chat|添加到聊天/ })).toBeNull();
37
41
 
38
- fireEvent.mouseUp(screen.getByText("selected text"));
39
- fireEvent.click(await screen.findByRole("button", { name: /Add to chat|添加到聊天/ }));
42
+ fireEvent.pointerUp(selectedText);
43
+ const menu = await screen.findByRole("button", { name: /Add to chat|添加到聊天/ });
44
+ await waitFor(() => expect(menu.style.visibility).toBe("visible"));
45
+ expect(menu.style.top).toBe("50px");
46
+ fireEvent.click(menu);
40
47
 
41
48
  expect(onAddToChat).toHaveBeenCalledWith({
42
49
  path: "docs/guide.md",
@@ -91,7 +98,7 @@ describe("WorkspaceTextSelectionMenu", () => {
91
98
 
92
99
  const menu = await screen.findByRole("button", { name: /Add to chat|添加到聊天/ });
93
100
  await waitFor(() => expect(menu.style.visibility).toBe("visible"));
94
- expect(menu.style.left).toBe("188px");
101
+ expect(menu.style.left).toBe("110px");
95
102
  expect(menu.style.top).toBe("130px");
96
103
  expect(menu.className).toContain("whitespace-nowrap");
97
104