@nextclaw/ui 0.15.14 → 0.15.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/dist/assets/{api-DGG69ZcJ.js → api-D5UCLVE_.js} +3 -3
  3. package/dist/assets/app-presenter-provider-DcpcRgLn.js +106 -0
  4. package/dist/assets/{appearance-settings-page-Byr55LGb.js → appearance-settings-page-yJR6Kt9c.js} +1 -1
  5. package/dist/assets/{book-open-BhaS5BJv.js → book-open-WsEnuW9P.js} +1 -1
  6. package/dist/assets/{card-Bcr5YM8F.js → card-xVrS1b1T.js} +1 -1
  7. package/dist/assets/{channels-list-page-C_x83ZCT.js → channels-list-page-CAWuTHSM.js} +1 -1
  8. package/dist/assets/{chat-page-CEavSS6B.js → chat-page-CXYXMIQQ.js} +1 -1
  9. package/dist/assets/{config-split-page-RXAoLeNv.js → config-split-page-xj0UtMow.js} +1 -1
  10. package/dist/assets/{confirm-dialog-o_VPKvVD.js → confirm-dialog-e2YCUzt1.js} +1 -1
  11. package/dist/assets/{desktop-update-config-DEOMOsDN.js → desktop-update-config-D5NIaimo.js} +1 -1
  12. package/dist/assets/{dist-C87P4QWA.js → dist-Ca-jiFrK.js} +1 -1
  13. package/dist/assets/doc-browser-B2tNEKrJ.js +1 -0
  14. package/dist/assets/{doc-browser-DQ75iUkA.js → doc-browser-CZX6I6CU.js} +1 -1
  15. package/dist/assets/{doc-browser-context-tE0--wSa.js → doc-browser-context-CGz0LULR.js} +1 -1
  16. package/dist/assets/{eye-DL9QkrKg.js → eye-B30J471A.js} +1 -1
  17. package/dist/assets/{form-actions-J0tIaUdK.js → form-actions-CnSzu3WD.js} +1 -1
  18. package/dist/assets/{index-BFIZUZP3.js → index-6Esxu-Pp.js} +2 -2
  19. package/dist/assets/index-BzSgA8tR.css +1 -0
  20. package/dist/assets/loader-circle-DjwSRQHW.js +1 -0
  21. package/dist/assets/{logo-badge-BYTbZbOF.js → logo-badge-CISsltAo.js} +1 -1
  22. package/dist/assets/mcp-marketplace-page-BZa7p7Qf.js +1 -0
  23. package/dist/assets/{mcp-marketplace-page-COWlzeP1.js → mcp-marketplace-page-CPcPzMUQ.js} +1 -1
  24. package/dist/assets/{model-config-page-oNqnMXNY.js → model-config-page-D6ex3_NM.js} +1 -1
  25. package/dist/assets/{navigation-link-Dk6VdDkc.js → navigation-link-qPPUiLt0.js} +1 -1
  26. package/dist/assets/plus-C45awBb1.js +1 -0
  27. package/dist/assets/{popover-Txwrum65.js → popover-DHx55dbc.js} +1 -1
  28. package/dist/assets/{provider-scoped-model-input-B7rQM4b6.js → provider-scoped-model-input-1TJxAhx6.js} +1 -1
  29. package/dist/assets/{providers-config-page-CP-OnvLK.js → providers-config-page-BwHtHIPe.js} +1 -1
  30. package/dist/assets/{react-B0LbRnAE.js → react-DerwD2EA.js} +1 -1
  31. package/dist/assets/{refresh-cw-C84mMku5.js → refresh-cw-C7bynrWX.js} +1 -1
  32. package/dist/assets/remote-CPpTKDgX.js +1 -0
  33. package/dist/assets/rotate-cw-Bhx0XkQP.js +1 -0
  34. package/dist/assets/{runtime-config-page-CIGtssWi.js → runtime-config-page-CMLVC5UV.js} +1 -1
  35. package/dist/assets/{save-R_qyrHpa.js → save-CsRsFtHS.js} +1 -1
  36. package/dist/assets/{search-UKxiouNi.js → search-D0HtJIL-.js} +1 -1
  37. package/dist/assets/{search-config-page-DzkpSGFH.js → search-config-page-n1GElgij.js} +1 -1
  38. package/dist/assets/{secrets-config-page-DhoLYm7Z.js → secrets-config-page-DbUc3Ggi.js} +1 -1
  39. package/dist/assets/security-config-n2WHhHXV.js +1 -0
  40. package/dist/assets/{select-CYHT8P5S.js → select-C1zfp9zM.js} +1 -1
  41. package/dist/assets/{settings-2-DGh4bGSi.js → settings-2-Ba3cLSN2.js} +1 -1
  42. package/dist/assets/skeleton-DL5npsVL.js +1 -0
  43. package/dist/assets/{tabs-BCihm1zK.js → tabs-DmKgMKzb.js} +1 -1
  44. package/dist/assets/{tag-chip-De0iz4o2.js → tag-chip-2vwlVUwM.js} +1 -1
  45. package/dist/assets/{trash-2-Bo3DWiju.js → trash-2-CUpWjNHu.js} +1 -1
  46. package/dist/assets/{use-config-DCzwgChy.js → use-config-BvxDWV-M.js} +1 -1
  47. package/dist/assets/{use-confirm-dialog-WZ94CehU.js → use-confirm-dialog-CeUEE4p5.js} +1 -1
  48. package/dist/assets/{use-viewport-layout-CZ-3pMne.js → use-viewport-layout-DaKJ-kap.js} +1 -1
  49. package/dist/assets/x-DGdYT7rH.js +1 -0
  50. package/dist/index.html +28 -28
  51. package/package.json +9 -9
  52. package/src/features/chat/components/conversation/__tests__/chat-conversation-content.test.tsx +13 -1
  53. package/src/features/chat/components/conversation/chat-conversation-content.tsx +2 -2
  54. package/src/features/chat/features/conversation/components/__tests__/session-conversation-area.test.tsx +11 -0
  55. package/src/features/chat/features/conversation/components/__tests__/session-conversation-input.streaming.test.tsx +4 -0
  56. package/src/features/chat/features/conversation/components/session-conversation-area.tsx +3 -0
  57. package/src/features/chat/features/conversation/hooks/__tests__/use-session-conversation-controller.test.tsx +64 -72
  58. package/src/features/chat/features/conversation/hooks/__tests__/use-session-run-queue.test.tsx +85 -0
  59. package/src/features/chat/features/conversation/hooks/use-session-conversation-controller.ts +108 -257
  60. package/src/features/chat/features/conversation/hooks/use-session-run-queue.ts +52 -0
  61. package/src/features/chat/features/conversation/utils/__tests__/session-queued-input.utils.test.ts +63 -0
  62. package/src/features/chat/features/conversation/utils/session-queued-input.utils.ts +134 -0
  63. package/src/features/chat/features/input/utils/__tests__/chat-composer-state.utils.test.ts +5 -5
  64. package/src/features/chat/features/input/utils/__tests__/chat-inline-token.utils.test.ts +113 -32
  65. package/src/features/chat/features/input/utils/chat-composer-token-protocol.utils.ts +3 -2
  66. package/src/features/chat/features/input/utils/chat-inline-token.utils.ts +118 -12
  67. package/src/features/chat/features/message/components/__tests__/chat-message-list-skill-preview.container.test.tsx +170 -0
  68. package/src/features/chat/features/message/components/__tests__/chat-message-list.container.test.tsx +18 -9
  69. package/src/features/chat/features/message/components/chat-message-list.container.tsx +36 -23
  70. package/src/features/chat/features/message/hooks/__tests__/use-chat-message-virtualizer.test.tsx +90 -0
  71. package/src/features/chat/features/message/hooks/use-chat-message-virtualizer.ts +3 -5
  72. package/src/features/chat/features/ncp/hooks/__tests__/use-ncp-agent-runtime-queue.test.tsx +51 -0
  73. package/src/features/chat/features/session/utils/chat-run-metadata.utils.ts +9 -3
  74. package/src/features/chat/pages/__tests__/ncp-chat-page.test.ts +42 -8
  75. package/src/shared/lib/api/remote.types.ts +28 -0
  76. package/src/shared/lib/i18n/locales/en-US/chat.json +1 -0
  77. package/src/shared/lib/i18n/locales/zh-CN/chat.json +1 -0
  78. package/dist/assets/app-presenter-provider-Dp90n1Va.js +0 -106
  79. package/dist/assets/doc-browser-BuGh646I.js +0 -1
  80. package/dist/assets/index-B2YF-7Yw.css +0 -1
  81. package/dist/assets/loader-circle-CAnHHSrY.js +0 -1
  82. package/dist/assets/mcp-marketplace-page-CaUFv37E.js +0 -1
  83. package/dist/assets/plus-DjdTWPjQ.js +0 -1
  84. package/dist/assets/remote-_L4pTEaW.js +0 -1
  85. package/dist/assets/rotate-cw-DQJhDnjg.js +0 -1
  86. package/dist/assets/security-config-yraYoy9s.js +0 -1
  87. package/dist/assets/skeleton-BQuylYFO.js +0 -1
  88. package/dist/assets/x-NZxXyqW1.js +0 -1
