Merge pull request #997 from deepseek-harness/worktree/chat-ui-polish
fix(web): polish chat activity and queue states
This commit is contained in:
@@ -27,11 +27,12 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor
|
|||||||
|
|
||||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/live-interactions', import.meta.url))
|
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/live-interactions', import.meta.url))
|
||||||
const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
|
const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
|
||||||
// One golden per interactive end-state: what the user is left looking at
|
// One golden pins the stable mid-turn loading state; the other three capture
|
||||||
// after cancel, after a non-retryable failure (pins the FIXME(web-error-surface)
|
// what the user is left looking at after cancel, after a non-retryable failure
|
||||||
// gap as a reviewable artifact: NO error copy in the tree), and after retry
|
// (pins the FIXME(web-error-surface) gap as a reviewable artifact: NO error
|
||||||
// recovery — three genuinely different terminal surfaces of one fixture.
|
// copy in the tree), and after retry recovery.
|
||||||
const CANCEL_EXPECTED = join(SNAPSHOT_DIR, 'cancel.expected.md')
|
const CANCEL_EXPECTED = join(SNAPSHOT_DIR, 'cancel.expected.md')
|
||||||
|
const LOADING_EXPECTED = join(SNAPSHOT_DIR, 'loading.expected.md')
|
||||||
const ERROR_EXPECTED = join(SNAPSHOT_DIR, 'error-auth.expected.md')
|
const ERROR_EXPECTED = join(SNAPSHOT_DIR, 'error-auth.expected.md')
|
||||||
const RETRY_EXPECTED = join(SNAPSHOT_DIR, 'retry.expected.md')
|
const RETRY_EXPECTED = join(SNAPSHOT_DIR, 'retry.expected.md')
|
||||||
const MODE = webSnapshotMode()
|
const MODE = webSnapshotMode()
|
||||||
@@ -133,6 +134,12 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
|
|||||||
// The marker IS the synchronization: the stream is provably parked in the
|
// The marker IS the synchronization: the stream is provably parked in the
|
||||||
// hang (prefix chunks delivered to the loop) before the stop click.
|
// hang (prefix chunks delivered to the loop) before the stop click.
|
||||||
await expect.poll(() => existsSync(marker), { timeout: 15_000 }).toBe(true)
|
await expect.poll(() => existsSync(marker), { timeout: 15_000 }).toBe(true)
|
||||||
|
await expect.poll(
|
||||||
|
() => page.getByRole('status').filter({ hasText: 'Deep diving...' }).isVisible(),
|
||||||
|
{ timeout: 10_000 },
|
||||||
|
).toBe(true)
|
||||||
|
const loadingSnapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd)
|
||||||
|
await compareOrRefreshGolden(LOADING_EXPECTED, loadingSnapshot, MODE)
|
||||||
await page.getByRole('button', { name: 'Stop generating' }).click()
|
await page.getByRole('button', { name: 'Stop generating' }).click()
|
||||||
await settled
|
await settled
|
||||||
expect(turnEndReasons(sessionEvents).at(-1)).toBe('aborted')
|
expect(turnEndReasons(sessionEvents).at(-1)).toBe('aborted')
|
||||||
@@ -231,7 +238,7 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
|
|||||||
|
|
||||||
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
|
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
|
||||||
await assertFixtureInventory(SNAPSHOT_DIR, [
|
await assertFixtureInventory(SNAPSHOT_DIR, [
|
||||||
'session.jsonl', 'cancel.expected.md', 'error-auth.expected.md', 'retry.expected.md',
|
'session.jsonl', 'cancel.expected.md', 'loading.expected.md', 'error-auth.expected.md', 'retry.expected.md',
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
- banner:
|
||||||
|
- navigation "Session hierarchy":
|
||||||
|
- button "Reply with a one-sentence description" [disabled]
|
||||||
|
- tablist:
|
||||||
|
- tab "Chat" [selected]
|
||||||
|
- tab "Trajectory"
|
||||||
|
- text: Reply with a one-sentence description of event sourcing, then stop. {{clock}}
|
||||||
|
- button "Copy":
|
||||||
|
- img
|
||||||
|
- button "Branch into a new conversation":
|
||||||
|
- img
|
||||||
|
- button "Edit":
|
||||||
|
- img
|
||||||
|
- paragraph: partial
|
||||||
|
- status: Deep diving...
|
||||||
|
- textbox "Message the agent"
|
||||||
|
- button "Commands":
|
||||||
|
- img
|
||||||
|
- 'button "Access mode, current: Full access"': Full access
|
||||||
|
- button "Select model, current DeepSeek-V4-Flash":
|
||||||
|
- text: DeepSeek-V4-Flash
|
||||||
|
- img
|
||||||
|
- button "Stop generating"
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
- button "Edit":
|
- button "Edit":
|
||||||
- img
|
- img
|
||||||
- paragraph: partial
|
- paragraph: partial
|
||||||
|
- status: Deep diving...
|
||||||
- button "2 queued messages"
|
- button "2 queued messages"
|
||||||
- textbox "Message the agent"
|
- textbox "Message the agent"
|
||||||
- button "Commands":
|
- button "Commands":
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
- button "Edit":
|
- button "Edit":
|
||||||
- img
|
- img
|
||||||
- paragraph: partial
|
- paragraph: partial
|
||||||
|
- status: Deep diving...
|
||||||
- button "2 queued messages" [disabled] [expanded]
|
- button "2 queued messages" [disabled] [expanded]
|
||||||
- list:
|
- list:
|
||||||
- listitem:
|
- listitem:
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
- button "Edit":
|
- button "Edit":
|
||||||
- img
|
- img
|
||||||
- paragraph: partial
|
- paragraph: partial
|
||||||
|
- status: Deep diving...
|
||||||
- list:
|
- list:
|
||||||
- listitem:
|
- listitem:
|
||||||
- text: Edited queue item
|
- text: Edited queue item
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
- img
|
- img
|
||||||
- img
|
- img
|
||||||
- text: Ask question waiting
|
- text: Ask question waiting
|
||||||
|
- status: Deep diving...
|
||||||
- region "Ready to continue?":
|
- region "Ready to continue?":
|
||||||
- text: Checkpoint
|
- text: Checkpoint
|
||||||
- heading "Ready to continue?" [level=2]
|
- heading "Ready to continue?" [level=2]
|
||||||
|
|||||||
@@ -2,5 +2,5 @@
|
|||||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||||
# after editing either side, bring the other along and re-record with:
|
# after editing either side, bring the other along and re-record with:
|
||||||
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
|
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
|
||||||
README.md: 7c6e36409efd5f2f9224e85a9cbd3a5e515833c1
|
README.md: b183aab56a10c3a22eeab31f8b1b0e6b5219beba
|
||||||
README.zh.md: 7661826153bc44ff47a660fa49a4ffd902d93bdd
|
README.zh.md: af6427774a048de324d57bc9856f77547e9a03cc
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
English | [中文](README.zh.md)
|
English | [中文](README.zh.md)
|
||||||
|
|
||||||
Conversation domain: skeleton (header/tabs/composer/empty state), chat view (grouped step-summary flow, streaming tail isolation, per-tool row slot with a bash sample registrant and the todo row), composer dock (session stats sticky with the input), input dock (queue rows plus the todo plan strip), minimal details panel, scope-addressed ConversationService. Contract: api-contracts v3 §7 plus the slot terminal design (store seat / props shares).
|
Conversation domain: skeleton (header/tabs/composer/empty state), chat view (grouped step-summary flow, streaming tail isolation, an animated left-to-right gradient `Deep diving...` turn status, per-tool row slot with a bash sample registrant and the todo row), composer dock (session stats sticky with the input), input dock (hairline-separated queue rows plus the todo plan strip), minimal details panel, scope-addressed ConversationService. Contract: api-contracts v3 §7 plus the slot terminal design (store seat / props shares).
|
||||||
|
|
||||||
The resident conversation shell survives no-session and session transitions. Without a current session it renders a disabled input bar; its root-scoped `conversation.hero.workspace` slot hosts the Workspace picker. Selecting a Workspace connects or reuses its Host-owned blank session and opens that session without replacing the shell. Blank sessions render the same composer body as active sessions, while the InputHub carries drafts across Workspace switches and mirrors them into the session store. In the active phase the session header occupies the top as ordinary column chrome; beneath it a scrollport (`data-conversation-scroll`) holds the flowing views and the sticky composer stack (stats dock + input docks + bar). Wheel over the textarea chains: the capped draft scrolls locally until its edge, then forwards to that host.
|
The resident conversation shell survives no-session and session transitions. Without a current session it renders a disabled input bar; its root-scoped `conversation.hero.workspace` slot hosts the Workspace picker. Selecting a Workspace connects or reuses its Host-owned blank session and opens that session without replacing the shell. Blank sessions render the same composer body as active sessions, while the InputHub carries drafts across Workspace switches and mirrors them into the session store. In the active phase the session header occupies the top as ordinary column chrome; beneath it a scrollport (`data-conversation-scroll`) holds the flowing views and the sticky composer stack (stats dock + input docks + bar). Wheel over the textarea chains: the capped draft scrolls locally until its edge, then forwards to that host.
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
[English](README.md) | 中文
|
[English](README.md) | 中文
|
||||||
|
|
||||||
会话领域:骨架(标题栏/标签页/编辑器/空状态)、聊天视图(分组步骤摘要流、流式尾部隔离、逐工具行 slot 及一个 bash 示例注册方与 todo 行)、编辑器 dock(与输入区一同 sticky 的会话统计行)、输入区 dock(队列行加 todo 计划条)、最小详情面板、按 scope 寻址的 ConversationService。契约:api-contracts v3 §7 加 slot 终端设计(store seat/props share)。
|
会话领域:骨架(标题栏/标签页/编辑器/空状态)、聊天视图(分组步骤摘要流、流式尾部隔离、带从左到右动态渐变的 `Deep diving...` 轮次状态、逐工具行 slot 及一个 bash 示例注册方与 todo 行)、编辑器 dock(与输入区一同 sticky 的会话统计行)、输入区 dock(带发丝分界线的队列行加 todo 计划条)、最小详情面板、按 scope 寻址的 ConversationService。契约:api-contracts v3 §7 加 slot 终端设计(store seat/props share)。
|
||||||
|
|
||||||
常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会渲染禁用输入栏;其根作用域的 `conversation.hero.workspace` slot 承载 Workspace 选择器。选择 Workspace 会连接或复用由 Host 拥有的空白会话,并在不替换会话壳的情况下打开该会话。空白会话与活跃会话渲染相同的输入区主体;InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store。活跃阶段会话标题栏以普通列 chrome 占据顶部;其下滚动容器(`data-conversation-scroll`)承载流动排版的各视图与 sticky 编辑器栈(统计 dock+输入区 dock+输入栏)。textarea 上的滚轮会链式处理:限高草稿先在本地滚动,到达边缘后再转交给该宿主。
|
常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会渲染禁用输入栏;其根作用域的 `conversation.hero.workspace` slot 承载 Workspace 选择器。选择 Workspace 会连接或复用由 Host 拥有的空白会话,并在不替换会话壳的情况下打开该会话。空白会话与活跃会话渲染相同的输入区主体;InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store。活跃阶段会话标题栏以普通列 chrome 占据顶部;其下滚动容器(`data-conversation-scroll`)承载流动排版的各视图与 sticky 编辑器栈(统计 dock+输入区 dock+输入栏)。textarea 上的滚轮会链式处理:限高草稿先在本地滚动,到达边缘后再转交给该宿主。
|
||||||
|
|
||||||
|
|||||||
@@ -66,33 +66,45 @@
|
|||||||
border-left: 1px solid var(--dsw-alias-border-l2);
|
border-left: 1px solid var(--dsw-alias-border-l2);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Turn loader: one row of four 2.5px pixels (StateDot blue) chasing left to
|
/* Turn activity keeps the former loader's one-line footprint. A pale
|
||||||
right with a stepped trail — flat keyframe holds, no tweening. Phase
|
brand-blue band sweeps from left to right; reduced-motion keeps it static. */
|
||||||
offsets come from per-rect animation-delay (index * -250ms) set inline
|
.turnStatus {
|
||||||
by the component. */
|
|
||||||
.turnDots {
|
|
||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
flex: none;
|
flex: none;
|
||||||
display: flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
/* One message line box: the dots center inside the text line height. */
|
|
||||||
height: 26px;
|
height: 26px;
|
||||||
/* Same pin as StateDot: ongoing blue has no alias token (business-primary
|
font: var(--dsw-font-s-strong-14);
|
||||||
is the 500 step, not this 450). */
|
white-space: nowrap;
|
||||||
color: var(--dsw-static-deepseek-450);
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
var(--dsw-static-deepseek-500) 0%,
|
||||||
|
var(--dsw-static-deepseek-500) 40%,
|
||||||
|
var(--dsw-static-deepseek-200) 50%,
|
||||||
|
var(--dsw-static-deepseek-500) 60%,
|
||||||
|
var(--dsw-static-deepseek-500) 100%
|
||||||
|
);
|
||||||
|
background-position: 100% 0;
|
||||||
|
background-size: 250% 100%;
|
||||||
|
background-clip: text;
|
||||||
|
color: transparent;
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
animation: dsh-turn-status-shimmer 1.8s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.turnDotCell {
|
@keyframes dsh-turn-status-shimmer {
|
||||||
fill: currentColor;
|
to {
|
||||||
opacity: 0.15;
|
background-position: 0 0;
|
||||||
animation: dsh-turn-dots-chase 1s infinite;
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes dsh-turn-dots-chase {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
0%, 24.9% { opacity: 1; }
|
.turnStatus {
|
||||||
25%, 49.9% { opacity: 0.6; }
|
background-position: 0 0;
|
||||||
50%, 74.9% { opacity: 0.35; }
|
background-size: 100% 100%;
|
||||||
75%, 100% { opacity: 0.15; }
|
animation: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.hint {
|
.hint {
|
||||||
|
|||||||
@@ -204,36 +204,11 @@ const CommandRow = memo(function CommandRow({ renderSlot, node, t }: {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
/** Turn loader: one row of four 2.5px pixels (half a notch above the StateDot
|
/** Turn-level model activity label retained across first-token, tool, and streaming phases. */
|
||||||
* 2px cell, same blue) chasing left to right with a stepped trail — flat
|
function TurnStatus() {
|
||||||
* keyframe holds, no tweening, no rotation. Phase offsets come from
|
|
||||||
* per-rect animation-delay. */
|
|
||||||
const LOADER_CELLS = [0, 5, 10, 15] as const
|
|
||||||
|
|
||||||
function TurnDots() {
|
|
||||||
return (
|
return (
|
||||||
/* The wrapper is a 26px line box (message line height) so the loader
|
<div className={css.turnStatus} role="status" aria-live="polite">
|
||||||
occupies one text line and centers the dots inside it. */
|
Deep diving...
|
||||||
<div className={css.turnDots} aria-hidden="true">
|
|
||||||
<svg
|
|
||||||
width="17.5"
|
|
||||||
height="2.5"
|
|
||||||
viewBox="0 0 17.5 2.5"
|
|
||||||
shapeRendering="crispEdges"
|
|
||||||
>
|
|
||||||
{LOADER_CELLS.map((x, index) => (
|
|
||||||
<rect
|
|
||||||
key={x}
|
|
||||||
className={css.turnDotCell}
|
|
||||||
x={x}
|
|
||||||
y="0"
|
|
||||||
width="2.5"
|
|
||||||
height="2.5"
|
|
||||||
/* Negative delay phases the chase so every cell animates from mount. */
|
|
||||||
style={{ animationDelay: `${(index - LOADER_CELLS.length) * 250}ms` }}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -491,7 +466,7 @@ export function ChatView({
|
|||||||
double-render the same wait. */}
|
double-render the same wait. */}
|
||||||
{/* Turn-level loading signal: rides the whole running turn (first-token
|
{/* Turn-level loading signal: rides the whole running turn (first-token
|
||||||
wait, tool execution, streaming) so it never flickers per step. */}
|
wait, tool execution, streaming) so it never flickers per step. */}
|
||||||
{running && <TurnDots />}
|
{running && <TurnStatus />}
|
||||||
</div>
|
</div>
|
||||||
{!atBottom && (
|
{!atBottom && (
|
||||||
<div className={css.toBottomSlot}>
|
<div className={css.toBottomSlot}>
|
||||||
|
|||||||
@@ -107,6 +107,10 @@
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.row + .row {
|
||||||
|
box-shadow: inset 0 1px 0 var(--dsw-alias-border-l1);
|
||||||
|
}
|
||||||
|
|
||||||
.preview,
|
.preview,
|
||||||
.editor {
|
.editor {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { Profiler } from 'react'
|
import { Profiler } from 'react'
|
||||||
import { act, cleanup, fireEvent, render } from '@testing-library/react'
|
import { act, cleanup, fireEvent, render, within } from '@testing-library/react'
|
||||||
import type {
|
import type {
|
||||||
AssistantMessageNode, CommandNode, ConversationNode, ConversationSnapshot,
|
AssistantMessageNode, CommandNode, ConversationNode, ConversationSnapshot,
|
||||||
ModelRetryNode, RunningToolCall, SessionId, SessionListState, ToolResultNode,
|
ModelRetryNode, RunningToolCall, SessionId, SessionListState, ToolResultNode,
|
||||||
@@ -254,16 +254,16 @@ describe('ChatView', () => {
|
|||||||
} as const satisfies ConversationNode
|
} as const satisfies ConversationNode
|
||||||
const h = makeHarness({ nodes: [user(1, 'try'), retryNode], running: true })
|
const h = makeHarness({ nodes: [user(1, 'try'), retryNode], running: true })
|
||||||
const view = render(<h.ChatView {...h.props} />)
|
const view = render(<h.ChatView {...h.props} />)
|
||||||
const disclosure = view.container.querySelector('details')
|
const disclosure = view.container.querySelector('details') as HTMLDetailsElement
|
||||||
expect(disclosure?.dataset.active).toBe('true')
|
expect(disclosure.dataset.active).toBe('true')
|
||||||
expect(view.getByRole('status').textContent).toBe('正在重试模型请求(1/2) · 1s')
|
expect(within(disclosure).getByRole('status').textContent).toBe('正在重试模型请求(1/2) · 1s')
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
h.set({ nodes: [user(1, 'try'), retryNode, nextRetry] })
|
h.set({ nodes: [user(1, 'try'), retryNode, nextRetry] })
|
||||||
})
|
})
|
||||||
expect(view.getAllByRole('status')).toHaveLength(1)
|
expect(within(disclosure).getAllByRole('status')).toHaveLength(1)
|
||||||
expect(view.container.querySelector('details')).toBe(disclosure)
|
expect(view.container.querySelector('details')).toBe(disclosure)
|
||||||
expect(view.getByRole('status').textContent).toBe('正在重试模型请求(2/2) · 1s')
|
expect(within(disclosure).getByRole('status').textContent).toBe('正在重试模型请求(2/2) · 1s')
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
h.set({
|
h.set({
|
||||||
@@ -277,14 +277,15 @@ describe('ChatView', () => {
|
|||||||
running: false,
|
running: false,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
expect(disclosure?.dataset.active).toBeUndefined()
|
expect(disclosure.dataset.active).toBeUndefined()
|
||||||
expect(view.getByRole('status').textContent).toBe('已重试模型请求(2/2) · 1s')
|
expect(within(disclosure).getByRole('status').textContent).toBe('已重试模型请求(2/2) · 1s')
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
h.set({ nodes: [user(1, 'try'), { ...retry(6), retryState: 'cancelled' }], running: true })
|
h.set({ nodes: [user(1, 'try'), { ...retry(6), retryState: 'cancelled' }], running: true })
|
||||||
})
|
})
|
||||||
expect(disclosure?.dataset.active).toBeUndefined()
|
const cancelledDisclosure = view.container.querySelector('details') as HTMLDetailsElement
|
||||||
expect(view.getByRole('status').textContent).toContain('重试已取消')
|
expect(cancelledDisclosure.dataset.active).toBeUndefined()
|
||||||
|
expect(within(cancelledDisclosure).getByRole('status').textContent).toContain('重试已取消')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('the expanded row Inspect pill hands the call id to inspectCall', () => {
|
it('the expanded row Inspect pill hands the call id to inspectCall', () => {
|
||||||
@@ -448,6 +449,7 @@ describe('ChatView', () => {
|
|||||||
const view = render(<h.ChatView {...h.props} />)
|
const view = render(<h.ChatView {...h.props} />)
|
||||||
expect(view.container.querySelector('[data-state="running"]')).not.toBeNull()
|
expect(view.container.querySelector('[data-state="running"]')).not.toBeNull()
|
||||||
expect(view.getByText('cmd-r1')).toBeTruthy()
|
expect(view.getByText('cmd-r1')).toBeTruthy()
|
||||||
|
expect(view.getByRole('status').textContent).toBe('Deep diving...')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('dispatches each tool row through the keyed slot with the tool name as entryKey', () => {
|
it('dispatches each tool row through the keyed slot with the tool name as entryKey', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user