@nextclaw/ui 0.11.17 → 0.11.19

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 (87) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/dist/assets/ChannelsList-DAx7wv0_.js +8 -0
  3. package/dist/assets/{ChatPage-C47h6sfA.js → ChatPage-l2PYwCeB.js} +9 -7
  4. package/dist/assets/DocBrowser-CIHLqoIm.js +1 -0
  5. package/dist/assets/{DocBrowser-C_C7daBv.js → DocBrowser-DKkE3Y4I.js} +1 -1
  6. package/dist/assets/{DocBrowserContext-CJ-YKtWh.js → DocBrowserContext-BcZRBsCg.js} +1 -1
  7. package/dist/assets/{LogoBadge-DRDmIa7o.js → LogoBadge-BIPDLEwK.js} +1 -1
  8. package/dist/assets/{MarketplacePage-DaSRsFUA.js → MarketplacePage-Dlp5BgCh.js} +1 -1
  9. package/dist/assets/MarketplacePage-TVeyVOuO.js +1 -0
  10. package/dist/assets/{McpMarketplacePage-B7HZn8zG.js → McpMarketplacePage-CwKtAil8.js} +1 -1
  11. package/dist/assets/{ModelConfig-MSi8VF9p.js → ModelConfig-Dg6F3Ldb.js} +1 -1
  12. package/dist/assets/{ProvidersList-_NBpSQWn.js → ProvidersList-f7bQdRxA.js} +1 -1
  13. package/dist/assets/{RemoteAccessPage-DSmdSsCJ.js → RemoteAccessPage-w_dY7P4T.js} +1 -1
  14. package/dist/assets/{RuntimeConfig-msA8NZOj.js → RuntimeConfig-M4OKjmgU.js} +1 -1
  15. package/dist/assets/{SearchConfig-BBtxHIN_.js → SearchConfig-v46R5a2U.js} +1 -1
  16. package/dist/assets/{SecretsConfig-BMAqj52o.js → SecretsConfig-CXvUpbB_.js} +1 -1
  17. package/dist/assets/{SessionsConfig-CEJqgz8F.js → SessionsConfig-7vUHMtOh.js} +1 -1
  18. package/dist/assets/{book-open-1agbn9dT.js → book-open-DzSduAaw.js} +1 -1
  19. package/dist/assets/{chat-session-display-DBBUJOYN.js → chat-session-display-CGfXhJoT.js} +1 -1
  20. package/dist/assets/{chunk-JZWAC4HX-BUooP92l.js → chunk-JZWAC4HX-C1vpvW4r.js} +1 -1
  21. package/dist/assets/{config-jOAXZWun.js → config-Df97LeLR.js} +1 -1
  22. package/dist/assets/{createLucideIcon-B8FV3fzy.js → createLucideIcon-CcR5wVoU.js} +1 -1
  23. package/dist/assets/{dist-D3OJg9V0.js → dist-BMlnBah3.js} +1 -1
  24. package/dist/assets/{dist-Cy668qFZ.js → dist-Dii9v3X9.js} +1 -1
  25. package/dist/assets/{external-link-DI4ZmR3r.js → external-link-CnSDrvJE.js} +1 -1
  26. package/dist/assets/{hash-DoXBhX9w.js → hash-CAnX6PNt.js} +1 -1
  27. package/dist/assets/i18n-CXBpwAwA.js +1 -0
  28. package/dist/assets/{index-bAeWRAyo.js → index-B0DzQqwv.js} +3 -3
  29. package/dist/assets/index-BahpXJg8.css +1 -0
  30. package/dist/assets/{label-Cz0q8fx4.js → label-CtIFj7_6.js} +1 -1
  31. package/dist/assets/loader-circle-qgU4zQDw.js +1 -0
  32. package/dist/assets/{logos-DjrINZ7P.js → logos-3KFNiOej.js} +1 -1
  33. package/dist/assets/{page-layout-Hr-Dvq4o.js → page-layout-BMwpn87D.js} +1 -1
  34. package/dist/assets/plus-C9cYVbL-.js +1 -0
  35. package/dist/assets/{popover-_nEUAtWY.js → popover-BIzq25oH.js} +1 -1
  36. package/dist/assets/{react-Bsr_GLhi.js → react-ji6GGP_j.js} +1 -1
  37. package/dist/assets/{save-Caodcm4q.js → save-CMgYkJ-y.js} +1 -1
  38. package/dist/assets/search-sl1OeJFl.js +1 -0
  39. package/dist/assets/{security-config-Zf1RBeS1.js → security-config-Xi5DYW7j.js} +1 -1
  40. package/dist/assets/{select-D60QRHg9.js → select-Cz82gl01.js} +1 -1
  41. package/dist/assets/skeleton-rgIt7a5q.js +1 -0
  42. package/dist/assets/{status-dot-D43lBF1a.js → status-dot-C7q1HvLH.js} +1 -1
  43. package/dist/assets/{switch-CcBS0F3U.js → switch-DYswvkYj.js} +1 -1
  44. package/dist/assets/{tabs-custom-UTbefkqB.js → tabs-custom-DKYQxrx1.js} +1 -1
  45. package/dist/assets/{trash-2-DvPrU1xO.js → trash-2-DfXI7-ap.js} +1 -1
  46. package/dist/assets/{useConfirmDialog-B89bxcd6.js → useConfirmDialog-CXDAxtRL.js} +1 -1
  47. package/dist/assets/{useMutation-BpXHE2OV.js → useMutation-s2sn2yzh.js} +1 -1
  48. package/dist/assets/x-MIimOGs6.js +1 -0
  49. package/dist/index.html +18 -18
  50. package/package.json +6 -6
  51. package/src/components/chat/ChatConversationPanel.tsx +1 -0
  52. package/src/components/chat/adapters/chat-input-bar.adapter.test.ts +103 -1
  53. package/src/components/chat/adapters/chat-input-bar.adapter.ts +80 -2
  54. package/src/components/chat/adapters/chat-message-inline-content.adapter.ts +95 -0
  55. package/src/components/chat/adapters/chat-message-part.adapter.ts +384 -0
  56. package/src/components/chat/adapters/chat-message.adapter.test.ts +49 -2
  57. package/src/components/chat/adapters/chat-message.adapter.ts +18 -366
  58. package/src/components/chat/adapters/chat-message.subagent-tool-card.ts +46 -17
  59. package/src/components/chat/chat-composer-state.test.ts +2 -6
  60. package/src/components/chat/chat-composer-state.ts +27 -6
  61. package/src/components/chat/chat-inline-token.utils.test.ts +87 -0
  62. package/src/components/chat/chat-inline-token.utils.ts +146 -0
  63. package/src/components/chat/chat-input/chat-input-bar.controller.test.tsx +24 -0
  64. package/src/components/chat/chat-input/chat-input-bar.controller.ts +81 -44
  65. package/src/components/chat/chat-recent-skills.manager.ts +8 -0
  66. package/src/components/chat/containers/chat-input-bar.container.tsx +31 -4
  67. package/src/components/chat/containers/chat-message-list.container.test.tsx +45 -0
  68. package/src/components/chat/containers/chat-message-list.container.tsx +11 -5
  69. package/src/components/chat/ncp/NcpChatPage.tsx +10 -1
  70. package/src/components/chat/ncp/ncp-chat-input.manager.ts +18 -4
  71. package/src/components/chat/presenter/chat-presenter-context.tsx +1 -0
  72. package/src/components/config/ChannelForm.tsx +71 -39
  73. package/src/components/config/channel-form-fields.test.ts +28 -0
  74. package/src/components/config/channel-form-fields.ts +95 -30
  75. package/src/components/config/weixin-channel-auth-section.test.tsx +26 -0
  76. package/src/components/config/weixin-channel-auth-section.tsx +6 -2
  77. package/src/lib/i18n.channel-auth.ts +5 -0
  78. package/dist/assets/ChannelsList-askIl_uW.js +0 -8
  79. package/dist/assets/DocBrowser-Cf7uSIoM.js +0 -1
  80. package/dist/assets/MarketplacePage-q12sRrvZ.js +0 -1
  81. package/dist/assets/i18n-Cn8SErDV.js +0 -1
  82. package/dist/assets/index-B2VeWxfm.css +0 -1
  83. package/dist/assets/loader-circle-d_mzMi2S.js +0 -1
  84. package/dist/assets/plus-BnGg0mB-.js +0 -1
  85. package/dist/assets/search-CQCQaN4Z.js +0 -1
  86. package/dist/assets/skeleton-BvV_2nf3.js +0 -1
  87. package/dist/assets/x-C8AWDn7c.js +0 -1
