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/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/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 new file mode 100644 index 0000000..5314db6 --- /dev/null +++ b/bridge-browser/src/content/follow_up_overlay.ts @@ -0,0 +1,168 @@ +import { t } from "../modules/i18n"; +import { type FollowUpItem, type FollowUpQueue, type FollowUpQueueSnapshot } from "./follow_up_queue"; + +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; + public readonly element: HTMLElement; + private readonly onStateChange: FollowUpStateListener; + private readonly queueElement: HTMLDivElement; + private readonly queue: FollowUpQueue; + private readonly summary: HTMLDivElement; + private readonly textarea: HTMLTextAreaElement; + + public constructor(queue: FollowUpQueue, onStateChange: FollowUpStateListener) { + this.queue = queue; + this.onStateChange = onStateChange; + const view = createComposerView(); + this.element = view.element; + this.queueElement = view.queueElement; + this.summary = view.summary; + this.textarea = view.textarea; + this.confirmButton = view.confirmButton; + this.bindComposer(); + queue.subscribe((snapshot) => this.renderQueue(snapshot)); + } + + public focusInput(): void { + this.textarea.focus(); + } + + 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 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.element.querySelector(".follow-up-count"); + if (count) { + count.textContent = String(snapshot.items.length); + count.style.display = snapshot.items.length > 0 ? "inline-flex" : "none"; + } + this.onStateChange({ count: snapshot.items.length, sending }); + } + + private createQueueItem(item: FollowUpItem): HTMLElement { + const row = document.createElement("div"); + row.className = `follow-up-item ${item.status}`; + const text = document.createElement("div"); + text.className = "follow-up-item-text"; + text.textContent = item.text; + const actions = document.createElement("div"); + actions.className = "follow-up-item-actions"; + row.append(text, actions); + + 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 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 syncConfirmButton(): void { + this.confirmButton.disabled = this.textarea.value.trim().length === 0; + } +} + +function createComposerView(): { + confirmButton: HTMLButtonElement; + element: HTMLElement; + queueElement: HTMLDivElement; + summary: HTMLDivElement; + textarea: HTMLTextAreaElement; +} { + const element = document.createElement("section"); + element.className = "follow-up-section"; + const header = document.createElement("div"); + header.className = "follow-up-header"; + const heading = document.createElement("div"); + heading.className = "follow-up-heading"; + const title = document.createElement("div"); + title.className = "follow-up-title"; + title.textContent = t("follow_up_title"); + const summary = document.createElement("div"); + summary.className = "follow-up-summary"; + summary.textContent = t("follow_up_description"); + const count = document.createElement("span"); + count.className = "follow-up-count"; + count.style.display = "none"; + heading.append(title, summary); + header.append(heading, count); + + const queueElement = document.createElement("div"); + 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 = "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 = "follow-up-composer-footer"; + const hint = document.createElement("span"); + hint.className = "follow-up-hint"; + hint.textContent = t("follow_up_shortcut"); + const confirmButton = document.createElement("button"); + confirmButton.type = "button"; + confirmButton.className = "follow-up-confirm"; + confirmButton.textContent = t("follow_up_confirm"); + footer.append(hint, confirmButton); + composer.append(textarea, footer); + 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 new file mode 100644 index 0000000..3d6554a --- /dev/null +++ b/bridge-browser/src/content/follow_up_overlay_styles.ts @@ -0,0 +1,40 @@ +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); } + .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; } + .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; } + .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; } + .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_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..a9544fd --- /dev/null +++ b/bridge-browser/src/content/follow_up_work_controller.ts @@ -0,0 +1,34 @@ +import type { SiteSelectors } from "../modules/config"; +import { CompletionNotifier } from "./completion_notifier"; + +interface FollowUpPanelControl { + setEnabled(enabled: boolean): void; +} + +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", +]; + +/** Keeps the follow-up launcher enabled and detects ordinary response completion. */ +export class FollowUpWorkController { + private readonly completionNotifier: CompletionNotifier; + + public constructor( + private readonly panel: FollowUpPanelControl, + onCompletedWithoutTools: () => void + ) { + this.completionNotifier = new CompletionNotifier({ onCompletedWithoutTools }); + } + + public observe(selectors: SiteSelectors): void { + this.panel.setEnabled(true); + this.completionNotifier.observe(selectors); + } + + public reset(): void { + this.completionNotifier.reset(); + this.panel.setEnabled(false); + } +} diff --git a/bridge-browser/src/content/main.ts b/bridge-browser/src/content/main.ts index 9ba2330..c53b466 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); -new ToolActivityOverlay(toolActivityTracker); -let lastProgressLogTime = 0; -let lastProgressStatus = ""; +const followUpQueue = new FollowUpQueue(); +const workPanel = new ToolActivityOverlay(toolActivityTracker, followUpQueue); +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(workPanel, () => { + 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 2ecfc05..cdaf9e6 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,29 +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(), - hasFileUpload: delivery.uploaded, - }, 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 { @@ -65,10 +63,71 @@ 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, delivery.uploaded); + } 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, + hasFileUpload = false + ): 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(), hasFileUpload }, + 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) { @@ -77,6 +136,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/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 e067204..0b9d7f3 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,139 @@ 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(private readonly 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); - tracker.subscribe((snapshot) => this.render(snapshot)); + this.dragController.bindHandle(this.launcher, true); + this.bindLauncher(); + this.followUpComposer = new FollowUpComposer(followUpQueue, + (state) => this.handleFollowUpState(state)); + this.panel.appendChild(this.followUpComposer.element); + 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;} + 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); - } - - 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 - ); + if (latestEntry && !isSameTurn) {this.startTurn(latestEntry.turn.id);} + if (!latestEntry) {this.currentTurnId = null; this.dismissedTurnId = null;} + + const currentEntry = latestEntry && latestEntry.turn.id !== this.dismissedTurnId + ? latestEntry + : undefined; + const historyEntries = currentEntry ? entries.slice(0, -1) : entries; + this.renderCurrent(currentEntry, historyEntries.length); + this.renderHistory(historyEntries, currentEntry?.turn.id); 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[], currentTurnId?: string): 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}`; + 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"); + 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 +166,21 @@ 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.appendChild(this.createCloseButton(entry.turn.id)); - } + actions.append(this.createHistoryButton(historyCount), this.createCollapseButton(), + this.createCloseButton(entry)); 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,80 +196,63 @@ 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 = createIconButton("×", t("activity_hide_history"), "icon-button close"); + button.onclick = () => {this.historyVisible = false; this.render(this.latestSnapshot);}; + return button; } - private createHistoryCloseButton(): HTMLButtonElement { - const button = document.createElement("button"); - button.type = "button"; - button.className = "icon-button close"; - button.title = t("activity_hide_history"); - button.setAttribute("aria-label", button.title); - button.textContent = "×"; - button.onclick = () => { - this.historyVisible = false; - this.render(this.latestSnapshot); - }; + 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; } - 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 = "×"; - button.onclick = () => { + private createCloseButton(entry: ToolActivityTurnEntry | undefined): HTMLButtonElement { + const button = createIconButton("×", t("activity_close"), "icon-button close"); + const turnId = entry && isTurnSettled(entry.turn) ? entry.turn.id : null; + button.disabled = turnId === null; + button.onclick = turnId === null ? null : () => { 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 +265,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 +305,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 +332,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 +344,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 +369,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..713b7bc 100644 --- a/bridge-browser/src/content/tool_activity_overlay_styles.ts +++ b/bridge-browser/src/content/tool_activity_overlay_styles.ts @@ -1,44 +1,74 @@ 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; } + .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; } .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; } .mark.error { background: #b91c1c; } .title, .history-title { overflow: hidden; color: #f9fafb; font-weight: 650; text-overflow: ellipsis; white-space: nowrap; } .summary { overflow: hidden; margin-top: 1px; color: #aeb5c2; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; } - .actions { flex: 0 0 auto; display: flex; gap: 3px; } - .icon-button, .history-button { height: 25px; padding: 0; border: 0; border-radius: 6px; color: #c8ced8; background: transparent; cursor: pointer; } + .actions { width: 124px; flex: 0 0 auto; display: flex; gap: 3px; } + .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: 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-clear-button { padding: 0 7px; font-size: 10px; white-space: nowrap; } .history-button.active { color: #dbeafe; background: rgba(37, 99, 235, .22); } - .icon-button:hover, .history-button:hover { color: #fff; background: rgba(255, 255, 255, .1); } - .icon-button.close:hover { background: #8f1d1d; } + .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, .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; } .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 +92,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/auto_send.ts b/bridge-browser/src/modules/auto_send.ts index b065e94..7200a80 100644 --- a/bridge-browser/src/modules/auto_send.ts +++ b/bridge-browser/src/modules/auto_send.ts @@ -8,10 +8,18 @@ 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"; -let autoSendTimer: NodeJS.Timeout | null = null; +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; @@ -27,11 +35,7 @@ export interface AutoSendConfig { * @description 如果定时器存在,清除它并输出取消日志。主要用于当监听到用户手动输入或页面有新活动时,打断之前的自动发送操作。 */ export function cancelAutoSend() { - if (autoSendTimer) { - clearTimeout(autoSendTimer); - autoSendTimer = null; - Logger.log("🚫 Auto-send cancelled (New activity detected)", "warn"); - } + activeAutoSend?.cancel(true); } // === 自动发送逻辑 === @@ -51,12 +55,9 @@ export function cancelAutoSend() { export function triggerAutoSend( config: AutoSendConfig, 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 = getAutoSendAttemptLimit(config.hasFileUpload); @@ -77,73 +78,104 @@ 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 = getAutoSendAction(retryCount); - 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(); - // 等待页面完成输入框清空、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); + preserveActiveElement(() => 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 = getAutoSendAction(retryCount); + 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/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 3fdc258..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: "隐藏历史" }, @@ -85,6 +87,30 @@ 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", + 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_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: "用户补充" }, + 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/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 new file mode 100644 index 0000000..a97ac6e --- /dev/null +++ b/bridge-browser/test/auto_send.test.ts @@ -0,0 +1,155 @@ +import type { SiteSelectors } from "../src/modules/config"; + +export {}; + +interface FakeFocusable { + focus: () => void; + shadowRoot?: { activeElement: FakeFocusable | null }; +} + +class FakeInput implements FakeFocusable { + 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; + } + + 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: { + activeElement: FakeFocusable | null; + querySelector: () => null; + querySelectorAll: (selector: string) => FakeInput[]; +} = { + 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"); + + 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"); + + input.innerText = "message"; + const cancelledSend = triggerAutoSend({ autoSend: true, hasFileUpload: false }, 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, hasFileUpload: 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..dcfb4ad --- /dev/null +++ b/bridge-browser/test/follow_up_overlay.test.ts @@ -0,0 +1,202 @@ +import { FollowUpQueue } from "../src/content/follow_up_queue"; + +interface FakeEvent { + ctrlKey: boolean; + isComposing: boolean; + key: string; + metaKey: boolean; + preventDefault: () => void; + stopPropagation: () => void; +} + +class FakeElement { + public readonly children: FakeElement[] = []; + public className = ""; + public disabled = false; + public onclick: (() => void) | null = null; + public placeholder = ""; + public readonly style: Record = {}; + public textContent = ""; + public title = ""; + public type = ""; + public value = ""; + private readonly attributes = new Map(); + private readonly listeners = new Map void>>(); + private readonly tagName: string; + + 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 { + this.children.push(child); + return child; + } + + public click(): void { + if (!this.disabled) {this.onclick?.();} + } + + public dispatch(type: string, event: FakeEvent): void { + this.listeners.get(type)?.forEach((listener) => listener(event)); + } + + public focus(): void { + fakeDocument.activeElement = this; + } + + public getText(): string { + return `${this.textContent}${this.children.map((child) => child.getText()).join("")}`; + } + + 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); + } + + 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 createElement(tagName: string): FakeElement { + return new FakeElement(tagName); + } +} + +const fakeDocument = new FakeDocument(); + +async function main(): Promise { + installBrowserGlobals(); + const { FollowUpComposer } = await import("../src/content/follow_up_overlay"); + runTest("waiting follow-ups remain visible and removable until delivery starts", () => { + 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(queue.beginDelivery().messages.length, 0, "unfinished draft entered delivery"); + + 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(root, textarea); + const delivery = queue.beginDelivery(); + assertEqual(delivery.messages.join("|"), "send this next", "confirmed text changed"); + 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"); + + 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("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"); + }); +} + +function confirmDraft(root: FakeElement, textarea: FakeElement): void { + textarea.dispatch("input", createEvent()); + getRequired(root, ".follow-up-confirm").click(); +} + +function createEvent(): FakeEvent { + return { + ctrlKey: false, + isComposing: false, + key: "", + metaKey: false, + preventDefault: () => undefined, + stopPropagation: () => undefined, + }; +} + +function getRequired(root: FakeElement, selector: string): FakeElement { + const element = root.querySelector(selector); + assert(element, `missing element ${selector}`); + return element; +} + +function installBrowserGlobals(): void { + Object.defineProperty(globalThis, "document", { configurable: true, value: fakeDocument }); + Object.defineProperty(globalThis, "navigator", { configurable: true, value: { language: "en-US" } }); +} + +function 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(); 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/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.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 8e79a76..4da316f 100644 --- a/bridge-browser/test/tool_activity_overlay.test.ts +++ b/bridge-browser/test/tool_activity_overlay.test.ts @@ -1,183 +1,31 @@ +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; + createFakeEvent, + fakeDocument, + type FakeElement, + fakeWindow, + installOverlayBrowserGlobals, +} from "./support/fake_overlay_dom"; + +interface OverlayInstance { + setEnabled(enabled: boolean): void; } -class FakeElement { - public readonly children: FakeElement[] = []; - public className = ""; - public onclick: (() => void) | null = null; - public onmousedown: ((event: FakeMouseEvent) => void) | null = null; - public parentElement: FakeElement | null = null; - public scrollTop = 0; - public shadowRoot: FakeElement | null = null; - public readonly style: Record = {}; - public textContent = ""; - public title = ""; - public type = ""; - private readonly attributes = new Map(); - private rect: FakeRect = { height: 0, left: 0, top: 0, width: 0 }; - - public constructor(private readonly tagName = "div") {} - - public append(...children: FakeElement[]): void { - children.forEach((child) => this.appendChild(child)); - } - - public appendChild(child: FakeElement): FakeElement { - child.parentElement = this; - this.children.push(child); - return child; - } - - public attachShadow(): FakeElement { - this.shadowRoot = new FakeElement("shadow-root"); - return this.shadowRoot; - } - - public click(): void { - this.onclick?.(); - } - - public closest(selector: string): FakeElement | null { - if (selector === "button" && this.tagName === "button") {return this;} - return this.parentElement?.closest(selector) ?? null; - } - - public getBoundingClientRect(): DOMRect { - const width = this.rect.width; - const height = this.rect.height; - const left = parsePixels(this.style.left) ?? this.getRightAnchoredLeft(width) ?? this.rect.left; - const top = parsePixels(this.style.top) ?? this.getBottomAnchoredTop(height) ?? this.rect.top; - return { - bottom: top + height, - height, - left, - right: left + width, - top, - width, - x: left, - y: top, - toJSON: () => ({}), - }; - } - - public getText(): string { - return `${this.textContent}${this.children.map((child) => child.getText()).join("")}`; - } - - public mouseDown(event: FakeMouseEvent): void { - this.onmousedown?.(event); - } - - public querySelector(selector: string): T | null { - const match = this.findByClass(selector.startsWith(".") ? selector.slice(1) : selector); - return match as T | null; - } - - public replaceChildren(...children: FakeElement[]): void { - this.children.length = 0; - this.append(...children); - } - - public setAttribute(name: string, value: string): void { - this.attributes.set(name, value); - } - - public setRect(rect: FakeRect): void { - this.rect = rect; - } - - private findByClass(className: string): FakeElement | null { - if (this.className.split(/\s+/).includes(className)) {return this;} - for (const child of this.children) { - const match = child.findByClass(className); - if (match) {return match;} - } - return null; - } - - private getBottomAnchoredTop(height: number): number | null { - const bottom = parsePixels(this.style.bottom); - return bottom === null ? null : fakeWindow.innerHeight - bottom - height; - } - - private getRightAnchoredLeft(width: number): number | null { - const right = parsePixels(this.style.right); - return right === null ? null : fakeWindow.innerWidth - right - width; - } -} - -class FakeDocument { - public readonly body = new FakeElement("body"); - - public createElement(tagName: string): FakeElement { - return new FakeElement(tagName); - } - - public reset(): void { - this.body.replaceChildren(); - } -} - -class FakeWindow { - public innerHeight = 700; - public innerWidth = 1000; - private animationFrameId = 1; - private readonly animationFrames = new Map void>(); - private readonly listeners = new Map void>>(); - - public addEventListener(type: string, listener: unknown): void { - if (typeof listener !== "function") {return;} - const listeners = this.listeners.get(type) ?? new Set<(event: unknown) => void>(); - listeners.add(listener as (event: unknown) => void); - this.listeners.set(type, listeners); - } - - public dispatch(type: string, event: unknown): void { - this.listeners.get(type)?.forEach((listener) => listener(event)); - } - - public flushAnimationFrames(): void { - const callbacks = Array.from(this.animationFrames.values()); - this.animationFrames.clear(); - callbacks.forEach((callback) => callback()); - } - - public queueAnimationFrame(callback: () => void): number { - const id = this.animationFrameId++; - this.animationFrames.set(id, callback); - return id; - } +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; + overlay: OverlayInstance; + panel: FakeElement; + queue: FollowUpQueue; + stack: FakeElement; + tracker: ToolActivityTracker; } -const fakeDocument = new FakeDocument(); -const fakeWindow = new FakeWindow(); let scheduledTimeoutCount = 0; async function main(): Promise { @@ -186,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", () => { @@ -195,6 +43,21 @@ 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); + }); + runTest("repeated enable updates do not rerender the work panel", () => { + testIdempotentEnabledState(ToolActivityOverlay); + }); + 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 { @@ -224,8 +87,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 { @@ -238,15 +101,21 @@ 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 { 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 +123,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 +151,121 @@ 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 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 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 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"); + 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(); 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, overlay, panel, queue, stack, tracker }; } function captureTurn( @@ -322,17 +289,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 +296,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 +309,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(); @@ -391,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();