fix(web): hide session lineage in header

This commit is contained in:
kingwl
2026-07-31 21:08:57 +08:00
parent 5c4b701afe
commit 07b0efc49e
32 changed files with 46 additions and 155 deletions

View File

@@ -496,7 +496,7 @@ export async function seedSession(scaffold: WebScaffold, fixtureText: string, id
* volatility collapse to stable tokens.
*/
function normalizeAria(snapshot: string, workspaceCwd: string): string {
// The header breadcrumb renders the workspace's basename, not the full
// The session heading renders the workspace's basename, not the full
// path, so both spellings must collapse to the token.
const base = workspaceCwd.split('/').pop()!
return snapshot

View File

@@ -1,6 +1,5 @@
- banner:
- navigation "Session hierarchy":
- 'button "Using ONE run_code program: run" [disabled]'
- 'heading "Using ONE run_code program: run" [level=1]'
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"

View File

@@ -1,6 +1,5 @@
- banner:
- navigation "Session hierarchy":
- button "Use only Cordis tools. First" [disabled]
- heading "Use only Cordis tools. First" [level=1]
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"

View File

@@ -1,6 +1,5 @@
- banner:
- navigation "Session hierarchy":
- button "Use the bash tool to" [disabled]
- heading "Use the bash tool to" [level=1]
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"

View File

@@ -1,6 +1,5 @@
- banner:
- navigation "Session hierarchy":
- button "Reply with the single word" [disabled]
- heading "Reply with the single word" [level=1]
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"

View File

@@ -1,6 +1,5 @@
- banner:
- navigation "Session hierarchy":
- button "Reply with a one-sentence description" [disabled]
- heading "Reply with a one-sentence description" [level=1]
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"

View File

@@ -1,6 +1,5 @@
- banner:
- navigation "Session hierarchy":
- button "Reply with a one-sentence description" [disabled]
- heading "Reply with a one-sentence description" [level=1]
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"

View File

@@ -1,6 +1,5 @@
- banner:
- navigation "Session hierarchy":
- button "Reply with a one-sentence description" [disabled]
- heading "Reply with a one-sentence description" [level=1]
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"

View File

@@ -1,6 +1,5 @@
- banner:
- navigation "Session hierarchy":
- button "Reply with a one-sentence description" [disabled]
- heading "Reply with a one-sentence description" [level=1]
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"

View File

@@ -1,6 +1,5 @@
- banner:
- navigation "Session hierarchy":
- button "Use the read tool twice" [disabled]
- heading "Use the read tool twice" [level=1]
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"

View File

@@ -1,6 +1,5 @@
- banner:
- navigation "Session hierarchy":
- 'button "Plan a small change: add" [disabled]'
- 'heading "Plan a small change: add" [level=1]'
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"

View File

@@ -1,6 +1,5 @@
- banner:
- navigation "Session hierarchy":
- button "Use the ask_user_question tool to" [disabled]
- heading "Use the ask_user_question tool to" [level=1]
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"

View File

@@ -1,6 +1,5 @@
- banner:
- navigation "Session hierarchy":
- button "Reply with a one-sentence description" [disabled]
- heading "Reply with a one-sentence description" [level=1]
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"

View File

@@ -1,6 +1,5 @@
- banner:
- navigation "Session hierarchy":
- button "Reply with a one-sentence description" [disabled]
- heading "Reply with a one-sentence description" [level=1]
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"

View File

@@ -1,6 +1,5 @@
- banner:
- navigation "Session hierarchy":
- button "Reply with a one-sentence description" [disabled]
- heading "Reply with a one-sentence description" [level=1]
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"

View File

@@ -1,6 +1,5 @@
- banner:
- navigation "Session hierarchy":
- button "Use the read tool twice" [disabled]
- heading "Use the read tool twice" [level=1]
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"

View File

@@ -1,6 +1,5 @@
- banner:
- navigation "Session hierarchy":
- button "Use the read tool twice" [disabled]
- heading "Use the read tool twice" [level=1]
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"

View File

@@ -1,6 +1,5 @@
- banner:
- navigation "Session hierarchy":
- button "Use the ask_user_question tool to" [disabled]
- heading "Use the ask_user_question tool to" [level=1]
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"

View File

@@ -1,6 +1,5 @@
- banner:
- navigation "Session hierarchy":
- button "Use the ask_user_question tool to" [disabled]
- heading "Use the ask_user_question tool to" [level=1]
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"

View File

@@ -1,6 +1,5 @@
- banner:
- navigation "Session hierarchy":
- button "Use web_search to search exactly" [disabled]
- heading "Use web_search to search exactly" [level=1]
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"

View File

@@ -2,5 +2,5 @@
# 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:
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
README.md: 68115f8f9b9225d1f6b0e9cc93394d042c2befaa
README.zh.md: 65a3b334ff316a37dfb8506396eae8c17cccb40d
README.md: abbc20aba27fbd8af911d810ad7976d29cb8ad4f
README.zh.md: 600b06140c342bca984787c42eca66f601904a65

View File

@@ -6,7 +6,7 @@ Conversation domain: skeleton (header/tabs/composer/empty state), chat view (gro
Compaction renders as one collapsed row at the checkpoint's flow position without replacing the transcript above it. The disclosure renders the checkpoint's `compact/summary` provenance; when that event is outside the loaded window, the row remains visible but non-expandable. The framed checkpoint payload is model-facing and never renders.
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 shows only the current session title and view tabs as ordinary column chrome; fork lineage remains session data and is not projected into the header. 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 view ring IS a slot: the conversation registration declares the `'conversation.view'` list slot (session scope) in its `children` table, ConversationRoot renders the active entry through its renderSlot share (`only: <active id>`), and view tabs project from the ring ledger's registration options (`id`/`order`/`label`). The chat view is this package's own ring entry; other plugins (ui-trajectory) contribute tabs through plain `ctx.slots.register` — the former package-local view registry (`registerView`/`ViewEntry`/`ConversationViewMap` and the chrome attachment table) is retired, with per-view chrome dissolved into the view components themselves.

View File

@@ -6,7 +6,7 @@
压缩compaction在检查点自身的消息流位置渲染为一行折叠标记不替换其上方的 transcript文本记录。展开内容来自检查点溯源的 `compact/summary`;该事件位于已加载窗口之外时,标记仍然可见但不可展开。面向模型的带框检查点载荷绝不渲染。
常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会渲染禁用输入栏;其根作用域的 `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仅显示当前会话标题和视图标签fork 谱系仍保留为会话数据,不投影到标题栏。其下滚动容器(`data-conversation-scroll`)承载流动排版的各视图与 sticky 编辑器栈(统计 dock输入区 dock输入栏。textarea 上的滚轮会链式处理:限高草稿先在本地滚动,到达边缘后再转交给该宿主。
视图环本身就是 slot会话注册声明 `'conversation.view'` 列表 slotSession scope并将其列在 `children` 表中ConversationRoot 通过 renderSlot share 渲染活跃配置项(`only: <active id>`);视图标签页从环账本的注册选项(`id``order``label`投影而来。聊天视图是该包package自身的环配置项其他插件ui-trajectory通过普通的 `ctx.slots.register` 贡献标签页。先前包内的视图注册表(`registerView``ViewEntry``ConversationViewMap` 及 chrome 附加表)已退役,逐视图 chrome 则被拆入视图组件自身。

View File

@@ -165,7 +165,6 @@ export function apply(ctx: Context): void {
// the resident parent keeps Hero and composer layout identity stable.
slots.register({
name: 'conversation.session',
locale: NS,
children: { 'conversation.view': { kind: 'list', scope: 'session' } },
store: chatStore,
inject: (sessionId: SessionId, _actions: BoundActions<typeof chatStore>): ConversationSessionInjected => ({
@@ -175,7 +174,6 @@ export function apply(ctx: Context): void {
version: () => slots.getVersion('conversation.view'),
},
bindDraftMirror: write => inputHub.shell(sessionId).bindMirror(write),
open: (id) => { sessions.open(id) },
}),
}, ConversationSession)

View File

@@ -3,7 +3,7 @@ import type { ReactNode, RefObject } from 'react'
import type {
InjectFace, MaybeSnapshotSelectorHook, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook,
} from '@deepseek-ai/dsh-client-ui-slots'
import type { CommandNode, ConversationSnapshot, ObservableSnapshot, PendingInteraction, PendingWait, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
import type { CommandNode, ConversationSnapshot, ObservableSnapshot, PendingInteraction, PendingWait, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
import type { ComposerKeyboard, EditSelection, InputActions, InputNotice, InputState } from '../input/contract.ts'
import type { createChatStore } from '../stores.ts'
@@ -244,8 +244,6 @@ export interface ConversationSessionInjected {
}
/** Bind the input machine's draft persistence mirror to the session store. */
bindDraftMirror: (write: (text: string) => void) => () => void
/** Select a real Session through the runtime navigation owner. */
open: (sessionId: SessionId) => void
}
/**
@@ -355,7 +353,6 @@ export type ConversationSessionSlotProps =
& PropsRenderSlots<'conversation.view'>
& PropsStore<ChatStore>
& ConversationSessionInjected
& PropsLocale<'conversation'>
/** The pending approval carrier the owner dispatches into the composer chain. */
export type ApprovalWait = PendingWait<'approval'>

View File

@@ -30,7 +30,6 @@ export const zh = {
'access.confirm.enable': '启用 Full access',
'hero.headline': '开始构建吧',
'hero.chooseWorkspace': '选择工作区',
'session.hierarchy': '会话层级',
'details.title': '详情',
'details.close': '关闭详情',
'details.empty': '点击消息流中的工具行查看详情',
@@ -130,7 +129,6 @@ export const en = {
'access.confirm.enable': 'Enable Full access',
'hero.headline': 'Let\'s start building',
'hero.chooseWorkspace': 'Choose workspace',
'session.hierarchy': 'Session hierarchy',
'details.title': 'Details',
'details.close': 'Close details',
'details.empty': 'Click a tool row in the message flow to view its details',

View File

@@ -1,4 +1,4 @@
/* Conversation column skeleton: header (breadcrumb row + tabs) over the view
/* Conversation column skeleton: header (session title + tabs) over the view
area, composer InputBar at the bottom. Column width/squeeze is layout's;
this fills its cell. Figma: Header 39:27730 (83px two-row), tabs 13px with
a 3px active bar. */
@@ -23,59 +23,24 @@
display: none;
}
.crumbRow {
.titleRow {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 32px;
}
.crumbs {
display: flex;
align-items: center;
gap: 4px;
.sessionTitle {
min-width: 0;
max-width: 100%;
overflow: hidden;
white-space: nowrap;
}
.crumbSeg {
display: inline-flex;
align-items: center;
gap: 4px;
min-width: 0;
}
.crumbSep {
/* figma: "/" separators are 14px caption gray (75:7903), one tint lighter than crumb text. */
color: var(--dsw-alias-label-caption);
font-size: 14px;
line-height: 20px;
}
.crumb {
max-width: 220px;
overflow: hidden;
margin: 0;
padding: 4px 8px;
border: none;
border-radius: 12px;
background: transparent;
font-size: 14px;
line-height: 20px;
color: var(--dsw-alias-label-tertiary);
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
}
.crumb:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
}
.crumbCurrent {
font-weight: 500;
color: var(--dsw-alias-label-primary);
cursor: default;
text-overflow: ellipsis;
white-space: nowrap;
}
/* figma Tab_Group 34:11441: 35px strip, gap 36, pad-left 8, tabs bottom-aligned. */

View File

@@ -2,35 +2,21 @@
import { useEffect, useSyncExternalStore, type ReactNode } from 'react'
import clsx from 'clsx'
import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
import type { ConversationSessionSlotProps } from '../contract/slots.ts'
import css from './ConversationRoot.module.css'
/** Full props composed from the strict session slot contract. */
export type ConversationSessionProps = ConversationSessionSlotProps
function deriveAncestry(list: SessionListState, id: SessionId): readonly SessionSummary[] {
const chain: SessionSummary[] = []
let cursor: SessionId | undefined = id
while (cursor !== undefined) {
const summary: SessionSummary | undefined = list.byId[cursor]
if (summary === undefined || chain.includes(summary)) break
chain.unshift(summary)
cursor = summary.parentId
}
return chain
}
export function ConversationSession({
sessionId, useSession, useSessions, useInput, inputActions, useStore, actions,
renderSlot, views, bindDraftMirror, open, wrapActiveBody, t,
renderSlot, views, bindDraftMirror, wrapActiveBody,
}: ConversationSessionProps) {
useSyncExternalStore(views.subscribe, views.version)
const tabs = views.list()
const activeId = useStore(s => s.view) ?? 'chat'
const active = tabs.find(view => view.id === activeId) ?? tabs[0]
const ancestry = useSessions(s => deriveAncestry(s, sessionId), shallowEqual)
const title = useSessions(s => s.byId[sessionId]?.displayTitle ?? sessionId)
const composerPhase = useSession(s => s.composerPhase)
const blank = useSession(s => s.blank)
const inputState = useInput(s => s)
@@ -69,26 +55,8 @@ export function ConversationSession({
>
{!hideChrome && (
<>
<div className={css.crumbRow}>
<nav className={css.crumbs} aria-label={t('session.hierarchy')}>
{ancestry.map((summary, index) => {
const last = index === ancestry.length - 1
return (
<span key={summary.id} className={css.crumbSeg}>
{index > 0 && <span className={css.crumbSep}>/</span>}
<button
type="button"
className={clsx(css.crumb, last && css.crumbCurrent)}
disabled={last}
onClick={() => { open(summary.id) }}
>
{summary.displayTitle}
</button>
</span>
)
})}
{ancestry.length === 0 && <span className={css.crumbCurrent}>{sessionId}</span>}
</nav>
<div className={css.titleRow}>
<h1 className={css.sessionTitle}>{title}</h1>
</div>
{tabs.length > 1 && (
<div className={css.tabs} role="tablist">

View File

@@ -228,12 +228,9 @@ describe('conversation slot inject surface', () => {
await b.runtime.dispose()
})
it('routes navigation and workspace switching through the runtime owners, carrying the draft', async () => {
it('routes workspace switching through the runtime owner, carrying the draft', async () => {
const b = await bench()
const { injected } = b.conversationSurface(ROOT)
const resident = b.residentSurface(ROOT)
injected.open(ROOT)
expect(b.runtime.sessions.calls).toContainEqual({ method: 'open', args: [ROOT] })
// Same-session connect (the picked workspace resolves to this session):
// no draft movement, plain re-open.
b.runtime.workspaces.stub('connectWorkspace', () => Promise.resolve(ROOT))
@@ -241,7 +238,7 @@ describe('conversation slot inject surface', () => {
actions.setDraft('carry me')
void resident.selectWorkspace('workspace-1' as never)
await vi.waitFor(() => {
expect(b.runtime.sessions.calls.filter(c => c.method === 'open')).toHaveLength(2)
expect(b.runtime.sessions.calls.filter(c => c.method === 'open')).toHaveLength(1)
})
expect(b.runtime.workspaces.calls).toContainEqual({ method: 'connectWorkspace', args: ['workspace-1'] })
expect(state.getSnapshot().draft).toBe('carry me')

View File

@@ -20,7 +20,7 @@
* suite only proves the assembled wiring.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, waitFor, within } from '@testing-library/react'
import { cleanup, fireEvent, waitFor } from '@testing-library/react'
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
import type { ISession, SessionId, TodoItem, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
@@ -256,16 +256,14 @@ describe('prompt rejection through the assembled composer', () => {
})
describe('title projection across assembled surfaces', () => {
it('one summary update re-labels the breadcrumb and document.title consumers together', async () => {
it('one summary update re-labels the current-session heading', async () => {
const runtime = await bench([])
const view = runtime.renderRoot()
// The strict session header breadcrumb reads useSessions ancestry.
const crumb = within(view.container.querySelector('[aria-label="会话层级"]') as HTMLElement)
expect(crumb.getByText('S')).toBeTruthy()
expect(view.getByRole('heading', { name: 'S', level: 1 })).toBeTruthy()
await runtime.sessions.updateSummary(SID, { displayTitle: '修订标题', title: '修订标题' })
await waitFor(() => { expect(crumb.getByText('修订标题')).toBeTruthy() })
expect(crumb.queryByText('S')).toBeNull()
await waitFor(() => { expect(view.getByRole('heading', { name: '修订标题', level: 1 })).toBeTruthy() })
expect(view.queryByRole('heading', { name: 'S', level: 1 })).toBeNull()
await runtime.dispose()
})
})

View File

@@ -111,7 +111,6 @@ function mount(
const useInput = bindSnapshotSelector(wiring.state)
const inputActions = wiring.actions
const stop = vi.fn()
const open = vi.fn()
const slotCalls: string[] = []
let pickerOwner: unknown
const renderSlot = ((key: string, owner: object, opts?: { only?: string }) => {
@@ -140,8 +139,6 @@ function mount(
version: () => 1,
}}
bindDraftMirror={write => wiring.bindMirror(write)}
open={open}
t={t}
{...owner}
/>
)
@@ -203,7 +200,7 @@ function mount(
}
const view = render(<ConversationRoot {...props} />)
return {
view, chat, sink, open, retargetWorkspace, session, slotCalls,
view, chat, sink, retargetWorkspace, session, slotCalls,
pickerOwner: () => pickerOwner,
rerender: () => { view.rerender(<ConversationRoot {...props} />) },
}
@@ -218,8 +215,8 @@ describe('ConversationRoot resident composer', () => {
expect(b.chat.store.getSnapshot().draft).toBe('ordinary revised')
fireEvent.keyDown(box, { key: 'Enter' })
expect(b.sink).toHaveBeenCalledWith('ordinary revised')
fireEvent.click(b.view.getByRole('button', { name: 'Root' }))
expect(b.open).toHaveBeenCalledWith(sid('root'))
expect(b.view.getByRole('heading', { name: 'Child', level: 1 })).toBeTruthy()
expect(b.view.queryByText('Root')).toBeNull()
})
it('active phase: fixed header outside the scrollport; sticky composer seat inside it', () => {

View File

@@ -23,7 +23,6 @@ import type {
import type { ConvViewProps, ViewTab } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { ConversationSession, type ConversationSessionProps } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/ConversationSession.tsx'
import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/client/stores.ts'
import { zh as conversationZh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-trajectory/client'
import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-trajectory'
import type { TrajectoryTurnModel } from '../src/client/layout.ts'
@@ -36,11 +35,6 @@ import { deriveTrajectoryTimeline } from '../src/client/timeline.ts'
const SID = 's1' as SessionId
// Stub of the conversation package's standard locale seat (this spec mounts
// its ConversationSession chrome); answers from the zh dictionary and falls
// back to the key like the real chain.
const tConversation: ConversationSessionProps['t'] =
key => (conversationZh as Record<string, string>)[key] ?? key
afterEach(cleanup)
// The chat store persists under its declared key; clear so one case's active
// view cannot rehydrate into the next.
@@ -219,7 +213,6 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES
return render(
<ConversationSession
sessionId={SID}
t={tConversation}
SessionProvider={({ children }) => children(SID)}
useSession={useSession}
useSessions={emptySessions()}
@@ -236,7 +229,6 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES
useInput={bindSnapshotSelector(createSnapshotStore({ draft: '', draftRev: 0, phase: 'plain', queue: [] })) as never}
inputActions={{ setDraft: vi.fn(), submit: vi.fn() }}
bindDraftMirror={() => () => {}}
open={vi.fn()}
/>,
)
}