fix: simplify tool execution records in conversations

Replace full-width tool cards with compact disclosure rows in conversations and subagent progress. Remove redundant runtime summaries, keep expansion under user control, and show successful calls with green checks.

Prioritize errors and results, fold inputs, and provide original-text copying through application notifications. Detail text wraps in bounded scroll areas with no extra display controls.

Validation: 3927 tests passed, 66 skipped; typecheck and lint passed.

Release note: Tool activity is now more compact, with clearer status icons and less repeated text. Expand a record to read and copy results, errors, or input parameters.
This commit is contained in:
mesalogo
2026-09-11 14:54:11 +08:00
parent f6287589a3
commit fc36433d76
9 changed files with 371 additions and 83 deletions
+7 -4
View File
@@ -3631,11 +3631,11 @@ describe("App", () => {
expect(orderedBlocks).toEqual([
expect.stringContaining("这是回答内容"),
expect.stringContaining("先检查项目结构"),
expect.stringContaining("OpenCode 工具:read"),
expect.stringContaining("read"),
expect.stringContaining("再检查关键文件"),
expect.stringContaining("最终结论"),
]);
expect(screen.getAllByText("OpenCode 工具:read")).toHaveLength(1);
expect(screen.queryByText("OpenCode 工具:read")).not.toBeInTheDocument();
expect(
screen.getByRole("region", { name: "工具执行,共 2 项" }),
).toBeInTheDocument();
@@ -3648,13 +3648,16 @@ describe("App", () => {
).not.toBeVisible();
fireEvent.click(screen.getByText("read").closest("summary")!);
expect(readTool).toHaveAttribute("open");
expect(within(readTool!).getByText("调用参数")).toBeVisible();
expect(within(readTool!).getByText("调用参数", { selector: "summary" })).toBeVisible();
expect(
within(readTool!).getByText(
(_, element) => element?.textContent === rawToolOutput,
),
).toBeVisible();
const activeReasoning = screen.getAllByText("正在推理");
fireEvent.click(within(readTool!).getByRole("button", { name: "复制执行结果" }));
await waitFor(() => expect(api.clipboard.writeText).toHaveBeenCalledWith(rawToolOutput));
expect(await screen.findByText("已复制工具详情")).toBeVisible();
expect(activeReasoning).toHaveLength(2);
for (const reasoning of activeReasoning) {
expect(reasoning.closest("details")).toHaveAttribute("open");
@@ -4652,7 +4655,7 @@ describe("App", () => {
});
});
expect(screen.getByText(toolError)).toBeInTheDocument();
expect(screen.getByText(toolError, { selector: ".tool-execution__error-preview" })).toBeVisible();
expect(screen.queryByText(runtimeError)).not.toBeInTheDocument();
expect(
screen.getByRole("button", { name: "重新编辑并发送" }),
+3 -3
View File
@@ -824,7 +824,7 @@ function ChatHistoryPane({
conversationHtmlRenderingEnabled: boolean;
conversation: Conversation;
locale: TimeFormatLocale;
onCopyMessage: (content: string) => Promise<void>;
onCopyMessage: (content: string, kind?: 'tool') => Promise<void>;
onDownloadImage: (item: ImageViewerItem) => void;
onOpenCitationContext: (reference: KnowledgeSearchReference) => Promise<void>;
onOpenCitationSource: (reference: KnowledgeSearchReference) => Promise<void>;
@@ -6623,8 +6623,8 @@ function App(): React.JSX.Element {
};
const copyMessage = useCallback(
(content: string): Promise<void> =>
writeClipboardText(content, t("notices.messageCopied")),
(content: string, kind?: 'tool'): Promise<void> =>
writeClipboardText(content, t(kind === 'tool' ? "chat.tools.copied" : "notices.messageCopied")),
[t, writeClipboardText],
);
+110
View File
@@ -61,6 +61,116 @@ function createMessages(): Message[] {
}
describe('ChatTimeline', () => {
it.each([
['read', 'OpenCode 工具:read', false],
['read', 'Continue 工具:read', false],
['read', 'DeepSeek Harness 工具:read', false],
['read', '远端 Runtime 工具:read', false],
['浏览器导航', '直连模型工具:浏览器导航', false],
['浏览器导航', '正在执行直连模型工具:浏览器导航', false],
['浏览器导航', '直连模型工具已完成:浏览器导航', false],
['浏览器导航', '直连模型工具执行失败:浏览器导航', false],
['read', ' OpenCode 工具: read ', false],
['read', 'OpenCode 工具:read README.md', true],
['浏览器导航', '直连模型工具需要刷新后重试:浏览器导航', true],
['浏览器导航', '用户拒绝了直连模型工具:浏览器导航', true],
['read', 'Custom tool: read', true]
])('deduplicates the tool heading for %s / %s', (name, summary, hasSummary) => {
const view = render(<ChatTimeline
artifactById={new Map()} conversationId="tools" hiddenMessageCount={0}
isUnusedConversation={false} locale="zh-CN" messageStartIndex={0}
{...callbacks} retryContent="" totalMessageCount={1}
messages={[{
id: 'tool-message', role: 'assistant', content: '', createdAt: 0, state: 'complete',
tools: [{ callId: 'call-1', name, summary, state: 'completed' }]
}]}
/>)
const card = view.container.querySelector('.tool-execution')!
expect(view.container.querySelector('.tool-execution-list > header')).toBeNull()
const status = card.querySelector('.tool-execution__status')!
expect(status).toHaveAttribute('aria-label', '已完成')
expect(status).toHaveAttribute('title', '已完成')
expect(status.textContent).toBe('')
expect(card.querySelector('.tool-execution__identity strong')).toHaveTextContent(name)
expect(Boolean(card.querySelector('.tool-execution__identity > span'))).toBe(hasSummary)
fireEvent.click(card.querySelector('summary')!)
expect(Boolean(card.querySelector('.tool-execution__full-summary'))).toBe(hasSummary)
})
it('constrains tool grid columns so long summaries cannot clip controls', () => {
for (const selector of ['.tool-execution-list', '.tool-execution-list > ol',
'.tool-execution__details', '.tool-execution__details section']) {
const rule = stylesheet.slice(stylesheet.indexOf(`${selector} {`)).split('}')[0]
expect(rule).toContain('grid-template-columns: minmax(0, 1fr)')
}
})
it('keeps tool expansion under user control through streaming and completion', () => {
const props = {
artifactById: new Map(), conversationId: 'tools',
hiddenMessageCount: 0, isUnusedConversation: false, locale: 'zh-CN' as const,
messageStartIndex: 0, ...callbacks, retryContent: '', totalMessageCount: 1
}
const message: Message = {
id: 'tool-message', role: 'assistant', content: '', createdAt: 0, state: 'streaming',
tools: [{ callId: 'read-1', name: 'read', summary: 'read', state: 'running' }]
}
const view = render(<ChatTimeline {...props} messages={[message]} />)
const card = view.container.querySelector('.tool-execution')!
expect(card).not.toHaveAttribute('open')
expect(within(card as HTMLElement).getAllByText('read')).toHaveLength(1)
fireEvent.click(card.querySelector('summary')!)
expect(card).toHaveAttribute('open')
expect(screen.getByText('执行中,尚无结果。')).toBeVisible()
view.rerender(<ChatTimeline {...props} messages={[{
...message, tools: [{ ...message.tools![0]!, state: 'completed' }]
}]} />)
expect(card).toHaveAttribute('open')
expect(screen.getByText('执行完成,无返回内容。')).toBeVisible()
fireEvent.click(card.querySelector('summary')!)
view.rerender(<ChatTimeline {...props} messages={[{
...message, tools: [{ ...message.tools![0]!, output: 'late result' }]
}]} />)
expect(card).not.toHaveAttribute('open')
})
it('prioritizes errors and results, folds JSON inputs and copies original content', () => {
const input = '{"path":"README.md","limit":20}'
const props = {
artifactById: new Map(), conversationId: 'tools',
hiddenMessageCount: 0, isUnusedConversation: false, locale: 'zh-CN' as const,
messageStartIndex: 0, ...callbacks, retryContent: '', totalMessageCount: 1
}
const message: Message = {
id: 'tool-message', role: 'assistant', content: '', createdAt: 0, state: 'complete',
tools: [{ callId: 'read-1', name: 'read', summary: 'Read README.md', state: 'failed',
input, output: '<div>partial output</div>', error: 'Read failed\nMore context' }]
}
const view = render(<ChatTimeline {...props} messages={[message]} />)
const card = view.container.querySelector('.tool-execution')!
const preview = view.container.querySelector('.tool-execution__error-preview')!
expect(preview).toHaveTextContent('Read failed')
expect(card.querySelector('.tool-execution__status')).toHaveTextContent('失败')
fireEvent.click(card.querySelector('summary')!)
expect(Array.from(card.querySelectorAll('pre')).map((pre) => pre.textContent)).toEqual([
'Read failed\nMore context', '<div>partial output</div>', JSON.stringify(JSON.parse(input), null, 2)
])
const inputCard = card.querySelector('.tool-execution__input')!
expect(inputCard).not.toHaveAttribute('open')
fireEvent.click(screen.getByRole('button', { name: '复制执行结果' }))
expect(callbacks.onCopyMessage).toHaveBeenLastCalledWith('<div>partial output</div>', 'tool')
const result = screen.getByRole('region', { name: '执行结果' })
expect(within(result).getAllByRole('button')).toHaveLength(1)
expect(within(result).getByRole('button', { name: '复制执行结果' })).toBeVisible()
fireEvent.click(inputCard.querySelector('summary')!)
fireEvent.click(screen.getByRole('button', { name: '复制调用参数' }))
expect(callbacks.onCopyMessage).toHaveBeenLastCalledWith(input, 'tool')
view.rerender(<ChatTimeline {...props} messages={[{
...message, tools: [{ ...message.tools![0]!, input: '{"incomplete":' }]
}]} />)
expect(inputCard.querySelector('pre')?.textContent).toBe('{"incomplete":')
})
it('shows an image context limitation as a quiet persistent footer, not an alert', () => {
const message: Message = {
id: 'image-message', role: 'assistant', content: '', createdAt: Date.now(),
+88 -41
View File
@@ -1,5 +1,6 @@
import {
Bot,
Check,
CheckCircle2,
ChevronRight,
CircleHelp,
@@ -11,7 +12,6 @@ import {
ClockFading,
LoaderCircle,
ShieldCheck,
TerminalSquare,
UserRound,
XCircle
} from 'lucide-react'
@@ -181,10 +181,46 @@ function MessageReasoning({
)
}
type CopyContent = (content: string, kind?: 'tool') => Promise<void>
function ToolDetail({ label, content, formatJson = false, onCopy }: {
label: string
content: string
formatJson?: boolean
onCopy: CopyContent
}): React.JSX.Element {
const { t } = useTranslation('app')
let display = content
if (formatJson) {
try {
display = JSON.stringify(JSON.parse(content), null, 2)
} catch {
// Truncated JSON and plain-text arguments remain readable as received.
}
}
return (
<section className="tool-detail" aria-label={label}>
<header className="tool-detail__toolbar">
<strong>{label}</strong>
<button type="button" aria-label={t('chat.tools.copy', { label })} onClick={() => { void onCopy(content, 'tool') }}>
<Copy aria-hidden="true" size={13} />
{t('chat.tools.copyAction')}
</button>
</header>
<pre tabIndex={0} aria-label={label}>
{display}
</pre>
</section>
)
}
function ToolExecutionList({
tools
tools,
onCopy
}: {
tools: ToolActivity[]
onCopy: CopyContent
}): React.JSX.Element {
const { t } = useTranslation('app')
@@ -193,59 +229,63 @@ function ToolExecutionList({
aria-label={t('chat.tools.region', { count: tools.length })}
className="tool-execution-list"
>
<header className="tool-execution-list__header">
<TerminalSquare aria-hidden="true" size={15} />
<strong>{t('chat.tools.title')}</strong>
<small>{t('chat.tools.count', { count: tools.length })}</small>
</header>
<ol>
{tools.map((tool) => {
const hasDetails = Boolean(
tool.input || tool.output || tool.error
const summary = tool.summary.trim()
// Only suppress known boilerplate; recovery hints and custom summaries remain visible.
const summaryIdentity = summary.replace(
/^(?:(?:OpenCode|Continue|DeepSeek Harness|远端 Runtime) 工具|直连模型工具(?:已完成|执行失败)?|正在执行直连模型工具)\s*[::]\s*/u,
''
)
const hasSummary = summary.length > 0 && summaryIdentity !== tool.name.trim()
const StateIcon = tool.state === 'running' ? LoaderCircle
: tool.state === 'pending' ? Clock3
: tool.state === 'completed' ? Check : XCircle
return (
<li key={tool.callId ?? tool.name}>
<details
className={`tool-execution tool-execution--${tool.state}`}
open={
tool.state === 'pending' || tool.state === 'running'
? true
: undefined
}
>
<summary>
<ChevronRight aria-hidden="true" size={14} className="tool-execution__chevron" />
<span className="tool-execution__identity">
<strong>{tool.name}</strong>
<span>{tool.summary}</span>
<strong title={tool.name}>{tool.name}</strong>
{hasSummary && <span title={tool.summary}>{tool.summary}</span>}
</span>
<small
aria-label={t(`chat.tools.states.${tool.state}`)}
title={t(`chat.tools.states.${tool.state}`)}
className="tool-execution__status"
>
{t(`chat.tools.states.${tool.state}`)}
<StateIcon aria-hidden="true" size={13} />
{(tool.state === 'failed' || tool.state === 'recoverable' || tool.state === 'cancelled') && t(`chat.tools.states.${tool.state}`)}
</small>
</summary>
<div className="tool-execution__details">
{tool.input && (
<section>
<strong>{t('chat.tools.input')}</strong>
<pre>{tool.input}</pre>
</section>
)}
{tool.output && (
<section>
<strong>{t('chat.tools.output')}</strong>
<pre>{tool.output}</pre>
</section>
)}
{hasSummary && <p className="tool-execution__full-summary">{tool.summary}</p>}
{tool.error && (
<section className="tool-execution__error">
<strong>{t('chat.tools.error')}</strong>
<pre>{tool.error}</pre>
</section>
<div className="tool-execution__error">
<ToolDetail label={t('chat.tools.error')} content={tool.error} onCopy={onCopy} />
</div>
)}
{tool.output ? (
<ToolDetail label={t('chat.tools.output')} content={tool.output} onCopy={onCopy} />
) : !tool.error && (
<p>{t(tool.state === 'running' ? 'chat.tools.waitingOutput'
: tool.state === 'pending' ? 'chat.tools.waitingStart'
: tool.state === 'completed' ? 'chat.tools.emptyOutput' : 'chat.tools.noDetails')}</p>
)}
{tool.input && (
<details className="tool-execution__input">
<summary>{t('chat.tools.input')}</summary>
<ToolDetail label={t('chat.tools.input')} content={tool.input} formatJson onCopy={onCopy} />
</details>
)}
{!hasDetails && <p>{t('chat.tools.noDetails')}</p>}
</div>
</details>
{tool.error && (
<p className="tool-execution__error-preview" title={tool.error}>{tool.error}</p>
)}
</li>
)
})}
@@ -256,10 +296,12 @@ function ToolExecutionList({
const SubagentStatusCard = memo(function SubagentStatusCard({
renderHtml,
subagent
subagent,
onCopy
}: {
renderHtml: boolean
subagent: SubagentActivity
onCopy: CopyContent
}): React.JSX.Element {
const { t } = useTranslation('app')
const [expanded, setExpanded] = useState(false)
@@ -349,7 +391,7 @@ const SubagentStatusCard = memo(function SubagentStatusCard({
<div className="subagent-status-card__progress">
{groupMessageBlocks(progress).map((item) =>
item.kind === 'tools' ? (
<ToolExecutionList key={item.id} tools={item.tools.map((tool) =>
<ToolExecutionList key={item.id} onCopy={onCopy} tools={item.tools.map((tool) =>
(tool.state === 'pending' || tool.state === 'running') &&
subagent.state !== 'queued' && subagent.state !== 'running'
? { ...tool, state: subagent.state === 'cancelled' ? 'cancelled' : 'interrupted' }
@@ -403,10 +445,12 @@ const SubagentStatusCard = memo(function SubagentStatusCard({
function SubagentStatusList({
renderHtml,
subagents
subagents,
onCopy
}: {
renderHtml: boolean
subagents: SubagentActivity[]
onCopy: CopyContent
}): React.JSX.Element {
const { t } = useTranslation('app')
@@ -420,6 +464,7 @@ function SubagentStatusList({
key={subagent.childTaskId}
renderHtml={renderHtml}
subagent={subagent}
onCopy={onCopy}
/>
))}
</section>
@@ -435,7 +480,7 @@ type ChatMessageRowProps = {
message: Message
renderAssistantHtml?: boolean
onArticleRef: (messageId: string, element: HTMLElement | null) => void
onCopyMessage: (content: string) => Promise<void>
onCopyMessage: CopyContent
onDownloadImage: (item: ImageViewerItem) => void
onOpenCitationContext: (
reference: KnowledgeSearchReference
@@ -665,9 +710,10 @@ function ChatMessageRowView({
<div className="message-blocks">
{groupMessageBlocks(message.blocks).map((item) =>
item.kind === 'tools' ? (
<ToolExecutionList key={item.id} tools={item.tools} />
<ToolExecutionList key={item.id} tools={item.tools} onCopy={onCopyMessage} />
) : item.kind === 'subagents' ? (
<SubagentStatusList
onCopy={onCopyMessage}
key={item.id}
renderHtml={renderAssistantHtml}
subagents={item.childTaskIds.flatMap((childTaskId) => {
@@ -725,6 +771,7 @@ function ChatMessageRowView({
)}
{unorderedSubagents && unorderedSubagents.length > 0 && (
<SubagentStatusList
onCopy={onCopyMessage}
renderHtml={renderAssistantHtml}
subagents={unorderedSubagents}
/>
@@ -929,7 +976,7 @@ function ChatMessageRowView({
{(!message.blocks || message.blocks.length === 0) &&
message.tools &&
message.tools.length > 0 && (
<ToolExecutionList tools={message.tools} />
<ToolExecutionList tools={message.tools} onCopy={onCopyMessage} />
)}
{message.approval && (
<div className="approval-card">
@@ -1187,7 +1234,7 @@ type ChatTimelineProps = {
messageStartIndex: number
messages: Message[]
onArticleRef: (messageId: string, element: HTMLElement | null) => void
onCopyMessage: (content: string) => Promise<void>
onCopyMessage: CopyContent
onDownloadImage: (item: ImageViewerItem) => void
onOpenCitationContext: (
reference: KnowledgeSearchReference
@@ -368,6 +368,12 @@ export const app = {
output: 'Result',
error: 'Error details',
noDetails: 'No execution details are available yet.',
waitingStart: 'Waiting to start; no result yet.',
waitingOutput: 'Running; no result yet.',
emptyOutput: 'Completed with no returned content.',
copyAction: 'Copy',
copy: 'Copy {{label}}',
copied: 'Tool details copied',
states: {
pending: 'Pending',
running: 'Running',
@@ -350,6 +350,12 @@ export const app = {
output: '执行结果',
error: '错误详情',
noDetails: '暂时没有可显示的执行详情。',
waitingStart: '等待执行,尚无结果。',
waitingOutput: '执行中,尚无结果。',
emptyOutput: '执行完成,无返回内容。',
copyAction: '复制',
copy: '复制{{label}}',
copied: '已复制工具详情',
states: {
pending: '等待中',
running: '进行中',
+141 -35
View File
@@ -6163,53 +6163,35 @@ button > svg {
.tool-execution-list {
display: grid;
grid-template-columns: minmax(0, 1fr);
min-width: 0;
margin-top: var(--space-2);
overflow: hidden;
border: 1px solid var(--border-default);
border-radius: var(--radius-card);
background: var(--surface-subtle);
color: var(--text-secondary);
}
.tool-execution-list__header {
display: flex;
align-items: center;
min-height: 32px;
padding: var(--space-2) var(--space-3);
gap: var(--space-2);
}
.tool-execution-list__header strong {
color: var(--text-primary);
font-size: var(--font-body);
}
.tool-execution-list__header small {
margin-left: auto;
color: var(--text-muted);
font-size: var(--font-caption);
}
.tool-execution-list > ol {
display: grid;
grid-template-columns: minmax(0, 1fr);
padding: 0;
border-top: 1px solid var(--border-subtle);
margin: 0;
list-style: none;
}
.tool-execution-list > ol > li + li {
border-top: 1px solid var(--border-subtle);
.tool-execution-list > ol > li {
min-width: 0;
}
.tool-execution > summary {
display: flex;
align-items: center;
width: fit-content;
max-width: 100%;
min-height: 36px;
padding: var(--space-2) var(--space-3);
padding: var(--space-2);
border-radius: var(--radius-control);
cursor: pointer;
gap: var(--space-3);
list-style-position: inside;
gap: var(--space-2);
list-style: none;
transition: background var(--motion-fast) ease-out;
}
@@ -6223,21 +6205,36 @@ button > svg {
}
.tool-execution__identity {
display: grid;
display: flex;
align-items: center;
flex: 1;
min-width: 0;
gap: var(--space-1);
gap: var(--space-2);
}
.tool-execution__identity strong {
flex: 0 1 auto;
max-width: 60%;
color: var(--text-primary);
font-family: var(--font-family-mono);
font-size: var(--font-body);
overflow-wrap: anywhere;
font-weight: 500;
}
.tool-execution__identity strong:only-child {
max-width: 100%;
}
.tool-execution__identity > *,
.tool-execution__error-preview {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tool-execution__identity > span {
color: var(--text-muted);
flex: 1;
color: var(--text-secondary);
font-size: var(--font-caption);
}
@@ -6254,12 +6251,20 @@ button > svg {
.tool-execution__details {
display: grid;
padding: 0 var(--space-3) var(--space-3);
grid-template-columns: minmax(0, 1fr);
min-width: 0;
margin: var(--space-1) 0 var(--space-2);
padding: var(--space-3);
border-left: 2px solid var(--border-subtle);
border-radius: var(--radius-control);
background: var(--surface-subtle);
gap: var(--space-3);
}
.tool-execution__details section {
display: grid;
grid-template-columns: minmax(0, 1fr);
min-width: 0;
gap: var(--space-2);
}
@@ -17530,6 +17535,107 @@ details.settings-section > :not(summary) + :not(summary) {
min-height: min(80px, 50%);
max-height: max(50%, calc(100% - 80px));
}
.tool-execution > summary::-webkit-details-marker {
display: none;
}
.tool-execution__status {
display: inline-flex;
flex: none;
align-items: center;
gap: var(--space-1);
}
.tool-execution--running > summary small {
color: var(--accent);
}
.tool-execution--running .tool-execution__status svg {
animation: subagent-status-spin 1s linear infinite;
}
.tool-execution__chevron {
flex: none;
transition: transform var(--motion-fast) ease-out;
}
.tool-execution[open] > summary .tool-execution__chevron {
transform: rotate(90deg);
}
.tool-execution__error-preview {
margin: 0;
padding: 0 var(--space-3) var(--space-2);
color: var(--danger);
font-size: var(--font-caption);
}
.tool-execution[open] + .tool-execution__error-preview {
display: none;
}
.tool-execution__full-summary {
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.tool-execution__input > summary {
padding: var(--space-2) 0;
cursor: pointer;
font-size: var(--font-caption);
}
.tool-detail__toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-1);
}
.tool-detail__toolbar strong {
margin-right: auto;
}
.tool-detail__toolbar button {
display: inline-flex;
align-items: center;
min-height: 28px;
padding: var(--space-1) var(--space-2);
border: 1px solid transparent;
border-radius: var(--radius-control);
background: transparent;
color: var(--text-secondary);
font-size: var(--font-caption);
gap: var(--space-1);
cursor: pointer;
}
.tool-detail__toolbar button:hover {
background: var(--accent-subtle);
color: var(--accent);
}
.tool-detail__toolbar button:focus-visible,
.tool-execution__input > summary:focus-visible,
.tool-detail pre:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.tool-execution--completed .tool-execution__status {
color: var(--success);
}
@media (prefers-reduced-motion: reduce) {
.tool-execution--running .tool-execution__status svg {
animation: none;
}
.tool-execution__chevron {
transition: none;
}
}
.workspace-git__history { display: flex; flex-direction: column; flex: 1; min-height: 0; }
.workspace-git__history[hidden] { display: none; }
.workspace-git__history-dock > [role="separator"] { height: var(--space-2); flex: 0 0 auto; cursor: ns-resize; touch-action: none; }
+8
View File
@@ -7,6 +7,14 @@ if (typeof Element !== 'undefined') {
Element.prototype.scrollTo = vi.fn()
}
if (typeof ResizeObserver === 'undefined') {
globalThis.ResizeObserver = class {
observe = vi.fn()
unobserve = vi.fn()
disconnect = vi.fn()
}
}
beforeEach(async () => {
clearSshHostRemoteEnvironmentCache()
if (typeof localStorage === 'undefined' || typeof document === 'undefined') {