<!-- SPEC: PJ2026-0106050514 Workbench实时运行面 draft-2026-06-30-p0-1297-spec-first; PJ2026-010401080313 Workbench实时权威 draft-2026-07-14-p0-pure-kafka-authority. Render a bounded, stable Trace tail from the Kafka SSE projection. -->
<script setup lang="ts">
import { computed, nextTick, ref, watch } from "vue";
import type { RunnerTrace } from "@/types";
import LoadingState from "@/components/common/LoadingState.vue";
import MessageMarkdown from "@/components/workbench/MessageMarkdown.vue";
import { useWorkbenchBottomFollowScroll } from "@/composables/useWorkbenchScrollRuntime";
import { formatDisplayClock } from "@/config/runtime";
import { readWorkbenchString, writeWorkbenchString } from "@/utils/workbench-storage-runtime";
import { traceDisplayRows, traceToolAccessibilityLabel, traceToolSummary, type TraceEventRow, type TraceFinalResponsePlacement } from "../../../../../tools/src/hwlab-cli/trace-renderer.ts";

const props = defineProps<{
  trace?: RunnerTrace | null;
  defaultExpanded?: boolean;
  storageKey?: string;
  autoExpanded?: boolean | null;
  finalResponsePlacement?: TraceFinalResponsePlacement;
  outerFinalResponseText?: string | null;
}>();

const TRACE_RENDER_ROW_WINDOW = 120;
const TRACE_RENDER_EVENT_WINDOW = TRACE_RENDER_ROW_WINDOW * 2;
const TRACE_LOAD_OLDER_THRESHOLD_PX = 24;
const listRef = ref<HTMLOListElement | null>(null);
const expanded = ref(false);
const visibleRowLimit = ref(TRACE_RENDER_ROW_WINDOW);
const visibleEventLimit = ref(TRACE_RENDER_EVENT_WINDOW);
const loadingOlder = ref(false);
const expandedToolRowIds = ref(new Set<string>());
const { following, keepBottomAfterUpdate, onScroll, scrollToBottom } = useWorkbenchBottomFollowScroll(listRef, { threshold: 24 });

const events = computed(() => Array.isArray(props.trace?.events) ? props.trace.events : []);
const eventCount = computed(() => props.trace?.eventCount ?? events.value.length);
const renderEvents = computed(() => events.value.length > visibleEventLimit.value
  ? events.value.slice(-visibleEventLimit.value)
  : events.value);
const rawReadableRows = computed(() => expanded.value ? traceDisplayRows(traceRecord(props.trace), renderEvents.value as Record<string, unknown>[], {
  formatClock: formatDisplayClock,
  finalResponsePlacement: props.finalResponsePlacement,
  outerFinalResponseText: props.outerFinalResponseText
}) : []);
const readableRows = computed(() => rawReadableRows.value);
const hiddenRowCount = computed(() => Math.max(0, readableRows.value.length - visibleRowLimit.value));
const renderedRows = computed(() => hiddenRowCount.value > 0 ? readableRows.value.slice(-visibleRowLimit.value) : readableRows.value);
const inputEventWindowed = computed(() => renderEvents.value.length < events.value.length);
const hasOlderRows = computed(() => hiddenRowCount.value > 0 || inputEventWindowed.value);
const traceSummaryLabel = computed(() => {
  const count = eventCount.value;
  if (count <= 0) return String(props.trace?.status ?? "").trim() || "pending";
  return `${count} events`;
});
const projectionDiagnosticLabel = computed(() => {
  const projection = props.trace?.projection ?? null;
  const health = projection?.projectionHealth ?? props.trace?.projectionHealth;
  if (!projection || health === "caught-up") return null;
  return firstNonEmptyString(projection.blocker?.userMessage, projection.blocker?.message, projection.blocker?.summary, projection.blocker?.code ? `状态更新异常：${projection.blocker.code}` : null);
});
const emptyTraceLabel = computed(() => {
  if (projectionDiagnosticLabel.value) return projectionDiagnosticLabel.value;
  if (isTerminalTraceStatus(props.trace?.status)) return terminalTraceEmptyLabel(props.trace?.status);
  if (events.value.length > 0 || (props.trace?.fullTraceLoaded === true && eventCount.value > 0)) return "暂无可读 Trace";
  return props.trace?.fullTraceLoaded === true ? "思考中..." : "加载中...";
});
let programmaticToggleTarget: boolean | null = null;