@@ -0,0 +1,146 @@
1
+ import type { ChatComposerNode } from "@nextclaw/agent-chat-ui";
2
+
3
+ export const CHAT_UI_INLINE_TOKENS_METADATA_KEY = "ui_inline_tokens";
4
+ const CHAT_SKILL_TOKEN_PREFIX = "$";
5
+
6
+ export type ChatInlineTokenSource = {
7
+ kind: string;
8
+ key: string;
9
+ label: string;
10
+ rawText: string;
11
+ };
12
+
13
+ export type ChatInlineTextFragment =
14
+ | {
15
+ type: "text";
16
+ text: string;
17
+ }
18
+ | {
19
+ type: "token";
20
+ token: ChatInlineTokenSource;
21
+ };
22
+
23
+ function isRecord(value: unknown): value is Record<string, unknown> {
24
+ return typeof value === "object" && value !== null && !Array.isArray(value);
25
+ }
26
+
27
+ function readOptionalString(value: unknown): string | null {
28
+ if (typeof value !== "string") {
29
+ return null;
30
+ }
31
+ const trimmed = value.trim();
32
+ return trimmed.length > 0 ? trimmed : null;
33
+ }
34
+
35
+ function dedupeInlineTokens(
36
+ tokens: readonly ChatInlineTokenSource[],
37
+ ): ChatInlineTokenSource[] {
38
+ const seen = new Set<string>();
39
+ const output: ChatInlineTokenSource[] = [];
40
+ for (const token of tokens) {
41
+ const dedupeKey = `${token.kind}:${token.key}:${token.rawText}`;
42
+ if (seen.has(dedupeKey)) {
43
+ continue;
44
+ }
45
+ seen.add(dedupeKey);
46
+ output.push(token);
47
+ }
48
+ return output;
49
+ }
50
+
51
+ export function buildInlineSkillTokensFromComposer(
52
+ nodes: readonly ChatComposerNode[],
53
+ ): ChatInlineTokenSource[] {
54
+ const tokens: ChatInlineTokenSource[] = [];
55
+ for (const node of nodes) {
56
+ if (node.type !== "token" || node.tokenKind !== "skill") {
57
+ continue;
58
+ }
59
+ tokens.push({
60
+ kind: "skill",
61
+ key: node.tokenKey,
62
+ label: node.label,
63
+ rawText: `${CHAT_SKILL_TOKEN_PREFIX}${node.tokenKey}`,
64
+ });
65
+ }
66
+ return dedupeInlineTokens(tokens);
67
+ }
68
+
69
+ export function readInlineTokensFromMetadata(
70
+ metadata: Record<string, unknown> | undefined,
71
+ ): ChatInlineTokenSource[] {
72
+ const raw = metadata?.[CHAT_UI_INLINE_TOKENS_METADATA_KEY];
73
+ if (!Array.isArray(raw)) {
74
+ return [];
75
+ }
76
+
77
+ const tokens: ChatInlineTokenSource[] = [];
78
+ for (const entry of raw) {
79
+ if (!isRecord(entry)) {
80
+ continue;
81
+ }
82
+ const kind = readOptionalString(entry.kind);
83
+ const key = readOptionalString(entry.key);
84
+ const rawText = readOptionalString(entry.rawText);
85
+ if (!kind || !key || !rawText) {
86
+ continue;
87
+ }
88
+ tokens.push({
89
+ kind,
90
+ key,
91
+ rawText,
92
+ label: readOptionalString(entry.label) ?? key,
93
+ });
94
+ }
95
+
96
+ return dedupeInlineTokens(tokens);
97
+ }
98
+
99
+ export function splitTextByInlineTokens(
100
+ text: string,
101
+ tokens: readonly ChatInlineTokenSource[],
102
+ ): ChatInlineTextFragment[] {
103
+ if (text.length === 0 || tokens.length === 0) {
104
+ return text.length === 0 ? [] : [{ type: "text", text }];
105
+ }
106
+
107
+ const orderedTokens = [...tokens].sort(
108
+ (left, right) => right.rawText.length - left.rawText.length,
109
+ );
110
+ const fragments: ChatInlineTextFragment[] = [];
111
+ let cursor = 0;
112
+
113
+ while (cursor < text.length) {
114
+ let matchedToken: ChatInlineTokenSource | null = null;
115
+ for (const token of orderedTokens) {
116
+ if (text.startsWith(token.rawText, cursor)) {
117
+ matchedToken = token;
118
+ break;
119
+ }
120
+ }
121
+
122
+ if (!matchedToken) {
123
+ let nextCursor = cursor + 1;
124
+ while (nextCursor < text.length) {
125
+ if (orderedTokens.some((token) => text.startsWith(token.rawText, nextCursor))) {
126
+ break;
127
+ }
128
+ nextCursor += 1;
129
+ }
130
+ fragments.push({
131
+ type: "text",
132
+ text: text.slice(cursor, nextCursor),
133
+ });
134
+ cursor = nextCursor;
135
+ continue;
136
+ }
137
+
138
+ fragments.push({
139
+ type: "token",
140
+ token: matchedToken,
141
+ });
142
+ cursor += matchedToken.rawText.length;
143
+ }
144
+
145
+ return fragments;
146
+ }
@@ -125,4 +125,28 @@ describe('useChatInputBarController', () => {
125
125
  });
