From e05ea08e2ba48ee12e48daa684a1e7e8d9406e29 Mon Sep 17 00:00:00 2001 From: Roman Kuksin Date: Wed, 1 Apr 2026 15:23:55 +0400 Subject: [PATCH 1/2] Add Checkouts tab showing local git repos with PR status - New tab lists all local git checkouts with async PR discovery - Shows dirty/clean, ahead/behind/diverged chips per branch - Chat button launches Claude with merge-base diff context - IDE buttons on all checkout rows - Sticky header with nav tabs - Rename "pick git clone" to "checkout" - Remove "copy git checkout cmd" from branch column Co-Authored-By: Claude Opus 4.6 (1M context) --- ai-status.js | 52 ++++++++++- dashboard-html.js | 197 +++++++++++++++++++++++++++++++++++------- public/repo-picker.js | 53 +++++++----- repo-scan.js | 24 +++-- server.js | 87 ++++++++++++++++--- 5 files changed, 335 insertions(+), 78 deletions(-) diff --git a/ai-status.js b/ai-status.js index fc0f005..2023c67 100644 --- a/ai-status.js +++ b/ai-status.js @@ -1,7 +1,8 @@ -import { runCmd, CHAOS, CMD_TIMEOUT, setCmdLogHook } from './helpers.js'; +import { runCmd, gh, daysSince, CHAOS, CMD_TIMEOUT, setCmdLogHook } from './helpers.js'; import { homedir } from 'node:os'; import { readCacheEntry, writeCacheEntry, hashPrompt, cleanAiCache } from './ai-cache.js'; import { fetchIssueDetails, fetchPRSummary, fetchPRPromptData, fetchRecentComments } from './github-api.js'; +import { checkDivergence } from './repo-scan.js'; import { cleanChatPrompts } from './launch-chat.js'; // === Timeline / Context / Prompt Builders === @@ -344,6 +345,40 @@ export function handleAIStream(req, res, { allItems, ghUsername, ghVersion, clau const pr = allPRs[index]; if (pr.fetchError) return; + // Checkout items: fetch real remote HEAD + discover PR for this branch + if (pr.section === 'checkout') { + if (pr._checkoutSkipAI) return; // default branch, no PR possible + // Refine diverge status with real remote HEAD from GitHub API + const remoteHeadSha = await gh('api', `repos/${pr.repo}/branches/${pr._checkoutBranch}`, '--jq', '.commit.sha', + { __reason: `remote HEAD: ${pr.repo}#${pr._checkoutBranch}` }, signal).then(s => s.trim() || null, () => null); + if (remoteHeadSha) { + const localHead = (await runCmd('git', ['rev-parse', 'HEAD'], { cwd: pr._checkoutPath, reason: 'local HEAD' }).catch(() => '')).trim(); + if (localHead === remoteHeadSha) { + pr._checkoutDivergeStatus = 'synced'; + } else if (localHead) { + pr._checkoutDivergeStatus = await checkDivergence(pr._checkoutPath, localHead, remoteHeadSha) || 'unsynced'; + } + } + // Discover PR for this branch + itemPhase[index] = `gh pr list --head ${pr._checkoutBranch}`; + sendIfActive(index, 'ai-phase', { index, phase: itemPhase[index] }); + const raw = await gh('pr', 'list', '--repo', pr.repo, '--head', pr._checkoutBranch, '--state', 'all', '--json', 'number,title,url,state,headRefName,updatedAt', '--limit', '1', { __reason: `checkout PR: ${pr.repo}#${pr._checkoutBranch}` }, signal).catch(() => '[]'); + const prs = JSON.parse(raw); + if (!prs.length) { + sendIfActive(index, 'pr-details', { index, branch: pr._checkoutBranch, repoShort: pr.repo.split('/').pop(), failing: [], divergeStatus: pr._checkoutDivergeStatus, dirty: pr._checkoutDirty, changedCount: pr._checkoutChangedFiles }); + sendIfActive(index, 'ai-done', { index, statusText: 'no PR', statusClass: 'warning' }); + return; + } + const found = prs[0]; + pr.title = found.title; + pr.html_url = found.url; + pr.number = found.number; + pr.state = found.state?.toLowerCase(); + pr.updated_at = found.updatedAt; + pr.days = daysSince(found.updatedAt); + pr._checkoutPRDiscovered = true; + } + // Lazy-load all details (deferred from Phase 1 for faster table render) if (pr.isIssue) { itemPhase[index] = `gh issue view ${pr.number} --repo ${pr.repo}`; @@ -363,7 +398,20 @@ export function handleAIStream(req, res, { allItems, ghUsername, ghVersion, clau const s = (c.conclusion || c.state || c.status || '').toUpperCase(); return s === 'FAILURE' || s === 'ERROR' || s === 'TIMED_OUT'; }); - sendIfActive(index, 'pr-details', { index, branch, repoShort, failing }); + const prDetailsData = { index, branch, repoShort, failing }; + if (pr._checkoutPRDiscovered) { + prDetailsData.prTitle = pr.title; + prDetailsData.prUrl = pr.html_url; + prDetailsData.prNumber = pr.number; + prDetailsData.prState = pr.state; + prDetailsData.prDays = pr.days; + } + if (pr.section === 'checkout') { + prDetailsData.divergeStatus = pr._checkoutDivergeStatus; + prDetailsData.dirty = pr._checkoutDirty; + prDetailsData.changedCount = pr._checkoutChangedFiles; + } + sendIfActive(index, 'pr-details', prDetailsData); if (pr.section === 'mentioned') { const myComments = (summary.comments || []).some(c => c.author?.login === ghUsername); const myReviews = (summary.reviews || []).some(r => r.author?.login === ghUsername); diff --git a/dashboard-html.js b/dashboard-html.js index 4debbf2..9af2821 100644 --- a/dashboard-html.js +++ b/dashboard-html.js @@ -106,7 +106,7 @@ export const INDEX_HTML = ` `; -export function buildDashboardHtml(myPRs, reviewPRs, assignedIssues, createdIssues, mentionedPRs, commentedPRs, mentionedIssues, commentedIssues, date, updateInfo, { repoColorMap, installedIDEs, period, ghUsername, archivedUrls, autoUnarchivedUrls, unimportantUrls, markedImportantUrls }) { +export function buildDashboardHtml(myPRs, reviewPRs, assignedIssues, createdIssues, mentionedPRs, commentedPRs, mentionedIssues, commentedIssues, date, updateInfo, { repoColorMap, installedIDEs, period, ghUsername, archivedUrls, autoUnarchivedUrls, unimportantUrls, markedImportantUrls, checkoutItems }) { const archivedSet = new Set(Object.keys(archivedUrls || {})); const autoUnarchivedSet = new Set(autoUnarchivedUrls || []); const unimportantSet = new Set(Object.keys(unimportantUrls || {})); @@ -115,22 +115,36 @@ export function buildDashboardHtml(myPRs, reviewPRs, assignedIssues, createdIssu return repoColorMap[repoName] || '#8b949e'; } + const chipPalette = { + green: { color: '#3fb950', bg: '#1a3a1a', border: '#238636' }, + yellow: { color: '#d29922', bg: '#3d1f00', border: '#9e6a03' }, + blue: { color: '#58a6ff', bg: '#0c2d6b', border: '#1f6feb' }, + red: { color: '#f85149', bg: '#3d0a0a', border: '#da3633' }, + purple: { color: '#a371f7', bg: '#271c4d', border: '#8957e5' }, + muted: { color: '#484f58', bg: '#161b22', border: '#30363d' }, + grey: { color: '#8b949e', bg: '#1c2128', border: '#30363d' }, + }; + + function chip(label, tone, extraClass) { + const p = chipPalette[tone] || chipPalette.grey; + return ` ${label}`; + } + function stateBadge(state) { if (!state) return ''; - const colors = { open: '#3fb950', merged: '#a371f7', closed: '#f85149' }; - const color = colors[state] || '#8b949e'; - return ` ${state}`; + const tones = { open: 'green', merged: 'purple', closed: 'red' }; + return chip(state, tones[state] || 'grey'); } - function statusCell(item, globalIndex) { + function statusCell(item, globalIndex, prefillActions) { if (item.fetchError) { return `Fetch failed: ${escapeHtml(item.fetchError)}`; } return ` queued...
- - ${item.isIssue ? '' : `pick git clone`} + ${prefillActions || ''} + ${item.isIssue || item.section === 'checkout' ? '' : `checkout`} copy prompt for debugging
@@ -146,7 +160,7 @@ export function buildDashboardHtml(myPRs, reviewPRs, assignedIssues, createdIssu const color = repoColor(repoShort); return ` ${escapeHtml(repoShort)} - + #${pr.number} ${escapeHtml(pr.title)} ${authorSpan}${stateSpan} @@ -221,8 +235,8 @@ export function buildDashboardHtml(myPRs, reviewPRs, assignedIssues, createdIssu const isUnimportant = !isArchived && unimportantSet.has(item.html_url); const urlAttr = escapeHtml(item.html_url).replace(/"/g, '"'); const isMarkedImportant = markedImportantSet.has(item.html_url); - const unarchivedBadge = autoUnarchivedSet.has(item.html_url) ? ' unarchived: new comments' : ''; - const importantBadge = isMarkedImportant ? ' marked as important' : ''; + const unarchivedBadge = autoUnarchivedSet.has(item.html_url) ? chip('unarchived: new comments', 'yellow', 'unarchived-badge') : ''; + const importantBadge = isMarkedImportant ? chip('marked as important', 'blue', 'important-badge') : ''; const hidden = isArchived || isUnimportant; const attrs = isArchived ? ' data-archived="1"' : isUnimportant ? ' data-unimportant="1"' : ''; const miAttr = isMarkedImportant ? ' data-marked-important="1"' : ''; @@ -238,6 +252,36 @@ export function buildDashboardHtml(myPRs, reviewPRs, assignedIssues, createdIssu `; } + function checkoutActions(item, globalIndex) { + let html = `chat`; + for (const ide of installedIDEs) { + const cmdEsc = escapeHtml(ide.cmd).replace(/'/g, '''); + html += `${escapeHtml(ide.name)}`; + } + return html; + } + + function checkoutRow(item, globalIndex) { + const repoShort = item.repo.split('/').pop(); + const color = repoColor(repoShort); + const branchEsc = escapeHtml(item._checkoutBranch || '(detached)'); + const pathAttr = escapeHtml(item._checkoutPath).replace(/"/g, '"'); + const skipAI = item._checkoutSkipAI; + const actions = checkoutActions(item, globalIndex); + return ` + ${escapeHtml(repoShort)} + + ${escapeHtml(item._checkoutDisplayPath)} + + ${branchEsc} + ${skipAI + ? `no PR
${actions}` + : statusCell(item, globalIndex, actions)} + + + `; + } + const periodLabels = { '7d': '7 days', '30d': '30 days', '90d': '3 months', 'all': 'All time' }; const periodOptions = ['7d', '30d', '90d', 'all'].map(value => `` @@ -252,6 +296,15 @@ export function buildDashboardHtml(myPRs, reviewPRs, assignedIssues, createdIssu const commentedPRRows = commentedPRs.map(pr => correspondenceRow(pr, true, idx++, true)).join('\n'); const mentionedIssueRows = mentionedIssues.map(i => correspondenceRow(i, false, idx++, false)).join('\n'); const commentedIssueRows = commentedIssues.map(i => correspondenceRow(i, false, idx++, false)).join('\n'); + const checkoutStartIdx = idx; + const checkoutRows = (checkoutItems || []).map(item => checkoutRow(item, idx++)).join('\n'); + const checkoutCloneData = JSON.stringify((checkoutItems || []).map((item, i) => ({ + idx: checkoutStartIdx + i, + path: item._checkoutPath, + dirty: item._checkoutDirty, + changedCount: item._checkoutChangedFiles, + divergeStatus: item._checkoutDivergeStatus, + }))); const hiddenSet = new Set([...archivedSet, ...unimportantSet]); const visibleMentionedPRs = mentionedPRs.filter(pr => !hiddenSet.has(pr.html_url)).length; @@ -320,9 +373,7 @@ export function buildDashboardHtml(myPRs, reviewPRs, assignedIssues, createdIssu .header-links a { color: #484f58; text-decoration: none; } .header-links a:hover { color: #58a6ff; } - .state-badge { font-size: 10px; border: 1px solid; border-radius: 3px; padding: 1px 4px; margin-left: 4px; } - .unarchived-badge { font-size: 10px; color: #d29922; border: 1px solid #d29922; border-radius: 3px; padding: 1px 4px; margin-left: 6px; } - .important-badge { font-size: 10px; color: #58a6ff; border: 1px solid #58a6ff; border-radius: 3px; padding: 1px 4px; margin-left: 6px; } + .chip { font-size: 10px; border: 1px solid; border-radius: 3px; padding: 1px 5px; margin-left: 6px; display: inline-block; } .status-text { white-space: pre-wrap; } .status-text.loading { color: #d29922; } .copy-prompt { cursor: pointer; color: #8b949e; font-size: 10px; position: relative; padding: 2px 8px; margin-right: 6px; font-family: inherit; display: inline-block; } @@ -360,6 +411,7 @@ export function buildDashboardHtml(myPRs, reviewPRs, assignedIssues, createdIssu .update-overlay { display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); z-index: 199; } .period-select { background: #21262d; color: #c9d1d9; border: 1px solid #30363d; border-radius: 3px; padding: 2px 6px; font-family: inherit; font-size: 12px; margin-left: 8px; cursor: pointer; } .period-select:hover { border-color: #58a6ff; } + .sticky-header { position: sticky; top: 0; z-index: 100; background: #0d1117; padding-bottom: 0; } .nav-tabs { display: flex; gap: 0; margin-bottom: 16px; border-bottom: 1px solid #21262d; } .nav-tab { color: #8b949e; text-decoration: none; padding: 8px 16px; font-size: 13px; border-bottom: 2px solid transparent; cursor: pointer; } .nav-tab:hover { color: #c9d1d9; } @@ -397,6 +449,7 @@ export function buildDashboardHtml(myPRs, reviewPRs, assignedIssues, createdIssu } ${updateHtml} + @@ -580,6 +635,28 @@ ${commentedIssueRows} +
+

Local Checkouts

+ +

Git Repositories (${(checkoutItems || []).length})

+ + + + + + + + + + + + +${checkoutRows} + +
RepoTitleBranchAI-StatusCIDays
+
+
+