diff --git a/apps/desktop/src/lib/trpc/routers/browser/browser.ts b/apps/desktop/src/lib/trpc/routers/browser/browser.ts index dd74439f930..8827d016742 100644 --- a/apps/desktop/src/lib/trpc/routers/browser/browser.ts +++ b/apps/desktop/src/lib/trpc/routers/browser/browser.ts @@ -134,6 +134,19 @@ export const createBrowserRouter = () => { }); }), + /** Global subscription for HTML5 fullscreen enter/leave from any browser pane. */ + onFullscreenChange: publicProcedure.subscription(() => { + return observable<{ paneId: string; isFullscreen: boolean }>((emit) => { + const handler = (data: { paneId: string; isFullscreen: boolean }) => { + emit.next(data); + }; + browserManager.on("fullscreen-change", handler); + return () => { + browserManager.off("fullscreen-change", handler); + }; + }); + }), + onContextMenuAction: publicProcedure .input(z.object({ paneId: z.string() })) .subscription(({ input }) => { diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/git-status.ts b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/git-status.ts index 2433a458e59..0e999b34799 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/git-status.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/git-status.ts @@ -28,6 +28,7 @@ import { fetchCheckJobSteps, fetchGitHubPRComments, fetchGitHubPRStatus, + fetchJobStatuses, fetchStructuredJobLogs, getRepoContext, type PullRequestCommentsTarget, @@ -1579,6 +1580,43 @@ export const createGitStatusProcedures = () => { detailsUrl: z.string(), }), ) + .query(async ({ input }) => { + const workspace = getWorkspace(input.workspaceId); + if (!workspace) { + return { + jobStatus: "queued" as const, + jobConclusion: null, + steps: [], + }; + } + + const worktree = workspace.worktreeId + ? getWorktree(workspace.worktreeId) + : null; + + let repoPath: string | null = worktree?.path ?? null; + if (!repoPath && workspace.type === "branch") { + const project = getProject(workspace.projectId); + repoPath = project?.mainRepoPath ?? null; + } + if (!repoPath) { + return { + jobStatus: "queued" as const, + jobConclusion: null, + steps: [], + }; + } + + return fetchStructuredJobLogs(repoPath, input.detailsUrl); + }), + + getJobStatuses: publicProcedure + .input( + z.object({ + workspaceId: z.string(), + detailsUrls: z.array(z.string()), + }), + ) .query(async ({ input }) => { const workspace = getWorkspace(input.workspaceId); if (!workspace) { @@ -1598,7 +1636,7 @@ export const createGitStatusProcedures = () => { return []; } - return fetchStructuredJobLogs(repoPath, input.detailsUrl); + return fetchJobStatuses(repoPath, input.detailsUrls); }), }); }; diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/github.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/github.ts index dd030478e62..d41c4725efe 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/github.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/github.ts @@ -501,39 +501,62 @@ export interface StructuredJobStep { logs: string; } +export interface StructuredJobResult { + jobStatus: "queued" | "in_progress" | "completed" | "waiting"; + jobConclusion: string | null; + steps: StructuredJobStep[]; +} + /** * Fetches job step metadata and logs, returning structured per-step data. */ export async function fetchStructuredJobLogs( worktreePath: string, detailsUrl: string, -): Promise { +): Promise { const jobId = parseJobIdFromUrl(detailsUrl); const nwo = parseNwoFromActionsUrl(detailsUrl); + const emptyResult: StructuredJobResult = { + jobStatus: "queued", + jobConclusion: null, + steps: [], + }; if (!jobId || !nwo) { - return []; + return emptyResult; } try { - const [jobResult, logsResult] = await Promise.all([ - execWithShellEnv("gh", ["api", `repos/${nwo}/actions/jobs/${jobId}`], { - cwd: worktreePath, - }), - execWithShellEnv( - "gh", - ["api", `repos/${nwo}/actions/jobs/${jobId}/logs`], - { cwd: worktreePath, maxBuffer: 10 * 1024 * 1024 }, - ), - ]); + // Always fetch job metadata; logs may 404 for in-progress jobs + const jobResult = await execWithShellEnv( + "gh", + ["api", `repos/${nwo}/actions/jobs/${jobId}`], + { cwd: worktreePath }, + ); const raw: unknown = JSON.parse(jobResult.stdout.trim()); const result = GHJobResponseSchema.safeParse(raw); if (!result.success || !result.data.steps) { - return []; + return emptyResult; } - const steps = result.data.steps; - const rawLogs = logsResult.stdout; + const jobData = result.data; + const steps = jobData.steps ?? []; + const jobCompleted = jobData.status === "completed"; + + // Only fetch logs if job is completed (API returns 404 for in-progress) + let rawLogs = ""; + if (jobCompleted) { + try { + const logsResult = await execWithShellEnv( + "gh", + ["api", `repos/${nwo}/actions/jobs/${jobId}/logs`], + { cwd: worktreePath, maxBuffer: 10 * 1024 * 1024 }, + ); + rawLogs = logsResult.stdout; + } catch { + // Logs not yet available + } + } // Parse raw logs into per-step sections. // GitHub log format: each line starts with a timestamp like "2024-01-01T00:00:00.0000000Z " @@ -571,26 +594,79 @@ export async function fetchStructuredJobLogs( } } - return steps.map((step) => { - let durationSeconds: number | null = null; - if (step.started_at && step.completed_at) { - durationSeconds = Math.round( - (new Date(step.completed_at).getTime() - - new Date(step.started_at).getTime()) / - 1000, - ); - } - return { - name: step.name, - number: step.number, - status: step.status, - conclusion: step.conclusion ?? null, - durationSeconds, - logs: stepLogs.get(step.number)?.join("\n") ?? "", - }; - }); + return { + jobStatus: jobData.status, + jobConclusion: jobData.conclusion ?? null, + steps: steps.map((step) => { + let durationSeconds: number | null = null; + if (step.started_at && step.completed_at) { + durationSeconds = Math.round( + (new Date(step.completed_at).getTime() - + new Date(step.started_at).getTime()) / + 1000, + ); + } + return { + name: step.name, + number: step.number, + status: step.status, + conclusion: step.conclusion ?? null, + durationSeconds, + logs: stepLogs.get(step.number)?.join("\n") ?? "", + }; + }), + }; } catch (err) { console.error("[fetchStructuredJobLogs] Failed:", err); - return []; + return emptyResult; } } + +export interface JobStatusInfo { + detailsUrl: string; + status: "queued" | "in_progress" | "completed" | "waiting"; + conclusion: string | null; +} + +/** + * Fetches current status for multiple jobs in parallel. + */ +export async function fetchJobStatuses( + worktreePath: string, + detailsUrls: string[], +): Promise { + const results = await Promise.allSettled( + detailsUrls.map(async (detailsUrl) => { + const jobId = parseJobIdFromUrl(detailsUrl); + const nwo = parseNwoFromActionsUrl(detailsUrl); + if (!jobId || !nwo) { + return { detailsUrl, status: "queued" as const, conclusion: null }; + } + const { stdout } = await execWithShellEnv( + "gh", + [ + "api", + `repos/${nwo}/actions/jobs/${jobId}`, + "--jq", + '.status + "|" + (.conclusion // "")', + ], + { cwd: worktreePath }, + ); + const [status, conclusion] = stdout.trim().split("|"); + return { + detailsUrl, + status: (status || "queued") as JobStatusInfo["status"], + conclusion: conclusion || null, + }; + }), + ); + return results.map((r, i) => + r.status === "fulfilled" + ? r.value + : { + detailsUrl: detailsUrls[i], + status: "queued" as const, + conclusion: null, + }, + ); +} diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/index.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/index.ts index 4485f51e72d..f6d918f82dc 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/index.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/index.ts @@ -4,6 +4,7 @@ export { fetchCheckJobSteps, fetchGitHubPRComments, fetchGitHubPRStatus, + fetchJobStatuses, fetchStructuredJobLogs, resolveReviewThread, } from "./github"; diff --git a/apps/desktop/src/main/lib/browser/browser-manager.ts b/apps/desktop/src/main/lib/browser/browser-manager.ts index 53022cd2f9e..ab0376ab393 100644 --- a/apps/desktop/src/main/lib/browser/browser-manager.ts +++ b/apps/desktop/src/main/lib/browser/browser-manager.ts @@ -1,5 +1,12 @@ import { EventEmitter } from "node:events"; -import { clipboard, Menu, shell, webContents } from "electron"; +import { + type BrowserWindow, + clipboard, + Menu, + nativeTheme, + shell, + webContents, +} from "electron"; interface ConsoleEntry { level: "log" | "warn" | "error" | "info" | "debug"; @@ -22,17 +29,34 @@ function sanitizeUrl(url: string): string { return `https://www.google.com/search?q=${encodeURIComponent(url)}`; } +function getChromeLikeUserAgent(userAgent: string): string { + return userAgent.replace(/\sElectron\/[^\s]+/g, "").trim(); +} + class BrowserManager extends EventEmitter { private paneWebContentsIds = new Map(); private consoleLogs = new Map(); private consoleListeners = new Map void>(); private contextMenuListeners = new Map void>(); + private fullscreenListeners = new Map void>(); + private popupListeners = new Map void>(); + /** Track which pane is currently in HTML fullscreen */ + private fullscreenPaneId: string | null = null; + + getFullscreenPaneId(): string | null { + return this.fullscreenPaneId; + } register(paneId: string, webContentsId: number): void { // Clean up previous listeners if re-registering with a new webContentsId const prevId = this.paneWebContentsIds.get(paneId); if (prevId != null && prevId !== webContentsId) { - for (const map of [this.consoleListeners, this.contextMenuListeners]) { + for (const map of [ + this.consoleListeners, + this.contextMenuListeners, + this.fullscreenListeners, + this.popupListeners, + ]) { const cleanup = map.get(paneId); if (cleanup) { cleanup(); @@ -46,26 +70,58 @@ class BrowserManager extends EventEmitter { // Keep throttling enabled so parked/offscreen persistent webviews don't // run at full speed in the background. wc.setBackgroundThrottling(true); - wc.setWindowOpenHandler(({ url }) => { - if (url && url !== "about:blank") { - this.emit(`new-window:${paneId}`, url); - this.emit("new-window", { paneId, url }); + wc.setWindowOpenHandler(({ url, disposition }) => { + if (!url || url === "about:blank") { + return { action: "deny" as const }; + } + + // window.open() calls (OAuth popups, auth flows, etc.) — allow as a + // real child BrowserWindow so window.opener / postMessage work. + if (disposition === "new-window") { + return { + action: "allow" as const, + overrideBrowserWindowOptions: { + width: 500, + height: 700, + autoHideMenuBar: true, + backgroundColor: nativeTheme.shouldUseDarkColors + ? "#252525" + : "#ffffff", + webPreferences: { + partition: "persist:superset", + }, + }, + }; } + + // Regular target="_blank" links — open as a new browser tab + this.emit(`new-window:${paneId}`, url); + this.emit("new-window", { paneId, url }); return { action: "deny" as const }; }); + this.setupPopupWindowHandler(paneId, wc); + this.setupFullscreenHandler(paneId, wc); this.setupConsoleCapture(paneId, wc); this.setupContextMenu(paneId, wc); } } unregister(paneId: string): void { - for (const map of [this.consoleListeners, this.contextMenuListeners]) { + for (const map of [ + this.consoleListeners, + this.contextMenuListeners, + this.fullscreenListeners, + this.popupListeners, + ]) { const cleanup = map.get(paneId); if (cleanup) { cleanup(); map.delete(paneId); } } + if (this.fullscreenPaneId === paneId) { + this.fullscreenPaneId = null; + } this.paneWebContentsIds.delete(paneId); this.consoleLogs.delete(paneId); } @@ -128,6 +184,82 @@ class BrowserManager extends EventEmitter { wc.openDevTools({ mode: "detach" }); } + /** + * Configure child windows created by window.open() (OAuth popups etc.). + * The child BrowserWindow preserves window.opener so postMessage-based + * auth flows work correctly. + */ + private setupPopupWindowHandler( + paneId: string, + wc: Electron.WebContents, + ): void { + const handler = (childWindow: BrowserWindow, { url }: { url: string }) => { + const childWc = childWindow.webContents; + + // Strip Electron token from child window's User-Agent + const originalUA = childWc.getUserAgent(); + childWc.setUserAgent(getChromeLikeUserAgent(originalUA)); + + // If the popup navigates to about:blank or a javascript: URI, it likely + // means the auth flow finished and the opener consumed the result. + childWc.on("will-navigate", (_event, navUrl) => { + if (navUrl === "about:blank") { + childWindow.close(); + } + }); + + // Some OAuth flows close the popup themselves via window.close() in JS. + // That is handled natively by Electron. We also handle the case where the + // user manually closes the popup — nothing special is needed. + + console.log(`[browser-manager] Popup opened for pane ${paneId}: ${url}`); + }; + + wc.on("did-create-window", handler); + this.popupListeners.set(paneId, () => { + try { + wc.off("did-create-window", handler); + } catch { + // webContents may be destroyed + } + }); + } + + /** + * Track HTML5 fullscreen enter/leave on webview content (e.g. YouTube + * video fullscreen). The BrowserWindow also enters fullscreen natively + * (like Chrome). We emit events so the renderer can adjust its UI + * (hide sidebar/tabs when entering, restore when leaving). + */ + private setupFullscreenHandler( + paneId: string, + wc: Electron.WebContents, + ): void { + const handleEnter = () => { + this.fullscreenPaneId = paneId; + this.emit("fullscreen-change", { paneId, isFullscreen: true }); + }; + + const handleLeave = () => { + if (this.fullscreenPaneId === paneId) { + this.fullscreenPaneId = null; + } + this.emit("fullscreen-change", { paneId, isFullscreen: false }); + }; + + wc.on("enter-html-full-screen", handleEnter); + wc.on("leave-html-full-screen", handleLeave); + + this.fullscreenListeners.set(paneId, () => { + try { + wc.off("enter-html-full-screen", handleEnter); + wc.off("leave-html-full-screen", handleLeave); + } catch { + // webContents may be destroyed + } + }); + } + private setupContextMenu(paneId: string, wc: Electron.WebContents): void { const handler = ( _event: Electron.Event, diff --git a/apps/desktop/src/renderer/hooks/useBrowserFullscreenHandler.ts b/apps/desktop/src/renderer/hooks/useBrowserFullscreenHandler.ts new file mode 100644 index 00000000000..bf342ea977d --- /dev/null +++ b/apps/desktop/src/renderer/hooks/useBrowserFullscreenHandler.ts @@ -0,0 +1,22 @@ +import { electronTrpc } from "renderer/lib/electron-trpc"; +import { useBrowserFullscreenStore } from "renderer/stores/browser-fullscreen"; + +/** + * Global handler for HTML5 fullscreen events from any browser pane. + * + * Updates the fullscreen store so BrowserPane components can react by + * hiding their toolbar/bookmarks and letting the webview fill the pane. + * + * Must be mounted in an always-rendered component (dashboard layout). + */ +export function useBrowserFullscreenHandler() { + const setFullscreenPane = useBrowserFullscreenStore( + (s) => s.setFullscreenPane, + ); + + electronTrpc.browser.onFullscreenChange.useSubscription(undefined, { + onData: ({ paneId, isFullscreen }) => { + setFullscreenPane(isFullscreen ? paneId : null); + }, + }); +} diff --git a/apps/desktop/src/renderer/lib/githubQueryPolicy/githubQueryPolicy.test.ts b/apps/desktop/src/renderer/lib/githubQueryPolicy/githubQueryPolicy.test.ts index dca23c1f3e0..0da50b2bbc6 100644 --- a/apps/desktop/src/renderer/lib/githubQueryPolicy/githubQueryPolicy.test.ts +++ b/apps/desktop/src/renderer/lib/githubQueryPolicy/githubQueryPolicy.test.ts @@ -29,9 +29,9 @@ describe("getGitHubStatusQueryPolicy", () => { }), ).toEqual({ enabled: true, - refetchInterval: 10_000, + refetchInterval: 3_000, refetchOnWindowFocus: true, - staleTime: 10_000, + staleTime: 3_000, }); }); @@ -46,7 +46,7 @@ describe("getGitHubStatusQueryPolicy", () => { enabled: false, refetchInterval: false, refetchOnWindowFocus: false, - staleTime: 10_000, + staleTime: 3_000, }); }); diff --git a/apps/desktop/src/renderer/lib/githubQueryPolicy/githubQueryPolicy.ts b/apps/desktop/src/renderer/lib/githubQueryPolicy/githubQueryPolicy.ts index 6109e168286..71c1acd47a1 100644 --- a/apps/desktop/src/renderer/lib/githubQueryPolicy/githubQueryPolicy.ts +++ b/apps/desktop/src/renderer/lib/githubQueryPolicy/githubQueryPolicy.ts @@ -1,5 +1,5 @@ -const ACTIVE_GITHUB_STATUS_STALE_TIME_MS = 10_000; -const ACTIVE_GITHUB_STATUS_REFETCH_INTERVAL_MS = 10_000; +const ACTIVE_GITHUB_STATUS_STALE_TIME_MS = 3_000; +const ACTIVE_GITHUB_STATUS_REFETCH_INTERVAL_MS = 3_000; const WORKSPACE_LIST_ITEM_GITHUB_STATUS_STALE_TIME_MS = 30_000; const PASSIVE_GITHUB_STATUS_STALE_TIME_MS = 5 * 60 * 1000; const GITHUB_PR_COMMENTS_STALE_TIME_MS = 30_000; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/layout.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/layout.tsx index 899fbf0fb3c..b664d9a013b 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/layout.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/layout.tsx @@ -6,6 +6,7 @@ import { } from "@tanstack/react-router"; import { useFeatureFlagEnabled } from "posthog-js/react"; import { useState } from "react"; +import { useBrowserFullscreenHandler } from "renderer/hooks/useBrowserFullscreenHandler"; import { useBrowserNewWindowHandler } from "renderer/hooks/useBrowserNewWindowHandler"; import { isTearoffWindow, @@ -105,6 +106,7 @@ function DashboardLayout() { // Must live here (always-mounted) because webviews persist in a hidden // container even when their BrowserPane component is unmounted. useBrowserNewWindowHandler(); + useBrowserFullscreenHandler(); useTearoffInit(); useReturnedTabListener(); const isTearoff = isTearoffWindow(); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ActionLogsPane/ActionLogsPane.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ActionLogsPane/ActionLogsPane.tsx index d5e17bf61cf..6036e6e9145 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ActionLogsPane/ActionLogsPane.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ActionLogsPane/ActionLogsPane.tsx @@ -95,6 +95,7 @@ interface JobStepsProps { detailsUrl: string; jobName: string; jobStatus: string; + jobConclusion: string | null; showTimestamps: boolean; searchQuery: string; } @@ -104,6 +105,7 @@ function JobSteps({ detailsUrl, jobName, jobStatus, + jobConclusion, showTimestamps, searchQuery, }: JobStepsProps) { @@ -112,12 +114,23 @@ function JobSteps({ electronTrpc.workspaces.rerunPullRequestChecks.useMutation(); const trpcUtils = electronTrpc.useUtils(); - const { data: steps, isLoading } = + const { data: jobResult, isLoading } = electronTrpc.workspaces.getJobLogs.useQuery( { workspaceId, detailsUrl }, - { staleTime: 30_000 }, + { + staleTime: 3_000, + refetchInterval: (query) => { + const data = query.state.data; + if (!data) return 3_000; + return data.jobStatus === "completed" ? false : 3_000; + }, + }, ); + const steps = jobResult?.steps ?? []; + const liveJobStatus = jobResult?.jobStatus ?? jobStatus; + const liveJobConclusion = jobResult?.jobConclusion ?? jobConclusion; + const toggleStep = (n: number) => { setExpandedSteps((prev) => { const next = new Set(prev); @@ -135,7 +148,8 @@ function JobSteps({ toast.success( `Re-running ${mode === "failed" ? "failed" : "all"} jobs (${result.rerunCount})`, ); - void trpcUtils.workspaces.getReviewStatus.invalidate(); + void trpcUtils.workspaces.getJobLogs.invalidate(); + void trpcUtils.workspaces.getGitHubStatus.invalidate(); } catch { toast.error("Failed to re-run jobs"); } @@ -162,8 +176,19 @@ function JobSteps({ (sum, s) => sum + (s.durationSeconds ?? 0), 0, ); + // jobStatus is already in check format (success/failure/pending/etc) or job API format + const resolvedStatus = + liveJobStatus === "completed" + ? liveJobConclusion === "success" + ? "success" + : liveJobConclusion === "cancelled" + ? "cancelled" + : "failure" + : liveJobStatus === "in_progress" || liveJobStatus === "pending" + ? "pending" + : (liveJobStatus as keyof typeof statusIcon); const { icon: JobIcon, className: jobIconClass } = - statusIcon[jobStatus as keyof typeof statusIcon] ?? statusIcon.pending; + statusIcon[resolvedStatus as keyof typeof statusIcon] ?? statusIcon.pending; const lowerQuery = searchQuery.toLowerCase(); @@ -338,10 +363,32 @@ export function ActionLogsPane({ onPopOut, }: ActionLogsPaneProps) { const pane = useTabsStore((s) => s.panes[paneId]); - const jobs: ActionLogsJob[] = pane?.actionLogs?.jobs ?? []; + const initialJobs: ActionLogsJob[] = pane?.actionLogs?.jobs ?? []; const initialIndex = pane?.actionLogs?.initialJobIndex ?? 0; const [selectedIndex, setSelectedIndex] = useState(initialIndex); const [showTimestamps, setShowTimestamps] = useState(false); + + // Read check statuses from the shared getGitHubStatus cache (same source as Review tab) + const { data: githubStatus } = + electronTrpc.workspaces.getGitHubStatus.useQuery( + { workspaceId }, + { staleTime: 3_000, refetchInterval: 3_000 }, + ); + const checks = githubStatus?.pr?.checks ?? []; + + // Build live job list: match by name to track across re-runs (URLs change on re-run) + const jobs: ActionLogsJob[] = initialJobs.map((job) => { + const liveCheck = checks.find((c) => c.name === job.name); + if (liveCheck) { + return { + detailsUrl: liveCheck.url ?? job.detailsUrl, + name: liveCheck.name ?? job.name, + status: liveCheck.status, + }; + } + return job; + }); + const [searchQuery, setSearchQuery] = useState(""); const [searchOpen, setSearchOpen] = useState(false); const searchInputRef = useRef(null); @@ -353,10 +400,6 @@ export function ActionLogsPane({ /(https:\/\/github\.com\/[^/]+\/[^/]+\/actions\/runs\/\d+\/job\/\d+)/, )?.[1]; - const _runUrl = selectedJob?.detailsUrl?.match( - /(https:\/\/github\.com\/[^/]+\/[^/]+\/actions\/runs\/\d+)/, - )?.[1]; - const handleResizeStart = useCallback( (e: React.MouseEvent) => { e.preventDefault(); @@ -519,7 +562,8 @@ export function ActionLogsPane({ {jobs.map((job, i) => { const { icon: JobIcon, className: jobIconClass } = - statusIcon[job.status]; + statusIcon[job.status as keyof typeof statusIcon] ?? + statusIcon.pending; return (