Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions bridge-browser/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,10 @@
"matches": [
"<all_urls>"
],
"exclude_matches": [
"http://127.0.0.1/bridge*",
"http://localhost/bridge*"
],
"js": ["public/generated/network_capture_main.js"],
"run_at": "document_start",
"world": "MAIN"
Expand All @@ -84,6 +88,10 @@
"matches": [
"<all_urls>"
],
"exclude_matches": [
"http://127.0.0.1/bridge*",
"http://localhost/bridge*"
],
"js": ["src/content/main.ts"]
}
]
Expand Down
20 changes: 14 additions & 6 deletions bridge-browser/src/content/completion_notifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -23,6 +27,8 @@ export class CompletionNotifier {
private lastNotificationTime = 0;
private readonly notifiedCompletionKeys = new Set<string>();

public constructor(private readonly options: CompletionNotifierOptions = {}) {}

public reset(): void {
this.clearCompletionTimer();
this.lastIdle = null;
Expand Down Expand Up @@ -86,20 +92,22 @@ 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") {
this.notifiedCompletionKeys.delete(oldestKey);
}
}

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");
Expand Down
35 changes: 27 additions & 8 deletions bridge-browser/src/content/floating_panel_drag.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
const DEFAULT_VIEWPORT_MARGIN = 8;
const DRAG_THRESHOLD_PX = 4;

export interface FloatingPanelPosition {
left: number;
Expand All @@ -11,6 +12,7 @@ export interface FloatingPanelSize {
}

interface DragState {
hasMoved: boolean;
initialLeft: number;
initialTop: number;
pointerX: number;
Expand All @@ -35,15 +37,22 @@ 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);
window.addEventListener("mouseup", this.handleMouseUp);
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 => {
Expand All @@ -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;
};

Expand Down
168 changes: 168 additions & 0 deletions bridge-browser/src/content/follow_up_overlay.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLElement>(".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 };
}
40 changes: 40 additions & 0 deletions bridge-browser/src/content/follow_up_overlay_styles.ts
Original file line number Diff line number Diff line change
@@ -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; }
`;
Loading
Loading