watch(() => traceExpansionScope(), () => {
  visibleRowLimit.value = TRACE_RENDER_ROW_WINDOW;
  visibleEventLimit.value = TRACE_RENDER_EVENT_WINDOW;
  loadingOlder.value = false;
}, { immediate: true });

watch(() => [eventCount.value, props.trace?.status, readableRows.value.length, expanded.value], () => {
  if (!expanded.value) return;
  void keepBottomAfterUpdate();
});

watch(() => [traceExpansionScope(), props.defaultExpanded, props.autoExpanded] as const, (current, previous) => {
  const stored = readStoredExpanded();
  if (stored !== null) {
    setExpandedProgrammatically(stored);
    return;
  }

  const [scope, defaultExpanded, autoExpanded] = current;
  if (!previous || scope !== previous[0]) {
    setExpandedProgrammatically(autoExpanded ?? defaultExpanded ?? false);
    return;
  }
  if (typeof autoExpanded === "boolean") {
    setExpandedProgrammatically(autoExpanded);
    return;
  }
  if (defaultExpanded !== previous[1]) setExpandedProgrammatically(defaultExpanded ?? false);
}, { immediate: true });

function traceExpansionScope(): string {
  if (props.storageKey) return `storage:${props.storageKey}`;
  return `trace:${String(props.trace?.traceId ?? "")}`;
}

function rowKey(row: TraceEventRow, index: number): string {
  if (rowIsTool(row)) return `${row.rowId}-${row.sequenceAuthority}`;
  return `${row.rowId}-${row.sequenceAuthority}-${row.seq ?? "no-seq"}-${index}`;
}

function onDisclosureToggle(event: Event): void {
  const target = event.currentTarget;
  if (!(target instanceof HTMLDetailsElement)) return;
  expanded.value = target.open;
  if (target.open) scrollToBottom();
  if (programmaticToggleTarget === target.open) {
    programmaticToggleTarget = null;
    return;
  }
  programmaticToggleTarget = null;
  writeStoredExpanded(target.open);
}

function onTimelineScroll(event: Event): void {
  onScroll();
  const target = event.currentTarget;
  if (!(target instanceof HTMLOListElement)) return;
  if (target.scrollTop > TRACE_LOAD_OLDER_THRESHOLD_PX || !hasOlderRows.value || loadingOlder.value) return;
  void loadOlderRows(target);
}

async function loadOlderRows(target: HTMLOListElement): Promise<void> {
  loadingOlder.value = true;
  const previousScrollTop = target.scrollTop;
  const previousScrollHeight = target.scrollHeight;
  visibleRowLimit.value = Math.min(events.value.length, visibleRowLimit.value + TRACE_RENDER_ROW_WINDOW);
  visibleEventLimit.value = Math.min(events.value.length, visibleEventLimit.value + TRACE_RENDER_EVENT_WINDOW);
  await nextTick();
  target.scrollTop = previousScrollTop + Math.max(0, target.scrollHeight - previousScrollHeight);
  onScroll();
  loadingOlder.value = false;
}

function onToolDisclosureToggle(row: TraceEventRow, event: Event): void {
  const target = event.currentTarget;
  if (!(target instanceof HTMLDetailsElement)) return;
  if (target.open) expandedToolRowIds.value.add(row.rowId);
  else expandedToolRowIds.value.delete(row.rowId);
}

function setExpandedProgrammatically(value: boolean): void {
  if (expanded.value === value) return;
  programmaticToggleTarget = value;
  expanded.value = value;
}

function readStoredExpanded(): boolean | null {
  if (!props.storageKey) return null;
  const value = readWorkbenchString(props.storageKey, "");
  if (value === "1") return true;
  if (value === "0") return false;
  return null;
}

