diff --git a/bridge-browser/src/content/floating_panel_drag.ts b/bridge-browser/src/content/floating_panel_drag.ts new file mode 100644 index 0000000..c86abce --- /dev/null +++ b/bridge-browser/src/content/floating_panel_drag.ts @@ -0,0 +1,103 @@ +const DEFAULT_VIEWPORT_MARGIN = 8; + +export interface FloatingPanelPosition { + left: number; + top: number; +} + +export interface FloatingPanelSize { + height: number; + width: number; +} + +interface DragState { + initialLeft: number; + initialTop: number; + pointerX: number; + pointerY: number; +} + +export function clampFloatingPanelPosition( + position: FloatingPanelPosition, + panel: FloatingPanelSize, + viewport: FloatingPanelSize, + margin = DEFAULT_VIEWPORT_MARGIN +): FloatingPanelPosition { + const maxLeft = Math.max(margin, viewport.width - panel.width - margin); + const maxTop = Math.max(margin, viewport.height - panel.height - margin); + return { + left: Math.max(margin, Math.min(position.left, maxLeft)), + top: Math.max(margin, Math.min(position.top, maxTop)), + }; +} + +export class FloatingPanelDragController { + private clampFrame: number | null = null; + private dragState: DragState | null = null; + private positioned = false; + + public constructor(private readonly host: HTMLElement) { + window.addEventListener("mousemove", this.handleMouseMove); + window.addEventListener("mouseup", this.handleMouseUp); + window.addEventListener("resize", this.scheduleClamp); + } + + public bindHandle(handle: HTMLElement): void { + handle.onmousedown = (event) => this.startDrag(event); + } + + public scheduleClamp = (): void => { + if (!this.positioned || this.clampFrame !== null) {return;} + this.clampFrame = requestAnimationFrame(() => { + this.clampFrame = null; + this.clampCurrentPosition(); + }); + }; + + private startDrag(event: MouseEvent): void { + if (event.button !== 0 || (event.target as Element | null)?.closest?.("button")) {return;} + const rect = this.host.getBoundingClientRect(); + this.dragState = { + initialLeft: rect.left, + initialTop: rect.top, + pointerX: event.clientX, + pointerY: event.clientY, + }; + this.positioned = true; + this.applyPosition({ left: rect.left, top: rect.top }, rect); + event.preventDefault(); + event.stopPropagation(); + } + + private readonly handleMouseMove = (event: MouseEvent): void => { + if (!this.dragState) {return;} + const rect = this.host.getBoundingClientRect(); + this.applyPosition({ + left: this.dragState.initialLeft + event.clientX - this.dragState.pointerX, + top: this.dragState.initialTop + event.clientY - this.dragState.pointerY, + }, rect); + event.preventDefault(); + }; + + private readonly handleMouseUp = (): void => { + this.dragState = null; + }; + + private clampCurrentPosition(): void { + if (this.host.style.display === "none") {return;} + const rect = this.host.getBoundingClientRect(); + this.applyPosition({ left: rect.left, top: rect.top }, rect); + } + + private applyPosition(position: FloatingPanelPosition, panel: FloatingPanelSize): void { + const clamped = clampFloatingPanelPosition(position, panel, { + height: window.innerHeight, + width: window.innerWidth, + }); + const bottom = window.innerHeight - clamped.top - panel.height; + this.host.style.left = `${clamped.left}px`; + this.host.style.top = "auto"; + this.host.style.right = "auto"; + this.host.style.bottom = `${bottom}px`; + } +} diff --git a/bridge-browser/src/content/tool_activity_overlay.ts b/bridge-browser/src/content/tool_activity_overlay.ts index 61bbb1a..f60130d 100644 --- a/bridge-browser/src/content/tool_activity_overlay.ts +++ b/bridge-browser/src/content/tool_activity_overlay.ts @@ -3,107 +3,166 @@ import { t } from "../modules/i18n"; import { type ToolActivityItem, type ToolActivitySnapshot, - type ToolActivityStatus, type ToolActivityTracker, type ToolActivityTurn, } from "./tool_activity"; +import { FloatingPanelDragController } from "./floating_panel_drag"; +import { TOOL_ACTIVITY_STYLE_TEXT } from "./tool_activity_overlay_styles"; +import { + formatTurnTime, + getActivityTurnEntries, + getDeliveryText, + getItemStatusText, + getTurnIcon, + getTurnSummary, + getTurnTone, + isTurnSettled, + type ToolActivityTurnEntry, +} from "./tool_activity_overlay_view"; -const SUCCESS_COLLAPSE_DELAY_MS = 4000; const ELAPSED_UPDATE_INTERVAL_MS = 1000; -const TERMINAL_STATUSES = new Set(["succeeded", "failed", "rejected"]); - -const STATUS_LABEL_KEYS: Record = { - awaiting_approval: "activity_awaiting_approval", - captured: "activity_captured", - executing: "activity_executing", - failed: "activity_failed", - queued: "activity_queued", - rejected: "activity_rejected", - succeeded: "activity_succeeded", -}; export class ToolActivityOverlay { - private collapseTimer: ReturnType | null = null; - private collapseTimerTurnId: string | null = null; private collapsed = false; private currentTurnId: string | null = null; private dismissedTurnId: string | null = null; + private readonly dragController: FloatingPanelDragController; + private historyVisible = false; private readonly host: HTMLDivElement; private latestSnapshot: ToolActivitySnapshot = { items: [], turns: [] }; private readonly panel: HTMLDivElement; + private readonly stack: HTMLDivElement; private ticker: ReturnType | null = null; public constructor(tracker: ToolActivityTracker) { const view = createOverlayView(); this.host = view.host; this.panel = view.panel; + this.stack = view.stack; + this.dragController = new FloatingPanelDragController(this.host); tracker.subscribe((snapshot) => this.render(snapshot)); } private render(snapshot: ToolActivitySnapshot): void { this.latestSnapshot = snapshot; - const turn = snapshot.turns.at(-1); - const items = turn ? getTurnItems(snapshot, turn) : []; - if (!turn || items.length === 0 || this.dismissedTurnId === turn.id) { + const entries = getActivityTurnEntries(snapshot); + const currentEntry = entries.at(-1); + if (!currentEntry || this.dismissedTurnId === currentEntry.turn.id) { this.host.style.display = "none"; this.syncTicker(false); return; } - if (turn.id !== this.currentTurnId) { - this.startTurn(turn.id); + if (currentEntry.turn.id !== this.currentTurnId) { + this.startTurn(currentEntry.turn.id); } + const historyScrollTop = this.getHistoryScrollTop(); + const historyEntries = entries.slice(0, -1).reverse(); this.host.style.display = "block"; + this.host.className = this.collapsed && !this.historyVisible ? "current-collapsed" : ""; this.panel.className = this.collapsed ? "panel collapsed" : "panel"; this.panel.replaceChildren( - this.createHeader(turn, items), - ...this.createExpandedContent(turn, items) + this.createCurrentHeader(currentEntry, historyEntries.length), + ...this.createCurrentDetails(currentEntry) + ); + this.stack.replaceChildren( + ...(this.historyVisible ? [this.createHistoryPanel(historyEntries)] : []), + this.panel ); - this.syncTicker(items.some((item) => item.status === "executing")); - this.syncAutoCollapse(turn, items); + this.restoreHistoryScrollTop(historyScrollTop); + this.syncTicker(snapshot.items.some((item) => item.status === "executing")); + this.dragController.scheduleClamp(); } - private createHeader(turn: ToolActivityTurn, items: ToolActivityItem[]): HTMLElement { + private createCurrentHeader(entry: ToolActivityTurnEntry, historyCount: number): HTMLElement { const header = document.createElement("div"); - header.className = "header"; + header.className = "header drag-header"; + header.title = t("activity_drag"); + this.dragController.bindHandle(header); const identity = document.createElement("div"); identity.className = "identity"; - const mark = document.createElement("span"); - mark.className = `mark ${getTurnTone(turn, items)}`; - mark.textContent = getTurnIcon(turn, items); const heading = document.createElement("div"); + heading.className = "heading"; const title = document.createElement("div"); title.className = "title"; title.textContent = `${BRANDING.productName} · ${t("activity_title")}`; const summary = document.createElement("div"); summary.className = "summary"; - summary.textContent = getTurnSummary(turn, items); + summary.textContent = getTurnSummary(entry.turn, entry.items); heading.append(title, summary); - identity.append(mark, heading); + identity.append(createTurnMark(entry.turn, entry.items), heading); const actions = document.createElement("div"); actions.className = "actions"; - actions.appendChild(this.createToggleButton()); - if (isTurnSettled(turn)) { - actions.appendChild(this.createCloseButton(turn.id)); + actions.append(this.createHistoryButton(historyCount), this.createToggleButton()); + if (isTurnSettled(entry.turn)) { + actions.appendChild(this.createCloseButton(entry.turn.id)); } header.append(identity, actions); return header; } - private createExpandedContent(turn: ToolActivityTurn, items: ToolActivityItem[]): HTMLElement[] { + private createCurrentDetails(entry: ToolActivityTurnEntry): HTMLElement[] { if (this.collapsed) {return [];} + return createTurnDetails(entry, "list"); + } + + private createHistoryButton(historyCount: number): HTMLButtonElement { + const button = document.createElement("button"); + button.type = "button"; + button.className = this.historyVisible ? "history-button active" : "history-button"; + button.title = t(this.historyVisible ? "activity_hide_history" : "activity_show_history"); + button.setAttribute("aria-label", button.title); + button.setAttribute("aria-pressed", String(this.historyVisible)); + button.textContent = `${t("activity_history")} (${historyCount})`; + button.onclick = () => { + this.historyVisible = !this.historyVisible; + this.render(this.latestSnapshot); + }; + return button; + } + + private createHistoryPanel(entries: ToolActivityTurnEntry[]): HTMLElement { + const panel = document.createElement("div"); + panel.className = "history-panel"; + + const header = document.createElement("div"); + header.className = "history-header drag-header"; + header.title = t("activity_drag"); + this.dragController.bindHandle(header); + const title = document.createElement("div"); + title.className = "history-title"; + title.textContent = `${t("activity_history")} · ${entries.length}`; + header.append(title, this.createHistoryCloseButton()); const list = document.createElement("div"); - list.className = "list"; - items.forEach((item) => list.appendChild(createActivityRow(item))); + list.className = "history-list"; + if (entries.length === 0) { + const empty = document.createElement("div"); + empty.className = "history-empty"; + empty.textContent = t("activity_no_history"); + list.appendChild(empty); + } else { + entries.forEach((entry) => list.appendChild(createHistoryTurn(entry))); + } + panel.append(header, list); + return panel; + } - const footer = document.createElement("div"); - footer.className = `footer ${getTurnTone(turn, items)}`; - footer.textContent = getDeliveryText(turn, items); - return [list, footer]; + private createHistoryCloseButton(): HTMLButtonElement { + const button = document.createElement("button"); + button.type = "button"; + button.className = "icon-button close"; + button.title = t("activity_hide_history"); + button.setAttribute("aria-label", button.title); + button.textContent = "×"; + button.onclick = () => { + this.historyVisible = false; + this.render(this.latestSnapshot); + }; + return button; } private createToggleButton(): HTMLButtonElement { @@ -136,29 +195,11 @@ export class ToolActivityOverlay { } private startTurn(turnId: string): void { - this.clearCollapseTimer(); this.currentTurnId = turnId; this.dismissedTurnId = null; this.collapsed = false; } - private syncAutoCollapse(turn: ToolActivityTurn, items: ToolActivityItem[]): void { - const shouldCollapse = turn.deliveryStatus === "delivered" && - items.every((item) => item.status === "succeeded"); - if (!shouldCollapse) { - this.clearCollapseTimer(); - return; - } - if (this.collapsed || this.collapseTimerTurnId === turn.id) {return;} - - this.collapseTimerTurnId = turn.id; - this.collapseTimer = setTimeout(() => { - this.collapseTimer = null; - this.collapsed = true; - this.render(this.latestSnapshot); - }, SUCCESS_COLLAPSE_DELAY_MS); - } - private syncTicker(shouldRun: boolean): void { if (shouldRun && !this.ticker) { this.ticker = setInterval(() => this.render(this.latestSnapshot), ELAPSED_UPDATE_INTERVAL_MS); @@ -168,13 +209,55 @@ export class ToolActivityOverlay { } } - private clearCollapseTimer(): void { - if (this.collapseTimer) {clearTimeout(this.collapseTimer);} - this.collapseTimer = null; - this.collapseTimerTurnId = null; + private getHistoryScrollTop(): number { + if (!this.historyVisible) {return 0;} + return this.stack.querySelector(".history-list")?.scrollTop ?? 0; + } + + private restoreHistoryScrollTop(scrollTop: number): void { + if (!this.historyVisible) {return;} + const history = this.stack.querySelector(".history-list"); + if (history) {history.scrollTop = scrollTop;} } } +function createHistoryTurn(entry: ToolActivityTurnEntry): HTMLElement { + const turn = document.createElement("section"); + turn.className = "history-turn"; + const header = document.createElement("div"); + header.className = "history-turn-header"; + const heading = document.createElement("div"); + heading.className = "turn-heading"; + const time = document.createElement("div"); + time.className = "turn-name"; + time.textContent = formatTurnTime(entry.turn.createdAt); + const summary = document.createElement("div"); + summary.className = "turn-meta"; + summary.textContent = getTurnSummary(entry.turn, entry.items); + heading.append(time, summary); + header.append(createTurnMark(entry.turn, entry.items), heading); + turn.append(header, ...createTurnDetails(entry, "history-tool-list")); + return turn; +} + +function createTurnMark(turn: ToolActivityTurn, items: ToolActivityItem[]): HTMLElement { + const mark = document.createElement("span"); + mark.className = `mark ${getTurnTone(turn, items)}`; + mark.textContent = getTurnIcon(turn, items); + return mark; +} + +function createTurnDetails(entry: ToolActivityTurnEntry, listClassName: string): HTMLElement[] { + const list = document.createElement("div"); + list.className = listClassName; + entry.items.forEach((item) => list.appendChild(createActivityRow(item))); + + const footer = document.createElement("div"); + footer.className = `footer ${getTurnTone(entry.turn, entry.items)}`; + footer.textContent = getDeliveryText(entry.turn, entry.items); + return [list, footer]; +} + function createActivityRow(item: ToolActivityItem): HTMLElement { const row = document.createElement("div"); row.className = `row ${item.status}`; @@ -211,125 +294,22 @@ function createTextLine(className: string, value: string): HTMLElement { return line; } -function getItemStatusText(item: ToolActivityItem): string { - const label = t(STATUS_LABEL_KEYS[item.status]); - if (item.status !== "executing" || item.startedAt === undefined) {return label;} - return `${label} · ${formatElapsed(Date.now() - item.startedAt)}`; -} - -function getTurnItems(snapshot: ToolActivitySnapshot, turn: ToolActivityTurn): ToolActivityItem[] { - const items = new Map(snapshot.items.map((item) => [item.requestKey, item])); - return turn.requestKeys.flatMap((requestKey) => { - const item = items.get(requestKey); - return item ? [item] : []; - }); -} - -function getTurnSummary(turn: ToolActivityTurn, items: ToolActivityItem[]): string { - const completedCount = items.filter((item) => TERMINAL_STATUSES.has(item.status)).length; - return `${completedCount}/${items.length} · ${getTurnStatusText(turn, items)}`; -} - -function getTurnStatusText(turn: ToolActivityTurn, items: ToolActivityItem[]): string { - if (turn.deliveryStatus === "failed") {return t("activity_delivery_failed");} - if (turn.deliveryStatus === "delivered") {return t("activity_delivered");} - if (turn.deliveryStatus === "delivering") {return t("activity_delivering");} - if (turn.deliveryStatus === "waiting" || items.every((item) => TERMINAL_STATUSES.has(item.status))) { - return t("activity_waiting_delivery"); - } - if (items.some((item) => item.status === "awaiting_approval")) { - return t("activity_awaiting_approval"); - } - if (items.some((item) => item.status === "executing")) {return t("activity_executing");} - if (items.some((item) => item.status === "queued")) {return t("activity_queued");} - return t("activity_captured"); -} - -function getDeliveryText(turn: ToolActivityTurn, items: ToolActivityItem[]): string { - const text = getTurnStatusText(turn, items); - if (turn.deliveryStatus === "pending" && items.some((item) => !TERMINAL_STATUSES.has(item.status))) { - return t("waiting_tools"); - } - return text; -} - -function getTurnTone(turn: ToolActivityTurn, items: ToolActivityItem[]): string { - if (turn.deliveryStatus === "failed" || items.some((item) => item.status === "failed")) {return "error";} - if (items.some((item) => item.status === "rejected")) {return "warn";} - if (turn.deliveryStatus === "delivered") {return "success";} - return "active"; -} - -function getTurnIcon(turn: ToolActivityTurn, items: ToolActivityItem[]): string { - const tone = getTurnTone(turn, items); - if (tone === "error") {return "!";} - if (tone === "warn") {return "×";} - if (tone === "success") {return "✓";} - return "●"; -} - -function isTurnSettled(turn: ToolActivityTurn): boolean { - return turn.deliveryStatus === "delivered" || turn.deliveryStatus === "failed"; -} - -function formatElapsed(milliseconds: number): string { - const totalSeconds = Math.max(0, Math.floor(milliseconds / 1000)); - const minutes = Math.floor(totalSeconds / 60).toString().padStart(2, "0"); - const seconds = (totalSeconds % 60).toString().padStart(2, "0"); - return `${minutes}:${seconds}`; -} - -function createOverlayView(): { host: HTMLDivElement; panel: HTMLDivElement } { +function createOverlayView(): { + host: HTMLDivElement; + panel: HTMLDivElement; + stack: HTMLDivElement; +} { const host = document.createElement("div"); host.style.display = "none"; const shadow = host.attachShadow({ mode: "open" }); const style = document.createElement("style"); style.textContent = TOOL_ACTIVITY_STYLE_TEXT; + const stack = document.createElement("div"); + stack.className = "overlay-stack"; const panel = document.createElement("div"); panel.className = "panel"; - shadow.append(style, panel); + stack.appendChild(panel); + shadow.append(style, stack); document.body.appendChild(host); - return { host, panel }; + return { host, panel, stack }; } - -const TOOL_ACTIVITY_STYLE_TEXT = ` - :host { position: fixed; right: 20px; bottom: 20px; z-index: 2147483646; width: min(390px, calc(100vw - 32px)); color-scheme: dark; } - * { box-sizing: border-box; } - button { font: inherit; } - .panel { overflow: hidden; color: #f3f4f6; background: rgba(20, 22, 26, .96); border: 1px solid #3b404a; border-radius: 12px; - box-shadow: 0 12px 34px rgba(0, 0, 0, .38); font: 12px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; backdrop-filter: blur(10px); } - .panel.collapsed { width: fit-content; min-width: 250px; margin-left: auto; } - .header { min-height: 54px; display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 9px 10px 9px 12px; } - .identity { min-width: 0; display: flex; align-items: center; gap: 10px; } - .mark { width: 24px; height: 24px; flex: 0 0 auto; display: grid; place-items: center; border-radius: 50%; color: #fff; background: #2563eb; font-weight: 700; } - .mark.active { animation: pulse 1.4s ease-in-out infinite; } - .mark.success { background: #15803d; } - .mark.warn { background: #b45309; } - .mark.error { background: #b91c1c; } - .title { overflow: hidden; color: #f9fafb; font-weight: 650; text-overflow: ellipsis; white-space: nowrap; } - .summary { overflow: hidden; margin-top: 1px; color: #aeb5c2; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; } - .actions { display: flex; gap: 3px; } - .icon-button { width: 25px; height: 25px; padding: 0; border: 0; border-radius: 6px; color: #c8ced8; background: transparent; cursor: pointer; } - .icon-button:hover { color: #fff; background: rgba(255, 255, 255, .1); } - .icon-button.close:hover { background: #8f1d1d; } - .list { max-height: min(330px, 45vh); overflow-y: auto; border-top: 1px solid #343942; } - .row { display: flex; gap: 10px; padding: 10px 12px; border-bottom: 1px solid rgba(255, 255, 255, .06); } - .status-dot { width: 8px; height: 8px; flex: 0 0 auto; margin-top: 5px; border-radius: 50%; background: #718096; } - .row.awaiting_approval .status-dot { background: #f59e0b; } - .row.executing .status-dot { background: #3b82f6; box-shadow: 0 0 0 4px rgba(59, 130, 246, .13); animation: pulse 1.2s ease-in-out infinite; } - .row.succeeded .status-dot { background: #22c55e; } - .row.failed .status-dot, .row.rejected .status-dot { background: #ef4444; } - .row-content { min-width: 0; flex: 1; } - .row-top { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; } - .tool-name { overflow: hidden; color: #f3f4f6; font: 600 12px/1.4 "SFMono-Regular", Consolas, monospace; text-overflow: ellipsis; white-space: nowrap; } - .status { flex: 0 0 auto; color: #aeb5c2; font-size: 10px; } - .purpose, .detail, .message { overflow: hidden; margin-top: 3px; text-overflow: ellipsis; white-space: nowrap; } - .purpose { color: #c4c9d2; } - .detail { color: #8fb9ff; font-family: "SFMono-Regular", Consolas, monospace; } - .message { color: #fca5a5; } - .footer { padding: 8px 12px; color: #9fbfff; background: rgba(37, 99, 235, .1); font-size: 11px; } - .footer.success { color: #86efac; background: rgba(21, 128, 61, .13); } - .footer.warn { color: #fcd34d; background: rgba(180, 83, 9, .13); } - .footer.error { color: #fca5a5; background: rgba(185, 28, 28, .13); } - @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: .55; } } -`; diff --git a/bridge-browser/src/content/tool_activity_overlay_styles.ts b/bridge-browser/src/content/tool_activity_overlay_styles.ts new file mode 100644 index 0000000..ca0bc42 --- /dev/null +++ b/bridge-browser/src/content/tool_activity_overlay_styles.ts @@ -0,0 +1,69 @@ +import { TOOL_ACTIVITY_OVERLAY_Z_INDEX } from "../modules/overlay_layers"; + +export const TOOL_ACTIVITY_STYLE_TEXT = ` + :host { position: fixed; right: 20px; bottom: 20px; z-index: ${TOOL_ACTIVITY_OVERLAY_Z_INDEX}; width: min(390px, calc(100vw - 32px)); + max-height: calc(100vh - 32px); color-scheme: dark; } + :host(.current-collapsed) { width: min(290px, calc(100vw - 16px)); } + * { box-sizing: border-box; } + button { font: inherit; } + .overlay-stack { display: flex; max-height: inherit; flex-direction: column; gap: 10px; } + .panel, .history-panel { width: 100%; display: flex; overflow: hidden; flex-direction: column; color: #f3f4f6; + background: rgba(20, 22, 26, .96); border: 1px solid #3b404a; border-radius: 12px; box-shadow: 0 12px 34px rgba(0, 0, 0, .38); + font: 12px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; backdrop-filter: blur(10px); } + .panel { max-height: min(360px, 46vh); align-self: flex-end; } + .panel.collapsed { width: 100%; min-width: 0; } + .history-panel { height: min(300px, 40vh); min-height: min(160px, 30vh); flex: 0 1 auto; } + .header, .history-header { flex: 0 0 auto; display: flex; align-items: center; justify-content: space-between; gap: 10px; user-select: none; } + .header { min-height: 54px; padding: 9px 10px 9px 12px; } + .history-header { min-height: 40px; padding: 7px 8px 7px 11px; border-bottom: 1px solid #343942; } + .drag-header { cursor: move; } + .identity { min-width: 0; flex: 1; display: flex; align-items: center; gap: 10px; } + .heading { min-width: 0; } + .mark { width: 24px; height: 24px; flex: 0 0 auto; display: grid; place-items: center; border-radius: 50%; color: #fff; + background: #2563eb; font-weight: 700; } + .mark.active { animation: pulse 1.4s ease-in-out infinite; } + .mark.success { background: #15803d; } + .mark.warn { background: #b45309; } + .mark.error { background: #b91c1c; } + .title, .history-title { overflow: hidden; color: #f9fafb; font-weight: 650; text-overflow: ellipsis; white-space: nowrap; } + .summary { overflow: hidden; margin-top: 1px; color: #aeb5c2; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; } + .actions { flex: 0 0 auto; display: flex; gap: 3px; } + .icon-button, .history-button { height: 25px; padding: 0; border: 0; border-radius: 6px; color: #c8ced8; background: transparent; cursor: pointer; } + .icon-button { width: 25px; } + .history-button { width: auto; padding: 0 7px; font-size: 10px; white-space: nowrap; } + .history-button.active { color: #dbeafe; background: rgba(37, 99, 235, .22); } + .icon-button:hover, .history-button:hover { color: #fff; background: rgba(255, 255, 255, .1); } + .icon-button.close:hover { background: #8f1d1d; } + .list { min-height: 0; flex: 1 1 auto; overflow-y: auto; border-top: 1px solid #343942; } + .row { display: flex; gap: 10px; padding: 10px 12px; border-bottom: 1px solid rgba(255, 255, 255, .06); } + .status-dot { width: 8px; height: 8px; flex: 0 0 auto; margin-top: 5px; border-radius: 50%; background: #718096; } + .row.awaiting_approval .status-dot { background: #f59e0b; } + .row.executing .status-dot { background: #3b82f6; box-shadow: 0 0 0 4px rgba(59, 130, 246, .13); animation: pulse 1.2s ease-in-out infinite; } + .row.succeeded .status-dot { background: #22c55e; } + .row.failed .status-dot, .row.rejected .status-dot { background: #ef4444; } + .row-content { min-width: 0; flex: 1; } + .row-top { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; } + .tool-name { overflow: hidden; color: #f3f4f6; font: 600 12px/1.4 "SFMono-Regular", Consolas, monospace; + text-overflow: ellipsis; white-space: nowrap; } + .status { flex: 0 0 auto; color: #aeb5c2; font-size: 10px; } + .purpose, .detail, .message { overflow: hidden; margin-top: 3px; text-overflow: ellipsis; white-space: nowrap; } + .purpose { color: #c4c9d2; } + .detail { color: #8fb9ff; font-family: "SFMono-Regular", Consolas, monospace; } + .message { color: #fca5a5; } + .footer { flex: 0 0 auto; padding: 8px 12px; color: #9fbfff; background: rgba(37, 99, 235, .1); font-size: 11px; } + .footer.success { color: #86efac; background: rgba(21, 128, 61, .13); } + .footer.warn { color: #fcd34d; background: rgba(180, 83, 9, .13); } + .footer.error { color: #fca5a5; background: rgba(185, 28, 28, .13); } + .history-list { min-height: 0; flex: 1 1 auto; overflow-y: auto; padding: 8px; } + .history-empty { padding: 18px 8px; color: #858d9a; text-align: center; } + .history-turn { overflow: hidden; border: 1px solid #343942; border-radius: 8px; background: rgba(255, 255, 255, .025); } + .history-turn + .history-turn { margin-top: 8px; } + .history-turn-header { min-height: 42px; display: flex; align-items: center; gap: 9px; padding: 7px 9px; background: rgba(255, 255, 255, .025); } + .history-turn-header .mark { width: 20px; height: 20px; font-size: 10px; } + .turn-heading { min-width: 0; flex: 1; } + .turn-name { color: #e5e7eb; font-size: 11px; font-weight: 650; } + .turn-meta { overflow: hidden; margin-top: 1px; color: #9ca3af; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; } + .history-tool-list .row { padding: 9px 10px; } + .history-turn .footer { padding: 6px 10px; } + @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: .55; } } +`; diff --git a/bridge-browser/src/content/tool_activity_overlay_view.ts b/bridge-browser/src/content/tool_activity_overlay_view.ts new file mode 100644 index 0000000..d1f0b05 --- /dev/null +++ b/bridge-browser/src/content/tool_activity_overlay_view.ts @@ -0,0 +1,116 @@ +import { t } from "../modules/i18n"; +import { + type ToolActivityItem, + type ToolActivitySnapshot, + type ToolActivityStatus, + type ToolActivityTurn, +} from "./tool_activity"; + +export type ToolActivityTone = "active" | "error" | "success" | "warn"; + +export interface ToolActivityTurnEntry { + items: ToolActivityItem[]; + turn: ToolActivityTurn; +} + +const TERMINAL_STATUSES = new Set([ + "succeeded", + "failed", + "rejected", +]); + +const STATUS_LABEL_KEYS: Record = { + awaiting_approval: "activity_awaiting_approval", + captured: "activity_captured", + executing: "activity_executing", + failed: "activity_failed", + queued: "activity_queued", + rejected: "activity_rejected", + succeeded: "activity_succeeded", +}; + +export function getActivityTurnEntries(snapshot: ToolActivitySnapshot): ToolActivityTurnEntry[] { + const items = new Map(snapshot.items.map((item) => [item.requestKey, item])); + return snapshot.turns.flatMap((turn) => { + const turnItems = turn.requestKeys.flatMap((requestKey) => { + const item = items.get(requestKey); + return item ? [item] : []; + }); + return turnItems.length > 0 ? [{ items: turnItems, turn }] : []; + }); +} + +export function getItemStatusText(item: ToolActivityItem): string { + const label = t(STATUS_LABEL_KEYS[item.status]); + if (item.status !== "executing" || item.startedAt === undefined) {return label;} + return `${label} · ${formatElapsed(Date.now() - item.startedAt)}`; +} + +export function getTurnSummary(turn: ToolActivityTurn, items: ToolActivityItem[]): string { + const completedCount = items.filter((item) => TERMINAL_STATUSES.has(item.status)).length; + const startedAt = getExecutingStartedAt(items); + const elapsed = startedAt === undefined ? "" : ` · ${formatElapsed(Date.now() - startedAt)}`; + return `${completedCount}/${items.length} · ${getTurnStatusText(turn, items)}${elapsed}`; +} + +export function getDeliveryText(turn: ToolActivityTurn, items: ToolActivityItem[]): string { + const text = getTurnStatusText(turn, items); + if (turn.deliveryStatus === "pending" && items.some((item) => !TERMINAL_STATUSES.has(item.status))) { + return t("waiting_tools"); + } + return text; +} + +export function getTurnTone(turn: ToolActivityTurn, items: ToolActivityItem[]): ToolActivityTone { + if (turn.deliveryStatus === "failed" || items.some((item) => item.status === "failed")) {return "error";} + if (items.some((item) => item.status === "rejected")) {return "warn";} + if (turn.deliveryStatus === "delivered") {return "success";} + return "active"; +} + +export function getTurnIcon(turn: ToolActivityTurn, items: ToolActivityItem[]): string { + const tone = getTurnTone(turn, items); + if (tone === "error") {return "!";} + if (tone === "warn") {return "×";} + if (tone === "success") {return "✓";} + return "●"; +} + +export function isTurnSettled(turn: ToolActivityTurn): boolean { + return turn.deliveryStatus === "delivered" || turn.deliveryStatus === "failed"; +} + +export function formatTurnTime(timestamp: number): string { + return new Intl.DateTimeFormat(undefined, { hour: "2-digit", minute: "2-digit" }).format(timestamp); +} + +function getTurnStatusText(turn: ToolActivityTurn, items: ToolActivityItem[]): string { + if (turn.deliveryStatus === "failed") {return t("activity_delivery_failed");} + if (turn.deliveryStatus === "delivered") {return t("activity_delivered");} + if (turn.deliveryStatus === "delivering") {return t("activity_delivering");} + if (turn.deliveryStatus === "waiting" || items.every((item) => TERMINAL_STATUSES.has(item.status))) { + return t("activity_waiting_delivery"); + } + if (items.some((item) => item.status === "awaiting_approval")) { + return t("activity_awaiting_approval"); + } + if (items.some((item) => item.status === "executing")) {return t("activity_executing");} + if (items.some((item) => item.status === "queued")) {return t("activity_queued");} + return t("activity_captured"); +} + +function getExecutingStartedAt(items: ToolActivityItem[]): number | undefined { + let startedAt: number | undefined; + items.forEach((item) => { + if (item.status !== "executing" || item.startedAt === undefined) {return;} + startedAt = startedAt === undefined ? item.startedAt : Math.min(startedAt, item.startedAt); + }); + return startedAt; +} + +function formatElapsed(milliseconds: number): string { + const totalSeconds = Math.max(0, Math.floor(milliseconds / 1000)); + const minutes = Math.floor(totalSeconds / 60).toString().padStart(2, "0"); + const seconds = (totalSeconds % 60).toString().padStart(2, "0"); + return `${minutes}:${seconds}`; +} diff --git a/bridge-browser/src/modules/approval_modal.ts b/bridge-browser/src/modules/approval_modal.ts index e502fb3..6ab1dab 100644 --- a/bridge-browser/src/modules/approval_modal.ts +++ b/bridge-browser/src/modules/approval_modal.ts @@ -10,6 +10,7 @@ import { } from "./command_approval"; import { isElementVisible } from "./dom_helpers"; import { t } from "./i18n"; +import { APPROVAL_MODAL_Z_INDEX } from "./overlay_layers"; import { clearUserAttention, showUserAttentionNotification, @@ -167,7 +168,7 @@ function createModalHost(): HTMLElement { const host = document.createElement("div"); Object.assign(host.style, { position: "fixed", - zIndex: 999999, + zIndex: APPROVAL_MODAL_Z_INDEX, top: 0, left: 0, width: "0", diff --git a/bridge-browser/src/modules/i18n.ts b/bridge-browser/src/modules/i18n.ts index 90664a5..98529d5 100644 --- a/bridge-browser/src/modules/i18n.ts +++ b/bridge-browser/src/modules/i18n.ts @@ -77,6 +77,11 @@ const I18N_MESSAGES: Record = { activity_minimize: { en: "Minimize", zh: "收起" }, activity_expand: { en: "Expand", zh: "展开" }, activity_close: { en: "Close", zh: "关闭" }, + activity_history: { en: "History", zh: "历史" }, + activity_show_history: { en: "Show history", zh: "显示历史" }, + activity_hide_history: { en: "Hide history", zh: "隐藏历史" }, + activity_no_history: { en: "No previous tool activity", zh: "暂无之前的工具活动" }, + activity_drag: { en: "Drag tool activity window", zh: "拖动工具活动窗口" }, hitl_title: { en: "Approval Required", zh: "请求执行工具" }, label_tool: { en: "Tool Name", zh: "工具名称" }, diff --git a/bridge-browser/src/modules/overlay_layers.ts b/bridge-browser/src/modules/overlay_layers.ts new file mode 100644 index 0000000..3d4169b --- /dev/null +++ b/bridge-browser/src/modules/overlay_layers.ts @@ -0,0 +1,2 @@ +export const TOOL_ACTIVITY_OVERLAY_Z_INDEX = 2147483646; +export const APPROVAL_MODAL_Z_INDEX = TOOL_ACTIVITY_OVERLAY_Z_INDEX + 1; diff --git a/bridge-browser/test/tool_activity.test.ts b/bridge-browser/test/tool_activity.test.ts new file mode 100644 index 0000000..32712de --- /dev/null +++ b/bridge-browser/test/tool_activity.test.ts @@ -0,0 +1,93 @@ +import { + ToolActivityTracker, + type ToolActivitySnapshot, +} from "../src/content/tool_activity"; +import { clampFloatingPanelPosition } from "../src/content/floating_panel_drag"; +import { + APPROVAL_MODAL_Z_INDEX, + TOOL_ACTIVITY_OVERLAY_Z_INDEX, +} from "../src/modules/overlay_layers"; + +function main(): void { + runTest("activity history retains only the latest eight turns", testRetainsLatestEightTurns); + runTest("approval UI stays above tool activity", testApprovalLayerPriority); + runTest("floating activity stays inside every viewport edge", testFloatingPanelBounds); +} + +function testRetainsLatestEightTurns(): void { + const tracker = new ToolActivityTracker(); + let snapshot: ToolActivitySnapshot = { items: [], turns: [] }; + tracker.subscribe((value) => {snapshot = value;}); + + for (let index = 1; index <= 9; index += 1) { + const turnId = `turn-${index}`; + tracker.capture({ + identity: { requestKey: `request-${index}` }, + payload: { name: `tool-${index}` }, + turnId, + }); + } + + assertEqual(snapshot.turns.length, 8, "unexpected retained turn count"); + assertEqual(snapshot.items.length, 8, "pruned turn items were retained"); + assertEqual(snapshot.turns[0]?.id, "turn-2", "oldest retained turn was incorrect"); + assertEqual(snapshot.turns.at(-1)?.id, "turn-9", "latest turn was not retained"); +} + +function testApprovalLayerPriority(): void { + assert( + APPROVAL_MODAL_Z_INDEX > TOOL_ACTIVITY_OVERLAY_Z_INDEX, + "tool activity can cover the approval modal" + ); +} + +function testFloatingPanelBounds(): void { + const panel = { height: 200, width: 300 }; + const viewport = { height: 600, width: 800 }; + assertPosition( + clampFloatingPanelPosition({ left: -100, top: -100 }, panel, viewport), + { left: 8, top: 8 }, + "top-left position was not clamped" + ); + assertPosition( + clampFloatingPanelPosition({ left: 900, top: 900 }, panel, viewport), + { left: 492, top: 392 }, + "bottom-right position was not clamped" + ); + assertPosition( + clampFloatingPanelPosition({ left: 100, top: 100 }, { height: 700, width: 900 }, viewport), + { left: 8, top: 8 }, + "oversized panel did not leave its header reachable" + ); +} + +function assertPosition( + actual: { left: number; top: number }, + expected: { left: number; top: number }, + message: string +): void { + assertEqual(actual.left, expected.left, `${message} (left)`); + assertEqual(actual.top, expected.top, `${message} (top)`); +} + +function runTest(name: string, test: () => void): void { + try { + test(); + console.log(`PASS ${name}`); + } catch (error) { + console.error(`FAIL ${name}`); + throw error; + } +} + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) {throw new Error(message);} +} + +function assertEqual(actual: unknown, expected: unknown, message: string): void { + if (actual !== expected) { + throw new Error(`${message}: expected ${String(expected)}, received ${String(actual)}`); + } +} + +main(); diff --git a/bridge-browser/test/tool_activity_overlay.test.ts b/bridge-browser/test/tool_activity_overlay.test.ts new file mode 100644 index 0000000..e8110cd --- /dev/null +++ b/bridge-browser/test/tool_activity_overlay.test.ts @@ -0,0 +1,363 @@ +import { ToolActivityTracker } from "../src/content/tool_activity"; + +type OverlayConstructor = new (tracker: ToolActivityTracker) => unknown; + +interface FakeRect { + height: number; + left: number; + top: number; + width: number; +} + +interface FakeMouseEvent { + button: number; + clientX: number; + clientY: number; + preventDefault: () => void; + stopPropagation: () => void; + target: FakeElement; +} + +class FakeElement { + public readonly children: FakeElement[] = []; + public className = ""; + public onclick: (() => void) | null = null; + public onmousedown: ((event: FakeMouseEvent) => void) | null = null; + public parentElement: FakeElement | null = null; + public scrollTop = 0; + public shadowRoot: FakeElement | null = null; + public readonly style: Record = {}; + public textContent = ""; + public title = ""; + public type = ""; + private readonly attributes = new Map(); + private rect: FakeRect = { height: 0, left: 0, top: 0, width: 0 }; + + public constructor(private readonly tagName = "div") {} + + public append(...children: FakeElement[]): void { + children.forEach((child) => this.appendChild(child)); + } + + public appendChild(child: FakeElement): FakeElement { + child.parentElement = this; + this.children.push(child); + return child; + } + + public attachShadow(): FakeElement { + this.shadowRoot = new FakeElement("shadow-root"); + return this.shadowRoot; + } + + public click(): void { + this.onclick?.(); + } + + public closest(selector: string): FakeElement | null { + if (selector === "button" && this.tagName === "button") {return this;} + return this.parentElement?.closest(selector) ?? null; + } + + public getBoundingClientRect(): DOMRect { + const width = this.rect.width; + const height = this.rect.height; + const left = parsePixels(this.style.left) ?? this.getRightAnchoredLeft(width) ?? this.rect.left; + const top = parsePixels(this.style.top) ?? this.getBottomAnchoredTop(height) ?? this.rect.top; + return { + bottom: top + height, + height, + left, + right: left + width, + top, + width, + x: left, + y: top, + toJSON: () => ({}), + }; + } + + public getText(): string { + return `${this.textContent}${this.children.map((child) => child.getText()).join("")}`; + } + + public mouseDown(event: FakeMouseEvent): void { + this.onmousedown?.(event); + } + + public querySelector(selector: string): T | null { + const match = this.findByClass(selector.startsWith(".") ? selector.slice(1) : selector); + return match as T | null; + } + + public replaceChildren(...children: FakeElement[]): void { + this.children.length = 0; + this.append(...children); + } + + public setAttribute(name: string, value: string): void { + this.attributes.set(name, value); + } + + public setRect(rect: FakeRect): void { + this.rect = rect; + } + + private findByClass(className: string): FakeElement | null { + if (this.className.split(/\s+/).includes(className)) {return this;} + for (const child of this.children) { + const match = child.findByClass(className); + if (match) {return match;} + } + return null; + } + + private getBottomAnchoredTop(height: number): number | null { + const bottom = parsePixels(this.style.bottom); + return bottom === null ? null : fakeWindow.innerHeight - bottom - height; + } + + private getRightAnchoredLeft(width: number): number | null { + const right = parsePixels(this.style.right); + return right === null ? null : fakeWindow.innerWidth - right - width; + } +} + +class FakeDocument { + public readonly body = new FakeElement("body"); + + public createElement(tagName: string): FakeElement { + return new FakeElement(tagName); + } + + public reset(): void { + this.body.replaceChildren(); + } +} + +class FakeWindow { + public innerHeight = 700; + public innerWidth = 1000; + private animationFrameId = 1; + private readonly animationFrames = new Map void>(); + private readonly listeners = new Map void>>(); + + public addEventListener(type: string, listener: unknown): void { + if (typeof listener !== "function") {return;} + const listeners = this.listeners.get(type) ?? new Set<(event: unknown) => void>(); + listeners.add(listener as (event: unknown) => void); + this.listeners.set(type, listeners); + } + + public dispatch(type: string, event: unknown): void { + this.listeners.get(type)?.forEach((listener) => listener(event)); + } + + public flushAnimationFrames(): void { + const callbacks = Array.from(this.animationFrames.values()); + this.animationFrames.clear(); + callbacks.forEach((callback) => callback()); + } + + public queueAnimationFrame(callback: () => void): number { + const id = this.animationFrameId++; + this.animationFrames.set(id, callback); + return id; + } + + public reset(): void { + this.innerHeight = 700; + this.innerWidth = 1000; + this.animationFrames.clear(); + this.listeners.clear(); + } +} + +const fakeDocument = new FakeDocument(); +const fakeWindow = new FakeWindow(); +let scheduledTimeoutCount = 0; + +async function main(): Promise { + installBrowserGlobals(); + const { ToolActivityOverlay } = await import("../src/content/tool_activity_overlay"); + runTest("history opens as a separate detailed block and keeps current status live", () => { + testDetailedHistoryBlock(ToolActivityOverlay); + }); + runTest("a new turn stays current while the prior turn enters detailed history", () => { + testNewTurnUpdatesHistory(ToolActivityOverlay); + }); + runTest("dragging moves the activity stack without losing viewport access", () => { + testUnifiedBoundedDragging(ToolActivityOverlay); + }); +} + +function testDetailedHistoryBlock(Overlay: OverlayConstructor): void { + const harness = createHarness(Overlay); + const firstKey = captureTurn(harness.tracker, "turn-1", "read_file"); + settleTurn(harness.tracker, firstKey); + const currentKey = captureTurn(harness.tracker, "turn-2", "execute_command"); + harness.tracker.updateStatus({ requestKey: currentKey }, "executing"); + + assert(!harness.stack.querySelector(".tabs"), "legacy current/history tabs remain"); + getRequired(harness.panel, ".history-button").click(); + const historyPanel = getRequired(harness.stack, ".history-panel"); + assertEqual(harness.stack.children[0], historyPanel, "history block was not placed above current activity"); + assertEqual(harness.stack.children[1], harness.panel, "current block moved outside the shared stack"); + assertIncludes(historyPanel.getText(), "read_file", "history omitted detailed tool data"); + assertIncludes(historyPanel.getText(), "Run read_file", "history omitted the tool purpose"); + assertIncludes(harness.panel.getText(), "Running", "current status was not kept visible"); + + const historyBeforeUpdate = getRequired(harness.stack, ".history-list"); + historyBeforeUpdate.scrollTop = 41; + harness.tracker.updateStatus({ requestKey: currentKey }, "awaiting_approval"); + assertEqual(getRequired(harness.stack, ".history-list").scrollTop, 41, "live update reset history scroll"); + assertIncludes(harness.panel.getText(), "Approval", "current approval state did not update"); + + settleTurn(harness.tracker, currentKey); + assertEqual(scheduledTimeoutCount, 0, "successful activity scheduled automatic collapse"); + assertEqual(harness.panel.className, "panel", "successful activity still collapsed automatically"); + assert(harness.stack.querySelector(".history-panel"), "completion hid the history block"); +} + +function testNewTurnUpdatesHistory(Overlay: OverlayConstructor): void { + const harness = createHarness(Overlay); + captureTurn(harness.tracker, "turn-1", "read_file"); + getRequired(harness.panel, ".history-button").click(); + assertIncludes(getRequired(harness.stack, ".history-list").getText(), "No previous", "empty history state was missing"); + + captureTurn(harness.tracker, "turn-2", "write_file"); + assertIncludes(harness.panel.getText(), "write_file", "new tool call was not shown as current"); + assertIncludes(getRequired(harness.stack, ".history-list").getText(), "read_file", "prior tool call did not enter history"); + assertIncludes(getRequired(harness.panel, ".history-button").getText(), "(1)", "history count did not update"); +} + +function testUnifiedBoundedDragging(Overlay: OverlayConstructor): void { + const harness = createHarness(Overlay); + captureTurn(harness.tracker, "turn-1", "read_file"); + const header = getRequired(harness.panel, ".drag-header"); + header.mouseDown(createMouseEvent(620, 420, header)); + fakeWindow.dispatch("mousemove", createMouseEvent(-1000, -1000, header)); + fakeWindow.dispatch("mouseup", createMouseEvent(-1000, -1000, header)); + assertEqual(harness.host.style.left, "8px", "drag escaped the left viewport edge"); + assertEqual(harness.host.getBoundingClientRect().top, 8, "drag escaped the top viewport edge"); + + harness.host.setRect({ height: 500, left: 0, top: 0, width: 380 }); + getRequired(harness.panel, ".history-button").click(); + fakeWindow.flushAnimationFrames(); + assertEqual(harness.host.getBoundingClientRect().top, 8, "opening history moved the stack out of view"); + assertEqual(fakeDocument.body.children.length, 1, "history and current activity used separate hosts"); + + fakeWindow.innerHeight = 400; + fakeWindow.innerWidth = 500; + harness.host.setRect({ height: 360, left: 0, top: 0, width: 380 }); + fakeWindow.dispatch("resize", {}); + fakeWindow.flushAnimationFrames(); + const resizedRect = harness.host.getBoundingClientRect(); + assert(resizedRect.left >= 8 && resizedRect.right <= 492, "resize left the stack outside horizontal bounds"); + assert(resizedRect.top >= 8 && resizedRect.bottom <= 392, "resize left the stack outside vertical bounds"); +} + +function createHarness(Overlay: OverlayConstructor): { + host: FakeElement; + panel: FakeElement; + stack: FakeElement; + tracker: ToolActivityTracker; +} { + fakeDocument.reset(); + fakeWindow.reset(); + scheduledTimeoutCount = 0; + const tracker = new ToolActivityTracker(); + new Overlay(tracker); + const host = fakeDocument.body.children.at(-1); + const stack = host?.shadowRoot?.querySelector(".overlay-stack"); + const panel = stack?.querySelector(".panel"); + assert(host && stack && panel, "tool activity overlay was not created"); + host.setRect({ height: 250, left: 600, top: 400, width: 380 }); + return { host, panel, stack, tracker }; +} + +function captureTurn(tracker: ToolActivityTracker, turnId: string, toolName: string): string { + const requestKey = `request:${turnId}`; + tracker.capture({ + identity: { requestKey }, + payload: { name: toolName, purpose: `Run ${toolName}` }, + turnId, + }); + return requestKey; +} + +function settleTurn(tracker: ToolActivityTracker, requestKey: string): void { + tracker.updateStatus({ requestKey }, "succeeded"); + tracker.updateDelivery([requestKey], "delivered"); +} + +function createMouseEvent(clientX: number, clientY: number, target: FakeElement): FakeMouseEvent { + return { + button: 0, + clientX, + clientY, + preventDefault: () => undefined, + stopPropagation: () => undefined, + target, + }; +} + +function getRequired(root: FakeElement, selector: string): FakeElement { + const element = root.querySelector(selector); + assert(element, `missing element ${selector}`); + return element; +} + +function installBrowserGlobals(): void { + Object.defineProperty(globalThis, "document", { configurable: true, value: fakeDocument }); + Object.defineProperty(globalThis, "navigator", { configurable: true, value: { language: "en-US" } }); + Object.defineProperty(globalThis, "window", { configurable: true, value: fakeWindow }); + Object.defineProperty(globalThis, "requestAnimationFrame", { + configurable: true, + value: (callback: () => void) => fakeWindow.queueAnimationFrame(callback), + }); + Object.defineProperty(globalThis, "setTimeout", { + configurable: true, + value: () => { + scheduledTimeoutCount += 1; + return scheduledTimeoutCount; + }, + }); + Object.defineProperty(globalThis, "clearTimeout", { configurable: true, value: () => undefined }); + Object.defineProperty(globalThis, "setInterval", { configurable: true, value: () => 1 }); + Object.defineProperty(globalThis, "clearInterval", { configurable: true, value: () => undefined }); +} + +function parsePixels(value: string | undefined): number | null { + if (!value || value === "auto") {return null;} + const parsed = Number.parseFloat(value); + return Number.isFinite(parsed) ? parsed : null; +} + +function runTest(name: string, test: () => void): void { + try { + test(); + console.log(`PASS ${name}`); + } catch (error) { + console.error(`FAIL ${name}`); + throw error; + } +} + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) {throw new Error(message);} +} + +function assertEqual(actual: unknown, expected: unknown, message: string): void { + if (actual !== expected) { + throw new Error(`${message}: expected ${String(expected)}, received ${String(actual)}`); + } +} + +function assertIncludes(actual: string, expected: string, message: string): void { + if (!actual.includes(expected)) { + throw new Error(`${message}: expected '${actual}' to include '${expected}'`); + } +} + +void main();