From 5980010aa8b30e40524757260e23db54a75cf294 Mon Sep 17 00:00:00 2001 From: TW Date: Sun, 30 Aug 2026 19:17:07 +0800 Subject: [PATCH 1/2] feat: add tool activity history view - Add current and history views for the latest eight in-memory tool activity turns. - Keep live status, expanded entries, and scroll position stable while history is open. - Place approval dialogs above the bounded, scrollable tool activity overlay. --- bridge-browser/package.json | 2 +- .../src/content/tool_activity_overlay.ts | 345 ++++++++++-------- .../content/tool_activity_overlay_styles.ts | 72 ++++ .../src/content/tool_activity_overlay_view.ts | 120 ++++++ bridge-browser/src/modules/approval_modal.ts | 3 +- bridge-browser/src/modules/i18n.ts | 5 + bridge-browser/src/modules/overlay_layers.ts | 2 + bridge-browser/test/tool_activity.test.ts | 62 ++++ .../test/tool_activity_overlay.test.ts | 305 ++++++++++++++++ bridge-browser/vite.test.config.ts | 2 + 10 files changed, 764 insertions(+), 154 deletions(-) create mode 100644 bridge-browser/src/content/tool_activity_overlay_styles.ts create mode 100644 bridge-browser/src/content/tool_activity_overlay_view.ts create mode 100644 bridge-browser/src/modules/overlay_layers.ts create mode 100644 bridge-browser/test/tool_activity.test.ts create mode 100644 bridge-browser/test/tool_activity_overlay.test.ts diff --git a/bridge-browser/package.json b/bridge-browser/package.json index f01c580..25b8a5a 100644 --- a/bridge-browser/package.json +++ b/bridge-browser/package.json @@ -9,7 +9,7 @@ "build": "pnpm run build:main-world && tsc && vite build", "build:main-world": "vite build --config vite.main-world.config.ts", "preview": "vite preview", - "test": "vite build --config vite.test.config.ts && node node_modules/.cache/runtime-tests/approval_policy.test.js && node node_modules/.cache/runtime-tests/dom_tool_activity.test.js && node node_modules/.cache/runtime-tests/network_capture_runtime.test.js && node node_modules/.cache/runtime-tests/result_delivery.test.js && node node_modules/.cache/runtime-tests/tool_call_tracker.test.js && node node_modules/.cache/runtime-tests/tool_result.test.js" + "test": "vite build --config vite.test.config.ts && node node_modules/.cache/runtime-tests/approval_policy.test.js && node node_modules/.cache/runtime-tests/dom_tool_activity.test.js && node node_modules/.cache/runtime-tests/network_capture_runtime.test.js && node node_modules/.cache/runtime-tests/result_delivery.test.js && node node_modules/.cache/runtime-tests/tool_call_tracker.test.js && node node_modules/.cache/runtime-tests/tool_activity.test.js && node node_modules/.cache/runtime-tests/tool_activity_overlay.test.js && node node_modules/.cache/runtime-tests/tool_result.test.js" }, "dependencies": { "@webcode/shared": "workspace:*" diff --git a/bridge-browser/src/content/tool_activity_overlay.ts b/bridge-browser/src/content/tool_activity_overlay.ts index 61bbb1a..8a5ba45 100644 --- a/bridge-browser/src/content/tool_activity_overlay.ts +++ b/bridge-browser/src/content/tool_activity_overlay.ts @@ -3,24 +3,27 @@ import { t } from "../modules/i18n"; import { type ToolActivityItem, type ToolActivitySnapshot, - type ToolActivityStatus, type ToolActivityTracker, type ToolActivityTurn, } from "./tool_activity"; +import { TOOL_ACTIVITY_STYLE_TEXT } from "./tool_activity_overlay_styles"; +import { + formatTurnTime, + getActivityTurnEntries, + getDeliveryText, + getItemStatusText, + getTurnIcon, + getTurnSummary, + getTurnTone, + isSuccessfulDeliveredTurn, + 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", -}; + +type ActivityViewMode = "current" | "history"; export class ToolActivityOverlay { private collapseTimer: ReturnType | null = null; @@ -28,10 +31,12 @@ export class ToolActivityOverlay { private collapsed = false; private currentTurnId: string | null = null; private dismissedTurnId: string | null = null; + private readonly expandedTurnIds = new Set(); private readonly host: HTMLDivElement; private latestSnapshot: ToolActivitySnapshot = { items: [], turns: [] }; private readonly panel: HTMLDivElement; private ticker: ReturnType | null = null; + private viewMode: ActivityViewMode = "current"; public constructor(tracker: ToolActivityTracker) { const view = createOverlayView(); @@ -42,68 +47,155 @@ export class ToolActivityOverlay { 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); + this.pruneExpandedTurns(entries); + 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(); this.host.style.display = "block"; this.panel.className = this.collapsed ? "panel collapsed" : "panel"; this.panel.replaceChildren( - this.createHeader(turn, items), - ...this.createExpandedContent(turn, items) + this.createHeader(currentEntry), + ...this.createExpandedContent(entries, currentEntry) ); - 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.syncAutoCollapse(currentEntry); } - private createHeader(turn: ToolActivityTurn, items: ToolActivityItem[]): HTMLElement { + private createHeader(entry: ToolActivityTurnEntry): HTMLElement { const header = document.createElement("div"); header.className = "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 mark = createTurnMark(entry.turn, entry.items); const heading = document.createElement("div"); 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); const actions = document.createElement("div"); actions.className = "actions"; actions.appendChild(this.createToggleButton()); - if (isTurnSettled(turn)) { - actions.appendChild(this.createCloseButton(turn.id)); + 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 createExpandedContent( + entries: ToolActivityTurnEntry[], + currentEntry: ToolActivityTurnEntry + ): HTMLElement[] { if (this.collapsed) {return [];} - const list = document.createElement("div"); - list.className = "list"; - items.forEach((item) => list.appendChild(createActivityRow(item))); + const tabs = this.createViewTabs(entries.length); + if (this.viewMode === "history") { + return [tabs, this.createHistoryView(entries, currentEntry)]; + } + return [tabs, ...createTurnDetails(currentEntry)]; + } + + private createViewTabs(turnCount: number): HTMLElement { + const tabs = document.createElement("div"); + tabs.className = "tabs"; + tabs.setAttribute("role", "tablist"); + tabs.append( + this.createViewTab("current", t("activity_current")), + this.createViewTab("history", `${t("activity_history")} (${turnCount})`) + ); + return tabs; + } - const footer = document.createElement("div"); - footer.className = `footer ${getTurnTone(turn, items)}`; - footer.textContent = getDeliveryText(turn, items); - return [list, footer]; + private createViewTab(mode: ActivityViewMode, label: string): HTMLButtonElement { + const button = document.createElement("button"); + button.type = "button"; + button.className = this.viewMode === mode ? "view-tab active" : "view-tab"; + button.textContent = label; + button.setAttribute("role", "tab"); + button.setAttribute("aria-selected", String(this.viewMode === mode)); + button.onclick = () => { + if (this.viewMode === mode) {return;} + this.clearCollapseTimer(); + this.viewMode = mode; + this.render(this.latestSnapshot); + }; + return button; + } + + private createHistoryView( + entries: ToolActivityTurnEntry[], + currentEntry: ToolActivityTurnEntry + ): HTMLElement { + const view = document.createElement("div"); + view.className = "history-view"; + + const current = document.createElement("div"); + current.className = "current-turn"; + current.appendChild(this.createTurnCard(currentEntry, true)); + + const label = document.createElement("div"); + label.className = "history-label"; + label.textContent = t("activity_previous_turns"); + + const history = document.createElement("div"); + history.className = "history-list"; + const previousEntries = entries.slice(0, -1).reverse(); + if (previousEntries.length === 0) { + const empty = document.createElement("div"); + empty.className = "history-empty"; + empty.textContent = t("activity_no_history"); + history.appendChild(empty); + } else { + previousEntries.forEach((entry) => history.appendChild(this.createTurnCard(entry, false))); + } + view.append(current, label, history); + return view; + } + + private createTurnCard(entry: ToolActivityTurnEntry, isCurrent: boolean): HTMLElement { + const expanded = this.expandedTurnIds.has(entry.turn.id); + const card = document.createElement("div"); + card.className = isCurrent ? "turn-card current" : "turn-card"; + + const summary = document.createElement("button"); + summary.type = "button"; + summary.className = "turn-summary"; + summary.setAttribute("aria-expanded", String(expanded)); + summary.append( + createTurnMark(entry.turn, entry.items), + createTurnHeading(entry, isCurrent), + createChevron(expanded) + ); + summary.onclick = () => { + if (expanded) { + this.expandedTurnIds.delete(entry.turn.id); + } else { + this.expandedTurnIds.add(entry.turn.id); + } + this.render(this.latestSnapshot); + }; + card.appendChild(summary); + if (expanded) { + card.append(...createTurnDetails(entry, true)); + } + return card; } private createToggleButton(): HTMLButtonElement { @@ -142,16 +234,14 @@ export class ToolActivityOverlay { this.collapsed = false; } - private syncAutoCollapse(turn: ToolActivityTurn, items: ToolActivityItem[]): void { - const shouldCollapse = turn.deliveryStatus === "delivered" && - items.every((item) => item.status === "succeeded"); - if (!shouldCollapse) { + private syncAutoCollapse(entry: ToolActivityTurnEntry): void { + if (this.viewMode === "history" || !isSuccessfulDeliveredTurn(entry.turn, entry.items)) { this.clearCollapseTimer(); return; } - if (this.collapsed || this.collapseTimerTurnId === turn.id) {return;} + if (this.collapsed || this.collapseTimerTurnId === entry.turn.id) {return;} - this.collapseTimerTurnId = turn.id; + this.collapseTimerTurnId = entry.turn.id; this.collapseTimer = setTimeout(() => { this.collapseTimer = null; this.collapsed = true; @@ -168,6 +258,24 @@ export class ToolActivityOverlay { } } + private getHistoryScrollTop(): number { + if (this.viewMode !== "history") {return 0;} + return this.panel.querySelector(".history-list")?.scrollTop ?? 0; + } + + private restoreHistoryScrollTop(scrollTop: number): void { + if (this.viewMode !== "history") {return;} + const history = this.panel.querySelector(".history-list"); + if (history) {history.scrollTop = scrollTop;} + } + + private pruneExpandedTurns(entries: ToolActivityTurnEntry[]): void { + const retainedTurnIds = new Set(entries.map((entry) => entry.turn.id)); + this.expandedTurnIds.forEach((turnId) => { + if (!retainedTurnIds.has(turnId)) {this.expandedTurnIds.delete(turnId);} + }); + } + private clearCollapseTimer(): void { if (this.collapseTimer) {clearTimeout(this.collapseTimer);} this.collapseTimer = null; @@ -175,6 +283,49 @@ export class ToolActivityOverlay { } } +function createTurnHeading(entry: ToolActivityTurnEntry, isCurrent: boolean): HTMLElement { + const heading = document.createElement("div"); + heading.className = "turn-heading"; + const name = document.createElement("div"); + name.className = "turn-name"; + name.textContent = isCurrent ? t("activity_current_turn") : formatTurnTime(entry.turn.createdAt); + const meta = document.createElement("div"); + meta.className = "turn-meta"; + meta.textContent = getTurnSummary(entry.turn, entry.items); + heading.append(name, meta); + return heading; +} + +function createChevron(expanded: boolean): HTMLElement { + const chevron = document.createElement("span"); + chevron.className = "chevron"; + chevron.textContent = expanded ? "⌄" : "›"; + return chevron; +} + +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, nested = false): HTMLElement[] { + const list = document.createElement("div"); + list.className = nested ? "turn-tool-list" : "list"; + 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); + if (!nested) {return [list, footer];} + + const details = document.createElement("div"); + details.className = "turn-details"; + details.append(list, footer); + return [details]; +} + function createActivityRow(item: ToolActivityItem): HTMLElement { const row = document.createElement("div"); row.className = `row ${item.status}`; @@ -211,74 +362,6 @@ 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 } { const host = document.createElement("div"); host.style.display = "none"; @@ -291,45 +374,3 @@ function createOverlayView(): { host: HTMLDivElement; panel: HTMLDivElement } { document.body.appendChild(host); return { host, panel }; } - -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..fb1a67d --- /dev/null +++ b/bridge-browser/src/content/tool_activity_overlay_styles.ts @@ -0,0 +1,72 @@ +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)); color-scheme: dark; } + * { box-sizing: border-box; } + button { font: inherit; } + .panel { display: flex; max-height: min(460px, calc(100vh - 40px)); 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.collapsed { width: fit-content; min-width: 250px; margin-left: auto; } + .header { min-height: 54px; flex: 0 0 auto; 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; } + .tabs { flex: 0 0 auto; display: grid; grid-template-columns: 1fr 1fr; gap: 4px; padding: 5px; border-top: 1px solid #343942; + background: rgba(255, 255, 255, .025); } + .view-tab { padding: 5px 8px; border: 0; border-radius: 6px; color: #9ca3af; background: transparent; cursor: pointer; font-size: 11px; } + .view-tab.active { color: #f9fafb; background: #303641; font-weight: 650; } + .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-view { min-height: 0; flex: 1 1 auto; display: flex; overflow: hidden; flex-direction: column; border-top: 1px solid #343942; } + .current-turn { flex: 0 1 auto; padding: 8px 8px 4px; overflow: hidden; } + .history-label { flex: 0 0 auto; padding: 7px 10px 5px; color: #858d9a; font-size: 10px; font-weight: 650; letter-spacing: .04em; + text-transform: uppercase; } + .history-list { min-height: 0; flex: 1 1 auto; overflow-y: auto; padding: 0 8px 8px; } + .history-empty { padding: 14px 8px; color: #858d9a; text-align: center; } + .turn-card { overflow: hidden; border: 1px solid #343942; border-radius: 8px; background: rgba(255, 255, 255, .025); } + .turn-card + .turn-card { margin-top: 6px; } + .turn-card.current { border-color: #3b5278; background: rgba(37, 99, 235, .08); } + .turn-summary { width: 100%; min-height: 44px; display: flex; align-items: center; gap: 9px; padding: 7px 9px; border: 0; color: inherit; + background: transparent; text-align: left; cursor: pointer; } + .turn-summary:hover { background: rgba(255, 255, 255, .045); } + .turn-summary .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; } + .chevron { flex: 0 0 auto; color: #7f8794; font-size: 14px; } + .turn-details { border-top: 1px solid rgba(255, 255, 255, .07); } + .turn-tool-list { max-height: min(150px, 22vh); overflow-y: auto; } + .turn-details .row { padding: 8px 10px; } + .turn-details .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..6ccb927 --- /dev/null +++ b/bridge-browser/src/content/tool_activity_overlay_view.ts @@ -0,0 +1,120 @@ +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 isSuccessfulDeliveredTurn(turn: ToolActivityTurn, items: ToolActivityItem[]): boolean { + return turn.deliveryStatus === "delivered" && items.every((item) => item.status === "succeeded"); +} + +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..d7a6b1f 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_current: { en: "Current", zh: "当前" }, + activity_history: { en: "History", zh: "历史" }, + activity_current_turn: { en: "Current turn", zh: "当前轮" }, + activity_previous_turns: { en: "Previous turns", zh: "之前的轮次" }, + activity_no_history: { en: "No previous tool activity", 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..42bf720 --- /dev/null +++ b/bridge-browser/test/tool_activity.test.ts @@ -0,0 +1,62 @@ +import { + ToolActivityTracker, + type ToolActivitySnapshot, +} from "../src/content/tool_activity"; +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); +} + +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 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..7997a9f --- /dev/null +++ b/bridge-browser/test/tool_activity_overlay.test.ts @@ -0,0 +1,305 @@ +import { ToolActivityTracker } from "../src/content/tool_activity"; + +type OverlayConstructor = new (tracker: ToolActivityTracker) => unknown; + +interface ScheduledTimeout { + callback: () => void; + cleared: boolean; + delay: number; + id: number; +} + +class FakeElement { + public readonly children: FakeElement[] = []; + public className = ""; + public onclick: (() => void) | 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(); + + public append(...children: FakeElement[]): void { + this.children.push(...children); + } + + public appendChild(child: FakeElement): FakeElement { + this.children.push(child); + return child; + } + + public attachShadow(): FakeElement { + this.shadowRoot = new FakeElement(); + return this.shadowRoot; + } + + public click(): void { + this.onclick?.(); + } + + public getAttribute(name: string): string | null { + return this.attributes.get(name) ?? null; + } + + public getText(): string { + return `${this.textContent}${this.children.map((child) => child.getText()).join("")}`; + } + + 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.children.push(...children); + } + + public setAttribute(name: string, value: string): void { + this.attributes.set(name, value); + } + + 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; + } +} + +class FakeDocument { + public readonly body = new FakeElement(); + + public createElement(): FakeElement { + return new FakeElement(); + } + + public reset(): void { + this.body.replaceChildren(); + } +} + +class FakeTimers { + private nextId = 1; + private readonly scheduled: ScheduledTimeout[] = []; + + public getActiveTimeouts(): ScheduledTimeout[] { + return this.scheduled.filter((timeout) => !timeout.cleared); + } + + public install(): () => void { + const timeoutDescriptor = Object.getOwnPropertyDescriptor(globalThis, "setTimeout"); + const clearTimeoutDescriptor = Object.getOwnPropertyDescriptor(globalThis, "clearTimeout"); + const intervalDescriptor = Object.getOwnPropertyDescriptor(globalThis, "setInterval"); + const clearIntervalDescriptor = Object.getOwnPropertyDescriptor(globalThis, "clearInterval"); + + Object.defineProperty(globalThis, "setTimeout", { + configurable: true, + value: (callback: unknown, delay = 0) => this.schedule(callback, delay), + }); + Object.defineProperty(globalThis, "clearTimeout", { + configurable: true, + value: (id: unknown) => this.clear(id), + }); + Object.defineProperty(globalThis, "setInterval", { + configurable: true, + value: () => this.nextId++, + }); + Object.defineProperty(globalThis, "clearInterval", { + configurable: true, + value: () => undefined, + }); + + return () => { + restoreProperty("setTimeout", timeoutDescriptor); + restoreProperty("clearTimeout", clearTimeoutDescriptor); + restoreProperty("setInterval", intervalDescriptor); + restoreProperty("clearInterval", clearIntervalDescriptor); + }; + } + + public reset(): void { + this.scheduled.length = 0; + } + + public runActiveTimeout(): void { + const timeout = this.getActiveTimeouts()[0]; + assert(timeout, "expected an active timeout"); + timeout.cleared = true; + timeout.callback(); + } + + private schedule(callback: unknown, delay: number): number { + assert(typeof callback === "function", "timer callback was not callable"); + const timeout = { callback: callback as () => void, cleared: false, delay, id: this.nextId++ }; + this.scheduled.push(timeout); + return timeout.id; + } + + private clear(id: unknown): void { + if (typeof id !== "number") {return;} + const timeout = this.scheduled.find((candidate) => candidate.id === id); + if (timeout) {timeout.cleared = true;} + } +} + +const fakeDocument = new FakeDocument(); +const fakeTimers = new FakeTimers(); + +async function main(): Promise { + installBrowserGlobals(); + const restoreTimers = fakeTimers.install(); + try { + const { ToolActivityOverlay } = await import("../src/content/tool_activity_overlay"); + runTest("history keeps current activity live without losing reading state", () => { + testHistoryLiveUpdates(ToolActivityOverlay); + }); + runTest("a new turn keeps history open and moves the prior turn down", () => { + testNewTurnKeepsHistoryOpen(ToolActivityOverlay); + }); + } finally { + restoreTimers(); + } +} + +function testHistoryLiveUpdates(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"); + + clickTab(harness.panel, 1); + assert(harness.panel.querySelector(".history-view"), "history view did not open"); + assertIncludes(getRequired(harness.panel, ".current-turn").getText(), "Running", "current status was missing"); + + getRequired(harness.panel, ".history-list").querySelector(".turn-summary")?.click(); + const historyBeforeUpdate = getRequired(harness.panel, ".history-list"); + historyBeforeUpdate.scrollTop = 41; + assert(historyBeforeUpdate.querySelector(".turn-details"), "history turn did not expand"); + + harness.tracker.updateStatus({ requestKey: currentKey }, "awaiting_approval"); + const historyAfterUpdate = getRequired(harness.panel, ".history-list"); + assertEqual(historyAfterUpdate.scrollTop, 41, "live update reset the history scroll position"); + assert(historyAfterUpdate.querySelector(".turn-details"), "live update collapsed the history turn"); + assertIncludes(getRequired(harness.panel, ".current-turn").getText(), "Approval", "approval state did not update"); + + settleTurn(harness.tracker, currentKey); + assertEqual(fakeTimers.getActiveTimeouts().length, 0, "history view scheduled auto-collapse"); + clickTab(harness.panel, 0); + const activeTimeouts = fakeTimers.getActiveTimeouts(); + assertEqual(activeTimeouts.length, 1, "current view did not resume auto-collapse"); + assertEqual(activeTimeouts[0]?.delay, 4000, "current view used the wrong collapse delay"); + fakeTimers.runActiveTimeout(); + assertEqual(harness.panel.className, "panel collapsed", "successful current view did not collapse"); +} + +function testNewTurnKeepsHistoryOpen(Overlay: OverlayConstructor): void { + const harness = createHarness(Overlay); + captureTurn(harness.tracker, "turn-1", "read_file"); + clickTab(harness.panel, 1); + + captureTurn(harness.tracker, "turn-2", "write_file"); + assert(harness.panel.querySelector(".history-view"), "new turn switched away from history"); + getRequired(harness.panel, ".current-turn").querySelector(".turn-summary")?.click(); + assertIncludes(getRequired(harness.panel, ".current-turn").getText(), "write_file", "new turn was not pinned"); + + const history = getRequired(harness.panel, ".history-list"); + history.querySelector(".turn-summary")?.click(); + assertIncludes(getRequired(harness.panel, ".history-list").getText(), "read_file", "prior turn was not moved to history"); + assertIncludes(getTab(harness.panel, 1).getText(), "(2)", "history tab count did not update"); +} + +function createHarness(Overlay: OverlayConstructor): { + panel: FakeElement; + tracker: ToolActivityTracker; +} { + fakeDocument.reset(); + fakeTimers.reset(); + const tracker = new ToolActivityTracker(); + new Overlay(tracker); + const host = fakeDocument.body.children.at(-1); + const panel = host?.shadowRoot?.querySelector(".panel"); + assert(panel, "tool activity panel was not created"); + return { panel, 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 clickTab(panel: FakeElement, index: number): void { + getTab(panel, index).click(); +} + +function getTab(panel: FakeElement, index: number): FakeElement { + const tabs = getRequired(panel, ".tabs"); + const tab = tabs.children[index]; + assert(tab, `missing activity tab ${index}`); + return tab; +} + +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" }, + }); +} + +function restoreProperty(name: string, descriptor: PropertyDescriptor | undefined): void { + if (descriptor) { + Object.defineProperty(globalThis, name, descriptor); + } else { + Reflect.deleteProperty(globalThis, name); + } +} + +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(); diff --git a/bridge-browser/vite.test.config.ts b/bridge-browser/vite.test.config.ts index 21b3a22..379fb52 100644 --- a/bridge-browser/vite.test.config.ts +++ b/bridge-browser/vite.test.config.ts @@ -14,6 +14,8 @@ export default defineConfig({ "network_capture_runtime.test": resolve(__dirname, "test/network_capture_runtime.test.ts"), "result_delivery.test": resolve(__dirname, "test/result_delivery.test.ts"), "tool_call_tracker.test": resolve(__dirname, "test/tool_call_tracker.test.ts"), + "tool_activity.test": resolve(__dirname, "test/tool_activity.test.ts"), + "tool_activity_overlay.test": resolve(__dirname, "test/tool_activity_overlay.test.ts"), "tool_result.test": resolve(__dirname, "test/tool_result.test.ts"), }, output: { From 2e4bf4559f864cbff3b6dec8ffcc13bfaddca668 Mon Sep 17 00:00:00 2001 From: TW Date: Sun, 30 Aug 2026 19:47:35 +0800 Subject: [PATCH 2/2] feat: add draggable tool activity history panel - Replace current and history tabs with a separate scrollable detailed history panel. - Remove automatic success collapse while preserving manual minimize controls. - Keep the combined activity stack inside viewport bounds during drag and resize. --- .../src/content/floating_panel_drag.ts | 103 ++++++ .../src/content/tool_activity_overlay.ts | 243 +++++--------- .../content/tool_activity_overlay_styles.ts | 61 ++-- .../src/content/tool_activity_overlay_view.ts | 4 - bridge-browser/src/modules/i18n.ts | 6 +- bridge-browser/test/tool_activity.test.ts | 31 ++ .../test/tool_activity_overlay.test.ts | 316 +++++++++++------- 7 files changed, 444 insertions(+), 320 deletions(-) create mode 100644 bridge-browser/src/content/floating_panel_drag.ts 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 8a5ba45..f60130d 100644 --- a/bridge-browser/src/content/tool_activity_overlay.ts +++ b/bridge-browser/src/content/tool_activity_overlay.ts @@ -6,6 +6,7 @@ import { 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, @@ -15,40 +16,36 @@ import { getTurnIcon, getTurnSummary, getTurnTone, - isSuccessfulDeliveredTurn, isTurnSettled, type ToolActivityTurnEntry, } from "./tool_activity_overlay_view"; -const SUCCESS_COLLAPSE_DELAY_MS = 4000; const ELAPSED_UPDATE_INTERVAL_MS = 1000; -type ActivityViewMode = "current" | "history"; - 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 expandedTurnIds = new Set(); + 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; - private viewMode: ActivityViewMode = "current"; 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 entries = getActivityTurnEntries(snapshot); - this.pruneExpandedTurns(entries); const currentEntry = entries.at(-1); if (!currentEntry || this.dismissedTurnId === currentEntry.turn.id) { this.host.style.display = "none"; @@ -61,25 +58,33 @@ export class ToolActivityOverlay { } 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(currentEntry), - ...this.createExpandedContent(entries, currentEntry) + this.createCurrentHeader(currentEntry, historyEntries.length), + ...this.createCurrentDetails(currentEntry) + ); + this.stack.replaceChildren( + ...(this.historyVisible ? [this.createHistoryPanel(historyEntries)] : []), + this.panel ); this.restoreHistoryScrollTop(historyScrollTop); this.syncTicker(snapshot.items.some((item) => item.status === "executing")); - this.syncAutoCollapse(currentEntry); + this.dragController.scheduleClamp(); } - private createHeader(entry: ToolActivityTurnEntry): 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 = createTurnMark(entry.turn, entry.items); const heading = document.createElement("div"); + heading.className = "heading"; const title = document.createElement("div"); title.className = "title"; title.textContent = `${BRANDING.productName} · ${t("activity_title")}`; @@ -87,11 +92,11 @@ export class ToolActivityOverlay { summary.className = "summary"; 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()); + actions.append(this.createHistoryButton(historyCount), this.createToggleButton()); if (isTurnSettled(entry.turn)) { actions.appendChild(this.createCloseButton(entry.turn.id)); } @@ -99,103 +104,65 @@ export class ToolActivityOverlay { return header; } - private createExpandedContent( - entries: ToolActivityTurnEntry[], - currentEntry: ToolActivityTurnEntry - ): HTMLElement[] { + private createCurrentDetails(entry: ToolActivityTurnEntry): HTMLElement[] { if (this.collapsed) {return [];} - - const tabs = this.createViewTabs(entries.length); - if (this.viewMode === "history") { - return [tabs, this.createHistoryView(entries, currentEntry)]; - } - return [tabs, ...createTurnDetails(currentEntry)]; + return createTurnDetails(entry, "list"); } - private createViewTabs(turnCount: number): HTMLElement { - const tabs = document.createElement("div"); - tabs.className = "tabs"; - tabs.setAttribute("role", "tablist"); - tabs.append( - this.createViewTab("current", t("activity_current")), - this.createViewTab("history", `${t("activity_history")} (${turnCount})`) - ); - return tabs; - } - - private createViewTab(mode: ActivityViewMode, label: string): HTMLButtonElement { + private createHistoryButton(historyCount: number): HTMLButtonElement { const button = document.createElement("button"); button.type = "button"; - button.className = this.viewMode === mode ? "view-tab active" : "view-tab"; - button.textContent = label; - button.setAttribute("role", "tab"); - button.setAttribute("aria-selected", String(this.viewMode === mode)); + 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 = () => { - if (this.viewMode === mode) {return;} - this.clearCollapseTimer(); - this.viewMode = mode; + this.historyVisible = !this.historyVisible; this.render(this.latestSnapshot); }; return button; } - private createHistoryView( - entries: ToolActivityTurnEntry[], - currentEntry: ToolActivityTurnEntry - ): HTMLElement { - const view = document.createElement("div"); - view.className = "history-view"; - - const current = document.createElement("div"); - current.className = "current-turn"; - current.appendChild(this.createTurnCard(currentEntry, true)); + private createHistoryPanel(entries: ToolActivityTurnEntry[]): HTMLElement { + const panel = document.createElement("div"); + panel.className = "history-panel"; - const label = document.createElement("div"); - label.className = "history-label"; - label.textContent = t("activity_previous_turns"); + 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 history = document.createElement("div"); - history.className = "history-list"; - const previousEntries = entries.slice(0, -1).reverse(); - if (previousEntries.length === 0) { + const list = document.createElement("div"); + list.className = "history-list"; + if (entries.length === 0) { const empty = document.createElement("div"); empty.className = "history-empty"; empty.textContent = t("activity_no_history"); - history.appendChild(empty); + list.appendChild(empty); } else { - previousEntries.forEach((entry) => history.appendChild(this.createTurnCard(entry, false))); + entries.forEach((entry) => list.appendChild(createHistoryTurn(entry))); } - view.append(current, label, history); - return view; + panel.append(header, list); + return panel; } - private createTurnCard(entry: ToolActivityTurnEntry, isCurrent: boolean): HTMLElement { - const expanded = this.expandedTurnIds.has(entry.turn.id); - const card = document.createElement("div"); - card.className = isCurrent ? "turn-card current" : "turn-card"; - - const summary = document.createElement("button"); - summary.type = "button"; - summary.className = "turn-summary"; - summary.setAttribute("aria-expanded", String(expanded)); - summary.append( - createTurnMark(entry.turn, entry.items), - createTurnHeading(entry, isCurrent), - createChevron(expanded) - ); - summary.onclick = () => { - if (expanded) { - this.expandedTurnIds.delete(entry.turn.id); - } else { - this.expandedTurnIds.add(entry.turn.id); - } + 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); }; - card.appendChild(summary); - if (expanded) { - card.append(...createTurnDetails(entry, true)); - } - return card; + return button; } private createToggleButton(): HTMLButtonElement { @@ -228,27 +195,11 @@ export class ToolActivityOverlay { } private startTurn(turnId: string): void { - this.clearCollapseTimer(); this.currentTurnId = turnId; this.dismissedTurnId = null; this.collapsed = false; } - private syncAutoCollapse(entry: ToolActivityTurnEntry): void { - if (this.viewMode === "history" || !isSuccessfulDeliveredTurn(entry.turn, entry.items)) { - this.clearCollapseTimer(); - return; - } - if (this.collapsed || this.collapseTimerTurnId === entry.turn.id) {return;} - - this.collapseTimerTurnId = entry.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); @@ -259,48 +210,34 @@ export class ToolActivityOverlay { } private getHistoryScrollTop(): number { - if (this.viewMode !== "history") {return 0;} - return this.panel.querySelector(".history-list")?.scrollTop ?? 0; + if (!this.historyVisible) {return 0;} + return this.stack.querySelector(".history-list")?.scrollTop ?? 0; } private restoreHistoryScrollTop(scrollTop: number): void { - if (this.viewMode !== "history") {return;} - const history = this.panel.querySelector(".history-list"); + if (!this.historyVisible) {return;} + const history = this.stack.querySelector(".history-list"); if (history) {history.scrollTop = scrollTop;} } - - private pruneExpandedTurns(entries: ToolActivityTurnEntry[]): void { - const retainedTurnIds = new Set(entries.map((entry) => entry.turn.id)); - this.expandedTurnIds.forEach((turnId) => { - if (!retainedTurnIds.has(turnId)) {this.expandedTurnIds.delete(turnId);} - }); - } - - private clearCollapseTimer(): void { - if (this.collapseTimer) {clearTimeout(this.collapseTimer);} - this.collapseTimer = null; - this.collapseTimerTurnId = null; - } } -function createTurnHeading(entry: ToolActivityTurnEntry, isCurrent: boolean): HTMLElement { +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 name = document.createElement("div"); - name.className = "turn-name"; - name.textContent = isCurrent ? t("activity_current_turn") : formatTurnTime(entry.turn.createdAt); - const meta = document.createElement("div"); - meta.className = "turn-meta"; - meta.textContent = getTurnSummary(entry.turn, entry.items); - heading.append(name, meta); - return heading; -} - -function createChevron(expanded: boolean): HTMLElement { - const chevron = document.createElement("span"); - chevron.className = "chevron"; - chevron.textContent = expanded ? "⌄" : "›"; - return chevron; + 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 { @@ -310,20 +247,15 @@ function createTurnMark(turn: ToolActivityTurn, items: ToolActivityItem[]): HTML return mark; } -function createTurnDetails(entry: ToolActivityTurnEntry, nested = false): HTMLElement[] { +function createTurnDetails(entry: ToolActivityTurnEntry, listClassName: string): HTMLElement[] { const list = document.createElement("div"); - list.className = nested ? "turn-tool-list" : "list"; + 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); - if (!nested) {return [list, footer];} - - const details = document.createElement("div"); - details.className = "turn-details"; - details.append(list, footer); - return [details]; + return [list, footer]; } function createActivityRow(item: ToolActivityItem): HTMLElement { @@ -362,15 +294,22 @@ function createTextLine(className: string, value: string): HTMLElement { return line; } -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 }; } diff --git a/bridge-browser/src/content/tool_activity_overlay_styles.ts b/bridge-browser/src/content/tool_activity_overlay_styles.ts index fb1a67d..ca0bc42 100644 --- a/bridge-browser/src/content/tool_activity_overlay_styles.ts +++ b/bridge-browser/src/content/tool_activity_overlay_styles.ts @@ -1,32 +1,39 @@ 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)); color-scheme: dark; } + :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; } - .panel { display: flex; max-height: min(460px, calc(100vh - 40px)); overflow: hidden; flex-direction: column; color: #f3f4f6; + .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.collapsed { width: fit-content; min-width: 250px; margin-left: auto; } - .header { min-height: 54px; flex: 0 0 auto; 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; } + .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 { overflow: hidden; color: #f9fafb; font-weight: 650; text-overflow: ellipsis; white-space: nowrap; } + .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 { 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); } + .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; } - .tabs { flex: 0 0 auto; display: grid; grid-template-columns: 1fr 1fr; gap: 4px; padding: 5px; border-top: 1px solid #343942; - background: rgba(255, 255, 255, .025); } - .view-tab { padding: 5px 8px; border: 0; border-radius: 6px; color: #9ca3af; background: transparent; cursor: pointer; font-size: 11px; } - .view-tab.active { color: #f9fafb; background: #303641; font-weight: 650; } .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; } @@ -47,26 +54,16 @@ export const TOOL_ACTIVITY_STYLE_TEXT = ` .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-view { min-height: 0; flex: 1 1 auto; display: flex; overflow: hidden; flex-direction: column; border-top: 1px solid #343942; } - .current-turn { flex: 0 1 auto; padding: 8px 8px 4px; overflow: hidden; } - .history-label { flex: 0 0 auto; padding: 7px 10px 5px; color: #858d9a; font-size: 10px; font-weight: 650; letter-spacing: .04em; - text-transform: uppercase; } - .history-list { min-height: 0; flex: 1 1 auto; overflow-y: auto; padding: 0 8px 8px; } - .history-empty { padding: 14px 8px; color: #858d9a; text-align: center; } - .turn-card { overflow: hidden; border: 1px solid #343942; border-radius: 8px; background: rgba(255, 255, 255, .025); } - .turn-card + .turn-card { margin-top: 6px; } - .turn-card.current { border-color: #3b5278; background: rgba(37, 99, 235, .08); } - .turn-summary { width: 100%; min-height: 44px; display: flex; align-items: center; gap: 9px; padding: 7px 9px; border: 0; color: inherit; - background: transparent; text-align: left; cursor: pointer; } - .turn-summary:hover { background: rgba(255, 255, 255, .045); } - .turn-summary .mark { width: 20px; height: 20px; font-size: 10px; } + .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; } - .chevron { flex: 0 0 auto; color: #7f8794; font-size: 14px; } - .turn-details { border-top: 1px solid rgba(255, 255, 255, .07); } - .turn-tool-list { max-height: min(150px, 22vh); overflow-y: auto; } - .turn-details .row { padding: 8px 10px; } - .turn-details .footer { padding: 6px 10px; } + .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 index 6ccb927..d1f0b05 100644 --- a/bridge-browser/src/content/tool_activity_overlay_view.ts +++ b/bridge-browser/src/content/tool_activity_overlay_view.ts @@ -80,10 +80,6 @@ export function isTurnSettled(turn: ToolActivityTurn): boolean { return turn.deliveryStatus === "delivered" || turn.deliveryStatus === "failed"; } -export function isSuccessfulDeliveredTurn(turn: ToolActivityTurn, items: ToolActivityItem[]): boolean { - return turn.deliveryStatus === "delivered" && items.every((item) => item.status === "succeeded"); -} - export function formatTurnTime(timestamp: number): string { return new Intl.DateTimeFormat(undefined, { hour: "2-digit", minute: "2-digit" }).format(timestamp); } diff --git a/bridge-browser/src/modules/i18n.ts b/bridge-browser/src/modules/i18n.ts index d7a6b1f..98529d5 100644 --- a/bridge-browser/src/modules/i18n.ts +++ b/bridge-browser/src/modules/i18n.ts @@ -77,11 +77,11 @@ const I18N_MESSAGES: Record = { activity_minimize: { en: "Minimize", zh: "收起" }, activity_expand: { en: "Expand", zh: "展开" }, activity_close: { en: "Close", zh: "关闭" }, - activity_current: { en: "Current", zh: "当前" }, activity_history: { en: "History", zh: "历史" }, - activity_current_turn: { en: "Current turn", zh: "当前轮" }, - activity_previous_turns: { en: "Previous turns", 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/test/tool_activity.test.ts b/bridge-browser/test/tool_activity.test.ts index 42bf720..32712de 100644 --- a/bridge-browser/test/tool_activity.test.ts +++ b/bridge-browser/test/tool_activity.test.ts @@ -2,6 +2,7 @@ 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, @@ -10,6 +11,7 @@ import { 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 { @@ -39,6 +41,35 @@ function testApprovalLayerPriority(): void { ); } +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(); diff --git a/bridge-browser/test/tool_activity_overlay.test.ts b/bridge-browser/test/tool_activity_overlay.test.ts index 7997a9f..e8110cd 100644 --- a/bridge-browser/test/tool_activity_overlay.test.ts +++ b/bridge-browser/test/tool_activity_overlay.test.ts @@ -2,17 +2,28 @@ import { ToolActivityTracker } from "../src/content/tool_activity"; type OverlayConstructor = new (tracker: ToolActivityTracker) => unknown; -interface ScheduledTimeout { - callback: () => void; - cleared: boolean; - delay: number; - id: number; +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 = {}; @@ -20,18 +31,22 @@ class FakeElement { 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 { - this.children.push(...children); + 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(); + this.shadowRoot = new FakeElement("shadow-root"); return this.shadowRoot; } @@ -39,14 +54,37 @@ class FakeElement { this.onclick?.(); } - public getAttribute(name: string): string | null { - return this.attributes.get(name) ?? null; + 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; @@ -54,13 +92,17 @@ class FakeElement { public replaceChildren(...children: FakeElement[]): void { this.children.length = 0; - this.children.push(...children); + 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) { @@ -69,13 +111,23 @@ class FakeElement { } 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(); + public readonly body = new FakeElement("body"); - public createElement(): FakeElement { - return new FakeElement(); + public createElement(tagName: string): FakeElement { + return new FakeElement(tagName); } public reset(): void { @@ -83,149 +135,145 @@ class FakeDocument { } } -class FakeTimers { - private nextId = 1; - private readonly scheduled: ScheduledTimeout[] = []; - - public getActiveTimeouts(): ScheduledTimeout[] { - return this.scheduled.filter((timeout) => !timeout.cleared); +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 install(): () => void { - const timeoutDescriptor = Object.getOwnPropertyDescriptor(globalThis, "setTimeout"); - const clearTimeoutDescriptor = Object.getOwnPropertyDescriptor(globalThis, "clearTimeout"); - const intervalDescriptor = Object.getOwnPropertyDescriptor(globalThis, "setInterval"); - const clearIntervalDescriptor = Object.getOwnPropertyDescriptor(globalThis, "clearInterval"); - - Object.defineProperty(globalThis, "setTimeout", { - configurable: true, - value: (callback: unknown, delay = 0) => this.schedule(callback, delay), - }); - Object.defineProperty(globalThis, "clearTimeout", { - configurable: true, - value: (id: unknown) => this.clear(id), - }); - Object.defineProperty(globalThis, "setInterval", { - configurable: true, - value: () => this.nextId++, - }); - Object.defineProperty(globalThis, "clearInterval", { - configurable: true, - value: () => undefined, - }); - - return () => { - restoreProperty("setTimeout", timeoutDescriptor); - restoreProperty("clearTimeout", clearTimeoutDescriptor); - restoreProperty("setInterval", intervalDescriptor); - restoreProperty("clearInterval", clearIntervalDescriptor); - }; + public dispatch(type: string, event: unknown): void { + this.listeners.get(type)?.forEach((listener) => listener(event)); } - public reset(): void { - this.scheduled.length = 0; + public flushAnimationFrames(): void { + const callbacks = Array.from(this.animationFrames.values()); + this.animationFrames.clear(); + callbacks.forEach((callback) => callback()); } - public runActiveTimeout(): void { - const timeout = this.getActiveTimeouts()[0]; - assert(timeout, "expected an active timeout"); - timeout.cleared = true; - timeout.callback(); + public queueAnimationFrame(callback: () => void): number { + const id = this.animationFrameId++; + this.animationFrames.set(id, callback); + return id; } - private schedule(callback: unknown, delay: number): number { - assert(typeof callback === "function", "timer callback was not callable"); - const timeout = { callback: callback as () => void, cleared: false, delay, id: this.nextId++ }; - this.scheduled.push(timeout); - return timeout.id; - } - - private clear(id: unknown): void { - if (typeof id !== "number") {return;} - const timeout = this.scheduled.find((candidate) => candidate.id === id); - if (timeout) {timeout.cleared = true;} + public reset(): void { + this.innerHeight = 700; + this.innerWidth = 1000; + this.animationFrames.clear(); + this.listeners.clear(); } } const fakeDocument = new FakeDocument(); -const fakeTimers = new FakeTimers(); +const fakeWindow = new FakeWindow(); +let scheduledTimeoutCount = 0; async function main(): Promise { installBrowserGlobals(); - const restoreTimers = fakeTimers.install(); - try { - const { ToolActivityOverlay } = await import("../src/content/tool_activity_overlay"); - runTest("history keeps current activity live without losing reading state", () => { - testHistoryLiveUpdates(ToolActivityOverlay); - }); - runTest("a new turn keeps history open and moves the prior turn down", () => { - testNewTurnKeepsHistoryOpen(ToolActivityOverlay); - }); - } finally { - restoreTimers(); - } + 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 testHistoryLiveUpdates(Overlay: OverlayConstructor): void { +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"); - clickTab(harness.panel, 1); - assert(harness.panel.querySelector(".history-view"), "history view did not open"); - assertIncludes(getRequired(harness.panel, ".current-turn").getText(), "Running", "current status was missing"); + 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"); - getRequired(harness.panel, ".history-list").querySelector(".turn-summary")?.click(); - const historyBeforeUpdate = getRequired(harness.panel, ".history-list"); + const historyBeforeUpdate = getRequired(harness.stack, ".history-list"); historyBeforeUpdate.scrollTop = 41; - assert(historyBeforeUpdate.querySelector(".turn-details"), "history turn did not expand"); - harness.tracker.updateStatus({ requestKey: currentKey }, "awaiting_approval"); - const historyAfterUpdate = getRequired(harness.panel, ".history-list"); - assertEqual(historyAfterUpdate.scrollTop, 41, "live update reset the history scroll position"); - assert(historyAfterUpdate.querySelector(".turn-details"), "live update collapsed the history turn"); - assertIncludes(getRequired(harness.panel, ".current-turn").getText(), "Approval", "approval state did not update"); + 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(fakeTimers.getActiveTimeouts().length, 0, "history view scheduled auto-collapse"); - clickTab(harness.panel, 0); - const activeTimeouts = fakeTimers.getActiveTimeouts(); - assertEqual(activeTimeouts.length, 1, "current view did not resume auto-collapse"); - assertEqual(activeTimeouts[0]?.delay, 4000, "current view used the wrong collapse delay"); - fakeTimers.runActiveTimeout(); - assertEqual(harness.panel.className, "panel collapsed", "successful current view did not collapse"); + 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 testNewTurnKeepsHistoryOpen(Overlay: OverlayConstructor): void { +function testNewTurnUpdatesHistory(Overlay: OverlayConstructor): void { const harness = createHarness(Overlay); captureTurn(harness.tracker, "turn-1", "read_file"); - clickTab(harness.panel, 1); + 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"); - assert(harness.panel.querySelector(".history-view"), "new turn switched away from history"); - getRequired(harness.panel, ".current-turn").querySelector(".turn-summary")?.click(); - assertIncludes(getRequired(harness.panel, ".current-turn").getText(), "write_file", "new turn was not pinned"); - - const history = getRequired(harness.panel, ".history-list"); - history.querySelector(".turn-summary")?.click(); - assertIncludes(getRequired(harness.panel, ".history-list").getText(), "read_file", "prior turn was not moved to history"); - assertIncludes(getTab(harness.panel, 1).getText(), "(2)", "history tab count did not update"); + 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(); - fakeTimers.reset(); + fakeWindow.reset(); + scheduledTimeoutCount = 0; const tracker = new ToolActivityTracker(); new Overlay(tracker); const host = fakeDocument.body.children.at(-1); - const panel = host?.shadowRoot?.querySelector(".panel"); - assert(panel, "tool activity panel was not created"); - return { panel, tracker }; + 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 { @@ -243,15 +291,15 @@ function settleTurn(tracker: ToolActivityTracker, requestKey: string): void { tracker.updateDelivery([requestKey], "delivered"); } -function clickTab(panel: FakeElement, index: number): void { - getTab(panel, index).click(); -} - -function getTab(panel: FakeElement, index: number): FakeElement { - const tabs = getRequired(panel, ".tabs"); - const tab = tabs.children[index]; - assert(tab, `missing activity tab ${index}`); - return tab; +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 { @@ -262,18 +310,28 @@ function getRequired(root: FakeElement, selector: string): FakeElement { function installBrowserGlobals(): void { Object.defineProperty(globalThis, "document", { configurable: true, value: fakeDocument }); - Object.defineProperty(globalThis, "navigator", { + 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: { language: "en-US" }, + 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 restoreProperty(name: string, descriptor: PropertyDescriptor | undefined): void { - if (descriptor) { - Object.defineProperty(globalThis, name, descriptor); - } else { - Reflect.deleteProperty(globalThis, name); - } +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 {