SLOPSHOPPER

pr-desk

/prs: your open PRs across your repos with Vercel preview links, checks, merge state and worktree.

newpanecommandtoastprocesstimer
v0.1.0no licenseupdated 2026-10-09eshin087/claude-code-mods/pr-desk
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pr-desk
│ ┃ PRs ✕ › fix the failing auth test and add an audit log call │ ┃ Open PRs refreshing… Refresh [ Close ] │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /prs │ ⎿ pr-desk: PR Desk opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · PRs
Open PRs refreshing… Refresh [ Close ]
README

PR Desk

What it does: your open pull requests in one place, with the Vercel preview link ready to click.

Where you see it: /prs toggles a pane. Each of your open PRs in the repos listed in config.json (and whichever repo the session is in) shows:

  • number and title (marked if draft)
  • checks: ✓ passing / ✗ failing / … running
  • merge state: mergeable / conflicts with base
  • branch, worktree folder (for the current repo), and last update
  • preview and PR links

It refreshes every 3 minutes while open, every 15 minutes in the background (so the badge stays current), or on Refresh.

How it works: runs gh pr list --author @me per repo. The preview link is read from the Vercel bot's PR comment. If none is found, it builds Vercel's branch alias and marks it "(guessed)".

Cost: none. Uses your GitHub CLI login. No model calls.

Data saved: none.

