diff --git a/AGENTS.md b/AGENTS.md index 4a99a3a78..515d76738 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -348,4 +348,4 @@ All optional: - [ ] Types check: `pnpm run type-check`. - [ ] Tests pass: `pnpm exec vitest run`. - [ ] No stray build artifacts or secrets committed. -- [ ] Documented any schema or config changes impacting users. +- [ ] Documented any schema or config changes impacting users. \ No newline at end of file diff --git a/src/main/preload.ts b/src/main/preload.ts index 4454b6ca6..5a7471f82 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -147,6 +147,15 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.on(channel, wrapped); return () => ipcRenderer.removeListener(channel, wrapped); }, + onPtyPrUrlDetected: (listener: (event: { id: string; url: string; cwd?: string }) => void) => { + const channel = 'pty:pr-url-detected'; + const wrapped = ( + _: Electron.IpcRendererEvent, + data: { id: string; url: string; cwd?: string } + ) => listener(data); + ipcRenderer.on(channel, wrapped); + return () => ipcRenderer.removeListener(channel, wrapped); + }, onNotificationFocusTask: (listener: (taskId: string) => void) => { const channel = 'notification:focus-task'; const wrapped = (_: Electron.IpcRendererEvent, taskId: string) => listener(taskId); diff --git a/src/main/services/ptyIpc.ts b/src/main/services/ptyIpc.ts index df8b4b4c6..81f9de239 100644 --- a/src/main/services/ptyIpc.ts +++ b/src/main/services/ptyIpc.ts @@ -6,6 +6,7 @@ import { killPty, getPty, getPtyKind, + getPtyCwd, startDirectPty, startSshPty, removePtyRecord, @@ -101,6 +102,27 @@ function bufferedSendPtyData(id: string, chunk: string): void { ptyDataTimers.set(id, t); } +// Detect GitHub PR URLs in terminal output for instant PR status refresh +const PR_URL_RE = /https?:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/pull\/\d+/; +const PR_EMIT_COOLDOWN_MS = 5000; +const lastPrEmit = new Map(); + +function maybeEmitPrUrlDetected(id: string, chunk: string): void { + // Skip SSH PTYs and any PTY without a known cwd to avoid unnecessary work + const cwd = getPtyCwd(id); + if (!cwd) return; + + const match = chunk.match(PR_URL_RE); + if (!match) return; + + // Rate-limit to prevent repeated refreshes from terminal output containing PR URLs + const now = Date.now(); + if (now - (lastPrEmit.get(id) ?? 0) < PR_EMIT_COOLDOWN_MS) return; + lastPrEmit.set(id, now); + + safeSendToOwner(id, 'pty:pr-url-detected', { id, url: match[0], cwd }); +} + /** * Deterministic port in the ephemeral range (49152–65535) derived from ptyId. * Used for the reverse SSH tunnel so the remote hook can reach the local @@ -365,6 +387,7 @@ export function registerPtyIpc(): void { listeners.delete(id); // Clear old listener registration if (!listeners.has(id)) { proc.onData((data) => { + maybeEmitPrUrlDetected(id, data); bufferedSendPtyData(id, data); }); @@ -444,6 +467,7 @@ export function registerPtyIpc(): void { if (!listeners.has(id)) { proc.onData((data) => { + maybeEmitPrUrlDetected(id, data); bufferedSendPtyData(id, data); }); proc.onExit(({ exitCode, signal }) => { @@ -587,6 +611,7 @@ export function registerPtyIpc(): void { // Attach data/exit listeners once per PTY id if (!listeners.has(id)) { proc.onData((data) => { + maybeEmitPrUrlDetected(id, data); bufferedSendPtyData(id, data); }); @@ -911,6 +936,7 @@ export function registerPtyIpc(): void { if (!listeners.has(id)) { proc.onData((data) => { + maybeEmitPrUrlDetected(id, data); bufferedSendPtyData(id, data); }); proc.onExit(({ exitCode, signal }) => { @@ -1032,6 +1058,7 @@ export function registerPtyIpc(): void { if (!listeners.has(id)) { proc.onData((data) => { + maybeEmitPrUrlDetected(id, data); bufferedSendPtyData(id, data); }); diff --git a/src/main/services/ptyManager.ts b/src/main/services/ptyManager.ts index 5c1ac3438..d1187d9d3 100644 --- a/src/main/services/ptyManager.ts +++ b/src/main/services/ptyManager.ts @@ -1291,7 +1291,7 @@ export async function startPty(options: { } } - ptys.set(id, { id, proc, kind: 'local', cols, rows, tmuxSessionName }); + ptys.set(id, { id, proc, cwd: useCwd, kind: 'local', cols, rows, tmuxSessionName }); return proc; } @@ -1366,6 +1366,10 @@ export function getPtyKind(id: string): 'local' | 'ssh' | undefined { return ptys.get(id)?.kind; } +export function getPtyCwd(id: string): string | undefined { + return ptys.get(id)?.cwd; +} + export function getPtyTmuxSessionName(id: string): string | undefined { return ptys.get(id)?.tmuxSessionName; } diff --git a/src/renderer/hooks/useAutoPrRefresh.ts b/src/renderer/hooks/useAutoPrRefresh.ts index 1b8461d08..5e6e7e101 100644 --- a/src/renderer/hooks/useAutoPrRefresh.ts +++ b/src/renderer/hooks/useAutoPrRefresh.ts @@ -8,11 +8,25 @@ const COOLDOWN_MS = 5000; // 5 second debounce for rapid focus/visibility events * Auto-refreshes PR status via: * 1. Window focus - refreshes all subscribed tasks (debounced) * 2. Polling - refreshes active task every 30s (pauses when hidden) + * 3. PTY PR URL events - refreshes immediately when terminal output includes a PR link */ export function useAutoPrRefresh(activeTaskPath: string | undefined): void { const lastFocusRefresh = useRef(0); const lastVisibilityRefresh = useRef(0); + // Event-driven refresh when terminal output includes a GitHub PR URL + useEffect(() => { + const unsubscribe = window.electronAPI.onPtyPrUrlDetected?.((event) => { + // Skip SSH PTYs where cwd is undefined to avoid refreshing wrong task + if (!event?.cwd) return; + refreshPrStatus(event.cwd).catch(() => {}); + }); + + return () => { + unsubscribe?.(); + }; + }, []); + // Window focus refresh (all subscribed tasks, debounced) useEffect(() => { const handleFocus = () => { diff --git a/src/renderer/types/electron-api.d.ts b/src/renderer/types/electron-api.d.ts index 444868b42..1b2f1e65e 100644 --- a/src/renderer/types/electron-api.d.ts +++ b/src/renderer/types/electron-api.d.ts @@ -122,6 +122,9 @@ declare global { onAgentEvent: ( listener: (event: AgentEvent, meta: { appFocused: boolean }) => void ) => () => void; + onPtyPrUrlDetected: ( + listener: (event: { id: string; url: string; cwd?: string }) => void + ) => () => void; onNotificationFocusTask: (listener: (taskId: string) => void) => () => void; terminalGetTheme: () => Promise<{ ok: boolean; @@ -1206,6 +1209,9 @@ export interface ElectronAPI { onAgentEvent: ( listener: (event: AgentEvent, meta: { appFocused: boolean }) => void ) => () => void; + onPtyPrUrlDetected: ( + listener: (event: { id: string; url: string; cwd?: string }) => void + ) => () => void; onNotificationFocusTask: (listener: (taskId: string) => void) => () => void; // Worktree management