Merge pull request #1409 from pikasTech/issue-1395-workbench-rum-probe
#1395 P2 Workbench visible RUM probe
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
// SPEC: PJ2026-01060505 Workbench Performance draft-2026-06-17-p0
|
||||
// Exercises the browser-side Workbench performance probe without sending network traffic.
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import type { ChatMessage, TraceEvent } from "../src/types/index.ts";
|
||||
import {
|
||||
acknowledgeWorkbenchVisible,
|
||||
drainWorkbenchPerformanceEventsForTest,
|
||||
finishWorkbenchOpenFullLoad,
|
||||
finishWorkbenchSessionSwitchFullLoad,
|
||||
markWorkbenchSubmitApiAccepted,
|
||||
markWorkbenchTraceEventsReceived,
|
||||
markWorkbenchTraceProjected,
|
||||
resetWorkbenchPerformanceForTest,
|
||||
startWorkbenchOpenJourney,
|
||||
startWorkbenchSessionSwitch,
|
||||
startWorkbenchSubmitJourney
|
||||
} from "../src/utils/workbench-performance.ts";
|
||||
|
||||
test("Workbench performance probe records journeys and strips high-cardinality identifiers", () => {
|
||||
resetWorkbenchPerformanceForTest();
|
||||
startWorkbenchOpenJourney({ route: "/workbench/sessions/cnv_secret", cache: "cold", authState: "warm" });
|
||||
acknowledgeWorkbenchVisible({ messages: [], activeConversationId: "cnv_secret", detailLoading: false });
|
||||
finishWorkbenchOpenFullLoad("ok");
|
||||
|
||||
startWorkbenchSessionSwitch({ conversationId: "cnv_secret", source: "rail", targetState: "running", cache: "warm" });
|
||||
acknowledgeWorkbenchVisible({ messages: [userMessage("cnv_secret")], activeConversationId: "cnv_secret", detailLoading: false });
|
||||
finishWorkbenchSessionSwitchFullLoad("cnv_secret", "ok");
|
||||
|
||||
const createdAt = new Date(Date.now() - 250).toISOString();
|
||||
startWorkbenchSubmitJourney({ traceId: "trc_secret", conversationId: "cnv_secret", entry: "existing", backend: "codex", transport: "sse" });
|
||||
markWorkbenchSubmitApiAccepted("trc_secret");
|
||||
markWorkbenchTraceEventsReceived({ traceId: "trc_secret", transport: "sse", events: [{ type: "assistant_message", createdAt, ts: createdAt } as TraceEvent] });
|
||||
markWorkbenchTraceProjected("trc_secret");
|
||||
acknowledgeWorkbenchVisible({ messages: [agentMessage("cnv_secret", "trc_secret")], activeConversationId: "cnv_secret", detailLoading: false });
|
||||
|
||||
const events = drainWorkbenchPerformanceEventsForTest();
|
||||
const payload = JSON.stringify(events);
|
||||
assert.ok(events.some((event) => event.kind === "workbench_journey" && event.journey === "workbench_open_first_visible"));
|
||||
assert.ok(events.some((event) => event.kind === "workbench_journey" && event.journey === "session_switch_first_visible"));
|
||||
assert.ok(events.some((event) => event.kind === "workbench_journey" && event.journey === "submit_to_first_visible" && event.backend === "agentrun-v01/codex"));
|
||||
assert.ok(events.some((event) => event.kind === "workbench_event_phase" && event.phase === "project_to_paint"));
|
||||
assert.ok(events.some((event) => event.kind === "workbench_backend_event_visible" && event.eventType === "assistant"));
|
||||
assert.doesNotMatch(payload, /trc_secret|cnv_secret|sessionId|conversationId|runId|commandId/u);
|
||||
});
|
||||
|
||||
function userMessage(conversationId: string): ChatMessage {
|
||||
return { id: "msg_user", role: "user", title: "User", text: "hello", status: "sent", createdAt: new Date().toISOString(), conversationId };
|
||||
}
|
||||
|
||||
function agentMessage(conversationId: string, traceId: string): ChatMessage {
|
||||
return {
|
||||
id: "msg_agent",
|
||||
role: "agent",
|
||||
title: "Code Agent",
|
||||
text: "visible",
|
||||
status: "running",
|
||||
createdAt: new Date().toISOString(),
|
||||
conversationId,
|
||||
traceId,
|
||||
runnerTrace: { traceId, eventCount: 1, events: [{ type: "assistant_message" }] }
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
// SPEC: PJ2026-01060505 Workbench Performance draft-2026-06-17-p0
|
||||
// Confirms Workbench message visibility after Vue render and browser paint.
|
||||
|
||||
import { computed, nextTick, onMounted, ref, watch } from "vue";
|
||||
import type { ChatMessage } from "@/types";
|
||||
import LoadingState from "@/components/common/LoadingState.vue";
|
||||
@@ -10,6 +13,7 @@ import MessageMarkdown from "@/components/workbench/MessageMarkdown.vue";
|
||||
import MessageTraceDebugPanel from "@/components/workbench/MessageTraceDebugPanel.vue";
|
||||
import { useClipboard } from "@/composables/useClipboard";
|
||||
import { useWorkbenchStore } from "@/stores/workbench";
|
||||
import { acknowledgeWorkbenchVisibleAfterPaint } from "@/utils/workbench-performance";
|
||||
import { traceIdentityText } from "./message-rendering";
|
||||
import { traceLifecycleExpanded } from "./trace-lifecycle";
|
||||
|
||||
@@ -32,11 +36,18 @@ watch(scrollSignature, async () => {
|
||||
if (!following.value) return;
|
||||
await nextTick();
|
||||
scrollToBottom();
|
||||
acknowledgeVisibleMessages();
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick();
|
||||
scrollToBottom();
|
||||
acknowledgeVisibleMessages();
|
||||
});
|
||||
|
||||
watch(() => workbench.conversationDetailLoading, async () => {
|
||||
await nextTick();
|
||||
acknowledgeVisibleMessages();
|
||||
});
|
||||
|
||||
function openDetails(message: ChatMessage): void {
|
||||
@@ -71,6 +82,10 @@ function scrollToBottom(): void {
|
||||
panel.scrollTop = panel.scrollHeight;
|
||||
}
|
||||
|
||||
function acknowledgeVisibleMessages(): void {
|
||||
acknowledgeWorkbenchVisibleAfterPaint({ messages: workbench.messages, activeConversationId: workbench.activeConversationId, detailLoading: workbench.conversationDetailLoading });
|
||||
}
|
||||
|
||||
function traceStorageKey(message: ChatMessage): string {
|
||||
return `hwlab.workbench.trace-open.${message.traceId ?? message.runnerTrace?.traceId ?? message.id}`;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// SPEC: PJ2026-010403 API契约 draft-2026-06-17-r0; PJ2026-010401 Web工作台 draft-2026-06-17-r0.
|
||||
// SPEC: PJ2026-010403 API契约 draft-2026-06-17-r0; PJ2026-010401 Web工作台 draft-2026-06-17-r0; PJ2026-01060505 Workbench Performance draft-2026-06-17-p0.
|
||||
// Responsibility: Workbench client state orchestration for session selection, turn admission, and trace lifecycle rendering.
|
||||
|
||||
import { computed, ref } from "vue";
|
||||
@@ -8,6 +8,7 @@ import { connectWorkbenchEvents, type WorkbenchEventStream, type WorkbenchRealti
|
||||
import { mergeRunnerTrace, snapshotToRunnerTrace, type TraceSnapshot } from "@/composables/useTraceSubscription";
|
||||
import type { AgentChatResponse, AgentChatResultResponse, AgentRunProvenance, ApiResult, ChatMessage, ConversationRecord, LiveSurface, ProviderProfile, TraceEvent, WorkspaceRecord } from "@/types";
|
||||
import { DEFAULT_WORKBENCH_PROJECT_ID, firstNonEmptyString, nextProtocolId, normalizeWorkbenchConversationId, rememberWorkbenchProjectId, resolveInitialWorkbenchProjectId, workspaceProjectId } from "@/utils";
|
||||
import { failWorkbenchSessionSwitch, failWorkbenchSubmitJourney, finishWorkbenchSessionSwitchFullLoad, markWorkbenchSubmitApiAccepted, markWorkbenchTraceEventsReceived, markWorkbenchTraceProjected, startWorkbenchSessionSwitch, startWorkbenchSubmitJourney } from "@/utils/workbench-performance";
|
||||
import { RECENT_DRAFTS_STORAGE_KEY, defaultProviderProfileOptions, mergeConversationIntoList, normalizeChatMessageStatus, normalizeRecentDrafts, normalizeWorkbenchMessageTitle, providerProfileOptionsFromPayload, recordRecentDraft, resolveCancelableAgentMessage, resolveComposerState, selectedConversationIdFromWorkspace, shouldApplyWorkspaceSnapshot, shouldShowSessionListLoading, sortSessionTabs, stableConversationList, workspaceWithClearedActiveTrace, type DraftEntry, type ProviderProfileOption, type SessionStatusAuthority, type TurnStatusAuthority } from "./workbench-session";
|
||||
import { initialWorkbenchConversationIdFromLocation, nextExplicitWorkbenchConversationId, resolveWorkbenchActiveConversationId } from "./workbench-projection";
|
||||
import { createWorkbenchServerState, reduceWorkbenchServerState, selectSessionStatusAuthority, selectTraceAuthorityById, selectTurnStatusAuthority, type WorkbenchServerAction } from "./workbench-server-state";
|
||||
@@ -184,6 +185,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
||||
const current = await ensureWorkspace();
|
||||
if (!current) return;
|
||||
const requestEpoch = beginWorkspaceSelection();
|
||||
startWorkbenchSessionSwitch({ conversationId: conversation.conversationId, source: "rail", targetState: conversation.status, cache: (conversation.messages?.length ?? 0) > 0 ? "warm" : "cold" });
|
||||
const tabProjectId = conversation.projectId ?? activeProjectId.value;
|
||||
switchingConversationId.value = conversation.conversationId;
|
||||
setActiveConversationSelection(conversation.conversationId);
|
||||
@@ -210,11 +212,13 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
||||
void persistSelectedConversation(selectedConversation, selectedProjectId, response);
|
||||
await refreshSelectedSessionStatus(selectedConversation);
|
||||
await refreshConversations(conversation.conversationId);
|
||||
finishWorkbenchSessionSwitchFullLoad(conversation.conversationId, "ok");
|
||||
return;
|
||||
}
|
||||
error.value = detailResponse.error ?? "conversation unavailable";
|
||||
if (response.status === 404 && await retrySelectConversationWithFreshWorkspace(conversation, tabProjectId, requestEpoch)) return;
|
||||
error.value = response.error ?? "session switch failed";
|
||||
failWorkbenchSessionSwitch(conversation.conversationId);
|
||||
}
|
||||
|
||||
async function selectConversationById(conversationId: string): Promise<boolean> {
|
||||
@@ -228,6 +232,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
||||
if (!current) return false;
|
||||
if (activeConversationId.value === normalized && messages.value.length > 0) return true;
|
||||
const requestEpoch = beginWorkspaceSelection();
|
||||
startWorkbenchSessionSwitch({ conversationId: normalized, source: "deeplink", targetState: "unknown", cache: "cold" });
|
||||
switchingConversationId.value = normalized;
|
||||
conversationDetailLoadingId.value = normalized;
|
||||
const existing = visibleConversations.value.find((conversation) => conversation.conversationId === normalized) ?? null;
|
||||
@@ -241,6 +246,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
||||
if (isCurrentWorkspaceSelection(requestEpoch, normalized)) clearSwitchingConversation(normalized);
|
||||
clearConversationDetailLoading(normalized);
|
||||
error.value = response.error ?? "session URL not found";
|
||||
failWorkbenchSessionSwitch(normalized);
|
||||
return false;
|
||||
}
|
||||
const selectedProjectId = conversationProjectId(conversation, activeProjectId.value);
|
||||
@@ -251,6 +257,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
||||
void refreshSessionStatusById(sessionIdFromConversation(conversation));
|
||||
void persistSelectedConversation(conversation, selectedProjectId);
|
||||
void refreshConversations(normalized);
|
||||
finishWorkbenchSessionSwitchFullLoad(normalized, "ok");
|
||||
return activeConversationId.value === normalized && messages.value.length > 0;
|
||||
}
|
||||
|
||||
@@ -464,6 +471,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
||||
return;
|
||||
}
|
||||
const steerMode = composer.value.submitMode === "steer";
|
||||
const submitEntry = steerMode ? "steer" : activeConversationId.value ? "existing" : "new";
|
||||
const traceId = steerMode && composer.value.targetTraceId ? composer.value.targetTraceId : nextProtocolId("trc");
|
||||
const steerTraceId = steerMode ? nextProtocolId("trc_steer") : null;
|
||||
const conversationId = activeConversationId.value ?? nextProtocolId("cnv");
|
||||
@@ -473,6 +481,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
||||
const user = makeMessage("user", value, "sent", { traceId: steerTraceId ?? traceId, conversationId, sessionId, threadId, title: steerMode ? "用户引导" : "用户" });
|
||||
const pending = makeMessage("agent", "", "running", { traceId, conversationId, sessionId, threadId, title: "Code Agent", retryInput: value, traceAutoLifecycle: "running" });
|
||||
messages.value.push(user, pending);
|
||||
startWorkbenchSubmitJourney({ traceId, conversationId, entry: submitEntry, backend: providerProfile.value, transport: "sse" });
|
||||
void refreshSessionStatusById(sessionId);
|
||||
chatPending.value = true;
|
||||
currentRequest.value = { traceId, conversationId, sessionId, threadId, status: "running" };
|
||||
@@ -481,6 +490,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
||||
const sessionCreate = await waitForPendingSessionCreate(conversationId);
|
||||
if (!sessionCreate.ok) {
|
||||
markMessage(traceId, { status: "failed", text: `新会话尚未准备好,消息未提交:${sessionCreate.error ?? "session create failed"}` });
|
||||
failWorkbenchSubmitJourney(traceId, "error");
|
||||
chatPending.value = false;
|
||||
currentRequest.value = null;
|
||||
return;
|
||||
@@ -490,12 +500,14 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
||||
const response = await route(payload, codeAgentTimeoutMs.value, () => activityRef.value);
|
||||
if (!response.ok || !response.data) {
|
||||
markMessage(traceId, { status: "failed", text: response.error ?? "Code Agent 请求失败" });
|
||||
failWorkbenchSubmitJourney(traceId, response.status === 0 ? "network" : "error");
|
||||
if (steerMode && response.status === 404) void clearActiveTrace(traceId, "steer-trace-not-found");
|
||||
chatPending.value = false;
|
||||
currentRequest.value = null;
|
||||
void refreshSessionStatusById(sessionId);
|
||||
return;
|
||||
}
|
||||
markWorkbenchSubmitApiAccepted(traceId);
|
||||
void refreshSessionStatusById(sessionId);
|
||||
alignOptimisticTurnMessages(traceId, response.data);
|
||||
applyTurnStatusSnapshot(traceId, response.data);
|
||||
@@ -611,6 +623,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
||||
|
||||
function applyTraceHydrationResult(traceId: string, result: AgentChatResultResponse): void {
|
||||
const events = Array.isArray(result.events) ? result.events : Array.isArray(result.traceEvents) ? result.traceEvents : [];
|
||||
markWorkbenchTraceEventsReceived({ traceId, events, transport: "rest_gap" });
|
||||
messages.value = messages.value.map((message) => {
|
||||
if (firstNonEmptyString(message.traceId, message.runnerTrace?.traceId) !== traceId || message.role !== "agent") return message;
|
||||
const runnerTrace = mergeRunnerTrace(message.runnerTrace, {
|
||||
@@ -647,6 +660,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
||||
rememberTraceAuthority(runnerTrace);
|
||||
return { ...message, status, text, runnerTrace, error: error ?? message.error ?? null, agentRun: agentRun ?? undefined, updatedAt: new Date().toISOString() };
|
||||
});
|
||||
markWorkbenchTraceProjected(traceId);
|
||||
}
|
||||
|
||||
function setProviderProfile(value: ProviderProfile): void {
|
||||
@@ -788,6 +802,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
||||
const id = firstNonEmptyString(traceId, event?.traceId, snapshot?.traceId);
|
||||
if (!id) return;
|
||||
const events = event ? [event] : Array.isArray(snapshot?.events) ? snapshot.events : [];
|
||||
markWorkbenchTraceEventsReceived({ traceId: id, events, transport: "sse" });
|
||||
applyTraceSnapshot(id, realtimeSnapshotToTraceSnapshot(id, snapshot ?? { traceId: id, status: event?.status, events }, events));
|
||||
if (event?.terminal === true || isTerminalMessageStatus(event?.status) || isTerminalMessageStatus(snapshot?.status)) void refreshTerminalTraceFromRest(id, "realtime-trace-event");
|
||||
}
|
||||
@@ -867,6 +882,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
||||
rememberTraceAuthority(runnerTrace);
|
||||
return { ...message, status, traceAutoLifecycle: "running", runnerTrace, updatedAt: new Date().toISOString() };
|
||||
});
|
||||
markWorkbenchTraceProjected(traceId);
|
||||
void refreshSessionStatusById(trace.sessionId ?? selectedSessionId.value);
|
||||
}
|
||||
|
||||
@@ -882,6 +898,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
||||
return { ...message, status: terminalStatus, text, traceAutoLifecycle: "terminal", runnerTrace, error: error ?? message.error ?? null, agentRun: agentRun ?? undefined, updatedAt: new Date().toISOString() };
|
||||
});
|
||||
rememberTurnStatus(traceId, result);
|
||||
markWorkbenchTraceProjected(traceId);
|
||||
void hydrateTraceEvents(messages.value);
|
||||
chatPending.value = false;
|
||||
currentRequest.value = null;
|
||||
@@ -1069,6 +1086,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
||||
currentRequest.value = null;
|
||||
await refreshSelectedSessionStatus(conversation);
|
||||
await refreshConversations(conversation.conversationId);
|
||||
finishWorkbenchSessionSwitchFullLoad(conversation.conversationId, selectedConversation ? "ok" : "partial");
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,414 @@
|
||||
// SPEC: PJ2026-01060505 Workbench Performance draft-2026-06-17-p0
|
||||
// Browser-side Workbench journey probe. Prometheus label shaping is enforced again by cloud-api.
|
||||
|
||||
import type { ChatMessage, TraceEvent } from "@/types";
|
||||
|
||||
type WorkbenchEventKind = "workbench_journey" | "workbench_event_phase" | "workbench_backend_event_visible";
|
||||
type WorkbenchOutcome = "ok" | "timeout" | "error" | "dropped" | "stale" | "partial" | "empty" | "network" | "unknown";
|
||||
|
||||
interface WorkbenchPerformanceEvent {
|
||||
kind: WorkbenchEventKind;
|
||||
valueMs: number;
|
||||
route?: string;
|
||||
journey?: string;
|
||||
phase?: string;
|
||||
eventType?: string;
|
||||
backend?: string;
|
||||
transport?: string;
|
||||
entry?: string;
|
||||
source?: string;
|
||||
targetState?: string;
|
||||
cache?: string;
|
||||
authState?: string;
|
||||
visibility?: string;
|
||||
outcome?: WorkbenchOutcome;
|
||||
}
|
||||
|
||||
interface OpenJourneyState {
|
||||
startAt: number;
|
||||
route: string;
|
||||
cache: string;
|
||||
authState: string;
|
||||
firstVisibleReported: boolean;
|
||||
fullLoadReported: boolean;
|
||||
}
|
||||
|
||||
interface SessionSwitchState {
|
||||
conversationId: string;
|
||||
startAt: number;
|
||||
route: string;
|
||||
source: string;
|
||||
targetState: string;
|
||||
cache: string;
|
||||
firstVisibleReported: boolean;
|
||||
fullLoadReported: boolean;
|
||||
}
|
||||
|
||||
interface SubmitJourneyState {
|
||||
traceId: string;
|
||||
conversationId: string;
|
||||
startAt: number;
|
||||
route: string;
|
||||
entry: string;
|
||||
backend: string;
|
||||
transport: string;
|
||||
firstVisibleReported: boolean;
|
||||
apiAcceptedReported: boolean;
|
||||
}
|
||||
|
||||
interface TraceEventState {
|
||||
traceId: string;
|
||||
receivedAt: number;
|
||||
projectedAt: number | null;
|
||||
eventCreatedAt: number | null;
|
||||
eventType: string;
|
||||
backend: string;
|
||||
transport: string;
|
||||
visibleReported: boolean;
|
||||
}
|
||||
|
||||
const SCHEMA_VERSION = "hwlab-web-performance-v2";
|
||||
const FLUSH_INTERVAL_MS = 2500;
|
||||
const MAX_QUEUE = 120;
|
||||
const submitJourneys = new Map<string, SubmitJourneyState>();
|
||||
const sessionSwitches = new Map<string, SessionSwitchState>();
|
||||
const traceEvents = new Map<string, TraceEventState>();
|
||||
let openJourney: OpenJourneyState | null = null;
|
||||
let queue: WorkbenchPerformanceEvent[] = [];
|
||||
let installed = false;
|
||||
let flushTimer: number | null = null;
|
||||
|
||||
export function startWorkbenchOpenJourney(input: { route?: string; cache?: "warm" | "cold" | "unknown"; authState?: "warm" | "login_redirect" | "unknown" } = {}): void {
|
||||
ensureInstalled();
|
||||
openJourney = {
|
||||
startAt: monotonicNow(),
|
||||
route: routeTemplate(input.route ?? pageRoute()),
|
||||
cache: input.cache ?? "unknown",
|
||||
authState: input.authState ?? "unknown",
|
||||
firstVisibleReported: false,
|
||||
fullLoadReported: false
|
||||
};
|
||||
}
|
||||
|
||||
export function finishWorkbenchOpenFullLoad(outcome: WorkbenchOutcome = "ok"): void {
|
||||
if (!openJourney || openJourney.fullLoadReported) return;
|
||||
openJourney.fullLoadReported = true;
|
||||
enqueue({
|
||||
kind: "workbench_journey",
|
||||
journey: "workbench_open_full_load",
|
||||
route: openJourney.route,
|
||||
cache: openJourney.cache,
|
||||
authState: openJourney.authState,
|
||||
visibility: visibilityState(),
|
||||
outcome,
|
||||
valueMs: elapsedSince(openJourney.startAt)
|
||||
});
|
||||
}
|
||||
|
||||
export function startWorkbenchSessionSwitch(input: { conversationId: string | null | undefined; source: "rail" | "deeplink" | "history" | "direct"; targetState?: string | null; cache?: "warm" | "cold" | "unknown" }): void {
|
||||
const conversationId = safeText(input.conversationId);
|
||||
if (!conversationId) return;
|
||||
ensureInstalled();
|
||||
sessionSwitches.set(conversationId, {
|
||||
conversationId,
|
||||
startAt: monotonicNow(),
|
||||
route: pageRoute(),
|
||||
source: input.source,
|
||||
targetState: normalizeTargetState(input.targetState),
|
||||
cache: input.cache ?? "unknown",
|
||||
firstVisibleReported: false,
|
||||
fullLoadReported: false
|
||||
});
|
||||
}
|
||||
|
||||
export function finishWorkbenchSessionSwitchFullLoad(conversationId: string | null | undefined, outcome: WorkbenchOutcome = "ok"): void {
|
||||
const state = sessionSwitches.get(safeText(conversationId));
|
||||
if (!state || state.fullLoadReported) return;
|
||||
state.fullLoadReported = true;
|
||||
enqueue({
|
||||
kind: "workbench_journey",
|
||||
journey: "session_switch_full_load",
|
||||
route: state.route,
|
||||
source: state.source,
|
||||
targetState: state.targetState,
|
||||
cache: state.cache,
|
||||
visibility: visibilityState(),
|
||||
outcome,
|
||||
valueMs: elapsedSince(state.startAt)
|
||||
});
|
||||
}
|
||||
|
||||
export function failWorkbenchSessionSwitch(conversationId: string | null | undefined): void {
|
||||
finishWorkbenchSessionSwitchFullLoad(conversationId, "error");
|
||||
}
|
||||
|
||||
export function startWorkbenchSubmitJourney(input: { traceId: string; conversationId: string; entry: "new" | "existing" | "steer" | "retry"; backend?: string | null; transport?: string | null }): void {
|
||||
if (!input.traceId || !input.conversationId) return;
|
||||
ensureInstalled();
|
||||
submitJourneys.set(input.traceId, {
|
||||
traceId: input.traceId,
|
||||
conversationId: input.conversationId,
|
||||
startAt: monotonicNow(),
|
||||
route: pageRoute(),
|
||||
entry: input.entry,
|
||||
backend: normalizeBackend(input.backend),
|
||||
transport: normalizeTransport(input.transport ?? "sse"),
|
||||
firstVisibleReported: false,
|
||||
apiAcceptedReported: false
|
||||
});
|
||||
}
|
||||
|
||||
export function markWorkbenchSubmitApiAccepted(traceId: string | null | undefined): void {
|
||||
const state = submitJourneys.get(safeText(traceId));
|
||||
if (!state || state.apiAcceptedReported) return;
|
||||
state.apiAcceptedReported = true;
|
||||
enqueue({
|
||||
kind: "workbench_event_phase",
|
||||
phase: "user_submit_to_api_accepted",
|
||||
eventType: "request",
|
||||
backend: state.backend,
|
||||
transport: state.transport,
|
||||
outcome: "ok",
|
||||
valueMs: elapsedSince(state.startAt)
|
||||
});
|
||||
}
|
||||
|
||||
export function failWorkbenchSubmitJourney(traceId: string | null | undefined, outcome: WorkbenchOutcome = "error"): void {
|
||||
const state = submitJourneys.get(safeText(traceId));
|
||||
if (!state || state.firstVisibleReported) return;
|
||||
state.firstVisibleReported = true;
|
||||
enqueue({
|
||||
kind: "workbench_journey",
|
||||
journey: "submit_to_first_visible",
|
||||
route: state.route,
|
||||
entry: state.entry,
|
||||
backend: state.backend,
|
||||
transport: state.transport,
|
||||
visibility: visibilityState(),
|
||||
outcome,
|
||||
valueMs: elapsedSince(state.startAt)
|
||||
});
|
||||
submitJourneys.delete(state.traceId);
|
||||
}
|
||||
|
||||
export function markWorkbenchTraceEventsReceived(input: { traceId: string | null | undefined; events: TraceEvent[]; transport: "sse" | "rest_gap" | "poll" }): void {
|
||||
const traceId = safeText(input.traceId);
|
||||
if (!traceId) return;
|
||||
const event = input.events.find(isUserVisibleTraceEvent) ?? input.events[0];
|
||||
if (!event) return;
|
||||
const eventType = eventTypeFromTraceEvent(event);
|
||||
const backend = backendFromTraceEvent(event);
|
||||
const eventCreatedAt = traceEventCreatedAt(event);
|
||||
const receivedAt = monotonicNow();
|
||||
traceEvents.set(traceId, { traceId, receivedAt, projectedAt: null, eventCreatedAt, eventType, backend, transport: input.transport, visibleReported: false });
|
||||
if (eventCreatedAt !== null) {
|
||||
enqueue({ kind: "workbench_event_phase", phase: "sse_to_receive", eventType, backend, transport: input.transport, outcome: "ok", valueMs: Math.max(0, wallNow() - eventCreatedAt) });
|
||||
}
|
||||
}
|
||||
|
||||
export function markWorkbenchTraceProjected(traceId: string | null | undefined): void {
|
||||
const state = traceEvents.get(safeText(traceId));
|
||||
if (!state || state.projectedAt !== null) return;
|
||||
const projectedAt = monotonicNow();
|
||||
state.projectedAt = projectedAt;
|
||||
enqueue({ kind: "workbench_event_phase", phase: "receive_to_project", eventType: state.eventType, backend: state.backend, transport: state.transport, outcome: "ok", valueMs: Math.max(0, projectedAt - state.receivedAt) });
|
||||
}
|
||||
|
||||
export function acknowledgeWorkbenchVisibleAfterPaint(input: { messages: ChatMessage[]; activeConversationId?: string | null; detailLoading?: boolean }): void {
|
||||
afterNextPaint(() => acknowledgeWorkbenchVisible(input));
|
||||
}
|
||||
|
||||
export function acknowledgeWorkbenchVisible(input: { messages: ChatMessage[]; activeConversationId?: string | null; detailLoading?: boolean }): void {
|
||||
const now = monotonicNow();
|
||||
const route = pageRoute();
|
||||
if (openJourney && !openJourney.firstVisibleReported) {
|
||||
openJourney.firstVisibleReported = true;
|
||||
enqueue({ kind: "workbench_journey", journey: "workbench_open_first_visible", route: openJourney.route || route, cache: openJourney.cache, authState: openJourney.authState, visibility: visibilityState(), outcome: "ok", valueMs: Math.max(0, now - openJourney.startAt) });
|
||||
}
|
||||
const activeConversationId = safeText(input.activeConversationId);
|
||||
const session = activeConversationId ? sessionSwitches.get(activeConversationId) : null;
|
||||
if (session && !session.firstVisibleReported && !input.detailLoading) {
|
||||
session.firstVisibleReported = true;
|
||||
enqueue({ kind: "workbench_journey", journey: "session_switch_first_visible", route: session.route, source: session.source, targetState: session.targetState, cache: session.cache, visibility: visibilityState(), outcome: input.messages.length > 0 ? "ok" : "empty", valueMs: Math.max(0, now - session.startAt) });
|
||||
}
|
||||
for (const message of input.messages) {
|
||||
const traceId = safeText(message.traceId ?? message.runnerTrace?.traceId);
|
||||
if (!traceId || message.role !== "agent" || !isVisibleAgentMessage(message)) continue;
|
||||
const submit = submitJourneys.get(traceId);
|
||||
if (submit && !submit.firstVisibleReported) {
|
||||
submit.firstVisibleReported = true;
|
||||
enqueue({ kind: "workbench_journey", journey: "submit_to_first_visible", route: submit.route, entry: submit.entry, backend: submit.backend, transport: submit.transport, visibility: visibilityState(), outcome: "ok", valueMs: Math.max(0, now - submit.startAt) });
|
||||
submitJourneys.delete(traceId);
|
||||
}
|
||||
const event = traceEvents.get(traceId);
|
||||
if (event && !event.visibleReported) {
|
||||
event.visibleReported = true;
|
||||
if (event.projectedAt !== null) enqueue({ kind: "workbench_event_phase", phase: "project_to_paint", eventType: event.eventType, backend: event.backend, transport: event.transport, outcome: "ok", valueMs: Math.max(0, now - event.projectedAt) });
|
||||
if (event.eventCreatedAt !== null) enqueue({ kind: "workbench_backend_event_visible", eventType: event.eventType, backend: event.backend, transport: event.transport, outcome: "ok", valueMs: Math.max(0, wallNow() - event.eventCreatedAt) });
|
||||
traceEvents.delete(traceId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function resetWorkbenchPerformanceForTest(): void {
|
||||
submitJourneys.clear();
|
||||
sessionSwitches.clear();
|
||||
traceEvents.clear();
|
||||
openJourney = null;
|
||||
queue = [];
|
||||
if (flushTimer !== null && typeof window !== "undefined") window.clearTimeout(flushTimer);
|
||||
flushTimer = null;
|
||||
installed = false;
|
||||
}
|
||||
|
||||
export function drainWorkbenchPerformanceEventsForTest(): WorkbenchPerformanceEvent[] {
|
||||
const events = queue;
|
||||
queue = [];
|
||||
return events;
|
||||
}
|
||||
|
||||
function enqueue(event: WorkbenchPerformanceEvent): void {
|
||||
if (!Number.isFinite(event.valueMs) || event.valueMs < 0) return;
|
||||
queue.push({ ...event, valueMs: Math.round(event.valueMs * 1000) / 1000 });
|
||||
if (queue.length > MAX_QUEUE) queue.splice(0, queue.length - MAX_QUEUE);
|
||||
scheduleFlush();
|
||||
}
|
||||
|
||||
function scheduleFlush(): void {
|
||||
if (typeof window === "undefined" || flushTimer !== null) return;
|
||||
flushTimer = window.setTimeout(() => flushWorkbenchPerformance(false), FLUSH_INTERVAL_MS);
|
||||
}
|
||||
|
||||
function flushWorkbenchPerformance(useBeacon: boolean): void {
|
||||
if (flushTimer !== null && typeof window !== "undefined") {
|
||||
window.clearTimeout(flushTimer);
|
||||
flushTimer = null;
|
||||
}
|
||||
if (!queue.length || typeof window === "undefined") return;
|
||||
const events = queue.splice(0, queue.length);
|
||||
const body = JSON.stringify({ schemaVersion: SCHEMA_VERSION, serviceId: "hwlab-cloud-web", page: pageRoute(), events });
|
||||
if (useBeacon && navigator.sendBeacon) {
|
||||
navigator.sendBeacon("/v1/web-performance", new Blob([body], { type: "application/json" }));
|
||||
return;
|
||||
}
|
||||
void fetch("/v1/web-performance", { method: "POST", headers: { "content-type": "application/json" }, body, credentials: "same-origin", keepalive: true }).catch(() => undefined);
|
||||
}
|
||||
|
||||
function ensureInstalled(): void {
|
||||
if (installed || typeof document === "undefined") return;
|
||||
installed = true;
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.visibilityState === "hidden") flushWorkbenchPerformance(true);
|
||||
});
|
||||
}
|
||||
|
||||
function afterNextPaint(callback: () => void): void {
|
||||
if (typeof requestAnimationFrame !== "function") {
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
requestAnimationFrame(() => requestAnimationFrame(callback));
|
||||
}
|
||||
|
||||
function isVisibleAgentMessage(message: ChatMessage): boolean {
|
||||
if (message.runnerTrace && ((message.runnerTrace.events?.length ?? 0) > 0 || message.runnerTrace.eventCount)) return true;
|
||||
if (String(message.text ?? "").trim()) return true;
|
||||
return !["pending", "running"].includes(String(message.status ?? "").toLowerCase());
|
||||
}
|
||||
|
||||
function isUserVisibleTraceEvent(event: TraceEvent): boolean {
|
||||
const type = eventTypeFromTraceEvent(event);
|
||||
return type === "assistant" || type === "tool_call" || type === "backend" || type === "terminal" || type === "error";
|
||||
}
|
||||
|
||||
function eventTypeFromTraceEvent(event: TraceEvent | null | undefined): string {
|
||||
const raw = safeText(event?.type ?? event?.kind ?? event?.label ?? event?.status).toLowerCase();
|
||||
if (/tool/u.test(raw)) return "tool_call";
|
||||
if (/assistant|message|delta|reply/u.test(raw)) return "assistant";
|
||||
if (/terminal|complete|final/u.test(raw)) return "terminal";
|
||||
if (/error|fail|timeout/u.test(raw)) return "error";
|
||||
if (/backend|runner|agentrun/u.test(raw)) return "backend";
|
||||
if (/request|accepted/u.test(raw)) return "request";
|
||||
return "status";
|
||||
}
|
||||
|
||||
function backendFromTraceEvent(event: TraceEvent | null | undefined): string {
|
||||
const direct = safeText(event?.backend ?? event?.backendProfile ?? event?.provider ?? event?.runnerKind);
|
||||
return normalizeBackend(direct);
|
||||
}
|
||||
|
||||
function normalizeBackend(value: unknown): string {
|
||||
const text = safeText(value).toLowerCase();
|
||||
if (!text) return "unknown";
|
||||
if (text.includes("/")) return text;
|
||||
if (/^(?:codex|deepseek|openai|claude|hyue)/u.test(text)) return `agentrun-v01/${text}`;
|
||||
return text;
|
||||
}
|
||||
|
||||
function normalizeTransport(value: unknown): string {
|
||||
const text = safeText(value).toLowerCase();
|
||||
return text === "rest_gap" || text === "poll" || text === "sse" ? text : "unknown";
|
||||
}
|
||||
|
||||
function normalizeTargetState(value: unknown): string {
|
||||
const text = safeText(value).toLowerCase();
|
||||
if (text === "running" || text === "terminal" || text === "empty") return text;
|
||||
if (["completed", "failed", "timeout", "canceled"].includes(text)) return "terminal";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function traceEventCreatedAt(event: TraceEvent): number | null {
|
||||
const raw = safeText(event.createdAt ?? event.ts ?? event.timestamp);
|
||||
const parsed = raw ? Date.parse(raw) : NaN;
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function pageRoute(): string {
|
||||
if (typeof window === "undefined") return "/workbench";
|
||||
return routeTemplate(`${window.location.pathname}${window.location.hash || ""}` || "/workbench");
|
||||
}
|
||||
|
||||
function routeTemplate(input: string): string {
|
||||
const raw = safeText(input) || "/workbench";
|
||||
const pathOnly = raw.startsWith("#") ? raw.slice(1) || "/" : raw.split(/[?#]/u, 1)[0] || "/";
|
||||
const segments = pathOnly.split("/").filter(Boolean).map((part) => routeSegmentTemplate(part));
|
||||
return `/${segments.join("/")}`;
|
||||
}
|
||||
|
||||
function routeSegmentTemplate(segment: string): string {
|
||||
const decoded = safeDecode(segment);
|
||||
if (/^(?:trc|trace|thread|run|cmd|job|ses|cnv|conv|gws|box|res|pod|dp|usr)[_-]/iu.test(decoded)) return ":id";
|
||||
if (/^[0-9a-f]{8,}(?:-[0-9a-f]{4,}){2,}$/iu.test(decoded)) return ":uuid";
|
||||
if (/^[A-Za-z0-9_-]{20,}$/u.test(decoded)) return ":id";
|
||||
if (/^\d{5,}$/u.test(decoded)) return ":id";
|
||||
return decoded.replace(/[^A-Za-z0-9_.:-]/gu, "_").slice(0, 80) || "_";
|
||||
}
|
||||
|
||||
function safeDecode(value: string): string {
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function visibilityState(): string {
|
||||
if (typeof document === "undefined") return "unknown";
|
||||
return document.visibilityState === "hidden" ? "background" : "foreground";
|
||||
}
|
||||
|
||||
function monotonicNow(): number {
|
||||
return typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : Date.now();
|
||||
}
|
||||
|
||||
function wallNow(): number {
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
function elapsedSince(startAt: number): number {
|
||||
return Math.max(0, monotonicNow() - startAt);
|
||||
}
|
||||
|
||||
function safeText(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
// SPEC: PJ2026-01060505 Workbench Performance draft-2026-06-17-p0
|
||||
// Starts Workbench first-screen journey timing around route hydrate.
|
||||
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import CommandComposer from "@/components/workbench/CommandComposer.vue";
|
||||
@@ -9,6 +12,7 @@ import { useAutoRefresh } from "@/composables/useAutoRefresh";
|
||||
import { shouldReflectWorkbenchConversationUrl } from "@/router/workbench-navigation";
|
||||
import { useWorkbenchStore } from "@/stores/workbench";
|
||||
import { DEFAULT_WORKBENCH_PROJECT_ID, normalizeWorkbenchConversationId, normalizeWorkbenchProjectId } from "@/utils";
|
||||
import { finishWorkbenchOpenFullLoad, startWorkbenchOpenJourney } from "@/utils/workbench-performance";
|
||||
|
||||
const workbench = useWorkbenchStore();
|
||||
const route = useRoute();
|
||||
@@ -20,9 +24,11 @@ const componentActive = ref(false);
|
||||
onMounted(async () => {
|
||||
componentActive.value = true;
|
||||
applyingRouteConversation.value = Boolean(routeConversationId.value);
|
||||
startWorkbenchOpenJourney({ route: route.path, cache: routeConversationId.value ? "cold" : "warm", authState: "warm" });
|
||||
try {
|
||||
await workbench.hydrate({ conversationId: routeConversationId.value });
|
||||
await applyRouteConversation();
|
||||
finishWorkbenchOpenFullLoad(workbench.error ? "error" : "ok");
|
||||
} finally {
|
||||
applyingRouteConversation.value = false;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user