@@ -0,0 +1,134 @@
1
+ import {
2
+ createChatComposerTextNode,
3
+ createChatComposerTokenNode,
4
+ normalizeChatComposerNodes,
5
+ type ChatComposerNode,
6
+ } from '@nextclaw/agent-chat-ui';
7
+ import type { UiNcpSessionQueuedInputView } from '@nextclaw/client-sdk';
8
+ import type { NcpDraftAttachment } from '@nextclaw/ncp-react';
9
+
10
+ import {
11
+ readInlineTokensFromMetadata,
12
+ resolveInlineTokensForText,
13
+ type ChatInlineTokenSource,
14
+ } from '@/features/chat/features/input/utils/chat-inline-token.utils';
15
+ import { deriveChatComposerDraft } from '@/features/chat/features/input/utils/chat-composer-state.utils';
16
+ import { normalizeRequestedSkills } from '@/features/chat/features/runtime/utils/chat-runtime.utils';
17
+ import type { SessionConversationComposerState } from '@/features/chat/features/conversation/hooks/use-session-conversation-input-state';
18
+
19
+ export type SessionQueuedInputComposerSnapshot = SessionConversationComposerState & {
20
+ readonly attachments: readonly NcpDraftAttachment[];
21
+ };
22
+
23
+ function buildTextNodes(
24
+ text: string,
25
+ inlineTokens: readonly ChatInlineTokenSource[],
26
+ ): ChatComposerNode[] {
27
+ const nodes: ChatComposerNode[] = [];
28
+ let offset = 0;
29
+ while (offset < text.length) {
30
+ const nextToken = inlineTokens
31
+ .flatMap((token) => {
32
+ const index = text.indexOf(token.rawText, offset);
33
+ return index < 0 ? [] : [{ index, token }];
34
+ })
35
+ .sort((left, right) => left.index - right.index || right.token.rawText.length - left.token.rawText.length)[0];
36
+ if (!nextToken) {
37
+ nodes.push(createChatComposerTextNode(text.slice(offset)));
38
+ return nodes;
39
+ }
40
+ if (nextToken.index > offset) {
41
+ nodes.push(createChatComposerTextNode(text.slice(offset, nextToken.index)));
42
+ }
43
+ nodes.push(createChatComposerTokenNode({
44
+ tokenKind: nextToken.token.kind,
45
+ tokenKey: "ref" in nextToken.token ? nextToken.token.ref : nextToken.token.key,
46
+ label: nextToken.token.label,
47
+ }));
48
+ offset = nextToken.index + nextToken.token.rawText.length;
49
+ }
50
+ return nodes;
51
+ }
52
+
53
+ function readInputMetadata(input: UiNcpSessionQueuedInputView): Record<string, unknown> {
54
+ return {
55
+ ...input.metadata,
56
+ ...input.message.metadata,
57
+ };
58
+ }
59
+
60
+ function readRequestedSkills(input: UiNcpSessionQueuedInputView): string[] {
61
+ const raw = readInputMetadata(input).requested_skill_refs;
62
+ return normalizeRequestedSkills(
63
+ Array.isArray(raw) ? raw.filter((value): value is string => typeof value === 'string') : undefined,
64
+ );
65
+ }
66
+
67
+ export function buildSessionQueuedInputPreview(input: UiNcpSessionQueuedInputView): string {
68
+ const inlineTokens = readInlineTokensFromMetadata(readInputMetadata(input));
69
+ const text = input.message.parts.flatMap((part) =>
70
+ part.type === 'text' || part.type === 'rich-text' || part.type === 'reasoning'
71
+ ? [part.text]
72
+ : [],
73
+ ).join(' ');
74
+ const plainText = inlineTokens.reduce(
75
+ (value, token) => value.split(token.rawText).join(''),
76
+ text,
77
+ ).replace(/\s+/g, ' ').trim();
78
+ if (plainText) {
79
+ return plainText;
80
+ }
81
+ return input.message.parts.flatMap((part) => part.type === 'file' && part.name ? [part.name] : []).join(', ');
82
+ }
83
+
84
+ export function buildSessionQueuedInputComposerSnapshot(
85
+ input: UiNcpSessionQueuedInputView,
86
+ availableSkills: readonly { ref: string; name: string }[],
87
+ ): SessionQueuedInputComposerSnapshot {
88
+ const nodes: ChatComposerNode[] = [];
89
+ const attachments: NcpDraftAttachment[] = [];
90
+ const metadataTokens = readInlineTokensFromMetadata(readInputMetadata(input));
91
+
92
+ input.message.parts.forEach((part, index) => {
93
+ if (part.type === 'text' || part.type === 'rich-text' || part.type === 'reasoning') {
94
+ nodes.push(...buildTextNodes(
95
+ part.text,
96
+ resolveInlineTokensForText(part.text, metadataTokens),
97
+ ));
98
+ return;
99
+ }
100
+ if (part.type !== 'file') {
101
+ return;
102
+ }
103
+ const id = `queued-attachment-${input.id}-${index}`;
104
+ const attachment: NcpDraftAttachment = {
105
+ id,
106
+ name: part.name ?? 'attachment',
107
+ mimeType: part.mimeType ?? 'application/octet-stream',
108
+ sizeBytes: part.sizeBytes ?? 0,
109
+ assetUri: part.assetUri,
110
+ url: part.url,
111
+ contentBase64: part.contentBase64,
112
+ };
113
+ attachments.push(attachment);
114
+ nodes.push(createChatComposerTokenNode({
115
+ tokenKind: 'file',
116
+ tokenKey: id,
117
+ label: attachment.name,
118
+ }));
119
+ });
120
+
121
+ const normalizedNodes = normalizeChatComposerNodes(nodes);
122
+ const selectedSkills = readRequestedSkills(input);
123
+ const skillNameByRef = new Map(availableSkills.map(({ ref, name }) => [ref, name]));
124
+ return {
125
+ text: deriveChatComposerDraft(normalizedNodes),
126
+ nodes: normalizedNodes,
127
+ selectedSkills,
128
+ skillRecords: selectedSkills.map((ref) => ({
129
+ ref,
130
+ name: skillNameByRef.get(ref) ?? ref,
131
+ })),
132
+ attachments,
133
+ };
134
+ }
@@ -12,10 +12,10 @@ describe('deriveNcpMessagePartsFromComposer', () => {
12
12
  label: 'one.png'
13
13
  }),