function writeStoredExpanded(value: boolean): void {
  if (!props.storageKey) return;
  writeWorkbenchString(props.storageKey, value ? "1" : "0");
}

function rowIsTool(row: TraceEventRow): boolean {
  return row.rowId.startsWith("tool:") || /commandExecution/u.test(row.header);
}

function toolCommandPreview(row: TraceEventRow): string | null {
  if (!rowIsTool(row)) return null;
  return traceToolSummary(row) || null;
}

function traceRecord(trace: RunnerTrace | null | undefined): Record<string, unknown> {
  return trace && typeof trace === "object" ? trace as Record<string, unknown> : {};
}

function isTerminalTraceStatus(status: unknown): boolean {
  return ["completed", "failed", "blocked", "timeout", "canceled", "cancelled"].includes(String(status ?? "").toLowerCase());
}

function terminalTraceEmptyLabel(status: unknown): string {
  switch (String(status ?? "").toLowerCase()) {
    case "completed": return "运行已完成，暂无可读 Trace";
    case "failed":
    case "blocked": return "运行失败，暂无可读 Trace";
    case "timeout": return "运行超时，暂无可读 Trace";
    case "canceled":
    case "cancelled": return "运行已取消，暂无可读 Trace";
    default: return "运行已结束，暂无可读 Trace";
  }
}

function firstNonEmptyString(...values: unknown[]): string | null {
  for (const value of values) {
    if (typeof value !== "string") continue;
    const text = value.trim();
    if (text) return text;
  }
  return null;
}

</script>

<template>
  <section v-if="trace" class="trace-timeline" :data-trace-id="trace.traceId || undefined" :data-status="trace.status" :data-projection-health="trace.projection?.projectionHealth || trace.projectionHealth || undefined" :data-following="following ? 'true' : 'false'">
    <details class="trace-disclosure" :open="expanded" :data-event-count="eventCount" :data-render-input-count="renderEvents.length" :data-readable-row-count="readableRows.length" :data-rendered-row-count="renderedRows.length" :data-row-windowed="hiddenRowCount > 0 || inputEventWindowed ? 'true' : 'false'" @toggle="onDisclosureToggle">
      <summary class="trace-disclosure-summary" aria-label="折叠运行记录">
        <span class="trace-disclosure-caret" aria-hidden="true" />
        <span class="trace-disclosure-label">运行记录</span>
        <span class="trace-disclosure-count">{{ traceSummaryLabel }}</span>
      </summary>
      <div v-if="expanded" class="trace-disclosure-body">
        <p v-if="hasOlderRows" class="trace-window-notice" :data-loading="loadingOlder ? 'true' : 'false'">已显示 {{ renderedRows.length }} 行，共 {{ eventCount }} events</p>
        <ol v-if="renderedRows.length > 0" ref="listRef" @scroll="onTimelineScroll">
          <li v-for="(row, index) in renderedRows" :key="rowKey(row, index)" v-memo="[trace.traceId, row.rowId, row.seq, row.header, row.body, row.bodyFormat, row.tone, row.toolState, row.terminal, hiddenRowCount + index, expandedToolRowIds.has(row.rowId)]" class="trace-render-row" data-testid="trace-render-row" :data-trace-id="trace.traceId || undefined" :data-event-seq="row.seq ?? undefined" :data-event-seq-authority="row.sequenceAuthority" :data-projected-seq="row.sequenceAuthority === 'projected' ? row.seq ?? undefined : undefined" :data-event-kind="row.rowId" :data-event-status="row.tone" :data-tool-state="row.toolState ?? undefined" :data-terminal="row.terminal ? 'true' : 'false'" :data-row-kind="rowIsTool(row) ? 'tool' : 'message'" :data-row-id="row.rowId" :aria-posinset="hiddenRowCount + index + 1">
            <details v-if="rowIsTool(row)" class="trace-tool-details" :aria-busy="row.toolState === 'running' ? 'true' : undefined" @toggle="onToolDisclosureToggle(row, $event)">
              <summary :aria-label="traceToolAccessibilityLabel(row)" :title="traceToolAccessibilityLabel(row)" aria-live="polite" aria-atomic="true">
                <code>{{ row.header }}</code>
                <span v-if="toolCommandPreview(row)" class="trace-tool-command-preview">{{ toolCommandPreview(row) }}</span>
              </summary>
              <template v-if="expandedToolRowIds.has(row.rowId)">
                <MessageMarkdown v-if="row.body && row.bodyFormat === 'markdown'" class="trace-row-body trace-row-markdown" :source="row.body" />
                <pre v-else-if="row.body" class="trace-row-body">{{ row.body }}</pre>
                <span v-else class="trace-row-body muted">已记录</span>
              </template>
            </details>
            <template v-else>
              <code>{{ row.header }}</code>
              <MessageMarkdown v-if="row.body && row.bodyFormat === 'markdown'" class="trace-row-body trace-row-markdown" :source="row.body" />
              <pre v-else-if="row.body" class="trace-row-body">{{ row.body }}</pre>
              <span v-else class="trace-row-body muted">已记录</span>
            </template>
          </li>
        </ol>
        <LoadingState v-if="readableRows.length === 0" class="trace-empty" :label="emptyTraceLabel" compact />
      </div>
    </details>
  </section>