126
126
  expect(onSend).toHaveBeenCalledTimes(1);
127
127
  });
128
+
129
+ it('does not send on enter while a response is still running', () => {
130
+ const onSend = vi.fn();
131
+ const event = createKeyEvent('Enter');
132
+ const { result } = renderHook(() =>
133
+ useChatInputBarController({
134
+ isSlashMode: false,
135
+ slashItems: [],
136
+ isSlashLoading: false,
137
+ onSelectSlashItem: vi.fn(),
138
+ onSend,
139
+ onStop: vi.fn(),
140
+ isSending: true,
141
+ canStopGeneration: true
142
+ })
143
+ );
144
+
145
+ act(() => {
146
+ result.current.onTextareaKeyDown(event);
147
+ });
148
+
149
+ expect(onSend).not.toHaveBeenCalled();
150
+ expect(event.preventDefault).toHaveBeenCalledTimes(1);
151
+ });
128
152
  });
@@ -13,15 +13,32 @@ type UseChatInputBarControllerParams = {
13
13
  canStopGeneration: boolean;
14
14
  };
15
15
 
16
+ function isSubmitKey(event: Pick<KeyboardEvent<HTMLTextAreaElement>, 'key' | 'shiftKey'>): boolean {
17
+ return event.key === 'Enter' && !event.shiftKey;
18
+ }
19
+
20
+ function isSlashDismissKey(event: Pick<KeyboardEvent<HTMLTextAreaElement>, 'key' | 'code' | 'nativeEvent'>): boolean {
21
+ return !event.nativeEvent.isComposing && (event.key === ' ' || event.code === 'Space');
22
+ }
23
+
16
24
  export function useChatInputBarController(params: UseChatInputBarControllerParams) {
25
+ const {
26
+ isSlashMode,
27
+ slashItems,
28
+ onSelectSlashItem,
29
+ onSend,
30
+ onStop,
31
+ isSending,
32
+ canStopGeneration
33
+ } = params;
17
34
  const [activeSlashIndex, setActiveSlashIndex] = useState(0);
18
35
  const [dismissedSlashPanel, setDismissedSlashPanel] = useState(false);
19
36
 
20
- const isSlashPanelOpen = params.isSlashMode && !dismissedSlashPanel;
21
- const activeSlashItem = params.slashItems[activeSlashIndex] ?? null;
37
+ const isSlashPanelOpen = isSlashMode && !dismissedSlashPanel;
38
+ const activeSlashItem = slashItems[activeSlashIndex] ?? null;
22
39
 
23
40
  useEffect(() => {
24
- if (!isSlashPanelOpen || params.slashItems.length === 0) {
41
+ if (!isSlashPanelOpen || slashItems.length === 0) {
25
42
  setActiveSlashIndex(0);
26
43
  return;
27
44
  }
@@ -29,65 +46,85 @@ export function useChatInputBarController(params: UseChatInputBarControllerParam
29
46
  if (current < 0) {
30
47
  return 0;
31
48
  }
32
- if (current >= params.slashItems.length) {
33
- return params.slashItems.length - 1;
49
+ if (current >= slashItems.length) {
50
+ return slashItems.length - 1;
34
51
  }
35
52
  return current;
36
53
  });
37
- }, [isSlashPanelOpen, params.slashItems.length]);
54
+ }, [isSlashPanelOpen, slashItems.length]);
38
55
 
