From 6bc6ff4f537bd495d74c5ca85b94c0a3ad80144a Mon Sep 17 00:00:00 2001 From: root Date: Wed, 15 Jul 2026 05:43:59 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=94=B6=E6=95=9B=20AgentRun=20?= =?UTF-8?q?=E8=A7=82=E5=AF=9F=E9=A1=B5=E4=B8=BB=E5=AE=A1=E9=98=BB=E5=A1=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/deploy.schema.json | 2 + deploy/deploy.yaml | 1 + internal/cloud/kafka-event-bridge.ts | 9 + internal/cloud/server-agent-observer-http.ts | 8 +- .../cloud/server-workbench-realtime-http.ts | 1 + .../cloud/workbench-kafka-refresh-handoff.ts | 45 ++- internal/dev-entrypoint/cloud-web-runtime.mjs | 2 + scripts/src/gitops-render/core.mjs | 1 + .../components/agents/AgentRunInspector.vue | 97 ++++++ .../src/components/common/BaseDrawer.vue | 6 +- .../src/components/common/OverlaySurface.vue | 6 +- .../src/components/common/VirtualList.vue | 22 +- .../src/config/agent-observer.ts | 2 + .../src/stores/agent-observer.ts | 87 ++++- .../src/styles/agent-observer.css | 196 ++++++++---- web/hwlab-cloud-web/src/types/global.d.ts | 1 + .../src/views/agents/AgentRunsView.vue | 300 ++++++------------ 17 files changed, 489 insertions(+), 297 deletions(-) create mode 100644 web/hwlab-cloud-web/src/components/agents/AgentRunInspector.vue diff --git a/deploy/deploy.schema.json b/deploy/deploy.schema.json index c15fd5af..62cd07c0 100644 --- a/deploy/deploy.schema.json +++ b/deploy/deploy.schema.json @@ -622,6 +622,7 @@ "reconnectDelayMs", "batchMaxEvents", "liveBufferLimit", + "identityWindowLimit", "reducerEventLimit", "reducerEntityLimit", "treeNodeLimit", @@ -640,6 +641,7 @@ "reconnectDelayMs": { "type": "integer", "minimum": 100 }, "batchMaxEvents": { "type": "integer", "minimum": 1 }, "liveBufferLimit": { "type": "integer", "minimum": 1 }, + "identityWindowLimit": { "type": "integer", "minimum": 1 }, "reducerEventLimit": { "type": "integer", "minimum": 1 }, "reducerEntityLimit": { "type": "integer", "minimum": 1 }, "treeNodeLimit": { "type": "integer", "minimum": 1 }, diff --git a/deploy/deploy.yaml b/deploy/deploy.yaml index 388ab61c..3447706f 100644 --- a/deploy/deploy.yaml +++ b/deploy/deploy.yaml @@ -387,6 +387,7 @@ lanes: reconnectDelayMs: 1500 batchMaxEvents: 100 liveBufferLimit: 2000 + identityWindowLimit: 12000 reducerEventLimit: 2000 reducerEntityLimit: 500 treeNodeLimit: 500 diff --git a/internal/cloud/kafka-event-bridge.ts b/internal/cloud/kafka-event-bridge.ts index b8837d59..16aef311 100644 --- a/internal/cloud/kafka-event-bridge.ts +++ b/internal/cloud/kafka-event-bridge.ts @@ -1310,6 +1310,9 @@ export async function openKafkaEventStream({ env = process.env, stream = "hwlab" function mapAgentRunSourceEventToHwlabEvent({ input, sourceEvent, payload, run, command, traceId, sessionId, sourceSeq, runId, commandId }) { const type = firstText(sourceEvent.type, payload.type, input.eventType) || "event"; + const task = objectValue(input.task ?? payload.task); + const runner = objectValue(input.runner ?? payload.runner); + const runnerCapacity = objectValue(runner.capacity ?? payload.runnerCapacity); const base = { traceId, sessionId, @@ -1318,8 +1321,14 @@ function mapAgentRunSourceEventToHwlabEvent({ input, sourceEvent, payload, run, runId, commandId, taskId: firstText(payload.taskId, sourceEvent.taskId), + taskStatus: firstText(task.status, payload.taskStatus, payload.taskState, sourceEvent.taskStatus), attemptId: firstText(command.attemptId, sourceEvent.attemptId, payload.attemptId), + attemptStatus: firstText(payload.attemptStatus, payload.attemptState, sourceEvent.attemptStatus), runnerId: firstText(command.runnerId, sourceEvent.runnerId, payload.runnerId), + runnerCapacityUsed: integerValue(runnerCapacity.used ?? runner.capacityUsed ?? payload.runnerCapacityUsed), + runnerCapacityTotal: integerValue(runnerCapacity.total ?? runner.capacityTotal ?? payload.runnerCapacityTotal), + nodeId: firstText(runner.nodeId, payload.nodeId, input.nodeId), + lane: firstText(runner.lane, payload.lane, input.lane), backend: firstText(run.backendProfile, payload.backendProfile, payload.providerProfile), providerId: firstText(run.providerId, payload.providerId), projectId: firstText(run.projectId, payload.projectId), diff --git a/internal/cloud/server-agent-observer-http.ts b/internal/cloud/server-agent-observer-http.ts index e120f0b7..17abb816 100644 --- a/internal/cloud/server-agent-observer-http.ts +++ b/internal/cloud/server-agent-observer-http.ts @@ -76,6 +76,7 @@ export async function handleAgentObserverHttp(request, response, options = {}) { const handoff = createWorkbenchKafkaRefreshHandoff({ allowUnscoped: true, liveBufferLimit: policy.liveBufferLimit, + identityWindowLimit: policy.identityWindowLimit, subscribeLive: (listener) => bridge.subscribeLiveHwlabEvents(listener), queryRetention: ({ signal }) => bridge.queryHwlabEventRetention({ limit: policy.retentionEventLimit, @@ -121,14 +122,19 @@ export async function handleAgentObserverHttp(request, response, options = {}) { } function agentObserverPolicy(env) { - return { + const policy = { retentionEventLimit: positiveInteger(env.HWLAB_AGENT_OBSERVER_RETENTION_EVENT_LIMIT, "HWLAB_AGENT_OBSERVER_RETENTION_EVENT_LIMIT"), retentionScanLimit: positiveInteger(env.HWLAB_AGENT_OBSERVER_RETENTION_SCAN_LIMIT, "HWLAB_AGENT_OBSERVER_RETENTION_SCAN_LIMIT"), retentionTimeoutMs: positiveInteger(env.HWLAB_AGENT_OBSERVER_RETENTION_TIMEOUT_MS, "HWLAB_AGENT_OBSERVER_RETENTION_TIMEOUT_MS"), retentionGroupPrefix: requiredText(env.HWLAB_AGENT_OBSERVER_RETENTION_GROUP_PREFIX, "HWLAB_AGENT_OBSERVER_RETENTION_GROUP_PREFIX"), liveBufferLimit: positiveInteger(env.HWLAB_AGENT_OBSERVER_LIVE_BUFFER_LIMIT, "HWLAB_AGENT_OBSERVER_LIVE_BUFFER_LIMIT"), + identityWindowLimit: positiveInteger(env.HWLAB_AGENT_OBSERVER_IDENTITY_WINDOW_LIMIT, "HWLAB_AGENT_OBSERVER_IDENTITY_WINDOW_LIMIT"), heartbeatMs: positiveInteger(env.HWLAB_AGENT_OBSERVER_SSE_HEARTBEAT_MS, "HWLAB_AGENT_OBSERVER_SSE_HEARTBEAT_MS") }; + if (policy.identityWindowLimit < policy.retentionEventLimit + policy.liveBufferLimit) { + throw Object.assign(new Error("HWLAB_AGENT_OBSERVER_IDENTITY_WINDOW_LIMIT must cover retentionEventLimit + liveBufferLimit."), { code: "agent_observer_config_invalid" }); + } + return policy; } function positiveInteger(value, name) { diff --git a/internal/cloud/server-workbench-realtime-http.ts b/internal/cloud/server-workbench-realtime-http.ts index 0c5bc0c1..9cb764bf 100644 --- a/internal/cloud/server-workbench-realtime-http.ts +++ b/internal/cloud/server-workbench-realtime-http.ts @@ -756,6 +756,7 @@ async function handleKafkaRefreshReplayWorkbenchRealtimeHttp(request, response, sessionId: requestedSessionId, traceId: requestedTraceId, liveBufferLimit: refreshReplay.liveBufferLimit, + identityWindowLimit: refreshReplay.matchedEventLimit + refreshReplay.liveBufferLimit, subscribeLive: (listener) => bridge.subscribeLiveHwlabEvents(listener), queryRetention: ({ signal }) => bridge.queryHwlabEventRetention({ sessionId: requestedSessionId, diff --git a/internal/cloud/workbench-kafka-refresh-handoff.ts b/internal/cloud/workbench-kafka-refresh-handoff.ts index 71c664cb..526b3e35 100644 --- a/internal/cloud/workbench-kafka-refresh-handoff.ts +++ b/internal/cloud/workbench-kafka-refresh-handoff.ts @@ -6,6 +6,7 @@ export function createWorkbenchKafkaRefreshHandoff(options = {}) { const traceId = textValue(options.traceId); const allowUnscoped = options.allowUnscoped === true; const liveBufferLimit = positiveInteger(options.liveBufferLimit); + const identityWindowLimit = positiveInteger(options.identityWindowLimit) ?? liveBufferLimit; if (!sessionId && !traceId && !allowUnscoped) throw handoffError("workbench_kafka_refresh_scope_missing", "Kafka refresh handoff requires a session or trace scope.", "idle"); if (!liveBufferLimit) throw handoffError("workbench_kafka_refresh_buffer_limit_invalid", "Kafka refresh handoff requires an explicit positive live buffer limit.", "idle"); if (typeof options.subscribeLive !== "function" || typeof options.queryRetention !== "function" || typeof options.deliverEvent !== "function" || typeof options.deliverConnected !== "function") { @@ -16,10 +17,11 @@ export function createWorkbenchKafkaRefreshHandoff(options = {}) { const barrierByPartition = new Map(); const replayProofByTransport = new Map(); const stableIdentityByTransport = new Map(); - const seenEventIds = new Set(); - const seenSourceEventIds = new Set(); + const seenEventIds = new Map(); + const seenSourceEventIds = new Map(); const sourceEventIdByEventId = new Map(); const eventIdBySourceEventId = new Map(); + const identityWindow = []; const abortController = new AbortController(); let phase = "idle"; let unsubscribe = null; @@ -197,9 +199,9 @@ export function createWorkbenchKafkaRefreshHandoff(options = {}) { scopedPartition = transport.partition; const transportKey = transportIdentityKey(transport); const priorProof = replayProofByTransport.get(transportKey); - if (priorProof && priorProof !== stableKey) throw handoffError("workbench_kafka_refresh_transport_conflict", "One Kafka transport identity maps to conflicting stable events.", phase); - replayProofByTransport.set(transportKey, stableKey); + if (priorProof && priorProof.stableKey !== stableKey) throw handoffError("workbench_kafka_refresh_transport_conflict", "One Kafka transport identity maps to conflicting stable events.", phase); const deliver = rememberIdentity(transportKey, stableIdentity, phase); + replayProofByTransport.set(transportKey, stableIdentityByTransport.get(transportKey)); normalized.push({ envelope, transport, deliver }); } return normalized; @@ -224,7 +226,7 @@ export function createWorkbenchKafkaRefreshHandoff(options = {}) { const transportKey = transportIdentityKey(transport); if (compareOffsets(transport.offset, barrier) < 0) { const replayProof = replayProofByTransport.get(transportKey); - if (replayProof !== stableKey) { + if (replayProof?.stableKey !== stableKey) { throw handoffError("workbench_kafka_refresh_barrier_gap", "A pre-barrier shared live record was not proven by the retained replay scan.", phase, { partition: transport.partition }); } } @@ -234,14 +236,14 @@ export function createWorkbenchKafkaRefreshHandoff(options = {}) { function rememberIdentity(transportKey, stableIdentity, currentPhase) { const stableKey = stableIdentityKey(stableIdentity); const priorStable = stableIdentityByTransport.get(transportKey); - if (priorStable && priorStable !== stableKey) { + if (priorStable && priorStable.stableKey !== stableKey) { throw handoffError("workbench_kafka_refresh_transport_conflict", "One Kafka transport identity maps to conflicting stable events.", currentPhase); } - const priorSourceEventId = stableIdentity.eventId ? sourceEventIdByEventId.get(stableIdentity.eventId) : null; + const priorSourceEventId = stableIdentity.eventId ? sourceEventIdByEventId.get(stableIdentity.eventId)?.sourceEventId : null; if (priorSourceEventId && stableIdentity.sourceEventId && priorSourceEventId !== stableIdentity.sourceEventId) { throw handoffError("workbench_kafka_refresh_stable_identity_conflict", "One outer eventId maps to conflicting sourceEventId identities.", currentPhase); } - const priorEventId = stableIdentity.sourceEventId ? eventIdBySourceEventId.get(stableIdentity.sourceEventId) : null; + const priorEventId = stableIdentity.sourceEventId ? eventIdBySourceEventId.get(stableIdentity.sourceEventId)?.eventId : null; if (priorEventId && stableIdentity.eventId && priorEventId !== stableIdentity.eventId) { throw handoffError("workbench_kafka_refresh_stable_identity_conflict", "One outer sourceEventId maps to conflicting eventId identities.", currentPhase); } @@ -249,15 +251,18 @@ export function createWorkbenchKafkaRefreshHandoff(options = {}) { (stableIdentity.eventId && seenEventIds.has(stableIdentity.eventId)) || (stableIdentity.sourceEventId && seenSourceEventIds.has(stableIdentity.sourceEventId)) ); - stableIdentityByTransport.set(transportKey, stableKey); + const entry = { transportKey, stableKey, eventId: stableIdentity.eventId, sourceEventId: stableIdentity.sourceEventId }; + stableIdentityByTransport.set(transportKey, entry); if (stableIdentity.eventId) { - seenEventIds.add(stableIdentity.eventId); - if (stableIdentity.sourceEventId) sourceEventIdByEventId.set(stableIdentity.eventId, stableIdentity.sourceEventId); + seenEventIds.set(stableIdentity.eventId, entry); + if (stableIdentity.sourceEventId) sourceEventIdByEventId.set(stableIdentity.eventId, entry); } if (stableIdentity.sourceEventId) { - seenSourceEventIds.add(stableIdentity.sourceEventId); - if (stableIdentity.eventId) eventIdBySourceEventId.set(stableIdentity.sourceEventId, stableIdentity.eventId); + seenSourceEventIds.set(stableIdentity.sourceEventId, entry); + if (stableIdentity.eventId) eventIdBySourceEventId.set(stableIdentity.sourceEventId, entry); } + identityWindow.push(entry); + trimIdentityWindow(); if (duplicate) { counts.deduplicated += 1; return false; @@ -265,6 +270,19 @@ export function createWorkbenchKafkaRefreshHandoff(options = {}) { return true; } + function trimIdentityWindow() { + while (identityWindow.length > identityWindowLimit) { + const entry = identityWindow.shift(); + if (!entry) return; + if (stableIdentityByTransport.get(entry.transportKey) === entry) stableIdentityByTransport.delete(entry.transportKey); + if (replayProofByTransport.get(entry.transportKey) === entry) replayProofByTransport.delete(entry.transportKey); + if (entry.eventId && seenEventIds.get(entry.eventId) === entry) seenEventIds.delete(entry.eventId); + if (entry.sourceEventId && seenSourceEventIds.get(entry.sourceEventId) === entry) seenSourceEventIds.delete(entry.sourceEventId); + if (entry.eventId && sourceEventIdByEventId.get(entry.eventId) === entry) sourceEventIdByEventId.delete(entry.eventId); + if (entry.sourceEventId && eventIdBySourceEventId.get(entry.sourceEventId) === entry) eventIdBySourceEventId.delete(entry.sourceEventId); + } + } + function deliverSerial(envelope, transport, delivery) { const run = async () => { if (failure || phase === "failed" || phase === "closed") throw failure ?? handoffError("workbench_kafka_refresh_stopped", "Kafka refresh delivery stopped before the queued business event could be written.", phase); @@ -294,6 +312,7 @@ export function createWorkbenchKafkaRefreshHandoff(options = {}) { topicPartitions: scopedPartition === null ? [] : [scopedPartition], barrier: [...barrierByPartition.entries()].map(([partition, endOffset]) => ({ partition, endOffset })).sort((left, right) => left.partition - right.partition), counts: { ...counts }, + identityWindowLimit, valuesRedacted: true }; } diff --git a/internal/dev-entrypoint/cloud-web-runtime.mjs b/internal/dev-entrypoint/cloud-web-runtime.mjs index 83c487ef..21963e69 100644 --- a/internal/dev-entrypoint/cloud-web-runtime.mjs +++ b/internal/dev-entrypoint/cloud-web-runtime.mjs @@ -431,6 +431,7 @@ function agentObserverRuntimeConfigFromEnv() { "HWLAB_AGENT_OBSERVER_RECONNECT_DELAY_MS", "HWLAB_AGENT_OBSERVER_BATCH_MAX_EVENTS", "HWLAB_AGENT_OBSERVER_LIVE_BUFFER_LIMIT", + "HWLAB_AGENT_OBSERVER_IDENTITY_WINDOW_LIMIT", "HWLAB_AGENT_OBSERVER_REDUCER_EVENT_LIMIT", "HWLAB_AGENT_OBSERVER_REDUCER_ENTITY_LIMIT", "HWLAB_AGENT_OBSERVER_TREE_NODE_LIMIT", @@ -447,6 +448,7 @@ function agentObserverRuntimeConfigFromEnv() { reconnectDelayMs: requiredPositiveWorkbenchInteger(process.env.HWLAB_AGENT_OBSERVER_RECONNECT_DELAY_MS, "HWLAB_AGENT_OBSERVER_RECONNECT_DELAY_MS"), batchMaxEvents: requiredPositiveWorkbenchInteger(process.env.HWLAB_AGENT_OBSERVER_BATCH_MAX_EVENTS, "HWLAB_AGENT_OBSERVER_BATCH_MAX_EVENTS"), liveBufferLimit: requiredPositiveWorkbenchInteger(process.env.HWLAB_AGENT_OBSERVER_LIVE_BUFFER_LIMIT, "HWLAB_AGENT_OBSERVER_LIVE_BUFFER_LIMIT"), + identityWindowLimit: requiredPositiveWorkbenchInteger(process.env.HWLAB_AGENT_OBSERVER_IDENTITY_WINDOW_LIMIT, "HWLAB_AGENT_OBSERVER_IDENTITY_WINDOW_LIMIT"), reducerEventLimit: requiredPositiveWorkbenchInteger(process.env.HWLAB_AGENT_OBSERVER_REDUCER_EVENT_LIMIT, "HWLAB_AGENT_OBSERVER_REDUCER_EVENT_LIMIT"), reducerEntityLimit: requiredPositiveWorkbenchInteger(process.env.HWLAB_AGENT_OBSERVER_REDUCER_ENTITY_LIMIT, "HWLAB_AGENT_OBSERVER_REDUCER_ENTITY_LIMIT"), treeNodeLimit: requiredPositiveWorkbenchInteger(process.env.HWLAB_AGENT_OBSERVER_TREE_NODE_LIMIT, "HWLAB_AGENT_OBSERVER_TREE_NODE_LIMIT"), diff --git a/scripts/src/gitops-render/core.mjs b/scripts/src/gitops-render/core.mjs index 5ec3a9e6..b191d132 100644 --- a/scripts/src/gitops-render/core.mjs +++ b/scripts/src/gitops-render/core.mjs @@ -1574,6 +1574,7 @@ function transformWorkloads({ workloads, deploy, catalog, source, sourceBranch = upsertEnv(container.env, "HWLAB_AGENT_OBSERVER_RECONNECT_DELAY_MS", String(agentObserverPolicy.reconnectDelayMs)); upsertEnv(container.env, "HWLAB_AGENT_OBSERVER_BATCH_MAX_EVENTS", String(agentObserverPolicy.batchMaxEvents)); upsertEnv(container.env, "HWLAB_AGENT_OBSERVER_LIVE_BUFFER_LIMIT", String(agentObserverPolicy.liveBufferLimit)); + upsertEnv(container.env, "HWLAB_AGENT_OBSERVER_IDENTITY_WINDOW_LIMIT", String(agentObserverPolicy.identityWindowLimit)); upsertEnv(container.env, "HWLAB_AGENT_OBSERVER_REDUCER_EVENT_LIMIT", String(agentObserverPolicy.reducerEventLimit)); upsertEnv(container.env, "HWLAB_AGENT_OBSERVER_REDUCER_ENTITY_LIMIT", String(agentObserverPolicy.reducerEntityLimit)); upsertEnv(container.env, "HWLAB_AGENT_OBSERVER_TREE_NODE_LIMIT", String(agentObserverPolicy.treeNodeLimit)); diff --git a/web/hwlab-cloud-web/src/components/agents/AgentRunInspector.vue b/web/hwlab-cloud-web/src/components/agents/AgentRunInspector.vue new file mode 100644 index 00000000..d00ca01d --- /dev/null +++ b/web/hwlab-cloud-web/src/components/agents/AgentRunInspector.vue @@ -0,0 +1,97 @@ + + + diff --git a/web/hwlab-cloud-web/src/components/common/BaseDrawer.vue b/web/hwlab-cloud-web/src/components/common/BaseDrawer.vue index b47aff86..1149ebd2 100644 --- a/web/hwlab-cloud-web/src/components/common/BaseDrawer.vue +++ b/web/hwlab-cloud-web/src/components/common/BaseDrawer.vue @@ -11,16 +11,18 @@ const props = withDefaults(defineProps<{ placement?: "left" | "right"; closeOnBackdrop?: boolean; busy?: boolean; + surfaceClass?: string; }>(), { description: "", placement: "right", closeOnBackdrop: true, - busy: false + busy: false, + surfaceClass: "" }); defineEmits<{ close: [] }>(); diff --git a/web/hwlab-cloud-web/src/components/common/OverlaySurface.vue b/web/hwlab-cloud-web/src/components/common/OverlaySurface.vue index 2d65dfd2..8d33b66a 100644 --- a/web/hwlab-cloud-web/src/components/common/OverlaySurface.vue +++ b/web/hwlab-cloud-web/src/components/common/OverlaySurface.vue @@ -18,6 +18,7 @@ const props = withDefaults(defineProps<{ closeOnBackdrop?: boolean; busy?: boolean; initialFocus?: string; + surfaceClass?: string; }>(), { description: "", kind: "dialog", @@ -26,7 +27,8 @@ const props = withDefaults(defineProps<{ fullscreen: false, closeOnBackdrop: true, busy: false, - initialFocus: "" + initialFocus: "", + surfaceClass: "" }); const emit = defineEmits<{ close: [] }>(); const surface = ref(null); @@ -56,7 +58,7 @@ function close(): void { :is="kind === 'drawer' ? 'aside' : 'section'" ref="surface" class="console-overlay-surface" - :class="{ 'console-drawer': kind === 'drawer' }" + :class="[{ 'console-drawer': kind === 'drawer' }, surfaceClass]" :data-placement="kind === 'drawer' ? placement : undefined" :data-wide="kind === 'dialog' && wide ? 'true' : undefined" :data-fullscreen="kind === 'dialog' && fullscreen ? 'true' : undefined" diff --git a/web/hwlab-cloud-web/src/components/common/VirtualList.vue b/web/hwlab-cloud-web/src/components/common/VirtualList.vue index bc3aa955..fe5a9d0d 100644 --- a/web/hwlab-cloud-web/src/components/common/VirtualList.vue +++ b/web/hwlab-cloud-web/src/components/common/VirtualList.vue @@ -19,10 +19,14 @@ const props = withDefaults(defineProps<{ overscan?: number; windowLimit?: number; ariaLabel?: string; + rootRole?: string; + itemRole?: string; }>(), { height: 420, overscan: 5, - ariaLabel: "虚拟列表" + ariaLabel: "虚拟列表", + rootRole: "list", + itemRole: "listitem" }); const scrollTop = ref(0); @@ -87,13 +91,25 @@ function onScroll(event: Event): void { scrollTop.value = target.scrollTop; viewportHeight.value = target.clientHeight; } + +async function scrollToIndex(index: number, align: "nearest" | "start" | "end" = "nearest"): Promise { + const target = Math.max(0, Math.min(props.items.length - 1, index)); + const top = target * props.itemHeight; + const bottom = top + props.itemHeight; + const viewportBottom = scrollTop.value + viewportHeight.value; + if (align === "start" || top < scrollTop.value) root.value?.scrollTo({ top }); + else if (align === "end" || bottom > viewportBottom) root.value?.scrollTo({ top: Math.max(0, bottom - viewportHeight.value) }); + await nextTick(); +} + +defineExpose({ scrollToIndex });