diff --git a/apps/desktop/src/renderer/components/chat/ChatGitToolbar.test.tsx b/apps/desktop/src/renderer/components/chat/ChatGitToolbar.test.tsx index 622d3802e..3fd0814ae 100644 --- a/apps/desktop/src/renderer/components/chat/ChatGitToolbar.test.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatGitToolbar.test.tsx @@ -1,13 +1,23 @@ /* @vitest-environment jsdom */ -import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import React from "react"; +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom"; import { useAppStore } from "../../state/appStore"; +import { clearPrReadInFlightForTest } from "../../lib/prReadCache"; import { ChatGitToolbar } from "./ChatGitToolbar"; const originalAde = globalThis.window.ade; +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + function installAdeMocks() { globalThis.window.ade = { git: { @@ -28,6 +38,7 @@ function installAdeMocks() { prs: { getForLane: vi.fn().mockResolvedValue(null), onEvent: vi.fn().mockImplementation(() => () => undefined), + refresh: vi.fn().mockResolvedValue([]), getChecks: vi.fn().mockResolvedValue([]), openInGitHub: vi.fn().mockResolvedValue(undefined), }, @@ -90,13 +101,16 @@ function LocationProbe() { return
{`${location.pathname}${location.search}`}
; } -function renderToolbar() { +function renderToolbar(props: { + onTogglePrPane?: () => void; + prPaneOpen?: boolean; +} = {}) { return render( - + )} @@ -109,6 +123,7 @@ function renderToolbar() { describe("ChatGitToolbar", () => { beforeEach(() => { vi.stubGlobal("PointerEvent", MouseEvent); + clearPrReadInFlightForTest(); installAdeMocks(); resetStore(); }); @@ -193,4 +208,143 @@ describe("ChatGitToolbar", () => { }); expect(window.ade.prs.openInGitHub).not.toHaveBeenCalled(); }); + + it("target-refreshes the linked PR when the chat PR pane opens", async () => { + vi.mocked(window.ade.prs.getForLane).mockResolvedValue({ + id: "pr-1", + laneId: "lane-1", + title: "Stale linked PR", + state: "open", + checksStatus: "pending", + githubPrNumber: 333, + githubUrl: "https://github.com/acme/ade/pull/333", + additions: 2, + deletions: 1, + updatedAt: null, + } as any); + vi.mocked(window.ade.prs.refresh).mockResolvedValue([{ + id: "pr-1", + laneId: "lane-1", + title: "Merged linked PR", + state: "merged", + checksStatus: "pending", + githubPrNumber: 333, + githubUrl: "https://github.com/acme/ade/pull/333", + additions: 2, + deletions: 1, + updatedAt: "2026-06-29T14:00:00.000Z", + } as any]); + + function Harness() { + const [open, setOpen] = React.useState(false); + return ( + setOpen((value) => !value)} + /> + ); + } + + render( + + + , + ); + + const badge = await screen.findByText("PR #333"); + expect(window.ade.prs.refresh).not.toHaveBeenCalled(); + + fireEvent.click(badge.closest("button")!); + + await waitFor(() => { + expect(window.ade.prs.refresh).toHaveBeenCalledWith({ prIds: ["pr-1"] }); + }); + expect(await screen.findByText("MERGED #333")).toBeTruthy(); + }); + + it("ignores stale toolbar live refresh results after switching lanes", async () => { + const laneOnePr = { + id: "pr-lane-1", + laneId: "lane-1", + title: "Lane one stale PR", + state: "open", + checksStatus: "pending", + githubPrNumber: 111, + githubUrl: "https://github.com/acme/ade/pull/111", + additions: 2, + deletions: 1, + updatedAt: null, + }; + const laneOneFreshPr = { + ...laneOnePr, + title: "Lane one refreshed PR", + state: "merged", + updatedAt: "2026-06-29T14:00:00.000Z", + }; + const laneTwoPr = { + id: "pr-lane-2", + laneId: "lane-2", + title: "Lane two PR", + state: "open", + checksStatus: "passing", + githubPrNumber: 222, + githubUrl: "https://github.com/acme/ade/pull/222", + additions: 4, + deletions: 0, + updatedAt: null, + }; + const laneOneLive = deferred(); + + vi.mocked(window.ade.prs.getForLane).mockImplementation(async (requestedLaneId: string) => ( + requestedLaneId === "lane-1" ? laneOnePr : laneTwoPr + ) as any); + vi.mocked(window.ade.prs.refresh).mockImplementation((args?: { prIds?: string[] }) => ( + args?.prIds?.[0] === "pr-lane-1" ? laneOneLive.promise : Promise.resolve([laneTwoPr]) + ) as any); + + function Harness() { + const [laneId, setLaneId] = React.useState("lane-1"); + const [open, setOpen] = React.useState(false); + return ( + <> + + setOpen((value) => !value)} + /> + + ); + } + + render( + + + , + ); + + fireEvent.click((await screen.findByText("PR #111")).closest("button")!); + + await waitFor(() => { + expect(window.ade.prs.refresh).toHaveBeenCalledWith({ prIds: ["pr-lane-1"] }); + }); + + fireEvent.click(screen.getByRole("button", { name: "Switch lane" })); + + expect(await screen.findByText("PR #222")).toBeTruthy(); + + await act(async () => { + laneOneLive.resolve([laneOneFreshPr]); + await laneOneLive.promise; + }); + + expect(screen.getByText("PR #222")).toBeTruthy(); + expect(screen.queryByText("MERGED #111")).toBeNull(); + }); }); diff --git a/apps/desktop/src/renderer/components/chat/ChatGitToolbar.tsx b/apps/desktop/src/renderer/components/chat/ChatGitToolbar.tsx index 99d5b9c17..9961c753e 100644 --- a/apps/desktop/src/renderer/components/chat/ChatGitToolbar.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatGitToolbar.tsx @@ -17,6 +17,7 @@ import type { DiffChanges, PrSummary, PrCheck } from "../../../shared/types"; import { useLaneGitActionRuntimeState } from "../lanes/LaneGitActionsPane"; import { formatPrBadgeLabel } from "../prs/shared/prFormatters"; import { useAppStore } from "../../state/appStore"; +import { refreshLinkedPrCoalesced } from "../../lib/prReadCache"; // --------------------------------------------------------------------------- // Types @@ -41,7 +42,8 @@ function dirtyFileCount(changes: DiffChanges): number { return changes.staged.length + changes.unstaged.length; } -function checksIcon(status: PrSummary["checksStatus"]) { +function checksIcon(status: PrSummary["checksStatus"], state: PrSummary["state"]) { + if (state !== "open" && state !== "draft") return null; switch (status) { case "passing": return ; @@ -114,6 +116,7 @@ export const ChatGitToolbar = React.memo(function ChatGitToolbar({ const navigate = useNavigate(); const runtime = useLaneGitActionRuntimeState(laneId); const isRemoteProject = useAppStore((s) => s.projectBinding?.kind === "remote"); + const projectRoot = useAppStore((s) => s.project?.rootPath ?? s.projectBinding?.rootPath ?? null); const [dirtyCount, setDirtyCount] = useState(0); const [linkedPr, setLinkedPr] = useState(null); @@ -123,6 +126,9 @@ export const ChatGitToolbar = React.memo(function ChatGitToolbar({ const [prChecks, setPrChecks] = useState(null); const [prChecksLoading, setPrChecksLoading] = useState(false); const [copyConfirmed, setCopyConfirmed] = useState(false); + const laneIdRef = React.useRef(laneId); + const refreshPrRequestRef = React.useRef(0); + laneIdRef.current = laneId; // ----------------------------------------------------------------------- // Refresh git status + PR link @@ -137,18 +143,34 @@ export const ChatGitToolbar = React.memo(function ChatGitToolbar({ } }, [laneId]); - const refreshPr = useCallback(async () => { + const refreshPr = useCallback(async (options: { live?: boolean } = {}) => { + const requestId = refreshPrRequestRef.current + 1; + refreshPrRequestRef.current = requestId; + const requestIsCurrent = () => laneIdRef.current === laneId && refreshPrRequestRef.current === requestId; try { const pr = await window.ade.prs.getForLane(laneId); + if (!requestIsCurrent()) return null; setLinkedPr(pr); setPrLoaded(true); + if (options.live && pr) { + try { + const refreshed = await refreshLinkedPrCoalesced(pr, { projectRoot }); + if (!requestIsCurrent()) return null; + setLinkedPr(refreshed); + return refreshed; + } catch { + return pr; + } + } return pr; } catch { - setLinkedPr(null); - setPrLoaded(true); + if (requestIsCurrent()) { + setLinkedPr(null); + setPrLoaded(true); + } return null; } - }, [laneId]); + }, [laneId, projectRoot]); useEffect(() => { setDirtyCount(0); @@ -228,6 +250,12 @@ export const ChatGitToolbar = React.memo(function ChatGitToolbar({ setPrChecks(null); }, [linkedPrId]); + useEffect(() => { + if (!linkedPrId) return; + if (!prPaneOpen && !prMenuOpen) return; + void refreshPr({ live: true }); + }, [linkedPrId, prMenuOpen, prPaneOpen, refreshPr]); + // Fetch live check details when the PR menu opens. Lazy: only fetched while // the menu is open so closed-state idles cost zero. useEffect(() => { @@ -308,7 +336,7 @@ export const ChatGitToolbar = React.memo(function ChatGitToolbar({ > {label} - {checksIcon(linkedPr.checksStatus)} + {checksIcon(linkedPr.checksStatus, linkedPr.state)} = {}): PrSummary { + return { + id: "pr-333", + laneId: "lane-1", + projectId: "project-1", + repoOwner: "arul28", + repoName: "ADE", + githubPrNumber: 333, + githubUrl: "https://github.com/arul28/ADE/pull/333", + githubNodeId: "PR_node333", + title: "Fix stale PR pane", + state: "open", + baseBranch: "main", + headBranch: "feature/pr-pane", + checksStatus: "pending", + reviewStatus: "approved", + additions: 248, + deletions: 50, + lastSyncedAt: null, + createdAt: "2026-06-29T13:00:00.000Z", + updatedAt: "2026-06-29T13:00:00.000Z", + ...overrides, + }; +} + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +function installAdeMocks(stalePr: PrSummary, freshPr: PrSummary) { + let prEventListener: ((event: PrEventPayload) => void) | null = null; + globalThis.window.ade = { + prs: { + getForLane: vi.fn().mockResolvedValue(stalePr), + refresh: vi.fn().mockResolvedValue([freshPr]), + onEvent: vi.fn().mockImplementation((listener) => { + prEventListener = listener; + return () => { + if (prEventListener === listener) prEventListener = null; + }; + }), + }, + app: { + openExternal: vi.fn().mockResolvedValue(undefined), + }, + } as any; + + return { + emitPrEvent: (event: PrEventPayload) => { + prEventListener?.(event); + }, + }; +} + +describe("ChatPrPane", () => { + beforeEach(() => { + clearPrReadInFlightForTest(); + useAppStore.setState({ + project: { rootPath: "/Users/admin/Projects/ADE", displayName: "ADE" } as any, + projectBinding: { + kind: "local", + key: "local:/Users/admin/Projects/ADE", + rootPath: "/Users/admin/Projects/ADE", + displayName: "ADE", + } as any, + }); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + if (originalAde === undefined) { + delete (globalThis.window as any).ade; + } else { + globalThis.window.ade = originalAde; + } + }); + + it("refreshes a linked PR on mount and hides stale running checks once merged", async () => { + const stalePr = buildPr({ state: "open", checksStatus: "pending" }); + const freshPr = buildPr({ + state: "merged", + checksStatus: "pending", + title: "Fix stale PR pane after merge", + updatedAt: "2026-06-29T14:00:00.000Z", + }); + const { emitPrEvent } = installAdeMocks(stalePr, freshPr); + + render( + + + , + ); + + expect(await screen.findByText("MERGED #333")).toBeTruthy(); + expect(screen.getByText("Fix stale PR pane after merge")).toBeTruthy(); + expect(screen.queryByText("Checks running")).toBeNull(); + + await waitFor(() => { + expect(window.ade.prs.refresh).toHaveBeenCalledWith({ prIds: ["pr-333"] }); + }); + expect(window.ade.prs.onEvent).toHaveBeenCalledTimes(1); + + act(() => { + emitPrEvent({ + type: "prs-updated", + polledAt: "2026-06-29T14:01:00.000Z", + prs: [buildPr({ id: "other-pr", laneId: "other-lane", githubPrNumber: 444 })], + }); + }); + + await waitFor(() => { + expect(screen.queryByText("MERGED #333")).toBeNull(); + }); + expect(window.ade.prs.onEvent).toHaveBeenCalledTimes(1); + }); + + it("ignores stale live refresh results after switching lanes", async () => { + const laneOnePr = buildPr({ + id: "pr-lane-1", + laneId: "lane-1", + githubPrNumber: 111, + title: "Lane one stale PR", + }); + const laneOneFreshPr = buildPr({ + ...laneOnePr, + title: "Lane one refreshed PR", + updatedAt: "2026-06-29T14:00:00.000Z", + }); + const laneTwoPr = buildPr({ + id: "pr-lane-2", + laneId: "lane-2", + githubPrNumber: 222, + title: "Lane two PR", + }); + const laneOneLive = deferred(); + const laneTwoLive = deferred(); + + globalThis.window.ade = { + prs: { + getForLane: vi.fn(async (requestedLaneId: string) => (requestedLaneId === "lane-1" ? laneOnePr : laneTwoPr)), + refresh: vi.fn((args?: { prIds?: string[] }) => ( + args?.prIds?.[0] === "pr-lane-1" ? laneOneLive.promise : laneTwoLive.promise + )), + onEvent: vi.fn().mockImplementation(() => () => undefined), + }, + app: { + openExternal: vi.fn().mockResolvedValue(undefined), + }, + } as any; + + const { rerender } = render( + + + , + ); + + expect(await screen.findByText("Lane one stale PR")).toBeTruthy(); + + rerender( + + + , + ); + + expect(await screen.findByText("Lane two PR")).toBeTruthy(); + + await act(async () => { + laneOneLive.resolve([laneOneFreshPr]); + await laneOneLive.promise; + }); + + expect(screen.getByText("Lane two PR")).toBeTruthy(); + expect(screen.queryByText("Lane one refreshed PR")).toBeNull(); + + await act(async () => { + laneTwoLive.resolve([laneTwoPr]); + await laneTwoLive.promise; + }); + }); +}); diff --git a/apps/desktop/src/renderer/components/chat/ChatPrPane.tsx b/apps/desktop/src/renderer/components/chat/ChatPrPane.tsx index c0b76d8f2..282d3a4e9 100644 --- a/apps/desktop/src/renderer/components/chat/ChatPrPane.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatPrPane.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useState } from "react"; +import React, { useCallback, useEffect, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; import { ArrowSquareOut, @@ -14,6 +14,8 @@ import { cn } from "../ui/cn"; import type { PrSummary } from "../../../shared/types"; import { formatPrBadgeLabel } from "../prs/shared/prFormatters"; import { ChatPrInlineCreator } from "./ChatPrInlineCreator"; +import { refreshLinkedPrCoalesced } from "../../lib/prReadCache"; +import { useAppStore } from "../../state/appStore"; /** * Left floating info-pane for an ADE chat's pull request. Mirrors the right @@ -36,8 +38,9 @@ function stateTone(state: PrSummary["state"]): { dot: string; label: string } { } } -function ChecksLabel({ status }: { status: PrSummary["checksStatus"] }) { - switch (status) { +function ChecksLabel({ pr }: { pr: PrSummary }) { + if (pr.state !== "open" && pr.state !== "draft") return null; + switch (pr.checksStatus) { case "passing": return Checks passing; case "failing": @@ -72,7 +75,7 @@ function PrDetails({

