Files
wehub-resource-sync 9b395f5cc3
Build Chrome Extension / build (push) Waiting to run
Trigger Website Rebuild (Docs Updated) / dispatch (push) Waiting to run
E2E Headed Chrome / e2e-headed (macos-15) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (windows-latest) (push) Has been cancelled
CI / build (macos-latest) (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
CI / unit-test (push) Has been cancelled
CI / bun-test (push) Has been cancelled
CI / adapter-test (push) Has been cancelled
CI / smoke-test (macos-latest) (push) Has been cancelled
CI / smoke-test (ubuntu-latest) (push) Has been cancelled
Security Audit / audit (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:39:48 +08:00

319 lines
13 KiB
JavaScript

// Shared helpers for Antigravity sidebar conversation management.
//
// Each conversation in the sidebar is rendered as a row whose visible
// title element has stable testid `convo-pill-<uuid>`. The row container
// is the 3rd ancestor — it carries `role="button"` and acts as the
// clickable row.
//
// On hover the row shows 3 icon-only buttons. The FIRST (button[0]) is a
// "more options" 3-dot trigger that opens a 3-item dropdown:
//
// Mark as Read
// Rename
// Delete Conversation
//
// We use that dropdown for all management operations. Antigravity does
// not currently expose Pin/Unpin as menu items (different model than
// Codex / Grok).
//
// All clicks go through the full pointer-event chain because the menu is
// likely radix-based and ignores bare .click().
import { CommandExecutionError, selectorError } from '@jackwener/opencli/errors';
const PILL_SELECTOR_PREFIX = 'convo-pill-';
export function unwrapEvaluateResult(payload) {
if (
payload
&& typeof payload === 'object'
&& Object.prototype.hasOwnProperty.call(payload, 'data')
&& Object.prototype.hasOwnProperty.call(payload, 'session')
) {
return payload.data;
}
return payload;
}
export function buildPillTestId(conversationId) {
return `${PILL_SELECTOR_PREFIX}${String(conversationId).toLowerCase()}`;
}
/**
* Return all visible conversation pills with their {id, title} for
* history-style listings or for fuzzy match.
*/
export async function listConversations(page) {
const result = unwrapEvaluateResult(await page.evaluate(`(function() {
return Array.from(document.querySelectorAll('[data-testid^="${PILL_SELECTOR_PREFIX}"]'))
.filter((el) => el.offsetParent)
.map((el, idx) => ({
index: idx + 1,
id: el.getAttribute('data-testid').slice(${PILL_SELECTOR_PREFIX.length}),
title: (el.textContent || '').trim().slice(0, 200),
}));
})()`));
return Array.isArray(result) ? result : [];
}
export async function conversationVisible(page, conversationId) {
const testId = buildPillTestId(conversationId);
return !!unwrapEvaluateResult(await page.evaluate(`(() => {
const el = document.querySelector(${JSON.stringify(`[data-testid="${testId}"]`)});
return !!(el && el.offsetParent);
})()`));
}
export async function getConversationMenuLabels(page, conversationId) {
const testId = buildPillTestId(conversationId);
const result = unwrapEvaluateResult(await page.evaluate(`(async () => {
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
const pill = document.querySelector(${JSON.stringify(`[data-testid="${testId}"]`)});
if (!pill) return { ok: false, reason: 'Conversation pill not found.', detail: 'testid=${testId}' };
let row = pill;
for (let i = 0; i < 3; i++) row = row.parentElement || row;
row.scrollIntoView({ block: 'center' });
row.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
row.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
let dotBtn = null;
for (let attempt = 0; attempt < 12; attempt += 1) {
await wait(80);
const btns = Array.from(row.querySelectorAll('button')).filter((b) => b.offsetParent);
if (btns.length >= 1) { dotBtn = btns[0]; break; }
}
if (!dotBtn) return { ok: false, reason: 'Per-row 3-dot trigger never mounted after hover.' };
const r = dotBtn.getBoundingClientRect();
const init = {
bubbles: true, cancelable: true, button: 0, buttons: 1,
clientX: Math.round(r.left + r.width / 2),
clientY: Math.round(r.top + r.height / 2),
};
dotBtn.dispatchEvent(new PointerEvent('pointerdown', { ...init, pointerType: 'mouse' }));
dotBtn.dispatchEvent(new MouseEvent('mousedown', init));
dotBtn.dispatchEvent(new PointerEvent('pointerup', { ...init, pointerType: 'mouse' }));
dotBtn.dispatchEvent(new MouseEvent('mouseup', init));
dotBtn.dispatchEvent(new MouseEvent('click', init));
let menuItems = [];
for (let attempt = 0; attempt < 20; attempt += 1) {
await wait(80);
menuItems = Array.from(document.querySelectorAll('[role="menuitem"], [role="option"]'))
.filter((it) => it instanceof HTMLElement && it.offsetParent);
if (menuItems.length) break;
}
const labels = menuItems.map((it) => {
const clone = it.cloneNode(true);
clone.querySelectorAll('kbd').forEach((k) => k.remove());
return (clone.textContent || '').trim();
}).filter(Boolean);
document.body.click();
return { ok: true, labels };
})()`));
return result || { ok: false, reason: 'Empty result from page.evaluate.' };
}
/**
* Open the per-row 3-dot menu for the given conversation, click the
* menu item whose visible text matches `labelOptions`, return status.
* Single page.evaluate so the menu stays mounted while we click.
*
* Returns { ok, clicked? , reason?, detail? }.
*/
export async function clickConversationMenuItem(page, conversationId, labelOptions) {
const testId = buildPillTestId(conversationId);
const testIdJson = JSON.stringify(testId);
const labelsJson = JSON.stringify(labelOptions);
// Wrap in try/catch — Antigravity menu clicks often trigger a
// sidebar re-render that destroys the eval reply mid-stream, surfacing
// as "Promise was collected" or 30s Runtime.evaluate timeout. The
// click DID happen (we verified live by toggling Mark as Read /
// Unread). Treat these specific failures as success-with-no-confirmation
// and let the caller re-query history to verify.
let result;
try {
result = unwrapEvaluateResult(await page.evaluate(`(async () => {
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
const testId = ${testIdJson};
const labels = ${labelsJson};
const pill = document.querySelector(\`[data-testid="\${testId}"]\`);
if (!pill) {
return { ok: false, reason: 'Conversation pill not found.', detail: 'testid=' + testId };
}
// Walk up to the row container — depth 3 holds the role="button" row
// with the per-row action buttons.
let row = pill;
for (let i = 0; i < 3; i++) row = row.parentElement || row;
if (!row) {
return { ok: false, reason: 'Could not locate the row container above the pill.' };
}
row.scrollIntoView({ block: 'center' });
// React synthetic hover mounts the per-row buttons. Visibility-state
// doesn't appear to gate Antigravity's overlay (unlike Codex), but
// we still dispatch the full set for safety.
row.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
row.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
// Wait for the row's 3-dot trigger to mount.
let dotBtn = null;
for (let attempt = 0; attempt < 12; attempt += 1) {
await wait(80);
const btns = Array.from(row.querySelectorAll('button')).filter((b) => b.offsetParent);
if (btns.length >= 1) { dotBtn = btns[0]; break; } // First button == more-options
}
if (!dotBtn) {
return { ok: false, reason: 'Per-row 3-dot trigger never mounted after hover.' };
}
// Open the menu via full pointer chain.
const r = dotBtn.getBoundingClientRect();
const init = {
bubbles: true, cancelable: true, button: 0, buttons: 1,
clientX: Math.round(r.left + r.width / 2),
clientY: Math.round(r.top + r.height / 2),
};
dotBtn.dispatchEvent(new PointerEvent('pointerdown', { ...init, pointerType: 'mouse' }));
dotBtn.dispatchEvent(new MouseEvent('mousedown', init));
dotBtn.dispatchEvent(new PointerEvent('pointerup', { ...init, pointerType: 'mouse' }));
dotBtn.dispatchEvent(new MouseEvent('mouseup', init));
dotBtn.dispatchEvent(new MouseEvent('click', init));
// Wait for menu items to mount.
let menuItems = [];
for (let attempt = 0; attempt < 20; attempt += 1) {
await wait(80);
menuItems = Array.from(document.querySelectorAll('[role="menuitem"], [role="option"]'))
.filter((it) => it instanceof HTMLElement && it.offsetParent);
if (menuItems.length) break;
}
if (!menuItems.length) {
return { ok: false, reason: 'Conversation 3-dot menu did not open after click.' };
}
function leadingText(el) {
const clone = el.cloneNode(true);
clone.querySelectorAll('kbd').forEach((k) => k.remove());
return (clone.textContent || '').trim();
}
let target = null;
for (const item of menuItems) {
const text = leadingText(item);
for (const label of labels) {
if (text === label || text.startsWith(label)) {
target = item;
break;
}
}
if (target) break;
}
if (!target) {
const visible = menuItems.map(leadingText);
document.body.click(); // close menu
return {
ok: false,
reason: 'No menu item matched the requested label.',
detail: 'wanted=' + JSON.stringify(labels) + ' visible=' + JSON.stringify(visible),
};
}
// Click via pointer chain too — radix is picky.
const tr = target.getBoundingClientRect();
const tinit = {
bubbles: true, cancelable: true, button: 0, buttons: 1,
clientX: Math.round(tr.left + tr.width / 2),
clientY: Math.round(tr.top + tr.height / 2),
};
const matchedLabel = leadingText(target);
// Defer to next microtask so the eval reply returns before any re-render.
Promise.resolve().then(() => {
try {
target.dispatchEvent(new PointerEvent('pointerdown', { ...tinit, pointerType: 'mouse' }));
target.dispatchEvent(new MouseEvent('mousedown', tinit));
target.dispatchEvent(new PointerEvent('pointerup', { ...tinit, pointerType: 'mouse' }));
target.dispatchEvent(new MouseEvent('mouseup', tinit));
target.dispatchEvent(new MouseEvent('click', tinit));
} catch {}
});
return { ok: true, clicked: matchedLabel };
})()`));
} catch (err) {
const msg = String(err?.message || err);
if (/Promise was collected|timed out after \d+s|Runtime\.evaluate/i.test(msg)) {
// Click was scheduled inside a microtask before destruction, so
// the action almost certainly fired. Report ambiguous-but-likely-ok.
return {
ok: true,
clicked: labelOptions[0],
note: 'eval reply destroyed by post-click re-render; click likely fired',
};
}
throw err;
}
return result || { ok: false, reason: 'Empty result from page.evaluate.' };
}
/**
* After Delete Conversation menu item is clicked, Antigravity shows a
* confirm dialog. Locate it and click the confirm button.
*/
export async function confirmDeleteDialog(page, confirmLabels) {
const labelsJson = JSON.stringify(confirmLabels);
const result = unwrapEvaluateResult(await page.evaluate(`(async () => {
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
let dialog = null;
for (let attempt = 0; attempt < 15; attempt += 1) {
await wait(120);
dialog = document.querySelector('[role="alertdialog"], [role="dialog"]');
if (dialog && dialog.offsetParent) break;
}
if (!dialog) {
return { ok: false, reason: 'Delete confirm dialog did not appear.' };
}
const buttons = Array.from(dialog.querySelectorAll('button'));
const labels = ${labelsJson};
const confirmBtn = buttons.find((b) => {
const t = (b.textContent || '').trim();
return labels.some((l) => t === l || t.toLowerCase() === l.toLowerCase());
});
if (!confirmBtn) {
return {
ok: false,
reason: 'Confirm button not found in dialog.',
detail: 'present=' + JSON.stringify(buttons.map((b) => (b.textContent || '').trim())),
};
}
const r = confirmBtn.getBoundingClientRect();
const init = {
bubbles: true, button: 0, buttons: 1, cancelable: true,
clientX: Math.round(r.left + r.width / 2),
clientY: Math.round(r.top + r.height / 2),
};
Promise.resolve().then(() => {
try {
confirmBtn.dispatchEvent(new PointerEvent('pointerdown', { ...init, pointerType: 'mouse' }));
confirmBtn.dispatchEvent(new MouseEvent('mousedown', init));
confirmBtn.dispatchEvent(new PointerEvent('pointerup', { ...init, pointerType: 'mouse' }));
confirmBtn.dispatchEvent(new MouseEvent('mouseup', init));
confirmBtn.dispatchEvent(new MouseEvent('click', init));
} catch {}
});
return { ok: true, confirmed: (confirmBtn.textContent || '').trim() };
})()`));
return result || { ok: false, reason: 'Empty result.' };
}
export const conversationTargetArgs = [
{
name: 'id',
positional: true,
type: 'string',
required: true,
help: 'Conversation UUID (the part after "convo-pill-" in the sidebar testid)',
},
];