diff --git a/.gitignore b/.gitignore index a410dc8..1b49aef 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,5 @@ artifacts/design-system/**/*.jpg artifacts/design-system/**/*.jpeg artifacts/design-system/**/*.gif artifacts/design-system/**/*.webp +docs/reports/usage-*.md +docs/reports/usage-*.query.json diff --git a/bin/remote-ui-smoke b/bin/remote-ui-smoke index fd90989..2bfa387 100755 --- a/bin/remote-ui-smoke +++ b/bin/remote-ui-smoke @@ -301,6 +301,26 @@ def associate_agent_schedule(logs_dir, agent_key) File.write(agents_path, JSON.pretty_generate(agents)) end +def associate_agent_pull_request(logs_dir, agent_key) + agents_path = File.join(logs_dir, "managed_agents.json") + agents = JSON.parse(File.read(agents_path)) + agent = agents.find { |item| item["key"] == agent_key } + raise "Failed to find smoke agent #{agent_key.inspect}" unless agent + + attachments_path = agent.fetch("log_path").sub(/\.raw\.log\z/, ".attachments.json") + File.write( + attachments_path, + JSON.pretty_generate( + "attachments" => [{ + "kind" => "pull_request", + "title" => "Example PR", + "url" => "https://github.com/example/web/pull/123", + "created_at" => Time.now.iso8601 + }] + ) + ) +end + def mark_schedule_daemon_running(logs_dir, pid) FileUtils.mkdir_p(logs_dir) now = Time.now.iso8601 @@ -385,6 +405,38 @@ def write_smoke_script(path) }); page.on("pageerror", (error) => errors.push(error.message)); + async function captureVisibleComposer(filename) { + if (!captureDir) return; + const composer = page.locator("#composer:not([hidden])"); + await composer.waitFor({ state: "visible", timeout: 10_000 }); + const bounds = await composer.boundingBox(); + if (!bounds) throw new Error("Visible composer had no screenshot bounds"); + const viewport = page.viewportSize(); + const padding = 36; + const x = Math.max(0, bounds.x - padding); + const y = Math.max(0, bounds.y - padding); + const clip = { + x, + y, + width: Math.min(viewport.width, bounds.x + bounds.width + padding) - x, + height: Math.min(viewport.height, bounds.y + bounds.height + padding) - y, + }; + await page.screenshot({ path: path.join(captureDir, filename), clip }); + } + + async function ensureSkillAutocompleteVisible() { + for (let attempt = 0; attempt < 20; attempt += 1) { + if (await page.locator("[data-skill-autocomplete]:not(.hidden)").isVisible()) return; + await page.evaluate(() => { + const prompt = document.querySelector("#prompt-input"); + prompt?.focus(); + if (prompt) refreshSkillAutocomplete(prompt); + }); + await page.waitForTimeout(100); + } + throw new Error("Skill autocomplete did not become visible"); + } + async function assertResourceCatalogCompatibility() { const catalogTimings = await page.evaluate(async () => { const samples = []; @@ -1642,7 +1694,8 @@ def write_smoke_script(path) await agentsRegressionPage.close(); const prContextPage = await browser.newPage({ viewport: { width: 390, height: 844 } }); - await prContextPage.goto(`${baseUrl}/ui#agent/${encodeURIComponent(agentKey)}`, { waitUntil: "networkidle" }); + const prContextAgentKey = unscheduledAgentKey; + await prContextPage.goto(`${baseUrl}/ui#agent/${encodeURIComponent(prContextAgentKey)}`, { waitUntil: "networkidle" }); await prContextPage.waitForSelector("#prompt-input", { state: "visible", timeout: 10_000 }); await prContextPage.evaluate((key) => { const agent = findAgent(key); @@ -1652,12 +1705,992 @@ def write_smoke_script(path) ]; state.renderedViewHtml = ""; render(); - }, agentKey); + }, prContextAgentKey); await prContextPage.click("#header-more-button"); - await prContextPage.waitForSelector(`[data-open-agent-pr-diffs='${agentKey}']`, { state: "visible", timeout: 10_000 }); - await prContextPage.click(`[data-open-agent-pr-diffs='${agentKey}']`); + await prContextPage.waitForSelector(`[data-open-agent-pr-diffs='${prContextAgentKey}']`, { state: "visible", timeout: 10_000 }); + await prContextPage.click(`[data-open-agent-pr-diffs='${prContextAgentKey}']`); await prContextPage.waitForSelector(".agent-pr-diff-shell", { state: "visible", timeout: 10_000 }); await prContextPage.waitForSelector(".agent-pr-diff-shell .empty-state", { state: "visible", timeout: 10_000 }); + await prContextPage.click("[data-refresh-pr-diff]"); + await prContextPage.waitForSelector("[data-select-pr-diff-line]", { state: "visible", timeout: 10_000 }); + const prOriginMetadata = await prContextPage.evaluate((key) => { + const route = parseRoute(); + const pullRequestId = selectedPullRequestId(key, route.pullRequestId); + const item = state.pullRequests[key].items.find((candidate) => candidate.id === pullRequestId); + const result = { + itemTitle: item?.title, + navTitle: document.querySelector(".pr-diff-nav-item.active strong")?.textContent, + detailTitle: document.querySelector(".pr-diff-title-meta strong")?.textContent, + states: [], + }; + [ + { state: "open", draft: false, merged: false, expected: "Open" }, + { state: "open", draft: true, merged: false, expected: "Draft" }, + { state: "closed", draft: true, merged: false, expected: "Closed" }, + { state: "closed", draft: false, merged: true, expected: "Merged" }, + ].forEach((status) => { + Object.assign(item, status); + state.renderedViewHtml = ""; + render(); + result.states.push({ + expected: status.expected, + badge: document.querySelector("[data-pr-status-badge]")?.textContent?.trim(), + navMeta: document.querySelector(".pr-diff-nav-item.active .pr-diff-nav-copy > span")?.textContent, + }); + }); + return result; + }, prContextAgentKey); + if (prOriginMetadata.itemTitle !== "Smoke pull request" || + prOriginMetadata.navTitle !== "Smoke pull request" || + prOriginMetadata.detailTitle !== "Smoke pull request" || + prOriginMetadata.states.some((status) => ( + status.badge !== status.expected || !status.navMeta?.includes(status.expected) + ))) { + throw new Error(`PR title or status did not reflect origin metadata: ${JSON.stringify(prOriginMetadata)}`); + } + const asynchronousDiffRenderContract = await prContextPage.evaluate(async () => { + const originalRenderDiffLine = renderDiffLine; + let renderCount = 0; + renderDiffLine = (...args) => { + renderCount += 1; + return originalRenderDiffLine(...args); + }; + resetPullRequestDiffRender(); + state.renderedViewHtml = ""; + render(); + const immediateRenderCount = renderCount; + const loadingBeforePaint = Boolean(document.querySelector(".pr-diff-loading-state")); + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + const deferredRenderCount = renderCount - immediateRenderCount; + renderDiffLine = originalRenderDiffLine; + return { immediateRenderCount, deferredRenderCount, loadingBeforePaint }; + }); + if (asynchronousDiffRenderContract.immediateRenderCount !== 0 || + asynchronousDiffRenderContract.deferredRenderCount < 1 || + !asynchronousDiffRenderContract.loadingBeforePaint) { + throw new Error(`PR diff rendered in the route-switch frame: ${JSON.stringify(asynchronousDiffRenderContract)}`); + } + const refreshedSnapshotRenderContract = await prContextPage.evaluate(async (key) => { + const route = parseRoute(); + const pullRequestId = selectedPullRequestId(key, route.pullRequestId); + const diffKey = pullRequestDiffKey(key, pullRequestId); + const originalDiff = state.pullRequestDiffs[diffKey]; + const originalRenderDiffLine = renderDiffLine; + let renderCount = 0; + renderDiffLine = (...args) => { + renderCount += 1; + return originalRenderDiffLine(...args); + }; + state.pullRequestDiffs[diffKey] = { + ...state.pullRequestDiffs[diffKey], + snapshot_id: `${state.pullRequestDiffs[diffKey].snapshot_id}-refreshed`, + }; + state.renderedViewHtml = ""; + render(); + const immediateRenderCount = renderCount; + const loadingBeforePaint = Boolean(document.querySelector(".pr-diff-loading-state")); + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + const deferredRenderCount = renderCount - immediateRenderCount; + renderDiffLine = originalRenderDiffLine; + state.pullRequestDiffs[diffKey] = originalDiff; + resetPullRequestDiffRender(); + state.renderedViewHtml = ""; + render(); + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + return { immediateRenderCount, deferredRenderCount, loadingBeforePaint }; + }, prContextAgentKey); + if (refreshedSnapshotRenderContract.immediateRenderCount !== 0 || + refreshedSnapshotRenderContract.deferredRenderCount < 1 || + !refreshedSnapshotRenderContract.loadingBeforePaint) { + throw new Error(`Refreshed PR diff rendered before the first paint: ${JSON.stringify(refreshedSnapshotRenderContract)}`); + } + const delayedConversationPattern = `**/agents/${encodeURIComponent(prContextAgentKey)}/conversation`; + const originalPullRequestHash = await prContextPage.evaluate(() => location.hash); + let releaseDelayedConversation; + const delayedConversationGate = new Promise((resolve) => { + releaseDelayedConversation = resolve; + }); + const delayedConversationHandler = async (route) => { + await delayedConversationGate; + await route.continue(); + }; + await prContextPage.route(delayedConversationPattern, delayedConversationHandler); + const secondPullRequestId = await prContextPage.evaluate((key) => { + const current = state.pullRequests[key].items[0]; + const id = `${current.id}-navigation-latency`; + state.pullRequests[key].items.push({ + ...current, + id, + number: Number(current.number || 0) + 1, + title: "Navigation latency fixture", + }); + state.renderedViewHtml = ""; + render(); + return id; + }, prContextAgentKey); + const navigationDiffPattern = `**/pull-requests/${encodeURIComponent(secondPullRequestId)}/diff`; + let resolveNavigationDiffRequest; + const navigationDiffRequest = new Promise((resolve) => { + resolveNavigationDiffRequest = resolve; + }); + const navigationDiffHandler = async (route) => { + resolveNavigationDiffRequest(); + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ diff: { id: secondPullRequestId, snapshot_id: "navigation-latency", files: [] } }), + }); + }; + await prContextPage.route(navigationDiffPattern, navigationDiffHandler); + const delayedConversationResponse = prContextPage.waitForResponse( + (response) => response.url().endsWith(`/agents/${encodeURIComponent(prContextAgentKey)}/conversation`), + { timeout: 10_000 } + ); + await prContextPage.click(`.pr-diff-nav-item[href*='${encodeURIComponent(secondPullRequestId)}']`); + await prContextPage.waitForFunction( + (id) => location.hash.includes(encodeURIComponent(id)), + secondPullRequestId, + { timeout: 10_000 } + ); + let navigationDiffTimeout; + await Promise.race([ + navigationDiffRequest, + new Promise((_resolve, reject) => { + navigationDiffTimeout = setTimeout( + () => reject(new Error("PR diff request waited behind unrelated route data")), + 10_000 + ); + }), + ]); + clearTimeout(navigationDiffTimeout); + releaseDelayedConversation(); + await delayedConversationResponse; + await prContextPage.unroute(delayedConversationPattern, delayedConversationHandler); + await prContextPage.unroute(navigationDiffPattern, navigationDiffHandler); + await prContextPage.goto(`${baseUrl}/ui${originalPullRequestHash}`, { waitUntil: "networkidle" }); + await prContextPage.waitForSelector("[data-select-pr-diff-line]", { state: "visible", timeout: 10_000 }); + const preloadPullRequestId = await prContextPage.evaluate((key) => { + const current = state.pullRequests[key].items[0]; + const id = `${current.id}-background-preload`; + state.pullRequests[key].items.push({ + ...current, + id, + number: Number(current.number || 0) + 3, + title: "Background preload fixture", + }); + return id; + }, prContextAgentKey); + const preloadPattern = `**/pull-requests/${encodeURIComponent(preloadPullRequestId)}/diff`; + let resolvePreloadRequest; + const preloadRequest = new Promise((resolve) => { resolvePreloadRequest = resolve; }); + const preloadHandler = async (route) => { + resolvePreloadRequest(); + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ diff: { id: preloadPullRequestId, snapshot_id: "background-preload", files: [] } }), + }); + }; + await prContextPage.route(preloadPattern, preloadHandler); + const preloadHash = await prContextPage.evaluate((key) => { + const route = parseRoute(); + preloadSavedPullRequestDiffs(key, state.pullRequests[key].items, selectedPullRequestId(key, route.pullRequestId)); + return location.hash; + }, prContextAgentKey); + let preloadTimeout; + await Promise.race([ + preloadRequest, + new Promise((_resolve, reject) => { + preloadTimeout = setTimeout( + () => reject(new Error("Saved PR diff did not preload in the background")), + 10_000 + ); + }), + ]); + clearTimeout(preloadTimeout); + await prContextPage.waitForFunction( + ({ key, id }) => { + const diff = state.pullRequestDiffs[pullRequestDiffKey(key, id)]; + return diff && !diff.loading && !diff.error; + }, + { key: prContextAgentKey, id: preloadPullRequestId }, + { timeout: 10_000 } + ); + const preloadStayedOnSelectedPullRequest = await prContextPage.evaluate((hash) => location.hash === hash, preloadHash); + await prContextPage.unroute(preloadPattern, preloadHandler); + if (!preloadStayedOnSelectedPullRequest) throw new Error("Background PR preload changed the selected route"); + const prioritizedFetchContract = await prContextPage.evaluate(async (key) => { + const originalApiGet = apiGet; + const originalApiPost = apiPost; + const originalEnsureAgentPullRequests = ensureAgentPullRequests; + const ids = ["queue-background-1", "queue-background-2", "queue-foreground"]; + const starts = []; + const releases = {}; + const waitUntil = async (predicate, message) => { + const deadline = Date.now() + 10_000; + while (!predicate()) { + if (Date.now() > deadline) throw new Error(message); + await new Promise((resolve) => setTimeout(resolve, 10)); + } + }; + apiGet = async (path) => { + const id = ids.find((candidate) => path.includes(candidate)); + if (!id) return originalApiGet(path); + starts.push(id); + if (id === ids[2]) return { diff: { id, snapshot_id: id, files: [] } }; + await new Promise((resolve) => { releases[id] = resolve; }); + return { diff: { id, snapshot_id: id, files: [] } }; + }; + let refreshGetRelease; + let refreshPostCount = 0; + apiPost = async (path, body) => { + if (!path.includes("queue-refresh")) return originalApiPost(path, body); + refreshPostCount += 1; + return { diff: { id: "queue-refresh", snapshot_id: "queue-refresh-post", files: [] } }; + }; + try { + const backgroundPromises = ids.map((id) => enqueuePullRequestDiffFetch(key, id, false, false, { background: true })); + await waitUntil(() => starts.length === 2, "background preloads did not fill their bounded capacity"); + const selectedPromise = enqueuePullRequestDiffFetch(key, ids[2]); + await waitUntil(() => starts.includes(ids[2]), "selected PR remained queued behind background preloads"); + const foregroundStartedBeforeRelease = !releases[ids[0]] && !releases[ids[1]] + ? false + : starts[2] === ids[2]; + releases[ids[0]](); + releases[ids[1]](); + await Promise.all([...backgroundPromises, selectedPromise]); + + const staleIds = ["stale-agent-1", "stale-agent-2", "stale-agent-queued"]; + const staleStarts = []; + const staleReleases = {}; + apiGet = async (path) => { + const id = staleIds.find((candidate) => path.includes(candidate)); + if (!id) return originalApiGet(path); + staleStarts.push(id); + await new Promise((resolve) => { staleReleases[id] = resolve; }); + return { diff: { id, snapshot_id: id, files: [] } }; + }; + const stalePromises = staleIds.map((id) => ( + enqueuePullRequestDiffFetch(`${key}-stale`, id, false, false, { background: true }) + )); + await waitUntil(() => staleStarts.length === 2, "stale agent preloads did not fill background capacity"); + await ensureRouteData(); + const staleQueuedRemoved = !state.pullRequestDiffFetches[pullRequestDiffKey(`${key}-stale`, staleIds[2])] && + !staleStarts.includes(staleIds[2]); + staleReleases[staleIds[0]](); + staleReleases[staleIds[1]](); + await Promise.all(stalePromises); + const staleResultsDiscarded = staleIds.every((id) => !state.pullRequestDiffs[pullRequestDiffKey(`${key}-stale`, id)]); + + apiGet = async (path) => { + if (!path.includes("queue-refresh")) return originalApiGet(path); + starts.push("queue-refresh-get"); + await new Promise((resolve) => { refreshGetRelease = resolve; }); + return { diff: { id: "queue-refresh", snapshot_id: "queue-refresh-get", files: [] } }; + }; + const staleGet = enqueuePullRequestDiffFetch(key, "queue-refresh", false, false, { background: true }); + await waitUntil(() => Boolean(refreshGetRelease), "background refresh fixture did not start"); + const explicitRefresh = enqueuePullRequestDiffFetch(key, "queue-refresh", true, true); + refreshGetRelease(); + await Promise.all([staleGet, explicitRefresh]); + const refreshedSnapshot = state.pullRequestDiffs[pullRequestDiffKey(key, "queue-refresh")]?.snapshot_id; + + const currentId = selectedPullRequestId(key, parseRoute().pullRequestId); + const currentKey = pullRequestDiffKey(key, currentId); + const currentDiff = state.pullRequestDiffs[currentKey]; + ensureAgentPullRequests = async () => {}; + apiPost = async (path, body) => { + if (!path.endsWith(`/agents/${encodeURIComponent(key)}/pull-requests/refresh`)) { + return originalApiPost(path, body); + } + return { + refreshed: [ + { ...currentDiff, id: currentId, snapshot_id: "refresh-all-selected" }, + ...Array.from({ length: MAX_CACHED_PR_DIFF_DATA + 2 }, (_, index) => ({ + id: `refresh-all-${index}`, + snapshot_id: `refresh-all-${index}`, + files: [], + })), + ], + failed: [], + }; + }; + await refreshAllPullRequestDiffs(key); + const refreshAllBounded = state.pullRequestDiffDataOrder.size <= MAX_CACHED_PR_DIFF_DATA; + const refreshAllSelectedRetained = state.pullRequestDiffs[currentKey]?.snapshot_id === "refresh-all-selected"; + state.pullRequestDiffs[currentKey] = currentDiff; + rememberPullRequestDiffData(currentKey); + + for (let index = 0; index < MAX_CACHED_PR_DIFF_DATA + 2; index += 1) { + const cacheKey = pullRequestDiffKey(key, `bounded-${index}`); + state.pullRequestDiffs[cacheKey] = { id: `bounded-${index}`, files: [] }; + rememberPullRequestDiffData(cacheKey); + rememberPullRequestDiffPreloadFailure(cacheKey); + } + state.pullRequestDiffs[currentKey] = currentDiff; + rememberPullRequestDiffData(currentKey); + const previousViewers = state.pullRequestDiffViewers; + state.pullRequestDiffViewers = new Map(); + for (let index = 0; index < MAX_CACHED_PR_DIFF_VIEWERS + 2; index += 1) { + const viewer = document.createElement("section"); + viewer.dataset.stateKey = `pr-diff-viewer:${key}:viewer-${index}`; + viewer.dataset.pollContentVersion = "snapshot:expanded"; + cachePullRequestDiffViewer(viewer); + } + const viewerCacheKeys = Array.from(state.pullRequestDiffViewers.keys()); + state.pullRequestDiffViewers = previousViewers; + return { + foregroundStartedBeforeRelease, + staleQueuedRemoved, + staleResultsDiscarded, + refreshPostCount, + refreshedSnapshot, + refreshAllBounded, + refreshAllSelectedRetained, + dataCacheSize: state.pullRequestDiffDataOrder.size, + failureCacheSize: state.pullRequestDiffPreloadFailures.size, + oldestDataEvicted: !state.pullRequestDiffDataOrder.has(pullRequestDiffKey(key, "bounded-0")), + oldestFailureEvicted: !state.pullRequestDiffPreloadFailures.has(pullRequestDiffKey(key, "bounded-0")), + viewerCacheSize: viewerCacheKeys.length, + oldestViewerEvicted: !viewerCacheKeys.some((cacheKey) => cacheKey.includes("viewer-0")), + }; + } finally { + apiGet = originalApiGet; + apiPost = originalApiPost; + ensureAgentPullRequests = originalEnsureAgentPullRequests; + } + }, prContextAgentKey); + if (!prioritizedFetchContract.foregroundStartedBeforeRelease || + !prioritizedFetchContract.staleQueuedRemoved || + !prioritizedFetchContract.staleResultsDiscarded || + prioritizedFetchContract.refreshPostCount !== 1 || + prioritizedFetchContract.refreshedSnapshot !== "queue-refresh-post" || + !prioritizedFetchContract.refreshAllBounded || + !prioritizedFetchContract.refreshAllSelectedRetained || + prioritizedFetchContract.dataCacheSize > 12 || + prioritizedFetchContract.failureCacheSize > 12 || + !prioritizedFetchContract.oldestDataEvicted || + !prioritizedFetchContract.oldestFailureEvicted || + prioritizedFetchContract.viewerCacheSize !== 6 || + !prioritizedFetchContract.oldestViewerEvicted) { + throw new Error(`PR diff queue priority, refresh, or cache bounds failed: ${JSON.stringify(prioritizedFetchContract)}`); + } + const cachedSwitchIds = await prContextPage.evaluate(async (key) => { + const route = parseRoute(); + const firstId = selectedPullRequestId(key, route.pullRequestId); + const firstItem = state.pullRequests[key].items.find((item) => item.id === firstId); + const firstDiff = state.pullRequestDiffs[pullRequestDiffKey(key, firstId)]; + const secondId = `${firstId}-cached-switch`; + state.pullRequests[key].items.push({ + ...firstItem, + id: secondId, + number: Number(firstItem.number || 0) + 2, + title: "Cached switch fixture", + }); + state.pullRequestDiffs[pullRequestDiffKey(key, secondId)] = { + ...firstDiff, + id: secondId, + snapshot_id: "cached-switch", + }; + state.renderedViewHtml = ""; + render(); + document.querySelector("[data-select-pr-diff-line]").click(); + await new Promise((resolve) => requestAnimationFrame(resolve)); + const firstComment = document.querySelector("[data-pr-context-comment]"); + firstComment.value = "Persistent first PR comment"; + firstComment.dispatchEvent(new Event("input", { bubbles: true })); + const firstViewer = document.querySelector("[data-pr-diff-viewer]"); + const scrollStyle = document.createElement("style"); + scrollStyle.id = "cached-pr-scroll-smoke-style"; + scrollStyle.textContent = ".pr-diff-detail { height: 120px !important; overflow-y: auto !important; }"; + document.head.append(scrollStyle); + const spacer = document.createElement("div"); + spacer.style.height = "2000px"; + spacer.dataset.cachedSwitchSpacer = "first"; + firstViewer.append(spacer); + const firstDetail = firstViewer.closest(".pr-diff-detail"); + void firstDetail.scrollHeight; + firstDetail.scrollTop = 140; + firstViewer.dataset.cachedSwitchProbe = "first"; + window.__tychoCachedFirstViewer = firstViewer; + return { firstId, secondId }; + }, prContextAgentKey); + await prContextPage.click(`.pr-diff-nav-item[href*='${encodeURIComponent(cachedSwitchIds.secondId)}']`); + await prContextPage.waitForSelector(`[data-pr-diff-viewer][data-pull-request-id='${cachedSwitchIds.secondId}']`, { state: "visible", timeout: 10_000 }); + await prContextPage.evaluate(() => { + const secondViewer = document.querySelector("[data-pr-diff-viewer]"); + const spacer = document.createElement("div"); + spacer.style.height = "2400px"; + spacer.dataset.cachedSwitchSpacer = "second"; + secondViewer.append(spacer); + const secondDetail = secondViewer.closest(".pr-diff-detail"); + void secondDetail.scrollHeight; + secondDetail.scrollTop = 240; + secondViewer.dataset.cachedSwitchProbe = "second"; + window.__tychoCachedSecondViewer = secondViewer; + }); + await prContextPage.click(`.pr-diff-nav-item[href*='${encodeURIComponent(cachedSwitchIds.firstId)}']`); + await prContextPage.waitForSelector(`[data-pr-diff-viewer][data-pull-request-id='${cachedSwitchIds.firstId}']`, { state: "visible", timeout: 10_000 }); + const cachedFirstViewerContract = await prContextPage.evaluate(() => ({ + reused: document.querySelector("[data-pr-diff-viewer]") === window.__tychoCachedFirstViewer && + document.querySelector("[data-pr-diff-viewer]")?.dataset.cachedSwitchProbe === "first", + comment: document.querySelector("[data-pr-context-comment]")?.value, + selected: document.querySelectorAll("[data-select-pr-diff-line]:checked").length, + scrollTop: document.querySelector(".pr-diff-detail")?.scrollTop, + })); + const cachedFirstViewerReused = cachedFirstViewerContract.reused && + cachedFirstViewerContract.comment === "Persistent first PR comment" && + cachedFirstViewerContract.selected === 1 && + Math.abs(cachedFirstViewerContract.scrollTop - 140) <= 1; + await prContextPage.click(`.pr-diff-nav-item[href*='${encodeURIComponent(cachedSwitchIds.secondId)}']`); + await prContextPage.waitForSelector(`[data-pr-diff-viewer][data-pull-request-id='${cachedSwitchIds.secondId}']`, { state: "visible", timeout: 10_000 }); + const cachedSecondViewerContract = await prContextPage.evaluate(() => ({ + reused: document.querySelector("[data-pr-diff-viewer]") === window.__tychoCachedSecondViewer && + document.querySelector("[data-pr-diff-viewer]")?.dataset.cachedSwitchProbe === "second", + scrollTop: document.querySelector(".pr-diff-detail")?.scrollTop, + cacheKeys: Array.from(state.pullRequestDiffViewers.keys()), + currentKey: pollContentKey(document.querySelector("[data-pr-diff-viewer]")), + firstKey: pollContentKey(window.__tychoCachedFirstViewer), + secondKey: pollContentKey(window.__tychoCachedSecondViewer), + })); + const cachedSecondViewerReused = cachedSecondViewerContract.reused && + Math.abs(cachedSecondViewerContract.scrollTop - 240) <= 1; + if (!cachedFirstViewerReused || !cachedSecondViewerReused) { + throw new Error(`Cached PR navigation lost viewer, form, selection, or scroll state: ${JSON.stringify({ cachedFirstViewerContract, cachedSecondViewerContract })}`); + } + await prContextPage.evaluate(() => document.querySelector("#cached-pr-scroll-smoke-style")?.remove()); + await prContextPage.setViewportSize({ width: 390, height: 844 }); + await prContextPage.evaluate(() => new Promise((resolve) => ( + requestAnimationFrame(() => requestAnimationFrame(resolve)) + ))); + const mobileScrollContract = await prContextPage.evaluate(async ({ firstId, secondId }) => { + const switchTo = async (id) => { + Array.from(document.querySelectorAll(".pr-diff-nav-item")) + .find((item) => item.getAttribute("href")?.includes(encodeURIComponent(id))) + .click(); + const deadline = Date.now() + 10_000; + const renderKeyPrefix = `${pullRequestDiffKey(parseRoute().key, id)}:`; + while (!state.pullRequestDiffRenderReadyKey.startsWith(renderKeyPrefix)) { + if (Date.now() > deadline) throw new Error(`Timed out rendering ${id}`); + await new Promise((resolve) => setTimeout(resolve, 10)); + } + await new Promise((resolve) => requestAnimationFrame(resolve)); + }; + window.scrollTo(0, 500); + await new Promise((resolve) => requestAnimationFrame(resolve)); + const secondTop = window.scrollY; + await switchTo(firstId); + window.scrollTo(0, 300); + await new Promise((resolve) => requestAnimationFrame(resolve)); + const firstTop = window.scrollY; + await switchTo(secondId); + const restoredSecondTop = window.scrollY; + await switchTo(firstId); + return { firstTop, secondTop, restoredFirstTop: window.scrollY, restoredSecondTop }; + }, cachedSwitchIds); + await prContextPage.setViewportSize({ width: 1280, height: 900 }); + if (mobileScrollContract.firstTop < 1 || mobileScrollContract.secondTop < 1 || + Math.abs(mobileScrollContract.restoredFirstTop - mobileScrollContract.firstTop) > 1 || + Math.abs(mobileScrollContract.restoredSecondTop - mobileScrollContract.secondTop) > 1) { + throw new Error(`Cached mobile PR navigation lost page scroll: ${JSON.stringify(mobileScrollContract)}`); + } + await prContextPage.evaluate(({ key, id }) => { + const selection = pullRequestDiffSelection(key, id); + selection.anchor = null; + selection.lines = []; + selection.comment = ""; + state.renderedViewHtml = ""; + render(); + }, { key: prContextAgentKey, id: cachedSwitchIds.firstId }); + await prContextPage.waitForSelector(`[data-pr-diff-viewer][data-pull-request-id='${cachedSwitchIds.firstId}']`, { state: "visible", timeout: 10_000 }); + const selectableLines = prContextPage.locator("[data-select-pr-diff-line]"); + if (await selectableLines.count() !== 2) throw new Error("PR detail did not expose selectable changed lines"); + const firstLine = selectableLines.nth(0); + const secondLine = selectableLines.nth(1); + if (!(await firstLine.getAttribute("aria-label"))?.includes("lib/example.rb")) { + throw new Error("PR diff line control is missing an accessible file and line label"); + } + const rangeLimitContract = await prContextPage.evaluate(async (key) => { + const route = parseRoute(); + const pullRequestId = selectedPullRequestId(key, route.pullRequestId); + const diffKey = pullRequestDiffKey(key, pullRequestId); + const diff = state.pullRequestDiffs[diffKey]; + const originalLines = diff.files[0].hunks[0].lines; + diff.files[0].hunks[0].lines = Array.from({ length: 101 }, (_, index) => ({ + kind: "context", old_number: index + 1, new_number: index + 1, content: `line ${index + 1}`, + })); + state.renderedViewHtml = ""; + render(); + selectPullRequestDiffLine({ dataset: { + agentKey: key, + pullRequestId, + snapshotId: diff.snapshot_id, + reviewLinePath: "lib/example.rb", + reviewLineHunk: "0", + reviewLineIndex: "0", + } }, false); + await new Promise((resolve) => requestAnimationFrame(resolve)); + const selection = pullRequestDiffSelection(key, pullRequestId, diff.snapshot_id); + const inlineComment = document.querySelector("[data-pr-inline-comment]"); + const longLines = Array.from(document.querySelectorAll("[data-select-pr-diff-line]")); + const inlinePlacement = Boolean( + inlineComment && longLines[0].compareDocumentPosition(inlineComment) & Node.DOCUMENT_POSITION_FOLLOWING && + inlineComment.compareDocumentPosition(longLines[1]) & Node.DOCUMENT_POSITION_FOLLOWING + ); + const focusedInlineComment = document.activeElement === document.querySelector("[data-pr-context-comment]"); + selectPullRequestDiffLine({ dataset: { + agentKey: key, + pullRequestId, + snapshotId: diff.snapshot_id, + reviewLinePath: "lib/example.rb", + reviewLineHunk: "0", + reviewLineIndex: "100", + } }, true); + const message = document.querySelector("#growl")?.innerText || ""; + diff.files[0].hunks[0].lines = originalLines; + selection.anchor = null; + selection.lines = []; + state.renderedViewHtml = ""; + render(); + return { message, inlinePlacement, focusedInlineComment }; + }, prContextAgentKey); + if (!rangeLimitContract.message.includes("at most 100 contiguous lines") || + !rangeLimitContract.inlinePlacement || !rangeLimitContract.focusedInlineComment) { + throw new Error(`PR long-hunk placement, focus, or range limit failed: ${JSON.stringify(rangeLimitContract)}`); + } + const diffErrorState = await prContextPage.evaluate((key) => { + const route = parseRoute(); + const pullRequestId = selectedPullRequestId(key, route.pullRequestId); + const diffKey = pullRequestDiffKey(key, pullRequestId); + const original = state.pullRequestDiffs[diffKey]; + state.pullRequestDiffs[diffKey] = { error: "stale fixture diff", files: [] }; + state.renderedViewHtml = ""; + render(); + const visible = document.querySelector(".ui-alert")?.innerText || ""; + state.pullRequestDiffs[diffKey] = original; + state.renderedViewHtml = ""; + render(); + return visible; + }, prContextAgentKey); + if (!diffErrorState.includes("Diff unavailable") || !diffErrorState.includes("stale fixture diff")) { + throw new Error(`PR diff errors are not visible: ${JSON.stringify(diffErrorState)}`); + } + const largeDiffSelectionContract = await prContextPage.evaluate(async (key) => { + const route = parseRoute(); + const pullRequestId = selectedPullRequestId(key, route.pullRequestId); + const diff = state.pullRequestDiffs[pullRequestDiffKey(key, pullRequestId)]; + const originalLines = diff.files[0].hunks[0].lines; + const originalSnapshotId = diff.snapshot_id; + const originalExpandAll = state.prDiffExpandAll[key]; + diff.files[0].hunks[0].lines = Array.from({ length: 5_000 }, (_, index) => ({ + kind: "context", old_number: index + 1, new_number: index + 1, content: `large diff line ${index + 1}`, + })); + state.renderedViewHtml = ""; + const fullRenderStartedAt = performance.now(); + render(); + const fullRenderSyncMs = performance.now() - fullRenderStartedAt; + await new Promise((resolve) => requestAnimationFrame(resolve)); + const fullRenderMs = performance.now() - fullRenderStartedAt; + + const viewerBefore = document.querySelector("[data-pr-diff-viewer]"); + const target = document.querySelectorAll("[data-select-pr-diff-line]")[900]; + target.scrollIntoView({ block: "center", inline: "nearest" }); + await new Promise((resolve) => requestAnimationFrame(resolve)); + const scrollBefore = window.scrollY; + const lineTopBefore = target.closest(".diff-line").getBoundingClientRect().top; + const startedAt = performance.now(); + target.click(); + const selectionSyncMs = performance.now() - startedAt; + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + const durationMs = performance.now() - startedAt; + const result = { + fullRenderMs, + fullRenderSyncMs, + durationMs, + selectionSyncMs, + viewerPersistent: viewerBefore === document.querySelector("[data-pr-diff-viewer]"), + scrollDelta: Math.abs(window.scrollY - scrollBefore), + lineDelta: Math.abs(target.closest(".diff-line").getBoundingClientRect().top - lineTopBefore), + formVisible: Boolean(document.querySelector("[data-pr-inline-comment]")), + }; + + const pollViewer = document.querySelector("[data-pr-diff-viewer]"); + const pollComment = document.querySelector("[data-pr-context-comment]"); + const pollScrollContainer = document.querySelector(".pr-diff-detail"); + const pollScrollTop = window.scrollY; + const pollLineTop = target.closest(".diff-line").getBoundingClientRect().top; + const originalRenderDiffLine = renderDiffLine; + let pollDiffLineRenderCount = 0; + renderDiffLine = (...args) => { + pollDiffLineRenderCount += 1; + return originalRenderDiffLine(...args); + }; + const pollRenderStartedAt = performance.now(); + render({ preserveLiveEditor: true, preservePollContent: true }); + result.pollRenderSyncMs = performance.now() - pollRenderStartedAt; + result.pollDiffLineRenderCount = pollDiffLineRenderCount; + renderDiffLine = originalRenderDiffLine; + await new Promise((resolve) => requestAnimationFrame(resolve)); + result.pollViewerPersistent = pollViewer === document.querySelector("[data-pr-diff-viewer]"); + result.pollCommentPersistent = pollComment === document.querySelector("[data-pr-context-comment]"); + result.pollFocusPersistent = document.activeElement === pollComment; + result.pollContainerReplaced = pollScrollContainer !== document.querySelector(".pr-diff-detail"); + result.pollScrollDelta = Math.abs(window.scrollY - pollScrollTop); + result.pollLineDelta = Math.abs(target.closest(".diff-line").getBoundingClientRect().top - pollLineTop); + pollComment.value = "Live comment text must not invalidate the diff shell."; + pollComment.dispatchEvent(new Event("input", { bubbles: true })); + let steadyPollDiffLineRenderCount = 0; + renderDiffLine = (...args) => { + steadyPollDiffLineRenderCount += 1; + return originalRenderDiffLine(...args); + }; + const steadyPollStartedAt = performance.now(); + render({ preserveLiveEditor: true, preservePollContent: true }); + result.steadyPollRenderSyncMs = performance.now() - steadyPollStartedAt; + renderDiffLine = originalRenderDiffLine; + result.steadyPollDiffLineRenderCount = steadyPollDiffLineRenderCount; + result.steadyPollComment = document.querySelector("[data-pr-context-comment]")?.value; + result.steadyPollViewerPersistent = pollViewer === document.querySelector("[data-pr-diff-viewer]"); + + const item = state.pullRequests[key].items.find((candidate) => candidate.id === pullRequestId); + const originalTitle = item.title; + item.title = `${originalTitle} updated`; + let forcedRenderDiffLineCount = 0; + renderDiffLine = (...args) => { + forcedRenderDiffLineCount += 1; + return originalRenderDiffLine(...args); + }; + render({ preserveLiveEditor: true, preservePollContent: false }); + renderDiffLine = originalRenderDiffLine; + result.forcedRenderDiffLineCount = forcedRenderDiffLineCount; + result.forcedViewerPersistent = pollViewer === document.querySelector("[data-pr-diff-viewer]"); + result.forcedCommentPersistent = pollComment === document.querySelector("[data-pr-context-comment]"); + result.forcedTitleUpdated = document.querySelector(".pr-diff-title-meta strong")?.textContent === item.title; + item.title = originalTitle; + + let changedSnapshotRenderCount = 0; + renderDiffLine = (...args) => { + changedSnapshotRenderCount += 1; + return originalRenderDiffLine(...args); + }; + diff.snapshot_id = `${originalSnapshotId}-changed`; + render({ preserveLiveEditor: true, preservePollContent: true }); + result.changedSnapshotImmediateRenderCount = changedSnapshotRenderCount; + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + renderDiffLine = originalRenderDiffLine; + result.changedSnapshotRenderCount = changedSnapshotRenderCount; + result.changedSnapshotViewerReplaced = pollViewer !== document.querySelector("[data-pr-diff-viewer]"); + + const changedSnapshotViewer = document.querySelector("[data-pr-diff-viewer]"); + diff.snapshot_id = originalSnapshotId; + let collapsedRenderCount = 0; + renderDiffLine = (...args) => { + collapsedRenderCount += 1; + return originalRenderDiffLine(...args); + }; + document.querySelector(`[data-toggle-pr-diff-expand-all="${CSS.escape(key)}"]`).click(); + result.collapsedImmediateRenderCount = collapsedRenderCount; + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + renderDiffLine = originalRenderDiffLine; + result.collapsedRenderCount = collapsedRenderCount; + result.collapsedViewerReplaced = changedSnapshotViewer !== document.querySelector("[data-pr-diff-viewer]"); + result.collapsedViewerPresent = Boolean(document.querySelector("[data-pr-diff-viewer]")); + result.collapsedLoadingVisible = Boolean(document.querySelector(".pr-diff-loading-state")); + result.collapsedFilesClosed = Array.from(document.querySelectorAll("[data-pr-diff-viewer] .diff-file")) + .every((detail) => !detail.open); + + const selection = pullRequestDiffSelection(key, pullRequestId, diff.snapshot_id); + selection.anchor = null; + selection.lines = []; + selection.comment = ""; + if (originalExpandAll === undefined) delete state.prDiffExpandAll[key]; + else state.prDiffExpandAll[key] = originalExpandAll; + document.querySelectorAll(".pr-diff-detail .diff-file").forEach((detail) => { + detail.open = originalExpandAll !== false; + }); + diff.files[0].hunks[0].lines = originalLines; + state.renderedViewHtml = ""; + render(); + return result; + }, prContextAgentKey); + if (!largeDiffSelectionContract.viewerPersistent || !largeDiffSelectionContract.formVisible || + !largeDiffSelectionContract.pollViewerPersistent || !largeDiffSelectionContract.pollCommentPersistent || + !largeDiffSelectionContract.pollFocusPersistent || + largeDiffSelectionContract.pollContainerReplaced || + largeDiffSelectionContract.pollDiffLineRenderCount !== 0 || + !largeDiffSelectionContract.steadyPollViewerPersistent || + largeDiffSelectionContract.steadyPollDiffLineRenderCount !== 0 || + largeDiffSelectionContract.steadyPollComment !== "Live comment text must not invalidate the diff shell." || + largeDiffSelectionContract.forcedRenderDiffLineCount !== 0 || + !largeDiffSelectionContract.forcedViewerPersistent || + !largeDiffSelectionContract.forcedCommentPersistent || + !largeDiffSelectionContract.forcedTitleUpdated || + largeDiffSelectionContract.changedSnapshotImmediateRenderCount !== 0 || + largeDiffSelectionContract.changedSnapshotRenderCount !== 5_000 || + !largeDiffSelectionContract.changedSnapshotViewerReplaced || + largeDiffSelectionContract.collapsedImmediateRenderCount !== 0 || + ![0, 5_000].includes(largeDiffSelectionContract.collapsedRenderCount) || + !largeDiffSelectionContract.collapsedViewerReplaced || + !largeDiffSelectionContract.collapsedViewerPresent || + largeDiffSelectionContract.collapsedLoadingVisible || + !largeDiffSelectionContract.collapsedFilesClosed || + largeDiffSelectionContract.scrollDelta > 1 || largeDiffSelectionContract.lineDelta > 1 || + largeDiffSelectionContract.pollScrollDelta > 1 || largeDiffSelectionContract.pollLineDelta > 1) { + throw new Error(`Large PR selection did not preserve the diff, form, or scroll: ${JSON.stringify(largeDiffSelectionContract)}`); + } + console.log(`PR rendering (5,000 lines): full ${largeDiffSelectionContract.fullRenderSyncMs.toFixed(1)}ms sync / selection ${largeDiffSelectionContract.selectionSyncMs.toFixed(1)}ms sync / first poll ${largeDiffSelectionContract.pollRenderSyncMs.toFixed(1)}ms sync / steady poll ${largeDiffSelectionContract.steadyPollRenderSyncMs.toFixed(1)}ms sync`); + await prContextPage.evaluate(async (key) => { + const route = parseRoute(); + const pullRequestId = selectedPullRequestId(key, route.pullRequestId); + const diff = state.pullRequestDiffs[pullRequestDiffKey(key, pullRequestId)]; + diff.files[0].hunks[0].lines[1].content = `new ${"wide-code-column ".repeat(24)}`; + diff.files[0].hunks[0].lines.push({ + kind: "context", old_number: 2, new_number: 2, content: "following unselected line", + }); + state.pullRequestDiffViewers.clear(); + resetPullRequestDiffRender(); + state.renderedViewHtml = ""; + render(); + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + }, prContextAgentKey); + await prContextPage.waitForSelector("[data-select-pr-diff-line]", { state: "visible", timeout: 10_000 }); + await firstLine.click(); + await secondLine.click({ modifiers: ["Shift"] }); + await prContextPage.waitForSelector("[data-pr-inline-comment]", { state: "visible", timeout: 10_000 }); + await prContextPage.evaluate(() => new Promise((resolve) => requestAnimationFrame(resolve))); + const rangeSelectionState = await prContextPage.evaluate((key) => { + const route = parseRoute(); + const pullRequestId = selectedPullRequestId(key, route.pullRequestId); + const diff = state.pullRequestDiffs[pullRequestDiffKey(key, pullRequestId)]; + const selection = pullRequestDiffSelection(key, pullRequestId, diff.snapshot_id); + return { + checked: document.querySelectorAll("[data-select-pr-diff-line]:checked").length, + selected: selection.lines.map((line) => line.line_index), + anchor: selection.anchor?.line_index, + snapshotId: diff.snapshot_id, + domSnapshotIds: Array.from(document.querySelectorAll("[data-select-pr-diff-line]"), (line) => line.dataset.snapshotId).slice(0, 3), + }; + }, prContextAgentKey); + if (rangeSelectionState.checked !== 2) { + throw new Error(`PR line range did not select two lines: ${JSON.stringify(rangeSelectionState)}`); + } + const selectedRange = await prContextPage.evaluate((key) => { + const route = parseRoute(); + const pullRequestId = selectedPullRequestId(key, route.pullRequestId); + const diff = state.pullRequestDiffs[pullRequestDiffKey(key, pullRequestId)]; + const selection = pullRequestDiffSelection(key, pullRequestId, diff.snapshot_id); + return { + selected: document.querySelectorAll("[data-select-pr-diff-line]:checked").length, + selectedIndexes: selection.lines.map((line) => line.line_index), + anchorIndex: selection.anchor?.line_index, + selectionText: document.querySelector("[data-pr-inline-comment]")?.innerText, + overflow: document.documentElement.scrollWidth > document.documentElement.clientWidth, + }; + }, prContextAgentKey); + if (selectedRange.selected !== 2 || !selectedRange.selectionText?.includes("Commenting on") || selectedRange.overflow) { + throw new Error(`PR line range selection is not usable on mobile: ${JSON.stringify(selectedRange)}`); + } + await prContextPage.fill("[data-pr-context-comment]", "Clarify this mixed-side range."); + await prContextPage.setViewportSize({ width: 1210, height: 640 }); + const landscapeInlineContract = await prContextPage.evaluate(() => { + const body = document.querySelector(".diff-file-body"); + const panel = document.querySelector("[data-pr-inline-comment]"); + const send = document.querySelector("[data-send-pr-diff-comment]"); + const add = document.querySelector("[data-attach-pr-diff-selection]"); + const selectedCheck = document.querySelector("[data-select-pr-diff-line]:checked + .diff-line-check"); + const emptyCheck = document.querySelector("[data-select-pr-diff-line]:not(:checked) + .diff-line-check"); + const nativeCheck = document.querySelector("[data-select-pr-diff-line]"); + const bodyRect = body?.getBoundingClientRect(); + const panelRect = panel?.getBoundingClientRect(); + const sendRect = send?.getBoundingClientRect(); + const addRect = add?.getBoundingClientRect(); + return { + bodyWidth: bodyRect?.width, + panelWidth: panelRect?.width, + panelInside: panelRect.left >= bodyRect.left - 1 && panelRect.right <= bodyRect.right + 1, + actionsInside: [sendRect, addRect].every((rect) => rect.left >= bodyRect.left - 1 && rect.right <= bodyRect.right + 1), + sendVisible: getComputedStyle(send).visibility !== "hidden" && sendRect.width > 0, + nativeHidden: getComputedStyle(nativeCheck).opacity === "0", + selectedMark: selectedCheck?.textContent, + emptyMark: emptyCheck?.textContent, + }; + }); + if (!landscapeInlineContract.panelInside || !landscapeInlineContract.actionsInside || + !landscapeInlineContract.sendVisible || !landscapeInlineContract.nativeHidden || + landscapeInlineContract.selectedMark !== "✓" || landscapeInlineContract.emptyMark !== "") { + throw new Error(`Landscape inline comment or minimal line selector failed: ${JSON.stringify(landscapeInlineContract)}`); + } + await prContextPage.evaluate(() => { + document.querySelector("#growl")?.classList.add("hidden"); + document.querySelector("[data-pr-inline-comment]")?.scrollIntoView({ block: "center", inline: "nearest" }); + }); + if (captureDir) await prContextPage.screenshot({ path: path.join(captureDir, "pr-context-selected-landscape.png"), fullPage: false }); + await prContextPage.setViewportSize({ width: 390, height: 844 }); + await prContextPage.evaluate(() => document.querySelector("#growl")?.classList.add("hidden")); + if (captureDir) await prContextPage.screenshot({ path: path.join(captureDir, "pr-context-selected-mobile.png"), fullPage: true }); + const cancelContract = await prContextPage.evaluate(() => { + const viewer = document.querySelector("[data-pr-diff-viewer]"); + const selectedLine = document.querySelector(".diff-line.selected"); + const lineTop = selectedLine?.getBoundingClientRect().top; + const scrollTop = window.scrollY; + document.querySelector("[data-clear-pr-diff-selection]").click(); + return { + viewerPersistent: viewer === document.querySelector("[data-pr-diff-viewer]"), + formRemoved: !document.querySelector("[data-pr-inline-comment]"), + selectedRows: document.querySelectorAll(".diff-line.selected").length, + checkedLines: document.querySelectorAll("[data-select-pr-diff-line]:checked").length, + selectedMarks: document.querySelectorAll(".diff-line-check.selected").length, + scrollDelta: Math.abs(window.scrollY - scrollTop), + lineDelta: Math.abs((selectedLine?.getBoundingClientRect().top || 0) - (lineTop || 0)), + }; + }); + if (!cancelContract.viewerPersistent || !cancelContract.formRemoved || cancelContract.selectedRows || + cancelContract.checkedLines || cancelContract.selectedMarks || cancelContract.scrollDelta > 1 || cancelContract.lineDelta > 1) { + throw new Error(`Cancel did not clear the persistent PR comment UI in place: ${JSON.stringify(cancelContract)}`); + } + await prContextPage.evaluate(() => new Promise((resolve) => requestAnimationFrame(resolve))); + await firstLine.click(); + await secondLine.click({ modifiers: ["Shift"] }); + await prContextPage.evaluate(() => new Promise((resolve) => requestAnimationFrame(resolve))); + if (await prContextPage.locator("[data-select-pr-diff-line]:checked").count() !== 2) { + throw new Error("PR range could not be reselected after clearing it"); + } + await prContextPage.keyboard.press("Escape"); + if (await prContextPage.locator("[data-select-pr-diff-line]:checked").count()) { + const escapeState = await prContextPage.evaluate((key) => ({ + route: parseRoute(), + selections: state.pullRequestDiffSelections, + speech: Boolean(state.speechRecognition), + key, + }), prContextAgentKey); + throw new Error(`Escape did not clear the active PR diff range: ${JSON.stringify(escapeState)}`); + } + await firstLine.click(); + await secondLine.click({ modifiers: ["Shift"] }); + await prContextPage.fill("[data-pr-context-comment]", "Clarify this mixed-side range."); + await prContextPage.evaluate(async () => refresh({ force: true, forceConversation: true })); + await prContextPage.waitForSelector("[data-select-pr-diff-line]:checked", { state: "visible", timeout: 10_000 }); + const refreshedInlineComment = await prContextPage.inputValue("[data-pr-context-comment]"); + if (await prContextPage.locator("[data-select-pr-diff-line]:checked").count() !== 2 || + refreshedInlineComment !== "Clarify this mixed-side range.") { + throw new Error("PR line selection and comment did not survive a conversation refresh"); + } + await prContextPage.click("[data-send-pr-diff-comment]"); + await prContextPage.waitForFunction( + (key) => arrayValue(state.conversations[key]?.blocks) + .some((block) => block.role === "user" && block.content?.includes("Clarify this mixed-side range.")), + prContextAgentKey, + { timeout: 10_000 } + ); + await prContextPage.waitForFunction((key) => !agentIsRunning(findAgent(key)), prContextAgentKey, { timeout: 10_000 }); + await prContextPage.waitForFunction((key) => !state.pendingPullRequestCommentKeys.has(key), prContextAgentKey, { timeout: 10_000 }); + if (await prContextPage.locator("[data-select-pr-diff-line]:checked").count()) { + throw new Error("Direct diff comment did not clear its completed selection"); + } + await firstLine.click(); + await secondLine.click({ modifiers: ["Shift"] }); + await prContextPage.fill("[data-pr-context-comment]", "Combine this section with another."); + await prContextPage.click("[data-attach-pr-diff-selection]"); + await prContextPage.waitForFunction(() => document.querySelectorAll("[data-pending-pr-context]").length === 1); + await prContextPage.click("[data-open-pr-context-composer]"); + await prContextPage.waitForSelector("[data-pending-pr-context]", { state: "visible", timeout: 10_000 }); + const pendingContext = await prContextPage.evaluate(() => ({ + text: document.querySelector("[data-pending-pr-context]")?.innerText, + reviewButton: document.querySelector("[data-open-pr-context-composer]")?.innerText, + focused: document.activeElement?.id === "prompt-input", + })); + if (!pendingContext.text?.includes("example/web#123") || + !pendingContext.text?.includes("lib/example.rb") || + !pendingContext.text?.includes("mixed sides") || + !pendingContext.text?.includes("Combine this section with another.") || + !pendingContext.reviewButton?.includes("Review combined comment") || + !pendingContext.focused) { + throw new Error(`Combined PR comment section is incomplete: ${JSON.stringify(pendingContext)}`); + } + await prContextPage.click("[data-remove-pr-context]"); + if (await prContextPage.locator("[data-pending-pr-context]").count()) { + throw new Error("Pending PR context could not be removed"); + } + await firstLine.click(); + await secondLine.click({ modifiers: ["Shift"] }); + await prContextPage.fill("[data-pr-context-comment]", "Duplicate range check."); + await prContextPage.click("[data-attach-pr-diff-selection]"); + await firstLine.click(); + await secondLine.click({ modifiers: ["Shift"] }); + await prContextPage.fill("[data-pr-context-comment]", "Duplicate range check."); + await prContextPage.click("[data-attach-pr-diff-selection]"); + if (await prContextPage.locator("[data-pending-pr-context]").count() !== 1 || + !(await prContextPage.locator("#growl").innerText()).includes("already attached")) { + throw new Error("Duplicate PR context was not rejected visibly"); + } + await prContextPage.evaluate(async (key) => { + const route = parseRoute(); + const pullRequestId = selectedPullRequestId(key, route.pullRequestId); + const diff = state.pullRequestDiffs[pullRequestDiffKey(key, pullRequestId)]; + diff.snapshot_id = "newer-snapshot"; + state.renderedViewHtml = ""; + render(); + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + }, prContextAgentKey); + if (!(await prContextPage.locator("[data-pending-pr-context]").innerText()).includes("outdated")) { + throw new Error("Stale pending PR context is not called out before submission"); + } + await prContextPage.evaluate(async (key) => { + const route = parseRoute(); + const pullRequestId = selectedPullRequestId(key, route.pullRequestId); + const diff = state.pullRequestDiffs[pullRequestDiffKey(key, pullRequestId)]; + diff.snapshot_id = document.querySelector("[data-pending-pr-context]")?.dataset.pendingPrContext + ? pendingPullRequestContextsFor(key)[0].snapshot_id + : diff.snapshot_id; + state.renderedViewHtml = ""; + render(); + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + }, prContextAgentKey); + await prContextPage.setViewportSize({ width: 1280, height: 800 }); + await prContextPage.evaluate(() => document.querySelector("#growl")?.classList.add("hidden")); + if (captureDir) await prContextPage.screenshot({ path: path.join(captureDir, "pr-context-pending-desktop.png"), fullPage: true }); + await prContextPage.evaluate(() => { + window.__originalPendingAttachmentPayloads = pendingAttachmentPayloads; + pendingAttachmentPayloads = async () => new Promise((resolve) => { + window.__releasePromptAttachmentPayloads = () => resolve([]); + }); + }); + await prContextPage.fill("#prompt-input", "Explain the selected change."); + await prContextPage.click("#composer button[type='submit']"); + await prContextPage.waitForFunction(() => typeof window.__releasePromptAttachmentPayloads === "function"); + await firstLine.click(); + await prContextPage.fill("[data-pr-context-comment]", "A later comment section."); + await prContextPage.click("[data-attach-pr-diff-selection]"); + if (await prContextPage.locator("[data-pending-pr-context]").count() !== 2) { + throw new Error("PR context could not be queued while another prompt payload was encoding"); + } + await prContextPage.evaluate(() => window.__releasePromptAttachmentPayloads()); + await prContextPage.waitForFunction( + () => { + const key = parseRoute().key; + return !state.pendingComposerKeys.has(key) && arrayValue(state.conversations[key]?.blocks) + .some((block) => block.content?.includes("Explain the selected change.") && block.content?.includes("Duplicate range check.")); + }, + null, + { timeout: 10_000 } + ); + const sentContext = await prContextPage.evaluate((key) => { + pendingAttachmentPayloads = window.__originalPendingAttachmentPayloads; + const message = arrayValue(state.conversations[key]?.blocks) + .find((block) => block.content?.includes("Explain the selected change.") && block.content?.includes("[TYCHO_PR_DIFF_CONTEXT]")); + return { + content: message?.content || "", + pending: pendingPullRequestContextsFor(key).map((context) => context.lines.length), + }; + }, prContextAgentKey); + if (!sentContext.content.includes('"repository":"example/web"') || + !sentContext.content.includes('"path":"lib/example.rb"') || + !sentContext.content.includes('"side":"left"') || + !sentContext.content.includes('"side":"right"') || + !sentContext.content.includes("Duplicate range check.") || + JSON.stringify(sentContext.pending) !== "[1]") { + throw new Error(`Sent PR context lost metadata or consumed a later range: ${JSON.stringify(sentContext)}`); + } await prContextPage.close(); const pausedReviewPage = await browser.newPage({ viewport: { width: 390, height: 844 } }); @@ -1788,12 +2821,40 @@ def write_smoke_script(path) await reviewPage.close(); } - await page.goto(`${baseUrl}/ui#agent/${encodeURIComponent(agentKey)}`, { waitUntil: "networkidle" }); + const speechAgentKey = await page.evaluate(async () => { + const data = await apiPost("/agents", { + project_key: "web", + template_key: "custom", + name: "Speech Smoke Agent", + prompt: "Test speech input.", + agent: "codex", + }); + state.agents.push(data.agent); + return data.agent.key; + }); + await page.goto(`${baseUrl}/ui#agent/${encodeURIComponent(speechAgentKey)}`, { waitUntil: "networkidle" }); await page.waitForSelector("#prompt-input", { state: "visible", timeout: 10_000 }); await page.evaluate(() => { window.SpeechRecognition = class SmokeSpeechRecognition { + constructor() { + window.__speechRecognitionInstances ||= []; + window.__speechRecognitionInstances.push(this); + } start() { window.__speechRecognitionStarted = (window.__speechRecognitionStarted || 0) + 1; } - stop() {} + stop() { + this.stopped = true; + if (this.finalOnStop) { + this.onresult?.({ + resultIndex: 0, + results: [{ isFinal: true, 0: { transcript: this.finalOnStop } }], + }); + } + this.onend?.(); + } + abort() { + this.aborted = true; + this.onerror?.({ error: "aborted" }); + } }; state.renderedViewHtml = ""; render(); @@ -1805,9 +2866,46 @@ def write_smoke_script(path) els.view.append(hiddenComposer); document.activeElement?.blur(); }); - await page.keyboard.press("Meta+Shift+."); + await page.waitForTimeout(1200); + const speechIconContract = await page.evaluate(() => ({ + icon: Boolean(document.querySelector('[data-toggle-speech-mode] path[d^="M12 2a3"]')), + state: document.querySelector("[data-toggle-speech-mode]")?.dataset.state, + label: document.querySelector("[data-toggle-speech-mode]")?.getAttribute("aria-label"), + inEditor: Boolean(document.querySelector(".composer-editor-shell > [data-speech-mode-control]")), + micTop: document.querySelector("[data-toggle-speech-mode]")?.getBoundingClientRect().top, + fullScreenTop: document.querySelector("[data-toggle-composer-full-screen]")?.getBoundingClientRect().top, + })); + if (!speechIconContract.icon || speechIconContract.state !== "idle" || + !speechIconContract.label?.includes("Start speech recognition") || + !speechIconContract.inEditor || speechIconContract.micTop <= speechIconContract.fullScreenTop) { + throw new Error(`Speech idle control is not clear or accessible: ${JSON.stringify(speechIconContract)}`); + } + await captureVisibleComposer("speech-idle-icon-desktop.png"); + await page.click("[data-toggle-speech-mode]"); + await page.evaluate(async () => refresh({ force: true, forceConversation: true })); + const listeningState = await page.evaluate(() => ({ + state: document.querySelector("[data-toggle-speech-mode]")?.dataset.state, + status: document.querySelector("[data-speech-mode-status]")?.textContent, + interimResults: state.speechRecognition?.interimResults, + started: window.__speechRecognitionStarted, + composerState: document.querySelector("#composer")?.dataset.speechState, + pulse: getComputedStyle(document.querySelector(".composer-editor-shell")).animationName, + statusHidden: document.querySelector("[data-speech-mode-status]")?.classList.contains("sr-only"), + })); + if (listeningState.state !== "listening" || !listeningState.status?.includes("Listening") || + !listeningState.interimResults || listeningState.started !== 1 || + listeningState.composerState !== "listening" || !listeningState.pulse.includes("speech-listening") || + !listeningState.statusHidden) { + throw new Error(`Speech listening state is incomplete: ${JSON.stringify(listeningState)}`); + } + await captureVisibleComposer("speech-listening-desktop.png"); const speechShortcutContract = await page.evaluate((key) => { const recognition = state.speechRecognition; + recognition?.onresult?.({ + resultIndex: 0, + results: [{ isFinal: false, 0: { transcript: "spoken" } }], + }); + const interim = document.querySelector(`#composer[data-agent-key="${CSS.escape(key)}"] #prompt-input`)?.value; recognition?.onresult?.({ resultIndex: 0, results: [{ isFinal: true, 0: { transcript: "spoken smoke" } }], @@ -1816,19 +2914,36 @@ def write_smoke_script(path) started: window.__speechRecognitionStarted, selectedAgentKey: state.speechComposerKey, focusedPrompt: document.activeElement?.id === "prompt-input" && document.activeElement?.closest("#composer")?.dataset.agentKey === key, + interim, transcript: document.querySelector(`#composer[data-agent-key="${CSS.escape(key)}"] #prompt-input`)?.value, + state: state.speechState, + status: document.querySelector("[data-speech-mode-status]")?.textContent, shortcutHint: document.querySelector("[data-toggle-speech-mode]")?.getAttribute("aria-label"), }; - }, agentKey); + }, speechAgentKey); if (speechShortcutContract.started !== 1 || - speechShortcutContract.selectedAgentKey !== agentKey || + speechShortcutContract.selectedAgentKey !== speechAgentKey || !speechShortcutContract.focusedPrompt || + speechShortcutContract.interim !== "spoken" || speechShortcutContract.transcript !== "spoken smoke" || + speechShortcutContract.state !== "processing" || + !speechShortcutContract.status?.includes("Transcript added") || !speechShortcutContract.shortcutHint?.includes("Shift+.")) { throw new Error(`Speech shortcut did not select and focus the visible composer: ${JSON.stringify(speechShortcutContract)}`); } - if (captureDir) await page.screenshot({ path: path.join(captureDir, "speech-shortcut-desktop.png") }); + await captureVisibleComposer("speech-transcribed-desktop.png"); await page.evaluate(() => stopSpeechMode()); + await page.click("#composer button[type='submit']"); + await page.waitForFunction( + (key) => arrayValue(state.conversations[key]?.blocks).some((block) => block.role === "user" && block.content === "spoken smoke"), + speechAgentKey, + { timeout: 10_000 } + ); + await page.waitForFunction((key) => !agentIsRunning(findAgent(key)), speechAgentKey, { timeout: 10_000 }); + await page.evaluate(() => { + state.renderedViewHtml = ""; + render(); + }); const speechGuardContract = await page.evaluate((key) => { const shortcut = (overrides = {}) => { let prevented = false; @@ -1897,16 +3012,206 @@ def write_smoke_script(path) externalInput.remove(); editable.remove(); return results; - }, agentKey); + }, speechAgentKey); if (speechGuardContract.textInput.handled || speechGuardContract.textInput.prevented || speechGuardContract.contentEditable.handled || speechGuardContract.contentEditable.prevented || speechGuardContract.repeat.handled || speechGuardContract.composing.handled || speechGuardContract.dialog.handled || speechGuardContract.dialog.prevented || - speechGuardContract.unsupported.handled || speechGuardContract.unsupported.prevented || + !speechGuardContract.unsupported.handled || !speechGuardContract.unsupported.prevented || !speechGuardContract.multiple.handled || !speechGuardContract.focusedVisibleComposer || - !speechGuardContract.disabledIgnored || speechGuardContract.activeKey !== agentKey) { + !speechGuardContract.disabledIgnored || speechGuardContract.activeKey !== speechAgentKey) { throw new Error(`Speech shortcut guards or composer selection failed: ${JSON.stringify(speechGuardContract)}`); } + const speechLifecycleContract = await page.evaluate(async (key) => { + const composer = document.querySelector(`#composer[data-agent-key="${CSS.escape(key)}"]`); + const input = composer.querySelector("#prompt-input"); + input.value = "before after"; + input.setSelectionRange(6, 6); + startSpeechMode(composer); + const firstRecognition = state.speechRecognition; + firstRecognition.onresult({ + resultIndex: 0, + results: [{ isFinal: true, 0: { transcript: "spoken words" } }], + }); + const insertedAtCaret = input.value; + startSpeechMode(composer); + const repeatedStopped = firstRecognition.stopped === true && !state.speechRecognition; + + input.value = "keep"; + input.setSelectionRange(input.value.length, input.value.length); + startSpeechMode(composer); + const cleanStopRecognition = state.speechRecognition; + cleanStopRecognition.onresult({ + resultIndex: 0, + results: [{ isFinal: false, 0: { transcript: "last words" } }], + }); + cleanStopRecognition.finalOnStop = "last words finalized"; + startSpeechMode(composer); + const cleanStopped = input.value; + + input.value = "keep this"; + input.setSelectionRange(4, 4); + startSpeechMode(composer); + const cancelRecognition = state.speechRecognition; + cancelRecognition.onresult({ + resultIndex: 0, + results: [{ isFinal: false, 0: { transcript: " temporary" } }], + }); + stopSpeechMode({ cancel: true }); + const cancelled = input.value; + + input.value = Array.from({ length: 24 }, (_, index) => `existing line ${index + 1}`).join("\\n"); + input.setSelectionRange(input.value.length, input.value.length); + input.scrollTop = 0; + startSpeechMode(composer); + const scrollingRecognition = state.speechRecognition; + scrollingRecognition.onresult({ + resultIndex: 0, + results: [{ isFinal: false, 0: { transcript: "spoken text at the end" } }], + }); + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + const speechFollow = { + scrollTop: input.scrollTop, + scrollHeight: input.scrollHeight, + clientHeight: input.clientHeight, + }; + stopSpeechMode({ cancel: true }); + + const middleLines = Array.from({ length: 30 }, (_, index) => `middle line ${index + 1}`); + input.value = middleLines.join("\\n"); + input.dispatchEvent(new Event("input", { bubbles: true })); + const middleCaret = middleLines.slice(0, 10).join("\\n").length; + input.setSelectionRange(middleCaret, middleCaret); + input.scrollTop = 80; + const middleOriginalScrollTop = input.scrollTop; + startSpeechMode(composer); + const middleRecognition = state.speechRecognition; + middleRecognition.onresult({ + resultIndex: 0, + results: [{ isFinal: false, 0: { transcript: "spoken middle ".repeat(80) } }], + }); + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + const middleFollow = { + originalScrollTop: middleOriginalScrollTop, + followedScrollTop: input.scrollTop, + maxScrollTop: input.scrollHeight - input.clientHeight, + }; + stopSpeechMode({ cancel: true }); + await new Promise((resolve) => requestAnimationFrame(resolve)); + middleFollow.restoredScrollTop = input.scrollTop; + + startSpeechMode(composer); + const permissionRecognition = state.speechRecognition; + permissionRecognition.onerror({ error: "not-allowed" }); + const permission = { + state: document.querySelector("[data-toggle-speech-mode]")?.dataset.state, + status: document.querySelector("[data-speech-mode-status]")?.textContent, + label: document.querySelector("[data-toggle-speech-mode]")?.getAttribute("aria-label"), + }; + + startSpeechMode(composer); + const networkRecognition = state.speechRecognition; + networkRecognition.onerror({ error: "network" }); + const network = { + state: document.querySelector("[data-toggle-speech-mode]")?.dataset.state, + status: document.querySelector("[data-speech-mode-status]")?.textContent, + }; + + startSpeechMode(composer); + const rerenderRecognition = state.speechRecognition; + state.renderedViewHtml = "force speech cleanup"; + render(); + const rerenderPreserved = rerenderRecognition === state.speechRecognition && rerenderRecognition.stopped !== true; + + const navigationComposer = document.querySelector(`#composer[data-agent-key="${CSS.escape(key)}"]`); + stopSpeechMode({ cancel: true }); + startSpeechMode(navigationComposer); + const navigationRecognition = state.speechRecognition; + window.dispatchEvent(new HashChangeEvent("hashchange")); + const navigationCleaned = navigationRecognition.stopped === true && !state.speechRecognition; + + return { + insertedAtCaret, repeatedStopped, cleanStopped, cancelled, permission, network, + speechFollow, middleFollow, rerenderPreserved, navigationCleaned, + }; + }, speechAgentKey); + if (speechLifecycleContract.insertedAtCaret !== "before spoken words after" || + !speechLifecycleContract.repeatedStopped || + speechLifecycleContract.cleanStopped !== "keep last words finalized" || + speechLifecycleContract.cancelled !== "keep this" || + speechLifecycleContract.speechFollow.scrollTop <= 0 || + speechLifecycleContract.speechFollow.scrollTop + speechLifecycleContract.speechFollow.clientHeight < + speechLifecycleContract.speechFollow.scrollHeight - 2 || + speechLifecycleContract.middleFollow.followedScrollTop <= speechLifecycleContract.middleFollow.originalScrollTop || + speechLifecycleContract.middleFollow.followedScrollTop >= speechLifecycleContract.middleFollow.maxScrollTop - 2 || + Math.abs(speechLifecycleContract.middleFollow.restoredScrollTop - speechLifecycleContract.middleFollow.originalScrollTop) > 1 || + speechLifecycleContract.permission.state !== "error" || + !speechLifecycleContract.permission.status?.includes("permission denied") || + !speechLifecycleContract.permission.label?.includes("Speech recognition error") || + !speechLifecycleContract.permission.label?.includes("permission denied") || + speechLifecycleContract.network.state !== "error" || + !speechLifecycleContract.network.status?.includes("network service") || + !speechLifecycleContract.rerenderPreserved || + !speechLifecycleContract.navigationCleaned) { + throw new Error(`Speech lifecycle, caret, permission, or cleanup failed: ${JSON.stringify(speechLifecycleContract)}`); + } + const speechFollowCapture = await page.evaluate(async (key) => { + const composer = document.querySelector(`#composer[data-agent-key="${CSS.escape(key)}"]`); + const input = composer.querySelector("#prompt-input"); + input.value = Array.from({ length: 24 }, (_, index) => `captured line ${index + 1}`).join("\\n"); + input.dispatchEvent(new Event("input", { bubbles: true })); + input.setSelectionRange(input.value.length, input.value.length); + input.scrollTop = 0; + startSpeechMode(composer); + state.speechRecognition.onresult({ + resultIndex: 0, + results: [{ isFinal: false, 0: { transcript: "spoken follow at the end" } }], + }); + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + return { + value: input.value, + scrollTop: input.scrollTop, + scrollHeight: input.scrollHeight, + clientHeight: input.clientHeight, + }; + }, speechAgentKey); + if (!speechFollowCapture.value.endsWith("spoken follow at the end") || speechFollowCapture.scrollTop <= 0 || + speechFollowCapture.scrollTop + speechFollowCapture.clientHeight < speechFollowCapture.scrollHeight - 2) { + throw new Error(`Speech follow capture did not stay at the active transcript: ${JSON.stringify(speechFollowCapture)}`); + } + await captureVisibleComposer("speech-follow-scroll-desktop.png"); + await page.evaluate(() => stopSpeechMode({ cancel: true })); + const unsupportedSpeechContract = await page.evaluate(() => { + const constructor = window.SpeechRecognition; + Object.defineProperties(window, { + SpeechRecognition: { configurable: true, value: undefined }, + webkitSpeechRecognition: { configurable: true, value: undefined }, + }); + state.renderedViewHtml = ""; + render(); + const button = document.querySelector("[data-toggle-speech-mode]"); + button.click(); + const result = { + disabled: button.disabled, + state: button.dataset.state, + status: document.querySelector("[data-speech-mode-status]")?.textContent, + label: button.getAttribute("aria-label"), + growl: document.querySelector("#growl")?.innerText, + }; + Object.defineProperty(window, "SpeechRecognition", { configurable: true, value: constructor }); + state.pollDeferredUntil = 0; + schedule(); + state.renderedViewHtml = ""; + render(); + return result; + }); + if (unsupportedSpeechContract.disabled || unsupportedSpeechContract.state !== "unsupported" || + !unsupportedSpeechContract.status?.includes("unavailable") || + !unsupportedSpeechContract.label?.includes("Use the keyboard instead") || + !unsupportedSpeechContract.growl?.includes("unavailable")) { + throw new Error(`Unsupported speech recognition is not actionable: ${JSON.stringify(unsupportedSpeechContract)}`); + } + await page.goto(`${baseUrl}/ui#agent/${encodeURIComponent(agentKey)}`, { waitUntil: "networkidle" }); await page.waitForSelector("#header-schedule-menu:not(.hidden)", { state: "visible", timeout: 10_000 }); const scheduleHeaderGeometry = await page.evaluate(() => { const scheduleTrigger = document.querySelector("[data-header-schedule-menu] > summary").getBoundingClientRect(); @@ -2214,10 +3519,14 @@ def write_smoke_script(path) const agent = findAgent(key); return agent && !agentIsRunning(agent); }, agentKey, { timeout: 10_000 }); + await page.evaluate(() => { + state.renderedViewHtml = ""; + render(); + }); await page.click("#prompt-input"); await page.fill("#prompt-input", "$rev"); await page.evaluate(() => refreshSkillAutocomplete(document.querySelector("#prompt-input"))); - await page.waitForSelector("[data-skill-autocomplete]:not(.hidden)", { state: "visible", timeout: 10_000 }); + await ensureSkillAutocompleteVisible(); if (!(await page.locator("[data-skill-autocomplete-rescan]").isVisible())) { throw new Error("Skill autocomplete re-scan command was not visible"); } @@ -2225,7 +3534,7 @@ def write_smoke_script(path) if (typeof refresh !== "function") throw new Error("refresh function is unavailable"); await refresh({ force: true, forceConversation: true }); }); - await page.waitForSelector("[data-skill-autocomplete]:not(.hidden)", { state: "visible", timeout: 10_000 }); + await ensureSkillAutocompleteVisible(); await page.waitForSelector("[data-skill-autocomplete-index]", { state: "visible", timeout: 10_000 }); const stillAutocompleting = await page.locator("#prompt-input").inputValue(); if (stillAutocompleting !== "$rev") { @@ -2588,6 +3897,366 @@ def write_smoke_script(path) state.renderedViewHtml = ""; render(); }, agentKey); + const focusedCodeHighlightContract = await page.evaluate((key) => { + const originalHash = location.hash; + const originalPrism = window.Prism; + const originalRubyFailure = codeHighlighter.failedLanguages.ruby; + const attachment = { + id: "smoke-code-highlight", + agent_key: key, + type: "file", + title: "smoke.rb", + path: "/tmp/tycho-smoke/smoke.rb", + format: "text", + mime_type: "text/x-ruby", + content: `puts :smoke # ${"x".repeat(500)}`, + }; + state.attachmentDetails[attachment.id] = attachment; + const agent = findAgent(key); + agent.attachments = [...(agent.attachments || []).filter((item) => item.id !== attachment.id), attachment]; + window.Prism = null; + codeHighlighter.failedLanguages.ruby = true; + history.replaceState(null, "", routeHash({ type: "agentAttachment", key, attachmentId: attachment.id })); + state.renderedRouteKey = null; + state.renderedViewHtml = ""; + render(); + clearTimeout(state.timer); + state.timer = null; + const workspaceBefore = document.querySelector("[data-agent-workspace]"); + const viewerBefore = document.querySelector(".attachment-code-viewer"); + viewerBefore.scrollLeft = 80; + const codeBefore = viewerBefore.querySelector("code")?.textContent; + window.Prism = { + languages: { ruby: {} }, + highlight: (source) => `${escapeHtml(source)}`, + }; + delete codeHighlighter.failedLanguages.ruby; + renderCodeRoute("ruby"); + const result = { + codeBefore, + highlighted: Boolean(document.querySelector(".attachment-code-viewer .smoke-token")), + scrollLeft: document.querySelector(".attachment-code-viewer")?.scrollLeft, + workspacePreserved: document.querySelector("[data-agent-workspace]") === workspaceBefore, + }; + if (originalPrism === undefined) delete window.Prism; + else window.Prism = originalPrism; + if (originalRubyFailure === undefined) delete codeHighlighter.failedLanguages.ruby; + else codeHighlighter.failedLanguages.ruby = originalRubyFailure; + history.replaceState(null, "", originalHash); + state.renderedRouteKey = null; + state.renderedViewHtml = ""; + render(); + schedule(); + return result; + }, agentKey); + if (!focusedCodeHighlightContract.codeBefore.startsWith("puts :smoke") || + !focusedCodeHighlightContract.highlighted || + focusedCodeHighlightContract.scrollLeft !== 80 || + !focusedCodeHighlightContract.workspacePreserved) { + throw new Error(`Focused code highlighting replaced the workspace or failed to upgrade: ${JSON.stringify(focusedCodeHighlightContract)}`); + } + const focusedMarkdownUpgradeContract = await page.evaluate(() => { + const originalMarked = window.marked; + const originalPurify = window.DOMPurify; + const originalFailed = markdownParser.failed; + const workspace = document.querySelector("[data-agent-workspace]"); + const fixture = document.createElement("div"); + fixture.dataset.smokeMarkdownFixture = "true"; + workspace.appendChild(fixture); + window.marked = null; + window.DOMPurify = null; + markdownParser.failed = false; + fixture.innerHTML = renderMarkdown("**focused markdown**", { menuScope: "smoke-focused-markdown" }); + const storedBeforeUpgrade = state.markdownFallbacks.size; + window.marked = { parse: () => "

focused markdown

" }; + window.DOMPurify = { sanitize: (html) => html }; + renderMarkdownRoute(); + const upgraded = Boolean(fixture.querySelector("strong")); + const workspacePreserved = document.querySelector("[data-agent-workspace]") === workspace; + + window.marked = null; + window.DOMPurify = null; + markdownParser.failed = true; + state.markdownFallbacks.clear(); + const failedHtml = renderMarkdown("**plain fallback**", { menuScope: "smoke-failed-markdown" }); + const failedFallbackStored = state.markdownFallbacks.size; + window.marked = originalMarked; + window.DOMPurify = originalPurify; + markdownParser.failed = originalFailed; + fixture.remove(); + return { storedBeforeUpgrade, upgraded, workspacePreserved, failedHtml, failedFallbackStored }; + }); + if (focusedMarkdownUpgradeContract.storedBeforeUpgrade < 1 || + !focusedMarkdownUpgradeContract.upgraded || + !focusedMarkdownUpgradeContract.workspacePreserved || + focusedMarkdownUpgradeContract.failedHtml.includes("data-markdown-fallback-key") || + focusedMarkdownUpgradeContract.failedFallbackStored !== 0) { + throw new Error(`Focused Markdown fallback upgrade or failure cleanup regressed: ${JSON.stringify(focusedMarkdownUpgradeContract)}`); + } + const focusedRoutePollingContract = await page.evaluate(async (key) => { + const originalHash = location.hash; + const originalFetch = window.fetch; + const originalRender = render; + const hiddenDescriptor = Object.getOwnPropertyDescriptor(document, "hidden"); + const savedFullScreenComposerKeys = state.fullScreenComposerKeys; + const savedFullScreenInquiryKeys = state.fullScreenInquiryKeys; + const savedPollDeferredUntil = state.pollDeferredUntil; + const routes = [ + { route: { type: "agentPullRequests", key }, workspacePaused: true, subtitle: "Conversation poll paused / PR details" }, + { route: { type: "agentAttachment", key, attachmentId: "smoke-attachment-menu" }, workspacePaused: true, subtitle: "Conversation poll paused / attachment" }, + { route: { type: "agentSummary", key }, workspacePaused: true, subtitle: "Conversation poll paused / summary" }, + { route: { type: "projectDiff", key: "web", scope: "worktree", backTo: { type: "agent", key } }, workspacePaused: true, subtitle: "Conversation poll paused / project diff" }, + { route: { type: "agent", key }, workspacePaused: false }, + ]; + let catalogReads = 0; + let conversationReads = 0; + let renderCalls = 0; + Object.defineProperty(document, "hidden", { configurable: true, get: () => false }); + state.fullScreenComposerKeys = new Set(); + state.fullScreenInquiryKeys = new Set(); + state.pollDeferredUntil = 0; + window.fetch = async (input, init) => { + const requestUrl = new URL(typeof input === "string" ? input : input.url, location.href); + if (requestUrl.pathname === "/servers/resources") catalogReads += 1; + if (requestUrl.pathname.endsWith("/conversation")) conversationReads += 1; + return originalFetch(input, init); + }; + render = (...args) => { + renderCalls += 1; + return originalRender(...args); + }; + try { + const results = []; + for (const { route, workspacePaused, subtitle } of routes) { + clearTimeout(state.timer); + state.timer = null; + catalogReads = 0; + conversationReads = 0; + renderCalls = 0; + history.replaceState(null, "", routeHash(route)); + await refresh({ force: true, forceConversation: true, preserveFocusedWorkspace: true }); + results.push({ type: route.type, workspacePaused, expectedSubtitle: subtitle, subtitle: els.subtitle.textContent.trim(), catalogReads, conversationReads, renderCalls }); + } + return results; + } finally { + window.fetch = originalFetch; + render = originalRender; + if (hiddenDescriptor) Object.defineProperty(document, "hidden", hiddenDescriptor); + else delete document.hidden; + state.fullScreenComposerKeys = savedFullScreenComposerKeys; + state.fullScreenInquiryKeys = savedFullScreenInquiryKeys; + state.pollDeferredUntil = savedPollDeferredUntil; + clearTimeout(state.timer); + state.timer = null; + history.replaceState(null, "", originalHash); + state.renderedRouteKey = null; + state.renderedViewHtml = ""; + render(); + schedule(); + } + }, agentKey); + if (focusedRoutePollingContract.some((route) => ( + route.catalogReads < 1 || + (route.workspacePaused && route.subtitle !== route.expectedSubtitle) || + (route.workspacePaused && (route.conversationReads !== 0 || route.renderCalls !== 0)) || + (!route.workspacePaused && (route.conversationReads < 1 || route.renderCalls < 1)) + ))) { + throw new Error(`Focused non-conversation routes performed workspace polling: ${JSON.stringify(focusedRoutePollingContract)}`); + } + const mobilePollingSubtitleHash = await page.evaluate(() => location.hash); + await page.setViewportSize({ width: 390, height: 844 }); + const mobilePollingSubtitleContract = await page.evaluate((key) => { + history.replaceState(null, "", routeHash({ type: "agentPullRequests", key })); + state.renderedRouteKey = null; + state.renderedViewHtml = ""; + render(); + const subtitle = document.querySelector("#screen-subtitle .subtitle-status-text"); + const styles = getComputedStyle(subtitle); + const headerRect = els.header.getBoundingClientRect(); + const firstContentRect = els.view.firstElementChild?.getBoundingClientRect(); + const result = { + text: subtitle?.textContent.trim(), + clientWidth: subtitle?.clientWidth || 0, + scrollWidth: subtitle?.scrollWidth || 0, + clientHeight: subtitle?.clientHeight || 0, + scrollHeight: subtitle?.scrollHeight || 0, + whiteSpace: styles.whiteSpace, + overflow: styles.overflow, + headerHeight: headerRect.height, + headerBottom: headerRect.bottom, + firstContentTop: firstContentRect?.top || 0, + detailHeaderHeight: parseFloat(getComputedStyle(els.view).getPropertyValue("--detail-header-height")) || 0, + }; + return result; + }, agentKey); + if (!mobilePollingSubtitleContract.text.startsWith("Conversation poll paused") || + mobilePollingSubtitleContract.whiteSpace === "nowrap" || + mobilePollingSubtitleContract.scrollWidth > mobilePollingSubtitleContract.clientWidth + 1 || + mobilePollingSubtitleContract.scrollHeight > mobilePollingSubtitleContract.clientHeight + 1 || + Math.abs(mobilePollingSubtitleContract.detailHeaderHeight - Math.ceil(mobilePollingSubtitleContract.headerHeight)) > 1 || + mobilePollingSubtitleContract.firstContentTop + 1 < mobilePollingSubtitleContract.headerBottom) { + throw new Error(`Mobile header clips the paused polling state: ${JSON.stringify(mobilePollingSubtitleContract)}`); + } + await page.setViewportSize({ width: 1280, height: 800 }); + await page.evaluate((hash) => { + history.replaceState(null, "", hash); + state.renderedRouteKey = null; + state.renderedViewHtml = ""; + render(); + }, mobilePollingSubtitleHash); + const focusedStatusSyncContract = await page.evaluate(async (key) => { + const originalHash = location.hash; + const originalLoadResourceCatalog = loadResourceCatalog; + const originalRender = render; + const agent = findAgent(key); + agent.running = true; + agent.status = "running"; + agent.unread = false; + history.replaceState(null, "", routeHash({ type: "agentSummary", key })); + state.renderedRouteKey = null; + state.renderedViewHtml = ""; + originalRender(); + clearTimeout(state.timer); + state.timer = null; + const workspaceBefore = document.querySelector("[data-agent-workspace]"); + const steadyActionBefore = document.querySelector(`#composer[data-agent-key="${CSS.escape(key)}"] [data-agent-action="stop"]`); + steadyActionBefore?.focus({ preventScroll: true }); + syncFocusedWorkspaceCatalog(parseRoute()); + const steadyActionAfter = document.querySelector(`#composer[data-agent-key="${CSS.escape(key)}"] [data-agent-action="stop"]`); + const before = { + status: document.querySelector(`[data-agent-live-status="${CSS.escape(key)}"]`)?.textContent.trim(), + action: document.querySelector(`#composer[data-agent-key="${CSS.escape(key)}"] [data-agent-action="stop"]`)?.textContent.trim(), + steadyActionPreserved: steadyActionAfter === steadyActionBefore, + steadyActionFocused: document.activeElement === steadyActionBefore, + }; + let renderCalls = 0; + let conversationReads = 0; + render = (...args) => { + renderCalls += 1; + return originalRender(...args); + }; + loadResourceCatalog = async () => { + const data = await originalLoadResourceCatalog(); + for (const server of data.servers || []) { + for (const item of server.agents || []) { + if (normalizeAgentResource(item, server)?.key !== key) continue; + item.running = false; + item.status = "succeeded"; + item.unread = false; + item.awaiting_input = false; + item.blocked = false; + item.last_result = "success"; + } + } + return data; + }; + const originalFetch = window.fetch; + window.fetch = async (input, init) => { + const requestUrl = new URL(typeof input === "string" ? input : input.url, location.href); + if (requestUrl.pathname.endsWith("/conversation")) conversationReads += 1; + return originalFetch(input, init); + }; + try { + await refresh({ preserveFocusedWorkspace: true }); + return { + before, + after: { + status: document.querySelector(`[data-agent-live-status="${CSS.escape(key)}"]`)?.textContent.trim(), + sendPrompt: document.querySelector(`#composer[data-agent-key="${CSS.escape(key)}"] button[type="submit"]`)?.textContent.trim(), + sendPromptFocused: document.activeElement === document.querySelector(`#composer[data-agent-key="${CSS.escape(key)}"] button[type="submit"]`), + }, + conversationReads, + renderCalls, + workspacePreserved: document.querySelector("[data-agent-workspace]") === workspaceBefore, + }; + } finally { + window.fetch = originalFetch; + loadResourceCatalog = originalLoadResourceCatalog; + render = originalRender; + clearTimeout(state.timer); + state.timer = null; + history.replaceState(null, "", originalHash); + state.renderedRouteKey = null; + state.renderedViewHtml = ""; + originalRender(); + schedule(); + } + }, agentKey); + if (focusedStatusSyncContract.before.status !== "Running" || + focusedStatusSyncContract.before.action !== "Stop agent" || + !focusedStatusSyncContract.before.steadyActionPreserved || + !focusedStatusSyncContract.before.steadyActionFocused || + focusedStatusSyncContract.after.status !== "Succeeded" || + focusedStatusSyncContract.after.sendPrompt !== "Send prompt" || + !focusedStatusSyncContract.after.sendPromptFocused || + focusedStatusSyncContract.conversationReads !== 0 || + !focusedStatusSyncContract.workspacePreserved) { + throw new Error(`Focused workspace status controls did not reconcile in place: ${JSON.stringify(focusedStatusSyncContract)}`); + } + const navigationDuringPollContract = await page.evaluate(async (key) => { + const originalHash = location.hash; + const originalLoadResourceCatalog = loadResourceCatalog; + const originalFetch = window.fetch; + const originalRender = render; + let releaseCatalog; + let catalogEntered; + const catalogStarted = new Promise((resolve) => { catalogEntered = resolve; }); + const catalogGate = new Promise((resolve) => { releaseCatalog = resolve; }); + let conversationReads = 0; + let renderCalls = 0; + history.replaceState(null, "", routeHash({ type: "agent", key })); + state.renderedRouteKey = null; + state.renderedViewHtml = ""; + originalRender(); + loadResourceCatalog = async () => { + catalogEntered(); + await catalogGate; + return originalLoadResourceCatalog(); + }; + window.fetch = async (input, init) => { + const requestUrl = new URL(typeof input === "string" ? input : input.url, location.href); + if (requestUrl.pathname.endsWith("/conversation")) conversationReads += 1; + return originalFetch(input, init); + }; + render = (...args) => { + renderCalls += 1; + return originalRender(...args); + }; + try { + const pendingRefresh = refresh({ force: true, forceConversation: true }); + await catalogStarted; + history.replaceState(null, "", routeHash({ type: "agentSummary", key })); + state.renderedRouteKey = null; + state.renderedViewHtml = ""; + originalRender(); + releaseCatalog(); + await pendingRefresh; + return { + route: parseRoute().type, + summaryPresent: Boolean(document.querySelector("[data-agent-summary-page]")), + conversationReads, + renderCalls, + }; + } finally { + releaseCatalog(); + window.fetch = originalFetch; + loadResourceCatalog = originalLoadResourceCatalog; + render = originalRender; + clearTimeout(state.timer); + state.timer = null; + history.replaceState(null, "", originalHash); + state.renderedRouteKey = null; + state.renderedViewHtml = ""; + originalRender(); + schedule(); + } + }, agentKey); + if (navigationDuringPollContract.route !== "agentSummary" || + !navigationDuringPollContract.summaryPresent || + navigationDuringPollContract.conversationReads !== 0 || + navigationDuringPollContract.renderCalls !== 0) { + throw new Error(`An older conversation poll replaced focused navigation: ${JSON.stringify(navigationDuringPollContract)}`); + } const attachmentPollRenderContract = await page.evaluate(async () => { clearTimeout(state.timer); state.timer = null; @@ -3551,6 +5220,8 @@ Dir.mktmpdir("tycho-remote-ui-smoke") do |dir| agent_key = create_agent(base_url) unscheduled_agent_key = create_agent(base_url, name: "Unscheduled Smoke Agent") associate_agent_schedule(fixture.fetch(:logs_dir), agent_key) + associate_agent_pull_request(fixture.fetch(:logs_dir), agent_key) + associate_agent_pull_request(fixture.fetch(:logs_dir), unscheduled_agent_key) wait_for_resource_agents(base_url, [agent_key, unscheduled_agent_key]) node_dir = File.join(dir, "node") install_playwright!(node_dir) diff --git a/docs/PROJECT_STATUS.md b/docs/PROJECT_STATUS.md index d955987..4a183e3 100644 --- a/docs/PROJECT_STATUS.md +++ b/docs/PROJECT_STATUS.md @@ -55,6 +55,9 @@ Key references: | Agent attachments | Structured agent results can include PR/document/image attachments, persisted in `.attachments.json` and mirrored into `memory.jsonl`, surfaced from chat with a `ctrl+a` navigable list | Durable links to artifacts survive later runs and memory rebuilds instead of living only in a single assistant message | | Remote artifact rendering | Render attached HTML in an origin-isolated iframe with a restrictive content policy and package explicitly referenced, allowlisted workspace web assets; render sanitized Markdown Mermaid fences with a pinned, conditional CDN loader in strict mode | Interactive lessons and shared course assets remain usable without granting generated HTML access to Tycho or browser storage, and ordinary Markdown does not pay the Mermaid download cost | | Pull request review | Agent-scoped PR diff inspection remains available; the cross-agent Review Inbox is paused because its eager aggregation is too slow and unresponsive | Redesign inbox discovery and loading around bounded, incremental work before restoring its route; retain GitHub App and `gh` compatibility | +| Agent pull request catalog | Persist canonical PR references and origin title/status metadata in an agent-owned `.pull_request_catalog.json` sidecar; keep ordinary agent PR listing network-free | Opening one agent reads only that agent's catalog; displayed titles and Open/Draft/Closed/Merged state come from cached GitHub metadata, refreshes remain explicit, and archiving moves the catalog with the agent | +| Agent pull request switching | Paint the route shell before asynchronously attaching its diff, give foreground navigation a reserved request slot, cancel stale background work outside the open PR route, preload at most six saved snapshots, retain at most twelve payloads plus six visited viewers and per-PR scroll positions, and render lines in 100-line containment chunks | Persistent snapshots remain authoritative; navigation never shares a frame with diff layout, while immutable parsed-store reuse and bounded browser caches preserve each PR's form, selection, and desktop/mobile scroll state | +| Managed-agent completion status | Let a validated structured result define Success, Partial, or Failed after a run; retain the process exit code as transport diagnostics | A usable structured result must not remain labeled Failed solely because its harness process exited nonzero, while missing or explicitly failed results still surface failure | | Conversation block scrolling | Initial chat load bottom-aligns the latest block when it fits, oversized blocks start at row 1, and navigation scrolls only enough to reveal the selected block | The selected label/cursor must remain visible and predictable while keeping surrounding recent context on first open | | Conversation viewport offsets | Block `line_offset` / `line_height` are derived from the final rendered rows; long unbroken preview tokens are hard-wrapped before entering the viewport, and footer debug is computed after viewport sync | Bubbles `Viewport` counts newline-separated lines, while terminals visually wrap long tokens; stale or mismatched offsets cause misleading `visible 0/0` debug and cropped selected blocks | | Inquiry submission | Gated review step inside a rounded box | Prevents accidental structured submissions | @@ -350,6 +353,7 @@ verify the bottle. - [x] Dedicated mobile structured inquiry submission UI - [x] Full-screen inquiry editor with trailing unstructured Leave feedback field - [x] Poll-safe inline and full-screen Conversation/inquiry forms that remain attached and focused during shell refreshes +- [x] Focused Summary, Attachment, PR Diff, and agent-backed project Diff routes show that conversation polling is paused, poll only the lightweight resource catalog, preserve their workspace DOM, and reconcile status/lifecycle controls in place; normal route polling resumes on Conversation and top-level live views - [x] Attachment detail context menus with exclusive Balanced/Widen/Full layouts, content/path copy, and forced cache refresh - [x] Responsive desktop/mobile shell with persistent, header-aligned in-shell desktop navigation across top-level and detail routes, consistently named New agent action, shared control sizing, and fixed-region safe-area handling - [x] Sticky Settings section navigator over one continuous page and copyable native session ID in Conversation Settings diff --git a/docs/PULL_REQUEST_DIFFS.md b/docs/PULL_REQUEST_DIFFS.md index 623bb63..c82bf3a 100644 --- a/docs/PULL_REQUEST_DIFFS.md +++ b/docs/PULL_REQUEST_DIFFS.md @@ -8,6 +8,12 @@ Tycho prefers a Tycho GitHub App user session obtained through OAuth device flow Review posting remains off by default. Operators must also set `TYCHO_GITHUB_WRITE_ENABLED=true`, save a draft bound to the current base and head, and confirm the mutation. GitHub remains the final permission authority; a token without `Pull requests: write` receives a sanitized failure. +Agent-scoped PR discovery persists canonical references and compact metadata in an agent-owned `~/.tycho/logs/agents/.pull_request_catalog.json` sidecar. Opening an agent's PR list reads only that agent's catalog and does not issue one GitHub request per PR. The cached GitHub title replaces attachment-supplied display text, and the list/detail surfaces show Open, Draft, Closed, or Merged state. Existing saved snapshots seed missing catalog metadata without another patch fetch. **Refresh metadata** updates the catalog explicitly; fetching one or all diffs remains a separate patch operation. Archiving an agent moves its catalog, backup, and lock sidecars with its other logs. + +The Remote UI preloads up to six saved diff snapshots for the open agent, cancels stale queued preloads when that route changes, retains at most twelve snapshot payloads, and keeps up to six visited diff viewers and scroll positions in bounded LRU caches. Foreground navigation has priority and one reserved request slot, so a click cannot wait behind background preloads; an explicit refresh that meets an in-flight preload follows it with the required refresh request. Single and bulk refresh results use the same bounded payload cache and protect the selected PR. Route changes paint the PR shell and loading state first, then attach even an already-cached diff on a later animation frame, keeping diff layout out of the navigation frame. Diff lines render in 100-line containment chunks so off-screen code does not force full-page layout. The persistent snapshot store remains authoritative, while the server reuses an immutable parsed document until the snapshot file's inode, size, or nanosecond mtime changes. Switching among visited PRs therefore avoids both JSON reparsing and rebuilding thousands of diff-line DOM controls; first visits use the preloaded snapshot and bounded layout. + +Automatic Remote UI polling does not fetch or rebuild the conversation behind a focused PR Diff. It still reconciles lightweight agent status and lifecycle controls from the resource catalog; the cross-route policy is recorded in `docs/PROJECT_STATUS.md`. + ## Workflow ```mermaid diff --git a/docs/REMOTE_SERVER.md b/docs/REMOTE_SERVER.md index 20759e1..47f4620 100644 --- a/docs/REMOTE_SERVER.md +++ b/docs/REMOTE_SERVER.md @@ -387,7 +387,8 @@ Conversation entries are projected from `AgentChatLog#chat_blocks` when availabl | `DELETE` | `/agents/{key}` | Archive one idle managed agent. | | `POST` | `/agents/archive` | Archive multiple idle managed agents from a `keys` array, returning archived, skipped, and failed keys. | | `GET` | `/agents/{key}/conversation` | Read the rendered conversation blocks for one agent. | -| `GET` | `/agents/{key}/pull-requests` | List GitHub pull request links detected from one agent's attachments with snapshot freshness metadata. | +| `GET` | `/agents/{key}/pull-requests` | List GitHub pull requests from that agent's persistent local catalog, including cached origin title and status, without waiting on GitHub. | +| `POST` | `/agents/{key}/pull-requests/metadata/refresh` | Explicitly refresh GitHub metadata for the agent's cataloged pull requests without fetching patches. | | `GET` | `/agents/{key}/pull-requests/{id}/diff` | Read one saved pull request diff snapshot. | | `POST` | `/agents/{key}/pull-requests/{id}/refresh` | Fetch current PR metadata and patch content, then save a fresh diff snapshot. | | `POST` | `/agents/{key}/pull-requests/refresh` | Refresh every detected pull request diff for one agent. | diff --git a/docs/reports/usage-2026-07-06-to-2026-08-06.md b/docs/reports/usage-2026-07-06-to-2026-08-06.md deleted file mode 100644 index 4b8ec7a..0000000 --- a/docs/reports/usage-2026-07-06-to-2026-08-06.md +++ /dev/null @@ -1,57 +0,0 @@ -# Managed-agent usage: 2026-07-06 through 2026-08-06 - -This report covers calendar dates July 6 through August 6 in `Asia/Jakarta`, expressed as the half-open interval `[2026-07-06, 2026-08-07)`. It was rebuilt after an idempotent historical backfill and queries only the normalized metrics interface. The reporting query does not scan raw logs. - -Reproduce it with: - -```bash -bundle exec bin/tycho metrics backfill --timezone Asia/Jakarta --json -bundle exec bin/tycho metrics query \ - --from 2026-07-06 \ - --to 2026-08-07 \ - --timezone Asia/Jakarta \ - --json -``` - -The checked-in query envelope and exact summary are in [usage-2026-07-06-to-2026-08-06.query.json](./usage-2026-07-06-to-2026-08-06.query.json). - -## Summary - -| Measure | Result | -|---|---:| -| Run starts | 2,232 | -| Managed agents | 193 | -| Distinct native sessions | 193 | -| Runs without a native session ID | 72 | -| Average runs per native session | 11.19 | -| Known estimated cost | $2,850.0494 | -| Priced runs | 1,499 | -| Unpriced runs | 733 | -| Priced run coverage | 67.2% | -| Fully priced sessions | 93 | -| Unpriced or partially priced sessions | 100 | -| Median priced session cost | $6.8795 | -| Maximum priced session cost | $407.1918 | - -Costs are estimates, not invoices. The known total sums runs with a reported or reproducibly priced estimate. Median and maximum include a session only when every run has a known cost; missing non-cost metadata does not exclude a priced session. Average runs per session counts only the 2,160 starts associated with a native session, not the 72 orphan starts. - -## Provider coverage - -| Adapter | Runs | Native sessions | Priced runs | Known estimated cost | -|---|---:|---:|---:|---:| -| Claude-compatible | 1,480 | 97 | 1,472 | $2,823.9456 | -| Codex | 669 | 90 | 19 | $25.9040 | -| OpenCode | 8 | 3 | 8 | $0.1998 | -| Unknown harness | 75 | 3 | 0 | unknown | - -Most unpriced Codex history lacks an archived configured model, so Tycho preserves its token telemetry without choosing a price. The 75 unknown-harness starts have durable timestamps but no provider event shape; 72 also lack a native session ID. They remain run starts with explicit unknown reasons instead of disappearing or becoming zero-cost runs. - -## Reconciliation with the prior report - -The prior ad-hoc report cited 2,156 run starts and 181 native sessions. The normalized interface reports 2,232 starts and 193 sessions: - -- The run count is 76 higher. Seventy-five are telemetry-empty starts that the old provider-event scan omitted; the normalized schema retains them with unknown harness/status/price fields. One residual run reflects a difference in the old scan or its exact cutoff. The prior report artifact is not present in this repository, so attributing that last run would require recreating the old raw-log query; this report deliberately does not do that. -- The native-session count is 12 higher. Three belong to runs whose exact session IDs survived even though their harness did not. The remaining difference comes from counting exact provider-scoped native IDs rather than using managed-agent or priced-run proxies. Managed-agent count is reported separately to prevent the prior conflation. -- The earlier finding that archived Claude-compatible history lacked exact configured model labels remains visible as incomplete provenance. Observed `modelUsage` attribution is retained where emitted, but Tycho does not promote it into a guessed configured model or invent a price. - -The new figures are reproducible from the persisted v1 metric records. Backfill was rerun after completion and returned zero creates/updates, confirming idempotence. diff --git a/docs/reports/usage-2026-07-06-to-2026-08-06.query.json b/docs/reports/usage-2026-07-06-to-2026-08-06.query.json deleted file mode 100644 index f1b142f..0000000 --- a/docs/reports/usage-2026-07-06-to-2026-08-06.query.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "interface": "tycho metrics query", - "schema_version": 1, - "query": { - "from": "2026-07-06", - "to": "2026-08-07", - "timezone": "Asia/Jakarta", - "range_semantics": "from_inclusive_to_exclusive", - "filters": {} - }, - "command": "bundle exec bin/tycho metrics query --from 2026-07-06 --to 2026-08-07 --timezone Asia/Jakarta --json", - "summary": { - "run_starts": 2232, - "managed_agents": 193, - "distinct_native_sessions": 193, - "runs_without_native_session": 72, - "average_runs_per_session": 11.191709844559586, - "known_estimated_cost_usd": 2850.0493570296, - "median_priced_session_cost_usd": 6.8794935, - "max_priced_session_cost_usd": 407.19176695, - "priced_run_count": 1499, - "unpriced_run_count": 733, - "priced_run_coverage": 0.671594982078853, - "priced_session_count": 93, - "unpriced_or_partially_priced_session_count": 100, - "cost_semantics": "estimate_not_invoice" - } -} diff --git a/lib/hq/domain/managed_agent.rb b/lib/hq/domain/managed_agent.rb index 8ecb886..e3b4cfb 100644 --- a/lib/hq/domain/managed_agent.rb +++ b/lib/hq/domain/managed_agent.rb @@ -597,9 +597,14 @@ def status return "blocked" if blocked? return "idle" if @started_at.nil? && last_run.nil? return "idle" if @last_exit_code.nil? - return "succeeded" if @last_exit_code.zero? return "stopped" if stopped_exit_code? + structured_status = @structured_result&.dig("status").to_s.strip + return "succeeded" if %w[success succeeded no_action_needed].include?(structured_status) + return "partial" if structured_status == "partial" + return "failed" if structured_status == "failed" + return "succeeded" if @last_exit_code.zero? + "failed" end @@ -627,6 +632,10 @@ def attachments_path derived_log_path("attachments.json") end + def pull_request_catalog_path + derived_log_path("pull_request_catalog.json") + end + def invalidate_derived_logs! [conversation_log_path, system_log_path].each do |path| FileUtils.rm_f(path) @@ -640,6 +649,9 @@ def log_files system_log_path, memory_path, attachments_path, + pull_request_catalog_path, + "#{pull_request_catalog_path}.bak", + "#{pull_request_catalog_path}.lock", invalid_structured_output_file_path, status_file_path, last_message_file_path, @@ -1126,6 +1138,7 @@ def external_process_environment(environment) { "BUNDLE_BIN_PATH" => nil, "BUNDLE_GEMFILE" => nil, + "BUNDLER_SETUP" => nil, "BUNDLER_VERSION" => nil, "GEM_HOME" => nil, "GEM_PATH" => nil, diff --git a/lib/hq/domain/pull_request_diff.rb b/lib/hq/domain/pull_request_diff.rb index 3c4da5d..189d299 100644 --- a/lib/hq/domain/pull_request_diff.rb +++ b/lib/hq/domain/pull_request_diff.rb @@ -48,10 +48,13 @@ class Store def initialize(path = File.join(HQ::AGENT_LOGS_DIR, "pull_request_diffs.json")) @path = path + @document_mutex = Mutex.new + @document_signature = nil + @document_cache = nil end def all - parsed = FileStore.read_json(@path, fallback: {}) + parsed = document return {} unless parsed.is_a?(Hash) return parsed.fetch("snapshots", {}) if parsed["version"] == STORE_VERSION @@ -62,7 +65,7 @@ def all end def fetch_snapshot(snapshot_id) - parsed = FileStore.read_json(@path, fallback: {}) + parsed = document return nil unless parsed.is_a?(Hash) && parsed["version"] == STORE_VERSION parsed.fetch("history", {})[snapshot_id.to_s] @@ -77,23 +80,59 @@ def fetch(id) def save(snapshot) with_lock do id = snapshot.fetch("id") - snapshots = all + snapshots = all.dup snapshots[id] = snapshot snapshots = snapshots.sort_by { |_key, value| value["fetched_at"].to_s }.last(MAX_SNAPSHOTS).to_h - parsed = FileStore.read_json(@path, fallback: {}) - history = parsed.is_a?(Hash) && parsed["version"] == STORE_VERSION ? parsed.fetch("history", {}) : {} + parsed = document + history = parsed.is_a?(Hash) && parsed["version"] == STORE_VERSION ? parsed.fetch("history", {}).dup : {} history[snapshot["snapshot_id"]] = snapshot if snapshot["snapshot_id"] history = history.sort_by { |_key, value| value["fetched_at"].to_s }.last(MAX_SNAPSHOTS).to_h FileStore.write_json( @path, { "version" => STORE_VERSION, "snapshots" => snapshots, "history" => history } ) + invalidate_document_cache end snapshot end private + def document + signature = document_signature + @document_mutex.synchronize do + return @document_cache if @document_cache && @document_signature == signature + + @document_cache = deep_freeze(FileStore.read_json(@path, fallback: {})) + @document_signature = signature + @document_cache + end + end + + def deep_freeze(value) + case value + when Hash + value.each { |key, item| deep_freeze(key); deep_freeze(item) } + when Array + value.each { |item| deep_freeze(item) } + end + value.freeze + end + + def document_signature + stat = File.stat(@path) + [stat.ino, stat.size, stat.mtime.to_i, stat.mtime.nsec] + rescue Errno::ENOENT + nil + end + + def invalidate_document_cache + @document_mutex.synchronize do + @document_signature = nil + @document_cache = nil + end + end + def with_lock FileUtils.mkdir_p(File.dirname(@path)) File.open("#{@path}.lock", File::RDWR | File::CREAT, 0o600) do |lock| @@ -105,6 +144,103 @@ def with_lock end end + class Catalog + STORE_VERSION = 1 + METADATA_KEYS = %w[ + title url state draft mergeable mergeable_state merged author base_sha head_sha base_ref head_ref + file_count additions deletions remote_updated_at + ].freeze + + def initialize(path:) + @path = path + end + + def all + parsed = FileStore.read_json(@path, fallback: {}) + return {} unless parsed.is_a?(Hash) && parsed["version"] == STORE_VERSION + + parsed.fetch("entries", {}) + rescue StandardError => e + HQ.logger.warn("PRCatalog") { "Failed to load PR catalog from #{@path}: #{e.class} - #{e.message}" } + {} + end + + def discover(references, metadata_by_id: {}) + update do |entries| + changed = false + Array(references).each do |reference| + unless entries.key?(reference.id) + entries[reference.id] = reference_entry(reference, Time.now.iso8601) + changed = true + end + metadata = metadata_by_id[reference.id] + next unless metadata.is_a?(Hash) && !entries[reference.id]["metadata"].is_a?(Hash) + + cache_metadata( + entries[reference.id], + metadata, + metadata["fetched_at"] || Time.now.iso8601, + source: "snapshot" + ) + changed = true + end + changed + end + end + + def save_metadata(reference, metadata) + save_all_metadata([[reference, metadata]]) + end + + def save_all_metadata(items) + items = Array(items) + refreshed_at = Time.now.iso8601 + update do |entries| + items.each do |reference, metadata| + entries[reference.id] ||= reference_entry(reference, refreshed_at) + cache_metadata(entries[reference.id], metadata, refreshed_at, source: "github") + end + items.any? + end + end + + private + + def reference_entry(reference, discovered_at) + { + "id" => reference.id, + "provider" => reference.provider, + "repository" => reference.repository, + "number" => reference.number, + "url" => reference.url, + "discovered_at" => discovered_at + }.compact + end + + def cache_metadata(entry, metadata, refreshed_at, source:) + entry["metadata"] = metadata.to_h + .select { |key, _value| METADATA_KEYS.include?(key.to_s) } + .transform_keys(&:to_s) + entry["metadata_refreshed_at"] = refreshed_at + entry["metadata_source"] = source + end + + def update + FileUtils.mkdir_p(File.dirname(@path)) + File.open("#{@path}.lock", File::RDWR | File::CREAT, 0o600) do |lock| + lock.flock(File::LOCK_EX) + entries = all + changed = yield entries + if changed + FileStore.write_json(@path, { "version" => STORE_VERSION, "entries" => entries }) + end + entries + ensure + lock.flock(File::LOCK_UN) rescue nil + end + end + end + class GitHubProvider def initialize(client: GitHubAPIClient.new) @client = client @@ -243,6 +379,7 @@ def snapshot_for(reference, provider: GitHubProvider.new, metadata: nil) "description" => metadata["body"] || reference.description, "state" => metadata["state"], "draft" => metadata["draft"], + "merged" => metadata["merged"], "author" => metadata["author"], "base_sha" => metadata["base_sha"], "head_sha" => metadata["head_sha"], @@ -262,9 +399,14 @@ def snapshot_for(reference, provider: GitHubProvider.new, metadata: nil) }.compact end - def reference_payload(reference, snapshot: nil, metadata: nil, error: nil) + def reference_payload(reference, snapshot: nil, metadata: nil, freshness_metadata: metadata, error: nil) payload = reference.to_h - payload["snapshot"] = snapshot_summary(snapshot, metadata:) + if metadata.is_a?(Hash) + Catalog::METADATA_KEYS.each do |key| + payload[key] = metadata[key] if metadata.key?(key) + end + end + payload["snapshot"] = snapshot_summary(snapshot, metadata: freshness_metadata) payload["error"] = error if error payload end diff --git a/lib/hq/remote_server.rb b/lib/hq/remote_server.rb index 5b02507..e831c13 100644 --- a/lib/hq/remote_server.rb +++ b/lib/hq/remote_server.rb @@ -386,6 +386,9 @@ def route(service, method, path, body, request = nil) return ok(memory_rebuild: service.rebuild_agent_memory(key)) end return ok(pull_requests: service.agent_pull_requests(key)) if method == "GET" && tail == ["pull-requests"] + if method == "POST" && tail == ["pull-requests", "metadata", "refresh"] + return ok(service.refresh_agent_pull_request_metadata(key)) + end return ok(service.refresh_agent_pull_requests(key)) if method == "POST" && tail == ["pull-requests", "refresh"] if tail.length == 3 && tail.first == "pull-requests" && tail[2] == "diff" return ok(diff: service.agent_pull_request_diff(key, tail[1])) if method == "GET" @@ -1453,6 +1456,8 @@ class RemoteService ".woff2" => "font/woff2" }.freeze MAX_PULL_REQUEST_INBOX_ITEMS = 100 + MAX_PROMPT_PULL_REQUEST_CONTEXTS = 5 + MAX_PROMPT_PULL_REQUEST_COMMENT_BYTES = 8 * 1024 IMAGE_CONTENT_TYPES = { ".gif" => "image/gif", ".heic" => "image/heic", @@ -1692,19 +1697,42 @@ def agent_pull_requests(key) ensure_github_enabled! agent = find_agent!(key) references = PullRequestDiff.references_for_agent(agent) - store = @pull_request_diff_store - provider = PullRequestDiff::GitHubProvider.new(client: @github_client) + snapshots = @pull_request_diff_store.all + catalog = pull_request_catalog(agent).discover(references, metadata_by_id: snapshots) references.map do |reference| - snapshot = store.fetch(reference.id) - begin - metadata = provider.metadata(reference) - PullRequestDiff.reference_payload(reference, snapshot:, metadata:) - rescue PullRequestDiff::Error => e - PullRequestDiff.reference_payload(reference, snapshot:, error: e.message) - end + pull_request_reference_payload(reference, catalog[reference.id], snapshots[reference.id]) end end + def refresh_agent_pull_request_metadata(key) + ensure_github_enabled! + agent = find_agent!(key) + references = PullRequestDiff.references_for_agent(agent) + catalog_store = pull_request_catalog(agent) + catalog_store.discover(references) + refreshed = [] + failed = [] + references.each do |reference| + refreshed << [reference, github_provider.metadata(reference)] + rescue PullRequestDiff::Error => e + failed << { + id: reference.id, + repository: reference.repository, + number: reference.number, + error: e.message + } + end + catalog = catalog_store.save_all_metadata(refreshed) + snapshots = @pull_request_diff_store.all + { + pull_requests: references.map do |reference| + pull_request_reference_payload(reference, catalog[reference.id], snapshots[reference.id]) + end, + refreshed: refreshed.map { |reference, _metadata| reference.id }, + failed: + } + end + def agent_pull_request_diff(key, id) ensure_github_enabled! agent = find_agent!(key) @@ -2622,8 +2650,10 @@ def mark_agent_read(key) def submit_prompt(key, attrs) target = find_agent!(key) + pull_request_context = render_prompt_pull_request_contexts(target, attrs) attachments = import_prompt_attachments(target, attrs) text = prompt_text(attrs, attachments:) + text = [text, pull_request_context].reject(&:empty?).join("\n") target.add_user_message!( text, attachments: @@ -3220,15 +3250,39 @@ def github_provider PullRequestDiff::GitHubProvider.new(client: @github_client) end + def pull_request_reference_payload(reference, entry, snapshot) + entry ||= {} + metadata = entry["metadata"] + freshness_metadata = metadata if entry["metadata_source"] == "github" + payload = PullRequestDiff.reference_payload(reference, snapshot:, metadata:, freshness_metadata:) + if metadata.is_a?(Hash) + payload["metadata_refreshed_at"] = entry["metadata_refreshed_at"] + end + payload + end + + def pull_request_catalog(agent) + PullRequestDiff::Catalog.new(path: agent.pull_request_catalog_path) + end + + def persist_pull_request_metadata(reference, metadata) + return if reference.agent_key.to_s.empty? + + agent = load_agents.find { |candidate| candidate.key == reference.agent_key } + pull_request_catalog(agent).save_metadata(reference, metadata) if agent + end + def refresh_pull_request_snapshot(reference) refresh_pull_request_snapshot_fetch(reference).fetch(:snapshot) end def refresh_pull_request_snapshot_fetch(reference) - coalesce_pull_request_fetch(reference, "snapshot") do + refreshed = coalesce_pull_request_fetch(reference, "snapshot") do metadata, pull = github_provider.metadata_with_pull(reference) - { snapshot: save_pull_request_snapshot(reference, metadata), pull: } + { snapshot: save_pull_request_snapshot(reference, metadata), pull:, metadata: } end + persist_pull_request_metadata(reference, refreshed.fetch(:metadata)) + refreshed end def refresh_pull_request_fetch(reference) @@ -3767,6 +3821,32 @@ def prompt_text(attrs, attachments:) raise Error.new("prompt is required") end + def render_prompt_pull_request_contexts(target, attrs) + contexts = attrs["pull_request_contexts"] + return "" unless contexts.is_a?(Array) && contexts.any? + if contexts.length > MAX_PROMPT_PULL_REQUEST_CONTEXTS + raise Error.new("Attach at most #{MAX_PROMPT_PULL_REQUEST_CONTEXTS} pull request ranges.", status: 400) + end + + rendered = contexts.map do |raw| + raise Error.new("Pull request context must be an object.", status: 400) unless raw.is_a?(Hash) + + reference = pull_request_reference!(target, raw["pull_request_id"]) + snapshot = @pull_request_diff_store.fetch(reference.id) + raise Error.new("Fetch the pull request diff before attaching lines.", status: 409) unless snapshot + + rendered = PullRequestSelection.render(snapshot, raw) + comment = raw["comment"].to_s.strip + if comment.bytesize > MAX_PROMPT_PULL_REQUEST_COMMENT_BYTES + raise Error.new("Pull request comments must be at most 8 KB.", status: 400) + end + comment.empty? ? rendered : [rendered, "Comment on this range:\n#{comment}"].join("\n") + rescue PullRequestSelection::Error => e + raise Error.new(e.message, status: 409) + end + rendered.join("\n") + end + def inquiry_answer_with_feedback(answer, feedback, supplied:) parsed = JSON.parse(answer) return [answer, false] unless parsed.is_a?(Hash) diff --git a/lib/hq/remote_ui/assets/app.css b/lib/hq/remote_ui/assets/app.css index 0e6aaee..e75e10f 100644 --- a/lib/hq/remote_ui/assets/app.css +++ b/lib/hq/remote_ui/assets/app.css @@ -3384,6 +3384,7 @@ select { .diff-file-body { display: grid; + container-type: inline-size; gap: 10px; min-width: 0; max-width: 100%; @@ -3429,7 +3430,14 @@ select { line-height: 1.45; } +.diff-line-chunk { + display: grid; + content-visibility: auto; + contain-intrinsic-size: auto 2000px; +} + .diff-line { + position: relative; display: grid; grid-template-columns: 22px 5ch 5ch 2ch minmax(40ch, 1fr); min-height: 20px; @@ -3438,6 +3446,50 @@ select { .diff-line.selected { outline: 1px solid var(--accent); outline-offset: -1px; } +.diff-line.selectable { + cursor: pointer; +} + +.diff-line.selectable:hover, +.diff-line.selectable:focus-within { + background: color-mix(in srgb, var(--accent) 12%, transparent); +} + +.diff-line-select { + position: absolute; + top: 50%; + left: 12px; + width: 18px; + height: 18px; + margin: 0; + opacity: 0; + transform: translateY(-50%); +} + +.diff-line-check { + align-self: center; + display: inline-grid; + width: 18px; + height: 18px; + place-items: center; + border-radius: 999px; + color: transparent; + font-family: ui-sans-serif, system-ui, sans-serif; + font-size: 13px; + font-weight: 800; + line-height: 1; +} + +.diff-line-check.selected { + background: var(--accent); + color: var(--accent-contrast); +} + +.diff-line-select:focus-visible + .diff-line-check { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + .diff-line.added { background: color-mix(in srgb, var(--ok) 14%, transparent); } @@ -3474,7 +3526,131 @@ select { white-space: pre; } +.pr-context-selection-bar { + position: sticky; + top: 0; + z-index: 3; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + border: 1px solid var(--border); + border-radius: 10px; + background: color-mix(in srgb, var(--panel) 94%, transparent); + padding: 10px 12px; + box-shadow: 0 8px 22px rgba(0, 0, 0, 0.18); +} + +.pr-context-selection-bar.has-selection { + border-color: var(--accent); +} + +.pr-context-selection-placeholder { + visibility: hidden; +} + +.pr-context-selection-copy, +.pr-context-selection-actions { + display: flex; + align-items: center; + gap: 8px; +} + +.pr-context-selection-copy > span { + display: grid; + gap: 2px; +} + +.pr-context-selection-copy > span > span { + color: var(--muted); + font-size: 0.82rem; +} + +.pr-inline-comment { + position: sticky; + left: 0; + display: grid; + box-sizing: border-box; + width: 100cqw; + min-width: 0; + max-width: 100cqw; + gap: 10px; + border-top: 1px solid var(--accent); + border-bottom: 1px solid var(--border); + background: color-mix(in srgb, var(--accent) 5%, var(--panel)); + padding: 12px; +} + +.pr-inline-comment-heading, +.pr-inline-comment-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.pr-inline-comment-heading { + color: var(--muted); + font-size: 0.84rem; +} + +.pr-inline-comment-input { + min-height: 96px; + resize: vertical; +} + +.pr-inline-comment-actions { + justify-content: flex-end; +} + +.pending-pr-contexts { + display: grid; + gap: 8px; + border: 1px solid color-mix(in srgb, var(--accent) 55%, var(--border)); + border-radius: 10px; + background: color-mix(in srgb, var(--accent) 7%, var(--panel)); + padding: 10px; +} + +.pending-pr-context.stale { + border-color: var(--danger); +} + +.pending-pr-context .pending-attachment-thumb { + color: var(--accent); +} + +.pending-pr-context-comment { + display: -webkit-box; + overflow: hidden; + color: var(--text) !important; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + white-space: pre-wrap; +} + @media (max-width: 560px) { + .pr-context-selection-bar, + .pr-context-selection-actions { + align-items: stretch; + flex-direction: column; + } + + .pr-context-selection-actions .ui-button { + justify-content: center; + width: 100%; + } + + .pr-inline-comment-actions { + align-items: stretch; + flex-direction: column-reverse; + } + + .pr-inline-comment-actions .ui-button { + justify-content: center; + width: 100%; + } + .diff-toolbar { align-items: stretch; flex-direction: column; @@ -5821,6 +5997,12 @@ body:has(.inquiry-form-full-screen) { gap: 8px; } +.agent-live-actions { + display: inline-flex; + align-items: center; + gap: 8px; +} + .agent-running-indicator { display: inline-grid; place-items: center; @@ -5845,6 +6027,14 @@ body:has(.inquiry-form-full-screen) { .agent-running-indicator .ui-icon { animation: none; } + + .speech-mode-button { + animation: none !important; + } + + .composer[data-speech-state="listening"] .composer-editor-shell { + animation: none !important; + } } .skill-toggle-button, @@ -5861,6 +6051,70 @@ body:has(.inquiry-form-full-screen) { line-height: 1; } +.composer:not(.composer-full-screen) .composer-editor-shell #prompt-input { + min-height: 92px; + max-height: calc(6lh + 20px); +} + +.composer-speech-control { + position: absolute; + top: 48px; + right: 6px; + z-index: 2; + display: inline-grid; +} + +.composer-full-screen .composer-speech-control { + top: 54px; + right: 6px; +} + +.composer-speech-button { + display: inline-grid; + width: 36px; + min-height: 36px; + place-items: center; + border: 0; + background: transparent; + padding: 0; + color: var(--muted); + line-height: 1; +} + +.composer-speech-button .ui-icon { + display: block; + margin: 0; +} + +.speech-mode-button[data-state="listening"] { + color: var(--danger); +} + +.speech-mode-button[data-state="processing"] { + color: var(--info); +} + +.speech-mode-button[data-state="error"] { + color: var(--danger); +} + +.speech-mode-button[data-state="unsupported"] { + color: var(--warning); + opacity: 0.72; +} + +@keyframes speech-listening-pulse { + 50% { + border-color: color-mix(in srgb, var(--danger) 82%, var(--border)); + box-shadow: 0 0 0 5px color-mix(in srgb, var(--danger) 20%, transparent); + } +} + +.composer[data-speech-state="listening"] .composer-editor-shell { + border-radius: 10px; + animation: speech-listening-pulse 1.4s ease-in-out infinite; +} + .attachment-toggle-button { color: var(--info); } @@ -6838,6 +7092,10 @@ body:has(.inquiry-form-full-screen) { } @media (max-width: 640px) { + .agent-workspace-pull-requests:has(.pr-inline-comment) .agent-dock { + display: none; + } + .agent-toolbar-actions { display: none; } @@ -6904,6 +7162,12 @@ body:has(.inquiry-form-full-screen) { white-space: nowrap; } + .subtitle-polling-paused .subtitle-status-text { + overflow: visible; + text-overflow: clip; + white-space: normal; + } + .content { padding-bottom: calc(var(--mobile-nav-height) + 24px + env(safe-area-inset-bottom, 0px)); } diff --git a/lib/hq/remote_ui/assets/app.js b/lib/hq/remote_ui/assets/app.js index 2a5bc59..fd8c7b8 100644 --- a/lib/hq/remote_ui/assets/app.js +++ b/lib/hq/remote_ui/assets/app.js @@ -7,10 +7,20 @@ const DEFAULT_REFRESH_INTERVALS = { }; const FORM_POLL_QUIET_MS = 3_000; const MAX_CONCURRENT_PR_DIFF_FETCHES = 3; +const MAX_PRELOADED_PR_DIFFS_PER_AGENT = 6; +const MAX_CACHED_PR_DIFF_DATA = 12; +const MAX_CACHED_PR_DIFF_VIEWERS = 6; +const MAX_CACHED_PR_DIFF_SCROLL_POSITIONS = 6; +const MAX_PR_DIFF_PRELOAD_FAILURES = 12; +const DIFF_LINE_RENDER_CHUNK_SIZE = 100; const PROMPT_ATTACHMENT_LIMITS = { maxFiles: 5, maxBytes: 10 * 1024 * 1024, }; +const PROMPT_PULL_REQUEST_CONTEXT_LIMITS = { + maxContexts: 5, + maxLines: 100, +}; const CLIPBOARD_ATTACHMENT_EXTENSIONS = { "application/json": ".json", "application/pdf": ".pdf", @@ -53,6 +63,7 @@ const MARKDOWN_SCRIPT_URLS = { dompurify: "https://cdn.jsdelivr.net/npm/dompurify@3.2.7/dist/purify.min.js", marked: "https://cdn.jsdelivr.net/npm/marked@18.0.3/lib/marked.umd.js", }; +const MARKDOWN_FALLBACK_LIMIT = 200; const MERMAID_SCRIPT_URL = "https://cdn.jsdelivr.net/npm/mermaid@11.15.0/dist/mermaid.min.js"; const CODE_HIGHLIGHTER_SCRIPT_URLS = { prism: "https://cdn.jsdelivr.net/npm/prismjs@1.30.0/prism.min.js", @@ -543,6 +554,13 @@ const ICONS = { `, + microphone: ` + + `, x: ` ${iconSvg("loaderPinwheel")}` : ""} + ${agentComposerAction(agent, sending)} + `; + if (restoreActionFocus) actions.querySelector("button")?.focus({ preventScroll: true }); + } + composer.querySelectorAll("[data-prompt-attachment-input], [data-add-prompt-attachment], [data-toggle-speech-mode], [data-toggle-skills]").forEach((control) => { + control.disabled = running; + }); } async function refresh(options = {}) { @@ -1669,13 +1775,15 @@ async function refresh(options = {}) { return; } + const initialRouteHash = location.hash; const preserveWorkspace = preserveWorkspaceDuringPoll(options); const inquiryAgentKey = preserveWorkspace ? els.view.querySelector("#inquiry-form")?.dataset.agentKey : ""; const preservedInquiryAgent = inquiryAgentKey ? state.agentDetails[inquiryAgentKey] : null; try { - setConnection("Refreshing"); + const preserveInitialSubtitle = preserveWorkspace && syncFocusedWorkspacePollingSubtitle(parseRoute()); + setConnection("Refreshing", { preserveHeaderSubtitle: preserveInitialSubtitle }); const catalogData = await loadResourceCatalog(); const previousAgents = state.agents; applyResourceCatalog(catalogData); @@ -1704,14 +1812,22 @@ async function refresh(options = {}) { state.lastUpdatedAt = new Date(); state.failureCount = 0; els.authPanel.classList.add("hidden"); - if (!preserveWorkspace) await ensureRouteData(options); - setConnection(refreshedText()); + const routeChangedDuringRefresh = location.hash !== initialRouteHash; + const preserveCurrentWorkspace = routeChangedDuringRefresh || preserveWorkspaceDuringPoll(options); + if (!preserveCurrentWorkspace) await ensureRouteData(options); const currentRoute = parseRoute(); + const focusedPollingSubtitle = preserveCurrentWorkspace && focusedWorkspacePollingSubtitle(currentRoute); + setConnection(refreshedText(), { + preserveHeaderSubtitle: routeChangedDuringRefresh || Boolean(focusedPollingSubtitle), + }); + if (focusedPollingSubtitle) syncFocusedWorkspacePollingSubtitle(currentRoute); if (shouldOpenSucceededSummary && currentRoute.type === "agent" && currentRoute.key === routeBeforeRefresh.key) { navigate({ type: "agentSummary", key: currentRoute.key }); return; } - if (!preserveWorkspace) { + if (preserveCurrentWorkspace) { + syncFocusedWorkspaceCatalog(currentRoute); + } else { render({ preserveLiveEditor: true, preservePollContent: !options.force && !options.forceAttachment, @@ -1726,7 +1842,7 @@ async function refresh(options = {}) { } else { setConnection(`Offline: ${error.message}`); } - if (!preserveWorkspace) { + if (!preserveWorkspaceDuringPoll(options) && location.hash === initialRouteHash) { render({ preserveLiveEditor: true, preservePollContent: !options.force && !options.forceAttachment, @@ -1739,6 +1855,8 @@ async function refresh(options = {}) { async function ensureRouteData(options = {}) { const route = parseRoute(); + cancelBackgroundPullRequestDiffFetchesExcept(route.type === "agentPullRequests" ? route.key : ""); + let agentShellData = Promise.resolve(); if (route.type === "tab" && route.tab === "settings") { await Promise.all([ ensureResponseStyle(options.forceResponseStyle || options.force), @@ -1749,23 +1867,30 @@ async function ensureRouteData(options = {}) { await ensureAgentDetail(route.key, options.forceAgent || options.force); const agent = findAgent(route.key); if (agent) { - await Promise.all([ + agentShellData = Promise.all([ ensureConversation(agent, options.forceConversation), ensureSkillsForProject(agent.project_key, agent.agent, { force: options.force }), ensureProject(agent.project_key), ]); } } + if (route.type !== "agentPullRequests") await agentShellData; if (route.type === "agentAttachment") { await ensureAttachment(route.attachmentId, options.forceAttachment); await ensureAttachmentPreview(route.attachmentId, options.forceAttachment); } if (route.type === "agentPullRequests") { - await ensureAgentPullRequests(route.key, options.forcePullRequests); - const refs = state.pullRequests[route.key]?.items || []; - const selected = selectedPullRequestId(route.key, route.pullRequestId); - const hasSnapshot = refs.find((item) => item.id === selected)?.snapshot; - if (selected && hasSnapshot) await ensurePullRequestDiff(route.key, selected, options.forcePullRequestDiff); + const pullRequestData = (async () => { + await ensureAgentPullRequests(route.key, options.forcePullRequests); + const refs = state.pullRequests[route.key]?.items || []; + const selected = selectedPullRequestId(route.key, route.pullRequestId); + const hasSnapshot = refs.find((item) => item.id === selected)?.snapshot; + if (selected && hasSnapshot) { + void ensurePullRequestDiff(route.key, selected, options.forcePullRequestDiff); + } + preloadSavedPullRequestDiffs(route.key, refs, selected); + })(); + await Promise.all([agentShellData, pullRequestData]); } if (route.type === "agentForm" && route.mode === "create") { await ensureProject(route.projectKey, true); @@ -2019,24 +2144,48 @@ async function ensurePullRequestReview(id, force = false) { async function ensurePullRequestDiff(agentKey, pullRequestId, force = false) { if (!agentKey || !pullRequestId) return; const key = pullRequestDiffKey(agentKey, pullRequestId); + touchPullRequestDiffData(key); if (!force && state.pullRequestDiffs[key] && !state.pullRequestDiffs[key].loading && !state.pullRequestDiffFetches[key]) return; await enqueuePullRequestDiffFetch(agentKey, pullRequestId, false, force); } +function preloadSavedPullRequestDiffs(agentKey, references, selectedId) { + const candidates = references + .filter((item) => item?.snapshot && item.id) + .slice(0, MAX_PRELOADED_PR_DIFFS_PER_AGENT); + candidates.forEach((item) => { + const key = pullRequestDiffKey(agentKey, item.id); + if (state.pullRequestDiffPreloadFailures.has(key)) return; + if (state.pullRequestDiffs[key] && !state.pullRequestDiffs[key].loading) return; + if (item.id === selectedId) return; + + void enqueuePullRequestDiffFetch(agentKey, item.id, false, false, { background: true }); + }); +} + function isPullRequestDiffFetching(agentKey, pullRequestId) { const key = pullRequestDiffKey(agentKey, pullRequestId); return Boolean(state.pullRequestDiffFetches[key]); } -function enqueuePullRequestDiffFetch(agentKey, pullRequestId, refresh = false, force = false) { +function enqueuePullRequestDiffFetch(agentKey, pullRequestId, refresh = false, force = false, options = {}) { const key = pullRequestDiffKey(agentKey, pullRequestId); if (!agentKey || !pullRequestId) return Promise.resolve(); + if (!options.background) state.pullRequestDiffPreloadFailures.delete(key); const existing = state.pullRequestDiffFetches[key]; if (existing?.queued || existing?.inFlight) { + if (existing.inFlight && refresh && !options.background) { + return existing.promise.then(() => enqueuePullRequestDiffFetch(agentKey, pullRequestId, true, force)); + } if (force) existing.force = true; if (refresh) existing.refresh = true; + if (!options.background) { + existing.background = false; + existing.discard = false; + } + drainPullRequestDiffFetchQueue(); return existing.promise; } @@ -2047,6 +2196,7 @@ function enqueuePullRequestDiffFetch(agentKey, pullRequestId, refresh = false, f pullRequestId, refresh, force, + background: options.background === true, queued: true, inFlight: false, }; @@ -2063,8 +2213,13 @@ function drainPullRequestDiffFetchQueue() { if (!state.pullRequestDiffFetchQueue.length) return; while (state.pullRequestDiffsInFlight < MAX_CONCURRENT_PR_DIFF_FETCHES && state.pullRequestDiffFetchQueue.length > 0) { - const next = state.pullRequestDiffFetchQueue.shift(); - if (!next) continue; + const foregroundIndex = state.pullRequestDiffFetchQueue.findIndex((request) => !request.background); + const next = foregroundIndex >= 0 + ? state.pullRequestDiffFetchQueue.splice(foregroundIndex, 1)[0] + : state.pullRequestDiffsInFlight < MAX_CONCURRENT_PR_DIFF_FETCHES - 1 + ? state.pullRequestDiffFetchQueue.shift() + : null; + if (!next) break; void processPullRequestDiffFetch(next); } @@ -2091,28 +2246,111 @@ async function processPullRequestDiffFetch(fetchRequest) { request.queued = false; request.inFlight = true; state.pullRequestDiffsInFlight += 1; - render(); + if (!request.background) render(); + let backgroundFailed = false; try { const route = `/agents/${encodeURIComponent(agentKey)}/pull-requests/${encodeURIComponent(pullRequestId)}/`; const data = request.refresh ? await apiPost(`${route}refresh`) : await apiGet(`${route}diff`); - state.pullRequestDiffs[key] = data.diff || { id: pullRequestId, files: [] }; + if (!request.discard) { + state.pullRequestDiffs[key] = data.diff || { id: pullRequestId, files: [] }; + rememberPullRequestDiffData(key); + } } catch (error) { - state.pullRequestDiffs[key] = { error: error.message, files: [] }; + if (request.discard) { + delete state.pullRequestDiffs[key]; + } else if (request.background) { + backgroundFailed = true; + rememberPullRequestDiffPreloadFailure(key); + delete state.pullRequestDiffs[key]; + } else { + state.pullRequestDiffs[key] = { error: error.message, files: [] }; + } } finally { state.pullRequestDiffsInFlight -= 1; - state.pullRequestDiffs[key] = state.pullRequestDiffs[key] || { id: pullRequestId, files: [] }; - state.pullRequestDiffs[key].loading = false; + if (request.discard) { + delete state.pullRequestDiffs[key]; + } else if (!backgroundFailed) { + state.pullRequestDiffs[key] = state.pullRequestDiffs[key] || { id: pullRequestId, files: [] }; + state.pullRequestDiffs[key].loading = false; + } const completed = state.pullRequestDiffFetches[key]; delete state.pullRequestDiffFetches[key]; - if (completed?.resolve) completed.resolve(state.pullRequestDiffs[key]); - render(); + if (completed?.resolve) completed.resolve(state.pullRequestDiffs[key] || null); + if (!completed?.background) render(); drainPullRequestDiffFetchQueue(); } } +function touchPullRequestDiffData(key) { + if (!state.pullRequestDiffDataOrder.has(key)) return; + state.pullRequestDiffDataOrder.delete(key); + state.pullRequestDiffDataOrder.set(key, true); +} + +function rememberPullRequestDiffData(key) { + state.pullRequestDiffDataOrder.delete(key); + state.pullRequestDiffDataOrder.set(key, true); + while (state.pullRequestDiffDataOrder.size > MAX_CACHED_PR_DIFF_DATA) { + const oldest = state.pullRequestDiffDataOrder.keys().next().value; + state.pullRequestDiffDataOrder.delete(oldest); + delete state.pullRequestDiffs[oldest]; + } +} + +function rememberPullRequestDiffPreloadFailure(key) { + state.pullRequestDiffPreloadFailures.delete(key); + state.pullRequestDiffPreloadFailures.add(key); + while (state.pullRequestDiffPreloadFailures.size > MAX_PR_DIFF_PRELOAD_FAILURES) { + state.pullRequestDiffPreloadFailures.delete(state.pullRequestDiffPreloadFailures.values().next().value); + } +} + +function cancelBackgroundPullRequestDiffFetchesExcept(agentKey) { + Object.values(state.pullRequestDiffFetches).forEach((request) => { + if (!request.background || request.agentKey === agentKey) return; + if (request.inFlight) { + request.discard = true; + return; + } + + state.pullRequestDiffFetchQueue = state.pullRequestDiffFetchQueue.filter((queued) => queued !== request); + delete state.pullRequestDiffFetches[request.key]; + delete state.pullRequestDiffs[request.key]; + request.resolve?.(null); + }); + drainPullRequestDiffFetchQueue(); +} + +function prunePullRequestDiffState(activeAgentKeys) { + const active = (key) => Array.from(activeAgentKeys).some((agentKey) => ( + String(key).startsWith(`${agentKey}:`) || String(key).startsWith(`pr-diff-viewer:${agentKey}:`) + )); + Object.keys(state.pullRequestDiffs).forEach((key) => { + if (!active(key)) delete state.pullRequestDiffs[key]; + }); + [state.pullRequestDiffDataOrder, state.pullRequestDiffViewers, state.pullRequestDiffScrollPositions].forEach((cache) => { + Array.from(cache.keys()).forEach((key) => { + if (!active(key)) cache.delete(key); + }); + }); + Array.from(state.pullRequestDiffPreloadFailures).forEach((key) => { + if (!active(key)) state.pullRequestDiffPreloadFailures.delete(key); + }); + Object.values(state.pullRequestDiffFetches).forEach((request) => { + if (active(request.key)) return; + if (request.inFlight) { + request.discard = true; + return; + } + state.pullRequestDiffFetchQueue = state.pullRequestDiffFetchQueue.filter((queued) => queued !== request); + delete state.pullRequestDiffFetches[request.key]; + request.resolve?.(null); + }); +} + async function ensureConversation(agent, force = false) { const cached = state.conversations[agent.key]; if (cached?.loading) return; @@ -2340,11 +2578,13 @@ function renderCurrentView() { } else { renderNow(); } + syncFocusedWorkspacePollingSubtitle(route); } function replaceView(html) { const routeKey = routeStateKey(parseRoute()); const sameRoute = state.renderedRouteKey === routeKey; + const reusePullRequestDiffViewer = Boolean(state.renderedViewHtml); if (sameRoute && state.renderedViewHtml === html) { syncPendingForms(); syncViewControls(); @@ -2354,19 +2594,30 @@ function replaceView(html) { return; } - const liveEditorPlan = sameRoute && state.preserveLiveEditorDuringRender ? liveEditorRefreshPlan(html) : null; - const snapshot = sameRoute ? captureViewState() : null; + const preserveActiveSpeechEditor = sameRoute && Boolean(state.speechRecognition); + const preservePullRequestDiff = sameRoute && Boolean(state.renderedViewHtml) && parseRoute().type === "agentPullRequests"; + const preservedPollContent = sameRoute && (state.preservePollContentDuringRender || preservePullRequestDiff) + ? preservedPollContentByKey(els.view) + : new Map(); + const liveEditorPlan = sameRoute && (state.preserveLiveEditorDuringRender || preserveActiveSpeechEditor) + ? liveEditorRefreshPlan(html) + : null; + const snapshot = sameRoute ? captureViewState(preservedPollContent) : null; const reconciled = liveEditorPlan ? reconcileViewAroundEditor(liveEditorPlan) : false; + if (!reconciled && state.speechRecognition) stopSpeechMode({ immediate: true }); if (!reconciled) replaceViewContent(html); + transplantPreservedPollContent(els.view, preservedPollContent); + if (reusePullRequestDiffViewer) transplantPreservedPollContent(els.view, state.pullRequestDiffViewers); state.renderedRouteKey = routeKey; state.renderedViewHtml = html; syncMarkdownHeadingAnchors(); restoreFormDrafts(); - restoreViewState(snapshot); + restoreViewState(snapshot, preservedPollContent); syncPendingForms(); syncViewControls(); syncFullScreenComposerModal(); syncAgentDockLayout(); + restoreVisiblePullRequestDiffScroll(); queueMermaidRendering(); } @@ -2408,7 +2659,7 @@ function transplantPreservedPollContent(incomingRoot, preserved) { if (!preserved?.size) return; pollContentElements(incomingRoot).forEach((incoming) => { const current = preserved.get(pollContentKey(incoming)); - if (current) incoming.replaceWith(current); + if (current && current !== incoming) incoming.replaceWith(current); }); } @@ -2431,8 +2682,12 @@ function editorFullScreen(editor) { } function reconcileViewAroundEditor(plan) { - const currentChain = editorAncestorChain(plan.editor, els.view); - const incomingChain = editorAncestorChain(plan.incomingEditor, plan.incomingRoot); + return reconcileNodesAroundAnchor(plan.editor, plan.incomingEditor, els.view, plan.incomingRoot); +} + +function reconcileNodesAroundAnchor(currentAnchor, incomingAnchor, currentRoot, incomingRoot) { + const currentChain = elementAncestorChain(currentAnchor, currentRoot); + const incomingChain = elementAncestorChain(incomingAnchor, incomingRoot); if (!currentChain || !incomingChain || currentChain.length !== incomingChain.length) return false; if (currentChain.some((node, index) => index > 0 && index < currentChain.length - 1 && node.tagName !== incomingChain[index].tagName)) return false; @@ -2441,15 +2696,15 @@ function reconcileViewAroundEditor(plan) { const incomingParent = incomingChain[index]; const currentBranch = currentChain[index - 1]; const incomingBranch = incomingChain[index - 1]; - if (currentParent !== els.view) syncElementAttributes(currentParent, incomingParent); - replaceSiblingsAroundEditor(currentParent, currentBranch, incomingParent, incomingBranch); + if (currentParent !== currentRoot) syncElementAttributes(currentParent, incomingParent); + replaceSiblingsAroundAnchor(currentParent, currentBranch, incomingParent, incomingBranch); } return true; } -function editorAncestorChain(editor, root) { - const chain = [editor]; - let current = editor; +function elementAncestorChain(element, root) { + const chain = [element]; + let current = element; while (current && current !== root) { current = current.parentNode; if (current) chain.push(current); @@ -2466,12 +2721,20 @@ function syncElementAttributes(current, incoming) { }); } -function replaceSiblingsAroundEditor(currentParent, currentBranch, incomingParent, incomingBranch) { +function replaceSiblingsAroundAnchor(currentParent, currentBranch, incomingParent, incomingBranch) { const preservedPollContent = state.preservePollContentDuringRender ? preservedPollContentByKey(currentParent) : new Map(); + const retainedBranches = reconcilePullRequestDiffSiblingBranch( + currentParent, + currentBranch, + incomingParent, + incomingBranch, + preservedPollContent + ); + const retainedNodes = new Set(retainedBranches.values()); Array.from(currentParent.childNodes).forEach((node) => { - if (node !== currentBranch) node.remove(); + if (node !== currentBranch && !retainedNodes.has(node)) node.remove(); }); let afterBranch = false; @@ -2480,23 +2743,62 @@ function replaceSiblingsAroundEditor(currentParent, currentBranch, incomingParen afterBranch = true; return; } - const clone = node.cloneNode(true); - transplantPreservedPollContent(clone, preservedPollContent); - if (afterBranch) currentParent.appendChild(clone); - else currentParent.insertBefore(clone, currentBranch); + const replacement = retainedBranches.get(node) || node.cloneNode(true); + if (!retainedBranches.has(node)) transplantPreservedPollContent(replacement, preservedPollContent); + if (afterBranch) currentParent.appendChild(replacement); + else currentParent.insertBefore(replacement, currentBranch); }); } +function reconcilePullRequestDiffSiblingBranch(currentParent, currentBranch, incomingParent, incomingBranch, preserved) { + if (!preserved.size) return new Map(); + + const currentBranchesByKey = new Map(); + Array.from(currentParent.children).forEach((branch) => { + if (branch === currentBranch) return; + pollContentElements(branch).filter((element) => element.matches("[data-pr-diff-viewer]")).forEach((element) => { + const key = pollContentKey(element); + if (key && preserved.get(key) === element) currentBranchesByKey.set(key, { branch, element }); + }); + }); + + const retained = new Map(); + Array.from(incomingParent.children).forEach((incomingBranchCandidate) => { + if (incomingBranchCandidate === incomingBranch) return; + const incomingElement = pollContentElements(incomingBranchCandidate) + .filter((element) => element.matches("[data-pr-diff-viewer]")) + .find((element) => currentBranchesByKey.has(pollContentKey(element))); + if (!incomingElement) return; + + const current = currentBranchesByKey.get(pollContentKey(incomingElement)); + if (current.branch.tagName !== incomingBranchCandidate.tagName) return; + if (!reconcileNodesAroundAnchor( + current.element, + incomingElement, + current.branch, + incomingBranchCandidate + )) return; + syncElementAttributes(current.branch, incomingBranchCandidate); + retained.set(incomingBranchCandidate, current.branch); + }); + return retained; +} + function routeStateKey(route) { return REMOTE_HELPERS.routeStateKey(route); } -function captureViewState() { +function captureViewState(preserved = new Map()) { const active = document.activeElement; + const skippedRoots = preservedPullRequestDiffRoots(preserved); + const elements = viewStateElements(skippedRoots); + const preservedActive = Array.from(skippedRoots).some((root) => root.contains(active)) ? active : null; const snapshot = { activeKey: null, activeFocusKey: null, activeSelection: null, + preservedActive, + preservedActiveSelection: preservedActive ? textSelectionFor(preservedActive) : null, controls: {}, details: {}, openElements: {}, @@ -2507,7 +2809,7 @@ function captureViewState() { scrollContainers: {}, }; - els.view.querySelectorAll("input, textarea, select").forEach((control, index) => { + elements.filter((element) => element.matches("input, textarea, select")).forEach((control, index) => { const key = elementStateKey(control, index); if (control === active) { snapshot.activeKey = key; @@ -2517,19 +2819,19 @@ function captureViewState() { snapshot.controls[key] = controlState(control); }); - els.view.querySelectorAll("[data-preserve-focus]").forEach((element, index) => { + elements.filter((element) => element.matches("[data-preserve-focus]")).forEach((element, index) => { if (element === active) snapshot.activeFocusKey = elementStateKey(element, index); }); - els.view.querySelectorAll("details").forEach((detail, index) => { + elements.filter((element) => element.matches("details")).forEach((detail, index) => { snapshot.details[elementStateKey(detail, index)] = detail.open; }); - els.view.querySelectorAll("[data-preserve-open]").forEach((element, index) => { + elements.filter((element) => element.matches("[data-preserve-open]")).forEach((element, index) => { snapshot.openElements[elementStateKey(element, index)] = !element.classList.contains("hidden"); }); - els.view.querySelectorAll("[data-preserve-scroll]").forEach((element, index) => { + elements.filter((element) => element.matches("[data-preserve-scroll]")).forEach((element, index) => { snapshot.scrollContainers[elementStateKey(element, index)] = { top: element.scrollTop, left: element.scrollLeft, @@ -2539,10 +2841,11 @@ function captureViewState() { return snapshot; } -function restoreViewState(snapshot) { +function restoreViewState(snapshot, preserved = new Map()) { if (!snapshot) return; + const elements = viewStateElements(preservedPullRequestDiffRoots(preserved)); - els.view.querySelectorAll("input, textarea, select").forEach((control, index) => { + elements.filter((element) => element.matches("input, textarea, select")).forEach((control, index) => { const key = elementStateKey(control, index); const stored = snapshot.controls[key]; if (stored) restoreControlState(control, stored); @@ -2551,20 +2854,20 @@ function restoreViewState(snapshot) { restoreTextSelection(control, snapshot.activeSelection); } }); - els.view.querySelectorAll("[data-preserve-focus]").forEach((element, index) => { + elements.filter((element) => element.matches("[data-preserve-focus]")).forEach((element, index) => { if (snapshot.activeFocusKey === elementStateKey(element, index)) { element.focus({ preventScroll: true }); } }); - els.view.querySelectorAll("details").forEach((detail, index) => { + elements.filter((element) => element.matches("details")).forEach((detail, index) => { const key = elementStateKey(detail, index); if (Object.prototype.hasOwnProperty.call(snapshot.details, key)) { detail.open = snapshot.details[key]; } }); - els.view.querySelectorAll("[data-preserve-open]").forEach((element, index) => { + elements.filter((element) => element.matches("[data-preserve-open]")).forEach((element, index) => { const key = elementStateKey(element, index); if (Object.prototype.hasOwnProperty.call(snapshot.openElements, key)) { const open = snapshot.openElements[key]; @@ -2573,7 +2876,7 @@ function restoreViewState(snapshot) { } }); - els.view.querySelectorAll("[data-preserve-scroll]").forEach((element, index) => { + elements.filter((element) => element.matches("[data-preserve-scroll]")).forEach((element, index) => { const stored = snapshot.scrollContainers[elementStateKey(element, index)]; if (!stored) return; @@ -2581,11 +2884,36 @@ function restoreViewState(snapshot) { element.scrollLeft = stored.left || 0; }); + if (snapshot.preservedActive?.isConnected && document.activeElement !== snapshot.preservedActive) { + snapshot.preservedActive.focus({ preventScroll: true }); + restoreTextSelection(snapshot.preservedActive, snapshot.preservedActiveSelection); + } + restorePageScroll(snapshot.pageScroll); repositionOpenSummaryAttachmentMenus(); restoreSkillAutocompleteAfterRender(); } +function viewStateElements(skippedRoots = new Set()) { + const elements = []; + const walker = document.createTreeWalker(els.view, NodeFilter.SHOW_ELEMENT, { + acceptNode(element) { + if (skippedRoots.has(element)) return NodeFilter.FILTER_REJECT; + return NodeFilter.FILTER_ACCEPT; + }, + }); + let element = walker.nextNode(); + while (element) { + elements.push(element); + element = walker.nextNode(); + } + return elements; +} + +function preservedPullRequestDiffRoots(preserved) { + return new Set(Array.from(preserved.values()).filter((element) => element.matches("[data-pr-diff-viewer]"))); +} + function syncPreservedOpenState(element, open) { if (element.matches("[data-attachment-flyout]")) { document.querySelector("[data-toggle-attachments]")?.setAttribute("aria-expanded", open ? "true" : "false"); @@ -4968,6 +5296,7 @@ function renderAgentSummaryView(agent, options = {}) {
+
Current session${statusBadge(statusLabel(agent), statusClass(agent), "chip")}
${kv("Outcome", summary.status || statusLabel(agent))} ${kv("Total Runs", agent.run_count || 0)} @@ -5213,6 +5542,7 @@ function renderAgentAttachmentView(agent, attachmentId, options = {}) { } function renderAgentPullRequestDiffView(agent, pullRequestId, options = {}) { + cacheVisiblePullRequestDiffViewer(); const data = state.pullRequests[agent.key] || { loading: true, items: [] }; const refs = Array.isArray(data.items) ? data.items : []; const selectedId = selectedPullRequestId(agent.key, pullRequestId); @@ -5270,12 +5600,14 @@ function renderPullRequestDiffNavItem(agent, item, selectedId) { const active = item.id === selectedId; const snapshot = item.snapshot || null; const fetching = isPullRequestDiffFetching(agent.key, item.id); + const prStatus = pullRequestStatus(item); const meta = [ item.repository ? `${item.repository}#${item.number}` : `PR #${item.number}`, + prStatus?.label, pullRequestFreshnessLabel(item), ].filter(Boolean).join(" / "); return ` - + ${escapeHtml(item.title || item.url || "Pull request")} @@ -5292,6 +5624,7 @@ function renderPullRequestDiffDetail(agent, item, diff, options = {}) { const snapshot = item.snapshot || null; const stale = snapshot?.fresh === false; const loading = isPullRequestDiffFetching(agent.key, item.id); + const prStatus = pullRequestStatus(item); const expandAll = state.prDiffExpandAll[agent.key] !== false; const expandLabel = expandAll ? "Collapse all" : "Open all"; const expandIcon = expandAll ? "listChevronsDownUp" : "listChevronsUpDown"; @@ -5306,6 +5639,7 @@ function renderPullRequestDiffDetail(agent, item, diff, options = {}) { ${escapeHtml(item.title || `${item.repository}#${item.number}`)} ${escapeHtml([item.repository, item.number ? `#${item.number}` : "", pullRequestFreshnessLabel(item)].filter(Boolean).join(" / "))}
+ ${prStatus ? `${statusBadge(prStatus.label, prStatus.className, "chip")}` : ""} ${snapshot ? statusBadge(stale ? "Stale" : snapshot.fresh === true ? "Fresh" : "Snapshot", stale ? "need" : "done", "chip") : statusBadge("Not fetched", "need", "chip")} ${snapshot?.head_sha ? metadataBadge(shortSha(snapshot.head_sha), "chip") : ""} ${snapshot?.truncated ? statusBadge("Truncated", "need", "chip") : ""} @@ -5328,9 +5662,24 @@ function renderPullRequestDiffDetail(agent, item, diff, options = {}) { `}`; if (!diff || diff.loading || loading) return `${header}${tychoLoadingState("Loading PR diff", { className: "pr-diff-loading-state" })}`; if (diff.error) return `${header}${feedbackMessage("Diff unavailable", diff.error, { intent: "danger", announce: "assertive" })}`; + const viewerVersion = `${diff.snapshot_id || "none"}:${expandAll ? "expanded" : "collapsed"}`; + if (deferPullRequestDiffRender(agent.key, item.id, viewerVersion)) { + return `${header}${tychoLoadingState("Loading PR diff", { className: "pr-diff-loading-state" })}`; + } const files = Array.isArray(diff.files) ? diff.files : []; const summary = `${files.length} ${files.length === 1 ? "file" : "files"} / +${diff.additions || 0} -${diff.deletions || 0}`; + const selection = pullRequestDiffSelection(agent.key, item.id, diff.snapshot_id || ""); + const viewerStateKey = `pr-diff-viewer:${agent.key}:${item.id}`; + const viewerBody = options.reuseViewer !== false && reusablePullRequestDiffViewer(agent.key, item.id, viewerStateKey, viewerVersion) + ? "" + : files.length + ? files.map((file, index) => renderProjectDiffFile(file, index, { + openAll: expandAll, + closeAll: !expandAll, + pullRequestContext: pullRequestDiffRenderContext(agent, item, diff, selection), + })).join("") + : emptyState("No file changes", "This pull request diff snapshot has no textual file changes."); return ` ${header}
@@ -5339,8 +5688,162 @@ function renderPullRequestDiffDetail(agent, item, diff, options = {}) { ${escapeHtml([diff.head_sha ? `head ${shortSha(diff.head_sha)}` : "", diff.fetched_at ? `fetched ${timeShort(diff.fetched_at)}` : ""].filter(Boolean).join(" / "))}
-
- ${files.length ? files.map((file, index) => renderProjectDiffFile(file, index, { openAll: expandAll })).join("") : emptyState("No file changes", "This pull request diff snapshot has no textual file changes.")} +
+ ${renderPullRequestLineSelection(agent, item, selection)} +
+
+ ${viewerBody} +
+ `; +} + +function reusablePullRequestDiffViewer(agentKey, pullRequestId, stateKey, version) { + const key = `${stateKey}:${version}`; + if (state.renderedViewHtml && state.renderedRouteKey === routeStateKey(parseRoute())) { + const viewer = document.querySelector( + `[data-pr-diff-viewer][data-agent-key="${CSS.escape(agentKey)}"][data-pull-request-id="${CSS.escape(pullRequestId)}"]` + ); + if (viewer && pollContentKey(viewer) === key) return viewer; + } + if (!state.renderedViewHtml) return null; + const cached = state.pullRequestDiffViewers.get(key); + if (!cached) return null; + + state.pullRequestDiffViewers.delete(key); + state.pullRequestDiffViewers.set(key, cached); + return cached; +} + +function deferPullRequestDiffRender(agentKey, pullRequestId, viewerVersion) { + const routeKey = pullRequestDiffKey(agentKey, pullRequestId); + const key = `${routeKey}:${viewerVersion}`; + if (state.pullRequestDiffRenderReadyKey === key) return false; + if (state.pullRequestDiffRenderFrame && state.pullRequestDiffRenderPendingKey === key) return true; + if (state.pullRequestDiffRenderFrame) window.cancelAnimationFrame(state.pullRequestDiffRenderFrame); + + state.pullRequestDiffRenderPendingKey = key; + state.pullRequestDiffRenderFrame = window.requestAnimationFrame(() => { + state.pullRequestDiffRenderFrame = window.requestAnimationFrame(() => { + state.pullRequestDiffRenderFrame = null; + state.pullRequestDiffRenderPendingKey = ""; + const route = parseRoute(); + const selectedId = route.type === "agentPullRequests" + ? selectedPullRequestId(route.key, route.pullRequestId) + : ""; + if (pullRequestDiffKey(route.key, selectedId) !== routeKey) return; + + const diff = state.pullRequestDiffs[routeKey]; + const expandAll = state.prDiffExpandAll[agentKey] !== false; + const currentVersion = `${diff?.snapshot_id || "none"}:${expandAll ? "expanded" : "collapsed"}`; + if (`${routeKey}:${currentVersion}` !== key) return; + + state.pullRequestDiffRenderReadyKey = key; + render(); + }); + }); + return true; +} + +function resetPullRequestDiffRender() { + state.pullRequestDiffRenderReadyKey = ""; + state.pullRequestDiffRenderPendingKey = ""; + if (!state.pullRequestDiffRenderFrame) return; + + window.cancelAnimationFrame(state.pullRequestDiffRenderFrame); + state.pullRequestDiffRenderFrame = null; +} + +function cacheVisiblePullRequestDiffViewer() { + if (!state.renderedViewHtml) return; + const viewer = els.view.querySelector("[data-pr-diff-viewer]"); + cachePullRequestDiffScroll(viewer); + cachePullRequestDiffViewer(viewer); +} + +function cachePullRequestDiffScroll(viewer) { + const agentKey = viewer?.dataset.agentKey; + const pullRequestId = viewer?.dataset.pullRequestId; + if (!agentKey || !pullRequestId) return; + const route = parseRoute(); + if (route.type === "agentPullRequests" && ( + route.key !== agentKey || selectedPullRequestId(route.key, route.pullRequestId) !== pullRequestId + )) return; + + const key = pullRequestDiffKey(agentKey, pullRequestId); + const detail = viewer.closest(".pr-diff-detail"); + state.pullRequestDiffScrollPositions.delete(key); + state.pullRequestDiffScrollPositions.set(key, { + detailTop: Math.max(0, detail?.scrollTop || 0), + pageTop: Math.max(0, window.scrollY || document.documentElement.scrollTop || 0), + }); + while (state.pullRequestDiffScrollPositions.size > MAX_CACHED_PR_DIFF_SCROLL_POSITIONS) { + state.pullRequestDiffScrollPositions.delete(state.pullRequestDiffScrollPositions.keys().next().value); + } +} + +function restoreVisiblePullRequestDiffScroll() { + const viewer = els.view.querySelector("[data-pr-diff-viewer]"); + const key = viewer ? pullRequestDiffKey(viewer.dataset.agentKey, viewer.dataset.pullRequestId) : ""; + const position = state.pullRequestDiffScrollPositions.get(key); + if (!position) return; + + const detail = viewer.closest(".pr-diff-detail"); + const userScrollGeneration = state.pullRequestDiffUserScrollGeneration; + if (detail) detail.scrollTop = position.detailTop; + window.scrollTo({ top: position.pageTop, left: window.scrollX }); + window.requestAnimationFrame(() => { + if (state.pullRequestDiffUserScrollGeneration !== userScrollGeneration) return; + + const current = els.view.querySelector("[data-pr-diff-viewer]"); + if (!current || pullRequestDiffKey(current.dataset.agentKey, current.dataset.pullRequestId) !== key) return; + + const currentDetail = current.closest(".pr-diff-detail"); + if (currentDetail) currentDetail.scrollTop = position.detailTop; + window.scrollTo({ top: position.pageTop, left: window.scrollX }); + }); +} + +function cachePullRequestDiffViewer(viewer) { + const key = pollContentKey(viewer); + if (!key) return; + + state.pullRequestDiffViewers.delete(key); + state.pullRequestDiffViewers.set(key, viewer); + while (state.pullRequestDiffViewers.size > MAX_CACHED_PR_DIFF_VIEWERS) { + const oldest = state.pullRequestDiffViewers.keys().next().value; + state.pullRequestDiffViewers.delete(oldest); + } +} + +function pullRequestDiffRenderContext(agent, item, diff, selection) { + return { + agentKey: agent.key, + pullRequestId: item.id, + snapshotId: diff.snapshot_id || "", + repository: diff.repository || item.repository, + number: diff.number || item.number, + selection, + selectedLines: selection.lines, + selectionLocked: state.pendingPullRequestCommentKeys.has(agent.key), + }; +} + +function renderPullRequestLineSelection(agent, item, selection) { + const count = selection.lines.length; + const pending = pendingPullRequestContextsFor(agent.key).length; + return ` +
+ + ${iconSvg("botMessageSquare")} + ${pending ? `${pending} comment section${pending === 1 ? "" : "s"} ready` : "Comment on the diff"}${pending ? "Select another range, or review the combined comment." : "Select a line, then Shift+select another line for a contiguous range."} + + ${pending ? `` : ""}
`; } @@ -5450,25 +5953,28 @@ function renderAgentComposerPlacement(agent, skills, options = {}) { } function renderAgentComposer(agent, skills, options = {}) { - const sending = state.pendingComposerKeys.has(agent.key); + const sending = state.pendingComposerKeys.has(agent.key) || state.pendingPullRequestCommentKeys.has(agent.key); const fullScreen = state.fullScreenComposerKeys.has(agent.key); const placeholder = sending ? "sending..." : "Send a prompt"; const modalAttributes = fullScreen ? ' role="dialog" aria-modal="true" aria-label="Full-screen editor" data-editor-modal data-composer-modal' : ""; + const speechState = speechModeStateForAgent(agent); const composer = ` -
+ ${fullScreen ? `` : ""}
${fullScreen ? "" : ``} + ${renderSpeechModeButton(agent)}
${renderPromptAttachmentInput(agent)} ${renderPendingAttachments(agent)} + ${renderPendingPullRequestContexts(agent)} ${renderAgentAttachments(agent)}
@@ -5771,22 +6278,55 @@ function scheduleAgentReading(agent) { }, 1200); } -function agentComposerAction(agent) { +function agentComposerAction(agent, sending = false) { if (agentIsRunning(agent)) { return ``; } - return ``; + return ``; +} + +function agentComposerActionState(agent, sending = false) { + if (agentIsRunning(agent)) return "running"; + return sending ? "sending" : "idle"; } function renderSpeechModeButton(agent) { const available = speechModeAvailable(); - const active = state.speechComposerKey === agent.key; - const disabled = agentIsRunning(agent) || !available; - const label = active ? "Stop speech mode" : "Start speech mode"; + const active = state.speechComposerKey === agent.key && Boolean(state.speechRecognition); + const speechState = speechModeStateForAgent(agent); + const disabled = agentIsRunning(agent); + const label = speechModeControlLabel(speechState, active, available); const hint = `${label} (${speechModeShortcutLabel()})`; - const unavailable = available ? "" : " Speech recognition is unavailable in this browser."; - return ``; + const status = speechModeStatus(speechState, state.speechMessage); + return ` + + ${escapeHtml(status)} + `; +} + +function speechModeControlLabel(speechState, active, available) { + if (active) return "Stop speech recognition"; + if (speechState === "error") return `Speech recognition error. ${state.speechMessage || "Try again."} Retry speech recognition`; + return available ? "Start speech recognition" : "Speech recognition unavailable. Use the keyboard instead"; +} + +function speechModeStateForAgent(agent) { + const available = speechModeAvailable(); + const active = state.speechComposerKey === agent.key && Boolean(state.speechRecognition); + const ownsStatus = state.speechStatusComposerKey === agent.key; + return active || ownsStatus ? state.speechState : available ? "idle" : "unsupported"; +} + +function speechModeStatus(value, message = "") { + if (message) return message; + return { + idle: "Speech idle", + listening: "Listening…", + processing: "Transcribing…", + error: "Speech recognition error", + unsupported: "Speech unavailable", + }[value] || "Speech idle"; } function composerIsVisible(composer) { @@ -5815,74 +6355,242 @@ function speechTargetComposer() { return visible.find((composer) => composer.dataset.agentKey === route?.key) || null; } -function insertSpeechTranscript(input, transcript) { - const value = input.value || ""; - const start = input.selectionStart ?? value.length; - const end = input.selectionEnd ?? start; - const spacing = start > 0 && !/\s$/.test(value.slice(0, start)) ? " " : ""; - input.value = `${value.slice(0, start)}${spacing}${transcript}${value.slice(end)}`; - const cursor = start + spacing.length + transcript.length; +function renderSpeechTranscript(session) { + const input = session?.input; + if (!input?.isConnected) return; + const transcript = [session.finalTranscript, session.interimTranscript].filter(Boolean).join(" ").trim(); + input.value = `${session.before}${transcript ? session.spacing : ""}${transcript}${session.after}`; + const cursor = session.before.length + (transcript ? session.spacing.length : 0) + transcript.length; input.setSelectionRange(cursor, cursor); input.dispatchEvent(new Event("input", { bubbles: true })); -} - -function stopSpeechMode() { + scrollSpeechCaretIntoView(session, cursor); + window.requestAnimationFrame(() => { + if (input.isConnected) scrollSpeechCaretIntoView(session, cursor); + }); +} + +function scrollSpeechCaretIntoView(session, cursor) { + const input = session?.input; + if (!input?.isConnected) return; + if (!session.after) { + input.scrollTop = input.scrollHeight; + return; + } + + const style = window.getComputedStyle(input); + const mirror = document.createElement("div"); + [ + "boxSizing", "fontFamily", "fontSize", "fontStyle", "fontWeight", "letterSpacing", "lineHeight", + "paddingTop", "paddingRight", "paddingBottom", "paddingLeft", "borderTopWidth", "borderRightWidth", + "borderBottomWidth", "borderLeftWidth", "tabSize", "textIndent", "textTransform", "wordBreak", + "overflowWrap", + ].forEach((property) => { mirror.style[property] = style[property]; }); + mirror.style.position = "fixed"; + mirror.style.top = "0"; + mirror.style.left = "-10000px"; + mirror.style.width = `${input.getBoundingClientRect().width}px`; + mirror.style.height = "auto"; + mirror.style.minHeight = "0"; + mirror.style.maxHeight = "none"; + mirror.style.overflow = "visible"; + mirror.style.visibility = "hidden"; + mirror.style.whiteSpace = "pre-wrap"; + mirror.textContent = input.value.slice(0, cursor); + const marker = document.createElement("span"); + marker.textContent = "\u200b"; + mirror.append(marker); + document.body.append(mirror); + const caretTop = marker.offsetTop; + const lineHeight = Number.parseFloat(style.lineHeight) || marker.getBoundingClientRect().height || 16; + const visibleTop = input.scrollTop; + const visibleBottom = visibleTop + input.clientHeight; + if (caretTop < visibleTop + lineHeight) input.scrollTop = Math.max(0, caretTop - lineHeight); + else if (caretTop + lineHeight > visibleBottom - lineHeight) { + input.scrollTop = Math.min(input.scrollHeight, caretTop + (lineHeight * 2) - input.clientHeight); + } + mirror.remove(); +} + +function finalizeSpeechMode(options = {}) { const recognition = state.speechRecognition; + const session = state.speechSession; + const composerKey = state.speechComposerKey; + if (state.speechStopTimer) window.clearTimeout(state.speechStopTimer); + if (session) { + if (options.cancel) { + session.input.value = session.originalValue; + session.input.setSelectionRange(session.selectionStart, session.selectionEnd); + session.input.dispatchEvent(new Event("input", { bubbles: true })); + session.input.scrollTop = session.originalScrollTop; + window.requestAnimationFrame(() => { + if (session.input.isConnected) session.input.scrollTop = session.originalScrollTop; + }); + } else { + session.finalTranscript = [session.finalTranscript, session.interimTranscript].filter(Boolean).join(" ").trim(); + session.interimTranscript = ""; + renderSpeechTranscript(session); + } + } state.speechRecognition = null; state.speechComposerKey = ""; - recognition?.stop(); + state.speechStatusComposerKey = options.state && options.state !== "idle" ? composerKey : ""; + state.speechSession = null; + state.speechStopTimer = null; + state.speechState = options.state || "idle"; + state.speechMessage = options.message || ""; + try { + if (options.cancel) recognition?.abort?.(); + else if (options.stopRecognition !== false) recognition?.stop?.(); + } catch (_error) { + // The browser may already have ended recognition while the view was changing. + } updateSpeechModeControls(); } +function stopSpeechMode(options = {}) { + const recognition = state.speechRecognition; + if (!recognition || options.cancel || options.immediate || options.state) { + finalizeSpeechMode(options); + return; + } + + state.speechState = "processing"; + state.speechMessage = "Finishing transcript…"; + if (state.speechStopTimer) window.clearTimeout(state.speechStopTimer); + state.speechStopTimer = window.setTimeout(() => { + if (state.speechRecognition === recognition) { + finalizeSpeechMode({ immediate: true, stopRecognition: false }); + } + }, 1500); + updateSpeechModeControls(); + try { + recognition.stop(); + } catch (_error) { + finalizeSpeechMode({ immediate: true, stopRecognition: false }); + } +} + function updateSpeechModeControls() { document.querySelectorAll("[data-toggle-speech-mode]").forEach((button) => { const active = button.closest("#composer")?.dataset.agentKey === state.speechComposerKey; - const label = active ? "Stop speech mode" : "Start speech mode"; - button.textContent = active ? "Listening" : "Speech"; + const available = speechModeAvailable(); + const ownsStatus = button.closest("#composer")?.dataset.agentKey === state.speechStatusComposerKey; + const value = active || ownsStatus ? state.speechState : available ? "idle" : "unsupported"; + const label = speechModeControlLabel(value, active, available); button.setAttribute("aria-label", `${label} (${speechModeShortcutLabel()})`); button.setAttribute("title", `${label} (${speechModeShortcutLabel()})`); button.setAttribute("aria-pressed", active ? "true" : "false"); + button.dataset.state = value; + const control = button.closest("[data-speech-mode-control]"); + if (control) control.dataset.state = value; + const composer = button.closest("#composer"); + if (composer) composer.dataset.speechState = value; + const status = control?.querySelector("[data-speech-mode-status]"); + if (status) { + status.textContent = speechModeStatus(value, active || ownsStatus ? state.speechMessage : ""); + } }); } +function speechRecognitionErrorMessage(error) { + return { + "not-allowed": "Microphone permission denied. Allow microphone access in browser settings and try again.", + "service-not-allowed": "Speech recognition is blocked by this browser. Check site and microphone permissions.", + "audio-capture": "No microphone is available. Connect one and try again.", + network: "Speech recognition lost its network service. Check connectivity and try again.", + "no-speech": "No speech was detected. Try again and speak after Listening appears.", + }[error] || "Speech recognition stopped unexpectedly. Try again."; +} + +function speechRecognitionTranscripts(results) { + const final = []; + const interim = []; + Array.from(results || []).forEach((result) => { + const text = String(result[0]?.transcript || "").trim(); + if (!text) return; + (result.isFinal ? final : interim).push(text); + }); + return { final: final.join(" "), interim: interim.join(" ") }; +} + function startSpeechMode(composer) { - if (!speechEligibleComposer(composer) || !speechModeAvailable()) return false; + if (!speechEligibleComposer(composer)) return false; + if (!speechModeAvailable()) { + state.speechState = "unsupported"; + state.speechStatusComposerKey = composer.dataset.agentKey; + state.speechMessage = "Speech recognition is unavailable here. Use Chrome with browser speech recognition enabled."; + updateSpeechModeControls(); + showGrowl(state.speechMessage, "need"); + return false; + } if (state.speechComposerKey === composer.dataset.agentKey) { stopSpeechMode(); return true; } - stopSpeechMode(); + stopSpeechMode({ immediate: true }); const input = composer.querySelector("#prompt-input"); + const originalValue = input.value || ""; + const selectionStart = input.selectionStart ?? originalValue.length; + const selectionEnd = input.selectionEnd ?? selectionStart; const Recognition = speechRecognitionConstructor(); const recognition = new Recognition(); recognition.continuous = true; - recognition.interimResults = false; + recognition.interimResults = true; + const session = { + input, + originalValue, + originalScrollTop: input.scrollTop, + selectionStart, + selectionEnd, + before: originalValue.slice(0, selectionStart), + after: originalValue.slice(selectionEnd), + spacing: selectionStart > 0 && !/\s$/.test(originalValue.slice(0, selectionStart)) ? " " : "", + finalTranscript: "", + interimTranscript: "", + }; recognition.onresult = (event) => { - const transcript = Array.from(event.results) - .slice(event.resultIndex) - .filter((result) => result.isFinal) - .map((result) => result[0]?.transcript || "") - .join(" ") - .trim(); - if (transcript && input.isConnected) insertSpeechTranscript(input, transcript); + if (state.speechRecognition !== recognition) return; + const transcripts = speechRecognitionTranscripts(event.results); + session.finalTranscript = transcripts.final; + session.interimTranscript = transcripts.interim; + state.speechState = transcripts.interim ? "listening" : transcripts.final ? "processing" : "listening"; + state.speechMessage = transcripts.interim + ? `Hearing: ${transcripts.interim}` + : transcripts.final ? "Transcript added. Listening for more…" : "Listening…"; + renderSpeechTranscript(session); + updateSpeechModeControls(); }; recognition.onend = () => { - if (state.speechRecognition === recognition) stopSpeechMode(); + if (state.speechRecognition === recognition) { + finalizeSpeechMode({ immediate: true, stopRecognition: false }); + } }; - recognition.onerror = () => { - if (state.speechRecognition === recognition) stopSpeechMode(); + recognition.onerror = (event) => { + if (state.speechRecognition !== recognition) return; + if (event.error === "aborted") { + finalizeSpeechMode({ immediate: true, stopRecognition: false }); + return; + } + const message = speechRecognitionErrorMessage(event.error); + finalizeSpeechMode({ state: "error", message, stopRecognition: false }); + showGrowl(message, "need"); }; state.speechRecognition = recognition; state.speechComposerKey = composer.dataset.agentKey; + state.speechStatusComposerKey = composer.dataset.agentKey; + state.speechSession = session; + state.speechState = "listening"; + state.speechMessage = "Listening…"; input.focus({ preventScroll: true }); try { recognition.start(); updateSpeechModeControls(); return true; } catch (_error) { - stopSpeechMode(); - showGrowl("Speech recognition could not start in this browser", "need"); + const message = "Speech recognition could not start. Check microphone permission and try again."; + finalizeSpeechMode({ state: "error", message, stopRecognition: false }); + showGrowl(message, "need"); return false; } } @@ -5891,9 +6599,10 @@ function handleSpeechModeShortcut(event) { if (!speechModeShortcut(event) || event.repeat || event.isComposing) return false; if (isTextEntryFocused() && !document.activeElement?.closest?.("#composer")) return false; const composer = speechTargetComposer(); - if (!composer || !speechModeAvailable()) return false; + if (!composer) return false; event.preventDefault(); - return startSpeechMode(composer); + startSpeechMode(composer); + return true; } function touchKeyboardLikely() { @@ -6572,7 +7281,7 @@ function renderProjectDiffFile(file, index, options = {}) { const body = file.binary ? `
Binary file is not expanded.
` : hunks.length - ? hunks.map(renderDiffHunk).join("") + ? hunks.map((hunk, hunkIndex) => renderDiffHunk(hunk, hunkIndex, { path, pullRequestContext: options.pullRequestContext })).join("") : `
${escapeHtml(file.message || "No textual hunks for this file.")}
`; return ` @@ -6592,28 +7301,81 @@ function renderProjectDiffFile(file, index, options = {}) { `; } -function renderDiffHunk(hunk, index) { +function renderDiffHunk(hunk, index, options = {}) { const lines = Array.isArray(hunk.lines) ? hunk.lines : []; + const selectedLines = arrayValue(options.pullRequestContext?.selection?.lines) + .filter((line) => line.path === options.path && line.hunk_index === index); + const finalSelectedLineIndex = selectedLines.length + ? Math.max(...selectedLines.map((line) => Number(line.line_index))) + : -1; + const renderedLines = []; + for (let start = 0; start < lines.length; start += DIFF_LINE_RENDER_CHUNK_SIZE) { + const chunk = lines.slice(start, start + DIFF_LINE_RENDER_CHUNK_SIZE).map((line, chunkIndex) => { + const lineIndex = start + chunkIndex; + return ` + ${renderDiffLine(line, lineIndex, { ...options, hunkIndex: index })} + ${lineIndex === finalSelectedLineIndex ? renderPullRequestInlineComment(options.pullRequestContext, options.path, index) : ""} + `; + }).join(""); + renderedLines.push(`
${chunk}
`); + } return `
${escapeHtml(hunk.header || "@@")}
-
- ${lines.map(renderDiffLine).join("")} +
${renderedLines.join("")}
+
+ `; +} + +function renderPullRequestInlineComment(context, path, hunkIndex) { + const selection = context?.selection; + const lines = arrayValue(selection?.lines); + if (!lines.some((line) => line.path === path && line.hunk_index === hunkIndex)) return ""; + + const first = lines[0] || {}; + const last = lines[lines.length - 1] || first; + const firstNumber = first.new_number || first.old_number || "?"; + const lastNumber = last.new_number || last.old_number || firstNumber; + const range = firstNumber === lastNumber ? `line ${firstNumber}` : `lines ${firstNumber}–${lastNumber}`; + const comment = String(selection.comment || ""); + const disabled = comment.trim() && !context.selectionLocked ? "" : "disabled"; + return ` +
+
+ Commenting on ${escapeHtml(range)} + +
+ +
+ +
`; } -function renderDiffLine(line) { +function renderDiffLine(line, lineIndex = 0, options = {}) { const kind = String(line.kind || "context"); const marker = kind === "added" ? "+" : kind === "removed" ? "-" : kind === "meta" ? "\\" : " "; + const context = options.pullRequestContext; + const selectable = context && ["added", "removed", "context"].includes(kind); + const descriptor = selectable ? { + path: options.path, + hunk_index: Number(options.hunkIndex), + line_index: Number(lineIndex), + } : null; + const selected = descriptor && arrayValue(context.selectedLines) + .some((item) => pullRequestLineKey(item) === pullRequestLineKey(descriptor)); + const lineNumber = line.new_number || line.old_number || ""; + const container = selectable ? "label" : "div"; return ` -
+ <${container} class="diff-line ${escapeAttr(kind)}${selected ? " selected" : ""}${selectable ? " selectable" : ""}"> + ${selectable ? `` : ""} ${diffLineNumber(line.old_number)} ${diffLineNumber(line.new_number)} ${escapeHtml(marker)} ${escapeHtml(line.content || "")} -
+ `; } @@ -6630,6 +7392,330 @@ function pullRequestDiffKey(agentKey, pullRequestId) { return `${agentKey || ""}:${pullRequestId || ""}`; } +function pullRequestDiffSelection(agentKey, pullRequestId, snapshotId = "") { + const key = pullRequestDiffKey(agentKey, pullRequestId); + const current = state.pullRequestDiffSelections[key]; + if (!current || (snapshotId && current.snapshotId !== snapshotId)) { + state.pullRequestDiffSelections[key] = { snapshotId, anchor: null, lines: [], comment: "" }; + } + return state.pullRequestDiffSelections[key]; +} + +function pullRequestLineKey(line) { + return [line?.path || "", Number(line?.hunk_index), Number(line?.line_index)].join("\u0000"); +} + +function pullRequestDiffLine(diff, path, hunkIndex, lineIndex) { + const file = arrayValue(diff?.files).find((item) => (item.path || item.old_path) === path); + const line = arrayValue(arrayValue(file?.hunks)[hunkIndex]?.lines)[lineIndex]; + if (!line || !["added", "removed", "context"].includes(line.kind)) return null; + return { + path, + hunk_index: Number(hunkIndex), + line_index: Number(lineIndex), + kind: line.kind, + side: line.kind === "removed" ? "left" : line.kind === "added" ? "right" : "both", + old_number: line.old_number, + new_number: line.new_number, + content: line.content || "", + }; +} + +function selectPullRequestDiffLine(target, extendRange = false) { + const agentKey = target.dataset.agentKey; + const pullRequestId = target.dataset.pullRequestId; + const snapshotId = target.dataset.snapshotId; + const diff = state.pullRequestDiffs[pullRequestDiffKey(agentKey, pullRequestId)]; + const selected = pullRequestDiffSelection(agentKey, pullRequestId, snapshotId); + const line = pullRequestDiffLine( + diff, + target.dataset.reviewLinePath, + Number(target.dataset.reviewLineHunk), + Number(target.dataset.reviewLineIndex) + ); + if (!line) { + showGrowl("That diff line is no longer available. Refresh the diff.", "need"); + return; + } + + if (extendRange && selected.anchor) { + if (selected.anchor.path !== line.path || selected.anchor.hunk_index !== line.hunk_index) { + showGrowl("A range must stay inside one diff hunk", "need"); + return; + } + const start = Math.min(selected.anchor.line_index, line.line_index); + const finish = Math.max(selected.anchor.line_index, line.line_index); + const range = []; + for (let index = start; index <= finish; index += 1) { + const item = pullRequestDiffLine(diff, line.path, line.hunk_index, index); + if (item) range.push(item); + } + if (range.length > PROMPT_PULL_REQUEST_CONTEXT_LIMITS.maxLines) { + showGrowl(`Select at most ${PROMPT_PULL_REQUEST_CONTEXT_LIMITS.maxLines} contiguous lines`, "need"); + return; + } + selected.lines = range; + } else if (selected.lines.some((item) => pullRequestLineKey(item) === pullRequestLineKey(line))) { + selected.anchor = null; + selected.lines = []; + selected.comment = ""; + } else { + selected.anchor = line; + selected.lines = [line]; + selected.comment = ""; + } + syncPullRequestDiffSelection(agentKey, pullRequestId, { focusComment: selected.lines.length > 0, scrollTarget: target }); +} + +function syncPullRequestDiffSelection(agentKey, pullRequestId, options = {}) { + const agent = findAgent(agentKey); + const item = arrayValue(state.pullRequests[agentKey]?.items).find((candidate) => candidate.id === pullRequestId); + const diff = state.pullRequestDiffs[pullRequestDiffKey(agentKey, pullRequestId)]; + if (!agent || !item || !diff || diff.error) return false; + + const selection = pullRequestDiffSelection(agentKey, pullRequestId, diff.snapshot_id || ""); + const viewer = document.querySelector( + `[data-pr-diff-viewer][data-agent-key="${CSS.escape(agentKey)}"][data-pull-request-id="${CSS.escape(pullRequestId)}"]` + ); + if (!viewer) return false; + + const scrollSnapshot = capturePullRequestScrollPosition(options.scrollTarget instanceof Element ? options.scrollTarget : viewer); + const selectedKeys = new Set(selection.lines.map(pullRequestLineKey)); + const controls = Array.from(viewer.querySelectorAll("[data-select-pr-diff-line]")); + const selectionLocked = state.pendingPullRequestCommentKeys.has(agentKey); + syncPullRequestDiffLineControls(controls, selectedKeys, selectionLocked); + + viewer.querySelectorAll("[data-pr-inline-comment]").forEach((comment) => comment.remove()); + if (selection.lines.length) { + const finalLine = selection.lines.at(-1); + const finalControl = controls.find((control) => ( + pullRequestLineKey(pullRequestLineDescriptor(control)) === pullRequestLineKey(finalLine) + )); + finalControl?.closest(".diff-line")?.insertAdjacentHTML( + "afterend", + renderPullRequestInlineComment( + pullRequestDiffRenderContext(agent, item, diff, selection), + finalLine.path, + finalLine.hunk_index + ) + ); + } + + const selectionSlot = document.querySelector("[data-pr-context-selection-slot]"); + if (selectionSlot) selectionSlot.innerHTML = renderPullRequestLineSelection(agent, item, selection); + restorePullRequestScrollPosition(scrollSnapshot); + window.requestAnimationFrame(() => { + // A native checkbox inside a label can finish its activation after the delegated click handler. + // Reassert the latest state model once without rebuilding the diff. A newer + // selection may have replaced this callback's snapshot in the same frame. + const currentSelection = pullRequestDiffSelection(agentKey, pullRequestId, diff.snapshot_id || ""); + const currentSelectedKeys = new Set(currentSelection.lines.map(pullRequestLineKey)); + syncPullRequestDiffLineControls(controls, currentSelectedKeys, state.pendingPullRequestCommentKeys.has(agentKey)); + if (options.focusComment && currentSelection.lines.length) { + document.querySelector("[data-pr-context-comment]")?.focus({ preventScroll: true }); + } + restorePullRequestScrollPosition(scrollSnapshot); + }); + return true; +} + +function syncPullRequestDiffLineControls(controls, selectedKeys, selectionLocked = false) { + controls.forEach((control) => { + const key = pullRequestLineKey(pullRequestLineDescriptor(control)); + const checked = selectedKeys.has(key); + control.checked = checked; + control.disabled = selectionLocked; + control.closest(".diff-line")?.classList.toggle("selected", checked); + const mark = control.nextElementSibling; + mark?.classList.toggle("selected", checked); + if (mark) mark.textContent = checked ? "✓" : ""; + }); +} + +function pullRequestLineDescriptor(control) { + return { + path: control.dataset.reviewLinePath, + hunk_index: Number(control.dataset.reviewLineHunk), + line_index: Number(control.dataset.reviewLineIndex), + }; +} + +function capturePullRequestScrollPosition(target) { + const anchor = target.closest?.(".diff-line") || null; + const containers = []; + let current = target; + while (current && current !== document.body) { + if (current.matches?.("[data-preserve-scroll]")) { + containers.push({ element: current, top: current.scrollTop, left: current.scrollLeft }); + } + current = current.parentElement; + } + return { + anchor, + anchorTop: anchor?.getBoundingClientRect().top, + containers, + page: { top: window.scrollY, left: window.scrollX }, + }; +} + +function restorePullRequestScrollPosition(snapshot) { + if (!snapshot) return; + snapshot.containers.forEach(({ element, top, left }) => { + if (!element.isConnected) return; + element.scrollTop = top; + element.scrollLeft = left; + }); + window.scrollTo(snapshot.page.left, snapshot.page.top); + if (!snapshot.anchor?.isConnected || !Number.isFinite(snapshot.anchorTop)) return; + + const anchorDelta = snapshot.anchor.getBoundingClientRect().top - snapshot.anchorTop; + if (Math.abs(anchorDelta) < 0.5) return; + const verticalContainer = snapshot.containers.find(({ element }) => ( + element.scrollHeight > element.clientHeight && ["auto", "scroll"].includes(getComputedStyle(element).overflowY) + )); + if (verticalContainer) verticalContainer.element.scrollTop += anchorDelta; + else window.scrollBy(0, anchorDelta); +} + +function pendingPullRequestContextsFor(agentKey) { + const key = String(agentKey || ""); + if (!key) return []; + state.pendingPullRequestContexts[key] ||= []; + return state.pendingPullRequestContexts[key]; +} + +function pullRequestContextFingerprint(context) { + return [context.pull_request_id, context.snapshot_id, ...arrayValue(context.lines).map(pullRequestLineKey)].join("|"); +} + +function selectedPullRequestCommentContext(agentKey, pullRequestId) { + const diff = state.pullRequestDiffs[pullRequestDiffKey(agentKey, pullRequestId)]; + const selection = pullRequestDiffSelection(agentKey, pullRequestId, diff?.snapshot_id || ""); + const comment = String(selection.comment || "").trim(); + if (!selection.lines.length) return null; + if (!comment) { + showGrowl("Write a comment for the selected lines", "need"); + document.querySelector("[data-pr-context-comment]")?.focus({ preventScroll: true }); + return null; + } + + return { + id: `pr-context-${Date.now()}-${Math.random().toString(16).slice(2)}`, + pull_request_id: pullRequestId, + snapshot_id: selection.snapshotId, + repository: diff.repository || "repository", + number: diff.number, + comment, + lines: selection.lines.map((line) => ({ ...line })), + }; +} + +function attachSelectedPullRequestLines(agentKey, pullRequestId) { + const diff = state.pullRequestDiffs[pullRequestDiffKey(agentKey, pullRequestId)]; + const selection = pullRequestDiffSelection(agentKey, pullRequestId, diff?.snapshot_id || ""); + const context = selectedPullRequestCommentContext(agentKey, pullRequestId); + if (!context) return; + + const pending = pendingPullRequestContextsFor(agentKey); + if (pending.some((item) => pullRequestContextFingerprint(item) === pullRequestContextFingerprint(context))) { + showGrowl("That pull request range is already attached", "need"); + return; + } + if (pending.length >= PROMPT_PULL_REQUEST_CONTEXT_LIMITS.maxContexts) { + showGrowl(`Attach at most ${PROMPT_PULL_REQUEST_CONTEXT_LIMITS.maxContexts} pull request ranges`, "need"); + return; + } + + pending.push(context); + selection.anchor = null; + selection.lines = []; + selection.comment = ""; + syncPullRequestDiffSelection(agentKey, pullRequestId); + render(); + showGrowl("Comment section added", "done"); +} + +function promptPullRequestContextPayload(context, options = {}) { + return { + pull_request_id: context.pull_request_id, + snapshot_id: context.snapshot_id, + ...(options.includeComment && context.comment ? { comment: context.comment } : {}), + lines: arrayValue(context.lines).map((line) => ({ + path: line.path, + hunk_index: line.hunk_index, + line_index: line.line_index, + })), + }; +} + +function sendSelectedPullRequestComment(agentKey, pullRequestId) { + const agent = findAgent(agentKey); + if (!agent || agentIsRunning(agent) || state.pendingComposerKeys.has(agentKey) || state.pendingPullRequestCommentKeys.has(agentKey)) { + showGrowl("Wait for the agent to finish before sending another comment", "need"); + return; + } + const context = selectedPullRequestCommentContext(agentKey, pullRequestId); + if (!context) return; + + const prompt = context.comment; + const pendingMessageId = addPendingConversationMessage( + agentKey, + pendingPromptMessageBlock(agentKey, prompt, [], [context]) + ); + state.pendingPullRequestCommentKeys.add(agentKey); + syncPullRequestDiffSelection(agentKey, pullRequestId); + render(); + mutate(async () => { + await apiPost(`/agents/${encodeURIComponent(agentKey)}/messages`, { + prompt, + start: true, + attachments: [], + pull_request_contexts: [promptPullRequestContextPayload(context)], + }); + const selection = pullRequestDiffSelection(agentKey, pullRequestId, context.snapshot_id); + selection.anchor = null; + selection.lines = []; + selection.comment = ""; + state.pendingPullRequestCommentKeys.delete(agentKey); + removePendingConversationMessage(agentKey, pendingMessageId, { render: false }); + }).finally(() => { + state.pendingPullRequestCommentKeys.delete(agentKey); + syncPullRequestDiffSelection(agentKey, pullRequestId); + removePendingConversationMessage(agentKey, pendingMessageId); + }); +} + +function removePendingPullRequestContext(agentKey, id) { + state.pendingPullRequestContexts[agentKey] = pendingPullRequestContextsFor(agentKey) + .filter((context) => context.id !== id); + render(); +} + +function clearActivePullRequestDiffSelection() { + const route = parseRoute(); + if (route.type !== "agentPullRequests") return false; + const pullRequestId = selectedPullRequestId(route.key, route.pullRequestId); + return clearPullRequestDiffSelection(route.key, pullRequestId); +} + +function clearPullRequestDiffSelection(agentKey, pullRequestId) { + const diff = state.pullRequestDiffs[pullRequestDiffKey(agentKey, pullRequestId)]; + const selection = pullRequestDiffSelection(agentKey, pullRequestId, diff?.snapshot_id || ""); + if (!selection.lines.length) return false; + selection.anchor = null; + selection.lines = []; + selection.comment = ""; + syncPullRequestDiffSelection(agentKey, pullRequestId); + return true; +} + +function handlePullRequestSelectionEscape(event) { + if (event.key !== "Escape" || state.speechRecognition) return; + if (!clearActivePullRequestDiffSelection()) return; + event.preventDefault(); + event.stopPropagation(); +} + function selectedPullRequestId(agentKey, requestedId = "") { const items = state.pullRequests[agentKey]?.items || []; if (requestedId && items.some((item) => item.id === requestedId)) return requestedId; @@ -6644,6 +7730,14 @@ function pullRequestFreshnessLabel(item) { return snapshot.fetched_at ? `fetched ${timeShort(snapshot.fetched_at)}` : "snapshot"; } +function pullRequestStatus(item) { + if (item?.merged === true) return { key: "merged", label: "Merged", className: "info" }; + if (item?.state === "closed") return { key: "closed", label: "Closed", className: "detail" }; + if (item?.draft === true) return { key: "draft", label: "Draft", className: "need" }; + if (item?.state === "open") return { key: "open", label: "Open", className: "done" }; + return null; +} + function shortSha(value) { const text = String(value || ""); return text.length > 8 ? text.slice(0, 8) : text; @@ -7467,6 +8561,46 @@ function renderPendingAttachments(agent) { `; } +function renderPendingPullRequestContexts(agent) { + const pending = pendingPullRequestContextsFor(agent.key); + if (!pending.length) return ""; + + return ` +
+
+ PR context ready + ${escapeHtml(`${pending.length}/${PROMPT_PULL_REQUEST_CONTEXT_LIMITS.maxContexts}`)} +
+
+ ${pending.map((context) => renderPendingPullRequestContext(agent.key, context)).join("")} +
+
+ `; +} + +function renderPendingPullRequestContext(agentKey, context) { + const lines = arrayValue(context.lines); + const first = lines[0] || {}; + const last = lines[lines.length - 1] || first; + const firstNumber = first.new_number || first.old_number || "?"; + const lastNumber = last.new_number || last.old_number || firstNumber; + const range = firstNumber === lastNumber ? `line ${firstNumber}` : `lines ${firstNumber}–${lastNumber}`; + const current = state.pullRequestDiffs[pullRequestDiffKey(agentKey, context.pull_request_id)]; + const stale = current?.snapshot_id && current.snapshot_id !== context.snapshot_id; + const side = new Set(lines.map((line) => line.side)).size === 1 ? first.side : "mixed sides"; + return ` +
+ + + ${escapeHtml(`${context.repository}${context.number ? `#${context.number}` : ""} · ${first.path || "diff"}`)} + ${escapeHtml(`${range} · ${side} · ${lines.length} selected${stale ? " · outdated; remove and reattach" : ""}`)} + ${context.comment ? `${escapeHtml(context.comment)}` : ""} + + +
+ `; +} + function renderPendingAttachment(agentKey, attachment) { const preview = attachment.previewUrl ? `` @@ -7483,7 +8617,7 @@ function renderPendingAttachment(agentKey, attachment) { `; } -function pendingPromptMessageBlock(agentKey, prompt, attachments = []) { +function pendingPromptMessageBlock(agentKey, prompt, attachments = [], pullRequestContexts = []) { const metadataAttachments = attachments.map((attachment) => ({ title: attachment.filename, target: attachment.filename, @@ -7499,7 +8633,15 @@ function pendingPromptMessageBlock(agentKey, prompt, attachments = []) { content: prompt, created_at: new Date().toISOString(), pending: true, - metadata: metadataAttachments.length ? { attachments: metadataAttachments } : {}, + metadata: { + ...(metadataAttachments.length ? { attachments: metadataAttachments } : {}), + ...(pullRequestContexts.length ? { pull_request_contexts: pullRequestContexts.map((context) => ({ + repository: context.repository, + number: context.number, + path: context.lines?.[0]?.path, + line_count: arrayValue(context.lines).length, + })) } : {}), + }, }; } @@ -8344,20 +9486,31 @@ function highlightedCodeHtml(text, language) { function renderCodeRoute(language) { const route = parseRoute(); - if (route.type === "agent" || route.type === "agentSummary" || route.type === "agentAttachment") { + if (route.type === "agent") { state.renderedViewHtml = ""; render(); return; } - if (route.type !== "attachment") return; - const attachment = state.attachmentDetails[route.id] || attachmentById(route.id); + if (route.type !== "attachment" && route.type !== "agentAttachment") return; + const attachmentId = route.type === "agentAttachment" ? route.attachmentId : route.id; + const attachment = state.attachmentDetails[attachmentId] || attachmentById(attachmentId); if (!attachment || attachmentKind(attachment) !== "file") return; if (attachmentFormat(attachment) !== "text") return; if (language && codeLanguageForAttachment(attachment) !== language) return; - state.renderedViewHtml = ""; - render(); + const viewer = els.view.querySelector(".attachment-code-viewer"); + if (!viewer) return; + const template = document.createElement("template"); + template.innerHTML = renderCodeAttachment(attachment.content, attachment); + const replacement = template.content.firstElementChild; + if (replacement) { + const scrollTop = viewer.scrollTop; + const scrollLeft = viewer.scrollLeft; + viewer.replaceWith(replacement); + replacement.scrollTop = scrollTop; + replacement.scrollLeft = scrollLeft; + } } function openAttachmentLinkOnce(id, href) { @@ -8370,9 +9523,16 @@ function openAttachmentLinkOnce(id, href) { function renderMarkdown(text, options = {}) { const source = String(text || ""); if (markdownParserReady()) return renderParsedMarkdown(source, options); + if (markdownParser.failed) return renderPlainTextMarkdown(source, options); ensureMarkdownParserLoaded(); - return renderPlainTextMarkdown(source, options); + state.markdownFallbackSequence += 1; + const fallbackKey = `markdown-fallback:${state.markdownFallbackSequence}`; + while (state.markdownFallbacks.size >= MARKDOWN_FALLBACK_LIMIT) { + state.markdownFallbacks.delete(state.markdownFallbacks.keys().next().value); + } + state.markdownFallbacks.set(fallbackKey, { source, options: { ...options } }); + return renderPlainTextMarkdown(source, { ...options, fallbackKey }); } function markdownParserReady() { @@ -8393,6 +9553,7 @@ function ensureMarkdownParserLoaded() { return true; }).catch((error) => { markdownParser.failed = true; + state.markdownFallbacks.clear(); console.warn("Markdown parser load failed", error); return false; }); @@ -8426,23 +9587,16 @@ function loadExternalScript(src) { } function renderMarkdownRoute() { - const route = parseRoute(); - if (route.type === "agent" || route.type === "agentSummary" || route.type === "agentAttachment") { - state.renderedViewHtml = ""; - render(); - return; - } - - if (route.type !== "attachment" && route.type !== "agentAttachment") return; - - const attachmentId = route.type === "agentAttachment" ? route.attachmentId : route.id; - const attachment = state.attachmentDetails[attachmentId] || attachmentById(attachmentId); - if (!attachment || attachmentKind(attachment) !== "file") return; - const format = String(attachment.format || attachmentFormat(attachment)).toLowerCase(); - if (format !== "markdown") return; + els.view.querySelectorAll("[data-markdown-fallback-key]").forEach((element) => { + const fallbackKey = element.dataset.markdownFallbackKey; + const fallback = state.markdownFallbacks.get(fallbackKey); + if (!fallback) return; - state.renderedViewHtml = ""; - render(); + element.outerHTML = renderParsedMarkdown(fallback.source, fallback.options); + state.markdownFallbacks.delete(fallbackKey); + }); + state.markdownFallbacks.clear(); + queueMermaidRendering(); } function renderParsedMarkdown(text, options = {}) { @@ -8570,7 +9724,8 @@ function markMermaidError(node, source) { function renderPlainTextMarkdown(text, options = {}) { const className = String(options.fallbackClassName || "attachment-text-viewer").trim() || "attachment-text-viewer"; const emptyText = Object.prototype.hasOwnProperty.call(options, "emptyText") ? options.emptyText : "No content"; - return `
${escapeHtml(text || emptyText)}
`; + const fallbackKey = options.fallbackKey ? ` data-markdown-fallback-key="${escapeAttr(options.fallbackKey)}"` : ""; + return `
${escapeHtml(text || emptyText)}
`; } function markdownViewerClassName(options = {}) { @@ -8878,7 +10033,11 @@ function setHeaderSubtitle(text) { function renderHeaderSubtitle() { const value = state.headerSubtitle; const icon = state.refreshing ? iconSvg("hourglass") : iconSvg(state.headerSubtitleIcon); - const className = state.refreshing ? "subtitle-status subtitle-refresh" : "subtitle-status"; + const className = [ + "subtitle-status", + state.refreshing ? "subtitle-refresh" : "", + value.startsWith("Conversation poll paused") ? "subtitle-polling-paused" : "", + ].filter(Boolean).join(" "); els.subtitle.innerHTML = `${escapeHtml(value)}`; } @@ -9836,9 +10995,10 @@ function responseStyleSourceLabel(source) { }[String(source || "").toLowerCase()] || "Disabled"; } -function setConnection(text) { +function setConnection(text, options = {}) { state.connection = text; state.refreshing = text === "Refreshing"; + if (options.preserveHeaderSubtitle) return; if (state.refreshing) { renderHeaderSubtitle(); } else { @@ -9932,6 +11092,13 @@ function onScroll() { window.requestAnimationFrame(handleScrollDirection); } +function notePullRequestDiffScrollIntent(event) { + if (!event.isTrusted || !els.view.querySelector("[data-pr-diff-viewer]")) return; + if (event.type === "keydown" && !["ArrowDown", "ArrowUp", "End", "Home", "PageDown", "PageUp", " "].includes(event.key)) return; + + state.pullRequestDiffUserScrollGeneration += 1; +} + function findAgent(key) { const listAgent = state.agents.find((agent) => agent.key === key) || null; const detailAgent = state.agentDetails[key] || @@ -11550,7 +12717,63 @@ els.authPanel.addEventListener("submit", (event) => { els.saveToken.addEventListener("click", () => withPendingForm(els.authPanel, saveRemoteToken)); els.view.addEventListener("click", (event) => { + const prDiffNavigation = event.target.closest(".pr-diff-nav-item[data-agent-key][data-pull-request-id]"); + if (prDiffNavigation && !event.altKey && !event.ctrlKey && !event.metaKey && !event.shiftKey) { + event.preventDefault(); + cacheVisiblePullRequestDiffViewer(); + showNav(); + navigate({ + type: "agentPullRequests", + key: prDiffNavigation.dataset.agentKey, + pullRequestId: prDiffNavigation.dataset.pullRequestId, + }); + return; + } showNav(); + const prDiffLine = event.target.closest("[data-select-pr-diff-line]"); + if (prDiffLine) { + event.preventDefault(); + selectPullRequestDiffLine(prDiffLine, event.shiftKey); + return; + } + const attachPrDiffSelection = event.target.closest("[data-attach-pr-diff-selection]"); + if (attachPrDiffSelection) { + attachSelectedPullRequestLines( + attachPrDiffSelection.dataset.agentKey, + attachPrDiffSelection.dataset.pullRequestId + ); + return; + } + const sendPrDiffComment = event.target.closest("[data-send-pr-diff-comment]"); + if (sendPrDiffComment) { + sendSelectedPullRequestComment( + sendPrDiffComment.dataset.agentKey, + sendPrDiffComment.dataset.pullRequestId + ); + return; + } + const openPrContextComposer = event.target.closest("[data-open-pr-context-composer]"); + if (openPrContextComposer) { + const agentKey = openPrContextComposer.dataset.openPrContextComposer; + const focusedComposer = document.querySelector(`#composer[data-agent-key="${CSS.escape(agentKey)}"]`)?.closest("details.focused-composer"); + if (focusedComposer) focusedComposer.open = true; + document.querySelector(`#composer[data-agent-key="${CSS.escape(agentKey)}"] #prompt-input`)?.focus({ preventScroll: true }); + syncAgentDockLayout(); + return; + } + const clearPrDiffSelection = event.target.closest("[data-clear-pr-diff-selection]"); + if (clearPrDiffSelection) { + clearPullRequestDiffSelection( + clearPrDiffSelection.dataset.agentKey, + clearPrDiffSelection.dataset.pullRequestId + ); + return; + } + const removePrContext = event.target.closest("[data-remove-pr-context]"); + if (removePrContext) { + removePendingPullRequestContext(removePrContext.dataset.agentKey, removePrContext.dataset.removePrContext); + return; + } if (event.target.closest("[data-start-github-login]")) { startGitHubLogin(); return; @@ -12412,6 +13635,7 @@ els.view.addEventListener("keydown", (event) => { }); document.addEventListener("keydown", handleFullScreenComposerKeydown, true); +document.addEventListener("keydown", handlePullRequestSelectionEscape, true); document.addEventListener("input", deferPollAfterFormInput, true); els.view.addEventListener("paste", (event) => { @@ -12427,6 +13651,21 @@ document.addEventListener("dragend", clearComposerDropTargets); document.addEventListener("drop", clearComposerDropTargets); els.view.addEventListener("input", (event) => { + if (event.target.matches("[data-pr-context-comment]")) { + const diff = state.pullRequestDiffs[pullRequestDiffKey( + event.target.dataset.agentKey, + event.target.dataset.pullRequestId + )]; + const selection = pullRequestDiffSelection( + event.target.dataset.agentKey, + event.target.dataset.pullRequestId, + diff?.snapshot_id || "" + ); + selection.comment = event.target.value; + event.target.closest("[data-pr-inline-comment]")?.querySelectorAll("[data-attach-pr-diff-selection], [data-send-pr-diff-comment]") + .forEach((button) => { button.disabled = !event.target.value.trim(); }); + return; + } if (event.target.matches("[data-review-filter]")) { state.pullRequestFilters.query = event.target.value; renderPullRequestInbox(); @@ -12740,19 +13979,39 @@ els.view.addEventListener("submit", (event) => { const key = button?.dataset.agentKey || form.dataset.agentKey; const promptValue = form.querySelector("#prompt-input")?.value.trim() || ""; const pendingAttachments = pendingAttachmentsFor(key); - const prompt = promptValue || (pendingAttachments.length ? "Please review the attached files." : ""); + const submittedPullRequestContexts = pendingPullRequestContextsFor(key).map((context) => ({ + ...context, + lines: arrayValue(context.lines).map((line) => ({ ...line })), + })); + const prompt = promptValue || (pendingAttachments.length + ? "Please review the attached files." + : submittedPullRequestContexts.length ? "Please review the attached pull request context." : ""); if (!key || !prompt) return; state.fullScreenComposerKeys.delete(key); schedule(); closeSkillFlyout(); setComposerSending(form, true); clearFormDraft(form); - const pendingMessageId = addPendingConversationMessage(key, pendingPromptMessageBlock(key, prompt, pendingAttachments)); + const pendingMessageId = addPendingConversationMessage( + key, + pendingPromptMessageBlock(key, prompt, pendingAttachments, submittedPullRequestContexts) + ); mutate(async () => { const attachments = await pendingAttachmentPayloads(key); - await apiPost(`/agents/${encodeURIComponent(key)}/messages`, { prompt, start: true, attachments }); + const pullRequestContexts = submittedPullRequestContexts.map((context) => ({ + ...promptPullRequestContextPayload(context, { includeComment: true }), + })); + await apiPost(`/agents/${encodeURIComponent(key)}/messages`, { + prompt, + start: true, + attachments, + pull_request_contexts: pullRequestContexts, + }); removePendingConversationMessage(key, pendingMessageId, { render: false }); clearPendingAttachments(key); + const submittedIds = new Set(submittedPullRequestContexts.map((context) => context.id)); + state.pendingPullRequestContexts[key] = pendingPullRequestContextsFor(key) + .filter((context) => !submittedIds.has(context.id)); }, { form }).finally(() => { setComposerSending(form, false); removePendingConversationMessage(key, pendingMessageId); @@ -13047,10 +14306,14 @@ async function refreshAgentPullRequests(agentKey) { if (!agentKey) return; try { - setConnection("Refreshing pull requests"); - await ensureAgentPullRequests(agentKey, true); + setConnection("Refreshing PR metadata"); + const data = await apiPost(`/agents/${encodeURIComponent(agentKey)}/pull-requests/metadata/refresh`); + state.pullRequests[agentKey] = { loading: false, items: data.pull_requests || [] }; state.renderedViewHtml = ""; - setConnection("Pull requests refreshed"); + const failures = Array.isArray(data.failed) ? data.failed.length : 0; + setConnection(failures + ? `PR metadata refreshed with ${failures} failure${failures === 1 ? "" : "s"}` + : "PR metadata refreshed"); render(); } catch (error) { setConnection(error.message); @@ -13098,11 +14361,25 @@ async function refreshAllPullRequestDiffs(agentKey) { try { setConnection("Fetching PR diffs"); const data = await apiPost(`/agents/${encodeURIComponent(agentKey)}/pull-requests/refresh`); - (data.refreshed || []).forEach((diff) => { + const route = parseRoute(); + const selectedId = route.type === "agentPullRequests" && route.key === agentKey + ? selectedPullRequestId(agentKey, route.pullRequestId) + : ""; + const selectedKey = selectedId ? pullRequestDiffKey(agentKey, selectedId) : ""; + const selectedDiff = selectedKey ? state.pullRequestDiffs[selectedKey] : null; + const refreshed = Array.isArray(data.refreshed) ? data.refreshed : []; + refreshed.sort((left, right) => Number(left?.id === selectedId) - Number(right?.id === selectedId)); + refreshed.forEach((diff) => { if (!diff?.id) return; - state.pullRequestDiffs[pullRequestDiffKey(agentKey, diff.id)] = diff; + const key = pullRequestDiffKey(agentKey, diff.id); + state.pullRequestDiffs[key] = diff; + rememberPullRequestDiffData(key); }); + if (selectedDiff && !refreshed.some((diff) => diff?.id === selectedId)) { + state.pullRequestDiffs[selectedKey] = selectedDiff; + rememberPullRequestDiffData(selectedKey); + } await ensureAgentPullRequests(agentKey, true); const failures = Array.isArray(data.failed) ? data.failed.length : 0; setConnection(failures ? `PR diffs refreshed with ${failures} failure${failures === 1 ? "" : "s"}` : "PR diffs refreshed"); @@ -14018,6 +15295,7 @@ function scrollAgentConversationToBottom() { } window.addEventListener("hashchange", () => { + if (state.speechRecognition) stopSpeechMode({ immediate: true }); saveAgentShellFormDrafts(); state.fullScreenComposerKeys.clear(); state.fullScreenInquiryKeys.clear(); @@ -14025,11 +15303,16 @@ window.addEventListener("hashchange", () => { closeUnreadPanel(); state.lastScrollY = window.scrollY; showNav(); + resetPullRequestDiffRender(); render(); ensureRouteData({ forceConversation: true, forceProject: true }).then(render).catch((error) => setConnection(error.message)); }); window.addEventListener("scroll", onScroll, { passive: true }); +window.addEventListener("wheel", notePullRequestDiffScrollIntent, { capture: true, passive: true }); +window.addEventListener("touchmove", notePullRequestDiffScrollIntent, { capture: true, passive: true }); +window.addEventListener("pointerdown", notePullRequestDiffScrollIntent, { capture: true, passive: true }); +window.addEventListener("keydown", notePullRequestDiffScrollIntent, true); window.addEventListener("focusin", () => { showNav(); @@ -14056,7 +15339,7 @@ document.addEventListener("visibilitychange", () => { state.failureCount = 0; state.lastScrollY = window.scrollY; showNav(); - refresh({ force: true }); + refresh({ force: true, preserveFocusedWorkspace: true }); } else { flushComposerDraftSaves(); } @@ -14572,6 +15855,16 @@ if (els.confirmationDialog) { document.addEventListener("keydown", (event) => { if (shortcutModifierKey(event)) setShortcutModifierActive(true); + if (event.key === "Escape" && state.speechRecognition) { + event.preventDefault(); + stopSpeechMode({ cancel: true }); + return; + } + if (event.key === "Escape" && clearActivePullRequestDiffSelection()) { + event.preventDefault(); + return; + } + if (handleSpeechModeShortcut(event)) return; if (handleAttachmentFlyoutKeydown(event)) return; diff --git a/test/managed_agent_test.rb b/test/managed_agent_test.rb index 6a169f1..9c36ee3 100644 --- a/test/managed_agent_test.rb +++ b/test/managed_agent_test.rb @@ -15,6 +15,7 @@ def run! assert_new_agents_use_unique_log_stems assert_lifetime_run_count_survives_retained_window assert_completed_status_overrides_live_pid + assert_structured_result_status_overrides_transport_exit assert_start_finalizes_unpolled_previous_run assert_cli_status_finalizes_unpolled_dead_pid assert_start_reconciles_session_after_restart @@ -100,6 +101,43 @@ def assert_lifetime_run_count_survives_retained_window end end + def assert_structured_result_status_overrides_transport_exit + run = HQ::ManagedAgent::AgentRun.new( + started_at: Time.utc(2026, 8, 10, 1), + finished_at: Time.utc(2026, 8, 10, 1, 1), + exit_code: 1, + status: "partial" + ) + agent = HQ::ManagedAgent.new( + key: "structured-status-agent", + name: "Structured status", + project_key: "demo", + template_key: "custom", + workspace: Dir.tmpdir, + prompt: "Prompt", + last_exit_code: 1, + runs: [run], + structured_result: { "status" => "partial", "summary" => "Work is ready; CI is flaky." } + ) + + assert(agent.status == "partial", + "expected a validated structured result to prevent a transport exit from rendering as failed") + + agent.structured_result = { "status" => "success", "summary" => "Completed." } + assert(agent.status == "succeeded", + "expected a successful structured result to override a nonzero transport exit") + + agent.structured_result = { "status" => "failed", "summary" => "Validation failed." } + agent.instance_variable_set(:@last_exit_code, 0) + assert(agent.status == "failed", + "expected an explicitly failed structured result to override a zero transport exit") + + agent.structured_result = { "status" => "success", "summary" => "Stopped after completion." } + agent.instance_variable_set(:@last_exit_code, 143) + assert(agent.status == "stopped", + "expected an explicit stop exit to remain a lifecycle state despite a structured result") + end + def assert_completed_run_persists_cost_snapshot Dir.mktmpdir("hq-managed-agent-cost-snapshot-test") do |dir| started_at = Time.utc(2026, 7, 22, 10, 0, 0) @@ -1504,10 +1542,26 @@ def assert_external_process_environment_removes_ruby_loader_state env = agent.send(:external_process_environment, "BUNDLE_GEMFILE" => "/custom/Gemfile", "CUSTOM" => "1") assert(env["BUNDLE_BIN_PATH"].nil?, "expected Bundler bin path to be cleared for harnesses") + assert(env.key?("BUNDLER_SETUP") && env["BUNDLER_SETUP"].nil?, + "expected Bundler setup hook to be cleared for harnesses") assert(env["RUBYOPT"].nil?, "expected Ruby loader options to be cleared for harnesses") assert(env["GEM_HOME"].nil?, "expected Ruby gem home to be cleared for harnesses") assert(env["BUNDLE_GEMFILE"] == "/custom/Gemfile", "expected explicit harness env to remain authoritative") assert(env["CUSTOM"] == "1", "expected explicit harness env to be preserved") + + runner_output = IO.popen( + agent.send(:external_process_environment, {}), + [ + RbConfig.ruby, + "-I", File.expand_path("../lib", __dir__), + "-r", "hq/domain/agent_correction_runner", + "-e", 'STDOUT.write("runner-loaded")' + ], + err: %i[child out], + &:read + ) + assert($?.success? && runner_output == "runner-loaded", + "expected sanitized environment to execute the correction runner, got #{runner_output.inspect}") end def assert_start_spawns_harness_through_validation_runner diff --git a/test/pull_request_diff_test.rb b/test/pull_request_diff_test.rb index be6cb30..f4c9b11 100644 --- a/test/pull_request_diff_test.rb +++ b/test/pull_request_diff_test.rb @@ -45,6 +45,7 @@ def run! assert_freshness_separates_code_and_activity assert_snapshot_identity_omits_agent_key assert_store_preserves_concurrent_snapshots + assert_store_reuses_and_invalidates_document_cache puts "pull_request_diff_test: ok" end @@ -135,6 +136,26 @@ def assert_store_preserves_concurrent_snapshots end end + def assert_store_reuses_and_invalidates_document_cache + Dir.mktmpdir("tycho-pr-diff-cache") do |dir| + path = File.join(dir, "snapshots.json") + store = HQ::PullRequestDiff::Store.new(path) + store.save("id" => "first", "snapshot_id" => "first-snapshot") + first_read = store.all + assert(store.all.equal?(first_read), "expected unchanged snapshot files to reuse the parsed document") + assert(first_read.frozen? && first_read.fetch("first").frozen?, "expected cached snapshots to be immutable") + begin + first_read.fetch("first")["snapshot_id"] = "mutated" + rescue FrozenError + nil + end + assert(store.fetch("first")["snapshot_id"] == "first-snapshot", "expected callers not to mutate cached snapshots") + + HQ::PullRequestDiff::Store.new(path).save("id" => "second", "snapshot_id" => "second-snapshot") + assert(store.all.keys.sort == %w[first second], "expected external snapshot writes to invalidate the parsed document") + end + end + def reference_for(agent_key: nil) HQ::PullRequestDiff::Reference.new( id: HQ::PullRequestDiff.reference_id("github", "example/web", 123), diff --git a/test/remote_server_test.rb b/test/remote_server_test.rb index 87813c7..6e11498 100644 --- a/test/remote_server_test.rb +++ b/test/remote_server_test.rb @@ -26,10 +26,12 @@ def run! assert_remote_inquiry_payload_has_stable_id_and_guarded_answer assert_remote_agent_payload_includes_attachments assert_remote_agent_pull_request_diff_payload + assert_agent_pull_request_listing_avoids_eager_metadata_requests assert_concurrent_pull_request_diff_refreshes_are_coalesced assert_pull_request_review_refresh_reuses_metadata assert_pull_request_feature_gate_and_global_inbox assert_pull_request_line_handoff_uses_snapshot_context + assert_remote_prompt_accepts_pull_request_context assert_github_app_auth_routes assert_pull_request_posting_is_confirmed_stale_safe_and_idempotent assert_remote_prompt_accepts_uploaded_attachments @@ -932,8 +934,10 @@ def assert_remote_agent_pull_request_diff_payload reference = payload.first assert(reference["repository"] == "example/web", "expected GitHub repository to be parsed") assert(reference["number"] == 123, "expected GitHub PR number to be parsed") - assert(reference["error"].to_s.length.positive?, - "expected metadata errors to be reported without hiding PR references") + assert(reference["error"].nil?, "expected ordinary PR listing to avoid unavailable GitHub metadata") + metadata_refresh = service.refresh_agent_pull_request_metadata(created[:key]) + assert(metadata_refresh[:failed].length == 1, + "expected explicit metadata refresh errors to be reported without hiding PR references") snapshot = { "id" => reference["id"], @@ -942,7 +946,9 @@ def assert_remote_agent_pull_request_diff_payload "repository" => "example/web", "number" => 123, "url" => "https://github.com/example/web/pull/123", - "title" => "Example PR", + "title" => "Origin PR title", + "state" => "open", + "draft" => true, "head_sha" => "abc1234", "base_sha" => "def5678", "fetched_at" => Time.now.iso8601, @@ -964,6 +970,13 @@ def assert_remote_agent_pull_request_diff_payload } HQ::PullRequestDiff::Store.new.save(snapshot) + listed_from_snapshot = service.agent_pull_requests(created[:key]).first + assert(listed_from_snapshot["title"] == "Origin PR title" && + listed_from_snapshot["state"] == "open" && listed_from_snapshot["draft"] == true, + "expected saved origin metadata to backfill the agent PR catalog and listing") + assert(!listed_from_snapshot.fetch("snapshot").key?("fresh"), + "expected snapshot-seeded metadata to leave remote freshness unknown") + diff = service.agent_pull_request_diff(created[:key], reference["id"]) assert(diff["files"].first["path"] == "lib/example.rb", "expected saved PR diff snapshot to be returned") @@ -973,26 +986,157 @@ def assert_remote_agent_pull_request_diff_payload end end + def assert_agent_pull_request_listing_avoids_eager_metadata_requests + with_remote_temp_store do |dir| + workspace = File.join(dir, "workspace") + write_project_workspace(workspace) + registry = registry_for_project(dir, workspace) + client = FakeGitHubReviewClient.new + snapshot_store = CountingPullRequestDiffStore.new(File.join(dir, "diffs.json")) + service = HQ::RemoteService.new(registry:, github_client: client, pull_request_diff_store: snapshot_store) + created = service.create_agent( + "project_key" => "web", + "template_key" => "custom", + "name" => "Many pull requests", + "prompt" => "Review pull requests.", + "agent" => "codex" + ) + agent = HQ::AgentStore.new(registry.projects).load.find { |item| item.key == created[:key] } + memory = HQ::AgentMemory.new(agent) + 3.times do |index| + memory.append_attachment!( + { + "kind" => "link", + "title" => "PR #{index + 1}", + "url" => "https://github.com/example/web/pull/#{index + 1}" + }, + created_at: Time.parse("2026-08-09 13:00:0#{index}") + ) + end + + listed = service.agent_pull_requests(created[:key]) + metadata_requests = client.requests.count { |kind, path| kind == :get_json && path.include?("/pulls/") } + + assert(listed.length == 3, "expected every attached pull request to be listed") + assert(metadata_requests.zero?, + "expected ordinary PR listing to avoid one blocking GitHub metadata request per pull request") + assert(snapshot_store.all_calls == 1, + "expected ordinary PR listing to parse the shared diff snapshot store only once") + + server = HQ::RemoteServer.new + response = server.send( + :route, + service, + "POST", + "/agents/#{created[:key]}/pull-requests/metadata/refresh", + {}, + nil + ) + refreshed = response[:body] + metadata_requests = client.requests.count { |kind, path| kind == :get_json && path.include?("/pulls/") } + assert(metadata_requests == 3, "expected explicit metadata refresh to request each pull request once") + assert(refreshed[:pull_requests].all? { |item| item["title"] == "Shared PR" }, + "expected refreshed GitHub metadata to be returned from the persistent catalog") + assert(refreshed[:pull_requests].all? { |item| item["metadata_refreshed_at"].to_s.length.positive? }, + "expected cached PR metadata to expose its refresh time") + catalog = JSON.parse(File.read(agent.pull_request_catalog_path)) + assert(catalog.fetch("entries").length == 3, + "expected newly discovered pull requests to be persisted in the agent-owned PR catalog") + assert(!File.exist?(File.join(HQ::AGENT_LOGS_DIR, "pull_request_catalog.json")), + "expected agent PR listing to avoid a shared cross-agent catalog") + + second_created = service.create_agent( + "project_key" => "web", + "template_key" => "custom", + "name" => "Other pull requests", + "prompt" => "Review another list.", + "agent" => "codex" + ) + second_agent = HQ::AgentStore.new(registry.projects).load.find { |item| item.key == second_created[:key] } + HQ::AgentMemory.new(second_agent).append_attachment!( + { + "kind" => "link", + "title" => "Other PR", + "url" => "https://github.com/other/repository/pull/99" + }, + created_at: Time.parse("2026-08-09 13:01:00") + ) + requests_before_second_list = client.requests.length + second_list = service.agent_pull_requests(second_created[:key]) + second_catalog = JSON.parse(File.read(second_agent.pull_request_catalog_path)) + assert(second_list.length == 1 && client.requests.length == requests_before_second_list, + "expected another agent's PR list to remain network-free") + assert(second_agent.pull_request_catalog_path != agent.pull_request_catalog_path, + "expected each agent to own a distinct PR catalog path") + assert(second_catalog.fetch("entries").length == 1 && catalog.fetch("entries").length == 3, + "expected one agent's PR discovery to stay isolated from every other catalog") + + restarted = HQ::RemoteService.new(registry:, github_client: FakeUnavailableGitHubClient.new) + cached = restarted.agent_pull_requests(created[:key]) + assert(cached.length == 3 && cached.all? { |item| item["title"] == "Shared PR" }, + "expected PR references and metadata to survive a Remote server restart") + + catalog_paths = [ + agent.pull_request_catalog_path, + "#{agent.pull_request_catalog_path}.bak", + "#{agent.pull_request_catalog_path}.lock" + ] + archive = agent.archive_logs!(File.join(dir, "archive")) + assert(catalog_paths.all? { |path| File.exist?(File.join(archive, File.basename(path))) }, + "expected agent archive to move the PR catalog, backup, and lock sidecars") + assert(catalog_paths.none? { |path| File.exist?(path) }, + "expected agent archive to leave no active PR catalog sidecars behind") + end + end + def assert_concurrent_pull_request_diff_refreshes_are_coalesced with_remote_temp_store do |dir| workspace = File.join(dir, "workspace") write_project_workspace(workspace) client = BlockingGitHubDiffClient.new + registry = registry_for_project(dir, workspace) service = HQ::RemoteService.new( - registry: registry_for_project(dir, workspace), + registry:, github_client: client, pull_request_diff_store: HQ::PullRequestDiff::Store.new(File.join(dir, "diffs.json")) ) - reference = HQ::PullRequestDiff.reference_from_url("https://github.com/example/web/pull/123") - first = Thread.new { service.send(:refresh_pull_request_snapshot, reference) } + agents = 2.times.map do |index| + created = service.create_agent( + "project_key" => "web", + "template_key" => "custom", + "name" => "Shared PR agent #{index + 1}", + "prompt" => "Review the shared PR.", + "agent" => "codex" + ) + agent = HQ::AgentStore.new(registry.projects).load.find { |item| item.key == created[:key] } + HQ::AgentMemory.new(agent).append_attachment!( + { + "kind" => "link", + "title" => "Shared PR", + "url" => "https://github.com/example/web/pull/123" + }, + created_at: Time.parse("2026-08-09 13:02:0#{index}") + ) + agent + end + references = agents.map { |agent| HQ::PullRequestDiff.references_for_agent(agent).fetch(0) } + first = Thread.new { service.send(:refresh_pull_request_snapshot, references.fetch(0)) } client.metadata_started.pop - second = Thread.new { service.send(:refresh_pull_request_snapshot, reference) } + second = Thread.new { service.send(:refresh_pull_request_snapshot, references.fetch(1)) } + + deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 2 + Thread.pass until second.status == "sleep" || Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline + follower_waiting = second.status == "sleep" client.release! [first, second].each(&:value) + assert(follower_waiting, "expected the second agent refresh to join the in-flight PR fetch") assert(client.metadata_requests == 1 && client.diff_requests == 1, "expected concurrent identical diff refreshes to share one metadata and diff fetch") + catalogs = agents.map { |agent| JSON.parse(File.read(agent.pull_request_catalog_path)) } + assert(catalogs.all? { |catalog| catalog.dig("entries", references.first.id, "metadata", "title") == "Shared PR" }, + "expected every coalesced caller to persist metadata in its own agent catalog") end end @@ -1125,6 +1269,90 @@ def assert_pull_request_line_handoff_uses_snapshot_context end end + def assert_remote_prompt_accepts_pull_request_context + with_remote_temp_store do |dir| + workspace = File.join(dir, "workspace") + write_project_workspace(workspace) + registry = registry_for_project(dir, workspace) + diff_store = HQ::PullRequestDiff::Store.new(File.join(dir, "composer-diffs.json")) + service = HQ::RemoteService.new(registry:, github_client: FakeGitHubReviewClient.new, + pull_request_diff_store: diff_store) + created = service.create_agent("project_key" => "web", "template_key" => "custom", "name" => "Composer", + "prompt" => "Work.", "agent" => "codex") + agent = HQ::AgentStore.new(registry.projects).load.find { |item| item.key == created[:key] } + HQ::AgentMemory.new(agent).append_attachment!( + { "kind" => "link", "title" => "PR", "url" => "https://github.com/example/web/pull/123" } + ) + HQ::AgentStore.new(registry.projects).save([agent]) + reference = HQ::PullRequestDiff.reference_from_url("https://github.com/example/web/pull/123") + diff_store.save( + "id" => reference.id, "snapshot_id" => "composer-snapshot", "provider" => "github", + "repository" => "example/web", "number" => 123, "base_sha" => "base", "head_sha" => "head", + "diff_format" => HQ::PullRequestDiff::DIFF_FORMAT, + "files" => [{ "path" => "lib/example.rb", "hunks" => [{ "lines" => [ + { "kind" => "removed", "old_number" => 3, "content" => "old" }, + { "kind" => "added", "new_number" => 4, "content" => "new" } + ] }] }] + ) + + result = service.submit_prompt(created[:key], + "prompt" => "Explain this range.", + "pull_request_contexts" => [{ + "pull_request_id" => reference.id, + "snapshot_id" => "composer-snapshot", + "comment" => "Explain why these two sides differ.", + "lines" => [ + { "path" => "lib/example.rb", "hunk_index" => 0, "line_index" => 0 }, + { "path" => "lib/example.rb", "hunk_index" => 0, "line_index" => 1 } + ] + }]) + content = result[:conversation].last[:content] + assert(content.start_with?("Explain this range.") && content.include?(HQ::PullRequestSelection::OPEN), + "expected normal composer messages to include validated PR context") + assert(content.include?('"path":"lib/example.rb"') && content.include?('"side":"left"') && + content.include?('"side":"right"') && content.include?('"old_number":3') && + content.include?('"new_number":4') && + content.include?("Comment on this range:\nExplain why these two sides differ."), + "expected PR context to carry repository file, side, and line metadata") + + begin + service.submit_prompt(created[:key], + "prompt" => "Oversized comment.", + "pull_request_contexts" => [{ + "pull_request_id" => reference.id, "snapshot_id" => "composer-snapshot", + "comment" => "x" * ((8 * 1024) + 1), + "lines" => [{ "path" => "lib/example.rb", "hunk_index" => 0, "line_index" => 0 }] + }]) + raise "expected oversized PR comment to fail" + rescue HQ::RemoteServer::Error => e + assert(e.status == 400 && e.message.include?("at most 8 KB"), + "expected oversized PR comments to return a bounded input error") + end + + begin + asset_pattern = File.join(HQ::AGENT_LOGS_DIR, "assets", created[:key], "**", "*") + asset_files_before = Dir.glob(asset_pattern).select { |path| File.file?(path) } + service.submit_prompt(created[:key], + "prompt" => "Stale.", + "attachments" => [{ + "filename" => "stale.txt", "mime_type" => "text/plain", + "content_base64" => Base64.strict_encode64("must not be imported") + }], + "pull_request_contexts" => [{ + "pull_request_id" => reference.id, "snapshot_id" => "old", + "lines" => [{ "path" => "lib/example.rb", "hunk_index" => 0, "line_index" => 0 }] + }]) + raise "expected stale composer PR context to fail" + rescue HQ::RemoteServer::Error => e + assert(e.status == 409 && e.message.include?("changed"), + "expected stale composer PR context to return an actionable conflict") + asset_files_after = Dir.glob(asset_pattern).select { |path| File.file?(path) } + assert(asset_files_after == asset_files_before, + "expected stale PR context validation to happen before uploaded files are written") + end + end + end + def assert_github_app_auth_routes with_remote_temp_store do |dir| workspace = File.join(dir, "workspace") @@ -4874,7 +5102,7 @@ def assert_remote_ui_routes_load_without_auth js[:body].include?("function liveEditorRefreshPlan") && js[:body].include?("function reconcileViewAroundEditor"), "expected polling renders to reconcile around stable Conversation and inquiry forms") - assert(js[:body].include?("replaceSiblingsAroundEditor") && + assert(js[:body].include?("replaceSiblingsAroundAnchor") && js[:body].include?('data-agent-running="${agentIsRunning(agent) ? "true" : "false"}"'), "expected live editor reconciliation to preserve controls without hiding real agent state transitions") assert(js[:body].include?("scrollContainers"), @@ -4893,12 +5121,18 @@ def assert_remote_ui_routes_load_without_auth js[:body].include?("delete state.agentDetails[agent.key]"), "expected catalog revision changes to invalidate stale agent attachment details") assert(js[:body].include?("function preserveWorkspaceDuringPoll") && - js[:body].include?("if (options.force || options.forceAttachment) return false") && + js[:body].include?("if (options.forceAttachment) return false") && + js[:body].include?("if (options.force && !options.preserveFocusedWorkspace) return false") && + js[:body].include?("function focusedWorkspacePreservedDuringPoll") && + js[:body].include?("function focusedWorkspacePollingSubtitle") && + js[:body].include?("Conversation poll paused") && + js[:body].include?("function syncFocusedWorkspaceCatalog") && js[:body].include?('els.view.querySelector("#inquiry-form")') && js[:body].include?("if (preservedInquiryAgent) state.agentDetails[inquiryAgentKey] = preservedInquiryAgent") && - js[:body].include?("if (!preserveWorkspace) await ensureRouteData(options)") && - js[:body].scan("if (!preserveWorkspace)").length == 3, - "expected automatic attachment and inquiry polling to update state without rendering the workspace") + js[:body].include?("const preserveCurrentWorkspace = routeChangedDuringRefresh || preserveWorkspaceDuringPoll(options)") && + js[:body].include?("if (!preserveCurrentWorkspace) await ensureRouteData(options)") && + js[:body].include?("syncFocusedWorkspaceCatalog(currentRoute)"), + "expected focused workspace polling to reconcile catalog state without fetching or rendering route data") assert(js[:body].include?("state.preservePollContentDuringRender") && js[:body].include?("replaceViewContent(html)"), "expected explicit attachment refreshes to replace preserved attachment content") @@ -5362,7 +5596,8 @@ def assert_remote_ui_routes_load_without_auth "expected Remote UI to clear the prompt before optimistic conversation rendering replaces the composer") assert(js[:body].include?("pendingConversationMessages"), "expected Remote UI to render optimistic pending chat messages") - assert(js[:body].include?("await apiPost(`/agents/${encodeURIComponent(key)}/messages`, { prompt, start: true, attachments });\n removePendingConversationMessage(key, pendingMessageId, { render: false });"), + assert(js[:body].include?("pull_request_contexts: pullRequestContexts") && + js[:body].include?("removePendingConversationMessage(key, pendingMessageId, { render: false });"), "expected Remote UI to remove optimistic chat before refreshing server-backed conversation") assert(js[:body].include?("loadingConversations"), "expected Remote UI to track conversation loading per agent") @@ -6176,6 +6411,20 @@ def release! end end + class CountingPullRequestDiffStore < HQ::PullRequestDiff::Store + attr_reader :all_calls + + def initialize(path) + super + @all_calls = 0 + end + + def all + @all_calls += 1 + super + end + end + class FakeUnavailableGitHubClient def enabled? true