</template>

<style scoped>
.trace-render-row[data-row-kind="tool"] {
  --trace-tool-accent: #64748b;
  --trace-tool-background: #f8fafc;
  --trace-tool-foreground: #334155;
}

.trace-render-row[data-row-kind="tool"][data-tool-state="success"] {
  --trace-tool-accent: #15803d;
  --trace-tool-background: #f0fdf4;
  --trace-tool-foreground: #166534;
}

.trace-render-row[data-row-kind="tool"][data-tool-state="failure"] {
  --trace-tool-accent: #b91c1c;
  --trace-tool-background: #fef2f2;
  --trace-tool-foreground: #991b1b;
}

.trace-render-row[data-row-kind="tool"][data-tool-state="running"] {
  --trace-tool-accent: #0369a1;
  --trace-tool-background: #f0f9ff;
  --trace-tool-foreground: #075985;
}

.trace-render-row[data-row-kind="tool"] .trace-tool-details > summary {
  box-sizing: border-box;
  height: 26px;
  min-height: 26px;
  border-left: 3px solid var(--trace-tool-accent);
  border-radius: 3px;
  background: var(--trace-tool-background);
  color: var(--trace-tool-foreground);
  padding: 3px 6px;
  transition: background-color 140ms ease, border-color 140ms ease, color 140ms ease, box-shadow 140ms ease;
}

.trace-render-row[data-row-kind="tool"] .trace-tool-details > summary::before {
  border-left-color: currentColor;
}

.trace-render-row[data-row-kind="tool"] .trace-tool-details > summary code {
  width: auto;
  max-width: none;
  flex: 0 0 auto;
  background: transparent;
  color: inherit;
}

.trace-render-row[data-row-kind="tool"] .trace-tool-command-preview {
  color: inherit;
}

.trace-render-row[data-row-kind="tool"] .trace-tool-details > summary:focus-visible {
  outline: 2px solid var(--trace-tool-accent);
  outline-offset: 2px;
}

.trace-render-row[data-row-kind="tool"][data-tool-state="running"] .trace-tool-details > summary {
  animation: trace-tool-running-pulse 1.4s ease-in-out infinite;
}

@keyframes trace-tool-running-pulse {
  0%,
  100% {
    box-shadow: inset 0 0 0 1px transparent;
  }

  50% {
    background: #e0f2fe;
    box-shadow: inset 0 0 0 1px #7dd3fc;
  }
}

@media (prefers-reduced-motion: reduce) {
  .trace-render-row[data-row-kind="tool"][data-tool-state="running"] .trace-tool-details > summary {
    animation: none;
  }
}

@media (forced-colors: active) {
  .trace-render-row[data-row-kind="tool"] .trace-tool-details > summary {
    border: 1px solid CanvasText;
    border-left-width: 3px;
  }

  .trace-render-row[data-row-kind="tool"][data-tool-state="running"] .trace-tool-details > summary {
    outline: 1px dashed Highlight;
  }
}
</style>