39
56
  useEffect(() => {
40
- if (!params.isSlashMode && dismissedSlashPanel) {
57
+ if (!isSlashMode && dismissedSlashPanel) {
41
58
  setDismissedSlashPanel(false);
42
59
  }
43
- }, [dismissedSlashPanel, params.isSlashMode]);
60
+ }, [dismissedSlashPanel, isSlashMode]);
44
61
 
45
62
  const handleSelectSlashItem = useCallback((item: ChatSlashItem) => {
46
- params.onSelectSlashItem(item);
63
+ onSelectSlashItem(item);
47
64
  setDismissedSlashPanel(false);
48
- }, [params]);
65
+ }, [onSelectSlashItem]);
66
+
67
+ const handleSlashKeyDown = useCallback((event: KeyboardEvent<HTMLTextAreaElement>): boolean => {
68
+ if (!isSlashPanelOpen || slashItems.length === 0) {
69
+ return false;
70
+ }
71
+ if (event.key === 'ArrowDown') {
72
+ event.preventDefault();
73
+ setActiveSlashIndex((current) => (current + 1) % slashItems.length);
74
+ return true;
75
+ }
76
+ if (event.key === 'ArrowUp') {
77
+ event.preventDefault();
78
+ setActiveSlashIndex((current) => (current - 1 + slashItems.length) % slashItems.length);
79
+ return true;
80
+ }
81
+ if (!isSubmitKey(event) && event.key !== 'Tab') {
82
+ return false;
83
+ }
84
+ event.preventDefault();
85
+ const selected = slashItems[activeSlashIndex];
86
+ if (selected) {
87
+ handleSelectSlashItem(selected);
88
+ }
89
+ return true;
90
+ }, [activeSlashIndex, handleSelectSlashItem, isSlashPanelOpen, slashItems]);
91
+
92
+ const handleEscapeKeyDown = useCallback((event: KeyboardEvent<HTMLTextAreaElement>): boolean => {
93
+ if (event.key !== 'Escape') {
94
+ return false;
95
+ }
96
+ if (isSlashPanelOpen) {
97
+ event.preventDefault();
98
+ setDismissedSlashPanel(true);
99
+ return true;
100
+ }
101
+ if (!isSending || !canStopGeneration) {
102
+ return false;
103
+ }
104
+ event.preventDefault();
105
+ void onStop();
106
+ return true;
107
+ }, [canStopGeneration, isSlashPanelOpen, isSending, onStop]);
49
108
 
