@steerable/agent-shell-web 0.6.15
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/LICENSE +91 -0
- package/README.md +20 -0
- package/package.json +69 -0
- package/public/favicon.ico +0 -0
- package/public/favicon.png +0 -0
- package/src/AppShell.tsx +24 -0
- package/src/assets/logo-generic.png +0 -0
- package/src/brand.test.ts +22 -0
- package/src/brand.ts +48 -0
- package/src/components/AgentSidebar.test.tsx +127 -0
- package/src/components/AgentSidebar.tsx +1010 -0
- package/src/components/ChatHeader.test.tsx +230 -0
- package/src/components/ChatHeader.tsx +436 -0
- package/src/components/DockPanelHeader.tsx +69 -0
- package/src/components/LocalLlmSettingsModal.test.tsx +372 -0
- package/src/components/LocalLlmSettingsModal.tsx +72 -0
- package/src/components/TerminalPanel.tsx +68 -0
- package/src/components/TerminalView.tsx +228 -0
- package/src/components/chat/ApprovalModal.test.tsx +83 -0
- package/src/components/chat/ApprovalModal.tsx +229 -0
- package/src/components/chat/AskUserModalHost.test.tsx +176 -0
- package/src/components/chat/AskUserModalHost.tsx +61 -0
- package/src/components/chat/AskUserQuestionMenu.tsx +621 -0
- package/src/components/chat/AssistantMessage.test.tsx +68 -0
- package/src/components/chat/AssistantMessage.tsx +469 -0
- package/src/components/chat/ChatInput.test.tsx +609 -0
- package/src/components/chat/ChatInput.tsx +2068 -0
- package/src/components/chat/ChatProjectBadge.tsx +362 -0
- package/src/components/chat/EmptyChat.tsx +18 -0
- package/src/components/chat/ExecPolicyPicker.test.tsx +26 -0
- package/src/components/chat/ExecPolicyPicker.tsx +120 -0
- package/src/components/chat/ExecutedActionsCard.tsx +216 -0
- package/src/components/chat/InterruptedTurnCard.tsx +53 -0
- package/src/components/chat/LocalChatPanel.tsx +408 -0
- package/src/components/chat/Markdown.test.tsx +170 -0
- package/src/components/chat/Markdown.tsx +432 -0
- package/src/components/chat/MessageList.tsx +332 -0
- package/src/components/chat/ModelPicker.test.tsx +149 -0
- package/src/components/chat/ModelPicker.tsx +289 -0
- package/src/components/chat/OrchestrationChildrenCard.tsx +40 -0
- package/src/components/chat/PlanTodoList.tsx +121 -0
- package/src/components/chat/ProjectTrustBanner.tsx +91 -0
- package/src/components/chat/SessionTreeModal.test.tsx +161 -0
- package/src/components/chat/SessionTreeModal.tsx +221 -0
- package/src/components/chat/StreamingStatus.tsx +95 -0
- package/src/components/chat/TaskOutcomeCards.test.tsx +97 -0
- package/src/components/chat/TaskOutcomeCards.tsx +128 -0
- package/src/components/chat/TaskPanelModal.test.tsx +198 -0
- package/src/components/chat/TaskPanelModal.tsx +310 -0
- package/src/components/chat/TaskProcessPanel.test.tsx +111 -0
- package/src/components/chat/TaskProcessPanel.tsx +208 -0
- package/src/components/chat/TurnProcessGroup.test.tsx +161 -0
- package/src/components/chat/TurnProcessGroup.tsx +193 -0
- package/src/components/chat/UnifiedDiff.tsx +31 -0
- package/src/components/chat/UserMessage.tsx +155 -0
- package/src/components/chat/elapsed.test.ts +42 -0
- package/src/components/chat/elapsed.ts +45 -0
- package/src/components/chat/executed-actions-model.test.ts +134 -0
- package/src/components/chat/executed-actions-model.ts +143 -0
- package/src/components/chat/orchestration-children-model.test.ts +115 -0
- package/src/components/chat/orchestration-children-model.ts +104 -0
- package/src/components/chat/timestamp.ts +24 -0
- package/src/components/chat/turn-timeline.test.ts +97 -0
- package/src/components/chat/turn-timeline.ts +132 -0
- package/src/components/chat/useChatTasks.test.ts +136 -0
- package/src/components/chat/useChatTasks.ts +134 -0
- package/src/components/chat/useCopy.ts +62 -0
- package/src/components/settings/AgentCapabilityFields.tsx +343 -0
- package/src/components/settings/AgentsSettingsPanel.test.tsx +229 -0
- package/src/components/settings/AgentsSettingsPanel.tsx +412 -0
- package/src/components/settings/DiagnoseSettingsPanel.tsx +119 -0
- package/src/components/settings/InsightsSettingsPanel.test.tsx +132 -0
- package/src/components/settings/InsightsSettingsPanel.tsx +309 -0
- package/src/components/settings/LlmSettingsPanel.tsx +875 -0
- package/src/components/settings/McpSettingsPanel.tsx +466 -0
- package/src/components/settings/ModelCapabilityChips.tsx +46 -0
- package/src/components/settings/ModelIdCombobox.test.tsx +88 -0
- package/src/components/settings/ModelIdCombobox.tsx +185 -0
- package/src/components/settings/SecuritySettingsPanel.test.tsx +122 -0
- package/src/components/settings/SecuritySettingsPanel.tsx +169 -0
- package/src/components/settings/SettingsSaveButton.tsx +35 -0
- package/src/components/settings/SkillsSettingsPanel.test.tsx +62 -0
- package/src/components/settings/SkillsSettingsPanel.tsx +334 -0
- package/src/components/settings/TelemetrySettingsPanel.tsx +173 -0
- package/src/components/settings/UsagePanel.tsx +196 -0
- package/src/components/settings/WebSearchSettingsPanel.test.tsx +90 -0
- package/src/components/settings/WebSearchSettingsPanel.tsx +183 -0
- package/src/components/settings/compat-flags-model.test.ts +71 -0
- package/src/components/settings/compat-flags-model.ts +57 -0
- package/src/components/settings/llm-vendors.test.ts +171 -0
- package/src/components/settings/llm-vendors.ts +275 -0
- package/src/components/settings/model-capabilities.test.ts +88 -0
- package/src/components/settings/model-capabilities.ts +123 -0
- package/src/components/settings/preset-choice-model.test.ts +96 -0
- package/src/components/settings/preset-choice-model.ts +75 -0
- package/src/fixtures/browser-dev-data.json +315 -0
- package/src/hooks/useChatsAndAgents.ts +182 -0
- package/src/layouts/AgentLayout.tsx +574 -0
- package/src/lib/attachments.ts +78 -0
- package/src/lib/browser-dev-electron-mock.ts +640 -0
- package/src/lib/chat-title.ts +33 -0
- package/src/lib/chat-transport.ts +346 -0
- package/src/lib/date-groups.ts +34 -0
- package/src/lib/demo-flag.ts +15 -0
- package/src/lib/electron-bridge.ts +270 -0
- package/src/lib/exec-policy.ts +25 -0
- package/src/lib/http-bridge.ts +293 -0
- package/src/lib/insights.ts +17 -0
- package/src/lib/local-api.ts +714 -0
- package/src/lib/pending-first-message.test.ts +17 -0
- package/src/lib/pending-first-message.ts +36 -0
- package/src/lib/slash-sources.ts +191 -0
- package/src/main.tsx +103 -0
- package/src/packs/registry.ts +64 -0
- package/src/pages/AgentPage.tsx +1226 -0
- package/src/pages/ChatPanelPreviewPage.tsx +264 -0
- package/src/pages/SettingsPage.test.tsx +102 -0
- package/src/pages/SettingsPage.tsx +197 -0
- package/src/styles/index.css +156 -0
- package/src/vite-env.d.ts +1 -0
- package/vite.base.ts +94 -0
|
@@ -0,0 +1,609 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ChatInput 的 streaming 期交互契约(W6-2):
|
|
3
|
+
* - Enter 转向失败不再静默——hook 兜底后本组件按结果给反馈:'queued'
|
|
4
|
+
* 提示「已改为排队」,三种结果都清草稿(消息必然已落地);
|
|
5
|
+
* - 待发队列横幅展示数量、撤回入口与「停止将丢弃」预警;
|
|
6
|
+
* - ⌘/Ctrl+Enter 排队快捷键在 streaming 期间有可见提示。
|
|
7
|
+
* 队列 drain / 兜底决策本身由框架 useChatStream 的测试覆盖,这里只验
|
|
8
|
+
* 呈现层接线。
|
|
9
|
+
*/
|
|
10
|
+
import { useState, type ReactElement } from 'react';
|
|
11
|
+
import { MemoryRouter, useLocation } from 'react-router-dom';
|
|
12
|
+
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
|
|
13
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
14
|
+
import type { SteerOutcome } from '@steerable/agent-ui';
|
|
15
|
+
import { ChatInput, type ChatInputProps } from './ChatInput';
|
|
16
|
+
|
|
17
|
+
async function flushComposerSync() {
|
|
18
|
+
await act(async () => {
|
|
19
|
+
await new Promise((resolve) => setTimeout(resolve, 60));
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
afterEach(() => cleanup());
|
|
24
|
+
|
|
25
|
+
function renderInput(overrides: Partial<ChatInputProps> = {}) {
|
|
26
|
+
const props: ChatInputProps = {
|
|
27
|
+
value: '',
|
|
28
|
+
onChange: vi.fn(),
|
|
29
|
+
onSubmit: vi.fn(),
|
|
30
|
+
...overrides,
|
|
31
|
+
};
|
|
32
|
+
render(<ChatInput {...props} />);
|
|
33
|
+
return props;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function pressEnter(init: { metaKey?: boolean } = {}) {
|
|
37
|
+
fireEvent.keyDown(screen.getByRole('textbox'), { key: 'Enter', ...init });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('ChatInput streaming 期 Enter 转向兜底(W6-2)', () => {
|
|
41
|
+
it('转向被兜底为排队(queued):清空草稿并提示「已改为排队」', async () => {
|
|
42
|
+
const onSteer = vi.fn<(text: string) => Promise<SteerOutcome>>().mockResolvedValue('queued');
|
|
43
|
+
const onChange = vi.fn();
|
|
44
|
+
renderInput({ value: '补充一下', onChange, isStreaming: true, onSteer });
|
|
45
|
+
|
|
46
|
+
pressEnter();
|
|
47
|
+
// 消息已交给 hook(进入待发队列),不是丢进虚空。
|
|
48
|
+
expect(onSteer).toHaveBeenCalledWith('补充一下');
|
|
49
|
+
|
|
50
|
+
await act(async () => {});
|
|
51
|
+
expect(onChange).toHaveBeenCalledWith('');
|
|
52
|
+
const notice = screen.getByRole('status');
|
|
53
|
+
expect(notice.textContent).toBe('当前回合无法追加,已加入排队,本轮结束后自动发出');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('转向被接受(steered):清空草稿且不显示排队提示', async () => {
|
|
57
|
+
const onSteer = vi.fn<(text: string) => Promise<SteerOutcome>>().mockResolvedValue('steered');
|
|
58
|
+
const onChange = vi.fn();
|
|
59
|
+
renderInput({ value: '补一句', onChange, isStreaming: true, onSteer });
|
|
60
|
+
|
|
61
|
+
pressEnter();
|
|
62
|
+
await act(async () => {});
|
|
63
|
+
expect(onChange).toHaveBeenCalledWith('');
|
|
64
|
+
expect(screen.queryByRole('status')).toBeNull();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('回合已结束兜底为直发(sent):清空草稿且不显示排队提示', async () => {
|
|
68
|
+
const onSteer = vi.fn<(text: string) => Promise<SteerOutcome>>().mockResolvedValue('sent');
|
|
69
|
+
const onChange = vi.fn();
|
|
70
|
+
renderInput({ value: '来迟了', onChange, isStreaming: true, onSteer });
|
|
71
|
+
|
|
72
|
+
pressEnter();
|
|
73
|
+
await act(async () => {});
|
|
74
|
+
expect(onChange).toHaveBeenCalledWith('');
|
|
75
|
+
expect(screen.queryByRole('status')).toBeNull();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('⌘/Ctrl+Enter 直接排入 follow-up 队列并清空草稿', () => {
|
|
79
|
+
const onFollowUp = vi.fn();
|
|
80
|
+
const onSteer = vi.fn();
|
|
81
|
+
const onChange = vi.fn();
|
|
82
|
+
renderInput({ value: '排队这条', onChange, isStreaming: true, onFollowUp, onSteer });
|
|
83
|
+
|
|
84
|
+
pressEnter({ metaKey: true });
|
|
85
|
+
expect(onFollowUp).toHaveBeenCalledWith('排队这条');
|
|
86
|
+
expect(onChange).toHaveBeenCalledWith('');
|
|
87
|
+
expect(onSteer).not.toHaveBeenCalled();
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe('ChatInput 待发队列可见性(W6-2)', () => {
|
|
92
|
+
it('排队横幅展示数量与「停止将丢弃」预警,可撤回单条,快捷键提示可见', () => {
|
|
93
|
+
const onRemoveFollowUp = vi.fn();
|
|
94
|
+
renderInput({
|
|
95
|
+
isStreaming: true,
|
|
96
|
+
onFollowUp: vi.fn(),
|
|
97
|
+
pendingFollowUps: ['第一条', '第二条'],
|
|
98
|
+
onRemoveFollowUp,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
expect(
|
|
102
|
+
screen.getByText('排队中(2)· 本轮结束后自动发出 · 停止将丢弃'),
|
|
103
|
+
).toBeTruthy();
|
|
104
|
+
expect(screen.getByText('第一条')).toBeTruthy();
|
|
105
|
+
expect(screen.getByText('第二条')).toBeTruthy();
|
|
106
|
+
// 发现性:streaming 期间快捷键区展示「⌘/Ctrl+Enter 排队」。
|
|
107
|
+
expect(screen.getByText('排队')).toBeTruthy();
|
|
108
|
+
|
|
109
|
+
fireEvent.click(screen.getByRole('button', { name: '撤回排队消息 2' }));
|
|
110
|
+
expect(onRemoveFollowUp).toHaveBeenCalledWith(1);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('无排队消息时不渲染横幅', () => {
|
|
114
|
+
renderInput({ isStreaming: true, pendingFollowUps: [] });
|
|
115
|
+
expect(screen.queryByText(/排队中/)).toBeNull();
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe('ChatInput 停止按钮的丢弃预警(W6-2)', () => {
|
|
120
|
+
it('有排队消息时 title 提示将丢弃 N 条,无排队时不提示', () => {
|
|
121
|
+
const base: Partial<ChatInputProps> = { isStreaming: true, onCancel: vi.fn() };
|
|
122
|
+
const { rerender } = render(
|
|
123
|
+
<ChatInput
|
|
124
|
+
value=""
|
|
125
|
+
onChange={vi.fn()}
|
|
126
|
+
onSubmit={vi.fn()}
|
|
127
|
+
{...base}
|
|
128
|
+
pendingFollowUps={['第一条', '第二条']}
|
|
129
|
+
/>,
|
|
130
|
+
);
|
|
131
|
+
expect(
|
|
132
|
+
screen.getByRole('button', { name: '停止生成' }).getAttribute('title'),
|
|
133
|
+
).toContain('将丢弃 2 条排队消息');
|
|
134
|
+
|
|
135
|
+
rerender(
|
|
136
|
+
<ChatInput
|
|
137
|
+
value=""
|
|
138
|
+
onChange={vi.fn()}
|
|
139
|
+
onSubmit={vi.fn()}
|
|
140
|
+
{...base}
|
|
141
|
+
pendingFollowUps={[]}
|
|
142
|
+
/>,
|
|
143
|
+
);
|
|
144
|
+
const title = screen.getByRole('button', { name: '停止生成' }).getAttribute('title');
|
|
145
|
+
expect(title).toContain('停止生成');
|
|
146
|
+
expect(title).not.toContain('丢弃');
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe('ChatInput IME composition (Pinyin)', () => {
|
|
151
|
+
it('does not sync the first composing letter until composition ends', () => {
|
|
152
|
+
const onChange = vi.fn();
|
|
153
|
+
function Harness() {
|
|
154
|
+
const [value, setValue] = useState('');
|
|
155
|
+
return (
|
|
156
|
+
<ChatInput
|
|
157
|
+
value={value}
|
|
158
|
+
onChange={(next) => {
|
|
159
|
+
onChange(next);
|
|
160
|
+
setValue(next);
|
|
161
|
+
}}
|
|
162
|
+
onSubmit={vi.fn()}
|
|
163
|
+
/>
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
render(<Harness />);
|
|
167
|
+
const editor = screen.getByRole('textbox');
|
|
168
|
+
|
|
169
|
+
fireEvent.compositionStart(editor);
|
|
170
|
+
editor.textContent = 'n';
|
|
171
|
+
fireEvent.input(editor);
|
|
172
|
+
|
|
173
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
174
|
+
expect(editor.textContent).toBe('n');
|
|
175
|
+
expect(editor.getAttribute('data-composing')).toBe('true');
|
|
176
|
+
|
|
177
|
+
fireEvent.compositionEnd(editor);
|
|
178
|
+
expect(onChange).toHaveBeenCalledWith('n');
|
|
179
|
+
expect(editor.getAttribute('data-composing')).toBeNull();
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('does not commit a letter that is followed by compositionstart before the deferred sync', async () => {
|
|
183
|
+
const onChange = vi.fn();
|
|
184
|
+
render(<ChatInput value="" onChange={onChange} onSubmit={vi.fn()} />);
|
|
185
|
+
const editor = screen.getByRole('textbox');
|
|
186
|
+
|
|
187
|
+
editor.textContent = 'n';
|
|
188
|
+
fireEvent.input(editor);
|
|
189
|
+
fireEvent.compositionStart(editor);
|
|
190
|
+
await flushComposerSync();
|
|
191
|
+
|
|
192
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
193
|
+
expect(editor.getAttribute('data-composing')).toBe('true');
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('marks composing on IME Process keydown (keyCode 229)', () => {
|
|
197
|
+
render(<ChatInput value="" onChange={vi.fn()} onSubmit={vi.fn()} />);
|
|
198
|
+
const editor = screen.getByRole('textbox');
|
|
199
|
+
fireEvent.keyDown(editor, { key: 'Process', keyCode: 229 });
|
|
200
|
+
expect(editor.getAttribute('data-composing')).toBe('true');
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('restores composing state on the first Pinyin letter keydown', () => {
|
|
204
|
+
render(<ChatInput value="" onChange={vi.fn()} onSubmit={vi.fn()} />);
|
|
205
|
+
const editor = screen.getByRole('textbox');
|
|
206
|
+
fireEvent.keyDown(editor, { key: 'n' });
|
|
207
|
+
expect(editor.getAttribute('data-composing')).toBe('true');
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('does not rewrite contenteditable DOM while composing', () => {
|
|
211
|
+
const onChange = vi.fn();
|
|
212
|
+
const { rerender } = render(
|
|
213
|
+
<ChatInput value="" onChange={onChange} onSubmit={vi.fn()} />,
|
|
214
|
+
);
|
|
215
|
+
const editor = screen.getByRole('textbox');
|
|
216
|
+
|
|
217
|
+
fireEvent.compositionStart(editor);
|
|
218
|
+
editor.textContent = 'ni';
|
|
219
|
+
rerender(
|
|
220
|
+
<ChatInput value="should-not-apply" onChange={onChange} onSubmit={vi.fn()} />,
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
expect(editor.textContent).toBe('ni');
|
|
224
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('keeps a text node in an empty editor so IME can attach marked text', () => {
|
|
228
|
+
render(<ChatInput value="" onChange={vi.fn()} onSubmit={vi.fn()} />);
|
|
229
|
+
const editor = screen.getByRole('textbox');
|
|
230
|
+
expect(editor.querySelector('br')).not.toBeNull();
|
|
231
|
+
expect(
|
|
232
|
+
Array.from(editor.childNodes).some((node) => node.nodeType === Node.TEXT_NODE),
|
|
233
|
+
).toBe(true);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('keeps the placeholder mounted so the first IME letter does not remount siblings', () => {
|
|
237
|
+
render(<ChatInput value="" onChange={vi.fn()} onSubmit={vi.fn()} />);
|
|
238
|
+
expect(document.querySelector('.chat-input-placeholder')).not.toBeNull();
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('still syncs ordinary non-IME typing', async () => {
|
|
242
|
+
const onChange = vi.fn();
|
|
243
|
+
function Harness() {
|
|
244
|
+
const [value, setValue] = useState('');
|
|
245
|
+
return (
|
|
246
|
+
<ChatInput
|
|
247
|
+
value={value}
|
|
248
|
+
onChange={(next) => {
|
|
249
|
+
onChange(next);
|
|
250
|
+
setValue(next);
|
|
251
|
+
}}
|
|
252
|
+
onSubmit={vi.fn()}
|
|
253
|
+
/>
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
render(<Harness />);
|
|
257
|
+
const editor = screen.getByRole('textbox');
|
|
258
|
+
editor.textContent = 'hello';
|
|
259
|
+
fireEvent.input(editor);
|
|
260
|
+
await flushComposerSync();
|
|
261
|
+
expect(onChange).toHaveBeenCalledWith('hello');
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('does not mount the highlight mirror before the first letter is committed', () => {
|
|
265
|
+
render(<ChatInput value="" onChange={vi.fn()} onSubmit={vi.fn()} />);
|
|
266
|
+
const editor = screen.getByRole('textbox');
|
|
267
|
+
fireEvent.keyDown(editor, { key: 'n' });
|
|
268
|
+
editor.textContent = 'n';
|
|
269
|
+
fireEvent.input(editor);
|
|
270
|
+
expect(document.querySelector('.chat-input-mirror')).toBeNull();
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
describe('ChatInput composer meta row', () => {
|
|
275
|
+
const agent = {
|
|
276
|
+
id: 'local-assistant',
|
|
277
|
+
slug: 'local-assistant',
|
|
278
|
+
name: '电脑操作员',
|
|
279
|
+
icon: null,
|
|
280
|
+
color: '#7c3aed',
|
|
281
|
+
description: null,
|
|
282
|
+
rolePrompt: null,
|
|
283
|
+
isBuiltin: true,
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
function renderComposer(ui: ReactElement) {
|
|
287
|
+
return render(<MemoryRouter>{ui}</MemoryRouter>);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
it('renders the agent picker after leadingChrome, above the input box', () => {
|
|
291
|
+
renderComposer(
|
|
292
|
+
<ChatInput
|
|
293
|
+
value=""
|
|
294
|
+
onChange={vi.fn()}
|
|
295
|
+
onSubmit={vi.fn()}
|
|
296
|
+
currentAgent={agent}
|
|
297
|
+
agents={[agent]}
|
|
298
|
+
onSelectAgent={vi.fn()}
|
|
299
|
+
leadingChrome={<button type="button">选择项目</button>}
|
|
300
|
+
/>,
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
const row = screen.getByTestId('composer-meta-row');
|
|
304
|
+
const project = screen.getByRole('button', { name: '选择项目' });
|
|
305
|
+
const picker = screen.getByTestId('agent-select');
|
|
306
|
+
const composer = screen.getByTestId('chat-composer');
|
|
307
|
+
|
|
308
|
+
expect(row.contains(project)).toBe(true);
|
|
309
|
+
expect(row.contains(picker)).toBe(true);
|
|
310
|
+
expect(
|
|
311
|
+
project.compareDocumentPosition(picker) & Node.DOCUMENT_POSITION_FOLLOWING,
|
|
312
|
+
).toBeTruthy();
|
|
313
|
+
expect(
|
|
314
|
+
picker.compareDocumentPosition(composer) & Node.DOCUMENT_POSITION_FOLLOWING,
|
|
315
|
+
).toBeTruthy();
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it('clicking another expert switches the current agent instead of inserting @mention', () => {
|
|
319
|
+
const other = {
|
|
320
|
+
...agent,
|
|
321
|
+
id: 'all-round-assistant',
|
|
322
|
+
slug: 'all-round-assistant',
|
|
323
|
+
name: '智能助手',
|
|
324
|
+
};
|
|
325
|
+
const onSelectAgent = vi.fn();
|
|
326
|
+
const onChange = vi.fn();
|
|
327
|
+
renderComposer(
|
|
328
|
+
<ChatInput
|
|
329
|
+
value=""
|
|
330
|
+
onChange={onChange}
|
|
331
|
+
onSubmit={vi.fn()}
|
|
332
|
+
currentAgent={agent}
|
|
333
|
+
selectedAgentId={agent.id}
|
|
334
|
+
agents={[agent, other]}
|
|
335
|
+
onSelectAgent={onSelectAgent}
|
|
336
|
+
/>,
|
|
337
|
+
);
|
|
338
|
+
|
|
339
|
+
fireEvent.click(screen.getByTestId('agent-select'));
|
|
340
|
+
fireEvent.click(screen.getByTestId('agent-option-all-round-assistant'));
|
|
341
|
+
|
|
342
|
+
expect(onSelectAgent).toHaveBeenCalledWith('all-round-assistant');
|
|
343
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it('shows selectedAgentId as the current expert even if currentAgent is stale', () => {
|
|
347
|
+
const other = {
|
|
348
|
+
...agent,
|
|
349
|
+
id: 'all-round-assistant',
|
|
350
|
+
slug: 'all-round-assistant',
|
|
351
|
+
name: '智能助手',
|
|
352
|
+
};
|
|
353
|
+
renderComposer(
|
|
354
|
+
<ChatInput
|
|
355
|
+
value=""
|
|
356
|
+
onChange={vi.fn()}
|
|
357
|
+
onSubmit={vi.fn()}
|
|
358
|
+
currentAgent={agent}
|
|
359
|
+
selectedAgentId={other.id}
|
|
360
|
+
agents={[agent, other]}
|
|
361
|
+
onSelectAgent={vi.fn()}
|
|
362
|
+
/>,
|
|
363
|
+
);
|
|
364
|
+
|
|
365
|
+
expect(screen.getByTestId('agent-select').textContent).toContain('智能助手');
|
|
366
|
+
fireEvent.click(screen.getByTestId('agent-select'));
|
|
367
|
+
expect(screen.getByTestId('agent-option-all-round-assistant').textContent).toContain('当前');
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it('opens 智能体管理 from the picker footer', () => {
|
|
371
|
+
function LocationProbe() {
|
|
372
|
+
const loc = useLocation();
|
|
373
|
+
return (
|
|
374
|
+
<div data-testid="loc">
|
|
375
|
+
{loc.pathname}
|
|
376
|
+
{loc.search}
|
|
377
|
+
</div>
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
render(
|
|
381
|
+
<MemoryRouter>
|
|
382
|
+
<ChatInput
|
|
383
|
+
value=""
|
|
384
|
+
onChange={vi.fn()}
|
|
385
|
+
onSubmit={vi.fn()}
|
|
386
|
+
currentAgent={agent}
|
|
387
|
+
agents={[agent]}
|
|
388
|
+
onSelectAgent={vi.fn()}
|
|
389
|
+
/>
|
|
390
|
+
<LocationProbe />
|
|
391
|
+
</MemoryRouter>,
|
|
392
|
+
);
|
|
393
|
+
fireEvent.click(screen.getByTestId('agent-select'));
|
|
394
|
+
fireEvent.click(screen.getByTestId('agent-manage'));
|
|
395
|
+
expect(screen.getByTestId('loc').textContent).toBe('/settings?section=agents');
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it('turns an @mention into a chip that click-removes it', () => {
|
|
399
|
+
const onChange = vi.fn();
|
|
400
|
+
function Harness() {
|
|
401
|
+
const [value, setValue] = useState('@电脑操作员 你好');
|
|
402
|
+
return (
|
|
403
|
+
<ChatInput
|
|
404
|
+
value={value}
|
|
405
|
+
onChange={(next) => {
|
|
406
|
+
onChange(next);
|
|
407
|
+
setValue(next);
|
|
408
|
+
}}
|
|
409
|
+
onSubmit={vi.fn()}
|
|
410
|
+
currentAgent={agent}
|
|
411
|
+
agents={[agent]}
|
|
412
|
+
onSelectAgent={vi.fn()}
|
|
413
|
+
/>
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
renderComposer(<Harness />);
|
|
417
|
+
|
|
418
|
+
const chip = screen.getByRole('button', { name: '移除 @电脑操作员' });
|
|
419
|
+
expect(chip.className).toContain('px-1.5');
|
|
420
|
+
expect(chip.className).toContain('py-0.5');
|
|
421
|
+
const remove = chip.querySelector('[data-mention-remove]');
|
|
422
|
+
expect(remove?.className).toContain('opacity-0');
|
|
423
|
+
fireEvent.click(chip);
|
|
424
|
+
expect(onChange).toHaveBeenCalledWith('你好');
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
it('renders mention chips inside the editor without blocking trailing text', () => {
|
|
428
|
+
renderComposer(
|
|
429
|
+
<ChatInput
|
|
430
|
+
value="@电脑操作员 111"
|
|
431
|
+
onChange={vi.fn()}
|
|
432
|
+
onSubmit={vi.fn()}
|
|
433
|
+
currentAgent={agent}
|
|
434
|
+
agents={[agent]}
|
|
435
|
+
/>,
|
|
436
|
+
);
|
|
437
|
+
|
|
438
|
+
const editor = screen.getByRole('textbox');
|
|
439
|
+
const chip = screen.getByRole('button', { name: '移除 @电脑操作员' });
|
|
440
|
+
expect(editor.contains(chip)).toBe(true);
|
|
441
|
+
// Trailing text ' 111' is a sibling node after chip in the editor layout flow
|
|
442
|
+
expect(editor.textContent).toBe('@电脑操作员 111');
|
|
443
|
+
expect(chip.nextSibling?.textContent).toBe(' 111');
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
it('turns a tool into a chip that click-removes it and does not block text', () => {
|
|
447
|
+
const onChange = vi.fn();
|
|
448
|
+
function Harness() {
|
|
449
|
+
const [value, setValue] = useState('/mcp__sqlite__query 查询数据');
|
|
450
|
+
return (
|
|
451
|
+
<ChatInput
|
|
452
|
+
value={value}
|
|
453
|
+
onChange={(next) => {
|
|
454
|
+
onChange(next);
|
|
455
|
+
setValue(next);
|
|
456
|
+
}}
|
|
457
|
+
onSubmit={vi.fn()}
|
|
458
|
+
/>
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
render(<Harness />);
|
|
462
|
+
|
|
463
|
+
const chip = screen.getByRole('button', { name: '移除 /mcp__sqlite__query' });
|
|
464
|
+
expect(chip.className).toContain('px-1.5');
|
|
465
|
+
expect(chip.className).toContain('py-0.5');
|
|
466
|
+
expect(chip.getAttribute('data-mention-type')).toBe('mcp');
|
|
467
|
+
const remove = chip.querySelector('[data-mention-remove]');
|
|
468
|
+
expect(remove?.className).toContain('opacity-0');
|
|
469
|
+
expect(chip.nextSibling?.textContent).toBe(' 查询数据');
|
|
470
|
+
|
|
471
|
+
fireEvent.click(chip);
|
|
472
|
+
expect(onChange).toHaveBeenCalledWith('查询数据');
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
it('turns a skill tool into a chip with amber styling and click-removes it', () => {
|
|
476
|
+
const onChange = vi.fn();
|
|
477
|
+
function Harness() {
|
|
478
|
+
const [value, setValue] = useState('/read-workspace 看看工区');
|
|
479
|
+
return (
|
|
480
|
+
<ChatInput
|
|
481
|
+
value={value}
|
|
482
|
+
onChange={(next) => {
|
|
483
|
+
onChange(next);
|
|
484
|
+
setValue(next);
|
|
485
|
+
}}
|
|
486
|
+
onSubmit={vi.fn()}
|
|
487
|
+
skills={[{ name: 'read-workspace', description: 'Read workspace' }]}
|
|
488
|
+
/>
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
render(<Harness />);
|
|
492
|
+
|
|
493
|
+
const chip = screen.getByRole('button', { name: '移除 /read-workspace' });
|
|
494
|
+
expect(chip.className).toContain('px-1.5');
|
|
495
|
+
expect(chip.className).toContain('py-0.5');
|
|
496
|
+
expect(chip.getAttribute('data-mention-type')).toBe('skill');
|
|
497
|
+
const remove = chip.querySelector('[data-mention-remove]');
|
|
498
|
+
expect(remove?.className).toContain('opacity-0');
|
|
499
|
+
expect(chip.nextSibling?.textContent).toBe(' 看看工区');
|
|
500
|
+
|
|
501
|
+
fireEvent.click(chip);
|
|
502
|
+
expect(onChange).toHaveBeenCalledWith('看看工区');
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
it('selecting an option from slash menu inserts and renders a tool chip', async () => {
|
|
506
|
+
const onChange = vi.fn();
|
|
507
|
+
function Harness() {
|
|
508
|
+
const [value, setValue] = useState('');
|
|
509
|
+
return (
|
|
510
|
+
<ChatInput
|
|
511
|
+
value={value}
|
|
512
|
+
onChange={(next) => {
|
|
513
|
+
onChange(next);
|
|
514
|
+
setValue(next);
|
|
515
|
+
}}
|
|
516
|
+
onSubmit={vi.fn()}
|
|
517
|
+
skills={[{ name: 'web-search', displayName: '网络搜索', description: '网页检索' }]}
|
|
518
|
+
/>
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
render(<Harness />);
|
|
522
|
+
|
|
523
|
+
const editor = screen.getByRole('textbox');
|
|
524
|
+
editor.textContent = '/';
|
|
525
|
+
fireEvent.input(editor);
|
|
526
|
+
await flushComposerSync();
|
|
527
|
+
|
|
528
|
+
const slashOption = screen.getByTestId('slash-option-skill-web-search');
|
|
529
|
+
fireEvent.click(slashOption);
|
|
530
|
+
await flushComposerSync();
|
|
531
|
+
|
|
532
|
+
const chip = screen.getByRole('button', { name: '移除 /web-search' });
|
|
533
|
+
expect(chip.className).toContain('px-1.5');
|
|
534
|
+
expect(chip.className).toContain('py-0.5');
|
|
535
|
+
expect(chip.getAttribute('data-mention-type')).toBe('skill');
|
|
536
|
+
expect(onChange).toHaveBeenCalledWith('/web-search ');
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
it('filters out internal skills like plan-mode, identity, and tool-usage from slash suggestions', async () => {
|
|
540
|
+
function Harness() {
|
|
541
|
+
const [value, setValue] = useState('');
|
|
542
|
+
return (
|
|
543
|
+
<ChatInput
|
|
544
|
+
value={value}
|
|
545
|
+
onChange={setValue}
|
|
546
|
+
onSubmit={vi.fn()}
|
|
547
|
+
skills={[
|
|
548
|
+
{ name: 'identity', id: '00-identity', description: 'Identity skill' },
|
|
549
|
+
{ name: 'plan-mode', id: '70-plan-mode', description: 'Plan mode skill' },
|
|
550
|
+
{ name: 'tool-usage', id: '80-tool-usage', description: 'Tool usage' },
|
|
551
|
+
{ name: 'anti-deferred-execution', id: '81-anti-deferred', description: 'Anti deferred' },
|
|
552
|
+
{ name: 'data-grounding', id: '82-data-grounding', description: 'Data grounding' },
|
|
553
|
+
{ name: 'local-exec', id: '85-local-exec', description: 'Local exec' },
|
|
554
|
+
{ name: 'proactive-coding', id: '86-proactive-coding', description: 'Proactive coding' },
|
|
555
|
+
{ name: 'goal', id: '10-goal', displayName: '目标跟踪', description: '目标与验收' },
|
|
556
|
+
{ name: 'loop', id: '11-loop', displayName: '循环执行', description: '重复执行' },
|
|
557
|
+
{
|
|
558
|
+
name: 'create-skill',
|
|
559
|
+
id: '12-create-skill',
|
|
560
|
+
displayName: '创建技能',
|
|
561
|
+
description: '创建本地技能',
|
|
562
|
+
},
|
|
563
|
+
{ name: 'web-search', displayName: '网络搜索', description: '网页检索' },
|
|
564
|
+
]}
|
|
565
|
+
/>
|
|
566
|
+
);
|
|
567
|
+
}
|
|
568
|
+
render(<Harness />);
|
|
569
|
+
|
|
570
|
+
const editor = screen.getByRole('textbox');
|
|
571
|
+
editor.textContent = '/';
|
|
572
|
+
fireEvent.input(editor);
|
|
573
|
+
await flushComposerSync();
|
|
574
|
+
|
|
575
|
+
expect(screen.queryByTestId('slash-option-skill-identity')).toBeNull();
|
|
576
|
+
expect(screen.queryByTestId('slash-option-skill-plan-mode')).toBeNull();
|
|
577
|
+
expect(screen.queryByTestId('slash-option-skill-tool-usage')).toBeNull();
|
|
578
|
+
expect(screen.queryByTestId('slash-option-skill-anti-deferred-execution')).toBeNull();
|
|
579
|
+
expect(screen.queryByTestId('slash-option-skill-data-grounding')).toBeNull();
|
|
580
|
+
expect(screen.queryByTestId('slash-option-skill-local-exec')).toBeNull();
|
|
581
|
+
expect(screen.queryByTestId('slash-option-skill-proactive-coding')).toBeNull();
|
|
582
|
+
// 场景包技能的隐藏(如某场景包的 90-xxx 技能)由包渲染层声明,
|
|
583
|
+
// 覆盖在消费方产品的包测试中。
|
|
584
|
+
expect(screen.getByTestId('slash-option-skill-web-search')).toBeTruthy();
|
|
585
|
+
// 面向用户的内置工作流技能必须留在菜单里
|
|
586
|
+
expect(screen.getByTestId('slash-option-skill-goal')).toBeTruthy();
|
|
587
|
+
expect(screen.getByTestId('slash-option-skill-loop')).toBeTruthy();
|
|
588
|
+
expect(screen.getByTestId('slash-option-skill-create-skill')).toBeTruthy();
|
|
589
|
+
});
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
describe('ChatInput 命令沙箱选择器', () => {
|
|
593
|
+
it('renders the picker and forwards a switch to full access', () => {
|
|
594
|
+
const onExecPolicyChange = vi.fn();
|
|
595
|
+
renderInput({
|
|
596
|
+
execPolicy: 'workspace',
|
|
597
|
+
onExecPolicyChange,
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
fireEvent.click(screen.getByTestId('exec-policy-picker'));
|
|
601
|
+
fireEvent.click(screen.getByTestId('exec-policy-full'));
|
|
602
|
+
expect(onExecPolicyChange).toHaveBeenCalledWith('full');
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
it('hides the picker when the change handler is omitted', () => {
|
|
606
|
+
renderInput({ execPolicy: 'workspace' });
|
|
607
|
+
expect(screen.queryByTestId('exec-policy-picker')).toBeNull();
|
|
608
|
+
});
|
|
609
|
+
});
|