{pr.title}

- + {pr.additions > 0 || pr.deletions > 0 ? ( +{pr.additions} @@ -112,36 +115,70 @@ export const ChatPrPane = React.memo(function ChatPrPane({ onClose?: () => void; }) { const navigate = useNavigate(); + const projectRoot = useAppStore((s) => s.project?.rootPath ?? s.projectBinding?.rootPath ?? null); const [pr, setPr] = useState(null); const [loading, setLoading] = useState(true); const [copied, setCopied] = useState(false); + const currentPrIdRef = useRef(null); + const laneIdRef = useRef(laneId); + const refreshRequestRef = useRef(0); + laneIdRef.current = laneId; + + const setCurrentPr = useCallback((nextPr: PrSummary | null) => { + currentPrIdRef.current = nextPr?.id ?? null; + setPr(nextPr); + }, []); - const refresh = useCallback(async () => { + const refresh = useCallback(async (options: { live?: boolean } = {}) => { + const requestId = refreshRequestRef.current + 1; + refreshRequestRef.current = requestId; + const requestIsCurrent = () => laneIdRef.current === laneId && refreshRequestRef.current === requestId; + let cached: PrSummary | null = null; try { - setPr(await window.ade.prs.getForLane(laneId)); + cached = await window.ade.prs.getForLane(laneId); + if (!requestIsCurrent()) return; + setCurrentPr(cached); + setLoading(false); + if (options.live && cached) { + const refreshed = await refreshLinkedPrCoalesced(cached, { projectRoot }); + if (!requestIsCurrent()) return; + setCurrentPr(refreshed); + } } catch { - setPr(null); + if (!cached && requestIsCurrent()) setCurrentPr(null); } finally { - setLoading(false); + if (requestIsCurrent()) setLoading(false); } - }, [laneId]); + }, [laneId, projectRoot, setCurrentPr]); // The inline creator hands us the freshly-created PR the moment createFromLane // resolves — swap to the details view instantly rather than waiting for the // next GitHub polling round-trip (`prs-updated`) to refresh the row. (The // creator only renders once `loading` is false, so no setLoading needed here.) const handleCreated = useCallback((created: PrSummary) => { - setPr(created); - }, []); + setCurrentPr(created); + }, [setCurrentPr]); - useEffect(() => { void refresh(); }, [refresh]); + useEffect(() => { void refresh({ live: true }); }, [refresh]); useEffect(() => { const unsubscribe = window.ade.prs.onEvent((event) => { - if (event.type === "prs-updated") void refresh(); + const currentPrId = currentPrIdRef.current; + if (event.type === "pr-notification") { + if (event.laneId === laneId || event.prId === currentPrId) void refresh(); + return; + } + if (event.type !== "prs-updated") return; + const eventIncludesLanePr = event.prs.some((next) => next.laneId === laneId); + const eventIncludesCurrentPr = currentPrId ? event.prs.some((next) => next.id === currentPrId) : false; + if (eventIncludesLanePr || eventIncludesCurrentPr || !currentPrId) { + void refresh(); + } else { + setCurrentPr(null); + } }); return unsubscribe; - }, [refresh]); + }, [laneId, refresh, setCurrentPr]); useEffect(() => { if (!copied) return; diff --git a/apps/desktop/src/renderer/lib/prReadCache.test.ts b/apps/desktop/src/renderer/lib/prReadCache.test.ts index 17d8f22f5..4dfd24255 100644 --- a/apps/desktop/src/renderer/lib/prReadCache.test.ts +++ b/apps/desktop/src/renderer/lib/prReadCache.test.ts @@ -3,6 +3,7 @@ import { clearPrReadInFlightForTest, getGitHubSnapshotCoalesced, listPrsCoalesced, + refreshLinkedPrCoalesced, refreshPrsCoalesced, } from "./prReadCache"; @@ -87,4 +88,65 @@ describe("prReadCache", () => { await expect(first).resolves.toEqual([{ id: "pr-1" }, { id: "pr-2" }]); await expect(second).resolves.toEqual([{ id: "pr-1" }, { id: "pr-2" }]); }); + + it("throttles repeated linked PR refreshes after a fresh result", async () => { + const stalePr = { id: "pr-1", laneId: "lane-1", state: "open" }; + const freshPr = { ...stalePr, state: "merged" }; + refresh.mockResolvedValueOnce([freshPr]); + + await expect( + refreshLinkedPrCoalesced(stalePr as any, { projectRoot: "/repo" }), + ).resolves.toEqual(freshPr); + await expect( + refreshLinkedPrCoalesced(stalePr as any, { projectRoot: "/repo" }), + ).resolves.toEqual(freshPr); + + expect(refresh).toHaveBeenCalledTimes(1); + + refresh.mockResolvedValueOnce([{ ...freshPr, title: "forced" }]); + await expect( + refreshLinkedPrCoalesced(stalePr as any, { projectRoot: "/repo", force: true }), + ).resolves.toMatchObject({ title: "forced" }); + expect(refresh).toHaveBeenCalledTimes(2); + }); + + it("preserves missing linked PR refreshes as null during the cooldown", async () => { + const stalePr = { id: "pr-1", laneId: "lane-1", state: "open" }; + refresh.mockResolvedValueOnce([]); + + await expect( + refreshLinkedPrCoalesced(stalePr as any, { projectRoot: "/repo" }), + ).resolves.toBeNull(); + await expect( + refreshLinkedPrCoalesced(stalePr as any, { projectRoot: "/repo" }), + ).resolves.toBeNull(); + + expect(refresh).toHaveBeenCalledTimes(1); + }); + + it("does not replace a fresher linked PR row during the cooldown", async () => { + const stalePr = { + id: "pr-1", + laneId: "lane-1", + state: "open", + updatedAt: "2026-06-29T14:00:00.000Z", + lastSyncedAt: "2026-06-29T14:00:00.000Z", + }; + const fresherPr = { + ...stalePr, + state: "merged", + updatedAt: "2026-06-29T14:01:00.000Z", + lastSyncedAt: "2026-06-29T14:01:00.000Z", + }; + refresh.mockResolvedValueOnce([stalePr]); + + await expect( + refreshLinkedPrCoalesced(stalePr as any, { projectRoot: "/repo" }), + ).resolves.toEqual(stalePr); + await expect( + refreshLinkedPrCoalesced(fresherPr as any, { projectRoot: "/repo" }), + ).resolves.toEqual(fresherPr); + + expect(refresh).toHaveBeenCalledTimes(1); + }); }); diff --git a/apps/desktop/src/renderer/lib/prReadCache.ts b/apps/desktop/src/renderer/lib/prReadCache.ts index 5c065ebcf..8f15e2c48 100644 --- a/apps/desktop/src/renderer/lib/prReadCache.ts +++ b/apps/desktop/src/renderer/lib/prReadCache.ts @@ -8,11 +8,26 @@ const prListInFlight = new Map>(); const githubSnapshotInFlight = new Map>(); const prRefreshInFlight = new Map>(); const prSurfaceWarmInFlight = new Map>(); +const linkedPrRefreshInFlight = new Map>(); +const linkedPrRecentRefresh = new Map(); + +export const LINKED_PR_LIVE_REFRESH_COOLDOWN_MS = 5_000; function projectKey(projectRoot: string | null | undefined): string { return projectRoot?.trim() || "active"; } +function summaryFreshness(summary: PrSummary): number { + const updatedAt = Date.parse(summary.updatedAt || ""); + const lastSyncedAt = Date.parse(summary.lastSyncedAt || ""); + return Math.max(Number.isFinite(updatedAt) ? updatedAt : 0, Number.isFinite(lastSyncedAt) ? lastSyncedAt : 0); +} + +function freshestLinkedPr(current: PrSummary, recent: PrSummary | null): PrSummary | null { + if (!recent) return null; + return summaryFreshness(current) > summaryFreshness(recent) ? current : recent; +} + function coalesceInFlight( cache: Map>, key: string, @@ -70,6 +85,44 @@ export function refreshPrsCoalesced( ); } +export function refreshLinkedPrCoalesced( + pr: PrSummary, + options?: { + projectRoot?: string | null; + force?: boolean; + cooldownMs?: number; + }, +): Promise { + const prId = String(pr.id ?? "").trim(); + if (!prId) return Promise.resolve(null); + + const key = JSON.stringify({ + projectRoot: projectKey(options?.projectRoot), + prId, + }); + const cooldownMs = Math.max(0, options?.cooldownMs ?? LINKED_PR_LIVE_REFRESH_COOLDOWN_MS); + const recent = linkedPrRecentRefresh.get(key); + if (!options?.force && recent && Date.now() - recent.refreshedAt < cooldownMs) { + return Promise.resolve(freshestLinkedPr(pr, recent.result)); + } + + return coalesceInFlight( + linkedPrRefreshInFlight, + key, + async () => { + try { + const refreshed = await refreshPrsCoalesced({ prIds: [prId] }, { projectRoot: options?.projectRoot }); + const result = refreshed.find((next) => next.id === prId) ?? null; + linkedPrRecentRefresh.set(key, { refreshedAt: Date.now(), result }); + return result; + } catch (error) { + linkedPrRecentRefresh.set(key, { refreshedAt: Date.now(), result: pr }); + throw error; + } + }, + ); +} + export function warmPrSurfaceCoalesced(options?: { projectRoot?: string | null; includeGithubSnapshot?: boolean; @@ -105,4 +158,6 @@ export function clearPrReadInFlightForTest(): void { githubSnapshotInFlight.clear(); prRefreshInFlight.clear(); prSurfaceWarmInFlight.clear(); + linkedPrRefreshInFlight.clear(); + linkedPrRecentRefresh.clear(); } diff --git a/docs/features/chat/README.md b/docs/features/chat/README.md index 557295dbd..da6f9be96 100644 --- a/docs/features/chat/README.md +++ b/docs/features/chat/README.md @@ -52,7 +52,8 @@ machinery layered on top. | `apps/desktop/src/renderer/lib/handoffLaunchJobs.ts` | Shared renderer helper for in-flight chat handoff placeholders. Defines the handoff job DTO, scope keying, status labels (`preparing-summary` -> `creating-chat` -> `sending-handoff`), search matching, and the stable placeholder id used by the Work session sidebar. | | `apps/desktop/src/renderer/state/appStore.ts` | Shared renderer state store. Besides project/lane/work selection, it persists user preferences such as `launchPromptClipboardEnabled` and `launchPromptClipboardNoticeEnabled`, mirrors them into per-project stores, and owns `draftLaunchJobsByScope` (+ `setDraftLaunchJobs`) for Work draft launch status strips plus `handoffLaunchJobsByScope` (+ `setHandoffLaunchJobs`) for Work sidebar handoff placeholders. These live in the **root** store (not the per-project store) on purpose: in-flight launches must survive a remote project switch that destroys the originating per-project store; `AgentChatPane` reads them via `useRootAppStore` / `rootAppStoreApi.getState()`. | | `apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx` | Virtualized transcript renderer. Coalesces resize / measurement updates and, while sticky-to-bottom is active, follows height changes across multiple animation frames so streamed output and late row measurements do not leave the user above the newest message. Programmatic scroll writes are tracked by target scroll position, not a stale counter, so browser-coalesced scroll events do not swallow the next real user gesture. Codex goal lifecycle events render as compact user-facing rows (`Goal set`, `Goal paused`, `Goal cleared`) instead of raw JSON-RPC/status wording. Handoff brief user messages with `metadata.hideFullPrompt` show only their `displayText` breadcrumb and do not expose or copy the internal prompt body. | -| `apps/desktop/src/renderer/components/chat/ChatGitToolbar.tsx` | Git / PR quick-action toolbar above the composer. If the lane already has a linked PR, the PR button opens that PR; otherwise it routes to the PR workspace with a create-PR handoff (`create=1&sourceLaneId=&target=primary`). | +| `apps/desktop/src/renderer/components/chat/ChatGitToolbar.tsx` | Git / PR quick-action toolbar above the composer. If the lane already has a linked PR, the PR button opens or toggles that PR; otherwise it routes to the PR workspace with a create-PR handoff (`create=1&sourceLaneId=&target=primary`). When the chat PR pane or compact PR menu opens, it asks `prReadCache.refreshLinkedPrCoalesced` for a targeted `prs.refresh({ prIds })` so the badge picks up merged/closed/check transitions without broad GitHub polling. | +| `apps/desktop/src/renderer/components/chat/ChatPrPane.tsx` | Left floating PR pane for Work chat. Renders cached lane PR details immediately, then performs the same cooldown-bound targeted PR refresh as the toolbar before settling the state. Terminal PRs hide stale running-check labels so merged/closed PRs do not keep showing in-progress CI from an old cache row. | | `apps/desktop/src/renderer/lib/visualContextFormatting.ts` | Serializes iOS, App Control, built-in browser, and attachment context into prompt text. | | `apps/desktop/src/renderer/components/chat/RewindFilesConfirmDialog.tsx`, `rewindFilesPreview.ts` | Claude file-rewind confirmation surface. `rewindFilesPreview.ts` maps the selected user message to turn diff summaries and per-file SHA ranges; the dialog lists every restored file, expands rows into `AdeDiffViewer`, and confirms the SDK `rewindFiles` call without using browser-native confirm UI. | | `apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.tsx`, `codex/CodexGoalCard.tsx` | Subagent drawer content plus the Codex chat goal card. The goal card sits above the plan/subagent roster, exposes edit/clear affordances through typed ADE goal APIs, and shows usage context as tokens/time only; provider token budgets are hidden and cleared so ADE goals stay unlimited. | diff --git a/docs/features/chat/composer-and-ui.md b/docs/features/chat/composer-and-ui.md index cc70d7551..23e7db419 100644 --- a/docs/features/chat/composer-and-ui.md +++ b/docs/features/chat/composer-and-ui.md @@ -32,7 +32,8 @@ stream plus session metadata. | `ChatIosSimulatorPanel.tsx` | macOS-only iOS Simulator drawer. Two mount points: under the chat composer and inside the Work right-edge sidebar. Tool-readiness checklist, device + target pickers, three-backend live preview, `interact` vs `inspect` mode, hit-test overlay, and selection emission as `IosElementContextItem`. Accepts an optional `laneId` prop, forwarded into `iosSimulator.launch` so the resulting `IosSimulatorSession` records its launching lane. Simulator controls are not blocked when another chat session owns the simulator — ownership only affects which session receives context insertions, not whether the user can interact with the device. See [iOS Simulator feature](../ios-simulator/README.md). | | `ChatBuiltInBrowserPanel.tsx` | In-app browser panel mounted under the Work right-edge sidebar's `browser` tab. Renders the address bar, navigation/tab strip, inspect toolbar, screenshot capture, and an empty/error state derived from `BuiltInBrowserStatus`; the actual page content is painted by a main-process `WebContentsView` whose bounds the panel reports back to the broker via `ade.builtInBrowser.setBounds`. Inspect-mode hit-tests emit `BuiltInBrowserContextItem` payloads through `onAddContext`; the sidebar then dispatches `ade:agent-chat:add-builtin-browser-context` to the active chat. The panel does not run inside `AgentChatPane` directly — instead, anywhere in the renderer that wants to open a URL calls `openUrlInAdeBrowser()` (in `apps/desktop/src/renderer/lib/openExternal.ts`), which fires `ADE_OPEN_BUILT_IN_BROWSER_EVENT` and asks the broker to open a new tab. | | `ChatTerminalDrawer.tsx` | Collapsible terminal drawer at the bottom of the chat. | -| `ChatGitToolbar.tsx` | Git status and quick-action toolbar above the composer. The PR action opens a linked PR when one exists, otherwise opens the PR creation handoff for the current lane targeting the primary branch. | +| `ChatGitToolbar.tsx` | Git status and quick-action toolbar above the composer. The PR action opens or toggles a linked PR when one exists, otherwise opens the PR creation handoff for the current lane targeting the primary branch. Opening the chat PR pane or compact PR menu performs a targeted, cooldown-bound refresh for that single linked PR. | +| `ChatPrPane.tsx` | Left floating PR pane for Work chat. Shows cached lane PR details immediately, then refreshes the linked PR row with the same targeted refresh path so pane toggles surface current merged/closed/check state without a broad PR sync. | | `ChatProposedPlanCard.tsx` | Composer-level plan approval card shown while input is locked. Renders the plan description or question text as rich markdown (`ChatMarkdown`) inside a scrollable container (capped at `min(34vh, 360px)`). Transcript plan events render through `AgentChatMessageList` / `CodexPlanCard`. | | `ChatModelSelectionPendingCard.tsx` | Full agent-briefing model picker for orchestration pending inputs. Shows description, touched files, run-after dependencies, provider/model controls, and submitting/cancel states without a recommended default model. | | `codex/CodexPlanCard.tsx` | Codex plan card rendered inline in the transcript for `plan` events. Shows plan state (Planning / Plan ready), step progress with status glyphs, and streaming plan text as rich markdown via `ChatMarkdown`. Completed plans with no discrete steps render the full markdown body inline; plans with steps offer a toggle to expand the raw markdown details (labelled "details" when complete, "live" while streaming). Handles missing `steps` arrays gracefully. | diff --git a/docs/features/pull-requests/README.md b/docs/features/pull-requests/README.md index 4297ce1d0..bf3cb7d25 100644 --- a/docs/features/pull-requests/README.md +++ b/docs/features/pull-requests/README.md @@ -598,6 +598,12 @@ best-effort — failures log a warning and do not abort the tick. `detailDeployments`, `detailAiSummary`). It exposes `setTimelineFilters`, `setAiSummaryDismissed`, and `regeneratePrAiSummary`. +- Chat-side PR surfaces (`ChatGitToolbar`, `ChatPrPane`) render the cached + lane PR row first, then use `renderer/lib/prReadCache.ts` to coalesce and + throttle a targeted `prs.refresh({ prIds })` for the linked PR when the + pane or compact menu opens. This keeps chat PR badges near-live without + forcing a repo snapshot refresh or broad background sync on every Work + chat mount. - `PrDetailPane` is where most rich behavior concentrates: issue resolver modal, rebase banner, check/review/comment sections with running indicators (`PrCiRunningIndicator`), merge readiness