50
109
  const onTextareaKeyDown = useCallback((event: KeyboardEvent<HTMLTextAreaElement>) => {
51
- if (isSlashPanelOpen && !event.nativeEvent.isComposing && (event.key === ' ' || event.code === 'Space')) {
110
+ if (isSubmitKey(event) && isSending) {
111
+ event.preventDefault();
112
+ return;
113
+ }
114
+ if (isSlashPanelOpen && isSlashDismissKey(event)) {
52
115
  setDismissedSlashPanel(true);
53
116
  }
54
- if (isSlashPanelOpen && params.slashItems.length > 0) {
55
- if (event.key === 'ArrowDown') {
56
- event.preventDefault();
57
- setActiveSlashIndex((current) => (current + 1) % params.slashItems.length);
58
- return;
59
- }
60
- if (event.key === 'ArrowUp') {
61
- event.preventDefault();
62
- setActiveSlashIndex((current) => (current - 1 + params.slashItems.length) % params.slashItems.length);
63
- return;
64
- }
65
- if ((event.key === 'Enter' && !event.shiftKey) || event.key === 'Tab') {
66
- event.preventDefault();
67
- const selected = params.slashItems[activeSlashIndex];
68
- if (selected) {
69
- handleSelectSlashItem(selected);
70
- }
71
- return;
72
- }
117
+ if (handleSlashKeyDown(event)) {
118
+ return;
73
119
  }
74
- if (event.key === 'Escape') {
75
- if (isSlashPanelOpen) {
76
- event.preventDefault();
77
- setDismissedSlashPanel(true);
78
- return;
79
- }
80
- if (params.isSending && params.canStopGeneration) {
81
- event.preventDefault();
82
- void params.onStop();
83
- return;
84
- }
120
+ if (handleEscapeKeyDown(event)) {
121
+ return;
85
122
  }
86
- if (event.key === 'Enter' && !event.shiftKey) {
123
+ if (isSubmitKey(event)) {
87
124
  event.preventDefault();
88
- void params.onSend();
125
+ void onSend();
89
126
  }
90
- }, [activeSlashIndex, handleSelectSlashItem, isSlashPanelOpen, params]);
127
+ }, [handleEscapeKeyDown, handleSlashKeyDown, isSending, isSlashPanelOpen, onSend]);
91
128
 
92
129
  return {
93
130
  isSlashPanelOpen,
@@ -0,0 +1,8 @@
1
+ import { RecentSelectionManager } from '@/lib/recent-selection.manager';
2
+
3
+ export const chatRecentSkillsManager = new RecentSelectionManager({
4
+ storageKey: 'nextclaw.chat.recent-skills',
5
+ limit: 5
6
+ });
7
+
8
+ export const CHAT_RECENT_SKILLS_MIN_OPTIONS = 4;
@@ -20,6 +20,10 @@ import {
20
20
  CHAT_RECENT_MODELS_MIN_OPTIONS,
21
21
  chatRecentModelsManager
22
22
  } from '@/components/chat/chat-recent-models.manager';
23
+ import {
24
+ CHAT_RECENT_SKILLS_MIN_OPTIONS,
25
+ chatRecentSkillsManager
26
+ } from '@/components/chat/chat-recent-skills.manager';
23
27
  import { useI18n } from '@/components/providers/I18nProvider';
24
28
  import { useChatInputStore } from '@/components/chat/stores/chat-input.store';
25
29
  import { t } from '@/lib/i18n';
@@ -112,6 +116,14 @@ export function ChatInputBarContainer() {
112
116
  availableValues: modelRecords.map((option) => option.value),
113
117
  minAvailableCount: CHAT_RECENT_MODELS_MIN_OPTIONS
114
118
  });
119
+ const recentSkillValues = chatRecentSkillsManager.resolveVisible({
120
+ availableValues: skillRecords.map((record) => record.key),
121
+ minAvailableCount: 0
122
+ });
123
+ const recentSkillGroupValues = chatRecentSkillsManager.resolveVisible({
124
+ availableValues: skillRecords.map((record) => record.key),
125
+ minAvailableCount: CHAT_RECENT_SKILLS_MIN_OPTIONS
126
+ });
115
127
 
116
128
  const hasModelOptions = modelRecords.length > 0;
117
129
  const isModelOptionsLoading = !snapshot.isProviderStateResolved && !hasModelOptions;
@@ -126,10 +138,12 @@ export function ChatInputBarContainer() {
126
138
  : t('chatModelNoOptions');
127
139
  const recentModelsLabel = language === 'zh' ? '最近选择' : 'Recent';
128
140
  const allModelsLabel = language === 'zh' ? '全部模型' : 'All models';
141
+ const recentSkillsLabel = language === 'zh' ? '最近使用' : 'Recent';
142
+ const allSkillsLabel = language === 'zh' ? '全部技能' : 'All skills';
129
143
 
130
144
  const slashItems = useMemo(
131
- () => buildChatSlashItems(skillRecords, slashQuery ?? '', slashTexts),
132
- [slashQuery, skillRecords, slashTexts]
145
+ () => buildChatSlashItems(skillRecords, slashQuery ?? '', slashTexts, recentSkillValues),
146
+ [slashQuery, skillRecords, slashTexts, recentSkillValues]
133
147
  );
134
148
 
135
149
  const selectedModelOption = modelRecords.find((option) => option.value === snapshot.selectedModel);
@@ -206,6 +220,8 @@ export function ChatInputBarContainer() {
206
220
 
207
221
  const skillPicker = buildSkillPickerModel({
208
222
  skillRecords,
223
+ recentSkillValues,
224
+ groupedRecentSkillValues: recentSkillGroupValues,
209
225
  selectedSkills: snapshot.selectedSkills,
210
226
  isLoading: snapshot.isSkillsLoading,
211
227
  onSelectedKeysChange: presenter.chatInputManager.selectSkills,
@@ -214,7 +230,9 @@ export function ChatInputBarContainer() {
214
230
  searchPlaceholder: t('chatSkillsPickerSearchPlaceholder'),
215
231
  emptyLabel: t('chatSkillsPickerEmpty'),
216
232
  loadingLabel: t('sessionsLoading'),
217
- manageLabel: t('chatSkillsPickerManage')
233
+ manageLabel: t('chatSkillsPickerManage'),
234
+ recentSkillsLabel,
235
+ allSkillsLabel
218
236
  }
219
237
  });
220
238
 
@@ -233,6 +251,11 @@ export function ChatInputBarContainer() {
233
251
  slashMenu={{
234
252
  isLoading: snapshot.isSkillsLoading,
235
253
  items: slashItems,
254
+ onSelectItem: (item) => {
255
+ if (item.value) {
256
+ presenter.chatInputManager.rememberSkillSelection(item.value);
257
+ }
258
+ },
236
259
  texts: {
237
260
  slashLoadingLabel: t('chatSlashLoading'),
238
261
  slashSectionLabel: t('chatSlashSectionSkills'),
@@ -274,7 +297,11 @@ export function ChatInputBarContainer() {
274
297
  isSending: snapshot.isSending,
275
298
  canStopGeneration: snapshot.canStopGeneration,
276
299
  sendDisabled:
277
- (snapshot.draft.trim().length === 0 && snapshot.attachments.length === 0) ||
300
+ (
301
+ snapshot.draft.trim().length === 0 &&
302
+ snapshot.attachments.length === 0 &&
303
+ snapshot.selectedSkills.length === 0
304
+ ) ||
278
305
  !hasModelOptions ||
279
306
  snapshot.sessionTypeUnavailable,
280
307
  stopDisabled: !snapshot.canStopGeneration,
@@ -99,3 +99,48 @@ it("keeps historical adapted message references stable when only the streaming m
99
99
  expect(secondMessages[0]).toBe(firstMessages[0]);
100
100
  expect(secondMessages[1]).not.toBe(firstMessages[1]);
101
101
  });
102
+
103
+ it("adapts persisted inline token metadata into rich message parts", () => {
104
+ const message = {
105
+ id: "user-inline-token",
106
+ sessionId: "session-1",
107
+ role: "user",
108
+ status: "final",
109
+ timestamp: "2026-03-31T10:00:00.000Z",
110
+ metadata: {
111
+ ui_inline_tokens: [
112
+ {
113
+ kind: "skill",
114
+ key: "weather",
115
+ label: "Weather",
116
+ rawText: "$weather",
117
+ },
118
+ ],
119
+ },
120
+ parts: [{ type: "text", text: "please use $weather now" }],
121
+ } satisfies NcpMessage;
122
+
123
+ render(<ChatMessageListContainer messages={[message]} isSending={false} />);
124
+
125
+ const renderedMessages =
126
+ captures.renders[captures.renders.length - 1]?.messages ?? [];
127
+ expect(renderedMessages[0]).toMatchObject({
128
+ parts: [
129
+ {
130
+ type: "inline-content",
131
+ segments: [
132
+ { type: "markdown", text: "please use " },
133
+ {
134
+ type: "token",
135
+ token: {
136
+ kind: "skill",
137
+ key: "weather",
138
+ label: "Weather",
139
+ },
140
+ },
141
+ { type: "markdown", text: " now" },
142
+ ],
143
+ },
144
+ ],
145
+ });
146
+ });
@@ -9,6 +9,7 @@ import {
9
9
  type ChatMessageAdapterTexts,
10
10
  type ChatMessageSource,
11
11
  } from "@/components/chat/adapters/chat-message.adapter";
12
+ import { readInlineTokensFromMetadata } from "@/components/chat/chat-inline-token.utils";
12
13
  import { adaptNcpMessageToUiMessage } from "@/components/chat/ncp/ncp-session-adapter";
13
14
  import { useI18n } from "@/components/providers/I18nProvider";
14
15
  import { formatDateTime, t } from "@/lib/i18n";
@@ -63,7 +64,11 @@ function buildChatMessageTexts(language: string) {
63
64
  };
64
65
  }
65
66
 
66
- export function ChatMessageListContainer(props: ChatMessageListContainerProps) {
67
+ export function ChatMessageListContainer({
68
+ messages: rawMessages,
69
+ isSending,
70
+ className,
71
+ }: ChatMessageListContainerProps) {
67
72
  const { language } = useI18n();
68
73
  const texts = useMemo<ChatMessageAdapterTexts>(
69
74
  () => buildChatMessageAdapterTexts(language),
@@ -71,7 +76,7 @@ export function ChatMessageListContainer(props: ChatMessageListContainerProps) {
71
76
  );
72
77
 
73
78
  const messages = useMemo(() => {
74
- return props.messages.map((message) => {
79
+ return rawMessages.map((message) => {
75
80
  const cached = messageViewModelCache.get(message);
76
81
  if (cached && cached.language === language) {
77
82
  return cached.viewModel;
@@ -84,6 +89,7 @@ export function ChatMessageListContainer(props: ChatMessageListContainerProps) {
84
89
  meta: {
85
90
  timestamp: uiMessage.meta?.timestamp,
86
91
  status: uiMessage.meta?.status,
92
+ inlineTokens: readInlineTokensFromMetadata(message.metadata),
87
93
  },
88
94
  parts: uiMessage.parts as unknown as ChatMessageSource["parts"],
89
95
  };
@@ -95,7 +101,7 @@ export function ChatMessageListContainer(props: ChatMessageListContainerProps) {
95
101
  messageViewModelCache.set(message, { language, viewModel });
96
102
  return viewModel;
97
103
  });
98
- }, [language, props.messages, texts]);
104
+ }, [language, rawMessages, texts]);
99
105
 
100
106
  const hasAssistantDraft = useMemo(
101
107
  () =>
@@ -114,9 +120,9 @@ export function ChatMessageListContainer(props: ChatMessageListContainerProps) {
114
120
  return (
115
121
  <ChatMessageList
116
122
  messages={messages}
117
- isSending={props.isSending}
123
+ isSending={isSending}
118
124
  hasAssistantDraft={hasAssistantDraft}
119
- className={props.className}
125
+ className={className}
120
126
  texts={messageTexts}
121
127
  />
122
128
  );
@@ -10,6 +10,7 @@ import { API_BASE } from '@/api/api-base';
10
10
  import { fetchNcpSessionMessages } from '@/api/ncp-session';
11
11
  import { ChatPageLayout, type ChatPageProps, useChatSessionSync } from '@/components/chat/chat-page-shell';
12
12
  import { sessionDisplayName } from '@/components/chat/chat-session-display';
13
+ import { buildInlineSkillTokensFromComposer, CHAT_UI_INLINE_TOKENS_METADATA_KEY } from '@/components/chat/chat-inline-token.utils';
13
14
  import { createNcpAppClientFetch } from '@/components/chat/ncp/ncp-app-client-fetch';
14
15
  import { parseSessionKeyFromRoute, resolveAgentIdFromSessionKey } from '@/components/chat/chat-session-route';
15
16
  import { useNcpChatPageData } from '@/components/chat/ncp/ncp-chat-page-data';
@@ -29,6 +30,7 @@ function buildNcpSendMetadata(payload: {
29
30
  thinkingLevel?: string;
30
31
  sessionType?: string;
31
32
  requestedSkills?: string[];
33
+ composerNodes?: Parameters<typeof buildInlineSkillTokensFromComposer>[0];
32
34
  }): Record<string, unknown> {
33
35
  const metadata: Record<string, unknown> = {};
34
36
  if (payload.model?.trim()) {
@@ -46,6 +48,12 @@ function buildNcpSendMetadata(payload: {
46
48
  if (requestedSkills.length > 0) {
47
49
  metadata.requested_skills = requestedSkills;
48
50
  }
51
+ const inlineSkillTokens = payload.composerNodes
52
+ ? buildInlineSkillTokensFromComposer(payload.composerNodes)
53
+ : [];
54
+ if (inlineSkillTokens.length > 0) {
55
+ metadata[CHAT_UI_INLINE_TOKENS_METADATA_KEY] = inlineSkillTokens;
56
+ }
49
57
  return metadata;
50
58
  }
51
59
 
@@ -195,7 +203,8 @@ export function NcpChatPage({ view }: ChatPageProps) {
195
203
  model: payload.model,
196
204
  thinkingLevel: payload.thinkingLevel,
197
205
  sessionType: payload.sessionType,
198
- requestedSkills: payload.requestedSkills
206
+ requestedSkills: payload.requestedSkills,
207
+ composerNodes: payload.composerNodes
199
208
  });
200
209
  const envelope = buildNcpRequestEnvelope({
201
210
  sessionId: payload.sessionKey,
@@ -20,6 +20,7 @@ import type { ChatStreamActionsManager } from '@/components/chat/managers/chat-s
20
20
  import type { ChatUiManager } from '@/components/chat/managers/chat-ui.manager';
21
21
  import { ChatSessionPreferenceSync } from '@/components/chat/chat-session-preference-sync';
22
22
  import { chatRecentModelsManager } from '@/components/chat/chat-recent-models.manager';
23
+ import { chatRecentSkillsManager } from '@/components/chat/chat-recent-skills.manager';
23
24
  import type { ChatModelOption } from '@/components/chat/chat-input.types';
24
25
  import { normalizeSessionType } from '@/components/chat/useChatSessionTypeState';
25
26
 
@@ -252,11 +253,24 @@ export class NcpChatInputManager {
252
253
  this.sessionPreferenceSync.syncSelectedSessionPreferences();
253
254
  };
254
255
 
256
+ rememberSkillSelection = (value: string) => {
257
+ chatRecentSkillsManager.remember(value);
258
+ };
259
+
255
260
  selectSkills = (next: string[]) => {
261
+ const prev = useChatInputStore.getState().snapshot.selectedSkills;
262
+ for (const value of next) {
263
+ if (!prev.includes(value)) {
264
+ this.rememberSkillSelection(value);
265
+ }
266
+ }
256
267
  this.setSelectedSkills(next);
257
268
  };
258
269
 
259
- private resolveThinkingForModel(modelOption: ChatModelOption | undefined, current: ThinkingLevel | null): ThinkingLevel | null {
270
+ private resolveThinkingForModel = (
271
+ modelOption: ChatModelOption | undefined,
272
+ current: ThinkingLevel | null
273
+ ): ThinkingLevel | null => {
260
274
  const capability = modelOption?.thinkingCapability;
261
275
  if (!capability || capability.supported.length === 0) {
262
276
  return null;
@@ -271,9 +285,9 @@ export class NcpChatInputManager {
271
285
  return capability.default;
272
286
  }
273
287
  return 'off';
274
- }
288
+ };
275
289
 
276
- private reconcileThinkingForModel(model: string): void {
290
+ private reconcileThinkingForModel = (model: string): void => {
277
291
  const snapshot = useChatInputStore.getState().snapshot;
278
292
  const modelOption = snapshot.modelOptions.find((option) => option.value === model);
279
293
  const { selectedThinkingLevel } = snapshot;
@@ -281,5 +295,5 @@ export class NcpChatInputManager {
281
295
  if (nextThinking !== selectedThinkingLevel) {
282
296
  useChatInputStore.getState().setSnapshot({ selectedThinkingLevel: nextThinking });
283
297
  }
284
- }
298
+ };
285
299
  }
@@ -29,6 +29,7 @@ export type ChatInputManagerLike = {
29
29
  selectModel: (value: string) => void;
30
30
  selectThinkingLevel: (value: ThinkingLevel) => void;
31
31
  selectSkills: (next: string[]) => void;
32
+ rememberSkillSelection: (value: string) => void;
32
33
  };
33
34
 
34
35
  export type ChatThreadManagerLike = {