fix(client): keep trajectory history on the session seam
This commit is contained in:
@@ -39,10 +39,8 @@ export type {
|
||||
export type {
|
||||
ConversationPromptSnapshot, RequestInspectionSnapshot, RequestPromptChange, RequestView,
|
||||
} from './sessions/request-inspection.ts'
|
||||
export { inspectRequests } from './sessions/request-inspection.ts'
|
||||
export { projectConversationHistory } from './sessions/fold-adapter.ts'
|
||||
export type { ConversationHistoryProjection } from './sessions/fold-adapter.ts'
|
||||
export type { SessionHistory, SessionHistorySnapshot } from './sessions/history.ts'
|
||||
export type { SessionHistoryInspection } from './sessions/history.ts'
|
||||
export { PendingWait } from './sessions/pending.ts'
|
||||
export type { PendingInteraction, PendingKind, PendingPayloads } from './sessions/pending.ts'
|
||||
export type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { TodoItem } from '@deepseek-ai/dsh-session/types'
|
||||
import type {
|
||||
RpcError, SessionId, ToolCallView, ToolResultView,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SessionHistoryInspection } from './history.ts'
|
||||
import type { PendingInteraction } from './pending.ts'
|
||||
export type { TodoItem }
|
||||
|
||||
@@ -238,6 +239,8 @@ export interface ConversationSnapshot {
|
||||
sessionId: SessionId
|
||||
/** Surface fold product (finalized conversation nodes in surface order). */
|
||||
nodes: readonly ConversationNode[]
|
||||
/** Lazy history-only projections consumed by inspection views. */
|
||||
inspection?: SessionHistoryInspection
|
||||
/** Fold degradation flag (cross-window replace defense): when true, nodes come from the lenient linear scan. */
|
||||
foldDegraded: boolean
|
||||
partial: PartialAssistant | null
|
||||
|
||||
@@ -1,33 +1,46 @@
|
||||
import type {
|
||||
HistoryEntry, RpcError, SessionId,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { ObservableSnapshot } from '../contract/store.ts'
|
||||
import type { OpenState } from './conversation.ts'
|
||||
import type { ToolSchema } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { HistoryEntry } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { ConversationNode } from './conversation.ts'
|
||||
import type { ConversationContext } from './conversation-context.ts'
|
||||
import { projectConversationHistory } from './fold-adapter.ts'
|
||||
import { inspectRequests, type RequestView } from './request-inspection.ts'
|
||||
|
||||
/** Lazily derived inspection data for one immutable session-history window. */
|
||||
export interface SessionHistoryInspection {
|
||||
eventNodes: readonly ConversationNode[]
|
||||
contexts: readonly ConversationContext[]
|
||||
requests: readonly RequestView[]
|
||||
callSchemas: ReadonlyMap<string, ToolSchema>
|
||||
}
|
||||
|
||||
/**
|
||||
* Immutable read window over one session's durable event log.
|
||||
*
|
||||
* The conversation snapshot is a chat projection. Consumers that need event
|
||||
* order or request lifecycle data read this source instead of widening that
|
||||
* projection with inspection-only fields.
|
||||
* Create a lazy inspection projection over an immutable history window.
|
||||
* Conversation consumers retain the cheap wrapper; only Trajectory reads the
|
||||
* getters that replay event order and request lifecycle state.
|
||||
* @param entries - Contiguous raw history entries in sequence order.
|
||||
* @returns Lazy, memoized inspection fields for that exact window.
|
||||
*/
|
||||
export interface SessionHistorySnapshot {
|
||||
sessionId: SessionId
|
||||
/** Contiguous raw log entries in ascending sequence order. */
|
||||
entries: readonly HistoryEntry[]
|
||||
/** Sequence of the first entry, or zero while the window is empty. */
|
||||
baseSeq: number
|
||||
openState: OpenState
|
||||
openError: RpcError | null
|
||||
hasMore: boolean
|
||||
loadingOlder: boolean
|
||||
}
|
||||
|
||||
/** Read-only observable history plus explicit full-ledger paging. */
|
||||
export interface SessionHistory extends ObservableSnapshot<SessionHistorySnapshot> {
|
||||
/**
|
||||
* Load every earlier page currently available.
|
||||
* @returns When paging is exhausted or cannot advance.
|
||||
*/
|
||||
loadAll(): Promise<void>
|
||||
export function createHistoryInspection(
|
||||
entries: readonly HistoryEntry[],
|
||||
): SessionHistoryInspection {
|
||||
let conversation: ReturnType<typeof projectConversationHistory> | undefined
|
||||
let requests: ReturnType<typeof inspectRequests> | undefined
|
||||
const conversationProjection = () =>
|
||||
conversation ??= projectConversationHistory(entries)
|
||||
const requestProjection = () =>
|
||||
requests ??= inspectRequests(entries)
|
||||
return {
|
||||
get eventNodes() {
|
||||
return conversationProjection().eventNodes
|
||||
},
|
||||
get contexts() {
|
||||
return conversationProjection().contexts
|
||||
},
|
||||
get requests() {
|
||||
return requestProjection().requests
|
||||
},
|
||||
get callSchemas() {
|
||||
return requestProjection().callSchemas
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,9 @@ import type {
|
||||
CodeSubCall, ComposerPhase, ConversationNode, ConversationSnapshot,
|
||||
OpenState, PromptError, QueuedMessage, RunningToolCall,
|
||||
} from './conversation.ts'
|
||||
import type { SessionHistory, SessionHistorySnapshot } from './history.ts'
|
||||
import {
|
||||
createHistoryInspection, type SessionHistoryInspection,
|
||||
} from './history.ts'
|
||||
import type { PendingInteraction } from './pending.ts'
|
||||
import { PendingWait } from './pending.ts'
|
||||
import { FoldAdapter } from './fold-adapter.ts'
|
||||
@@ -112,6 +114,10 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
/** Raw history revision; published entries are copied so later live appends never mutate a prior snapshot. */
|
||||
private historyRev = 0
|
||||
private historyEntriesCache: { rev: number; value: readonly HistoryEntry[] } | null = null
|
||||
private historyInspectionCache: {
|
||||
rev: number
|
||||
value: SessionHistoryInspection
|
||||
} | null = null
|
||||
private running = false
|
||||
/**
|
||||
* Sticky send marker, private input of the composerPhase derivation: set
|
||||
@@ -132,13 +138,9 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
private subscribedLastSeq: number | null = null
|
||||
|
||||
private snapshotCache: ConversationSnapshot
|
||||
private historySnapshotCache: SessionHistorySnapshot | undefined
|
||||
private readonly notifier = new Notifier(() => {
|
||||
this.snapshotCache = this.buildSnapshot()
|
||||
this.historySnapshotCache = this.buildHistorySnapshot()
|
||||
})
|
||||
/** Raw log read surface; trajectory-like consumers project their own model from it. */
|
||||
readonly history: SessionHistory
|
||||
/**
|
||||
* Agent-scoped cordis context, bound once by SessionsService when it
|
||||
* mints the scope (the client mirror of the host Agent's loopCtx). The
|
||||
@@ -159,19 +161,6 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
private readonly options: SessionOptions = {},
|
||||
) {
|
||||
this.snapshotCache = this.buildSnapshot()
|
||||
this.historySnapshotCache = this.buildHistorySnapshot()
|
||||
this.history = {
|
||||
getSnapshot: () => {
|
||||
this.notifier.ensureFresh()
|
||||
/* v8 ignore next -- constructor initializes the cache before history is published. */
|
||||
if (this.historySnapshotCache === undefined) {
|
||||
throw new Error(`session ${this.sessionId} history cache is uninitialized`)
|
||||
}
|
||||
return this.historySnapshotCache
|
||||
},
|
||||
subscribe: listener => this.notifier.subscribe(listener),
|
||||
loadAll: () => this.loadAllHistory(),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -307,7 +296,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
* backend failure cannot become an automatic retry loop.
|
||||
* @returns When the available history has been exhausted or paging stops making progress.
|
||||
*/
|
||||
private async loadAllHistory(): Promise<void> {
|
||||
async loadAllHistory(): Promise<void> {
|
||||
while (this.openState === 'open' && this.hasMore && !this.loadingOlder) {
|
||||
const previousBaseSeq = this.baseSeq
|
||||
await this.loadOlder()
|
||||
@@ -572,7 +561,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
if (tailSeq !== null && event.seq <= tailSeq) return // replay overlap, drop
|
||||
this.events.push(event)
|
||||
this.views.push(view)
|
||||
this.historyRev++
|
||||
if (event.type !== 'assistant/chunk') this.historyRev++
|
||||
this.foldAdapter.append(event, view)
|
||||
this.applyEventSideEffects(event, view)
|
||||
}
|
||||
@@ -831,6 +820,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
return {
|
||||
sessionId: this.sessionId,
|
||||
nodes,
|
||||
inspection: this.buildHistoryInspection(),
|
||||
foldDegraded: degraded,
|
||||
partial,
|
||||
runningCalls: this.callsCache.value,
|
||||
@@ -854,8 +844,8 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Build the raw history read surface without leaking the mutable window arrays. */
|
||||
private buildHistorySnapshot(): SessionHistorySnapshot {
|
||||
/** Build the lazy history inspection wrapper without leaking mutable window arrays. */
|
||||
private buildHistoryInspection(): SessionHistoryInspection {
|
||||
if (this.historyEntriesCache === null || this.historyEntriesCache.rev !== this.historyRev) {
|
||||
this.historyEntriesCache = {
|
||||
rev: this.historyRev,
|
||||
@@ -865,27 +855,16 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
}),
|
||||
}
|
||||
}
|
||||
const previous = this.historySnapshotCache
|
||||
if (
|
||||
previous !== undefined
|
||||
&& previous.entries === this.historyEntriesCache.value
|
||||
&& previous.baseSeq === this.baseSeq
|
||||
&& previous.openState === this.openState
|
||||
&& previous.openError === this.openError
|
||||
&& previous.hasMore === this.hasMore
|
||||
&& previous.loadingOlder === this.loadingOlder
|
||||
this.historyInspectionCache === null
|
||||
|| this.historyInspectionCache.rev !== this.historyRev
|
||||
) {
|
||||
return previous
|
||||
}
|
||||
return {
|
||||
sessionId: this.sessionId,
|
||||
entries: this.historyEntriesCache.value,
|
||||
baseSeq: this.baseSeq,
|
||||
openState: this.openState,
|
||||
openError: this.openError,
|
||||
hasMore: this.hasMore,
|
||||
loadingOlder: this.loadingOlder,
|
||||
this.historyInspectionCache = {
|
||||
rev: this.historyRev,
|
||||
value: createHistoryInspection(this.historyEntriesCache.value),
|
||||
}
|
||||
}
|
||||
return this.historyInspectionCache.value
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -270,12 +270,12 @@ describe('paging', () => {
|
||||
}
|
||||
|
||||
await session.open()
|
||||
await session.history.loadAll()
|
||||
await session.loadAllHistory()
|
||||
|
||||
expect(api.callsOf('session.history')).toHaveLength(3)
|
||||
expect(session.history.getSnapshot().hasMore).toBe(false)
|
||||
expect(session.history.getSnapshot().entries.map(entry => entry.event.seq))
|
||||
.toEqual([...Array(18).keys()])
|
||||
expect(session.getSnapshot().hasMore).toBe(false)
|
||||
expect(session.getSnapshot().inspection?.eventNodes.map(node => node.seq))
|
||||
.toEqual([1, 3, 7, 9, 13, 15])
|
||||
expect(session.getSnapshot().nodes.map(node => node.seq)).toEqual([1, 3, 7, 9, 13, 15])
|
||||
})
|
||||
|
||||
@@ -286,10 +286,10 @@ describe('paging', () => {
|
||||
: Promise.resolve(err({ code: 'internal', message: 'page unavailable', details: {} }))
|
||||
|
||||
await session.open()
|
||||
await session.history.loadAll()
|
||||
await session.loadAllHistory()
|
||||
|
||||
expect(api.callsOf('session.history')).toHaveLength(2)
|
||||
expect(session.history.getSnapshot().hasMore).toBe(true)
|
||||
expect(session.getSnapshot().hasMore).toBe(true)
|
||||
})
|
||||
|
||||
it('drops a discontinuous older page fail-soft (window unchanged, hasMore cleared)', async () => {
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
/** Trajectory view: compact summary over a turn-aware event ledger. */
|
||||
|
||||
import { useEffect, useMemo, useState, useSyncExternalStore } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type {
|
||||
AssistantMessageNode, ConversationContext, SessionHistory,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import {
|
||||
inspectRequests, projectConversationHistory,
|
||||
AssistantMessageNode, ConversationContext, RequestView,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { deriveTrajectoryContextBranches } from './context-branches.ts'
|
||||
import {
|
||||
@@ -19,10 +16,11 @@ import { deriveTrajectoryLayout } from './layout.ts'
|
||||
import css from './views.module.css'
|
||||
|
||||
const EMPTY_IDS: ReadonlySet<number> = new Set()
|
||||
const EMPTY_REQUESTS: readonly RequestView[] = []
|
||||
|
||||
/** Raw session-history source needed by the event-complete trajectory view. */
|
||||
/** Session-history paging needed by the event-complete trajectory view. */
|
||||
export interface TrajectoryViewInjected {
|
||||
history: SessionHistory
|
||||
loadAllHistory: () => Promise<void>
|
||||
}
|
||||
|
||||
interface UsageLike {
|
||||
@@ -69,47 +67,29 @@ function addUsage(
|
||||
}
|
||||
}
|
||||
|
||||
export function TrajectoryView({ useSession, history }: ConvViewProps & TrajectoryViewInjected) {
|
||||
export function TrajectoryView({ useSession, loadAllHistory }: ConvViewProps & TrajectoryViewInjected) {
|
||||
const [collapsedTurns, setCollapsedTurns] = useState<ReadonlySet<number>>(EMPTY_IDS)
|
||||
const [collapsedAssistants, setCollapsedAssistants] =
|
||||
useState<ReadonlySet<number>>(EMPTY_IDS)
|
||||
const nodes = useSession(s => s.nodes)
|
||||
const inspection = useSession(s => s.inspection)
|
||||
const hasMore = useSession(s => s.hasMore)
|
||||
const openState = useSession(s => s.openState)
|
||||
const partial = useSession(s => s.partial)
|
||||
const runningCalls = useSession(s => s.runningCalls)
|
||||
const codeDispatches = useSession(s => s.codeDispatches)
|
||||
const subscribeHistory = useMemo(
|
||||
() => (listener: () => void) => history.subscribe(listener),
|
||||
[history],
|
||||
)
|
||||
const getHistorySnapshot = useMemo(
|
||||
() => () => history.getSnapshot(),
|
||||
[history],
|
||||
)
|
||||
const historySnapshot = useSyncExternalStore(
|
||||
subscribeHistory,
|
||||
getHistorySnapshot,
|
||||
getHistorySnapshot,
|
||||
)
|
||||
const loadAllHistoryRef = useRef(loadAllHistory)
|
||||
loadAllHistoryRef.current = loadAllHistory
|
||||
useEffect(() => {
|
||||
if (historySnapshot.openState === 'open' && historySnapshot.hasMore) {
|
||||
void history.loadAll()
|
||||
}
|
||||
}, [history, historySnapshot.hasMore, historySnapshot.openState])
|
||||
const projectedHistory = useMemo(
|
||||
() => projectConversationHistory(historySnapshot.entries),
|
||||
[historySnapshot.entries],
|
||||
)
|
||||
const requestInspection = useMemo(
|
||||
() => inspectRequests(historySnapshot.entries),
|
||||
[historySnapshot.entries],
|
||||
)
|
||||
const requests = requestInspection.requests
|
||||
const callSchemas = requestInspection.callSchemas
|
||||
if (openState === 'open' && hasMore) void loadAllHistoryRef.current()
|
||||
}, [hasMore, openState])
|
||||
const requests = inspection?.requests ?? EMPTY_REQUESTS
|
||||
const callSchemas = inspection?.callSchemas
|
||||
const contexts = useMemo<readonly ConversationContext[]>(
|
||||
() => projectedHistory.contexts.length === 0
|
||||
() => inspection === undefined || inspection.contexts.length === 0
|
||||
? [{ id: 0, nodes }]
|
||||
: projectedHistory.contexts,
|
||||
[nodes, projectedHistory.contexts],
|
||||
: inspection.contexts,
|
||||
[inspection, nodes],
|
||||
)
|
||||
const branches = useMemo(
|
||||
() => deriveTrajectoryContextBranches(contexts),
|
||||
@@ -117,9 +97,9 @@ export function TrajectoryView({ useSession, history }: ConvViewProps & Trajecto
|
||||
)
|
||||
const currentBranch = branches.at(-1)
|
||||
if (currentBranch === undefined) throw new Error('trajectory branch projection must not be empty')
|
||||
const selectedNodes = projectedHistory.eventNodes.length === 0
|
||||
const selectedNodes = inspection === undefined || inspection.eventNodes.length === 0
|
||||
? nodes
|
||||
: projectedHistory.eventNodes
|
||||
: inspection.eventNodes
|
||||
const globalRequestNumbers = useMemo<readonly TrajectoryRequestNumber[]>(() => {
|
||||
const assistantsByStep = new Map<string, AssistantMessageNode>()
|
||||
for (const context of contexts) {
|
||||
@@ -256,7 +236,7 @@ export function TrajectoryView({ useSession, history }: ConvViewProps & Trajecto
|
||||
partial,
|
||||
runningCalls,
|
||||
requests,
|
||||
callSchemas,
|
||||
...(callSchemas === undefined ? {} : { callSchemas }),
|
||||
codeDispatches,
|
||||
}),
|
||||
[
|
||||
|
||||
@@ -36,7 +36,7 @@ export function apply(ctx: Context): void {
|
||||
if (session === undefined) {
|
||||
throw new Error(`ui-trajectory: session "${sessionId}" resolved no binding`)
|
||||
}
|
||||
return { history: session.history }
|
||||
return { loadAllHistory: () => session.loadAllHistory() }
|
||||
},
|
||||
}, TrajectoryView)
|
||||
ctx.slots.register(
|
||||
|
||||
@@ -60,7 +60,7 @@ describe('tsdown client artifact', () => {
|
||||
const { handoff, surface } = await loadArtifact()
|
||||
expect(handoff.id).toBe(PLUGIN_ID)
|
||||
expect(surface.apply).toBeTypeOf('function')
|
||||
expect(surface.inject).toEqual(['slots', 'conversation'])
|
||||
expect(surface.inject).toEqual(['slots', 'conversation', 'sessions'])
|
||||
})
|
||||
|
||||
it.skipIf(code === undefined)('mounted as an object plugin, apply registers both view tabs on the real ring', async () => {
|
||||
@@ -72,10 +72,10 @@ describe('tsdown client artifact', () => {
|
||||
name: 'root',
|
||||
children: { 'conversation.view': { kind: 'list', scope: 'session' } },
|
||||
}, (_p: { renderSlot?: unknown }) => null)
|
||||
// The plugin injects 'conversation' as an ordering edge (the declaring
|
||||
// plugin provides it after declaring the ring); the bench declares the
|
||||
// ring itself, so a stub satisfies the wait.
|
||||
// The plugin injects 'conversation' as an ordering edge and 'sessions'
|
||||
// for its per-session history callback; this bench supplies both.
|
||||
ctx.provide('conversation', {})
|
||||
ctx.provide('sessions', {})
|
||||
const fiber = ctx.plugin(surface as { apply: (ctx: Context) => void })
|
||||
await fiber.await()
|
||||
expect(slots.entries('conversation.view').map(e => e.options.id)).toEqual(['trajectory', 'waterfall'])
|
||||
|
||||
@@ -16,8 +16,7 @@ import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {
|
||||
ConversationSnapshot, SessionHistory, SessionHistorySnapshot, SessionId,
|
||||
SessionListState, WorkspaceListState,
|
||||
ConversationSnapshot, SessionId, SessionListState, WorkspaceListState,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ConvViewProps, ViewTab } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
// Export discipline: packages/client/AGENTS.md.
|
||||
@@ -83,42 +82,11 @@ function standaloneProps(nodes: ConversationSnapshot['nodes']): ConvViewProps {
|
||||
} as unknown as ConvViewProps
|
||||
}
|
||||
|
||||
function emptyHistory(): SessionHistory {
|
||||
const store = createSnapshotStore<SessionHistorySnapshot>({
|
||||
sessionId: SID,
|
||||
entries: [],
|
||||
baseSeq: 0,
|
||||
openState: 'open',
|
||||
openError: null,
|
||||
hasMore: false,
|
||||
loadingOlder: false,
|
||||
})
|
||||
return {
|
||||
getSnapshot: () => store.getSnapshot(),
|
||||
subscribe: listener => store.subscribe(listener),
|
||||
loadAll: () => Promise.resolve(),
|
||||
}
|
||||
}
|
||||
|
||||
/** Real-stack bench: root Context + real SlotsService ring + the plugin fiber. */
|
||||
async function bench() {
|
||||
const ctx = new Context()
|
||||
const slots = new SlotsService(ctx)
|
||||
const loadAllHistory = vi.fn(() => Promise.resolve())
|
||||
const historyStore = createSnapshotStore<SessionHistorySnapshot>({
|
||||
sessionId: SID,
|
||||
entries: [],
|
||||
baseSeq: 0,
|
||||
openState: 'open',
|
||||
openError: null,
|
||||
hasMore: true,
|
||||
loadingOlder: false,
|
||||
})
|
||||
const history: SessionHistory = {
|
||||
getSnapshot: () => historyStore.getSnapshot(),
|
||||
subscribe: listener => historyStore.subscribe(listener),
|
||||
loadAll: loadAllHistory,
|
||||
}
|
||||
// The conversation entry's role: declare the ring, then seed the chat entry.
|
||||
slots.register({
|
||||
name: 'root',
|
||||
@@ -132,7 +100,7 @@ async function bench() {
|
||||
ctx.provide('conversation', {})
|
||||
ctx.provide('sessions', {
|
||||
binding: (sessionId: SessionId) => sessionId === SID
|
||||
? { session: { history } }
|
||||
? { session: { loadAllHistory } }
|
||||
: undefined,
|
||||
})
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
@@ -295,7 +263,7 @@ describe('span derivation', () => {
|
||||
expect(container.firstChild).toBeNull()
|
||||
render(createElement(
|
||||
TrajectoryView,
|
||||
{ ...standaloneProps([]), history: emptyHistory() },
|
||||
{ ...standaloneProps([]), loadAllHistory: () => Promise.resolve() },
|
||||
))
|
||||
expect(screen.getByRole('toolbar', { name: 'Trajectory toolbar' })).toBeTruthy()
|
||||
expect(screen.queryByRole('row')).toBeNull()
|
||||
|
||||
Reference in New Issue
Block a user