Merge pull request #1734 from pikasTech/fix/1730-workbench-timing-ticker

fix(web): 推进 Workbench 运行中 timing 文案
This commit is contained in:
Lyon
2026-06-20 16:14:23 +08:00
committed by GitHub
3 changed files with 114 additions and 7 deletions
@@ -1,5 +1,5 @@
<script setup lang="ts">
// SPEC: PJ2026-01060505 Workbench Performance draft-2026-06-17-p0; PJ2026-0104010803 唯一投影 draft-2026-06-19-p2-terminal-sealed-final-response.
// SPEC: PJ2026-01060505 Workbench Performance draft-2026-06-17-p0; PJ2026-0104010803 唯一投影 draft-2026-06-19-p2-terminal-sealed-final-response; PJ2026-010401 Web工作台 draft-2026-06-20-p1-view-local-timing-ticker.
// Confirms Workbench message visibility after Vue render and keeps sealed final responses separate from diagnostics.
import { computed, nextTick, onMounted, ref, watch } from "vue";
@@ -15,6 +15,7 @@ import MessageTraceDebugPanel from "@/components/workbench/MessageTraceDebugPane
import { workbenchTraceTimelinePolicy } from "@/config/runtime";
import { useBottomFollowScroll } from "@/composables/useBottomFollowScroll";
import { useClipboard } from "@/composables/useClipboard";
import { useWorkbenchNowTicker } from "@/composables/useWorkbenchNowTicker";
import { useWorkbenchStore } from "@/stores/workbench";
import { acknowledgeWorkbenchVisibleAfterPaint } from "@/utils/workbench-performance";
import { traceIdentityText } from "./message-rendering";
@@ -26,6 +27,8 @@ const panelRef = ref<HTMLElement | null>(null);
const { following, keepBottomAfterUpdate, onScroll: onPanelScroll, scrollToBottom } = useBottomFollowScroll(panelRef, { threshold: 56 });
const detailMessageId = ref<string | null>(null);
const visibleMessages = computed(() => workbench.sessionDetailLoading ? [] : workbench.activeMessages);
const hasRunningAgentMessages = computed(() => visibleMessages.value.some(isRunningMessage));
const { nowMs } = useWorkbenchNowTicker(hasRunningAgentMessages);
const detailMessage = computed(() => workbench.activeMessages.find((message) => message.id === detailMessageId.value) ?? null);
const traceTimelinePolicy = computed(() => workbenchTraceTimelinePolicy());
const scrollSignature = computed(() => workbench.activeMessages.map((message) => [
@@ -230,7 +233,7 @@ function traceForDisplay(message: ChatMessage): ChatMessage["runnerTrace"] {
function messageDurationMeta(message: ChatMessage): { text: string; label: string } | null {
if (message.role !== "agent") return null;
const durationMs = finiteDurationMs(message.timing?.durationMs);
const durationMs = isRunningMessage(message) ? durationSince(message.timing?.startedAt) : finiteDurationMs(message.timing?.durationMs);
if (durationMs === null) return null;
const value = formatDuration(durationMs);
return { text: `耗时 ${value}`, label: `总耗时:${value}` };
@@ -238,12 +241,25 @@ function messageDurationMeta(message: ChatMessage): { text: string; label: strin
function messageActivityMeta(message: ChatMessage): { text: string; label: string } | null {
if (!isRunningMessage(message)) return null;
const ageMs = finiteDurationMs(message.timing?.lastEventAgeMs);
const ageMs = durationSince(message.timing?.lastEventAt);
if (ageMs === null) return null;
const value = formatDuration(ageMs) + "前";
return { text: `最近 ${value}`, label: `最近事件:${value}` };
}
function durationSince(timestamp: unknown): number | null {
const startedAtMs = timestampMs(timestamp);
return startedAtMs === null ? null : Math.max(0, nowMs.value - startedAtMs);
}
function timestampMs(value: unknown): number | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
if (!trimmed) return null;
const parsed = Date.parse(trimmed);
return Number.isFinite(parsed) ? parsed : null;
}
function finiteDurationMs(value: unknown): number | null {
const number = Number(value);
return Number.isFinite(number) && number >= 0 ? Math.trunc(number) : null;
@@ -0,0 +1,80 @@
// SPEC: PJ2026-010401 Web工作台 draft-2026-06-20-p1-view-local-timing-ticker; PJ2026-0104010803 唯一投影 draft-2026-06-20-p1-view-local-timing-ticker.
// Responsibility: Shared browser-only render ticker for relative Workbench timing labels; it never writes server-state or projection facts.
import { onBeforeUnmount, readonly, ref, watch, type Ref } from "vue";
const TICK_INTERVAL_MS = 1000;
const nowMs = ref(Date.now());
const activeSubscribers = new Map<symbol, boolean>();
let timer: number | null = null;
let visibilityListenerAttached = false;
export function useWorkbenchNowTicker(enabled: Ref<boolean>) {
const subscriber = Symbol("workbench-now-ticker");
activeSubscribers.set(subscriber, false);
ensureVisibilityListener();
const stopWatch = watch(() => enabled.value, (value) => {
activeSubscribers.set(subscriber, value === true);
reconcileTimer();
}, { immediate: true });
onBeforeUnmount(() => {
stopWatch();
activeSubscribers.delete(subscriber);
reconcileTimer();
detachVisibilityListenerIfIdle();
});
return { nowMs: readonly(nowMs), refreshNow };
}
function refreshNow(): void {
nowMs.value = Date.now();
}
function reconcileTimer(): void {
const shouldRun = isBrowser() && isDocumentVisible() && hasActiveSubscriber();
if (shouldRun && timer === null) {
refreshNow();
timer = window.setInterval(refreshNow, TICK_INTERVAL_MS);
return;
}
if (!shouldRun && timer !== null) {
window.clearInterval(timer);
timer = null;
}
}
function hasActiveSubscriber(): boolean {
for (const active of activeSubscribers.values()) {
if (active) return true;
}
return false;
}
function ensureVisibilityListener(): void {
if (visibilityListenerAttached || typeof document === "undefined") return;
document.addEventListener("visibilitychange", handleVisibilityChange);
visibilityListenerAttached = true;
}
function detachVisibilityListenerIfIdle(): void {
if (activeSubscribers.size > 0 || !visibilityListenerAttached || typeof document === "undefined") return;
document.removeEventListener("visibilitychange", handleVisibilityChange);
visibilityListenerAttached = false;
}
function handleVisibilityChange(): void {
refreshNow();
reconcileTimer();
}
function isBrowser(): boolean {
return typeof window !== "undefined";
}
function isDocumentVisible(): boolean {
return typeof document === "undefined" || document.visibilityState !== "hidden";
}
@@ -2,19 +2,30 @@ import { expect, gotoWorkbench, saveScreenshot, test } from "../fixtures/test";
import { selectors, sessionTab } from "../fixtures/selectors";
test("Code Agent message headers render canonical timing metadata", async ({ page }, testInfo) => {
await page.clock.install({ time: new Date("2026-06-17T09:39:45.000Z") });
await gotoWorkbench(page, "/workbench/sessions/ses_running");
const running = page.locator(`${selectors.messageCard}[data-role="agent"][data-status="running"]`).first();
await expect(running.locator(`.message-header-main ${selectors.messageDurationMeta}`)).toContainText("耗时 19 分 40 秒");
await expect(running.locator(`.message-header-actions ${selectors.messageActivityMeta}`)).toContainText("最近 19 分 36 秒前");
await expect(running.locator(selectors.messageDurationMeta)).toHaveAttribute("aria-label", "总耗时:19 分 40 秒");
await expect(running.locator(selectors.messageActivityMeta)).toHaveAttribute("aria-label", "最近事件:19 分 36 秒前");
await expect(running.locator(`.message-header-main ${selectors.messageDurationMeta}`)).toContainText("耗时 4 秒");
await expect(running.locator(`.message-header-actions ${selectors.messageActivityMeta}`)).toContainText("最近 0 秒前");
await expect(running.locator(selectors.messageDurationMeta)).toHaveAttribute("aria-label", "总耗时:4 秒");
await expect(running.locator(selectors.messageActivityMeta)).toHaveAttribute("aria-label", "最近事件:0 秒前");
await expect(running.locator(selectors.messageDurationMeta)).not.toContainText("最近");
await page.clock.fastForward(5000);
await expect(running.locator(selectors.messageDurationMeta)).toContainText("耗时 9 秒");
await expect(running.locator(selectors.messageActivityMeta)).toContainText("最近 5 秒前");
await page.clock.fastForward(60000);
await expect(running.locator(selectors.messageDurationMeta)).toContainText("耗时 1 分 9 秒");
await expect(running.locator(selectors.messageActivityMeta)).toContainText("最近 1 分 5 秒前");
await page.locator(sessionTab("ses_completed")).click();
const completed = page.locator(`${selectors.messageCard}[data-role="agent"][data-status="completed"]`).first();
await expect(completed.locator(selectors.messageDurationMeta)).toContainText("耗时 6 秒");
await expect(completed.locator(selectors.messageActivityMeta)).toHaveCount(0);
await page.clock.fastForward(60000);
await expect(completed.locator(selectors.messageDurationMeta)).toContainText("耗时 6 秒");
await page.locator(sessionTab("ses_failed")).click();
const failed = page.locator(`${selectors.messageCard}[data-role="agent"][data-status="failed"]`).first();