From 8314b3dcd459cea32ca4120645d3b8e7873d84bc Mon Sep 17 00:00:00 2001 From: TW Date: Thu, 3 Sep 2026 20:37:08 +0800 Subject: [PATCH 1/6] feat: add next-turn follow-up overlay - Add a draggable work-only composer with individually confirmed follow-up messages. - Merge confirmed follow-ups into tool-result turns or send them after ordinary replies. - Remove follow-ups only after verified auto-send and retain them when delivery fails. --- .../src/content/completion_notifier.ts | 20 +- .../src/content/follow_up_overlay.ts | 187 ++++++++++ .../src/content/follow_up_overlay_styles.ts | 42 +++ bridge-browser/src/content/follow_up_queue.ts | 98 +++++ .../src/content/follow_up_work_controller.ts | 39 ++ bridge-browser/src/content/main.ts | 39 +- .../src/content/result_delivery_controller.ts | 112 +++++- bridge-browser/src/modules/auto_send.ts | 170 +++++---- bridge-browser/src/modules/i18n.ts | 16 + bridge-browser/src/modules/overlay_layers.ts | 3 +- bridge-browser/test/auto_send.test.ts | 125 +++++++ .../test/completion_notifier.test.ts | 99 ++++++ bridge-browser/test/follow_up_overlay.test.ts | 336 ++++++++++++++++++ bridge-browser/test/follow_up_queue.test.ts | 62 ++++ 14 files changed, 1233 insertions(+), 115 deletions(-) create mode 100644 bridge-browser/src/content/follow_up_overlay.ts create mode 100644 bridge-browser/src/content/follow_up_overlay_styles.ts create mode 100644 bridge-browser/src/content/follow_up_queue.ts create mode 100644 bridge-browser/src/content/follow_up_work_controller.ts create mode 100644 bridge-browser/test/auto_send.test.ts create mode 100644 bridge-browser/test/completion_notifier.test.ts create mode 100644 bridge-browser/test/follow_up_overlay.test.ts create mode 100644 bridge-browser/test/follow_up_queue.test.ts diff --git a/bridge-browser/src/content/completion_notifier.ts b/bridge-browser/src/content/completion_notifier.ts index c42b56e..1543b68 100644 --- a/bridge-browser/src/content/completion_notifier.ts +++ b/bridge-browser/src/content/completion_notifier.ts @@ -11,6 +11,10 @@ interface LatestResponseSnapshot { hasToolCall: boolean; } +interface CompletionNotifierOptions { + onCompletedWithoutTools?: () => void; +} + const NO_RESPONSE_SIGNATURE = "no-response"; const COMPLETION_SETTLE_MS = 600; const COMPLETION_NOTIFICATION_COOLDOWN_MS = 1000; @@ -23,6 +27,8 @@ export class CompletionNotifier { private lastNotificationTime = 0; private readonly notifiedCompletionKeys = new Set(); + public constructor(private readonly options: CompletionNotifierOptions = {}) {} + public reset(): void { this.clearCompletionTimer(); this.lastIdle = null; @@ -86,13 +92,8 @@ export class CompletionNotifier { return; } - const now = Date.now(); - if (now - this.lastNotificationTime < COMPLETION_NOTIFICATION_COOLDOWN_MS) { - return; - } - this.notifiedCompletionKeys.add(completionKey); - this.lastNotificationTime = now; + this.options.onCompletedWithoutTools?.(); if (this.notifiedCompletionKeys.size > MAX_NOTIFIED_COMPLETION_KEYS) { const oldestKey = this.notifiedCompletionKeys.values().next().value; if (typeof oldestKey === "string") { @@ -100,6 +101,13 @@ export class CompletionNotifier { } } + const now = Date.now(); + if (now - this.lastNotificationTime < COMPLETION_NOTIFICATION_COOLDOWN_MS) { + return; + } + + this.lastNotificationTime = now; + void requestCompletionAttention().then((result) => { if (result === "sent") { Logger.log("Completion attention requested", "action"); diff --git a/bridge-browser/src/content/follow_up_overlay.ts b/bridge-browser/src/content/follow_up_overlay.ts new file mode 100644 index 0000000..9caef4e --- /dev/null +++ b/bridge-browser/src/content/follow_up_overlay.ts @@ -0,0 +1,187 @@ +import { BRANDING } from "@webcode/shared"; +import { t } from "../modules/i18n"; +import { FloatingPanelDragController } from "./floating_panel_drag"; +import { type FollowUpItem, type FollowUpQueue, type FollowUpQueueSnapshot } from "./follow_up_queue"; +import { FOLLOW_UP_OVERLAY_STYLE_TEXT } from "./follow_up_overlay_styles"; +import { isTurnSettled } from "./tool_activity_overlay_view"; +import { type ToolActivitySnapshot, type ToolActivityTracker } from "./tool_activity"; + +/** Floating composer for follow-ups that are safe to include in the next automatic turn. */ +export class FollowUpOverlay { + private enabled = true; + private readonly confirmButton: HTMLButtonElement; + private readonly dragController: FloatingPanelDragController; + private generating = false; + private readonly host: HTMLDivElement; + private readonly queueElement: HTMLDivElement; + private queueSending = false; + private readonly queue: FollowUpQueue; + private readonly summary: HTMLDivElement; + private toolWorking = false; + private readonly textarea: HTMLTextAreaElement; + + public constructor(queue: FollowUpQueue, tracker: ToolActivityTracker) { + this.queue = queue; + const view = createOverlayView(); + this.host = view.host; + this.queueElement = view.queueElement; + this.summary = view.summary; + this.textarea = view.textarea; + this.confirmButton = view.confirmButton; + + this.dragController = new FloatingPanelDragController(this.host); + this.dragController.bindHandle(view.header); + this.bindComposer(); + queue.subscribe((snapshot) => this.renderQueue(snapshot)); + tracker.subscribe((snapshot) => this.updateToolWorking(snapshot)); + } + + public setGenerating(generating: boolean): void { + this.generating = generating; + this.syncVisibility(); + } + + public setEnabled(enabled: boolean): void { + this.enabled = enabled; + this.syncVisibility(); + } + + private bindComposer(): void { + this.confirmButton.onclick = () => this.confirmDraft(); + this.textarea.addEventListener("input", () => this.syncConfirmButton()); + this.textarea.addEventListener("keydown", (event) => { + event.stopPropagation(); + if (event.key === "Enter" && (event.ctrlKey || event.metaKey) && !event.isComposing) { + event.preventDefault(); + this.confirmDraft(); + } + }); + this.textarea.addEventListener("keypress", (event) => event.stopPropagation()); + this.textarea.addEventListener("keyup", (event) => event.stopPropagation()); + this.syncConfirmButton(); + } + + private confirmDraft(): void { + if (!this.queue.confirm(this.textarea.value)) {return;} + this.textarea.value = ""; + this.syncConfirmButton(); + this.textarea.focus(); + } + + private renderQueue(snapshot: FollowUpQueueSnapshot): void { + this.queueElement.replaceChildren(...snapshot.items.map((item) => this.createQueueItem(item))); + const sendingCount = snapshot.items.filter((item) => item.status === "sending").length; + this.queueSending = sendingCount > 0; + this.summary.textContent = sendingCount > 0 + ? t("follow_up_sending") + : t("follow_up_description"); + const count = this.host.shadowRoot?.querySelector(".count"); + if (count) { + count.textContent = String(snapshot.items.length); + count.style.display = snapshot.items.length > 0 ? "block" : "none"; + } + this.syncVisibility(); + this.dragController.scheduleClamp(); + } + + private createQueueItem(item: FollowUpItem): HTMLElement { + const row = document.createElement("div"); + row.className = `item ${item.status}`; + const text = document.createElement("div"); + text.className = "item-text"; + text.textContent = item.text; + row.appendChild(text); + + if (item.status === "sending") { + const state = document.createElement("span"); + state.className = "item-state"; + state.textContent = t("follow_up_sending_short"); + row.appendChild(state); + } else { + const remove = document.createElement("button"); + remove.type = "button"; + remove.className = "remove"; + remove.title = t("follow_up_remove"); + remove.setAttribute("aria-label", remove.title); + remove.textContent = "×"; + remove.onclick = () => this.queue.remove(item.id); + row.appendChild(remove); + } + return row; + } + + private updateToolWorking(snapshot: ToolActivitySnapshot): void { + const currentTurn = snapshot.turns.at(-1); + this.toolWorking = Boolean(currentTurn && !isTurnSettled(currentTurn)); + this.syncVisibility(); + } + + private syncConfirmButton(): void { + this.confirmButton.disabled = this.textarea.value.trim().length === 0; + } + + private syncVisibility(): void { + const working = this.generating || this.toolWorking || this.queueSending; + this.host.style.display = this.enabled && working ? "block" : "none"; + } +} + +function createOverlayView(): { + confirmButton: HTMLButtonElement; + header: HTMLDivElement; + host: HTMLDivElement; + queueElement: HTMLDivElement; + summary: HTMLDivElement; + textarea: HTMLTextAreaElement; +} { + const host = document.createElement("div"); + host.style.display = "none"; + const shadow = host.attachShadow({ mode: "open" }); + const style = document.createElement("style"); + style.textContent = FOLLOW_UP_OVERLAY_STYLE_TEXT; + + const panel = document.createElement("section"); + panel.className = "panel"; + const header = document.createElement("div"); + header.className = "header"; + header.title = t("follow_up_drag"); + const heading = document.createElement("div"); + heading.className = "heading"; + const title = document.createElement("div"); + title.className = "title"; + title.textContent = `${BRANDING.productName} · ${t("follow_up_title")}`; + const summary = document.createElement("div"); + summary.className = "summary"; + summary.textContent = t("follow_up_description"); + const count = document.createElement("span"); + count.className = "count"; + count.style.display = "none"; + heading.append(title, summary); + header.append(heading, count); + + const body = document.createElement("div"); + body.className = "body"; + const queueElement = document.createElement("div"); + queueElement.className = "queue"; + const composer = document.createElement("div"); + composer.className = "composer"; + const textarea = document.createElement("textarea"); + textarea.placeholder = t("follow_up_placeholder"); + textarea.setAttribute("aria-label", t("follow_up_title")); + const footer = document.createElement("div"); + footer.className = "composer-footer"; + const hint = document.createElement("span"); + hint.className = "hint"; + hint.textContent = t("follow_up_shortcut"); + const confirmButton = document.createElement("button"); + confirmButton.type = "button"; + confirmButton.className = "confirm"; + confirmButton.textContent = t("follow_up_confirm"); + footer.append(hint, confirmButton); + composer.append(textarea, footer); + body.append(queueElement, composer); + panel.append(header, body); + shadow.append(style, panel); + document.body.appendChild(host); + return { confirmButton, header, host, queueElement, summary, textarea }; +} diff --git a/bridge-browser/src/content/follow_up_overlay_styles.ts b/bridge-browser/src/content/follow_up_overlay_styles.ts new file mode 100644 index 0000000..82303ed --- /dev/null +++ b/bridge-browser/src/content/follow_up_overlay_styles.ts @@ -0,0 +1,42 @@ +import { FOLLOW_UP_OVERLAY_Z_INDEX } from "../modules/overlay_layers"; + +export const FOLLOW_UP_OVERLAY_STYLE_TEXT = ` + :host { position: fixed; left: 20px; bottom: 20px; z-index: ${FOLLOW_UP_OVERLAY_Z_INDEX}; + width: min(360px, calc(100vw - 32px)); max-height: calc(100vh - 32px); color-scheme: dark; } + * { box-sizing: border-box; } + button, textarea { font: inherit; } + .panel { width: 100%; max-height: inherit; 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); } + .header { min-height: 48px; display: flex; align-items: center; justify-content: space-between; gap: 10px; + padding: 8px 10px 8px 12px; cursor: move; user-select: none; } + .heading { min-width: 0; } + .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; } + .count { flex: 0 0 auto; padding: 2px 7px; color: #bfdbfe; background: rgba(37, 99, 235, .2); + border: 1px solid rgba(96, 165, 250, .28); border-radius: 999px; font-size: 10px; } + .body { min-height: 0; display: flex; flex-direction: column; border-top: 1px solid #343942; } + .queue { min-height: 0; max-height: min(190px, 28vh); flex: 1 1 auto; overflow-y: auto; } + .queue:empty { display: none; } + .item { display: flex; align-items: flex-start; gap: 8px; padding: 8px 10px 8px 12px; + border-bottom: 1px solid rgba(255, 255, 255, .06); } + .item-text { min-width: 0; flex: 1; overflow-wrap: anywhere; color: #d8dde6; white-space: pre-wrap; } + .item.sending .item-text { color: #93c5fd; } + .item-state { flex: 0 0 auto; color: #93c5fd; font-size: 10px; white-space: nowrap; } + .remove { width: 22px; height: 22px; flex: 0 0 auto; padding: 0; border: 0; border-radius: 5px; + color: #aeb5c2; background: transparent; cursor: pointer; } + .remove:hover { color: #fff; background: #8f1d1d; } + .composer { flex: 0 0 auto; padding: 10px; } + textarea { width: 100%; min-height: 74px; max-height: min(180px, 24vh); resize: vertical; display: block; + padding: 8px 9px; color: #f3f4f6; background: #111318; border: 1px solid #454b56; border-radius: 7px; + line-height: 1.45; outline: none; } + textarea:focus { border-color: #3b82f6; box-shadow: 0 0 0 2px rgba(59, 130, 246, .16); } + textarea::placeholder { color: #747d8b; } + .composer-footer { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-top: 8px; } + .hint { color: #858d9a; font-size: 10px; } + .confirm { flex: 0 0 auto; padding: 5px 10px; border: 1px solid #3b82f6; border-radius: 6px; + color: #fff; background: #2563eb; cursor: pointer; } + .confirm:hover { background: #1d4ed8; } + .confirm:disabled { color: #7d8490; background: #292d34; border-color: #3b4048; cursor: default; } +`; diff --git a/bridge-browser/src/content/follow_up_queue.ts b/bridge-browser/src/content/follow_up_queue.ts new file mode 100644 index 0000000..6d71784 --- /dev/null +++ b/bridge-browser/src/content/follow_up_queue.ts @@ -0,0 +1,98 @@ +export type FollowUpItemStatus = "confirmed" | "sending"; + +export interface FollowUpItem { + id: string; + status: FollowUpItemStatus; + text: string; +} + +export interface FollowUpDelivery { + ids: string[]; + messages: string[]; +} + +export interface FollowUpQueueSnapshot { + items: FollowUpItem[]; +} + +type FollowUpQueueListener = (snapshot: FollowUpQueueSnapshot) => void; + +/** Keeps explicitly confirmed user follow-ups separate from unfinished input. */ +export class FollowUpQueue { + private idSequence = 0; + private readonly items: FollowUpItem[] = []; + private readonly listeners = new Set(); + + public confirm(text: string): FollowUpItem | null { + const normalized = text.trim(); + if (!normalized) {return null;} + + const item: FollowUpItem = { + id: `follow-up-${Date.now()}-${++this.idSequence}`, + status: "confirmed", + text: normalized, + }; + this.items.push(item); + this.emit(); + return { ...item }; + } + + public remove(id: string): boolean { + const index = this.items.findIndex((item) => item.id === id && item.status === "confirmed"); + if (index < 0) {return false;} + + this.items.splice(index, 1); + this.emit(); + return true; + } + + public beginDelivery(): FollowUpDelivery { + const deliverable = this.items.filter((item) => item.status === "confirmed"); + if (deliverable.length === 0) { + return { ids: [], messages: [] }; + } + + deliverable.forEach((item) => {item.status = "sending";}); + this.emit(); + return { + ids: deliverable.map((item) => item.id), + messages: deliverable.map((item) => item.text), + }; + } + + public completeDelivery(ids: readonly string[]): void { + const deliveredIds = new Set(ids); + const remaining = this.items.filter((item) => !deliveredIds.has(item.id)); + if (remaining.length === this.items.length) {return;} + + this.items.splice(0, this.items.length, ...remaining); + this.emit(); + } + + public releaseDelivery(ids: readonly string[]): void { + const releasedIds = new Set(ids); + let changed = false; + this.items.forEach((item) => { + if (releasedIds.has(item.id) && item.status === "sending") { + item.status = "confirmed"; + changed = true; + } + }); + if (changed) {this.emit();} + } + + public subscribe(listener: FollowUpQueueListener): () => void { + this.listeners.add(listener); + listener(this.getSnapshot()); + return () => this.listeners.delete(listener); + } + + private emit(): void { + const snapshot = this.getSnapshot(); + this.listeners.forEach((listener) => listener(snapshot)); + } + + private getSnapshot(): FollowUpQueueSnapshot { + return { items: this.items.map((item) => ({ ...item })) }; + } +} diff --git a/bridge-browser/src/content/follow_up_work_controller.ts b/bridge-browser/src/content/follow_up_work_controller.ts new file mode 100644 index 0000000..c5589f0 --- /dev/null +++ b/bridge-browser/src/content/follow_up_work_controller.ts @@ -0,0 +1,39 @@ +import type { SiteSelectors } from "../modules/config"; +import { isStopButtonVisible } from "../modules/page_selectors"; +import { CompletionNotifier } from "./completion_notifier"; +import { FollowUpOverlay } from "./follow_up_overlay"; +import type { FollowUpQueue } from "./follow_up_queue"; +import type { ToolActivityTracker } from "./tool_activity"; + +export const OBSERVED_PAGE_WORK_ATTRIBUTES = [ + "aria-busy", "aria-disabled", "aria-hidden", "aria-label", "class", + "data-disabled", "data-loading", "data-state", "data-test-id", "data-testid", + "data-visible", "disabled", "hidden", "inert", "style", "title", +]; + +/** Synchronizes the follow-up overlay with ordinary generation and tool activity. */ +export class FollowUpWorkController { + private readonly completionNotifier: CompletionNotifier; + private readonly overlay: FollowUpOverlay; + + public constructor( + queue: FollowUpQueue, + tracker: ToolActivityTracker, + onCompletedWithoutTools: () => void + ) { + this.overlay = new FollowUpOverlay(queue, tracker); + this.completionNotifier = new CompletionNotifier({ onCompletedWithoutTools }); + } + + public observe(selectors: SiteSelectors): void { + this.overlay.setEnabled(true); + this.overlay.setGenerating(isStopButtonVisible(selectors)); + this.completionNotifier.observe(selectors); + } + + public reset(): void { + this.completionNotifier.reset(); + this.overlay.setEnabled(false); + this.overlay.setGenerating(false); + } +} diff --git a/bridge-browser/src/content/main.ts b/bridge-browser/src/content/main.ts index 9ba2330..5ed2567 100644 --- a/bridge-browser/src/content/main.ts +++ b/bridge-browser/src/content/main.ts @@ -10,9 +10,10 @@ import { } from "../types"; import { AutoInitPromptController } from "./auto_init_prompt"; import { createApprovalState, parseStoredApprovalEntries, type ApprovalState } from "./approval_policy"; -import { CompletionNotifier } from "./completion_notifier"; import { DomToolActivityController } from "./dom_tool_activity"; import { DomToolTurnController } from "./dom_tool_turn"; +import { FollowUpQueue } from "./follow_up_queue"; +import { FollowUpWorkController, OBSERVED_PAGE_WORK_ATTRIBUTES } from "./follow_up_work_controller"; import { hasPromptResourceChange, loadPromptsFromStorage } from "./prompt_resources"; import { createNetworkCaptureRuntime } from "./network_capture_runtime"; import { ResultDeliveryController } from "./result_delivery_controller"; @@ -25,17 +26,7 @@ import { ToolRequestRegistry } from "./tool_request_registry"; import { logVirtualizedHistorySkip } from "./virtualized_history_skip"; // === 配置与状态 === -const CONFIG = { - pollInterval: 1000, - autoSend: true, - autoApproveTools: false, -}; - -const OBSERVED_STATE_ATTRIBUTES = [ - "aria-busy", "aria-disabled", "aria-hidden", "aria-label", "class", - "data-disabled", "data-loading", "data-state", "data-test-id", "data-testid", - "data-visible", "disabled", "hidden", "inert", "style", "title", -]; +const CONFIG = { pollInterval: 1000, autoSend: true, autoApproveTools: false }; // [State] Connection Guard let isClientConnected = false; @@ -139,7 +130,7 @@ async function refreshConnectedState(): Promise { await loadPromptsFromStorage(); autoInitPrompt.scheduleCheck(); if (DOM) { - completionNotifier.observe(DOM); + followUpWork.observe(DOM); } runMainLoop(); } @@ -197,11 +188,12 @@ function applySyncedSiteConfig(siteId: string, sites: SyncedAiSite[]): void { domToolActivity.reset(); domToolTurns.reset(); networkCapture.configure(getSiteNetworkCaptureConfig(matchedSite.capture)); - completionNotifier.reset(); + followUpWork.reset(); autoInitPrompt.setupTrigger(); void loadPromptsFromStorage(); autoInitPrompt.scheduleCheck(); startObserver(); + followUpWork.observe(DOM); return; } @@ -210,6 +202,7 @@ function applySyncedSiteConfig(siteId: string, sites: SyncedAiSite[]): void { domToolActivity.reset(); domToolTurns.reset(); networkCapture.reset(); + followUpWork.reset(); console.log(`${BRANDING.productName}: Site '${siteId}' is not configured in VS Code. Idle.`); } @@ -217,6 +210,7 @@ function resetCurrentSite(): void { domToolActivity.reset(); domToolTurns.reset(); networkCapture.reset(); + followUpWork.reset(); DOM = null; currentSiteName = null; currentSiteId = null; @@ -248,9 +242,9 @@ const requestRegistry = new ToolRequestRegistry(); const toolActivityTracker = new ToolActivityTracker(); const domToolActivity = new DomToolActivityController(toolActivityTracker); const domToolTurns = new DomToolTurnController(requestRegistry); +const followUpQueue = new FollowUpQueue(); new ToolActivityOverlay(toolActivityTracker); -let lastProgressLogTime = 0; -let lastProgressStatus = ""; +let lastProgressLogTime = 0, lastProgressStatus = ""; // === 性能优化: MutationObserver 取代 setInterval === // 主循环调度锁。DOM 变化、工具完成、协议错误稳定性检查都可能频繁触发 runMainLoop; @@ -291,6 +285,7 @@ const networkCapture = createNetworkCaptureRuntime({ }); const resultDelivery = new ResultDeliveryController({ + followUpQueue, getAutoSend: () => CONFIG.autoSend, hasPendingTurns: () => networkCapture.hasPendingTurns(), onBatchFinalized: (requestKeys) => domToolTurns.finalizeRequests(requestKeys), @@ -299,7 +294,11 @@ const resultDelivery = new ResultDeliveryController({ toolActivityTracker, }); -const completionNotifier = new CompletionNotifier(); +const followUpWork = new FollowUpWorkController(followUpQueue, toolActivityTracker, () => { + if (DOM && !networkCapture.hasPendingTurns()) { + resultDelivery.deliverFollowUps(DOM); + } +}); /** * 延迟调度一次主循环扫描。 @@ -492,7 +491,7 @@ const observer = new MutationObserver(() => { if (!isClientConnected) { return; } if (DOM) { - completionNotifier.observe(DOM); + followUpWork.observe(DOM); } // DOM 变化只说明页面可能出现了新内容;延迟扫描能等待流式文本继续补全。 @@ -516,7 +515,7 @@ function startObserver() { // 1. Start observing immediately (but logic inside is guarded by isClientConnected) observer.observe(document.body, { attributes: true, - attributeFilter: OBSERVED_STATE_ATTRIBUTES, + attributeFilter: OBSERVED_PAGE_WORK_ATTRIBUTES, childList: true, subtree: true, characterData: true @@ -535,7 +534,7 @@ function startObserver() { // 连接恢复后先检查自动初始化触发词,再立刻扫描现有消息,避免等待下一次页面变化。 autoInitPrompt.scheduleCheck(); if (DOM) { - completionNotifier.observe(DOM); + followUpWork.observe(DOM); } runMainLoop(); } else { diff --git a/bridge-browser/src/content/result_delivery_controller.ts b/bridge-browser/src/content/result_delivery_controller.ts index 50fc748..f82762b 100644 --- a/bridge-browser/src/content/result_delivery_controller.ts +++ b/bridge-browser/src/content/result_delivery_controller.ts @@ -1,11 +1,15 @@ import type { SiteSelectors } from "../modules/config"; +import { t } from "../modules/i18n"; import { Logger } from "../modules/logger"; +import type { ToolResultDeliveryBatch } from "../modules/tool_result"; import * as UI from "../modules/ui"; +import type { FollowUpDelivery, FollowUpQueue } from "./follow_up_queue"; import type { ToolActivityTracker } from "./tool_activity"; import type { BufferedResultBatch, ToolRequestRegistry } from "./tool_request_registry"; interface ResultDeliveryControllerOptions { getAutoSend: () => boolean; + followUpQueue: FollowUpQueue; hasPendingTurns: () => boolean; onBatchFinalized?: (requestKeys: readonly string[]) => void; requestRegistry: ToolRequestRegistry; @@ -27,26 +31,23 @@ export class ResultDeliveryController { this.isDeliveryRunning = true; this.options.toolActivityTracker.updateDelivery(resultBatch.ids, "delivering"); - let batchFinalized = false; - void UI.deliverResult(resultBatch, selectors) - .then((delivery) => { - batchFinalized = true; - this.finalizeBatch(resultBatch.ids); - if (!delivery.delivered) { - this.handleDeliveryFailure(resultBatch); - return; - } + void this.deliverResultBatch(resultBatch, selectors); + } - this.options.toolActivityTracker.updateDelivery(resultBatch.ids, "delivered"); - UI.triggerAutoSend({ autoSend: this.options.getAutoSend() }, selectors); - }) + /** Sends confirmed follow-ups after an ordinary assistant response with no tool calls. */ + public deliverFollowUps(selectors: SiteSelectors): void { + if (this.isDeliveryRunning || !this.options.getAutoSend()) {return;} + + const followUps = this.options.followUpQueue.beginDelivery(); + if (followUps.ids.length === 0) {return;} + + this.isDeliveryRunning = true; + void this.writeFollowUpsAndSend(followUps, selectors) .catch((error: unknown) => { - batchFinalized = true; - this.finalizeBatch(resultBatch.ids); - this.options.toolActivityTracker.updateDelivery(resultBatch.ids, "failed"); - Logger.log(`Result delivery failed: ${getErrorMessage(error)}`, "error"); + this.options.followUpQueue.releaseDelivery(followUps.ids); + Logger.log(`Follow-up delivery failed: ${getErrorMessage(error)}`, "error"); }) - .finally(() => this.finishDelivery(batchFinalized)); + .finally(() => this.finishDelivery(false)); } private finalizeBatch(requestKeys: readonly string[]): void { @@ -62,10 +63,70 @@ export class ResultDeliveryController { ); } + private async deliverResultBatch( + resultBatch: BufferedResultBatch, + selectors: SiteSelectors + ): Promise { + let batchFinalized = false; + try { + const delivery = await UI.deliverResult(resultBatch, selectors); + batchFinalized = true; + this.finalizeBatch(resultBatch.ids); + if (!delivery.delivered) { + this.handleDeliveryFailure(resultBatch); + return; + } + + this.options.toolActivityTracker.updateDelivery(resultBatch.ids, "delivered"); + const followUps = this.options.getAutoSend() + ? this.options.followUpQueue.beginDelivery() + : { ids: [], messages: [] }; + await this.writeFollowUpsAndSend(followUps, selectors); + } catch (error: unknown) { + if (!batchFinalized) { + batchFinalized = true; + this.finalizeBatch(resultBatch.ids); + this.options.toolActivityTracker.updateDelivery(resultBatch.ids, "failed"); + } + Logger.log(`Result delivery failed: ${getErrorMessage(error)}`, "error"); + } finally { + this.finishDelivery(batchFinalized); + } + } + + private async writeFollowUpsAndSend( + followUps: FollowUpDelivery, + selectors: SiteSelectors + ): Promise { + try { + if (followUps.ids.length > 0) { + const delivery = await UI.deliverResult(createFollowUpBatch(followUps.messages), selectors); + if (!delivery.delivered) { + this.options.followUpQueue.releaseDelivery(followUps.ids); + Logger.log("Confirmed follow-ups could not be written. Auto-send skipped.", "error"); + return; + } + } + + const sendResult = await UI.triggerAutoSend( + { autoSend: this.options.getAutoSend() }, + selectors + ); + if (sendResult === "sent") { + this.options.followUpQueue.completeDelivery(followUps.ids); + } else { + this.options.followUpQueue.releaseDelivery(followUps.ids); + } + } catch (error) { + this.options.followUpQueue.releaseDelivery(followUps.ids); + throw error; + } + } + private finishDelivery(batchFinalized: boolean): void { this.isDeliveryRunning = false; - const shouldRerun = batchFinalized && ( - this.isRerunNeeded || this.options.hasPendingTurns() + const shouldRerun = this.isRerunNeeded || ( + batchFinalized && this.options.hasPendingTurns() ); this.isRerunNeeded = false; if (shouldRerun) { @@ -74,6 +135,19 @@ export class ResultDeliveryController { } } +function createFollowUpBatch(messages: readonly string[]): ToolResultDeliveryBatch { + const heading = t("follow_up_delivery_heading"); + const outputParts = messages.map((message, index) => { + const suffix = messages.length > 1 ? ` ${index + 1}` : ""; + return `[${heading}${suffix}]\n${message}`; + }); + return { + attachmentGroups: [], + output: outputParts.join("\n\n"), + outputParts, + }; +} + function getErrorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } diff --git a/bridge-browser/src/modules/auto_send.ts b/bridge-browser/src/modules/auto_send.ts index a8f8cbc..525ffc3 100644 --- a/bridge-browser/src/modules/auto_send.ts +++ b/bridge-browser/src/modules/auto_send.ts @@ -10,8 +10,15 @@ import { import { isElementVisible } from "./dom_helpers"; import { showUserAttentionNotification } from "./user_attention"; -let autoSendTimer: NodeJS.Timeout | null = null; type AutoSendAction = "ctrl-enter" | "enter" | "button"; +export type AutoSendResult = "cancelled" | "disabled" | "failed" | "sent"; + +interface ActiveAutoSend { + cancel: (shouldLog: boolean) => void; + timer: NodeJS.Timeout | null; +} + +let activeAutoSend: ActiveAutoSend | null = null; const AUTO_SEND_INITIAL_DELAY_MS = 350; const AUTO_SEND_SETTLE_MS = 1200; @@ -29,11 +36,7 @@ const AUTO_SEND_ACTIONS: AutoSendAction[] = [ * @description 如果定时器存在,清除它并输出取消日志。主要用于当监听到用户手动输入或页面有新活动时,打断之前的自动发送操作。 */ export function cancelAutoSend() { - if (autoSendTimer) { - clearTimeout(autoSendTimer); - autoSendTimer = null; - Logger.log("🚫 Auto-send cancelled (New activity detected)", "warn"); - } + activeAutoSend?.cancel(true); } // === 自动发送逻辑 === @@ -53,12 +56,9 @@ export function cancelAutoSend() { export function triggerAutoSend( config: { autoSend: boolean }, domSelectors: SiteSelectors -) { - if (!config.autoSend) {return;} - if (autoSendTimer) { - clearTimeout(autoSendTimer); - autoSendTimer = null; - } +): Promise { + if (!config.autoSend) {return Promise.resolve("disabled");} + activeAutoSend?.cancel(false); let retryCount = 0; const maxRetries = AUTO_SEND_ACTIONS.length; @@ -79,73 +79,105 @@ export function triggerAutoSend( return isStopButtonVisible(domSelectors); }; - const scheduleRetry = () => { - retryCount++; - if (retryCount < maxRetries) { - autoSendTimer = setTimeout(trySend, AUTO_SEND_RETRY_MS); - } else { - Logger.log(t("auto_send_timeout"), "error"); - void showUserAttentionNotification({ - title: "Auto-Send Failed", - message: "Could not send message.", - }); - } - }; - - const trySend = () => { - autoSendTimer = null; - const inputEl = getInputEl(); - if (inputEl) {inputEl.focus();} + return new Promise((resolve) => { + let settled = false; - if (isSendComplete()) { - Logger.log(t("send_success_cleared"), "success"); - return; - } - - if (inputEl) { - inputEl.dispatchEvent(new Event("input", { bubbles: true })); - inputEl.dispatchEvent(new Event("change", { bubbles: true })); - } - - const action = AUTO_SEND_ACTIONS[retryCount] ?? "enter"; - if (action === "ctrl-enter" || action === "enter") { - if (inputEl) { - const withCtrl = action === "ctrl-enter"; - triggerSingleEnter(inputEl, withCtrl); - Logger.log(`Auto-send fallback: ${withCtrl ? "Ctrl+Enter" : "Enter"} (${retryCount + 1})`, "action"); - } else { - Logger.log(t("input_not_found"), "error"); + const finish = (result: AutoSendResult, shouldLogCancellation = false) => { + if (settled) {return;} + settled = true; + if (activeAutoSend?.timer) {clearTimeout(activeAutoSend.timer);} + activeAutoSend = null; + if (shouldLogCancellation && result === "cancelled") { + Logger.log("🚫 Auto-send cancelled (New activity detected)", "warn"); } - } else { - const btnNow = getSendButton(domSelectors); - if (isSendButtonReady(btnNow)) { - const isActuallyStopBtn = isSendButtonActuallyStopButton(domSelectors, btnNow); - if (!isActuallyStopBtn) { - triggerButtonSend(btnNow); - Logger.log( - `${t("auto_send_attempt")} (${retryCount + 1})`, - "action" - ); - } - } else if (!btnNow) { - Logger.log(t("send_btn_missing"), "warn"); + resolve(result); + }; + + const schedule = (callback: () => void, delayMs: number) => { + const timer = setTimeout(() => { + if (activeAutoSend?.timer === timer) {activeAutoSend.timer = null;} + callback(); + }, delayMs); + if (activeAutoSend) {activeAutoSend.timer = timer;} + }; + + const scheduleRetry = () => { + retryCount++; + if (retryCount < maxRetries) { + schedule(trySend, AUTO_SEND_RETRY_MS); } else { - Logger.log(t("send_btn_disabled"), "warn"); + Logger.log(t("auto_send_timeout"), "error"); + void showUserAttentionNotification({ + title: "Auto-Send Failed", + message: "Could not send message.", + }); + finish("failed"); } - } + }; + + const trySend = () => { + const inputEl = getInputEl(); + if (inputEl) {inputEl.focus();} - // 等待页面完成输入框清空、stop 按钮切换等异步渲染;下一轮才尝试另一种发送方式。 - autoSendTimer = setTimeout(() => { - autoSendTimer = null; if (isSendComplete()) { Logger.log(t("send_success_cleared"), "success"); + finish("sent"); return; } - scheduleRetry(); - }, AUTO_SEND_SETTLE_MS); - }; - autoSendTimer = setTimeout(trySend, AUTO_SEND_INITIAL_DELAY_MS); + dispatchSendAttempt(inputEl, domSelectors, retryCount); + // 等待页面完成输入框清空、stop 按钮切换等异步渲染;下一轮才尝试另一种发送方式。 + schedule(() => { + if (isSendComplete()) { + Logger.log(t("send_success_cleared"), "success"); + finish("sent"); + return; + } + scheduleRetry(); + }, AUTO_SEND_SETTLE_MS); + }; + + activeAutoSend = { + cancel: (shouldLog) => finish(isSendComplete() ? "sent" : "cancelled", shouldLog), + timer: null, + }; + schedule(trySend, AUTO_SEND_INITIAL_DELAY_MS); + }); +} + +function dispatchSendAttempt( + inputEl: HTMLElement | null, + domSelectors: SiteSelectors, + retryCount: number +): void { + if (inputEl) { + inputEl.dispatchEvent(new Event("input", { bubbles: true })); + inputEl.dispatchEvent(new Event("change", { bubbles: true })); + } + + const action = AUTO_SEND_ACTIONS[retryCount] ?? "enter"; + if (action === "ctrl-enter" || action === "enter") { + if (!inputEl) { + Logger.log(t("input_not_found"), "error"); + return; + } + const withCtrl = action === "ctrl-enter"; + triggerSingleEnter(inputEl, withCtrl); + Logger.log(`Auto-send fallback: ${withCtrl ? "Ctrl+Enter" : "Enter"} (${retryCount + 1})`, "action"); + return; + } + + const btnNow = getSendButton(domSelectors); + if (isSendButtonReady(btnNow)) { + if (!isSendButtonActuallyStopButton(domSelectors, btnNow)) { + triggerButtonSend(btnNow); + Logger.log(`${t("auto_send_attempt")} (${retryCount + 1})`, "action"); + } + } else if (!btnNow) { + Logger.log(t("send_btn_missing"), "warn"); + } else { + Logger.log(t("send_btn_disabled"), "warn"); + } } /** diff --git a/bridge-browser/src/modules/i18n.ts b/bridge-browser/src/modules/i18n.ts index 98529d5..804106a 100644 --- a/bridge-browser/src/modules/i18n.ts +++ b/bridge-browser/src/modules/i18n.ts @@ -83,6 +83,22 @@ const I18N_MESSAGES: Record = { activity_no_history: { en: "No previous tool activity", zh: "暂无之前的工具活动" }, activity_drag: { en: "Drag tool activity window", zh: "拖动工具活动窗口" }, + follow_up_title: { en: "Next-turn follow-up", zh: "下一轮补充" }, + follow_up_description: { + en: "Only confirmed messages will be sent when this work finishes", + zh: "仅已确认的内容会在本轮工作结束后发送", + }, + follow_up_placeholder: { + en: "Add context without touching the active chat input...", + zh: "在这里补充信息,不影响当前聊天输入框...", + }, + follow_up_shortcut: { en: "Ctrl/⌘ + Enter to confirm", zh: "Ctrl/⌘ + Enter 确认" }, + follow_up_confirm: { en: "Confirm", zh: "确认加入" }, + follow_up_remove: { en: "Remove confirmed follow-up", zh: "移除已确认的补充" }, + follow_up_sending: { en: "Sending confirmed follow-ups...", zh: "正在发送已确认的补充..." }, + follow_up_sending_short: { en: "Sending", zh: "发送中" }, + follow_up_delivery_heading: { en: "User follow-up", zh: "用户补充" }, + hitl_title: { en: "Approval Required", zh: "请求执行工具" }, label_tool: { en: "Tool Name", zh: "工具名称" }, label_purpose: { en: "Purpose", zh: "操作意图" }, diff --git a/bridge-browser/src/modules/overlay_layers.ts b/bridge-browser/src/modules/overlay_layers.ts index 3d4169b..de6a8f0 100644 --- a/bridge-browser/src/modules/overlay_layers.ts +++ b/bridge-browser/src/modules/overlay_layers.ts @@ -1,2 +1,3 @@ -export const TOOL_ACTIVITY_OVERLAY_Z_INDEX = 2147483646; +export const FOLLOW_UP_OVERLAY_Z_INDEX = 2147483645; +export const TOOL_ACTIVITY_OVERLAY_Z_INDEX = FOLLOW_UP_OVERLAY_Z_INDEX + 1; export const APPROVAL_MODAL_Z_INDEX = TOOL_ACTIVITY_OVERLAY_Z_INDEX + 1; diff --git a/bridge-browser/test/auto_send.test.ts b/bridge-browser/test/auto_send.test.ts new file mode 100644 index 0000000..b0cd197 --- /dev/null +++ b/bridge-browser/test/auto_send.test.ts @@ -0,0 +1,125 @@ +import type { SiteSelectors } from "../src/modules/config"; + +export {}; + +class FakeInput { + public innerText = ""; + + public contains(): boolean { + return false; + } + + public dispatchEvent(event: Event): boolean { + if (event.type === "keydown" && (event as KeyboardEvent).key === "Enter") { + this.innerText = ""; + } + return true; + } + + public focus(): void { + fakeDocument.activeElement = this; + } +} + +const input = new FakeInput(); +const fakeDocument = { + activeElement: input, + querySelector: () => null, + querySelectorAll: (selector: string) => selector === "#input" ? [input] : [], +}; +const timers = new Map void>(); +let nextTimerId = 1; + +const SELECTORS: SiteSelectors = { + codeBlocks: "code", + inputArea: "#input", + messageBlocks: ".message", + sendButton: ".send", + stopButton: ".stop", +}; + +async function main(): Promise { + installBrowserGlobals(); + const { cancelAutoSend, triggerAutoSend } = await import("../src/modules/auto_send"); + + input.innerText = "message"; + const successfulSend = triggerAutoSend({ autoSend: true }, SELECTORS); + flushNextTimer(); + flushNextTimer(); + assertEqual(await successfulSend, "sent", "successful send did not resolve as sent"); + + input.innerText = "message"; + const cancelledSend = triggerAutoSend({ autoSend: true }, SELECTORS); + cancelAutoSend(); + assertEqual(await cancelledSend, "cancelled", "cancelled send did not resolve"); + assertEqual(timers.size, 0, "cancelled send left a retry timer"); + + assertEqual( + await triggerAutoSend({ autoSend: false }, SELECTORS), + "disabled", + "disabled auto-send did not resolve" + ); +} + +function installBrowserGlobals(): void { + class FakeKeyboardEvent extends Event { + public readonly key: string; + + public constructor(type: string, init: KeyboardEventInit) { + super(type, init); + this.key = init.key ?? ""; + } + } + class FakeTextControl {} + + Object.defineProperty(globalThis, "document", { configurable: true, value: fakeDocument }); + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: { language: "en-US" }, + }); + Object.defineProperty(globalThis, "window", { + configurable: true, + value: { + getComputedStyle: () => ({ display: "block", pointerEvents: "auto", visibility: "visible" }), + }, + }); + Object.defineProperty(globalThis, "HTMLInputElement", { + configurable: true, + value: FakeTextControl, + }); + Object.defineProperty(globalThis, "HTMLTextAreaElement", { + configurable: true, + value: FakeTextControl, + }); + Object.defineProperty(globalThis, "KeyboardEvent", { + configurable: true, + value: FakeKeyboardEvent, + }); + Object.defineProperty(globalThis, "setTimeout", { + configurable: true, + value: (handler: () => void) => { + const id = nextTimerId++; + timers.set(id, handler); + return id; + }, + }); + Object.defineProperty(globalThis, "clearTimeout", { + configurable: true, + value: (id: number) => timers.delete(id), + }); +} + +function flushNextTimer(): void { + const entry = timers.entries().next().value as [number, () => void] | undefined; + if (!entry) {throw new Error("missing scheduled auto-send timer");} + timers.delete(entry[0]); + entry[1](); +} + +function assertEqual(actual: unknown, expected: unknown, message: string): void { + if (actual !== expected) { + throw new Error(`${message}: expected ${String(expected)}, received ${String(actual)}`); + } +} + +void main(); diff --git a/bridge-browser/test/completion_notifier.test.ts b/bridge-browser/test/completion_notifier.test.ts new file mode 100644 index 0000000..408a0db --- /dev/null +++ b/bridge-browser/test/completion_notifier.test.ts @@ -0,0 +1,99 @@ +interface FakeMessage { + querySelectorAll: () => Array<{ textContent: string }>; + textContent: string; +} + +export {}; + +let nextTimerId = 1; +const timers = new Map void>(); +let stopVisible = false; +const message: FakeMessage = { + querySelectorAll: () => [], + textContent: "", +}; + +async function main(): Promise { + installBrowserGlobals(); + const { CompletionNotifier } = await import("../src/content/completion_notifier"); + let completedCount = 0; + const notifier = new CompletionNotifier({ + onCompletedWithoutTools: () => {completedCount += 1;}, + }); + const selectors = { + codeBlocks: "code", + inputArea: "input", + messageBlocks: ".message", + sendButton: ".send", + stopButton: ".stop", + }; + + notifier.observe(selectors); + stopVisible = true; + notifier.observe(selectors); + message.textContent = "ordinary response"; + stopVisible = false; + notifier.observe(selectors); + flushTimers(); + assertEqual(completedCount, 1, "ordinary completion did not trigger follow-up delivery"); + + stopVisible = true; + notifier.observe(selectors); + message.textContent = "tool response"; + message.querySelectorAll = () => [{ + textContent: '{"mcp_action":"call","name":"read_file","arguments":{}}', + }]; + stopVisible = false; + notifier.observe(selectors); + flushTimers(); + assertEqual(completedCount, 1, "tool completion triggered standalone follow-up delivery"); +} + +function installBrowserGlobals(): void { + const stopButton = { + getBoundingClientRect: () => ({ height: 20, width: 20 }), + }; + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: { language: "en-US" }, + }); + Object.defineProperty(globalThis, "window", { + configurable: true, + value: { + getComputedStyle: () => ({ display: "block", pointerEvents: "auto", visibility: "visible" }), + }, + }); + Object.defineProperty(globalThis, "document", { + configurable: true, + value: { + querySelector: (selector: string) => selector === ".stop" && stopVisible ? stopButton : null, + querySelectorAll: (selector: string) => selector === ".message" ? [message] : [], + }, + }); + Object.defineProperty(globalThis, "setTimeout", { + configurable: true, + value: (handler: () => void) => { + const id = nextTimerId++; + timers.set(id, handler); + return id; + }, + }); + Object.defineProperty(globalThis, "clearTimeout", { + configurable: true, + value: (id: number) => timers.delete(id), + }); +} + +function flushTimers(): void { + const callbacks = [...timers.values()]; + timers.clear(); + callbacks.forEach((callback) => callback()); +} + +function assertEqual(actual: unknown, expected: unknown, messageText: string): void { + if (actual !== expected) { + throw new Error(`${messageText}: expected ${String(expected)}, received ${String(actual)}`); + } +} + +void main(); diff --git a/bridge-browser/test/follow_up_overlay.test.ts b/bridge-browser/test/follow_up_overlay.test.ts new file mode 100644 index 0000000..2d8c15e --- /dev/null +++ b/bridge-browser/test/follow_up_overlay.test.ts @@ -0,0 +1,336 @@ +import { FollowUpQueue } from "../src/content/follow_up_queue"; +import { ToolActivityTracker } from "../src/content/tool_activity"; + +interface FakeRect { + height: number; + left: number; + top: number; + width: number; +} + +interface FakeEvent { + button: number; + clientX: number; + clientY: number; + ctrlKey: boolean; + isComposing: boolean; + key: string; + metaKey: boolean; + preventDefault: () => void; + stopPropagation: () => void; + target: FakeElement; +} + +class FakeElement { + public readonly children: FakeElement[] = []; + public className = ""; + public disabled = false; + public onclick: (() => void) | null = null; + public onmousedown: ((event: FakeEvent) => void) | null = null; + public parentElement: FakeElement | null = null; + public placeholder = ""; + public shadowRoot: FakeElement | null = null; + public readonly style: Record = {}; + public textContent = ""; + public title = ""; + public type = ""; + public value = ""; + private readonly attributes = new Map(); + private readonly listeners = new Map void>>(); + private rect: FakeRect = { height: 0, left: 0, top: 0, width: 0 }; + + public constructor(private readonly tagName = "div") {} + + public append(...children: FakeElement[]): void { + children.forEach((child) => this.appendChild(child)); + } + + public appendChild(child: FakeElement): FakeElement { + child.parentElement = this; + this.children.push(child); + return child; + } + + public attachShadow(): FakeElement { + this.shadowRoot = new FakeElement("shadow-root"); + return this.shadowRoot; + } + + public addEventListener(type: string, listener: (event: FakeEvent) => void): void { + const listeners = this.listeners.get(type) ?? new Set<(event: FakeEvent) => void>(); + listeners.add(listener); + this.listeners.set(type, listeners); + } + + public click(): void { + if (!this.disabled) {this.onclick?.();} + } + + public closest(selector: string): FakeElement | null { + if (selector === "button" && this.tagName === "button") {return this;} + return this.parentElement?.closest(selector) ?? null; + } + + public dispatch(type: string, event: FakeEvent): void { + this.listeners.get(type)?.forEach((listener) => listener(event)); + } + + public focus(): void { + fakeDocument.activeElement = this; + } + + public getBoundingClientRect(): DOMRect { + const { height, width } = this.rect; + const left = parsePixels(this.style.left) ?? this.rect.left; + const bottom = parsePixels(this.style.bottom); + const top = parsePixels(this.style.top) ?? ( + bottom === null ? this.rect.top : fakeWindow.innerHeight - bottom - height + ); + 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: FakeEvent): void { + this.onmousedown?.(event); + } + + public querySelector(selector: string): T | null { + const match = selector.startsWith(".") + ? this.findByClass(selector.slice(1)) + : this.findByTag(selector); + return match as T | null; + } + + public replaceChildren(...children: FakeElement[]): void { + this.children.length = 0; + this.append(...children); + } + + public setAttribute(name: string, value: string): void { + this.attributes.set(name, value); + } + + public setRect(rect: FakeRect): void { + this.rect = rect; + } + + private findByClass(className: string): FakeElement | null { + if (this.className.split(/\s+/).includes(className)) {return this;} + for (const child of this.children) { + const match = child.findByClass(className); + if (match) {return match;} + } + return null; + } + + private findByTag(tagName: string): FakeElement | null { + if (this.tagName === tagName) {return this;} + for (const child of this.children) { + const match = child.findByTag(tagName); + if (match) {return match;} + } + return null; + } +} + +class FakeDocument { + public activeElement: FakeElement | null = null; + public readonly body = new FakeElement("body"); + + public createElement(tagName: string): FakeElement { + return new FakeElement(tagName); + } + + public reset(): void { + this.activeElement = null; + this.body.replaceChildren(); + } +} + +class FakeWindow { + public innerHeight = 700; + public innerWidth = 1000; + private animationFrameId = 1; + 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: FakeEvent) => void>(); + listeners.add(listener as (event: FakeEvent) => void); + this.listeners.set(type, listeners); + } + + public dispatch(type: string, event: FakeEvent): void { + this.listeners.get(type)?.forEach((listener) => listener(event)); + } + + public queueAnimationFrame(callback: () => void): number { + callback(); + return this.animationFrameId++; + } + + public reset(): void { + this.innerHeight = 700; + this.innerWidth = 1000; + this.listeners.clear(); + } +} + +const fakeDocument = new FakeDocument(); +const fakeWindow = new FakeWindow(); + +async function main(): Promise { + installBrowserGlobals(); + const { FollowUpOverlay } = await import("../src/content/follow_up_overlay"); + runTest("drafts stay isolated until confirmation and disappear after delivery", () => { + const harness = createHarness(FollowUpOverlay); + harness.overlay.setGenerating(true); + const textarea = getRequired(harness.host.shadowRoot!, "textarea"); + textarea.value = "unfinished draft"; + assertEqual(harness.queue.beginDelivery().messages.length, 0, "unfinished draft entered delivery"); + + confirmDraft(harness.host, textarea); + textarea.value = "second detail"; + confirmDraft(harness.host, textarea); + const delivery = harness.queue.beginDelivery(); + assertEqual(delivery.messages.join("|"), "unfinished draft|second detail", "confirmed text changed"); + assertIncludes(getRequired(harness.host.shadowRoot!, ".queue").getText(), "unfinished draft", "queue hid a confirmed message"); + + harness.queue.completeDelivery(delivery.ids); + assert(!getRequired(harness.host.shadowRoot!, ".queue").getText(), "delivered messages remained visible"); + }); + runTest("composer only appears during work and dragging stays in the viewport", () => { + const harness = createHarness(FollowUpOverlay); + assertEqual(harness.host.style.display, "none", "idle composer was visible"); + harness.overlay.setGenerating(true); + assertEqual(harness.host.style.display, "block", "generation did not show composer"); + harness.overlay.setGenerating(false); + assertEqual(harness.host.style.display, "none", "idle composer stayed visible"); + + captureTool(harness.tracker); + assertEqual(harness.host.style.display, "block", "tool work did not show composer"); + const header = getRequired(harness.host.shadowRoot!, ".header"); + header.mouseDown(createEvent(header, 20, 420)); + fakeWindow.dispatch("mousemove", createEvent(header, -1000, -1000)); + fakeWindow.dispatch("mouseup", createEvent(header, -1000, -1000)); + assertEqual(harness.host.style.left, "8px", "composer escaped the left edge"); + assertEqual(harness.host.getBoundingClientRect().top, 8, "composer escaped the top edge"); + }); +} + +interface OverlayInstance { + setGenerating: (generating: boolean) => void; +} + +type OverlayConstructor = new ( + queue: FollowUpQueue, + tracker: ToolActivityTracker +) => OverlayInstance; + +function createHarness(Overlay: OverlayConstructor): { + host: FakeElement; + overlay: OverlayInstance; + queue: FollowUpQueue; + tracker: ToolActivityTracker; +} { + fakeDocument.reset(); + fakeWindow.reset(); + const queue = new FollowUpQueue(); + const tracker = new ToolActivityTracker(); + const overlay = new Overlay(queue, tracker); + const host = fakeDocument.body.children.at(-1); + assert(host?.shadowRoot, "follow-up overlay was not created"); + host.setRect({ height: 260, left: 20, top: 420, width: 350 }); + return { host, overlay, queue, tracker }; +} + +function confirmDraft(host: FakeElement, textarea: FakeElement): void { + textarea.dispatch("input", createEvent(textarea)); + getRequired(host.shadowRoot!, ".confirm").click(); +} + +function captureTool(tracker: ToolActivityTracker): void { + tracker.capture({ + identity: { requestKey: "follow-up-tool" }, + payload: { name: "read_file", purpose: "Read context" }, + turnId: "follow-up-turn", + }); +} + +function createEvent(target: FakeElement, clientX = 0, clientY = 0): FakeEvent { + return { + button: 0, + clientX, + clientY, + ctrlKey: false, + isComposing: false, + key: "", + metaKey: false, + preventDefault: () => undefined, + stopPropagation: () => undefined, + target, + }; +} + +function getRequired(root: FakeElement, selector: string): FakeElement { + const element = root.querySelector(selector); + assert(element, `missing element ${selector}`); + return element; +} + +function installBrowserGlobals(): void { + Object.defineProperty(globalThis, "document", { configurable: true, value: fakeDocument }); + Object.defineProperty(globalThis, "navigator", { configurable: true, value: { language: "en-US" } }); + Object.defineProperty(globalThis, "window", { configurable: true, value: fakeWindow }); + Object.defineProperty(globalThis, "requestAnimationFrame", { + configurable: true, + value: (callback: () => void) => fakeWindow.queueAnimationFrame(callback), + }); +} + +function parsePixels(value: string | undefined): number | null { + if (!value || value === "auto") {return null;} + const parsed = Number.parseFloat(value); + return Number.isFinite(parsed) ? parsed : null; +} + +function runTest(name: string, test: () => void): void { + try { + test(); + console.log(`PASS ${name}`); + } catch (error) { + console.error(`FAIL ${name}`); + throw error; + } +} + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) {throw new Error(message);} +} + +function assertEqual(actual: unknown, expected: unknown, message: string): void { + if (actual !== expected) { + throw new Error(`${message}: expected ${String(expected)}, received ${String(actual)}`); + } +} + +function assertIncludes(actual: string, expected: string, message: string): void { + if (!actual.includes(expected)) { + throw new Error(`${message}: expected '${actual}' to include '${expected}'`); + } +} + +void main(); diff --git a/bridge-browser/test/follow_up_queue.test.ts b/bridge-browser/test/follow_up_queue.test.ts new file mode 100644 index 0000000..184c8e4 --- /dev/null +++ b/bridge-browser/test/follow_up_queue.test.ts @@ -0,0 +1,62 @@ +import { FollowUpQueue } from "../src/content/follow_up_queue"; + +function main(): void { + runTest("only confirmed follow-ups enter delivery", testConfirmedDeliveryOnly); + runTest("successful delivery removes only the sent snapshot", testSuccessfulDelivery); + runTest("failed delivery returns messages to the confirmed queue", testFailedDelivery); +} + +function testConfirmedDeliveryOnly(): void { + const queue = new FollowUpQueue(); + assertEqual(queue.confirm(" "), null, "blank draft was confirmed"); + queue.confirm(" first detail "); + queue.confirm("second\ndetail"); + + const delivery = queue.beginDelivery(); + assertEqual(delivery.messages.join("|"), "first detail|second\ndetail", "confirmed text changed"); +} + +function testSuccessfulDelivery(): void { + const queue = new FollowUpQueue(); + queue.confirm("first detail"); + const firstDelivery = queue.beginDelivery(); + queue.confirm("arrived during delivery"); + queue.completeDelivery(firstDelivery.ids); + + const nextDelivery = queue.beginDelivery(); + assertEqual(nextDelivery.messages.length, 1, "sent follow-up remained visible"); + assertEqual(nextDelivery.messages[0], "arrived during delivery", "later follow-up was removed early"); +} + +function testFailedDelivery(): void { + const queue = new FollowUpQueue(); + queue.confirm("retry me"); + const delivery = queue.beginDelivery(); + assert(!queue.remove(delivery.ids[0] ?? ""), "sending follow-up was removable"); + queue.releaseDelivery(delivery.ids); + + const retry = queue.beginDelivery(); + assertEqual(retry.messages[0], "retry me", "failed follow-up was lost"); +} + +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(); From d2694cc9c289de33aa06ba9de2872c0396497b43 Mon Sep 17 00:00:00 2001 From: TW Date: Fri, 4 Sep 2026 00:31:34 +0800 Subject: [PATCH 2/6] feat: keep next-turn follow-up composer available - Keep a compact follow-up launcher visible and show queued messages until delivery. - Allow dragging in compact and expanded states without treating drags as clicks. - Restore follow-up input focus after result writes, attachment pastes, and auto-send. - Cover queue deletion, drag behavior, and focus restoration in runtime tests. --- .../src/content/floating_panel_drag.ts | 35 ++++-- .../src/content/follow_up_overlay.ts | 113 +++++++++++++----- .../src/content/follow_up_overlay_styles.ts | 33 ++++- .../src/content/follow_up_work_controller.ts | 9 +- bridge-browser/src/content/main.ts | 2 +- bridge-browser/src/modules/auto_send.ts | 4 +- .../src/modules/focus_preservation.ts | 31 +++++ bridge-browser/src/modules/i18n.ts | 4 + bridge-browser/src/modules/result_delivery.ts | 64 +++++----- bridge-browser/test/auto_send.test.ts | 34 +++++- bridge-browser/test/follow_up_overlay.test.ts | 80 ++++++++----- 11 files changed, 293 insertions(+), 116 deletions(-) create mode 100644 bridge-browser/src/modules/focus_preservation.ts diff --git a/bridge-browser/src/content/floating_panel_drag.ts b/bridge-browser/src/content/floating_panel_drag.ts index c86abce..6022ccf 100644 --- a/bridge-browser/src/content/floating_panel_drag.ts +++ b/bridge-browser/src/content/floating_panel_drag.ts @@ -1,4 +1,5 @@ const DEFAULT_VIEWPORT_MARGIN = 8; +const DRAG_THRESHOLD_PX = 4; export interface FloatingPanelPosition { left: number; @@ -11,6 +12,7 @@ export interface FloatingPanelSize { } interface DragState { + hasMoved: boolean; initialLeft: number; initialTop: number; pointerX: number; @@ -35,6 +37,7 @@ export class FloatingPanelDragController { private clampFrame: number | null = null; private dragState: DragState | null = null; private positioned = false; + private suppressNextClick = false; public constructor(private readonly host: HTMLElement) { window.addEventListener("mousemove", this.handleMouseMove); @@ -42,8 +45,14 @@ export class FloatingPanelDragController { window.addEventListener("resize", this.scheduleClamp); } - public bindHandle(handle: HTMLElement): void { - handle.onmousedown = (event) => this.startDrag(event); + public bindHandle(handle: HTMLElement, allowButtonTarget = false): void { + handle.onmousedown = (event) => this.startDrag(event, allowButtonTarget); + } + + public consumeDragClick(): boolean { + const shouldSuppress = this.suppressNextClick; + this.suppressNextClick = false; + return shouldSuppress; } public scheduleClamp = (): void => { @@ -54,32 +63,42 @@ export class FloatingPanelDragController { }); }; - private startDrag(event: MouseEvent): void { - if (event.button !== 0 || (event.target as Element | null)?.closest?.("button")) {return;} + private startDrag(event: MouseEvent, allowButtonTarget: boolean): void { + this.suppressNextClick = false; + if ( + event.button !== 0 || + (!allowButtonTarget && (event.target as Element | null)?.closest?.("button")) + ) {return;} const rect = this.host.getBoundingClientRect(); this.dragState = { + hasMoved: false, 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 deltaX = event.clientX - this.dragState.pointerX; + const deltaY = event.clientY - this.dragState.pointerY; + if (!this.dragState.hasMoved && Math.hypot(deltaX, deltaY) < DRAG_THRESHOLD_PX) {return;} + + this.dragState.hasMoved = true; + this.positioned = true; 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, + left: this.dragState.initialLeft + deltaX, + top: this.dragState.initialTop + deltaY, }, rect); event.preventDefault(); }; private readonly handleMouseUp = (): void => { + this.suppressNextClick = this.dragState?.hasMoved ?? false; this.dragState = null; }; diff --git a/bridge-browser/src/content/follow_up_overlay.ts b/bridge-browser/src/content/follow_up_overlay.ts index 9caef4e..c53aa15 100644 --- a/bridge-browser/src/content/follow_up_overlay.ts +++ b/bridge-browser/src/content/follow_up_overlay.ts @@ -3,50 +3,52 @@ import { t } from "../modules/i18n"; import { FloatingPanelDragController } from "./floating_panel_drag"; import { type FollowUpItem, type FollowUpQueue, type FollowUpQueueSnapshot } from "./follow_up_queue"; import { FOLLOW_UP_OVERLAY_STYLE_TEXT } from "./follow_up_overlay_styles"; -import { isTurnSettled } from "./tool_activity_overlay_view"; -import { type ToolActivitySnapshot, type ToolActivityTracker } from "./tool_activity"; -/** Floating composer for follow-ups that are safe to include in the next automatic turn. */ +/** Persistent compact launcher and composer for next-turn follow-ups. */ export class FollowUpOverlay { - private enabled = true; + private readonly collapseButton: HTMLButtonElement; private readonly confirmButton: HTMLButtonElement; private readonly dragController: FloatingPanelDragController; - private generating = false; + private enabled = false; private readonly host: HTMLDivElement; + private readonly launcher: HTMLButtonElement; + private readonly launcherCount: HTMLSpanElement; private readonly queueElement: HTMLDivElement; private queueSending = false; private readonly queue: FollowUpQueue; private readonly summary: HTMLDivElement; - private toolWorking = false; private readonly textarea: HTMLTextAreaElement; - public constructor(queue: FollowUpQueue, tracker: ToolActivityTracker) { + public constructor(queue: FollowUpQueue) { this.queue = queue; const view = createOverlayView(); this.host = view.host; + this.launcher = view.launcher; + this.launcherCount = view.launcherCount; this.queueElement = view.queueElement; this.summary = view.summary; this.textarea = view.textarea; + this.collapseButton = view.collapseButton; this.confirmButton = view.confirmButton; this.dragController = new FloatingPanelDragController(this.host); this.dragController.bindHandle(view.header); + this.dragController.bindHandle(view.launcher, true); this.bindComposer(); queue.subscribe((snapshot) => this.renderQueue(snapshot)); - tracker.subscribe((snapshot) => this.updateToolWorking(snapshot)); - } - - public setGenerating(generating: boolean): void { - this.generating = generating; - this.syncVisibility(); } public setEnabled(enabled: boolean): void { this.enabled = enabled; + if (!enabled) {this.setExpanded(false);} this.syncVisibility(); } private bindComposer(): void { + this.launcher.onclick = () => { + if (!this.dragController.consumeDragClick()) {this.setExpanded(true);} + }; + this.collapseButton.onclick = () => this.setExpanded(false); this.confirmButton.onclick = () => this.confirmDraft(); this.textarea.addEventListener("input", () => this.syncConfirmButton()); this.textarea.addEventListener("keydown", (event) => { @@ -61,6 +63,13 @@ export class FollowUpOverlay { this.syncConfirmButton(); } + private setExpanded(expanded: boolean): void { + this.host.className = expanded ? "webcode-follow-up-expanded" : ""; + this.launcher.setAttribute("aria-expanded", String(expanded)); + if (expanded) {this.textarea.focus();} + this.dragController.scheduleClamp(); + } + private confirmDraft(): void { if (!this.queue.confirm(this.textarea.value)) {return;} this.textarea.value = ""; @@ -69,17 +78,23 @@ export class FollowUpOverlay { } private renderQueue(snapshot: FollowUpQueueSnapshot): void { + const wasSending = this.queueSending; this.queueElement.replaceChildren(...snapshot.items.map((item) => this.createQueueItem(item))); const sendingCount = snapshot.items.filter((item) => item.status === "sending").length; this.queueSending = sendingCount > 0; this.summary.textContent = sendingCount > 0 ? t("follow_up_sending") - : t("follow_up_description"); + : snapshot.items.length > 0 + ? t("follow_up_waiting") + : t("follow_up_description"); const count = this.host.shadowRoot?.querySelector(".count"); if (count) { count.textContent = String(snapshot.items.length); count.style.display = snapshot.items.length > 0 ? "block" : "none"; } + this.launcherCount.textContent = String(snapshot.items.length); + this.launcherCount.style.display = snapshot.items.length > 0 ? "inline-flex" : "none"; + if (wasSending && snapshot.items.length === 0) {this.setExpanded(false);} this.syncVisibility(); this.dragController.scheduleClamp(); } @@ -90,14 +105,19 @@ export class FollowUpOverlay { const text = document.createElement("div"); text.className = "item-text"; text.textContent = item.text; - row.appendChild(text); + const actions = document.createElement("div"); + actions.className = "item-actions"; + row.append(text, actions); if (item.status === "sending") { const state = document.createElement("span"); state.className = "item-state"; state.textContent = t("follow_up_sending_short"); - row.appendChild(state); + actions.appendChild(state); } else { + const state = document.createElement("span"); + state.className = "item-state waiting"; + state.textContent = t("follow_up_waiting_short"); const remove = document.createElement("button"); remove.type = "button"; remove.className = "remove"; @@ -105,31 +125,27 @@ export class FollowUpOverlay { remove.setAttribute("aria-label", remove.title); remove.textContent = "×"; remove.onclick = () => this.queue.remove(item.id); - row.appendChild(remove); + actions.append(state, remove); } return row; } - private updateToolWorking(snapshot: ToolActivitySnapshot): void { - const currentTurn = snapshot.turns.at(-1); - this.toolWorking = Boolean(currentTurn && !isTurnSettled(currentTurn)); - this.syncVisibility(); - } - private syncConfirmButton(): void { this.confirmButton.disabled = this.textarea.value.trim().length === 0; } private syncVisibility(): void { - const working = this.generating || this.toolWorking || this.queueSending; - this.host.style.display = this.enabled && working ? "block" : "none"; + this.host.style.display = this.enabled ? "block" : "none"; } } function createOverlayView(): { + collapseButton: HTMLButtonElement; confirmButton: HTMLButtonElement; header: HTMLDivElement; host: HTMLDivElement; + launcher: HTMLButtonElement; + launcherCount: HTMLSpanElement; queueElement: HTMLDivElement; summary: HTMLDivElement; textarea: HTMLTextAreaElement; @@ -140,6 +156,8 @@ function createOverlayView(): { const style = document.createElement("style"); style.textContent = FOLLOW_UP_OVERLAY_STYLE_TEXT; + const { launcher, launcherCount } = createLauncherView(); + const panel = document.createElement("section"); panel.className = "panel"; const header = document.createElement("div"); @@ -156,8 +174,14 @@ function createOverlayView(): { const count = document.createElement("span"); count.className = "count"; count.style.display = "none"; + const collapseButton = document.createElement("button"); + collapseButton.type = "button"; + collapseButton.className = "collapse"; + collapseButton.title = t("follow_up_collapse"); + collapseButton.setAttribute("aria-label", collapseButton.title); + collapseButton.textContent = "−"; heading.append(title, summary); - header.append(heading, count); + header.append(heading, count, collapseButton); const body = document.createElement("div"); body.className = "body"; @@ -181,7 +205,40 @@ function createOverlayView(): { composer.append(textarea, footer); body.append(queueElement, composer); panel.append(header, body); - shadow.append(style, panel); + shadow.append(style, launcher, panel); document.body.appendChild(host); - return { confirmButton, header, host, queueElement, summary, textarea }; + return { + collapseButton, + confirmButton, + header, + host, + launcher, + launcherCount, + queueElement, + summary, + textarea, + }; +} + +function createLauncherView(): { + launcher: HTMLButtonElement; + launcherCount: HTMLSpanElement; +} { + const launcher = document.createElement("button"); + launcher.type = "button"; + launcher.className = "launcher"; + launcher.title = t("follow_up_open"); + launcher.setAttribute("aria-label", launcher.title); + launcher.setAttribute("aria-expanded", "false"); + const mark = document.createElement("span"); + mark.className = "launcher-mark"; + mark.textContent = "+"; + const label = document.createElement("span"); + label.className = "launcher-label"; + label.textContent = t("follow_up_title"); + const launcherCount = document.createElement("span"); + launcherCount.className = "launcher-count"; + launcherCount.style.display = "none"; + launcher.append(mark, label, launcherCount); + return { launcher, launcherCount }; } diff --git a/bridge-browser/src/content/follow_up_overlay_styles.ts b/bridge-browser/src/content/follow_up_overlay_styles.ts index 82303ed..45bfe8a 100644 --- a/bridge-browser/src/content/follow_up_overlay_styles.ts +++ b/bridge-browser/src/content/follow_up_overlay_styles.ts @@ -2,30 +2,51 @@ import { FOLLOW_UP_OVERLAY_Z_INDEX } from "../modules/overlay_layers"; export const FOLLOW_UP_OVERLAY_STYLE_TEXT = ` :host { position: fixed; left: 20px; bottom: 20px; z-index: ${FOLLOW_UP_OVERLAY_Z_INDEX}; - width: min(360px, calc(100vw - 32px)); max-height: calc(100vh - 32px); color-scheme: dark; } + width: fit-content; max-width: calc(100vw - 32px); max-height: calc(100vh - 32px); color-scheme: dark; } + :host(.webcode-follow-up-expanded) { width: min(360px, calc(100vw - 32px)); } * { box-sizing: border-box; } button, textarea { font: inherit; } - .panel { width: 100%; max-height: inherit; display: flex; overflow: hidden; flex-direction: column; + .launcher { min-height: 42px; display: flex; align-items: center; gap: 8px; padding: 7px 11px 7px 8px; + color: #f3f4f6; background: rgba(20, 22, 26, .96); border: 1px solid #3b404a; border-radius: 11px; + box-shadow: 0 9px 26px rgba(0, 0, 0, .34); cursor: grab; backdrop-filter: blur(10px); } + .launcher:hover { background: rgba(31, 35, 42, .98); border-color: #596171; } + .launcher:active { cursor: grabbing; } + .launcher:focus-visible { outline: 2px solid #3b82f6; outline-offset: 2px; } + .launcher-mark { width: 25px; height: 25px; display: inline-flex; align-items: center; justify-content: center; + flex: 0 0 auto; color: #dbeafe; background: #2563eb; border-radius: 7px; font-size: 19px; line-height: 1; } + .launcher-label { font: 600 12px/1.2 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; white-space: nowrap; } + .launcher-count { min-width: 18px; height: 18px; align-items: center; justify-content: center; padding: 0 5px; + color: #bfdbfe; background: rgba(37, 99, 235, .2); border: 1px solid rgba(96, 165, 250, .28); + border-radius: 999px; font: 600 10px/1 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } + .panel { width: 100%; max-height: inherit; display: none; 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); } + :host(.webcode-follow-up-expanded) .launcher { display: none; } + :host(.webcode-follow-up-expanded) .panel { display: flex; } .header { min-height: 48px; display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 8px 10px 8px 12px; cursor: move; user-select: none; } - .heading { min-width: 0; } + .heading { min-width: 0; flex: 1; } .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; } .count { flex: 0 0 auto; padding: 2px 7px; color: #bfdbfe; background: rgba(37, 99, 235, .2); border: 1px solid rgba(96, 165, 250, .28); border-radius: 999px; font-size: 10px; } + .collapse { width: 26px; height: 26px; flex: 0 0 auto; padding: 0; color: #aeb5c2; background: transparent; + border: 0; border-radius: 6px; font-size: 18px; line-height: 1; cursor: pointer; } + .collapse:hover { color: #fff; background: #343942; } .body { min-height: 0; display: flex; flex-direction: column; border-top: 1px solid #343942; } .queue { min-height: 0; max-height: min(190px, 28vh); flex: 1 1 auto; overflow-y: auto; } .queue:empty { display: none; } - .item { display: flex; align-items: flex-start; gap: 8px; padding: 8px 10px 8px 12px; + .item { display: flex; align-items: center; gap: 8px; padding: 8px 10px 8px 12px; border-bottom: 1px solid rgba(255, 255, 255, .06); } .item-text { min-width: 0; flex: 1; overflow-wrap: anywhere; color: #d8dde6; white-space: pre-wrap; } .item.sending .item-text { color: #93c5fd; } + .item-actions { display: flex; align-items: center; gap: 5px; flex: 0 0 auto; } .item-state { flex: 0 0 auto; color: #93c5fd; font-size: 10px; white-space: nowrap; } - .remove { width: 22px; height: 22px; flex: 0 0 auto; padding: 0; border: 0; border-radius: 5px; - color: #aeb5c2; background: transparent; cursor: pointer; } + .item-state.waiting { color: #aeb5c2; } + .remove { width: 22px; height: 22px; display: inline-flex; align-items: center; justify-content: center; + flex: 0 0 auto; padding: 0; border: 0; border-radius: 5px; color: #aeb5c2; background: transparent; + font-size: 16px; line-height: 1; cursor: pointer; } .remove:hover { color: #fff; background: #8f1d1d; } .composer { flex: 0 0 auto; padding: 10px; } textarea { width: 100%; min-height: 74px; max-height: min(180px, 24vh); resize: vertical; display: block; diff --git a/bridge-browser/src/content/follow_up_work_controller.ts b/bridge-browser/src/content/follow_up_work_controller.ts index c5589f0..39bd083 100644 --- a/bridge-browser/src/content/follow_up_work_controller.ts +++ b/bridge-browser/src/content/follow_up_work_controller.ts @@ -1,9 +1,7 @@ import type { SiteSelectors } from "../modules/config"; -import { isStopButtonVisible } from "../modules/page_selectors"; import { CompletionNotifier } from "./completion_notifier"; import { FollowUpOverlay } from "./follow_up_overlay"; import type { FollowUpQueue } from "./follow_up_queue"; -import type { ToolActivityTracker } from "./tool_activity"; export const OBSERVED_PAGE_WORK_ATTRIBUTES = [ "aria-busy", "aria-disabled", "aria-hidden", "aria-label", "class", @@ -11,29 +9,26 @@ export const OBSERVED_PAGE_WORK_ATTRIBUTES = [ "data-visible", "disabled", "hidden", "inert", "style", "title", ]; -/** Synchronizes the follow-up overlay with ordinary generation and tool activity. */ +/** Keeps the follow-up launcher enabled and detects ordinary response completion. */ export class FollowUpWorkController { private readonly completionNotifier: CompletionNotifier; private readonly overlay: FollowUpOverlay; public constructor( queue: FollowUpQueue, - tracker: ToolActivityTracker, onCompletedWithoutTools: () => void ) { - this.overlay = new FollowUpOverlay(queue, tracker); + this.overlay = new FollowUpOverlay(queue); this.completionNotifier = new CompletionNotifier({ onCompletedWithoutTools }); } public observe(selectors: SiteSelectors): void { this.overlay.setEnabled(true); - this.overlay.setGenerating(isStopButtonVisible(selectors)); this.completionNotifier.observe(selectors); } public reset(): void { this.completionNotifier.reset(); this.overlay.setEnabled(false); - this.overlay.setGenerating(false); } } diff --git a/bridge-browser/src/content/main.ts b/bridge-browser/src/content/main.ts index 5ed2567..a5f4c58 100644 --- a/bridge-browser/src/content/main.ts +++ b/bridge-browser/src/content/main.ts @@ -294,7 +294,7 @@ const resultDelivery = new ResultDeliveryController({ toolActivityTracker, }); -const followUpWork = new FollowUpWorkController(followUpQueue, toolActivityTracker, () => { +const followUpWork = new FollowUpWorkController(followUpQueue, () => { if (DOM && !networkCapture.hasPendingTurns()) { resultDelivery.deliverFollowUps(DOM); } diff --git a/bridge-browser/src/modules/auto_send.ts b/bridge-browser/src/modules/auto_send.ts index 4522963..7200a80 100644 --- a/bridge-browser/src/modules/auto_send.ts +++ b/bridge-browser/src/modules/auto_send.ts @@ -8,6 +8,7 @@ import { isStopButtonVisible, } from "./page_selectors"; import { isElementVisible } from "./dom_helpers"; +import { preserveActiveElement } from "./focus_preservation"; import { showUserAttentionNotification } from "./user_attention"; import { getAutoSendAction, getAutoSendAttemptLimit } from "./auto_send_policy"; @@ -115,7 +116,6 @@ export function triggerAutoSend( const trySend = () => { const inputEl = getInputEl(); - if (inputEl) {inputEl.focus();} if (isSendComplete()) { Logger.log(t("send_success_cleared"), "success"); @@ -123,7 +123,7 @@ export function triggerAutoSend( return; } - dispatchSendAttempt(inputEl, domSelectors, retryCount); + preserveActiveElement(() => dispatchSendAttempt(inputEl, domSelectors, retryCount)); // 等待页面完成输入框清空、stop 按钮切换等异步渲染;下一轮才尝试另一种发送方式。 schedule(() => { if (isSendComplete()) { diff --git a/bridge-browser/src/modules/focus_preservation.ts b/bridge-browser/src/modules/focus_preservation.ts new file mode 100644 index 0000000..719f5df --- /dev/null +++ b/bridge-browser/src/modules/focus_preservation.ts @@ -0,0 +1,31 @@ +/** Runs a synchronous page interaction without permanently stealing the user's current focus. */ +export function preserveActiveElement(action: () => T): T { + const previous = getDeepActiveElement(); + try { + return action(); + } finally { + restoreActiveElement(previous); + } +} + +function getDeepActiveElement(): HTMLElement | null { + let active = document.activeElement; + while (active?.shadowRoot?.activeElement) { + active = active.shadowRoot.activeElement; + } + return isFocusableElement(active) ? active : null; +} + +function restoreActiveElement(element: HTMLElement | null): void { + if (!element || element.isConnected === false || getDeepActiveElement() === element) {return;} + + try { + element.focus({ preventScroll: true }); + } catch { + element.focus(); + } +} + +function isFocusableElement(element: Element | null): element is HTMLElement { + return element !== null && "focus" in element && typeof element.focus === "function"; +} diff --git a/bridge-browser/src/modules/i18n.ts b/bridge-browser/src/modules/i18n.ts index dc5ccfa..7e8164d 100644 --- a/bridge-browser/src/modules/i18n.ts +++ b/bridge-browser/src/modules/i18n.ts @@ -96,7 +96,11 @@ const I18N_MESSAGES: Record = { }, follow_up_shortcut: { en: "Ctrl/⌘ + Enter to confirm", zh: "Ctrl/⌘ + Enter 确认" }, follow_up_confirm: { en: "Confirm", zh: "确认加入" }, + follow_up_open: { en: "Add a next-turn follow-up", zh: "添加下一轮补充" }, + follow_up_collapse: { en: "Collapse follow-up composer", zh: "收起补充输入框" }, follow_up_remove: { en: "Remove confirmed follow-up", zh: "移除已确认的补充" }, + follow_up_waiting: { en: "Queued for the next automatic turn", zh: "已加入下一轮自动发送队列" }, + follow_up_waiting_short: { en: "Waiting", zh: "等待发送" }, follow_up_sending: { en: "Sending confirmed follow-ups...", zh: "正在发送已确认的补充..." }, follow_up_sending_short: { en: "Sending", zh: "发送中" }, follow_up_delivery_heading: { en: "User follow-up", zh: "用户补充" }, diff --git a/bridge-browser/src/modules/result_delivery.ts b/bridge-browser/src/modules/result_delivery.ts index 68de2a1..516f0a0 100644 --- a/bridge-browser/src/modules/result_delivery.ts +++ b/bridge-browser/src/modules/result_delivery.ts @@ -3,6 +3,7 @@ import { type SiteSelectors } from "./config"; import { i18n, t } from "./i18n"; import { Logger } from "./logger"; import { delay } from "./dom_helpers"; +import { preserveActiveElement } from "./focus_preservation"; import { getInputAreaBySelector, getInputAreaElement } from "./page_selectors"; import { showUserAttentionNotification } from "./user_attention"; import { @@ -27,10 +28,7 @@ export interface DeliverResultStatus { attemptedUpload: boolean; } -interface InputWriteResult { - delivered: boolean; - attemptedWrite: boolean; -} +interface InputWriteResult { delivered: boolean; attemptedWrite: boolean; } export interface AttachmentPasteDispatchResult { acknowledged: boolean; @@ -77,27 +75,29 @@ export function replaceInputBoxText(text: string, inputSelector: string): boolea } function setInputBoxText(text: string, inputEl: HTMLElement | HTMLInputElement | HTMLTextAreaElement, forceFallback = false) { - inputEl.focus(); - let success = false; - - if (!forceFallback) { - try { - const selected = document.execCommand("selectAll", false); - success = selected && document.execCommand("insertText", false, text); - } catch { + preserveActiveElement(() => { + inputEl.focus(); + let success = false; + + if (!forceFallback) { + try { + const selected = document.execCommand("selectAll", false); + success = selected && document.execCommand("insertText", false, text); + } catch { + } } - } - if (!success) { - if (isTextControl(inputEl)) { - setTextControlValue(inputEl, text); - } else { - inputEl.innerText = text; + if (!success) { + if (isTextControl(inputEl)) { + setTextControlValue(inputEl, text); + } else { + inputEl.innerText = text; + } } - } - inputEl.dispatchEvent(new Event("input", { bubbles: true })); - inputEl.dispatchEvent(new Event("change", { bubbles: true })); + inputEl.dispatchEvent(new Event("input", { bubbles: true })); + inputEl.dispatchEvent(new Event("change", { bubbles: true })); + }); } /** @@ -468,17 +468,19 @@ export function pasteFilesAsAttachments( const clipboardData = new DataTransfer(); files.forEach((file) => clipboardData.items.add(file)); - inputEl.focus(); - const pasteEvent = new ClipboardEvent("paste", { - bubbles: true, - cancelable: true, - clipboardData, + return preserveActiveElement(() => { + inputEl.focus(); + const pasteEvent = new ClipboardEvent("paste", { + bubbles: true, + cancelable: true, + clipboardData, + }); + const notCanceled = inputEl.dispatchEvent(pasteEvent); + return { + acknowledged: isPasteEventAcknowledged(notCanceled, pasteEvent.defaultPrevented), + dispatched: true, + }; }); - const notCanceled = inputEl.dispatchEvent(pasteEvent); - return { - acknowledged: isPasteEventAcknowledged(notCanceled, pasteEvent.defaultPrevented), - dispatched: true, - }; } catch (error) { const reason = `The simulated paste event failed: ${getErrorMessage(error)}.`; Logger.log(`Attachment paste dispatch failed: ${reason}`, "warn"); diff --git a/bridge-browser/test/auto_send.test.ts b/bridge-browser/test/auto_send.test.ts index 23410c7..a97ac6e 100644 --- a/bridge-browser/test/auto_send.test.ts +++ b/bridge-browser/test/auto_send.test.ts @@ -2,7 +2,12 @@ import type { SiteSelectors } from "../src/modules/config"; export {}; -class FakeInput { +interface FakeFocusable { + focus: () => void; + shadowRoot?: { activeElement: FakeFocusable | null }; +} + +class FakeInput implements FakeFocusable { public innerText = ""; public contains(): boolean { @@ -19,10 +24,28 @@ class FakeInput { public focus(): void { fakeDocument.activeElement = this; } + + public getBoundingClientRect(): DOMRect { + return { + bottom: 40, + height: 40, + left: 0, + right: 300, + top: 0, + width: 300, + x: 0, + y: 0, + toJSON: () => ({}), + }; + } } const input = new FakeInput(); -const fakeDocument = { +const fakeDocument: { + activeElement: FakeFocusable | null; + querySelector: () => null; + querySelectorAll: (selector: string) => FakeInput[]; +} = { activeElement: input, querySelector: () => null, querySelectorAll: (selector: string) => selector === "#input" ? [input] : [], @@ -42,9 +65,16 @@ async function main(): Promise { installBrowserGlobals(); const { cancelAutoSend, triggerAutoSend } = await import("../src/modules/auto_send"); + const followUpInput = new FakeInput(); + const followUpHost: FakeFocusable = { + focus: () => {fakeDocument.activeElement = followUpHost;}, + shadowRoot: { activeElement: followUpInput }, + }; + fakeDocument.activeElement = followUpHost; input.innerText = "message"; const successfulSend = triggerAutoSend({ autoSend: true, hasFileUpload: false }, SELECTORS); flushNextTimer(); + assertEqual(fakeDocument.activeElement, followUpInput, "auto-send did not restore follow-up input focus"); flushNextTimer(); assertEqual(await successfulSend, "sent", "successful send did not resolve as sent"); diff --git a/bridge-browser/test/follow_up_overlay.test.ts b/bridge-browser/test/follow_up_overlay.test.ts index b456a12..62d64b9 100644 --- a/bridge-browser/test/follow_up_overlay.test.ts +++ b/bridge-browser/test/follow_up_overlay.test.ts @@ -1,5 +1,4 @@ import { FollowUpQueue } from "../src/content/follow_up_queue"; -import { ToolActivityTracker } from "../src/content/tool_activity"; interface FakeRect { height: number; @@ -195,33 +194,64 @@ const fakeWindow = new FakeWindow(); async function main(): Promise { installBrowserGlobals(); const { FollowUpOverlay } = await import("../src/content/follow_up_overlay"); - runTest("drafts stay isolated until confirmation and disappear after delivery", () => { + runTest("waiting follow-ups remain visible and removable until delivery starts", () => { const harness = createHarness(FollowUpOverlay); - harness.overlay.setGenerating(true); + harness.overlay.setEnabled(true); + getRequired(harness.host.shadowRoot!, ".launcher").click(); const textarea = getRequired(harness.host.shadowRoot!, "textarea"); textarea.value = "unfinished draft"; assertEqual(harness.queue.beginDelivery().messages.length, 0, "unfinished draft entered delivery"); confirmDraft(harness.host, textarea); - textarea.value = "second detail"; + assertIncludes(getRequired(harness.host.shadowRoot!, ".queue").getText(), "unfinished draft", "confirmed message was hidden"); + getRequired(harness.host.shadowRoot!, ".remove").click(); + assertEqual(harness.queue.beginDelivery().messages.length, 0, "removed message remained queued"); + + textarea.value = "send this next"; confirmDraft(harness.host, textarea); const delivery = harness.queue.beginDelivery(); - assertEqual(delivery.messages.join("|"), "unfinished draft|second detail", "confirmed text changed"); - assertIncludes(getRequired(harness.host.shadowRoot!, ".queue").getText(), "unfinished draft", "queue hid a confirmed message"); + assertEqual(delivery.messages.join("|"), "send this next", "confirmed text changed"); + assert(!getRequired(harness.host.shadowRoot!, ".queue").querySelector(".remove"), "sending message could still be removed"); harness.queue.completeDelivery(delivery.ids); assert(!getRequired(harness.host.shadowRoot!, ".queue").getText(), "delivered messages remained visible"); + assertEqual(harness.host.className, "", "empty composer did not collapse after delivery"); + assertEqual(harness.host.style.display, "block", "persistent launcher disappeared after delivery"); + }); + runTest("compact launcher stays visible and opens the composer", () => { + const harness = createHarness(FollowUpOverlay); + assertEqual(harness.host.style.display, "none", "disabled launcher was visible"); + harness.overlay.setEnabled(true); + assertEqual(harness.host.style.display, "block", "idle launcher was hidden"); + + getRequired(harness.host.shadowRoot!, ".launcher").click(); + assertEqual(harness.host.className, "webcode-follow-up-expanded", "launcher did not open the composer"); + assertEqual(fakeDocument.activeElement, getRequired(harness.host.shadowRoot!, "textarea"), "composer did not focus its input"); + + getRequired(harness.host.shadowRoot!, ".collapse").click(); + assertEqual(harness.host.className, "", "composer did not collapse to its launcher"); }); - runTest("composer only appears during work and dragging stays in the viewport", () => { + runTest("collapsed launcher can be dragged without opening the composer", () => { const harness = createHarness(FollowUpOverlay); - assertEqual(harness.host.style.display, "none", "idle composer was visible"); - harness.overlay.setGenerating(true); - assertEqual(harness.host.style.display, "block", "generation did not show composer"); - harness.overlay.setGenerating(false); - assertEqual(harness.host.style.display, "none", "idle composer stayed visible"); - - captureTool(harness.tracker); - assertEqual(harness.host.style.display, "block", "tool work did not show composer"); + harness.overlay.setEnabled(true); + harness.host.setRect({ height: 42, left: 20, top: 638, width: 150 }); + const launcher = getRequired(harness.host.shadowRoot!, ".launcher"); + + launcher.mouseDown(createEvent(launcher, 30, 650)); + fakeWindow.dispatch("mousemove", createEvent(launcher, 230, 450)); + fakeWindow.dispatch("mouseup", createEvent(launcher, 230, 450)); + launcher.click(); + + assertEqual(harness.host.style.left, "220px", "collapsed launcher did not move"); + assertEqual(harness.host.className, "", "dragging the launcher opened the composer"); + launcher.click(); + assertEqual(harness.host.className, "webcode-follow-up-expanded", "launcher did not open after dragging"); + }); + runTest("expanded composer stays inside the viewport when dragged", () => { + const harness = createHarness(FollowUpOverlay); + harness.overlay.setEnabled(true); + getRequired(harness.host.shadowRoot!, ".launcher").click(); + const header = getRequired(harness.host.shadowRoot!, ".header"); header.mouseDown(createEvent(header, 20, 420)); fakeWindow.dispatch("mousemove", createEvent(header, -1000, -1000)); @@ -232,29 +262,26 @@ async function main(): Promise { } interface OverlayInstance { - setGenerating: (generating: boolean) => void; + setEnabled: (enabled: boolean) => void; } type OverlayConstructor = new ( - queue: FollowUpQueue, - tracker: ToolActivityTracker + queue: FollowUpQueue ) => OverlayInstance; function createHarness(Overlay: OverlayConstructor): { host: FakeElement; overlay: OverlayInstance; queue: FollowUpQueue; - tracker: ToolActivityTracker; } { fakeDocument.reset(); fakeWindow.reset(); const queue = new FollowUpQueue(); - const tracker = new ToolActivityTracker(); - const overlay = new Overlay(queue, tracker); + const overlay = new Overlay(queue); const host = fakeDocument.body.children.at(-1); assert(host?.shadowRoot, "follow-up overlay was not created"); host.setRect({ height: 260, left: 20, top: 420, width: 350 }); - return { host, overlay, queue, tracker }; + return { host, overlay, queue }; } function confirmDraft(host: FakeElement, textarea: FakeElement): void { @@ -262,15 +289,6 @@ function confirmDraft(host: FakeElement, textarea: FakeElement): void { getRequired(host.shadowRoot!, ".confirm").click(); } -function captureTool(tracker: ToolActivityTracker): void { - tracker.capture({ - identity: { requestKey: "follow-up-tool" }, - payload: { name: "read_file", purpose: "Read context" }, - source: "dom", - turnId: "follow-up-turn", - }); -} - function createEvent(target: FakeElement, clientX = 0, clientY = 0): FakeEvent { return { button: 0, From 1b238a3978195190c44134ff444abaca69a82430 Mon Sep 17 00:00:00 2001 From: TW Date: Fri, 4 Sep 2026 01:00:25 +0800 Subject: [PATCH 3/6] feat: unify tool activity and follow-up panels - Combine current tool activity and next-turn follow-ups in one draggable work panel. - Keep history above current activity so both remain visible at the same time. - Preserve follow-up drafts and focus while live tool status updates render. --- .../src/content/follow_up_overlay.ts | 218 ++++-------- .../src/content/follow_up_overlay_styles.ts | 89 ++--- .../src/content/follow_up_work_controller.ts | 14 +- bridge-browser/src/content/main.ts | 4 +- .../src/content/tool_activity_overlay.ts | 336 ++++++++++++------ .../content/tool_activity_overlay_styles.ts | 55 ++- bridge-browser/src/modules/i18n.ts | 4 + bridge-browser/test/follow_up_overlay.test.ts | 239 +++---------- .../test/support/fake_overlay_dom.ts | 238 +++++++++++++ .../test/tool_activity_overlay.test.ts | 289 ++++----------- 10 files changed, 737 insertions(+), 749 deletions(-) create mode 100644 bridge-browser/test/support/fake_overlay_dom.ts diff --git a/bridge-browser/src/content/follow_up_overlay.ts b/bridge-browser/src/content/follow_up_overlay.ts index c53aa15..5314db6 100644 --- a/bridge-browser/src/content/follow_up_overlay.ts +++ b/bridge-browser/src/content/follow_up_overlay.ts @@ -1,54 +1,41 @@ -import { BRANDING } from "@webcode/shared"; import { t } from "../modules/i18n"; -import { FloatingPanelDragController } from "./floating_panel_drag"; import { type FollowUpItem, type FollowUpQueue, type FollowUpQueueSnapshot } from "./follow_up_queue"; -import { FOLLOW_UP_OVERLAY_STYLE_TEXT } from "./follow_up_overlay_styles"; -/** Persistent compact launcher and composer for next-turn follow-ups. */ -export class FollowUpOverlay { - private readonly collapseButton: HTMLButtonElement; +export interface FollowUpComposerState { + count: number; + sending: boolean; +} + +type FollowUpStateListener = (state: FollowUpComposerState) => void; + +/** Stable follow-up composer embedded in the shared work panel. */ +export class FollowUpComposer { private readonly confirmButton: HTMLButtonElement; - private readonly dragController: FloatingPanelDragController; - private enabled = false; - private readonly host: HTMLDivElement; - private readonly launcher: HTMLButtonElement; - private readonly launcherCount: HTMLSpanElement; + public readonly element: HTMLElement; + private readonly onStateChange: FollowUpStateListener; private readonly queueElement: HTMLDivElement; - private queueSending = false; private readonly queue: FollowUpQueue; private readonly summary: HTMLDivElement; private readonly textarea: HTMLTextAreaElement; - public constructor(queue: FollowUpQueue) { + public constructor(queue: FollowUpQueue, onStateChange: FollowUpStateListener) { this.queue = queue; - const view = createOverlayView(); - this.host = view.host; - this.launcher = view.launcher; - this.launcherCount = view.launcherCount; + this.onStateChange = onStateChange; + const view = createComposerView(); + this.element = view.element; this.queueElement = view.queueElement; this.summary = view.summary; this.textarea = view.textarea; - this.collapseButton = view.collapseButton; this.confirmButton = view.confirmButton; - - this.dragController = new FloatingPanelDragController(this.host); - this.dragController.bindHandle(view.header); - this.dragController.bindHandle(view.launcher, true); this.bindComposer(); queue.subscribe((snapshot) => this.renderQueue(snapshot)); } - public setEnabled(enabled: boolean): void { - this.enabled = enabled; - if (!enabled) {this.setExpanded(false);} - this.syncVisibility(); + public focusInput(): void { + this.textarea.focus(); } private bindComposer(): void { - this.launcher.onclick = () => { - if (!this.dragController.consumeDragClick()) {this.setExpanded(true);} - }; - this.collapseButton.onclick = () => this.setExpanded(false); this.confirmButton.onclick = () => this.confirmDraft(); this.textarea.addEventListener("input", () => this.syncConfirmButton()); this.textarea.addEventListener("keydown", (event) => { @@ -63,13 +50,6 @@ export class FollowUpOverlay { this.syncConfirmButton(); } - private setExpanded(expanded: boolean): void { - this.host.className = expanded ? "webcode-follow-up-expanded" : ""; - this.launcher.setAttribute("aria-expanded", String(expanded)); - if (expanded) {this.textarea.focus();} - this.dragController.scheduleClamp(); - } - private confirmDraft(): void { if (!this.queue.confirm(this.textarea.value)) {return;} this.textarea.value = ""; @@ -78,167 +58,111 @@ export class FollowUpOverlay { } private renderQueue(snapshot: FollowUpQueueSnapshot): void { - const wasSending = this.queueSending; this.queueElement.replaceChildren(...snapshot.items.map((item) => this.createQueueItem(item))); - const sendingCount = snapshot.items.filter((item) => item.status === "sending").length; - this.queueSending = sendingCount > 0; - this.summary.textContent = sendingCount > 0 + const sending = snapshot.items.some((item) => item.status === "sending"); + this.summary.textContent = sending ? t("follow_up_sending") : snapshot.items.length > 0 ? t("follow_up_waiting") : t("follow_up_description"); - const count = this.host.shadowRoot?.querySelector(".count"); + const count = this.element.querySelector(".follow-up-count"); if (count) { count.textContent = String(snapshot.items.length); - count.style.display = snapshot.items.length > 0 ? "block" : "none"; + count.style.display = snapshot.items.length > 0 ? "inline-flex" : "none"; } - this.launcherCount.textContent = String(snapshot.items.length); - this.launcherCount.style.display = snapshot.items.length > 0 ? "inline-flex" : "none"; - if (wasSending && snapshot.items.length === 0) {this.setExpanded(false);} - this.syncVisibility(); - this.dragController.scheduleClamp(); + this.onStateChange({ count: snapshot.items.length, sending }); } private createQueueItem(item: FollowUpItem): HTMLElement { const row = document.createElement("div"); - row.className = `item ${item.status}`; + row.className = `follow-up-item ${item.status}`; const text = document.createElement("div"); - text.className = "item-text"; + text.className = "follow-up-item-text"; text.textContent = item.text; const actions = document.createElement("div"); - actions.className = "item-actions"; + actions.className = "follow-up-item-actions"; row.append(text, actions); - if (item.status === "sending") { - const state = document.createElement("span"); - state.className = "item-state"; - state.textContent = t("follow_up_sending_short"); - actions.appendChild(state); - } else { - const state = document.createElement("span"); - state.className = "item-state waiting"; - state.textContent = t("follow_up_waiting_short"); - const remove = document.createElement("button"); - remove.type = "button"; - remove.className = "remove"; - remove.title = t("follow_up_remove"); - remove.setAttribute("aria-label", remove.title); - remove.textContent = "×"; - remove.onclick = () => this.queue.remove(item.id); - actions.append(state, remove); + const state = document.createElement("span"); + state.className = item.status === "sending" + ? "follow-up-item-state" + : "follow-up-item-state waiting"; + state.textContent = t(item.status === "sending" ? "follow_up_sending_short" : "follow_up_waiting_short"); + actions.appendChild(state); + if (item.status === "confirmed") { + actions.appendChild(this.createRemoveButton(item.id)); } return row; } - private syncConfirmButton(): void { - this.confirmButton.disabled = this.textarea.value.trim().length === 0; + private createRemoveButton(itemId: string): HTMLButtonElement { + const remove = document.createElement("button"); + remove.type = "button"; + remove.className = "follow-up-remove"; + remove.title = t("follow_up_remove"); + remove.setAttribute("aria-label", remove.title); + remove.textContent = "×"; + remove.onclick = () => this.queue.remove(itemId); + return remove; } - private syncVisibility(): void { - this.host.style.display = this.enabled ? "block" : "none"; + private syncConfirmButton(): void { + this.confirmButton.disabled = this.textarea.value.trim().length === 0; } } -function createOverlayView(): { - collapseButton: HTMLButtonElement; +function createComposerView(): { confirmButton: HTMLButtonElement; - header: HTMLDivElement; - host: HTMLDivElement; - launcher: HTMLButtonElement; - launcherCount: HTMLSpanElement; + element: HTMLElement; queueElement: HTMLDivElement; summary: HTMLDivElement; textarea: HTMLTextAreaElement; } { - const host = document.createElement("div"); - host.style.display = "none"; - const shadow = host.attachShadow({ mode: "open" }); - const style = document.createElement("style"); - style.textContent = FOLLOW_UP_OVERLAY_STYLE_TEXT; - - const { launcher, launcherCount } = createLauncherView(); - - const panel = document.createElement("section"); - panel.className = "panel"; + const element = document.createElement("section"); + element.className = "follow-up-section"; const header = document.createElement("div"); - header.className = "header"; - header.title = t("follow_up_drag"); + header.className = "follow-up-header"; const heading = document.createElement("div"); - heading.className = "heading"; + heading.className = "follow-up-heading"; const title = document.createElement("div"); - title.className = "title"; - title.textContent = `${BRANDING.productName} · ${t("follow_up_title")}`; + title.className = "follow-up-title"; + title.textContent = t("follow_up_title"); const summary = document.createElement("div"); - summary.className = "summary"; + summary.className = "follow-up-summary"; summary.textContent = t("follow_up_description"); const count = document.createElement("span"); - count.className = "count"; + count.className = "follow-up-count"; count.style.display = "none"; - const collapseButton = document.createElement("button"); - collapseButton.type = "button"; - collapseButton.className = "collapse"; - collapseButton.title = t("follow_up_collapse"); - collapseButton.setAttribute("aria-label", collapseButton.title); - collapseButton.textContent = "−"; heading.append(title, summary); - header.append(heading, count, collapseButton); + header.append(heading, count); - const body = document.createElement("div"); - body.className = "body"; const queueElement = document.createElement("div"); - queueElement.className = "queue"; + queueElement.className = "follow-up-queue"; + const { composer, confirmButton, textarea } = createInputView(); + element.append(header, queueElement, composer); + return { confirmButton, element, queueElement, summary, textarea }; +} + +function createInputView(): { + composer: HTMLDivElement; + confirmButton: HTMLButtonElement; + textarea: HTMLTextAreaElement; +} { const composer = document.createElement("div"); - composer.className = "composer"; + composer.className = "follow-up-composer"; const textarea = document.createElement("textarea"); textarea.placeholder = t("follow_up_placeholder"); textarea.setAttribute("aria-label", t("follow_up_title")); const footer = document.createElement("div"); - footer.className = "composer-footer"; + footer.className = "follow-up-composer-footer"; const hint = document.createElement("span"); - hint.className = "hint"; + hint.className = "follow-up-hint"; hint.textContent = t("follow_up_shortcut"); const confirmButton = document.createElement("button"); confirmButton.type = "button"; - confirmButton.className = "confirm"; + confirmButton.className = "follow-up-confirm"; confirmButton.textContent = t("follow_up_confirm"); footer.append(hint, confirmButton); composer.append(textarea, footer); - body.append(queueElement, composer); - panel.append(header, body); - shadow.append(style, launcher, panel); - document.body.appendChild(host); - return { - collapseButton, - confirmButton, - header, - host, - launcher, - launcherCount, - queueElement, - summary, - textarea, - }; -} - -function createLauncherView(): { - launcher: HTMLButtonElement; - launcherCount: HTMLSpanElement; -} { - const launcher = document.createElement("button"); - launcher.type = "button"; - launcher.className = "launcher"; - launcher.title = t("follow_up_open"); - launcher.setAttribute("aria-label", launcher.title); - launcher.setAttribute("aria-expanded", "false"); - const mark = document.createElement("span"); - mark.className = "launcher-mark"; - mark.textContent = "+"; - const label = document.createElement("span"); - label.className = "launcher-label"; - label.textContent = t("follow_up_title"); - const launcherCount = document.createElement("span"); - launcherCount.className = "launcher-count"; - launcherCount.style.display = "none"; - launcher.append(mark, label, launcherCount); - return { launcher, launcherCount }; + return { composer, confirmButton, textarea }; } diff --git a/bridge-browser/src/content/follow_up_overlay_styles.ts b/bridge-browser/src/content/follow_up_overlay_styles.ts index 45bfe8a..3d6554a 100644 --- a/bridge-browser/src/content/follow_up_overlay_styles.ts +++ b/bridge-browser/src/content/follow_up_overlay_styles.ts @@ -1,63 +1,40 @@ -import { FOLLOW_UP_OVERLAY_Z_INDEX } from "../modules/overlay_layers"; - -export const FOLLOW_UP_OVERLAY_STYLE_TEXT = ` - :host { position: fixed; left: 20px; bottom: 20px; z-index: ${FOLLOW_UP_OVERLAY_Z_INDEX}; - width: fit-content; max-width: calc(100vw - 32px); max-height: calc(100vh - 32px); color-scheme: dark; } - :host(.webcode-follow-up-expanded) { width: min(360px, calc(100vw - 32px)); } - * { box-sizing: border-box; } - button, textarea { font: inherit; } - .launcher { min-height: 42px; display: flex; align-items: center; gap: 8px; padding: 7px 11px 7px 8px; - color: #f3f4f6; background: rgba(20, 22, 26, .96); border: 1px solid #3b404a; border-radius: 11px; - box-shadow: 0 9px 26px rgba(0, 0, 0, .34); cursor: grab; backdrop-filter: blur(10px); } - .launcher:hover { background: rgba(31, 35, 42, .98); border-color: #596171; } - .launcher:active { cursor: grabbing; } - .launcher:focus-visible { outline: 2px solid #3b82f6; outline-offset: 2px; } - .launcher-mark { width: 25px; height: 25px; display: inline-flex; align-items: center; justify-content: center; - flex: 0 0 auto; color: #dbeafe; background: #2563eb; border-radius: 7px; font-size: 19px; line-height: 1; } - .launcher-label { font: 600 12px/1.2 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; white-space: nowrap; } - .launcher-count { min-width: 18px; height: 18px; align-items: center; justify-content: center; padding: 0 5px; - color: #bfdbfe; background: rgba(37, 99, 235, .2); border: 1px solid rgba(96, 165, 250, .28); - border-radius: 999px; font: 600 10px/1 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } - .panel { width: 100%; max-height: inherit; display: none; 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); } - :host(.webcode-follow-up-expanded) .launcher { display: none; } - :host(.webcode-follow-up-expanded) .panel { display: flex; } - .header { min-height: 48px; display: flex; align-items: center; justify-content: space-between; gap: 10px; - padding: 8px 10px 8px 12px; cursor: move; user-select: none; } - .heading { min-width: 0; flex: 1; } - .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; } - .count { flex: 0 0 auto; padding: 2px 7px; color: #bfdbfe; background: rgba(37, 99, 235, .2); - border: 1px solid rgba(96, 165, 250, .28); border-radius: 999px; font-size: 10px; } - .collapse { width: 26px; height: 26px; flex: 0 0 auto; padding: 0; color: #aeb5c2; background: transparent; - border: 0; border-radius: 6px; font-size: 18px; line-height: 1; cursor: pointer; } - .collapse:hover { color: #fff; background: #343942; } - .body { min-height: 0; display: flex; flex-direction: column; border-top: 1px solid #343942; } - .queue { min-height: 0; max-height: min(190px, 28vh); flex: 1 1 auto; overflow-y: auto; } - .queue:empty { display: none; } - .item { display: flex; align-items: center; gap: 8px; padding: 8px 10px 8px 12px; +export const FOLLOW_UP_COMPOSER_STYLE_TEXT = ` + .follow-up-section { min-height: 0; display: flex; overflow: hidden; flex: 0 1 auto; flex-direction: column; + border-top: 1px solid #343942; } + .follow-up-header { min-height: 46px; display: flex; align-items: center; justify-content: space-between; gap: 10px; + padding: 7px 11px 7px 12px; background: rgba(255, 255, 255, .025); } + .follow-up-heading { min-width: 0; flex: 1; } + .follow-up-title { overflow: hidden; color: #f9fafb; font-weight: 650; text-overflow: ellipsis; white-space: nowrap; } + .follow-up-summary { overflow: hidden; margin-top: 1px; color: #aeb5c2; font-size: 10px; + text-overflow: ellipsis; white-space: nowrap; } + .follow-up-count { min-width: 20px; height: 20px; align-items: center; justify-content: center; flex: 0 0 auto; + padding: 0 6px; color: #bfdbfe; background: rgba(37, 99, 235, .2); border: 1px solid rgba(96, 165, 250, .28); + border-radius: 999px; font-size: 10px; } + .follow-up-queue { min-height: 0; max-height: min(150px, 22vh); flex: 1 1 auto; overflow-y: auto; } + .follow-up-queue:empty { display: none; } + .follow-up-item { min-height: 39px; display: flex; align-items: center; gap: 8px; padding: 8px 10px 8px 12px; border-bottom: 1px solid rgba(255, 255, 255, .06); } - .item-text { min-width: 0; flex: 1; overflow-wrap: anywhere; color: #d8dde6; white-space: pre-wrap; } - .item.sending .item-text { color: #93c5fd; } - .item-actions { display: flex; align-items: center; gap: 5px; flex: 0 0 auto; } - .item-state { flex: 0 0 auto; color: #93c5fd; font-size: 10px; white-space: nowrap; } - .item-state.waiting { color: #aeb5c2; } - .remove { width: 22px; height: 22px; display: inline-flex; align-items: center; justify-content: center; + .follow-up-item-text { min-width: 0; flex: 1; overflow-wrap: anywhere; color: #d8dde6; white-space: pre-wrap; } + .follow-up-item.sending .follow-up-item-text { color: #93c5fd; } + .follow-up-item-actions { min-height: 24px; display: flex; align-items: center; justify-content: flex-end; gap: 5px; + flex: 0 0 auto; } + .follow-up-item-state { display: inline-flex; align-items: center; height: 22px; flex: 0 0 auto; color: #93c5fd; + font-size: 10px; line-height: 1; white-space: nowrap; } + .follow-up-item-state.waiting { color: #aeb5c2; } + .follow-up-remove { width: 22px; height: 22px; display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto; padding: 0; border: 0; border-radius: 5px; color: #aeb5c2; background: transparent; font-size: 16px; line-height: 1; cursor: pointer; } - .remove:hover { color: #fff; background: #8f1d1d; } - .composer { flex: 0 0 auto; padding: 10px; } - textarea { width: 100%; min-height: 74px; max-height: min(180px, 24vh); resize: vertical; display: block; + .follow-up-remove:hover { color: #fff; background: #8f1d1d; } + .follow-up-composer { flex: 0 0 auto; padding: 10px; } + .follow-up-composer textarea { width: 100%; min-height: 68px; max-height: min(160px, 22vh); resize: vertical; display: block; padding: 8px 9px; color: #f3f4f6; background: #111318; border: 1px solid #454b56; border-radius: 7px; line-height: 1.45; outline: none; } - textarea:focus { border-color: #3b82f6; box-shadow: 0 0 0 2px rgba(59, 130, 246, .16); } - textarea::placeholder { color: #747d8b; } - .composer-footer { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-top: 8px; } - .hint { color: #858d9a; font-size: 10px; } - .confirm { flex: 0 0 auto; padding: 5px 10px; border: 1px solid #3b82f6; border-radius: 6px; + .follow-up-composer textarea:focus { border-color: #3b82f6; box-shadow: 0 0 0 2px rgba(59, 130, 246, .16); } + .follow-up-composer textarea::placeholder { color: #747d8b; } + .follow-up-composer-footer { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-top: 8px; } + .follow-up-hint { color: #858d9a; font-size: 10px; } + .follow-up-confirm { flex: 0 0 auto; padding: 5px 10px; border: 1px solid #3b82f6; border-radius: 6px; color: #fff; background: #2563eb; cursor: pointer; } - .confirm:hover { background: #1d4ed8; } - .confirm:disabled { color: #7d8490; background: #292d34; border-color: #3b4048; cursor: default; } + .follow-up-confirm:hover { background: #1d4ed8; } + .follow-up-confirm:disabled { color: #7d8490; background: #292d34; border-color: #3b4048; cursor: default; } `; diff --git a/bridge-browser/src/content/follow_up_work_controller.ts b/bridge-browser/src/content/follow_up_work_controller.ts index 39bd083..a9544fd 100644 --- a/bridge-browser/src/content/follow_up_work_controller.ts +++ b/bridge-browser/src/content/follow_up_work_controller.ts @@ -1,7 +1,9 @@ import type { SiteSelectors } from "../modules/config"; import { CompletionNotifier } from "./completion_notifier"; -import { FollowUpOverlay } from "./follow_up_overlay"; -import type { FollowUpQueue } from "./follow_up_queue"; + +interface FollowUpPanelControl { + setEnabled(enabled: boolean): void; +} export const OBSERVED_PAGE_WORK_ATTRIBUTES = [ "aria-busy", "aria-disabled", "aria-hidden", "aria-label", "class", @@ -12,23 +14,21 @@ export const OBSERVED_PAGE_WORK_ATTRIBUTES = [ /** Keeps the follow-up launcher enabled and detects ordinary response completion. */ export class FollowUpWorkController { private readonly completionNotifier: CompletionNotifier; - private readonly overlay: FollowUpOverlay; public constructor( - queue: FollowUpQueue, + private readonly panel: FollowUpPanelControl, onCompletedWithoutTools: () => void ) { - this.overlay = new FollowUpOverlay(queue); this.completionNotifier = new CompletionNotifier({ onCompletedWithoutTools }); } public observe(selectors: SiteSelectors): void { - this.overlay.setEnabled(true); + this.panel.setEnabled(true); this.completionNotifier.observe(selectors); } public reset(): void { this.completionNotifier.reset(); - this.overlay.setEnabled(false); + this.panel.setEnabled(false); } } diff --git a/bridge-browser/src/content/main.ts b/bridge-browser/src/content/main.ts index a5f4c58..c53b466 100644 --- a/bridge-browser/src/content/main.ts +++ b/bridge-browser/src/content/main.ts @@ -243,7 +243,7 @@ const toolActivityTracker = new ToolActivityTracker(); const domToolActivity = new DomToolActivityController(toolActivityTracker); const domToolTurns = new DomToolTurnController(requestRegistry); const followUpQueue = new FollowUpQueue(); -new ToolActivityOverlay(toolActivityTracker); +const workPanel = new ToolActivityOverlay(toolActivityTracker, followUpQueue); let lastProgressLogTime = 0, lastProgressStatus = ""; // === 性能优化: MutationObserver 取代 setInterval === @@ -294,7 +294,7 @@ const resultDelivery = new ResultDeliveryController({ toolActivityTracker, }); -const followUpWork = new FollowUpWorkController(followUpQueue, () => { +const followUpWork = new FollowUpWorkController(workPanel, () => { if (DOM && !networkCapture.hasPendingTurns()) { resultDelivery.deliverFollowUps(DOM); } diff --git a/bridge-browser/src/content/tool_activity_overlay.ts b/bridge-browser/src/content/tool_activity_overlay.ts index e067204..ff198c3 100644 --- a/bridge-browser/src/content/tool_activity_overlay.ts +++ b/bridge-browser/src/content/tool_activity_overlay.ts @@ -1,12 +1,15 @@ import { BRANDING } from "@webcode/shared"; import { t } from "../modules/i18n"; +import { FloatingPanelDragController } from "./floating_panel_drag"; +import { FollowUpComposer, type FollowUpComposerState } from "./follow_up_overlay"; +import { FOLLOW_UP_COMPOSER_STYLE_TEXT } from "./follow_up_overlay_styles"; +import type { FollowUpQueue } from "./follow_up_queue"; import { type ToolActivityItem, type ToolActivitySnapshot, 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, @@ -22,66 +25,145 @@ import { const ELAPSED_UPDATE_INTERVAL_MS = 1000; +/** Shared floating panel for current tool activity and next-turn follow-ups. */ export class ToolActivityOverlay { - private collapsed = false; + private readonly activityMount: HTMLDivElement; private currentTurnId: string | null = null; private dismissedTurnId: string | null = null; private readonly dragController: FloatingPanelDragController; + private enabled = false; + private expanded = false; + private readonly followUpComposer: FollowUpComposer; + private followUpState: FollowUpComposerState = { count: 0, sending: false }; + private readonly headerMount: HTMLDivElement; + private readonly historyPanel: HTMLDivElement; private historyVisible = false; private readonly host: HTMLDivElement; + private readonly launcher: HTMLButtonElement; + private readonly launcherCount: HTMLSpanElement; + private readonly launcherLabel: HTMLSpanElement; + private readonly launcherMark: HTMLSpanElement; private latestSnapshot: ToolActivitySnapshot = { items: [], turns: [] }; private readonly panel: HTMLDivElement; - private readonly stack: HTMLDivElement; private ticker: ReturnType | null = null; - public constructor(tracker: ToolActivityTracker) { + public constructor(tracker: ToolActivityTracker, followUpQueue: FollowUpQueue) { const view = createOverlayView(); this.host = view.host; + this.launcher = view.launcher; + this.launcherCount = view.launcherCount; + this.launcherLabel = view.launcherLabel; + this.launcherMark = view.launcherMark; + this.historyPanel = view.historyPanel; this.panel = view.panel; - this.stack = view.stack; + this.headerMount = view.headerMount; + this.activityMount = view.activityMount; this.dragController = new FloatingPanelDragController(this.host); + this.dragController.bindHandle(this.launcher, true); + this.bindLauncher(); + this.followUpComposer = new FollowUpComposer( + followUpQueue, + (state) => this.handleFollowUpState(state) + ); + this.panel.appendChild(this.followUpComposer.element); tracker.subscribe((snapshot) => this.render(snapshot)); } + public setEnabled(enabled: boolean): void { + this.enabled = enabled; + if (!enabled) { + this.expanded = false; + this.historyVisible = false; + } + this.render(this.latestSnapshot); + } + + private bindLauncher(): void { + this.launcher.onclick = () => { + if (!this.dragController.consumeDragClick()) {this.setExpanded(true);} + }; + } + + private setExpanded(expanded: boolean): void { + this.expanded = expanded; + this.render(this.latestSnapshot); + if (expanded) {this.followUpComposer.focusInput();} + } + + private handleFollowUpState(state: FollowUpComposerState): void { + this.followUpState = state; + this.render(this.latestSnapshot); + } + private render(snapshot: ToolActivitySnapshot): void { this.latestSnapshot = snapshot; const entries = getActivityTurnEntries(snapshot); - const currentEntry = entries.at(-1); - if (!currentEntry || this.dismissedTurnId === currentEntry.turn.id) { - this.host.style.display = "none"; - this.syncTicker(false); - return; - } - - const isSameTurn = currentEntry.turn.id === this.currentTurnId; + const latestEntry = entries.at(-1); + const isSameTurn = latestEntry?.turn.id === this.currentTurnId; const currentScrollTop = isSameTurn ? this.getCurrentScrollTop() : 0; const historyScrollTop = this.getHistoryScrollTop(); - if (!isSameTurn) { - this.startTurn(currentEntry.turn.id); + if (latestEntry && !isSameTurn) {this.startTurn(latestEntry.turn.id);} + if (!latestEntry) { + this.currentTurnId = null; + this.dismissedTurnId = null; } - 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.createCurrentHeader(currentEntry, historyEntries.length), - ...this.createCurrentDetails(currentEntry) - ); - this.stack.replaceChildren( - ...(this.historyVisible ? [this.createHistoryPanel(historyEntries)] : []), - this.panel - ); + const currentEntry = latestEntry && latestEntry.turn.id !== this.dismissedTurnId + ? latestEntry + : undefined; + const historyEntries = latestEntry ? entries.slice(0, -1).reverse() : []; + this.renderCurrent(currentEntry, historyEntries.length); + this.renderHistory(historyEntries); this.restoreCurrentScrollTop(currentScrollTop); this.restoreHistoryScrollTop(historyScrollTop); - this.syncTicker(snapshot.items.some((item) => item.status === "executing")); + this.syncVisibility(Boolean(currentEntry)); + this.syncTicker(Boolean(currentEntry?.items.some((item) => item.status === "executing"))); this.dragController.scheduleClamp(); } - private createCurrentHeader(entry: ToolActivityTurnEntry, historyCount: number): HTMLElement { + private renderCurrent(entry: ToolActivityTurnEntry | undefined, historyCount: number): void { + this.host.className = this.expanded ? "work-panel-expanded" : ""; + this.launcher.setAttribute("aria-expanded", String(this.expanded)); + this.headerMount.replaceChildren(this.createCurrentHeader(entry, historyCount)); + this.activityMount.replaceChildren(...(entry ? createTurnDetails(entry, "list") : [])); + this.activityMount.style.display = entry ? "flex" : "none"; + this.updateLauncher(entry); + } + + private renderHistory(entries: ToolActivityTurnEntry[]): void { + const shouldShow = this.expanded && this.historyVisible; + this.historyPanel.style.display = shouldShow ? "flex" : "none"; + if (!shouldShow) {return;} + + const header = document.createElement("div"); + header.className = "history-header drag-header"; + header.title = t("work_panel_drag"); + this.dragController.bindHandle(header); + const title = document.createElement("div"); + title.className = "history-title"; + title.textContent = `${t("activity_history")} · ${entries.length}`; + header.append(title, this.createHistoryCloseButton()); + + const list = document.createElement("div"); + list.className = "history-list"; + if (entries.length === 0) { + const empty = document.createElement("div"); + empty.className = "history-empty"; + empty.textContent = t("activity_no_history"); + list.appendChild(empty); + } else { + entries.forEach((entry) => list.appendChild(createHistoryTurn(entry))); + } + this.historyPanel.replaceChildren(header, list); + } + + private createCurrentHeader( + entry: ToolActivityTurnEntry | undefined, + historyCount: number + ): HTMLElement { const header = document.createElement("div"); header.className = "header drag-header"; - header.title = t("activity_drag"); + header.title = t("work_panel_drag"); this.dragController.bindHandle(header); const identity = document.createElement("div"); @@ -90,28 +172,23 @@ export class ToolActivityOverlay { heading.className = "heading"; const title = document.createElement("div"); title.className = "title"; - title.textContent = `${BRANDING.productName} · ${t("activity_title")}`; + title.textContent = `${BRANDING.productName} · ${t("work_panel_title")}`; const summary = document.createElement("div"); summary.className = "summary"; - summary.textContent = getTurnSummary(entry.turn, entry.items); + summary.textContent = this.getPanelSummary(entry); heading.append(title, summary); - identity.append(createTurnMark(entry.turn, entry.items), heading); + identity.append(entry ? createTurnMark(entry.turn, entry.items) : createIdleMark(), heading); const actions = document.createElement("div"); actions.className = "actions"; - actions.append(this.createHistoryButton(historyCount), this.createToggleButton()); - if (isTurnSettled(entry.turn)) { + actions.append(this.createHistoryButton(historyCount), this.createCollapseButton()); + if (entry && isTurnSettled(entry.turn)) { actions.appendChild(this.createCloseButton(entry.turn.id)); } header.append(identity, actions); return header; } - private createCurrentDetails(entry: ToolActivityTurnEntry): HTMLElement[] { - if (this.collapsed) {return [];} - return createTurnDetails(entry, "list"); - } - private createHistoryButton(historyCount: number): HTMLButtonElement { const button = document.createElement("button"); button.type = "button"; @@ -127,40 +204,8 @@ export class ToolActivityOverlay { return button; } - private createHistoryPanel(entries: ToolActivityTurnEntry[]): HTMLElement { - const panel = document.createElement("div"); - panel.className = "history-panel"; - - const header = document.createElement("div"); - header.className = "history-header drag-header"; - header.title = t("activity_drag"); - this.dragController.bindHandle(header); - const title = document.createElement("div"); - title.className = "history-title"; - title.textContent = `${t("activity_history")} · ${entries.length}`; - header.append(title, this.createHistoryCloseButton()); - - const list = document.createElement("div"); - list.className = "history-list"; - if (entries.length === 0) { - const empty = document.createElement("div"); - empty.className = "history-empty"; - empty.textContent = t("activity_no_history"); - list.appendChild(empty); - } else { - entries.forEach((entry) => list.appendChild(createHistoryTurn(entry))); - } - panel.append(header, list); - return panel; - } - 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 = "×"; + const button = createIconButton("×", t("activity_hide_history"), "icon-button close"); button.onclick = () => { this.historyVisible = false; this.render(this.latestSnapshot); @@ -168,39 +213,47 @@ export class ToolActivityOverlay { return button; } - private createToggleButton(): HTMLButtonElement { - const button = document.createElement("button"); - button.type = "button"; - button.className = "icon-button"; - button.title = t(this.collapsed ? "activity_expand" : "activity_minimize"); - button.setAttribute("aria-label", button.title); - button.textContent = this.collapsed ? "□" : "−"; - button.onclick = () => { - this.collapsed = !this.collapsed; - this.render(this.latestSnapshot); - }; + private createCollapseButton(): HTMLButtonElement { + const button = createIconButton("−", t("activity_minimize"), "icon-button collapse"); + button.onclick = () => this.setExpanded(false); return button; } private createCloseButton(turnId: string): HTMLButtonElement { - const button = document.createElement("button"); - button.type = "button"; - button.className = "icon-button close"; - button.title = t("activity_close"); - button.setAttribute("aria-label", button.title); - button.textContent = "×"; + const button = createIconButton("×", t("activity_close"), "icon-button close"); button.onclick = () => { this.dismissedTurnId = turnId; - this.host.style.display = "none"; - this.syncTicker(false); + this.render(this.latestSnapshot); }; return button; } + private getPanelSummary(entry: ToolActivityTurnEntry | undefined): string { + if (entry) {return getTurnSummary(entry.turn, entry.items);} + if (this.followUpState.sending) {return t("follow_up_sending");} + if (this.followUpState.count > 0) {return t("follow_up_waiting");} + return t("follow_up_description"); + } + + private updateLauncher(entry: ToolActivityTurnEntry | undefined): void { + this.launcherMark.className = entry + ? `launcher-mark ${getTurnTone(entry.turn, entry.items)}` + : "launcher-mark idle"; + this.launcherMark.textContent = entry ? getTurnIcon(entry.turn, entry.items) : "+"; + this.launcherLabel.textContent = entry + ? getTurnSummary(entry.turn, entry.items) + : t("follow_up_title"); + this.launcherCount.textContent = String(this.followUpState.count); + this.launcherCount.style.display = this.followUpState.count > 0 ? "inline-flex" : "none"; + } + private startTurn(turnId: string): void { this.currentTurnId = turnId; this.dismissedTurnId = null; - this.collapsed = false; + } + + private syncVisibility(hasCurrentActivity: boolean): void { + this.host.style.display = this.enabled || hasCurrentActivity ? "block" : "none"; } private syncTicker(shouldRun: boolean): void { @@ -213,24 +266,22 @@ export class ToolActivityOverlay { } private getCurrentScrollTop(): number { - if (this.collapsed) {return 0;} - return this.panel.querySelector(".list")?.scrollTop ?? 0; + return this.activityMount.querySelector(".list")?.scrollTop ?? 0; } private getHistoryScrollTop(): number { if (!this.historyVisible) {return 0;} - return this.stack.querySelector(".history-list")?.scrollTop ?? 0; + return this.historyPanel.querySelector(".history-list")?.scrollTop ?? 0; } private restoreCurrentScrollTop(scrollTop: number): void { - if (this.collapsed) {return;} - const list = this.panel.querySelector(".list"); + const list = this.activityMount.querySelector(".list"); if (list) {list.scrollTop = scrollTop;} } private restoreHistoryScrollTop(scrollTop: number): void { if (!this.historyVisible) {return;} - const history = this.stack.querySelector(".history-list"); + const history = this.historyPanel.querySelector(".history-list"); if (history) {history.scrollTop = scrollTop;} } } @@ -255,17 +306,24 @@ function createHistoryTurn(entry: ToolActivityTurnEntry): HTMLElement { } function createTurnMark(turn: ToolActivityTurn, items: ToolActivityItem[]): HTMLElement { + return createMark(`mark ${getTurnTone(turn, items)}`, getTurnIcon(turn, items)); +} + +function createMark(className: string, text: string): HTMLElement { const mark = document.createElement("span"); - mark.className = `mark ${getTurnTone(turn, items)}`; - mark.textContent = getTurnIcon(turn, items); + mark.className = className; + mark.textContent = text; return mark; } +function createIdleMark(): HTMLElement { + return createMark("mark idle", "+"); +} + function createTurnDetails(entry: ToolActivityTurnEntry, listClassName: string): HTMLElement[] { const list = document.createElement("div"); list.className = listClassName; entry.items.forEach((item) => list.appendChild(createActivityRow(item))); - const footer = document.createElement("div"); footer.className = `footer ${getTurnTone(entry.turn, entry.items)}`; footer.textContent = getDeliveryText(entry.turn, entry.items); @@ -275,7 +333,6 @@ function createTurnDetails(entry: ToolActivityTurnEntry, listClassName: string): function createActivityRow(item: ToolActivityItem): HTMLElement { const row = document.createElement("div"); row.className = `row ${item.status}`; - const dot = document.createElement("span"); dot.className = "status-dot"; const content = document.createElement("div"); @@ -288,15 +345,14 @@ function createActivityRow(item: ToolActivityItem): HTMLElement { const source = document.createElement("span"); source.className = `source-badge ${item.source}`; source.textContent = t(item.source === "network" ? "activity_source_network" : "activity_source_dom"); - const toolIdentity = document.createElement("div"); - toolIdentity.className = "tool-identity"; - toolIdentity.append(name, source); + const identity = document.createElement("div"); + identity.className = "tool-identity"; + identity.append(name, source); const status = document.createElement("span"); status.className = "status"; status.textContent = getItemStatusText(item); - top.append(toolIdentity, status); + top.append(identity, status); content.appendChild(top); - if (item.purpose) {content.appendChild(createTextLine("purpose", item.purpose));} if (item.detail) {content.appendChild(createTextLine("detail", item.detail));} if (item.message && (item.status === "failed" || item.status === "rejected")) { @@ -314,22 +370,72 @@ function createTextLine(className: string, value: string): HTMLElement { return line; } -function createOverlayView(): { +function createIconButton(text: string, title: string, className = "icon-button"): HTMLButtonElement { + const button = document.createElement("button"); + button.type = "button"; + button.className = className; + button.title = title; + button.setAttribute("aria-label", title); + button.textContent = text; + return button; +} + +interface OverlayView { + activityMount: HTMLDivElement; + headerMount: HTMLDivElement; + historyPanel: HTMLDivElement; host: HTMLDivElement; + launcher: HTMLButtonElement; + launcherCount: HTMLSpanElement; + launcherLabel: HTMLSpanElement; + launcherMark: HTMLSpanElement; panel: HTMLDivElement; - stack: HTMLDivElement; -} { +} + +function createOverlayView(): OverlayView { 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; + style.textContent = `${TOOL_ACTIVITY_STYLE_TEXT}\n${FOLLOW_UP_COMPOSER_STYLE_TEXT}`; + const launcherView = createLauncherView(); const stack = document.createElement("div"); stack.className = "overlay-stack"; + const historyPanel = document.createElement("div"); + historyPanel.className = "history-panel"; + historyPanel.style.display = "none"; const panel = document.createElement("div"); panel.className = "panel"; - stack.appendChild(panel); - shadow.append(style, stack); + const headerMount = document.createElement("div"); + headerMount.className = "header-mount"; + const activityMount = document.createElement("div"); + activityMount.className = "activity-mount"; + panel.append(headerMount, activityMount); + stack.append(historyPanel, panel); + shadow.append(style, launcherView.launcher, stack); document.body.appendChild(host); - return { host, panel, stack }; + return { activityMount, headerMount, historyPanel, host, panel, ...launcherView }; +} + +function createLauncherView(): Pick< + OverlayView, + "launcher" | "launcherCount" | "launcherLabel" | "launcherMark" +> { + const launcher = document.createElement("button"); + launcher.type = "button"; + launcher.className = "launcher"; + launcher.title = t("work_panel_open"); + launcher.setAttribute("aria-label", launcher.title); + launcher.setAttribute("aria-expanded", "false"); + const launcherMark = document.createElement("span"); + launcherMark.className = "launcher-mark idle"; + launcherMark.textContent = "+"; + const launcherLabel = document.createElement("span"); + launcherLabel.className = "launcher-label"; + launcherLabel.textContent = t("follow_up_title"); + const launcherCount = document.createElement("span"); + launcherCount.className = "launcher-count"; + launcherCount.style.display = "none"; + launcher.append(launcherMark, launcherLabel, launcherCount); + return { launcher, launcherCount, launcherLabel, launcherMark }; } diff --git a/bridge-browser/src/content/tool_activity_overlay_styles.ts b/bridge-browser/src/content/tool_activity_overlay_styles.ts index 081075e..5071431 100644 --- a/bridge-browser/src/content/tool_activity_overlay_styles.ts +++ b/bridge-browser/src/content/tool_activity_overlay_styles.ts @@ -1,19 +1,42 @@ import { TOOL_ACTIVITY_OVERLAY_Z_INDEX } from "../modules/overlay_layers"; export const TOOL_ACTIVITY_STYLE_TEXT = ` - :host { position: fixed; right: 20px; bottom: 20px; z-index: ${TOOL_ACTIVITY_OVERLAY_Z_INDEX}; width: min(390px, calc(100vw - 32px)); - max-height: calc(100vh - 32px); color-scheme: dark; } - :host(.current-collapsed) { width: min(290px, calc(100vw - 16px)); } + :host { position: fixed; right: 20px; bottom: 20px; z-index: ${TOOL_ACTIVITY_OVERLAY_Z_INDEX}; width: fit-content; + max-width: calc(100vw - 16px); max-height: calc(100vh - 16px); color-scheme: dark; } + :host(.work-panel-expanded) { width: min(390px, calc(100vw - 32px)); } * { box-sizing: border-box; } - button { font: inherit; } - .overlay-stack { display: flex; max-height: inherit; flex-direction: column; gap: 10px; } + button, textarea { font: inherit; } + .overlay-stack { display: none; max-height: inherit; flex-direction: column; gap: 10px; } + :host(.work-panel-expanded) .overlay-stack { display: flex; } + :host(.work-panel-expanded) .launcher { display: none; } + .launcher { min-height: 42px; max-width: min(320px, calc(100vw - 16px)); display: flex; align-items: center; gap: 8px; + padding: 7px 11px 7px 8px; color: #f3f4f6; background: rgba(20, 22, 26, .96); border: 1px solid #3b404a; + border-radius: 11px; box-shadow: 0 9px 26px rgba(0, 0, 0, .34); cursor: grab; backdrop-filter: blur(10px); } + .launcher:hover { background: rgba(31, 35, 42, .98); border-color: #596171; } + .launcher:active { cursor: grabbing; } + .launcher:focus-visible { outline: 2px solid #3b82f6; outline-offset: 2px; } + .launcher-mark { width: 25px; height: 25px; display: inline-flex; align-items: center; justify-content: center; + flex: 0 0 auto; color: #fff; background: #2563eb; border-radius: 7px; font-size: 13px; font-weight: 700; line-height: 1; } + .launcher-mark.idle { color: #dbeafe; font-size: 19px; } + .launcher-mark.active { animation: pulse 1.4s ease-in-out infinite; } + .launcher-mark.success { background: #15803d; } + .launcher-mark.warn { background: #b45309; } + .launcher-mark.error { background: #b91c1c; } + .launcher-label { min-width: 0; overflow: hidden; font: 600 12px/1.2 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + text-overflow: ellipsis; white-space: nowrap; } + .launcher-count { min-width: 18px; height: 18px; align-items: center; justify-content: center; flex: 0 0 auto; + padding: 0 5px; color: #bfdbfe; background: rgba(37, 99, 235, .2); border: 1px solid rgba(96, 165, 250, .28); + border-radius: 999px; font: 600 10px/1 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } .panel, .history-panel { width: 100%; display: flex; overflow: hidden; flex-direction: column; color: #f3f4f6; - background: rgba(20, 22, 26, .96); border: 1px solid #3b404a; border-radius: 12px; box-shadow: 0 12px 34px rgba(0, 0, 0, .38); - font: 12px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; backdrop-filter: blur(10px); } - .panel { max-height: min(360px, 46vh); align-self: flex-end; } - .panel.collapsed { width: 100%; min-width: 0; } - .history-panel { height: min(300px, 40vh); min-height: min(160px, 30vh); flex: 0 1 auto; } - .header, .history-header { flex: 0 0 auto; display: flex; align-items: center; justify-content: space-between; gap: 10px; user-select: none; } + background: rgba(20, 22, 26, .96); border: 1px solid #3b404a; border-radius: 12px; + box-shadow: 0 12px 34px rgba(0, 0, 0, .38); font: 12px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + backdrop-filter: blur(10px); } + .panel { max-height: min(540px, 58vh); align-self: flex-end; } + .history-panel { height: min(300px, 32vh); min-height: min(150px, 25vh); flex: 0 1 auto; } + .header-mount { flex: 0 0 auto; } + .activity-mount { min-height: 0; flex: 1 1 auto; flex-direction: column; } + .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; } @@ -21,6 +44,7 @@ export const TOOL_ACTIVITY_STYLE_TEXT = ` .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.idle { border-radius: 7px; color: #dbeafe; font-size: 18px; font-weight: 500; } .mark.active { animation: pulse 1.4s ease-in-out infinite; } .mark.success { background: #15803d; } .mark.warn { background: #b45309; } @@ -28,7 +52,8 @@ export const TOOL_ACTIVITY_STYLE_TEXT = ` .title, .history-title { overflow: hidden; color: #f9fafb; font-weight: 650; text-overflow: ellipsis; white-space: nowrap; } .summary { overflow: hidden; margin-top: 1px; color: #aeb5c2; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; } .actions { flex: 0 0 auto; display: flex; gap: 3px; } - .icon-button, .history-button { height: 25px; padding: 0; border: 0; border-radius: 6px; color: #c8ced8; background: transparent; cursor: pointer; } + .icon-button, .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); } @@ -38,7 +63,8 @@ export const TOOL_ACTIVITY_STYLE_TEXT = ` .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.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; } @@ -62,7 +88,8 @@ export const TOOL_ACTIVITY_STYLE_TEXT = ` .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 { 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; } diff --git a/bridge-browser/src/modules/i18n.ts b/bridge-browser/src/modules/i18n.ts index 7e8164d..c5b13ba 100644 --- a/bridge-browser/src/modules/i18n.ts +++ b/bridge-browser/src/modules/i18n.ts @@ -85,6 +85,10 @@ const I18N_MESSAGES: Record = { activity_source_dom: { en: "DOM", zh: "DOM" }, activity_source_network: { en: "Network", zh: "网络" }, + work_panel_title: { en: "Work panel", zh: "工作面板" }, + work_panel_open: { en: "Open work panel", zh: "展开工作面板" }, + work_panel_drag: { en: "Drag work panel", zh: "拖动工作面板" }, + follow_up_title: { en: "Next-turn follow-up", zh: "下一轮补充" }, follow_up_description: { en: "Only confirmed messages will be sent when this work finishes", diff --git a/bridge-browser/test/follow_up_overlay.test.ts b/bridge-browser/test/follow_up_overlay.test.ts index 62d64b9..dcfb4ad 100644 --- a/bridge-browser/test/follow_up_overlay.test.ts +++ b/bridge-browser/test/follow_up_overlay.test.ts @@ -1,23 +1,12 @@ import { FollowUpQueue } from "../src/content/follow_up_queue"; -interface FakeRect { - height: number; - left: number; - top: number; - width: number; -} - interface FakeEvent { - button: number; - clientX: number; - clientY: number; ctrlKey: boolean; isComposing: boolean; key: string; metaKey: boolean; preventDefault: () => void; stopPropagation: () => void; - target: FakeElement; } class FakeElement { @@ -25,10 +14,7 @@ class FakeElement { public className = ""; public disabled = false; public onclick: (() => void) | null = null; - public onmousedown: ((event: FakeEvent) => void) | null = null; - public parentElement: FakeElement | null = null; public placeholder = ""; - public shadowRoot: FakeElement | null = null; public readonly style: Record = {}; public textContent = ""; public title = ""; @@ -36,40 +22,31 @@ class FakeElement { public value = ""; private readonly attributes = new Map(); private readonly listeners = new Map void>>(); - private rect: FakeRect = { height: 0, left: 0, top: 0, width: 0 }; + private readonly tagName: string; - public constructor(private readonly tagName = "div") {} + public constructor(tagName = "div") { + this.tagName = tagName; + } + + public addEventListener(type: string, listener: (event: FakeEvent) => void): void { + const listeners = this.listeners.get(type) ?? new Set<(event: FakeEvent) => void>(); + listeners.add(listener); + this.listeners.set(type, listeners); + } public append(...children: FakeElement[]): void { children.forEach((child) => this.appendChild(child)); } public appendChild(child: FakeElement): FakeElement { - child.parentElement = this; this.children.push(child); return child; } - public attachShadow(): FakeElement { - this.shadowRoot = new FakeElement("shadow-root"); - return this.shadowRoot; - } - - public addEventListener(type: string, listener: (event: FakeEvent) => void): void { - const listeners = this.listeners.get(type) ?? new Set<(event: FakeEvent) => void>(); - listeners.add(listener); - this.listeners.set(type, listeners); - } - public click(): void { if (!this.disabled) {this.onclick?.();} } - public closest(selector: string): FakeElement | null { - if (selector === "button" && this.tagName === "button") {return this;} - return this.parentElement?.closest(selector) ?? null; - } - public dispatch(type: string, event: FakeEvent): void { this.listeners.get(type)?.forEach((listener) => listener(event)); } @@ -78,34 +55,10 @@ class FakeElement { fakeDocument.activeElement = this; } - public getBoundingClientRect(): DOMRect { - const { height, width } = this.rect; - const left = parsePixels(this.style.left) ?? this.rect.left; - const bottom = parsePixels(this.style.bottom); - const top = parsePixels(this.style.top) ?? ( - bottom === null ? this.rect.top : fakeWindow.innerHeight - bottom - height - ); - 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: FakeEvent): void { - this.onmousedown?.(event); - } - public querySelector(selector: string): T | null { const match = selector.startsWith(".") ? this.findByClass(selector.slice(1)) @@ -122,10 +75,6 @@ class FakeElement { 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) { @@ -147,160 +96,69 @@ class FakeElement { class FakeDocument { public activeElement: FakeElement | null = null; - public readonly body = new FakeElement("body"); public createElement(tagName: string): FakeElement { return new FakeElement(tagName); } - - public reset(): void { - this.activeElement = null; - this.body.replaceChildren(); - } -} - -class FakeWindow { - public innerHeight = 700; - public innerWidth = 1000; - private animationFrameId = 1; - 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: FakeEvent) => void>(); - listeners.add(listener as (event: FakeEvent) => void); - this.listeners.set(type, listeners); - } - - public dispatch(type: string, event: FakeEvent): void { - this.listeners.get(type)?.forEach((listener) => listener(event)); - } - - public queueAnimationFrame(callback: () => void): number { - callback(); - return this.animationFrameId++; - } - - public reset(): void { - this.innerHeight = 700; - this.innerWidth = 1000; - this.listeners.clear(); - } } const fakeDocument = new FakeDocument(); -const fakeWindow = new FakeWindow(); async function main(): Promise { installBrowserGlobals(); - const { FollowUpOverlay } = await import("../src/content/follow_up_overlay"); + const { FollowUpComposer } = await import("../src/content/follow_up_overlay"); runTest("waiting follow-ups remain visible and removable until delivery starts", () => { - const harness = createHarness(FollowUpOverlay); - harness.overlay.setEnabled(true); - getRequired(harness.host.shadowRoot!, ".launcher").click(); - const textarea = getRequired(harness.host.shadowRoot!, "textarea"); + const queue = new FollowUpQueue(); + const states: Array<{ count: number; sending: boolean }> = []; + const composer = new FollowUpComposer(queue, (state) => states.push(state)); + const root = composer.element as unknown as FakeElement; + const textarea = getRequired(root, "textarea"); textarea.value = "unfinished draft"; - assertEqual(harness.queue.beginDelivery().messages.length, 0, "unfinished draft entered delivery"); + assertEqual(queue.beginDelivery().messages.length, 0, "unfinished draft entered delivery"); - confirmDraft(harness.host, textarea); - assertIncludes(getRequired(harness.host.shadowRoot!, ".queue").getText(), "unfinished draft", "confirmed message was hidden"); - getRequired(harness.host.shadowRoot!, ".remove").click(); - assertEqual(harness.queue.beginDelivery().messages.length, 0, "removed message remained queued"); + confirmDraft(root, textarea); + assertIncludes(getRequired(root, ".follow-up-queue").getText(), "unfinished draft", "confirmed message was hidden"); + getRequired(root, ".follow-up-remove").click(); + assertEqual(queue.beginDelivery().messages.length, 0, "removed message remained queued"); textarea.value = "send this next"; - confirmDraft(harness.host, textarea); - const delivery = harness.queue.beginDelivery(); + confirmDraft(root, textarea); + const delivery = queue.beginDelivery(); assertEqual(delivery.messages.join("|"), "send this next", "confirmed text changed"); - assert(!getRequired(harness.host.shadowRoot!, ".queue").querySelector(".remove"), "sending message could still be removed"); - - harness.queue.completeDelivery(delivery.ids); - assert(!getRequired(harness.host.shadowRoot!, ".queue").getText(), "delivered messages remained visible"); - assertEqual(harness.host.className, "", "empty composer did not collapse after delivery"); - assertEqual(harness.host.style.display, "block", "persistent launcher disappeared after delivery"); - }); - runTest("compact launcher stays visible and opens the composer", () => { - const harness = createHarness(FollowUpOverlay); - assertEqual(harness.host.style.display, "none", "disabled launcher was visible"); - harness.overlay.setEnabled(true); - assertEqual(harness.host.style.display, "block", "idle launcher was hidden"); - - getRequired(harness.host.shadowRoot!, ".launcher").click(); - assertEqual(harness.host.className, "webcode-follow-up-expanded", "launcher did not open the composer"); - assertEqual(fakeDocument.activeElement, getRequired(harness.host.shadowRoot!, "textarea"), "composer did not focus its input"); + assert(!composer.element.querySelector(".follow-up-remove"), "sending message could still be removed"); + assert(states.at(-1)?.sending, "sending state was not reported to the parent panel"); - getRequired(harness.host.shadowRoot!, ".collapse").click(); - assertEqual(harness.host.className, "", "composer did not collapse to its launcher"); + queue.completeDelivery(delivery.ids); + assert(!getRequired(root, ".follow-up-queue").getText(), "delivered messages remained visible"); + assertEqual(states.at(-1)?.count, 0, "empty queue count was not reported to the parent panel"); }); - runTest("collapsed launcher can be dragged without opening the composer", () => { - const harness = createHarness(FollowUpOverlay); - harness.overlay.setEnabled(true); - harness.host.setRect({ height: 42, left: 20, top: 638, width: 150 }); - const launcher = getRequired(harness.host.shadowRoot!, ".launcher"); - - launcher.mouseDown(createEvent(launcher, 30, 650)); - fakeWindow.dispatch("mousemove", createEvent(launcher, 230, 450)); - fakeWindow.dispatch("mouseup", createEvent(launcher, 230, 450)); - launcher.click(); - - assertEqual(harness.host.style.left, "220px", "collapsed launcher did not move"); - assertEqual(harness.host.className, "", "dragging the launcher opened the composer"); - launcher.click(); - assertEqual(harness.host.className, "webcode-follow-up-expanded", "launcher did not open after dragging"); + runTest("composer keeps its input mounted and focuses it on request", () => { + const queue = new FollowUpQueue(); + const composer = new FollowUpComposer(queue, () => undefined); + const root = composer.element as unknown as FakeElement; + const textarea = getRequired(root, "textarea"); + textarea.value = "draft stays here"; + composer.focusInput(); + queue.confirm("another message"); + assertEqual(getRequired(root, "textarea"), textarea, "queue update replaced the textarea"); + assertEqual(textarea.value, "draft stays here", "queue update cleared the unfinished draft"); + assertEqual(fakeDocument.activeElement, textarea, "queue update moved focus away from the textarea"); }); - runTest("expanded composer stays inside the viewport when dragged", () => { - const harness = createHarness(FollowUpOverlay); - harness.overlay.setEnabled(true); - getRequired(harness.host.shadowRoot!, ".launcher").click(); - - const header = getRequired(harness.host.shadowRoot!, ".header"); - header.mouseDown(createEvent(header, 20, 420)); - fakeWindow.dispatch("mousemove", createEvent(header, -1000, -1000)); - fakeWindow.dispatch("mouseup", createEvent(header, -1000, -1000)); - assertEqual(harness.host.style.left, "8px", "composer escaped the left edge"); - assertEqual(harness.host.getBoundingClientRect().top, 8, "composer escaped the top edge"); - }); -} - -interface OverlayInstance { - setEnabled: (enabled: boolean) => void; -} - -type OverlayConstructor = new ( - queue: FollowUpQueue -) => OverlayInstance; - -function createHarness(Overlay: OverlayConstructor): { - host: FakeElement; - overlay: OverlayInstance; - queue: FollowUpQueue; -} { - fakeDocument.reset(); - fakeWindow.reset(); - const queue = new FollowUpQueue(); - const overlay = new Overlay(queue); - const host = fakeDocument.body.children.at(-1); - assert(host?.shadowRoot, "follow-up overlay was not created"); - host.setRect({ height: 260, left: 20, top: 420, width: 350 }); - return { host, overlay, queue }; } -function confirmDraft(host: FakeElement, textarea: FakeElement): void { - textarea.dispatch("input", createEvent(textarea)); - getRequired(host.shadowRoot!, ".confirm").click(); +function confirmDraft(root: FakeElement, textarea: FakeElement): void { + textarea.dispatch("input", createEvent()); + getRequired(root, ".follow-up-confirm").click(); } -function createEvent(target: FakeElement, clientX = 0, clientY = 0): FakeEvent { +function createEvent(): FakeEvent { return { - button: 0, - clientX, - clientY, ctrlKey: false, isComposing: false, key: "", metaKey: false, preventDefault: () => undefined, stopPropagation: () => undefined, - target, }; } @@ -313,17 +171,6 @@ function getRequired(root: FakeElement, selector: string): FakeElement { function installBrowserGlobals(): void { Object.defineProperty(globalThis, "document", { configurable: true, value: fakeDocument }); Object.defineProperty(globalThis, "navigator", { configurable: true, value: { language: "en-US" } }); - Object.defineProperty(globalThis, "window", { configurable: true, value: fakeWindow }); - Object.defineProperty(globalThis, "requestAnimationFrame", { - configurable: true, - value: (callback: () => void) => fakeWindow.queueAnimationFrame(callback), - }); -} - -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 { diff --git a/bridge-browser/test/support/fake_overlay_dom.ts b/bridge-browser/test/support/fake_overlay_dom.ts new file mode 100644 index 0000000..0a33799 --- /dev/null +++ b/bridge-browser/test/support/fake_overlay_dom.ts @@ -0,0 +1,238 @@ +export interface FakeRect { + height: number; + left: number; + top: number; + width: number; +} + +export interface FakeEvent { + button: number; + clientX: number; + clientY: number; + ctrlKey: boolean; + isComposing: boolean; + key: string; + metaKey: boolean; + preventDefault: () => void; + stopPropagation: () => void; + target: FakeElement; +} + +export class FakeElement { + public readonly children: FakeElement[] = []; + public className = ""; + public disabled = false; + public onclick: (() => void) | null = null; + public onmousedown: ((event: FakeEvent) => void) | null = null; + public parentElement: FakeElement | null = null; + public placeholder = ""; + public scrollTop = 0; + public shadowRoot: FakeElement | null = null; + public readonly style: Record = {}; + public textContent = ""; + public title = ""; + public type = ""; + public value = ""; + private readonly attributes = new Map(); + private readonly listeners = new Map void>>(); + private rect: FakeRect = { height: 0, left: 0, top: 0, width: 0 }; + + public constructor(private readonly tagName = "div") {} + + public addEventListener(type: string, listener: (event: FakeEvent) => void): void { + const listeners = this.listeners.get(type) ?? new Set<(event: FakeEvent) => void>(); + listeners.add(listener); + this.listeners.set(type, listeners); + } + + public append(...children: FakeElement[]): void { + children.forEach((child) => this.appendChild(child)); + } + + public appendChild(child: FakeElement): FakeElement { + child.parentElement = this; + this.children.push(child); + return child; + } + + public attachShadow(): FakeElement { + this.shadowRoot = new FakeElement("shadow-root"); + return this.shadowRoot; + } + + public click(): void { + if (!this.disabled) {this.onclick?.();} + } + + public closest(selector: string): FakeElement | null { + if (selector === "button" && this.tagName === "button") {return this;} + return this.parentElement?.closest(selector) ?? null; + } + + public dispatch(type: string, event = createFakeEvent(this)): void { + this.listeners.get(type)?.forEach((listener) => listener(event)); + } + + public focus(): void { + fakeDocument.activeElement = this; + } + + public getBoundingClientRect(): DOMRect { + const { height, width } = this.rect; + 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: FakeEvent): void { + this.onmousedown?.(event); + } + + public querySelector(selector: string): T | null { + const match = selector.startsWith(".") + ? this.findByClass(selector.slice(1)) + : this.findByTag(selector); + return match as T | null; + } + + public replaceChildren(...children: FakeElement[]): void { + this.children.length = 0; + this.append(...children); + } + + public setAttribute(name: string, value: string): void { + this.attributes.set(name, value); + } + + public setRect(rect: FakeRect): void { + this.rect = rect; + } + + private findByClass(className: string): FakeElement | null { + if (this.className.split(/\s+/).includes(className)) {return this;} + for (const child of this.children) { + const match = child.findByClass(className); + if (match) {return match;} + } + return null; + } + + private findByTag(tagName: string): FakeElement | null { + if (this.tagName === tagName) {return this;} + for (const child of this.children) { + const match = child.findByTag(tagName); + if (match) {return match;} + } + return null; + } + + private getBottomAnchoredTop(height: number): number | null { + const bottom = parsePixels(this.style.bottom); + return bottom === null ? null : fakeWindow.innerHeight - bottom - height; + } + + private getRightAnchoredLeft(width: number): number | null { + const right = parsePixels(this.style.right); + return right === null ? null : fakeWindow.innerWidth - right - width; + } +} + +class FakeDocument { + public activeElement: FakeElement | null = null; + public readonly body = new FakeElement("body"); + + public createElement(tagName: string): FakeElement { + return new FakeElement(tagName); + } + + public reset(): void { + this.activeElement = null; + this.body.replaceChildren(); + } +} + +class FakeWindow { + public innerHeight = 700; + public innerWidth = 1000; + private animationFrameId = 1; + private readonly animationFrames = new Map void>(); + private readonly listeners = new Map void>>(); + + public addEventListener(type: string, listener: unknown): void { + if (typeof listener !== "function") {return;} + const listeners = this.listeners.get(type) ?? new Set<(event: unknown) => void>(); + listeners.add(listener as (event: unknown) => void); + this.listeners.set(type, listeners); + } + + public dispatch(type: string, event: unknown): void { + this.listeners.get(type)?.forEach((listener) => listener(event)); + } + + public flushAnimationFrames(): void { + const callbacks = Array.from(this.animationFrames.values()); + this.animationFrames.clear(); + callbacks.forEach((callback) => callback()); + } + + public queueAnimationFrame(callback: () => void): number { + const id = this.animationFrameId++; + this.animationFrames.set(id, callback); + return id; + } + + public reset(): void { + this.innerHeight = 700; + this.innerWidth = 1000; + this.animationFrames.clear(); + this.listeners.clear(); + } +} + +export const fakeDocument = new FakeDocument(); +export const fakeWindow = new FakeWindow(); + +export function createFakeEvent(target: FakeElement, clientX = 0, clientY = 0): FakeEvent { + return { + button: 0, + clientX, + clientY, + ctrlKey: false, + isComposing: false, + key: "", + metaKey: false, + preventDefault: () => undefined, + stopPropagation: () => undefined, + target, + }; +} + +export function installOverlayBrowserGlobals(): void { + Object.defineProperty(globalThis, "document", { configurable: true, value: fakeDocument }); + Object.defineProperty(globalThis, "navigator", { configurable: true, value: { language: "en-US" } }); + Object.defineProperty(globalThis, "window", { configurable: true, value: fakeWindow }); + Object.defineProperty(globalThis, "requestAnimationFrame", { + configurable: true, + value: (callback: () => void) => fakeWindow.queueAnimationFrame(callback), + }); +} + +function parsePixels(value: string | undefined): number | null { + if (!value || value === "auto") {return null;} + const parsed = Number.parseFloat(value); + return Number.isFinite(parsed) ? parsed : null; +} diff --git a/bridge-browser/test/tool_activity_overlay.test.ts b/bridge-browser/test/tool_activity_overlay.test.ts index 8e79a76..aea0918 100644 --- a/bridge-browser/test/tool_activity_overlay.test.ts +++ b/bridge-browser/test/tool_activity_overlay.test.ts @@ -1,183 +1,30 @@ +import { FollowUpQueue } from "../src/content/follow_up_queue"; +import { ToolActivityTracker, type ToolActivitySource } from "../src/content/tool_activity"; import { - ToolActivityTracker, - type ToolActivitySource, -} from "../src/content/tool_activity"; - -type OverlayConstructor = new (tracker: ToolActivityTracker) => unknown; - -interface FakeRect { - height: number; - left: number; - top: number; - width: number; -} - -interface FakeMouseEvent { - button: number; - clientX: number; - clientY: number; - preventDefault: () => void; - stopPropagation: () => void; - target: FakeElement; -} - -class FakeElement { - public readonly children: FakeElement[] = []; - public className = ""; - public onclick: (() => void) | null = null; - public onmousedown: ((event: FakeMouseEvent) => void) | null = null; - public parentElement: FakeElement | null = null; - public scrollTop = 0; - public shadowRoot: FakeElement | null = null; - public readonly style: Record = {}; - public textContent = ""; - public title = ""; - public type = ""; - private readonly attributes = new Map(); - private rect: FakeRect = { height: 0, left: 0, top: 0, width: 0 }; - - public constructor(private readonly tagName = "div") {} - - public append(...children: FakeElement[]): void { - children.forEach((child) => this.appendChild(child)); - } - - public appendChild(child: FakeElement): FakeElement { - child.parentElement = this; - this.children.push(child); - return child; - } - - public attachShadow(): FakeElement { - this.shadowRoot = new FakeElement("shadow-root"); - return this.shadowRoot; - } - - public click(): void { - this.onclick?.(); - } - - public closest(selector: string): FakeElement | null { - if (selector === "button" && this.tagName === "button") {return this;} - return this.parentElement?.closest(selector) ?? null; - } - - public getBoundingClientRect(): DOMRect { - const width = this.rect.width; - const height = this.rect.height; - const left = parsePixels(this.style.left) ?? this.getRightAnchoredLeft(width) ?? this.rect.left; - const top = parsePixels(this.style.top) ?? this.getBottomAnchoredTop(height) ?? this.rect.top; - return { - bottom: top + height, - height, - left, - right: left + width, - top, - width, - x: left, - y: top, - toJSON: () => ({}), - }; - } - - public getText(): string { - return `${this.textContent}${this.children.map((child) => child.getText()).join("")}`; - } - - public mouseDown(event: FakeMouseEvent): void { - this.onmousedown?.(event); - } - - public querySelector(selector: string): T | null { - const match = this.findByClass(selector.startsWith(".") ? selector.slice(1) : selector); - return match as T | null; - } - - public replaceChildren(...children: FakeElement[]): void { - this.children.length = 0; - this.append(...children); - } - - public setAttribute(name: string, value: string): void { - this.attributes.set(name, value); - } - - public setRect(rect: FakeRect): void { - this.rect = rect; - } - - private findByClass(className: string): FakeElement | null { - if (this.className.split(/\s+/).includes(className)) {return this;} - for (const child of this.children) { - const match = child.findByClass(className); - if (match) {return match;} - } - return null; - } - - private getBottomAnchoredTop(height: number): number | null { - const bottom = parsePixels(this.style.bottom); - return bottom === null ? null : fakeWindow.innerHeight - bottom - height; - } - - private getRightAnchoredLeft(width: number): number | null { - const right = parsePixels(this.style.right); - return right === null ? null : fakeWindow.innerWidth - right - width; - } + createFakeEvent, + fakeDocument, + type FakeElement, + fakeWindow, + installOverlayBrowserGlobals, +} from "./support/fake_overlay_dom"; + +interface OverlayInstance { + setEnabled(enabled: boolean): void; } -class FakeDocument { - public readonly body = new FakeElement("body"); - - public createElement(tagName: string): FakeElement { - return new FakeElement(tagName); - } - - public reset(): void { - this.body.replaceChildren(); - } -} - -class FakeWindow { - public innerHeight = 700; - public innerWidth = 1000; - private animationFrameId = 1; - private readonly animationFrames = new Map void>(); - private readonly listeners = new Map void>>(); - - public addEventListener(type: string, listener: unknown): void { - if (typeof listener !== "function") {return;} - const listeners = this.listeners.get(type) ?? new Set<(event: unknown) => void>(); - listeners.add(listener as (event: unknown) => void); - this.listeners.set(type, listeners); - } - - public dispatch(type: string, event: unknown): void { - this.listeners.get(type)?.forEach((listener) => listener(event)); - } - - public flushAnimationFrames(): void { - const callbacks = Array.from(this.animationFrames.values()); - this.animationFrames.clear(); - callbacks.forEach((callback) => callback()); - } - - public queueAnimationFrame(callback: () => void): number { - const id = this.animationFrameId++; - this.animationFrames.set(id, callback); - return id; - } +type OverlayConstructor = new ( + tracker: ToolActivityTracker, + followUpQueue: FollowUpQueue +) => OverlayInstance; - public reset(): void { - this.innerHeight = 700; - this.innerWidth = 1000; - this.animationFrames.clear(); - this.listeners.clear(); - } +interface OverlayHarness { + host: FakeElement; + panel: FakeElement; + queue: FollowUpQueue; + stack: FakeElement; + tracker: ToolActivityTracker; } -const fakeDocument = new FakeDocument(); -const fakeWindow = new FakeWindow(); let scheduledTimeoutCount = 0; async function main(): Promise { @@ -195,6 +42,12 @@ async function main(): Promise { runTest("dragging moves the activity stack without losing viewport access", () => { testUnifiedBoundedDragging(ToolActivityOverlay); }); + runTest("compact work panel stays collapsed through new tool activity", () => { + testCompactPanelBehavior(ToolActivityOverlay); + }); + runTest("tool updates preserve the follow-up draft and input focus", () => { + testStableFollowUpInput(ToolActivityOverlay); + }); } function testDetailedHistoryBlock(Overlay: OverlayConstructor): void { @@ -224,8 +77,8 @@ function testDetailedHistoryBlock(Overlay: OverlayConstructor): void { settleTurn(harness.tracker, currentKey); assertEqual(scheduledTimeoutCount, 0, "successful activity scheduled automatic collapse"); - assertEqual(harness.panel.className, "panel", "successful activity still collapsed automatically"); - assert(harness.stack.querySelector(".history-panel"), "completion hid the history block"); + assertEqual(harness.host.className, "work-panel-expanded", "successful activity collapsed automatically"); + assertEqual(historyPanel.style.display, "flex", "completion hid the history block"); } function testNewTurnUpdatesHistory(Overlay: OverlayConstructor): void { @@ -244,9 +97,9 @@ 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)); + header.mouseDown(createFakeEvent(header, 620, 420)); + fakeWindow.dispatch("mousemove", createFakeEvent(header, -1000, -1000)); + fakeWindow.dispatch("mouseup", createFakeEvent(header, -1000, -1000)); assertEqual(harness.host.style.left, "8px", "drag escaped the left viewport edge"); assertEqual(harness.host.getBoundingClientRect().top, 8, "drag escaped the top viewport edge"); @@ -254,7 +107,7 @@ function testUnifiedBoundedDragging(Overlay: OverlayConstructor): void { 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"); + assertEqual(fakeDocument.body.children.length, 1, "history, current activity, and follow-up used separate hosts"); fakeWindow.innerHeight = 400; fakeWindow.innerWidth = 500; @@ -282,23 +135,58 @@ function testCaptureSourceBadges(Overlay: OverlayConstructor): void { assertEqual(historyBadge.getText(), "DOM", "historical DOM source badge had the wrong label"); } -function createHarness(Overlay: OverlayConstructor): { - host: FakeElement; - panel: FakeElement; - stack: FakeElement; - tracker: ToolActivityTracker; -} { +function testCompactPanelBehavior(Overlay: OverlayConstructor): void { + const harness = createHarness(Overlay, false); + assertEqual(harness.host.style.display, "block", "enabled work panel launcher was hidden"); + captureTurn(harness.tracker, "turn-1", "read_file"); + assertEqual(harness.host.className, "", "new tool activity forced the work panel open"); + const launcher = getRequired(harness.host.shadowRoot!, ".launcher"); + assertIncludes(launcher.getText(), "Captured", "launcher omitted current tool status"); + harness.host.setRect({ height: 42, left: 600, top: 638, width: 280 }); + launcher.mouseDown(createFakeEvent(launcher, 620, 650)); + fakeWindow.dispatch("mousemove", createFakeEvent(launcher, 420, 450)); + fakeWindow.dispatch("mouseup", createFakeEvent(launcher, 420, 450)); + launcher.click(); + assertEqual(harness.host.className, "", "dragging the compact launcher opened the panel"); + + launcher.click(); + assertEqual(harness.host.className, "work-panel-expanded", "launcher did not expand the shared panel"); + assertEqual(fakeDocument.activeElement, getRequired(harness.host.shadowRoot!, "textarea"), "expanded panel did not focus follow-up input"); + assertEqual(fakeDocument.body.children.length, 1, "tool activity and follow-up used separate overlay hosts"); + getRequired(harness.panel, ".collapse").click(); + assertEqual(harness.host.className, "", "shared panel did not collapse to its launcher"); +} + +function testStableFollowUpInput(Overlay: OverlayConstructor): void { + const harness = createHarness(Overlay); + const textarea = getRequired(harness.host.shadowRoot!, "textarea"); + textarea.value = "keep this draft"; + textarea.focus(); + const requestKey = captureTurn(harness.tracker, "turn-1", "execute_command"); + harness.tracker.updateStatus({ requestKey }, "executing"); + harness.queue.confirm("send after this turn"); + + assertEqual(getRequired(harness.host.shadowRoot!, "textarea"), textarea, "tool update replaced the follow-up input"); + assertEqual(textarea.value, "keep this draft", "tool update cleared the unfinished follow-up draft"); + assertEqual(fakeDocument.activeElement, textarea, "tool update moved focus away from follow-up input"); + assertIncludes(harness.panel.getText(), "send after this turn", "confirmed follow-up was not shown in the shared panel"); +} + +function createHarness(Overlay: OverlayConstructor, expand = true): OverlayHarness { fakeDocument.reset(); fakeWindow.reset(); scheduledTimeoutCount = 0; const tracker = new ToolActivityTracker(); - new Overlay(tracker); + const queue = new FollowUpQueue(); + const overlay = new Overlay(tracker, queue); const host = fakeDocument.body.children.at(-1); const stack = host?.shadowRoot?.querySelector(".overlay-stack"); const panel = stack?.querySelector(".panel"); assert(host && stack && panel, "tool activity overlay was not created"); host.setRect({ height: 250, left: 600, top: 400, width: 380 }); - return { host, panel, stack, tracker }; + overlay.setEnabled(true); + if (expand) {getRequired(host.shadowRoot!, ".launcher").click();} + return { host, panel, queue, stack, tracker }; } function captureTurn( @@ -322,17 +210,6 @@ function settleTurn(tracker: ToolActivityTracker, requestKey: string): void { tracker.updateDelivery([requestKey], "delivered"); } -function createMouseEvent(clientX: number, clientY: number, target: FakeElement): FakeMouseEvent { - return { - button: 0, - clientX, - clientY, - preventDefault: () => undefined, - stopPropagation: () => undefined, - target, - }; -} - function getRequired(root: FakeElement, selector: string): FakeElement { const element = root.querySelector(selector); assert(element, `missing element ${selector}`); @@ -340,13 +217,7 @@ function getRequired(root: FakeElement, selector: string): FakeElement { } function installBrowserGlobals(): void { - Object.defineProperty(globalThis, "document", { configurable: true, value: fakeDocument }); - Object.defineProperty(globalThis, "navigator", { configurable: true, value: { language: "en-US" } }); - Object.defineProperty(globalThis, "window", { configurable: true, value: fakeWindow }); - Object.defineProperty(globalThis, "requestAnimationFrame", { - configurable: true, - value: (callback: () => void) => fakeWindow.queueAnimationFrame(callback), - }); + installOverlayBrowserGlobals(); Object.defineProperty(globalThis, "setTimeout", { configurable: true, value: () => { @@ -359,12 +230,6 @@ function installBrowserGlobals(): void { Object.defineProperty(globalThis, "clearInterval", { configurable: true, value: () => undefined }); } -function parsePixels(value: string | undefined): number | null { - if (!value || value === "auto") {return null;} - const parsed = Number.parseFloat(value); - return Number.isFinite(parsed) ? parsed : null; -} - function runTest(name: string, test: () => void): void { try { test(); From b93b1fbbbfe5c1dafb33e2ddec3f512342d8b5bf Mon Sep 17 00:00:00 2001 From: TW Date: Fri, 4 Sep 2026 10:54:50 +0800 Subject: [PATCH 4/6] fix: prevent bridge page observer feedback loops - Exclude bridge routes from target-page content scripts in both execution worlds. - Skip redundant work panel renders when its enabled state has not changed. - Cover bridge exclusions and idempotent panel enabling with regression tests. --- bridge-browser/manifest.json | 8 +++ .../src/content/tool_activity_overlay.ts | 4 +- bridge-browser/test/manifest.test.ts | 53 +++++++++++++++++++ .../test/tool_activity_overlay.test.ts | 19 ++++++- 4 files changed, 81 insertions(+), 3 deletions(-) create mode 100644 bridge-browser/test/manifest.test.ts diff --git a/bridge-browser/manifest.json b/bridge-browser/manifest.json index 5ff365d..8e8073a 100644 --- a/bridge-browser/manifest.json +++ b/bridge-browser/manifest.json @@ -76,6 +76,10 @@ "matches": [ "" ], + "exclude_matches": [ + "http://127.0.0.1/bridge*", + "http://localhost/bridge*" + ], "js": ["public/generated/network_capture_main.js"], "run_at": "document_start", "world": "MAIN" @@ -84,6 +88,10 @@ "matches": [ "" ], + "exclude_matches": [ + "http://127.0.0.1/bridge*", + "http://localhost/bridge*" + ], "js": ["src/content/main.ts"] } ] diff --git a/bridge-browser/src/content/tool_activity_overlay.ts b/bridge-browser/src/content/tool_activity_overlay.ts index ff198c3..2767d08 100644 --- a/bridge-browser/src/content/tool_activity_overlay.ts +++ b/bridge-browser/src/content/tool_activity_overlay.ts @@ -70,6 +70,7 @@ export class ToolActivityOverlay { } public setEnabled(enabled: boolean): void { + if (this.enabled === enabled) {return;} this.enabled = enabled; if (!enabled) { this.expanded = false; @@ -158,8 +159,7 @@ export class ToolActivityOverlay { } private createCurrentHeader( - entry: ToolActivityTurnEntry | undefined, - historyCount: number + entry: ToolActivityTurnEntry | undefined, historyCount: number ): HTMLElement { const header = document.createElement("div"); header.className = "header drag-header"; diff --git a/bridge-browser/test/manifest.test.ts b/bridge-browser/test/manifest.test.ts new file mode 100644 index 0000000..ffed166 --- /dev/null +++ b/bridge-browser/test/manifest.test.ts @@ -0,0 +1,53 @@ +import manifest from "../manifest.json"; + +interface ContentScriptEntry { + exclude_matches?: string[]; + js: string[]; +} + +const BRIDGE_PAGE_EXCLUSIONS = [ + "http://127.0.0.1/bridge*", + "http://localhost/bridge*", +]; + +const TARGET_PAGE_SCRIPTS = [ + "public/generated/network_capture_main.js", + "src/content/main.ts", +]; + +function main(): void { + const contentScripts = manifest.content_scripts as ContentScriptEntry[]; + TARGET_PAGE_SCRIPTS.forEach((scriptPath) => { + runTest(`${scriptPath} excludes local bridge pages`, () => { + const entry = contentScripts.find((candidate) => candidate.js.includes(scriptPath)); + assert(entry, `missing content script entry for ${scriptPath}`); + assertEqual( + JSON.stringify(entry.exclude_matches), + JSON.stringify(BRIDGE_PAGE_EXCLUSIONS), + `${scriptPath} bridge exclusions changed` + ); + }); + }); +} + +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 index aea0918..923c712 100644 --- a/bridge-browser/test/tool_activity_overlay.test.ts +++ b/bridge-browser/test/tool_activity_overlay.test.ts @@ -19,6 +19,7 @@ type OverlayConstructor = new ( interface OverlayHarness { host: FakeElement; + overlay: OverlayInstance; panel: FakeElement; queue: FollowUpQueue; stack: FakeElement; @@ -48,6 +49,9 @@ async function main(): Promise { runTest("tool updates preserve the follow-up draft and input focus", () => { testStableFollowUpInput(ToolActivityOverlay); }); + runTest("repeated enable updates do not rerender the work panel", () => { + testIdempotentEnabledState(ToolActivityOverlay); + }); } function testDetailedHistoryBlock(Overlay: OverlayConstructor): void { @@ -172,6 +176,19 @@ function testStableFollowUpInput(Overlay: OverlayConstructor): void { assertIncludes(harness.panel.getText(), "send after this turn", "confirmed follow-up was not shown in the shared panel"); } +function testIdempotentEnabledState(Overlay: OverlayConstructor): void { + const harness = createHarness(Overlay, false); + const header = getRequired(harness.panel, ".header"); + + harness.overlay.setEnabled(true); + + assertEqual( + getRequired(harness.panel, ".header"), + header, + "repeated enabled state replaced the panel DOM" + ); +} + function createHarness(Overlay: OverlayConstructor, expand = true): OverlayHarness { fakeDocument.reset(); fakeWindow.reset(); @@ -186,7 +203,7 @@ function createHarness(Overlay: OverlayConstructor, expand = true): OverlayHarne host.setRect({ height: 250, left: 600, top: 400, width: 380 }); overlay.setEnabled(true); if (expand) {getRequired(host.shadowRoot!, ".launcher").click();} - return { host, panel, queue, stack, tracker }; + return { host, overlay, panel, queue, stack, tracker }; } function captureTurn( From 88241c42bb2a52836f3a58c8237078543c4ed7f2 Mon Sep 17 00:00:00 2001 From: TW Date: Fri, 4 Sep 2026 11:01:58 +0800 Subject: [PATCH 5/6] fix: stabilize work panel header actions - Keep history, minimize, and close controls in fixed-width header slots. - Disable close until the current tool turn delivery settles instead of removing it. - Cover action ordering and close availability transitions. --- .../src/content/tool_activity_overlay.ts | 12 ++++---- .../content/tool_activity_overlay_styles.ts | 9 +++--- .../test/tool_activity_overlay.test.ts | 29 +++++++++++++++++++ 3 files changed, 40 insertions(+), 10 deletions(-) diff --git a/bridge-browser/src/content/tool_activity_overlay.ts b/bridge-browser/src/content/tool_activity_overlay.ts index 2767d08..2ac7ae3 100644 --- a/bridge-browser/src/content/tool_activity_overlay.ts +++ b/bridge-browser/src/content/tool_activity_overlay.ts @@ -181,10 +181,8 @@ export class ToolActivityOverlay { const actions = document.createElement("div"); actions.className = "actions"; - actions.append(this.createHistoryButton(historyCount), this.createCollapseButton()); - if (entry && isTurnSettled(entry.turn)) { - actions.appendChild(this.createCloseButton(entry.turn.id)); - } + actions.append(this.createHistoryButton(historyCount), this.createCollapseButton(), + this.createCloseButton(entry)); header.append(identity, actions); return header; } @@ -219,9 +217,11 @@ export class ToolActivityOverlay { return button; } - private createCloseButton(turnId: string): HTMLButtonElement { + private createCloseButton(entry: ToolActivityTurnEntry | undefined): HTMLButtonElement { const button = createIconButton("×", t("activity_close"), "icon-button close"); - button.onclick = () => { + const turnId = entry && isTurnSettled(entry.turn) ? entry.turn.id : null; + button.disabled = turnId === null; + button.onclick = turnId === null ? null : () => { this.dismissedTurnId = turnId; this.render(this.latestSnapshot); }; diff --git a/bridge-browser/src/content/tool_activity_overlay_styles.ts b/bridge-browser/src/content/tool_activity_overlay_styles.ts index 5071431..0177095 100644 --- a/bridge-browser/src/content/tool_activity_overlay_styles.ts +++ b/bridge-browser/src/content/tool_activity_overlay_styles.ts @@ -51,14 +51,15 @@ export const TOOL_ACTIVITY_STYLE_TEXT = ` .mark.error { background: #b91c1c; } .title, .history-title { overflow: hidden; color: #f9fafb; font-weight: 650; text-overflow: ellipsis; white-space: nowrap; } .summary { overflow: hidden; margin-top: 1px; color: #aeb5c2; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; } - .actions { flex: 0 0 auto; display: flex; gap: 3px; } + .actions { width: 124px; 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 { width: 68px; padding: 0 7px; font-size: 10px; font-variant-numeric: tabular-nums; 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; } + .icon-button:not(:disabled):hover, .history-button:hover { color: #fff; background: rgba(255, 255, 255, .1); } + .icon-button.close:not(:disabled):hover { background: #8f1d1d; } + .icon-button:disabled { opacity: .35; cursor: default; } .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; } diff --git a/bridge-browser/test/tool_activity_overlay.test.ts b/bridge-browser/test/tool_activity_overlay.test.ts index 923c712..2584468 100644 --- a/bridge-browser/test/tool_activity_overlay.test.ts +++ b/bridge-browser/test/tool_activity_overlay.test.ts @@ -52,6 +52,9 @@ async function main(): Promise { runTest("repeated enable updates do not rerender the work panel", () => { testIdempotentEnabledState(ToolActivityOverlay); }); + runTest("header action positions stay stable while close availability changes", () => { + testStableHeaderActions(ToolActivityOverlay); + }); } function testDetailedHistoryBlock(Overlay: OverlayConstructor): void { @@ -189,6 +192,32 @@ function testIdempotentEnabledState(Overlay: OverlayConstructor): void { ); } +function testStableHeaderActions(Overlay: OverlayConstructor): void { + const harness = createHarness(Overlay, false); + assertHeaderActions(harness.panel, true); + + const requestKey = captureTurn(harness.tracker, "turn-1", "read_file"); + harness.tracker.updateStatus({ requestKey }, "executing"); + assertHeaderActions(harness.panel, true); + + settleTurn(harness.tracker, requestKey); + assertHeaderActions(harness.panel, false); + getRequired(harness.panel, ".close").click(); + assertHeaderActions(harness.panel, true); + + captureTurn(harness.tracker, "turn-2", "write_file"); + assertHeaderActions(harness.panel, true); +} + +function assertHeaderActions(panel: FakeElement, closeDisabled: boolean): void { + const actions = getRequired(panel, ".actions"); + assertEqual(actions.children.length, 3, "header action slot count changed"); + assert(actions.children[0]?.className.includes("history-button"), "history action moved"); + assert(actions.children[1]?.className.includes("collapse"), "collapse action moved"); + assert(actions.children[2]?.className.includes("close"), "close action moved"); + assertEqual(actions.children[2]?.disabled, closeDisabled, "close availability was incorrect"); +} + function createHarness(Overlay: OverlayConstructor, expand = true): OverlayHarness { fakeDocument.reset(); fakeWindow.reset(); From e978762cb0d39eb97389a1d085e5caaad957d684 Mon Sep 17 00:00:00 2001 From: TW Date: Fri, 4 Sep 2026 11:14:58 +0800 Subject: [PATCH 6/6] feat: improve work panel history controls - Display tool activity history in chronological order. - Archive dismissed current activity and add a clear-history action. - Preserve the active turn when historical activity is cleared. --- bridge-browser/src/content/tool_activity.ts | 11 +++++ .../src/content/tool_activity_overlay.ts | 47 +++++++++---------- .../content/tool_activity_overlay_styles.ts | 9 ++-- bridge-browser/src/modules/i18n.ts | 2 + bridge-browser/test/tool_activity.test.ts | 25 ++++++++++ .../test/tool_activity_overlay.test.ts | 43 ++++++++++++++++- 6 files changed, 109 insertions(+), 28 deletions(-) diff --git a/bridge-browser/src/content/tool_activity.ts b/bridge-browser/src/content/tool_activity.ts index ca740be..e6eb727 100644 --- a/bridge-browser/src/content/tool_activity.ts +++ b/bridge-browser/src/content/tool_activity.ts @@ -159,6 +159,17 @@ export class ToolActivityTracker { this.emit(); } + public clearHistory(preservedTurnId?: string): void { + let changed = false; + for (const [turnId, turn] of this.turns) { + if (turnId === preservedTurnId) {continue;} + turn.requestKeys.forEach((requestKey) => this.items.delete(requestKey)); + this.turns.delete(turnId); + changed = true; + } + if (changed) {this.emit();} + } + public reset(): void { if (this.items.size === 0 && this.turns.size === 0) {return;} this.items.clear(); diff --git a/bridge-browser/src/content/tool_activity_overlay.ts b/bridge-browser/src/content/tool_activity_overlay.ts index 2ac7ae3..0b9d7f3 100644 --- a/bridge-browser/src/content/tool_activity_overlay.ts +++ b/bridge-browser/src/content/tool_activity_overlay.ts @@ -47,7 +47,7 @@ export class ToolActivityOverlay { private readonly panel: HTMLDivElement; private ticker: ReturnType | null = null; - public constructor(tracker: ToolActivityTracker, followUpQueue: FollowUpQueue) { + public constructor(private readonly tracker: ToolActivityTracker, followUpQueue: FollowUpQueue) { const view = createOverlayView(); this.host = view.host; this.launcher = view.launcher; @@ -61,21 +61,16 @@ export class ToolActivityOverlay { this.dragController = new FloatingPanelDragController(this.host); this.dragController.bindHandle(this.launcher, true); this.bindLauncher(); - this.followUpComposer = new FollowUpComposer( - followUpQueue, - (state) => this.handleFollowUpState(state) - ); + this.followUpComposer = new FollowUpComposer(followUpQueue, + (state) => this.handleFollowUpState(state)); this.panel.appendChild(this.followUpComposer.element); - tracker.subscribe((snapshot) => this.render(snapshot)); + this.tracker.subscribe((snapshot) => this.render(snapshot)); } public setEnabled(enabled: boolean): void { if (this.enabled === enabled) {return;} this.enabled = enabled; - if (!enabled) { - this.expanded = false; - this.historyVisible = false; - } + if (!enabled) {this.expanded = false; this.historyVisible = false;} this.render(this.latestSnapshot); } @@ -104,17 +99,14 @@ export class ToolActivityOverlay { const currentScrollTop = isSameTurn ? this.getCurrentScrollTop() : 0; const historyScrollTop = this.getHistoryScrollTop(); if (latestEntry && !isSameTurn) {this.startTurn(latestEntry.turn.id);} - if (!latestEntry) { - this.currentTurnId = null; - this.dismissedTurnId = null; - } + if (!latestEntry) {this.currentTurnId = null; this.dismissedTurnId = null;} const currentEntry = latestEntry && latestEntry.turn.id !== this.dismissedTurnId ? latestEntry : undefined; - const historyEntries = latestEntry ? entries.slice(0, -1).reverse() : []; + const historyEntries = currentEntry ? entries.slice(0, -1) : entries; this.renderCurrent(currentEntry, historyEntries.length); - this.renderHistory(historyEntries); + this.renderHistory(historyEntries, currentEntry?.turn.id); this.restoreCurrentScrollTop(currentScrollTop); this.restoreHistoryScrollTop(historyScrollTop); this.syncVisibility(Boolean(currentEntry)); @@ -131,7 +123,7 @@ export class ToolActivityOverlay { this.updateLauncher(entry); } - private renderHistory(entries: ToolActivityTurnEntry[]): void { + private renderHistory(entries: ToolActivityTurnEntry[], currentTurnId?: string): void { const shouldShow = this.expanded && this.historyVisible; this.historyPanel.style.display = shouldShow ? "flex" : "none"; if (!shouldShow) {return;} @@ -143,14 +135,16 @@ export class ToolActivityOverlay { const title = document.createElement("div"); title.className = "history-title"; title.textContent = `${t("activity_history")} · ${entries.length}`; - header.append(title, this.createHistoryCloseButton()); + const actions = document.createElement("div"); + actions.className = "history-actions"; + actions.append(this.createHistoryClearButton(entries.length, currentTurnId), this.createHistoryCloseButton()); + header.append(title, actions); 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"); + empty.className = "history-empty"; empty.textContent = t("activity_no_history"); list.appendChild(empty); } else { entries.forEach((entry) => list.appendChild(createHistoryTurn(entry))); @@ -204,10 +198,15 @@ export class ToolActivityOverlay { private createHistoryCloseButton(): HTMLButtonElement { const button = createIconButton("×", t("activity_hide_history"), "icon-button close"); - button.onclick = () => { - this.historyVisible = false; - this.render(this.latestSnapshot); - }; + button.onclick = () => {this.historyVisible = false; this.render(this.latestSnapshot);}; + return button; + } + + private createHistoryClearButton(historyCount: number, currentTurnId?: string): HTMLButtonElement { + const button = createIconButton(t("activity_clear"), t("activity_clear_history"), + "history-clear-button"); + button.disabled = historyCount === 0; + button.onclick = button.disabled ? null : () => this.tracker.clearHistory(currentTurnId); return button; } diff --git a/bridge-browser/src/content/tool_activity_overlay_styles.ts b/bridge-browser/src/content/tool_activity_overlay_styles.ts index 0177095..713b7bc 100644 --- a/bridge-browser/src/content/tool_activity_overlay_styles.ts +++ b/bridge-browser/src/content/tool_activity_overlay_styles.ts @@ -39,6 +39,7 @@ export const TOOL_ACTIVITY_STYLE_TEXT = ` 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; } + .history-actions { flex: 0 0 auto; display: flex; align-items: center; gap: 3px; } .drag-header { cursor: move; } .identity { min-width: 0; flex: 1; display: flex; align-items: center; gap: 10px; } .heading { min-width: 0; } @@ -52,14 +53,16 @@ export const TOOL_ACTIVITY_STYLE_TEXT = ` .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 { width: 124px; flex: 0 0 auto; display: flex; gap: 3px; } - .icon-button, .history-button { height: 25px; padding: 0; border: 0; border-radius: 6px; color: #c8ced8; + .icon-button, .history-button, .history-clear-button { height: 25px; padding: 0; border: 0; border-radius: 6px; color: #c8ced8; background: transparent; cursor: pointer; } .icon-button { width: 25px; } .history-button { width: 68px; padding: 0 7px; font-size: 10px; font-variant-numeric: tabular-nums; white-space: nowrap; } + .history-clear-button { padding: 0 7px; font-size: 10px; white-space: nowrap; } .history-button.active { color: #dbeafe; background: rgba(37, 99, 235, .22); } - .icon-button:not(:disabled):hover, .history-button:hover { color: #fff; background: rgba(255, 255, 255, .1); } + .icon-button:not(:disabled):hover, .history-button:hover, .history-clear-button:not(:disabled):hover { + color: #fff; background: rgba(255, 255, 255, .1); } .icon-button.close:not(:disabled):hover { background: #8f1d1d; } - .icon-button:disabled { opacity: .35; cursor: default; } + .icon-button:disabled, .history-clear-button:disabled { opacity: .35; cursor: default; } .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; } diff --git a/bridge-browser/src/modules/i18n.ts b/bridge-browser/src/modules/i18n.ts index c5b13ba..74a805c 100644 --- a/bridge-browser/src/modules/i18n.ts +++ b/bridge-browser/src/modules/i18n.ts @@ -77,6 +77,8 @@ const I18N_MESSAGES: Record = { activity_minimize: { en: "Minimize", zh: "收起" }, activity_expand: { en: "Expand", zh: "展开" }, activity_close: { en: "Close", zh: "关闭" }, + activity_clear: { en: "Clear", zh: "清除" }, + activity_clear_history: { en: "Clear history", zh: "清除历史记录" }, activity_history: { en: "History", zh: "历史" }, activity_show_history: { en: "Show history", zh: "显示历史" }, activity_hide_history: { en: "Hide history", zh: "隐藏历史" }, diff --git a/bridge-browser/test/tool_activity.test.ts b/bridge-browser/test/tool_activity.test.ts index 40660be..c4cf0f6 100644 --- a/bridge-browser/test/tool_activity.test.ts +++ b/bridge-browser/test/tool_activity.test.ts @@ -10,10 +10,35 @@ import { function main(): void { runTest("activity history retains only the latest eight turns", testRetainsLatestEightTurns); + runTest("clearing history preserves the selected current turn", testClearHistory); runTest("approval UI stays above tool activity", testApprovalLayerPriority); runTest("floating activity stays inside every viewport edge", testFloatingPanelBounds); } +function testClearHistory(): void { + const tracker = new ToolActivityTracker(); + let snapshot: ToolActivitySnapshot = { items: [], turns: [] }; + tracker.subscribe((value) => {snapshot = value;}); + + for (let index = 1; index <= 3; index += 1) { + tracker.capture({ + identity: { requestKey: `request-${index}` }, + payload: { name: `tool-${index}` }, + source: "dom", + turnId: `turn-${index}`, + }); + } + + tracker.clearHistory("turn-3"); + assertEqual(snapshot.turns.length, 1, "historical turns were not cleared"); + assertEqual(snapshot.items.length, 1, "historical activity items were not cleared"); + assertEqual(snapshot.turns[0]?.id, "turn-3", "current turn was cleared with history"); + + tracker.clearHistory(); + assertEqual(snapshot.turns.length, 0, "archived current turn was not clearable"); + assertEqual(snapshot.items.length, 0, "archived current activity was not clearable"); +} + function testRetainsLatestEightTurns(): void { const tracker = new ToolActivityTracker(); let snapshot: ToolActivitySnapshot = { items: [], turns: [] }; diff --git a/bridge-browser/test/tool_activity_overlay.test.ts b/bridge-browser/test/tool_activity_overlay.test.ts index 2584468..4da316f 100644 --- a/bridge-browser/test/tool_activity_overlay.test.ts +++ b/bridge-browser/test/tool_activity_overlay.test.ts @@ -34,7 +34,7 @@ async function main(): Promise { 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", () => { + runTest("prior turns enter detailed history in chronological order", () => { testNewTurnUpdatesHistory(ToolActivityOverlay); }); runTest("current and historical tools show their capture source", () => { @@ -55,6 +55,9 @@ async function main(): Promise { runTest("header action positions stay stable while close availability changes", () => { testStableHeaderActions(ToolActivityOverlay); }); + runTest("closing archives current activity and history can be cleared", () => { + testArchiveAndClearHistory(ToolActivityOverlay); + }); } function testDetailedHistoryBlock(Overlay: OverlayConstructor): void { @@ -98,6 +101,12 @@ function testNewTurnUpdatesHistory(Overlay: OverlayConstructor): void { 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"); + + captureTurn(harness.tracker, "turn-3", "execute_command"); + const historyText = getRequired(harness.stack, ".history-list").getText(); + assertBefore(historyText, "read_file", "write_file", "history was not ordered oldest first"); + assertIncludes(harness.panel.getText(), "execute_command", "latest tool call was not shown as current"); + assertIncludes(getRequired(harness.panel, ".history-button").getText(), "(2)", "history count did not update"); } function testUnifiedBoundedDragging(Overlay: OverlayConstructor): void { @@ -209,6 +218,30 @@ function testStableHeaderActions(Overlay: OverlayConstructor): void { assertHeaderActions(harness.panel, true); } +function testArchiveAndClearHistory(Overlay: OverlayConstructor): void { + const harness = createHarness(Overlay); + const firstKey = captureTurn(harness.tracker, "turn-1", "read_file"); + settleTurn(harness.tracker, firstKey); + getRequired(harness.panel, ".close").click(); + + assertIncludes(getRequired(harness.panel, ".history-button").getText(), "(1)", + "closing current activity did not increase history count"); + getRequired(harness.panel, ".history-button").click(); + assertIncludes(getRequired(harness.stack, ".history-list").getText(), "read_file", + "closed current activity was not archived"); + getRequired(harness.stack, ".history-clear-button").click(); + assertIncludes(getRequired(harness.stack, ".history-list").getText(), "No previous", + "clear history did not remove archived activity"); + + const secondKey = captureTurn(harness.tracker, "turn-2", "write_file"); + settleTurn(harness.tracker, secondKey); + captureTurn(harness.tracker, "turn-3", "execute_command"); + getRequired(harness.stack, ".history-clear-button").click(); + assertIncludes(harness.panel.getText(), "execute_command", "clearing history removed current activity"); + assertIncludes(getRequired(harness.stack, ".history-list").getText(), "No previous", + "clear history did not remove prior activity"); +} + function assertHeaderActions(panel: FakeElement, closeDisabled: boolean): void { const actions = getRequired(panel, ".actions"); assertEqual(actions.children.length, 3, "header action slot count changed"); @@ -302,4 +335,12 @@ function assertIncludes(actual: string, expected: string, message: string): void } } +function assertBefore(actual: string, first: string, second: string, message: string): void { + const firstIndex = actual.indexOf(first); + const secondIndex = actual.indexOf(second); + if (firstIndex < 0 || secondIndex < 0 || firstIndex >= secondIndex) { + throw new Error(`${message}: expected '${first}' before '${second}' in '${actual}'`); + } +} + void main();