14
14
  createChatComposerTextNode(' between '),
15
- createChatComposerTokenNode({
16
- tokenKind: 'skill',
17
- tokenKey: 'web-search',
18
- label: 'Web Search'
15
+ createChatComposerTokenNode({
16
+ tokenKind: 'skill',
17
+ tokenKey: 'workspace:/tmp/workspace/skills/web-search',
18
+ label: 'Web Search'
19
19
  }),
20
20
  createChatComposerTextNode('after'),
21
21
  createChatComposerTokenNode({
@@ -56,7 +56,7 @@ describe('deriveNcpMessagePartsFromComposer', () => {
56
56
  },
57
57
  {
58
58
  type: 'text',
59
- text: ' between $web-searchafter'
59
+ text: ' between $Web Searchafter'
60
60
  },
61
61
  {
62
62
  type: 'file',
@@ -11,72 +11,151 @@ import {
11
11
  describe('chat-inline-token utils', () => {
12
12
  it('builds ordered inline skill tokens from composer nodes', () => {
13
13
  expect(
14
- buildInlineTokensFromComposer([
15
- createChatComposerTextNode('before '),
16
- createChatComposerTokenNode({
17
- tokenKind: 'skill',
18
- tokenKey: 'weather',
19
- label: 'Weather'
20
- }),
21
- createChatComposerTokenNode({
22
- tokenKind: 'skill',
23
- tokenKey: 'docs',
24
- label: 'Docs'
25
- })
26
- ])
14
+ buildInlineTokensFromComposer(
15
+ [
16
+ createChatComposerTextNode('before '),
17
+ createChatComposerTokenNode({
18
+ tokenKind: 'skill',
19
+ tokenKey: 'workspace:/skills/weather',
20
+ label: 'weather'
21
+ }),
22
+ createChatComposerTokenNode({
23
+ tokenKind: 'skill',
24
+ tokenKey: 'global:/skills/docs',
25
+ label: 'docs'
26
+ })
27
+ ],
28
+ [
29
+ {
30
+ ref: 'workspace:/skills/weather',
31
+ name: 'weather',
32
+ source: 'workspace',
33
+ path: '/skills/weather/SKILL.md',
34
+ },
35
+ {
36
+ ref: 'global:/skills/docs',
37
+ name: 'docs',
38
+ source: 'global',
39
+ path: '/skills/docs/SKILL.md',
40
+ },
41
+ ],
42
+ )
27
43
  ).toEqual([
28
44
  {
29
45
  kind: 'skill',
30
- key: 'weather',
31
- label: 'Weather',
46
+ ref: 'workspace:/skills/weather',
47
+ name: 'weather',
48
+ source: 'workspace',
49
+ path: '/skills/weather/SKILL.md',
50
+ label: 'weather',
32
51
  rawText: '$weather'
33
52
  },
34
53
  {
35
54
  kind: 'skill',
36
- key: 'docs',
37
- label: 'Docs',
55
+ ref: 'global:/skills/docs',
56
+ name: 'docs',
57
+ source: 'global',
58
+ path: '/skills/docs/SKILL.md',
59
+ label: 'docs',
38
60
  rawText: '$docs'
39
61
  }
40
62
  ]);
41
63
  });
42
64
 
43
- it('reads generic inline token metadata safely', () => {
65
+ it('reads versioned inline skill metadata without parsing its ref', () => {
44
66
  expect(
45
67
  readInlineTokensFromMetadata({
46
- [CHAT_INLINE_TOKENS_METADATA_KEY]: [
47
- {
48
- kind: 'skill',
49
- key: 'weather',
50
- label: 'Weather',
51
- rawText: '$weather'
52
- }
53
- ]
68
+ [CHAT_INLINE_TOKENS_METADATA_KEY]: {
69
+ schemaVersion: 2,
70
+ items: [
71
+ {
72
+ kind: 'skill',
73
+ ref: 'workspace:/skills/weather',
74
+ name: 'weather',
75
+ source: 'workspace',
76
+ path: '/skills/weather/SKILL.md',
77
+ label: 'weather',
78
+ rawText: '$weather'
79
+ }
80
+ ]
81
+ }
54
82
  })
55
83
  ).toEqual([
56
84
  {
57
85
  kind: 'skill',
58
- key: 'weather',
59
- label: 'Weather',
86
+ ref: 'workspace:/skills/weather',
87
+ name: 'weather',
88
+ source: 'workspace',
89
+ path: '/skills/weather/SKILL.md',
90
+ label: 'weather',
60
91
  rawText: '$weather'
61
92
  }
62
93
  ]);
63
94
  });
64
95
 
96
+ it('normalizes persisted v1 skill keys only at the metadata boundary', () => {
97
+ expect(readInlineTokensFromMetadata({
98
+ [CHAT_INLINE_TOKENS_METADATA_KEY]: [
99
+ {
100
+ kind: 'skill',
101
+ key: 'workspace:/skills/weather',
102
+ label: 'weather',
103
+ rawText: '$workspace:/skills/weather',
104
+ },
105
+ ],
106
+ })).toEqual([
107
+ {
108
+ kind: 'skill',
109
+ ref: 'workspace:/skills/weather',
110
+ name: 'weather',
111
+ source: null,
112
+ path: null,
113
+ label: 'weather',
114
+ rawText: '$workspace:/skills/weather',
115
+ },
116
+ ]);
117
+ });
118
+
119
+ it('preserves every skill source as an explicit field', () => {
120
+ const sources = ['builtin', 'global', 'project', 'workspace'] as const;
121
+ const tokens = buildInlineTokensFromComposer(
122
+ sources.map((source) => createChatComposerTokenNode({
123
+ tokenKind: 'skill',
124
+ tokenKey: `${source}:/skills/${source}`,
125
+ label: source,
126
+ })),
127
+ sources.map((source) => ({
128
+ ref: `${source}:/skills/${source}`,
129
+ name: source,
130
+ source,
131
+ path: `/skills/${source}/SKILL.md`,
132
+ })),
133
+ );
134
+
135
+ expect(tokens.map((token) => token.kind === 'skill' ? token.source : null)).toEqual(sources);
136
+ });
137
+
65
138
  it('merges metadata tokens with pure text protocol tokens', () => {
66
139
  expect(
67
140
  resolveInlineTokensForText('please use $weather and @panel-app:task-board', [
68
141
  {
69
142
  kind: 'skill',
70
- key: 'weather',
71
- label: 'Weather',
143
+ ref: 'workspace:/skills/weather',
144
+ name: 'weather',
145
+ source: 'workspace',
146
+ path: '/skills/weather/SKILL.md',
147
+ label: 'weather',
72
148
  rawText: '$weather'
73
149
  }
74
150
  ])
75
151
  ).toEqual([
76
152
  {
77
153
  kind: 'skill',
78
- key: 'weather',
79
- label: 'Weather',
154
+ ref: 'workspace:/skills/weather',
155
+ name: 'weather',
156
+ source: 'workspace',
157
+ path: '/skills/weather/SKILL.md',
158
+ label: 'weather',
80
159
  rawText: '$weather'
81
160
  },
82
161
  {
@@ -87,7 +166,9 @@ describe('chat-inline-token utils', () => {
87
166
  }
88
167
  ]);
89
168
  });
169
+ });
90
170
 
171
+ describe('chat inline token workspace references', () => {
91
172
  it('builds inline panel app tokens from pure text protocol', () => {
92
173
  expect(buildInlineTokensFromTextProtocol('review @panel-app:task-board now')).toEqual([
93
174
  {
@@ -9,12 +9,13 @@ const CHAT_WORKSPACE_FILE_TOKEN_PREFIX = '@file:';
9
9
  const CHAT_WORKSPACE_DIRECTORY_TOKEN_PREFIX = '@folder:';
10
10
 
11
11
  export function serializeChatComposerTokenText(params: {
12
+ label?: string;
12
13
  tokenKey: string;
13
14
  tokenKind: string;
14
15
  }): string | null {
15
- const { tokenKey, tokenKind } = params;
16
+ const { label, tokenKey, tokenKind } = params;
16
17
  if (tokenKind === 'skill') {
17
- return `${CHAT_SKILL_TOKEN_PREFIX}${tokenKey}`;
18
+ return `${CHAT_SKILL_TOKEN_PREFIX}${label?.trim() || tokenKey}`;
18
19
  }
19
20
  if (tokenKind === 'panel_app') {
20
21
  return `${CHAT_PANEL_APP_TOKEN_PREFIX}${tokenKey}`;
@@ -1,9 +1,12 @@
1
1
  import type { ChatComposerNode } from '@nextclaw/agent-chat-ui';
2
2
  import {
3
3
  CHAT_INLINE_TOKENS_METADATA_KEY,
4
+ CHAT_INLINE_TOKENS_SCHEMA_VERSION,
4
5
  CHAT_WORKSPACE_DIRECTORY_TOKEN_KIND,
5
6
  CHAT_WORKSPACE_FILE_TOKEN_KIND,
6
7
  type ChatInlineTokenMetadata,
8
+ type ChatInlineTokensMetadata,
9
+ type ChatSkillSource,
7
10
  } from '@nextclaw/shared';
8
11
  import { serializeChatComposerTokenText } from './chat-composer-token-protocol.utils';
9
12
 
@@ -13,7 +16,29 @@ const CHAT_PANEL_APP_TOKEN_PATTERN = /@panel-app:([A-Za-z0-9_-]+)/g;
13
16
  const CHAT_WORKSPACE_FILE_TOKEN_PATTERN = /@file:([^\s]+)/g;
14
17
  const CHAT_WORKSPACE_DIRECTORY_TOKEN_PATTERN = /@folder:([^\s]+)/g;
15
18
 
16
- export type ChatInlineTokenSource = ChatInlineTokenMetadata;
19
+ export type ChatSkillReferenceSnapshot = {
20
+ ref: string;
21
+ name: string;
22
+ source: ChatSkillSource;
23
+ path: string;
24
+ };
25
+
26
+ export type ChatInlineTokenSource =
27
+ | {
28
+ kind: 'skill';
29
+ ref: string;
30
+ name: string;
31
+ source: ChatSkillSource | null;
32
+ path: string | null;
33
+ label: string;
34
+ rawText: string;
35
+ }
36
+ | {
37
+ kind: string;
38
+ key: string;
39
+ label: string;
40
+ rawText: string;
41
+ };
17
42
 
18
43
  export function resolveWorkspaceReferencePath(params: {
19
44
  projectRoot: string | null | undefined;
@@ -44,11 +69,12 @@ function readOptionalString(value: unknown): string | null {
44
69
  return trimmed.length > 0 ? trimmed : null;
45
70
  }
46
71
 
47
- function dedupeInlineTokens(tokens: readonly ChatInlineTokenSource[]): ChatInlineTokenSource[] {
72
+ function dedupeInlineTokens<T extends ChatInlineTokenSource>(tokens: readonly T[]): T[] {
48
73
  const seen = new Set<string>();
49
- const output: ChatInlineTokenSource[] = [];
74
+ const output: T[] = [];
50
75
  for (const token of tokens) {
51
- const dedupeKey = `${token.kind}:${token.key}:${token.rawText}`;
76
+ const identity = 'ref' in token ? token.ref : token.key;
77
+ const dedupeKey = `${token.kind}:${identity}:${token.rawText}`;
52
78
  if (seen.has(dedupeKey)) {
53
79
  continue;
54
80
  }
@@ -58,8 +84,12 @@ function dedupeInlineTokens(tokens: readonly ChatInlineTokenSource[]): ChatInlin
58
84
  return output;
59
85
  }
60
86
 
61
- export function buildInlineTokensFromComposer(nodes: readonly ChatComposerNode[]): ChatInlineTokenSource[] {
62
- const tokens: ChatInlineTokenSource[] = [];
87
+ export function buildInlineTokensFromComposer(
88
+ nodes: readonly ChatComposerNode[],
89
+ skillRecords: readonly ChatSkillReferenceSnapshot[] = [],
90
+ ): ChatInlineTokenMetadata[] {
91
+ const skillByRef = new Map(skillRecords.map((record) => [record.ref, record]));
92
+ const tokens: ChatInlineTokenMetadata[] = [];
63
93
  for (const node of nodes) {
64
94
  if (node.type !== 'token') {
65
95
  continue;
@@ -75,8 +105,27 @@ export function buildInlineTokensFromComposer(nodes: readonly ChatComposerNode[]
75
105
  if (!rawText) {
76
106
  continue;
77
107
  }
108
+ if (node.tokenKind === 'skill') {
109
+ const skill = skillByRef.get(node.tokenKey);
110
+ if (!skill?.path.trim()) {
111
+ continue;
112
+ }
113
+ tokens.push({
114
+ kind: 'skill',
115
+ ref: skill.ref,
116
+ name: skill.name,
117
+ source: skill.source,
118
+ path: skill.path,
119
+ label: node.label,
120
+ rawText,
121
+ });
122
+ continue;
123
+ }
124
+ const workspaceKind = node.tokenKind === CHAT_WORKSPACE_FILE_TOKEN_KIND
125
+ ? CHAT_WORKSPACE_FILE_TOKEN_KIND
126
+ : CHAT_WORKSPACE_DIRECTORY_TOKEN_KIND;
78
127
  tokens.push({
79
- kind: node.tokenKind,
128
+ kind: workspaceKind,
80
129
  key: node.tokenKey,
81
130
  label: node.label,
82
131
  rawText,
@@ -152,28 +201,85 @@ export function readInlineTokensFromMetadata(
152
201
  metadata: Record<string, unknown> | undefined
153
202
  ): ChatInlineTokenSource[] {
154
203
  const raw = metadata?.[CHAT_INLINE_TOKENS_METADATA_KEY];
155
- if (!Array.isArray(raw)) {
204
+ if (Array.isArray(raw)) {
205
+ return readLegacyInlineTokens(raw);
206
+ }
207
+ if (
208
+ !isRecord(raw) ||
209
+ raw.schemaVersion !== CHAT_INLINE_TOKENS_SCHEMA_VERSION ||
210
+ !Array.isArray(raw.items)
211
+ ) {
156
212
  return [];
157
213
  }
158
214
 
159
215
  const tokens: ChatInlineTokenSource[] = [];
160
- for (const entry of raw) {
216
+ for (const entry of raw.items) {
161
217
  if (!isRecord(entry)) {
162
218
  continue;
163
219
  }
164
220
  const kind = readOptionalString(entry.kind);
165
- const key = readOptionalString(entry.key);
166
221
  const rawText = readOptionalString(entry.rawText);
167
- if (!kind || !key || !rawText) {
222
+ const label = readOptionalString(entry.label);
223
+ if (!kind || !label || !rawText) {
224
+ continue;
225
+ }
226
+ if (kind === 'skill') {
227
+ const ref = readOptionalString(entry.ref);
228
+ const name = readOptionalString(entry.name);
229
+ const path = readOptionalString(entry.path);
230
+ const source = readSkillSource(entry.source);
231
+ if (!ref || !name || !path || !source) {
232
+ continue;
233
+ }
234
+ tokens.push({ kind, ref, name, source, path, label, rawText });
235
+ continue;
236
+ }
237
+ const key = readOptionalString(entry.key);
238
+ if (!key) {
168
239
  continue;
169
240
  }
170
241
  tokens.push({
171
242
  kind,
172
243
  key,
173
244
  rawText,
174
- label: readOptionalString(entry.label) ?? key
245
+ label,
175
246
  });
176
247
  }
177
248
 
178
249
  return dedupeInlineTokens(tokens);
179
250
  }
251
+
252
+ function readSkillSource(value: unknown): ChatSkillSource | null {
253
+ return value === 'builtin' || value === 'global' || value === 'project' || value === 'workspace'
254
+ ? value
255
+ : null;
256
+ }
257
+
258
+ function readLegacyInlineTokens(entries: readonly unknown[]): ChatInlineTokenSource[] {
259
+ const tokens: ChatInlineTokenSource[] = [];
260
+ for (const entry of entries) {
261
+ if (!isRecord(entry)) {
262
+ continue;
263
+ }
264
+ const kind = readOptionalString(entry.kind);
265
+ const key = readOptionalString(entry.key);
266
+ const rawText = readOptionalString(entry.rawText);
267
+ if (!kind || !key || !rawText) {
268
+ continue;
269
+ }
270
+ const label = readOptionalString(entry.label) ?? key;
271
+ tokens.push(kind === 'skill'
272
+ ? { kind, ref: key, name: label, source: null, path: null, label, rawText }
273
+ : { kind, key, label, rawText });
274
+ }
275
+ return dedupeInlineTokens(tokens);
276
+ }
277
+
278
+ export function createInlineTokensMetadata(
279
+ items: ChatInlineTokenMetadata[],
280
+ ): ChatInlineTokensMetadata {
281
+ return {
282
+ schemaVersion: CHAT_INLINE_TOKENS_SCHEMA_VERSION,
283
+ items,
284
+ };
285
+ }