Settings: copy config.example.json to config.json and list your repos (owner/name) and your Vercel team slug (for preview links when the Vercel bot comment isn't found). config.json stays out of git, so private repo names never reach the public repo.

Notes: only PRs authored by you are listed, so outside PRs (like forks) never show up here.

Source 2 files
hooks/register.tsx 259 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Desk, DeskChecks, DeskPr } from '../types'
5
6// Your repos and Vercel team come from config.json beside this mod (kept out of
7// git: copy config.example.json). Without it, only the session's own repo is listed.
8type DeskConfig = { repos: string[]; vercelTeam: string | null }
9const PANE = 'prs'
10const REFRESH_MS = 180_000
11
12const desk = atom({ plugin: 'pr-desk', key: 'desk' } as const, null)
13const isLoading = atom({ plugin: 'pr-desk', key: 'isLoading' } as const, false)
14const isOpen = atom({ plugin: 'pr-desk', key: 'isOpen' } as const, false)
15
16type GhCheck = { conclusion?: string | null; status?: string | null; state?: string | null }
17type GhPr = {
18  number: number
19  title: string
20  headRefName: string
21  url: string
22  mergeable: string
23  statusCheckRollup: GhCheck[] | null
24  updatedAt: string
25  isDraft: boolean
26}
27
28const repoFromRemote = (remote: string | null) => {
29  const m = remote ? /github\.com[:/]([^/]+)\/([^/]+?)(?:\.git)?\/?$/i.exec(remote) : null
30  return m ? `${m[1]}/${m[2]}` : null
31}
32
33const checksOf = (rollup: GhCheck[] | null): DeskChecks => {
34  if (!rollup || rollup.length === 0) return 'none'
35  const states = rollup.map(c => (c.conclusion ?? c.state ?? c.status ?? '').toUpperCase())
36  if (states.some(s => ['FAILURE', 'ERROR', 'TIMED_OUT', 'CANCELLED', 'ACTION_REQUIRED', 'STARTUP_FAILURE'].includes(s))) return 'failing'
37  if (states.some(s => ['', 'PENDING', 'QUEUED', 'IN_PROGRESS', 'WAITING', 'EXPECTED', 'REQUESTED'].includes(s))) return 'pending'
38  return 'passing'
39}
40
41// Vercel's branch alias: release/v0.10.2 -> release-v0102
42const branchSlug = (branch: string) =>
43  branch.toLowerCase().replace(/\//g, '-').replace(/\./g, '').replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-')
44
45const ago = (iso: string, now: number) => {
46  const m = Math.max(0, Math.round((now - Date.parse(iso)) / 60000))
47  if (m < 60) return `${m}m ago`
48  const h = Math.round(m / 60)
49  return h < 48 ? `${h}h ago` : `${Math.round(h / 24)}d ago`
50}
51
52const gh = async ($: EngineInterface, args: string[]) => {
53  const run = await $.process.run(['gh', ...args], { timeoutMs: 30_000 })
54  if (run.exitCode !== 0) throw new Error(run.stderr.trim().split('\n')[0] || `gh exited ${run.exitCode}`)
55  return run.stdout
56}
57
58const loadConfig = async ($: EngineInterface): Promise<DeskConfig> => {
59  try {
60    const raw = JSON.parse(await $.fs.read(`${$.plugin.root}/config.json`)) as Partial<DeskConfig>
61    return {
62      repos: Array.isArray(raw.repos) ? raw.repos.filter((r): r is string => typeof r === 'string') : [],
63      vercelTeam: typeof raw.vercelTeam === 'string' && raw.vercelTeam !== '' ? raw.vercelTeam : null,
64    }
65  } catch {
66    return { repos: [], vercelTeam: null }
67  }
68}
69
70const previewFor = async ($: EngineInterface, repo: string, pr: GhPr, vercelTeam: string | null) => {
71  try {
72    const out = JSON.parse(await gh($, ['pr', 'view', String(pr.number), '-R', repo, '--json', 'comments'])) as {
73      comments: { author?: { login?: string }; body: string }[]
74    }
75    for (const c of out.comments) {
76      const m = /([a-z0-9-]+-git-[a-z0-9-]+\.vercel\.app)/i.exec(c.body)
77      if (m) return { preview: `https://${m[1]!.toLowerCase()}`, isPreviewGuess: false }
78    }
79  } catch {
80    // fall through to the alias guess
81  }
82  const project = repo.split('/')[1]!.toLowerCase()
83  if (!vercelTeam) return { preview: null, isPreviewGuess: false }
84  return { preview: `https://${project}-git-${branchSlug(pr.headRefName)}-${vercelTeam}.vercel.app`, isPreviewGuess: true }
85}
86
87const worktrees = async ($: EngineInterface) => {
88  const byBranch = new Map<string, string>()
89  const run = await $.process.run(['git', 'worktree', 'list', '--porcelain']).catch(() => null)
90  if (!run || run.exitCode !== 0) return byBranch
91  let path = ''
92  for (const line of run.stdout.split(/\r?\n/)) {
93    if (line.startsWith('worktree ')) path = line.slice(9)
94    if (line.startsWith('branch refs/heads/')) byBranch.set(line.slice(18), path.split(/[\\/]/).pop() ?? path)
95  }
96  return byBranch
97}
98
99const refresh = async ($: EngineInterface) => {
100  if (await read($, isLoading)) return
101  await update($, isLoading, () => true)
102  try {
103    const here = repoFromRemote((await $.session.repo())?.remote ?? null)
104    const config = await loadConfig($)
105    const repos = [...new Map([here, ...config.repos].filter((r): r is string => !!r).map(r => [r.toLowerCase(), r])).values()]
106    const trees = await worktrees($)
107    const prs: DeskPr[] = []
108    const errors: string[] = []
109    await Promise.all(
110      repos.map(async repo => {
111        try {
112          const list = JSON.parse(
113            await gh($, [
114              'pr', 'list', '-R', repo, '--author', '@me', '--state', 'open', '--limit', '30',
115              '--json', 'number,title,headRefName,url,mergeable,statusCheckRollup,updatedAt,isDraft',
116            ]),
117          ) as GhPr[]
118          for (const pr of list) {
119            const isHere = here !== null && repo.toLowerCase() === here.toLowerCase()
120            prs.push({
121              repo,
122              number: pr.number,
123              title: pr.title,
124              branch: pr.headRefName,
125              url: pr.url,
126              ...(await previewFor($, repo, pr, config.vercelTeam)),
127              mergeable: pr.mergeable,
128              checks: checksOf(pr.statusCheckRollup),
129              isDraft: pr.isDraft,
130              updatedAt: pr.updatedAt,
131              worktree: isHere ? trees.get(pr.headRefName) ?? null : null,
132            })
133          }
134        } catch (err) {
135          errors.push(`${repo}: ${err instanceof Error ? err.message : String(err)}`)
136        }
137      }),
138    )
139    prs.sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt))
140    const fresh: Desk = { at: await $.clock.now(), prs, errors }
141    await update($, desk, () => fresh)
142  } finally {
143    await update($, isLoading, () => false)
144  }
145}
146
147const togglePane = async ($: EngineInterface) => {
148  if ((await $.ui.panes()).some(p => p.id === PANE)) {
149    await $.ui.close({ id: PANE })
150    // A plugin's own close does not reach its own ui.close hook: reset the flag here.
151    await update($, isOpen, () => false)
152    return 'PR Desk closed.'
153  }
154  const opened = await $.ui.open({ id: PANE, title: 'PRs' })
155  if (opened.isPlaced) {
156    await update($, isOpen, () => true)
157    void refresh($).catch(err => $.ui.toast(`PR Desk: ${String(err)}`))
158  } else {
159    $.ui.toast(`PR Desk could not open (${opened.reason}).`)
160  }
161  return opened.isPlaced ? 'PR Desk opened.' : `Could not open the pane (${opened.reason}).`
162}
163
164let ticks = 0
165
166export const register: Register = on => {
167  on('session.start', async ($, e, next) => {
168    await $.command.register({ name: 'prs', description: 'Open PR Desk: your open PRs, preview links, checks and merge state' })
169    // Every 3 minutes while the pane is open; every 15 minutes in the background,
170    // so the dock's PR badge stays current.
171    $.clock.every(REFRESH_MS, async () => {
172      ticks += 1
173      const open = (await $.ui.panes()).some(p => p.id === PANE)
174      if (open || ticks % 5 === 0) await refresh($).catch(() => undefined)
175    })
176    $.clock.after(5000, () => void refresh($).catch(() => undefined))
177    return next(e)
178  })
179
180  on('command.run', { command: 'prs' }, async $ => ({ text: await togglePane($) }))
181
182  // A press on the dock's PRs button.
183  on('state.set', { plugin: 'mod-hub', key: 'signal' }, async ($, e, next) => {
184    const done = await next(e)
185    // Only a write that landed: update() retries a missed one, which would toggle twice.
186    const sig = e.value as { target?: string; action?: string } | null
187    if (done.value?.isSet !== true || !sig) return done
188    if (sig.target === 'pr-desk') await togglePane($)
189    // Review: show the panel, never close it.
190    else if (sig.action === 'review' && !(await $.ui.panes()).some(p => p.id === PANE)) await togglePane($)
191    return done
192  })
193
194  on('ui.close', { id: PANE }, async ($, e, next) => {
195    const done = await next(e)
196    await update($, isOpen, () => false)
197    return done
198  })
199
200  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
201    const { Box, Text, Button, Link } = $.ui.resolve(e)
202    const d = await read($, desk)
203    const loading = await read($, isLoading)
204    const now = await $.clock.now()
205    const repos = [...new Set((d?.prs ?? []).map(p => p.repo))]
206
207    return (
208      <Box flexDirection="column" gap={1}>
209        <Box flexDirection="row" gap={1}>
210          <Text bold>Open PRs</Text>
211          <Text dimColor>
212            {loading ? 'refreshing…' : d ? `${d.prs.length} open · checked ${ago(new Date(d.at).toISOString(), now)}` : 'loading…'}
213          </Text>
214          <Button key="refresh" label="Refresh" plain onPress={() => refresh($)} />
215          <Button key="close" label="Close" role="dismiss" onPress={() => togglePane($)} />
216        </Box>
217        {d && d.prs.length === 0 && d.errors.length === 0 && <Text dimColor>No open PRs of yours.</Text>}
218        {repos.map(repo => (
219          <Box flexDirection="column">
220            <Text bold color="claude">{repo.split('/')[1]}</Text>
221            {d!.prs
222              .filter(p => p.repo === repo)
223              .map(p => (
224                <Box flexDirection="column" marginBottom={1}>
225                  <Text bold wrap="truncate-end">
226                    #{p.number} {p.title}
227                    {p.isDraft ? ' (draft)' : ''}
228                  </Text>
229                  <Box flexDirection="row" gap={1}>
230                    <Text color={p.checks === 'failing' ? 'error' : p.checks === 'passing' ? 'success' : 'warning'}>
231                      {p.checks === 'failing' ? '✗ checks failing' : p.checks === 'passing' ? '✓ checks' : p.checks === 'pending' ? '… checks running' : '· no checks'}
232                    </Text>
233                    <Text color={p.mergeable === 'CONFLICTING' ? 'error' : undefined} dimColor={p.mergeable !== 'CONFLICTING'}>
234                      {p.mergeable === 'MERGEABLE' ? 'mergeable' : p.mergeable === 'CONFLICTING' ? 'conflicts with base' : 'merge state unknown'}
235                    </Text>
236                    <Text dimColor>· {ago(p.updatedAt, now)}</Text>
237                  </Box>
238                  <Text dimColor wrap="truncate-end">
239                    {p.branch}
240                    {p.worktree ? ` · worktree ${p.worktree}` : ''}
241                  </Text>
242                  <Box flexDirection="row" gap={2}>
243                    {p.preview && <Link href={p.preview} label={p.isPreviewGuess ? 'preview (guessed)' : 'preview'} />}
244                    <Link href={p.url} label="PR" />
245                  </Box>
246                </Box>
247              ))}
248          </Box>
249        ))}
250        {(d?.errors ?? []).map(err => (
251          <Text color="warning" wrap="truncate-end">
252            {err}
253          </Text>
254        ))}
255      </Box>
256    )
257  })
258}
259
types/index.d.ts 25 lines
1export type DeskChecks = 'passing' | 'failing' | 'pending' | 'none'
2
3export type DeskPr = {
4  repo: string
5  number: number
6  title: string
7  branch: string
8  url: string
9  preview: string | null
10  isPreviewGuess: boolean
11  mergeable: string
12  checks: DeskChecks
13  isDraft: boolean
14  updatedAt: string
15  worktree: string | null
16}
17
18export type Desk = { at: number; prs: DeskPr[]; errors: string[] }
19
20declare module 'claude-code' {
21  interface PluginState {
22    'pr-desk': { desk: Desk | null; isLoading: boolean; isOpen: boolean }
23  }
24}
25