@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.
- package/CHANGELOG.md +17 -0
- package/dist/assets/{api-Glw2OGXK.js → api-BxPNDp30.js} +3 -3
- package/dist/assets/appearance-settings-page-DpVJjvkB.js +1 -0
- package/dist/assets/{channels-list-page-C7E4Sxwd.js → channels-list-page-DRaNWAxC.js} +2 -2
- package/dist/assets/chat-page-BuFDmG5_.js +1 -0
- package/dist/assets/config-split-page-BVWVDbvG.js +1 -0
- package/dist/assets/confirm-dialog-mA2iVzxh.js +5 -0
- package/dist/assets/{desktop-update-config-BwuyBPJb.js → desktop-update-config-DyQb1LXr.js} +1 -1
- package/dist/assets/{dist-dv_SUR54.js → dist-B4Pf5X3Q.js} +1 -1
- package/dist/assets/{dist-CtLyEJeO.js → dist-DHEWkGWX.js} +1 -1
- package/dist/assets/{doc-browser-DlOOhBxI.js → doc-browser-CtO_HtRy.js} +1 -1
- package/dist/assets/{doc-browser-context-BIBjkwfV.js → doc-browser-context-CuGI6PQc.js} +1 -1
- package/dist/assets/doc-browser-eMUagyBV.js +1 -0
- package/dist/assets/form-actions-BOfIyG3O.js +1 -0
- package/dist/assets/index-BjBdWHV1.js +107 -0
- package/dist/assets/index-DmpmjAGQ.css +1 -0
- package/dist/assets/loader-circle-Df8wF2Pr.js +1 -0
- package/dist/assets/mcp-marketplace-page-DI49IKxp.js +1 -0
- package/dist/assets/{mcp-marketplace-page-ByhvQQj5.js → mcp-marketplace-page-ofDqbOIF.js} +3 -3
- package/dist/assets/{model-config-page-bgEx3MjQ.js → model-config-page-D73Tu3jW.js} +1 -1
- package/dist/assets/{navigation-link-CB0OQbnY.js → navigation-link-CAFOcXNg.js} +2 -2
- package/dist/assets/plus-DVU5RLCS.js +1 -0
- package/dist/assets/provider-scoped-model-input-BaJJJYMX.js +1 -0
- package/dist/assets/providers-config-page-BL6IRVBm.js +1 -0
- package/dist/assets/react-BC_orpAj.js +8 -0
- package/dist/assets/{refresh-cw-Ba0LZou4.js → refresh-cw-CWDp_JnN.js} +1 -1
- package/dist/assets/remote-T24O58lH.js +1 -0
- package/dist/assets/rotate-cw-BqhwSV5p.js +1 -0
- package/dist/assets/{runtime-config-page-C0ByyUVr.js → runtime-config-page-CXUx017t.js} +1 -1
- package/dist/assets/{save-DTOAvqzt.js → save-Cnr71JON.js} +1 -1
- package/dist/assets/{search-D4X5DNMe.js → search-CCX11X2m.js} +1 -1
- package/dist/assets/{search-config-page-Cr6254K5.js → search-config-page-BB6d5gT7.js} +1 -1
- package/dist/assets/{secrets-config-page-W2Hx332d.js → secrets-config-page-DYsDeT9K.js} +2 -2
- package/dist/assets/security-config-CVPgqL7G.js +1 -0
- package/dist/assets/{select-CDv0g2Pj.js → select-DvwBu99K.js} +2 -2
- package/dist/assets/{settings-2-CoxzNABp.js → settings-2-Dmv6zMne.js} +1 -1
- package/dist/assets/skeleton-C5tWata3.js +1 -0
- package/dist/assets/{tag-chip-CjkZEoS2.js → tag-chip-gMPvx7n_.js} +1 -1
- package/dist/assets/use-confirm-dialog-D_3-QpZU.js +1 -0
- package/dist/assets/x-BEWSqeux.js +1 -0
- package/dist/index.html +20 -20
- package/package.json +7 -7
- package/src/features/chat/features/conversation/hooks/__tests__/use-session-provider-model-catalog.test.tsx +54 -2
- package/src/features/chat/features/conversation/hooks/use-chat-composer-reference-intent.ts +10 -1
- package/src/features/chat/features/conversation/hooks/use-session-provider-model-catalog.ts +76 -1
- package/src/features/chat/features/conversation/utils/session-conversation-input-toolbar.utils.ts +6 -1
- package/src/features/chat/features/input/utils/__tests__/chat-inline-token.utils.test.ts +29 -0
- package/src/features/chat/features/input/utils/__tests__/chat-input-bar.utils.test.ts +12 -3
- package/src/features/chat/features/input/utils/chat-composer-token-protocol.utils.ts +5 -0
- package/src/features/chat/features/input/utils/chat-conversation-excerpt-token.utils.ts +59 -0
- package/src/features/chat/features/input/utils/chat-inline-token.utils.ts +42 -16
- package/src/features/chat/features/input/utils/chat-input-toolbar.utils.ts +50 -14
- package/src/features/chat/features/message/components/chat-message-list.container.tsx +40 -0
- package/src/features/chat/features/message/utils/chat-message-texts.utils.ts +5 -0
- package/src/features/chat/features/workspace/components/__tests__/workspace-text-selection-menu.test.tsx +11 -4
- package/src/features/chat/features/workspace/components/workspace-text-selection-menu.tsx +17 -195
- package/src/features/chat/managers/__tests__/chat-composer-intent.manager.test.ts +23 -0
- package/src/features/chat/managers/chat-composer-intent.manager.ts +54 -9
- package/src/features/chat/types/chat-input-bar.types.ts +3 -0
- package/src/features/side-dock/components/__tests__/side-dock.test.tsx +1 -0
- package/src/features/side-dock/configs/side-dock-built-in-items.config.ts +0 -9
- package/src/shared/lib/i18n/locales/en-US/chat.json +7 -1
- package/src/shared/lib/i18n/locales/zh-CN/chat.json +7 -1
- package/dist/assets/appearance-settings-page-Bl_W_9m7.js +0 -1
- package/dist/assets/chat-page-BT6dNI-4.js +0 -1
- package/dist/assets/config-split-page-D5BgtKrm.js +0 -1
- package/dist/assets/confirm-dialog-Bf907Azu.js +0 -5
- package/dist/assets/doc-browser-C23NDGh5.js +0 -1
- package/dist/assets/form-actions-DAzi68Cn.js +0 -1
- package/dist/assets/index-BLNg2yg0.css +0 -1
- package/dist/assets/index-D3EKjEyK.js +0 -107
- package/dist/assets/loader-circle-BSg5u0r4.js +0 -1
- package/dist/assets/mcp-marketplace-page-CytXwnUs.js +0 -1
- package/dist/assets/plus-CNjWdLhZ.js +0 -1
- package/dist/assets/provider-scoped-model-input-8F6JMd2n.js +0 -1
- package/dist/assets/providers-config-page-CxpTjR81.js +0 -1
- package/dist/assets/react-Bk2ydNTe.js +0 -8
- package/dist/assets/remote-Dv38PuMV.js +0 -1
- package/dist/assets/rotate-cw-IkYVk9JX.js +0 -1
- package/dist/assets/security-config-1E8OF4kR.js +0 -1
- package/dist/assets/skeleton-DW018fJv.js +0 -1
- package/dist/assets/use-confirm-dialog-CavtY1vJ.js +0 -1
- 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 {
|
|
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
|
}
|
package/src/features/chat/features/conversation/utils/session-conversation-input-toolbar.utils.ts
CHANGED
|
@@ -46,7 +46,9 @@ type ToolbarSelectBuildParams = {
|
|
|
46
46
|
readonly unfavoriteModelLabel: string;
|
|
47
47
|
};
|
|
48
48
|
|
|
49
|
-
export function buildSessionConversationToolbarSelects(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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:
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
|
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.
|
|
39
|
-
|
|
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("
|
|
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
|
|